diff --git a/packages/path_provider_tvos/CHANGELOG.md b/packages/path_provider_tvos/CHANGELOG.md index 3ec9bc6..97aa950 100644 --- a/packages/path_provider_tvos/CHANGELOG.md +++ b/packages/path_provider_tvos/CHANGELOG.md @@ -1,3 +1,26 @@ +## 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. +* `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 + 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..459c1f2 100644 --- a/packages/path_provider_tvos/README.md +++ b/packages/path_provider_tvos/README.md @@ -14,20 +14,46 @@ 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.** 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. ### ❌ 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..3890564 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 @@ -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')); + }); }); } diff --git a/packages/path_provider_tvos/tvos/Classes/PathProviderPlugin.swift b/packages/path_provider_tvos/tvos/Classes/PathProviderPlugin.swift index 4fb59e1..2469408 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,10 @@ public class PathProviderPlugin: NSObject, FlutterPlugin { case "getTemporaryDirectory": 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)) @@ -42,21 +62,46 @@ 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 } - /// 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 }