From ebf8db1e1c6f3bb2d3fa6836237c80973fa8965b Mon Sep 17 00:00:00 2001 From: MAUstaoglu Date: Mon, 20 Jul 2026 21:20:18 +0200 Subject: [PATCH 1/3] fix(path_provider_tvos): stop returning directories tvOS cannot write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin was written on the assumption that tvOS has a normal iOS-style sandbox, and its header comment said so: "tvOS has a normal app sandbox (Documents, Library, Library/Caches, Library/Application Support) ... so the standard NSSearchPath* lookups work unchanged here." That is wrong for two of the four directories it names. Measured on a physical Apple TV 4K (tvOS 26.5) by writing a file into each: tmp writable Library/Caches writable Documents exists, writes DENIED (errno 1) Library/Application Support does not exist, CANNOT be created (errno 1) The tvOS simulator permits all of these writes, which is why it went unnoticed — the same asymmetry that hid the sqflite_tvos default-path bug (#4). The concrete defect: getApplicationSupportDirectory() ran createDirectory through `try?`, so a genuinely failed creation was swallowed and the caller received a path that does not exist and cannot be made. The failure only surfaced at the caller's first write, as an errno naming nothing useful. It now returns nil, which path_provider surfaces as MissingPlatformDirectoryException at the call site. getApplicationDocumentsDirectory() keeps returning its path: the directory is real and reads work, so reporting it is not a lie — but the README now says plainly that writes fail there and points callers at getApplicationCacheDirectory(). Verified on the device through the public path_provider API after the change: temporary -> writable appCache -> writable appDocuments -> returned, PathAccessException on write appSupport -> MissingPlatformDirectoryException library -> returned, PathAccessException on write downloads -> null --- packages/path_provider_tvos/CHANGELOG.md | 19 +++++++ packages/path_provider_tvos/README.md | 37 +++++++++++--- packages/path_provider_tvos/pubspec.yaml | 2 +- .../tvos/Classes/PathProviderPlugin.swift | 50 +++++++++++++++---- 4 files changed, 90 insertions(+), 18 deletions(-) diff --git a/packages/path_provider_tvos/CHANGELOG.md b/packages/path_provider_tvos/CHANGELOG.md index 3ec9bc6..9ad98e0 100644 --- a/packages/path_provider_tvos/CHANGELOG.md +++ b/packages/path_provider_tvos/CHANGELOG.md @@ -1,3 +1,22 @@ +## 0.0.3 + +* **Fix:** `getApplicationSupportDirectory()` no longer returns a path that + does not exist. The tvOS sandbox refuses to create + `Library/Application Support`, and that failure was swallowed (`try?`), so + callers received a path and only discovered the problem at their first write. + It now returns `null`, which `path_provider` surfaces as + `MissingPlatformDirectoryException` at the call site. +* **Docs:** corrected the claim that tvOS has "a normal app sandbox" where the + standard lookups work unchanged. Verified on a physical Apple TV 4K + (tvOS 26.5): only `Library/Caches` and `tmp` are writable — writes to + `Documents` are denied, and `Library/Application Support` cannot be created. + The tvOS simulator permits all of these, which is why this went unnoticed. +* **Behaviour change:** apps calling `getApplicationSupportDirectory()` on tvOS + now get an exception instead of an unusable path. Switch to + `getApplicationCacheDirectory()`, and note that tvOS storage is purgeable by + platform contract — durable data belongs on a server or in iCloud key-value + storage. + ## 0.0.2 * Add Swift Package Manager support: ships a `tvos/Package.swift` so the diff --git a/packages/path_provider_tvos/README.md b/packages/path_provider_tvos/README.md index a967ac2..6944416 100644 --- a/packages/path_provider_tvos/README.md +++ b/packages/path_provider_tvos/README.md @@ -14,20 +14,45 @@ for [flutter-tvos](https://github.com/fluttertv/flutter-tvos). ```yaml dependencies: path_provider: ^2.x - path_provider_tvos: ^0.0.2 + path_provider_tvos: ^0.0.3 ``` +## ⚠️ tvOS is not iOS: only two directories are writable + +The tvOS sandbox is far more restrictive than the iOS one. Measured on a +physical Apple TV 4K (tvOS 26.5) by writing a file into each directory: + +| directory | on a real Apple TV | +|---|---| +| `tmp` | ✅ writable | +| `Library/Caches` | ✅ writable | +| `Documents` | ❌ exists, but **writes are denied** | +| `Library/Application Support` | ❌ **does not exist and cannot be created** | + +**The tvOS simulator permits all of these writes**, so code that works in the +simulator can still fail on real hardware. Test storage on a device. + +tvOS provides **no persistent local storage** by platform contract: even +`Library/Caches` is purgeable by the OS at any time. Data that must survive +belongs on a server or in iCloud key-value storage. + ## tvOS support -### ✅ Supported +### ✅ Supported and writable - `getTemporaryDirectory()` — `NSTemporaryDirectory()` -- `getApplicationDocumentsDirectory()` — app `Documents/` -- `getApplicationSupportDirectory()` — `Library/Application Support/` - (auto-created) - `getApplicationCacheDirectory()` — `Library/Caches/` (auto-created) -- `getLibraryDirectory()` — app `Library/` + +### ⚠️ Returned, but not writable +- `getApplicationDocumentsDirectory()` — app `Documents/`. Returned for parity + with iOS and fine to read from, but writes fail on a real Apple TV. **Use + `getApplicationCacheDirectory()` instead.** +- `getLibraryDirectory()` — app `Library/`. The container itself; write to + `Library/Caches` beneath it. ### ❌ Not supported on tvOS +- `getApplicationSupportDirectory()` → throws `MissingPlatformDirectoryException`. + The tvOS sandbox refuses to create `Library/Application Support`, so this + fails at the call rather than handing back an unusable path. - `getDownloadsDirectory()` → returns `null` (no user Downloads dir). - `getExternalStorage*` → `UnsupportedError` (Android-only, same as iOS). diff --git a/packages/path_provider_tvos/pubspec.yaml b/packages/path_provider_tvos/pubspec.yaml index 1630c71..d49af7c 100644 --- a/packages/path_provider_tvos/pubspec.yaml +++ b/packages/path_provider_tvos/pubspec.yaml @@ -1,6 +1,6 @@ name: path_provider_tvos description: "tvOS (Apple TV) implementation of the path_provider Flutter plugin, provided by flutter-tvos." -version: 0.0.2 +version: 0.0.3 homepage: https://fluttertv.dev repository: https://github.com/fluttertv/plugins/tree/main/packages/path_provider_tvos issue_tracker: https://github.com/fluttertv/plugins/issues diff --git a/packages/path_provider_tvos/tvos/Classes/PathProviderPlugin.swift b/packages/path_provider_tvos/tvos/Classes/PathProviderPlugin.swift index 4fb59e1..7f488f4 100644 --- a/packages/path_provider_tvos/tvos/Classes/PathProviderPlugin.swift +++ b/packages/path_provider_tvos/tvos/Classes/PathProviderPlugin.swift @@ -4,12 +4,28 @@ // // Maintained tvOS implementation of `path_provider`. // -// tvOS has a normal app sandbox (Documents, Library, Library/Caches, -// Library/Application Support) plus NSTemporaryDirectory(), so the -// standard NSSearchPath* lookups path_provider_foundation uses on iOS -// work unchanged here. There is no user-facing Downloads directory on -// tvOS, so that request returns nil (matching iOS/path_provider, where -// downloads is macOS-only). +// The tvOS sandbox is NOT the iOS sandbox. Measured on a physical Apple TV 4K +// (tvOS 26.5) by writing a file into each directory: +// +// tmp writable +// Library/Caches writable +// Documents exists, but writes are DENIED (errno 1) +// Library/Application Support does not exist and CANNOT be created (errno 1) +// +// Only Caches and tmp are usable for writing. tvOS provides no persistent +// local storage by platform contract — data that must survive belongs on a +// server or in iCloud key-value storage, and even Caches is purgeable at any +// time. The tvOS *simulator* permits all of these writes, so this difference +// appears only on real hardware. +// +// Documents is still returned: it is a real directory and reads work. Callers +// simply must not write there. Application Support returns nil instead of a +// path that neither exists nor can be created — path_provider turns nil into +// MissingPlatformDirectoryException at the call site, which is far easier to +// diagnose than an errno at the caller's first write. +// +// There is no user-facing Downloads directory on tvOS, so that request returns +// nil (matching iOS/path_provider, where downloads is macOS-only). import Flutter import Foundation @@ -27,6 +43,7 @@ public class PathProviderPlugin: NSObject, FlutterPlugin { case "getTemporaryDirectory": result(NSTemporaryDirectory()) case "getApplicationDocumentsDirectory": + // Returned for parity with iOS, but NOT writable on tvOS — see above. result(directory(.documentDirectory)) case "getApplicationSupportDirectory": result(ensuredDirectory(.applicationSupportDirectory)) @@ -47,16 +64,27 @@ public class PathProviderPlugin: NSObject, FlutterPlugin { .first } - /// Application Support / Caches are not guaranteed to exist on first - /// launch; create them so callers can write immediately (this mirrors - /// path_provider_foundation's behaviour on iOS/macOS). + /// Application Support / Caches are not guaranteed to exist on first launch, + /// so create them when missing (mirroring path_provider_foundation on + /// iOS/macOS). On tvOS the creation genuinely fails for Application Support, + /// so the error is surfaced rather than swallowed: `try?` here would return a + /// path that does not exist, and the caller would only find out at its first + /// write, with an errno that names nothing useful. private func ensuredDirectory( _ type: FileManager.SearchPathDirectory ) -> String? { guard let path = directory(type) else { return nil } if !FileManager.default.fileExists(atPath: path) { - try? FileManager.default.createDirectory( - atPath: path, withIntermediateDirectories: true, attributes: nil) + do { + try FileManager.default.createDirectory( + atPath: path, withIntermediateDirectories: true, attributes: nil) + } catch { + NSLog( + "[path_provider_tvos] cannot create \(path): " + + "\(error.localizedDescription). The tvOS sandbox only permits " + + "writes to Library/Caches and tmp.") + return nil + } } return path } From cce24feb66753afea37de71fee11bf2dee40c2c1 Mon Sep 17 00:00:00 2001 From: MAUstaoglu Date: Tue, 21 Jul 2026 08:57:58 +0200 Subject: [PATCH 2/3] warn once when Documents is requested on tvOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review point: the branch stops returning Application Support, which cannot be created, but still returns Documents, which cannot be written. So the silent write failure this change set out to kill survived for the *more common* call — getApplicationDocumentsDirectory() + write a file — mitigated only by documentation. Keep returning the path: Documents exists, reads work, some apps ship pre-populated data there, and returning nil would break iOS parity and readers. That asymmetry with Application Support is deliberate. But make the failure traceable — log once, on first request, naming the sandbox restriction and pointing at getApplicationCacheDirectory(). A `static let` is lazily initialised exactly once per process, so this is a thread-safe one-shot with no flag to manage. Verified on a physical Apple TV 4K (tvOS 26.5). Calling getApplicationDocumentsDirectory() twice, then writing: PP_DOC_WRITE FAILED PathAccessException PP_SUPPORT THREW MissingPlatformDirectoryException with exactly one warning in the device log for the two calls, plus the Application Support failure now naming the real cause instead of being swallowed by `try?`: [path_provider_tvos] cannot create .../Library/Application Support: You don't have permission to save the file "Application Support" in the folder "Library".. The tvOS sandbox only permits writes to Library/Caches and tmp. Note the CLI's simulator log stream filters on the Flutter image, so a plugin NSLog does not surface in `flutter-tvos run` there; on device it comes through the console. A simulator run also creates Application Support without complaint — the divergence from hardware that hid this. --- packages/path_provider_tvos/CHANGELOG.md | 4 ++++ packages/path_provider_tvos/README.md | 3 ++- .../tvos/Classes/PathProviderPlugin.swift | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/path_provider_tvos/CHANGELOG.md b/packages/path_provider_tvos/CHANGELOG.md index 9ad98e0..97aa950 100644 --- a/packages/path_provider_tvos/CHANGELOG.md +++ b/packages/path_provider_tvos/CHANGELOG.md @@ -11,6 +11,10 @@ (tvOS 26.5): only `Library/Caches` and `tmp` are writable — writes to `Documents` are denied, and `Library/Application Support` cannot be created. The tvOS simulator permits all of these, which is why this went unnoticed. +* `getApplicationDocumentsDirectory()` now logs a one-time warning on tvOS. + The path is still returned — Documents exists and reads work — but writes to + it fail on a physical Apple TV, and silently handing back a path for the most + common `path_provider` call made that failure hard to trace. * **Behaviour change:** apps calling `getApplicationSupportDirectory()` on tvOS now get an exception instead of an unusable path. Switch to `getApplicationCacheDirectory()`, and note that tvOS storage is purgeable by diff --git a/packages/path_provider_tvos/README.md b/packages/path_provider_tvos/README.md index 6944416..459c1f2 100644 --- a/packages/path_provider_tvos/README.md +++ b/packages/path_provider_tvos/README.md @@ -45,7 +45,8 @@ belongs on a server or in iCloud key-value storage. ### ⚠️ Returned, but not writable - `getApplicationDocumentsDirectory()` — app `Documents/`. Returned for parity with iOS and fine to read from, but writes fail on a real Apple TV. **Use - `getApplicationCacheDirectory()` instead.** + `getApplicationCacheDirectory()` instead.** Calling it logs a one-time + warning so a later write failure is traceable to the sandbox. - `getLibraryDirectory()` — app `Library/`. The container itself; write to `Library/Caches` beneath it. diff --git a/packages/path_provider_tvos/tvos/Classes/PathProviderPlugin.swift b/packages/path_provider_tvos/tvos/Classes/PathProviderPlugin.swift index 7f488f4..2469408 100644 --- a/packages/path_provider_tvos/tvos/Classes/PathProviderPlugin.swift +++ b/packages/path_provider_tvos/tvos/Classes/PathProviderPlugin.swift @@ -44,6 +44,9 @@ public class PathProviderPlugin: NSObject, FlutterPlugin { result(NSTemporaryDirectory()) case "getApplicationDocumentsDirectory": // Returned for parity with iOS, but NOT writable on tvOS — see above. + // Warn once so the write that fails later is traceable to the sandbox + // rather than to an errno that names nothing. + _ = PathProviderPlugin.warnDocumentsNotWritable result(directory(.documentDirectory)) case "getApplicationSupportDirectory": result(ensuredDirectory(.applicationSupportDirectory)) @@ -59,6 +62,20 @@ public class PathProviderPlugin: NSObject, FlutterPlugin { } } + /// Emitted the first time Documents is requested. `static let` is lazily + /// initialised exactly once per process, so this is a thread-safe one-shot. + /// Documents is still returned — it exists, reads work, and some apps ship + /// pre-populated data there — but writes to it fail on a real Apple TV, and + /// silently returning a path for the most common path_provider call is what + /// makes that failure hard to place. + private static let warnDocumentsNotWritable: Void = { + NSLog( + "[path_provider_tvos] getApplicationDocumentsDirectory(): on a physical " + + "Apple TV, Documents is readable but NOT writable. Use " + + "getApplicationCacheDirectory() for files your app writes. " + + "(The tvOS simulator permits the write, so this only fails on device.)") + }() + private func directory(_ type: FileManager.SearchPathDirectory) -> String? { return NSSearchPathForDirectoriesInDomains(type, .userDomainMask, true) .first From bb8f20f46147e9dac6277a19c32bd57ef5c0ac36 Mon Sep 17 00:00:00 2001 From: MAUstaoglu Date: Tue, 21 Jul 2026 09:04:20 +0200 Subject: [PATCH 3/3] test(path_provider_tvos): cover the sandbox-refused directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package had one test asserting the class exists. Replace it with tests that would actually fail if this branch regressed. The device behaviour itself cannot be unit-tested — the simulator happily creates Application Support, which is what hid the bug — so these lock in the Dart half of the contract the Swift change depends on: given nil from the native side, the app-facing call must raise MissingPlatformDirectoryException rather than hand back a path. Also covered: every getter maps to the right channel method, downloads short-circuits without a channel round-trip, the Android-only APIs still throw, and Documents keeps returning its path (nil'ing it would break iOS parity and readers). Adds path_provider as a dev_dependency: it is the layer that converts a null platform result into the exception, so asserting the user-visible behaviour needs it. Checked the tests have teeth by making getApplicationSupportPath fall back to a path instead of returning null — the Application Support test fails, the rest stay green. --- packages/path_provider_tvos/pubspec.yaml | 3 + .../test/path_provider_tvos_test.dart | 106 +++++++++++++++++- 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/packages/path_provider_tvos/pubspec.yaml b/packages/path_provider_tvos/pubspec.yaml index d49af7c..3890564 100644 --- a/packages/path_provider_tvos/pubspec.yaml +++ b/packages/path_provider_tvos/pubspec.yaml @@ -20,6 +20,9 @@ dev_dependencies: flutter_lints: ^4.0.0 flutter_test: sdk: flutter + # Used by the tests to assert the app-facing behaviour: path_provider is what + # turns a null from this implementation into MissingPlatformDirectoryException. + path_provider: ^2.1.0 flutter: plugin: diff --git a/packages/path_provider_tvos/test/path_provider_tvos_test.dart b/packages/path_provider_tvos/test/path_provider_tvos_test.dart index edcb7dd..d0a35e6 100644 --- a/packages/path_provider_tvos/test/path_provider_tvos_test.dart +++ b/packages/path_provider_tvos/test/path_provider_tvos_test.dart @@ -1,15 +1,111 @@ // Copyright 2026 The FlutterTV Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// -// Generated on 2026-05-18 by `flutter-tvos plugin port`. -// Source plugin: path_provider_foundation +import 'dart:io' show Directory; + +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:path_provider_tvos/path_provider_tvos.dart'; void main() { - test('package compiles and exposes PathProviderTvos', () { - expect(PathProviderTvos, isNotNull); + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('plugins.flutter.io/path_provider'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + late List log; + + /// Answers the native side with [responses], recording every method name. + /// A missing key answers null, which is what the tvOS plugin returns for a + /// directory the sandbox will not give us. + void mockNative(Map responses) { + messenger.setMockMethodCallHandler(channel, (MethodCall call) async { + log.add(call.method); + return responses[call.method]; + }); + } + + setUp(() { + log = []; + PathProviderTvos.registerWith(); + }); + + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + test('registerWith installs itself as the platform implementation', () { + expect(PathProviderPlatform.instance, isA()); + }); + + group('method-channel contract', () { + test('each getter invokes the matching native method', () async { + mockNative({ + 'getTemporaryDirectory': '/tmp', + 'getApplicationSupportDirectory': '/support', + 'getLibraryDirectory': '/library', + 'getApplicationDocumentsDirectory': '/documents', + 'getApplicationCacheDirectory': '/caches', + }); + final tvos = PathProviderTvos(); + + expect(await tvos.getTemporaryPath(), '/tmp'); + expect(await tvos.getApplicationSupportPath(), '/support'); + expect(await tvos.getLibraryPath(), '/library'); + expect(await tvos.getApplicationDocumentsPath(), '/documents'); + expect(await tvos.getApplicationCachePath(), '/caches'); + + expect(log, [ + 'getTemporaryDirectory', + 'getApplicationSupportDirectory', + 'getLibraryDirectory', + 'getApplicationDocumentsDirectory', + 'getApplicationCacheDirectory', + ]); + }); + + test('downloads resolves to null without touching the channel', () async { + mockNative({}); + expect(await PathProviderTvos().getDownloadsPath(), isNull); + expect(log, isEmpty); + }); + + test('Android-only APIs throw UnsupportedError', () async { + final tvos = PathProviderTvos(); + expect(tvos.getExternalStoragePath, throwsUnsupportedError); + expect(tvos.getExternalCachePaths, throwsUnsupportedError); + expect(tvos.getExternalStoragePaths, throwsUnsupportedError); + }); + }); + + group('directories the tvOS sandbox refuses', () { + // The native side returns nil for Application Support because tvOS will not + // let us create it (measured on a physical Apple TV). Returning a path that + // does not exist is what this replaced: the caller used to find out only at + // its first write, as an errno naming nothing. + test('a null Application Support surfaces as an exception, not a path', + () async { + mockNative({'getApplicationSupportDirectory': null}); + + expect(await PathProviderTvos().getApplicationSupportPath(), isNull); + await expectLater( + getApplicationSupportDirectory(), + throwsA(isA()), + ); + }); + + test('Documents is still returned — it exists and reads work', () async { + mockNative({ + 'getApplicationDocumentsDirectory': '/documents', + }); + + // Deliberately NOT null: nil'ing this would break iOS parity and any app + // reading pre-populated data. Writes fail on device; the plugin logs a + // one-time warning and the README says so. + expect(await getApplicationDocumentsDirectory(), + isA().having((Directory d) => d.path, 'path', '/documents')); + }); }); }