Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/path_provider_tvos/CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -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
Expand Down
38 changes: 32 additions & 6 deletions packages/path_provider_tvos/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).

Expand Down
5 changes: 4 additions & 1 deletion packages/path_provider_tvos/pubspec.yaml
Original file line numberDiff line numberDiff line change
@@ -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
Expand All@@ -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:
Expand Down
106 changes: 101 additions & 5 deletions packages/path_provider_tvos/test/path_provider_tvos_test.dart
Original file line numberDiff line numberDiff line change
@@ -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<String> 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<String, String?> responses) {
messenger.setMockMethodCallHandler(channel, (MethodCall call) async {
log.add(call.method);
return responses[call.method];
});
}

setUp(() {
log = <String>[];
PathProviderTvos.registerWith();
});

tearDown(() => messenger.setMockMethodCallHandler(channel, null));

test('registerWith installs itself as the platform implementation', () {
expect(PathProviderPlatform.instance, isA<PathProviderTvos>());
});

group('method-channel contract', () {
test('each getter invokes the matching native method', () async {
mockNative(<String, String?>{
'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, <String>[
'getTemporaryDirectory',
'getApplicationSupportDirectory',
'getLibraryDirectory',
'getApplicationDocumentsDirectory',
'getApplicationCacheDirectory',
]);
});

test('downloads resolves to null without touching the channel', () async {
mockNative(<String, String?>{});
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(<String, String?>{'getApplicationSupportDirectory': null});

expect(await PathProviderTvos().getApplicationSupportPath(), isNull);
await expectLater(
getApplicationSupportDirectory(),
throwsA(isA<MissingPlatformDirectoryException>()),
);
});

test('Documents is still returned — it exists and reads work', () async {
mockNative(<String, String?>{
'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<Directory>().having((Directory d) => d.path, 'path', '/documents'));
});
});
}
67 changes: 56 additions & 11 deletions packages/path_provider_tvos/tvos/Classes/PathProviderPlugin.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth deciding explicitly — the PR title says "stop returning directories tvOS cannot write", but Documents (writes denied per your own table) is still returned here. So the most common path_provider usage — getApplicationDocumentsDirectory() + write a file — still fails silently at the first write with the same unhelpful errno this PR fixes for App Support. It's only mitigated by docs ("use getApplicationCacheDirectory instead").

Keeping the path is defensible: Documents exists and reads work (some apps read pre-populated data), nil'ing it would break iOS parity and readers, and writability can depend on entitlements — so returning it lets apps that can write still work, whereas App Support genuinely can't be created. That asymmetry is reasonable.

But the silent-write-failure the PR sets out to kill still lives for the more common directory. Cheap way to make it loud without breaking parity or readers: a one-time NSLog when getApplicationDocumentsDirectory is called on tvOS ("Documents is not writable on a real Apple TV; use the cache directory"). Either add that, or soften the title to reflect that Documents is a docs-only mitigation. Your call — not a blocker.

// 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))
Expand All@@ -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
}
Expand Down