diff --git a/android/src/main/kotlin/io/swipelab/ux/FilePlugin.kt b/android/src/main/kotlin/io/swipelab/ux/FilePlugin.kt index 5f72806..cfc5160 100644 --- a/android/src/main/kotlin/io/swipelab/ux/FilePlugin.kt +++ b/android/src/main/kotlin/io/swipelab/ux/FilePlugin.kt @@ -64,6 +64,15 @@ class FilePlugin : NativePlugin, MethodChannel.MethodCallHandler, "share" -> handleShare(call, result) "open" -> handleOpen(call, result) "pick" -> handlePick(call, result) + // No file-manager equivalent that's reliable across OEMs. + "showInFolder" -> result.success(false) + // Android URI permissions, once persisted, don't need a per-read + // begin/end cycle — these are no-ops for API symmetry. + "beginScopedAccess" -> { + val path = call.argument("path") + if (path != null) result.success(mapOf("path" to path)) else result.success(null) + } + "endScopedAccess" -> result.success(null) "videoThumbnail" -> handleVideoThumbnail(call, result) else -> result.notImplemented() } @@ -171,6 +180,17 @@ class FilePlugin : NativePlugin, MethodChannel.MethodCallHandler, r.error("no_context", "lost application context", null) return true } + // Persist the URI grant so we can resolve the original later + // (e.g. across cold restarts). Best-effort — some providers (notably + // ones without `FLAG_GRANT_PERSISTABLE_URI_PERMISSION` in the + // returned intent) reject this; the cached copy still works. + try { + ctx.contentResolver.takePersistableUriPermission( + uri, Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } catch (_: SecurityException) { + // No persistable grant — proceed with cache copy only. + } try { val picked = copyUriToCache(ctx, uri) r.success(picked) @@ -225,6 +245,11 @@ class FilePlugin : NativePlugin, MethodChannel.MethodCallHandler, "name" to displayName, "mimeType" to mimeType, "size" to (size ?: copied), + // The "bookmark" on Android is the original SAF URI as UTF-8 + // bytes — kept for symmetry with macOS/iOS scoped bookmarks + // and so callers can re-resolve the source if a future code + // path needs it (read via ContentResolver). + "bookmark" to uri.toString().toByteArray(Charsets.UTF_8), ) } diff --git a/ios/Classes/FilePlugin.swift b/ios/Classes/FilePlugin.swift index 6bf4a24..5f45b9a 100644 --- a/ios/Classes/FilePlugin.swift +++ b/ios/Classes/FilePlugin.swift @@ -9,6 +9,13 @@ public class FilePlugin: NSObject, NativePlugin { private var previewDataSource: FilePreviewDataSource? private var pickerDelegate: UxDocumentPickerDelegate? + private struct ScopedEntry { + let url: URL + var count: Int + } + private var scoped: [String: ScopedEntry] = [:] + private let scopedQueue = DispatchQueue(label: "ux.file.scoped") + public func register(with registrar: FlutterPluginRegistrar) { let c = FlutterMethodChannel(name: "ux/file", binaryMessenger: registrar.messenger()) c.setMethodCallHandler { [weak self] call, result in @@ -19,11 +26,14 @@ public class FilePlugin: NSObject, NativePlugin { private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { - case "share": handleShare(call, result: result) - case "open": handleOpen(call, result: result) - case "pick": handlePick(call, result: result) - case "videoThumbnail": handleVideoThumbnail(call, result: result) - default: result(FlutterMethodNotImplemented) + case "share": handleShare(call, result: result) + case "open": handleOpen(call, result: result) + case "pick": handlePick(call, result: result) + case "showInFolder": result(false) // no Finder equivalent on iOS + case "beginScopedAccess": handleBeginScopedAccess(call, result: result) + case "endScopedAccess": handleEndScopedAccess(call, result: result) + case "videoThumbnail": handleVideoThumbnail(call, result: result) + default: result(FlutterMethodNotImplemented) } } @@ -120,10 +130,11 @@ public class FilePlugin: NSObject, NativePlugin { let mimeTypes = (args?["mimeTypes"] as? [String]) ?? [] let utis = utiStrings(forMimeTypes: mimeTypes) - // `.import` mode copies the picked file into the app's Documents/Inbox - // so the URL is stable after the picker dismisses (matches Android's - // cache-copy semantics). - let picker = UIDocumentPickerViewController(documentTypes: utis, in: .import) + // `.open` returns a security-scoped URL pointing at the user's + // original file (no Documents/Inbox copy). Persist the bookmark + // returned to Dart so future opens can re-acquire scope across + // cold starts. + let picker = UIDocumentPickerViewController(documentTypes: utis, in: .open) picker.allowsMultipleSelection = false let delegate = UxDocumentPickerDelegate(result: result) { [weak self] in self?.pickerDelegate = nil @@ -173,9 +184,15 @@ public class FilePlugin: NSObject, NativePlugin { return result(FlutterError(code: "no_view", message: "no top view controller", details: nil)) } - let url = URL(fileURLWithPath: path) - let ds = FilePreviewDataSource(url: url) - // QLPreviewController requires a strong-retained data source. + let bookmarkData = (args["bookmark"] as? FlutterStandardTypedData)?.data + let resolved = resolveURL(path: path, bookmark: bookmarkData) + let url = resolved.url + let started = resolved.scoped + // QuickLook keeps the URL alive while presented; we hold scope for + // the controller's lifetime by deferring stop until dismiss. + let ds = FilePreviewDataSource(url: url) { + if started { url.stopAccessingSecurityScopedResource() } + } previewDataSource = ds let vc = QLPreviewController() @@ -185,11 +202,101 @@ public class FilePlugin: NSObject, NativePlugin { result(true) } } + + private func handleBeginScopedAccess(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any], + let bookmarkData = (args["bookmark"] as? FlutterStandardTypedData)?.data else { + return result(FlutterError(code: "bad_args", message: "bookmark is required", details: nil)) + } + var stale = false + let url: URL + do { + url = try URL( + resolvingBookmarkData: bookmarkData, + options: [], + relativeTo: nil, + bookmarkDataIsStale: &stale, + ) + } catch { + return result(FlutterError(code: "stale_bookmark", + message: "could not resolve bookmark: \(error.localizedDescription)", + details: nil)) + } + guard url.startAccessingSecurityScopedResource() else { + return result(FlutterError(code: "access_denied", + message: "startAccessingSecurityScopedResource failed", + details: nil)) + } + let path = url.path + scopedQueue.sync { + if var entry = scoped[path] { + entry.count += 1 + scoped[path] = entry + url.stopAccessingSecurityScopedResource() + } else { + scoped[path] = ScopedEntry(url: url, count: 1) + } + } + var reply: [String: Any] = ["path": path] + if stale { + if let refreshed = try? url.bookmarkData( + options: [], + includingResourceValuesForKeys: nil, + relativeTo: nil, + ) { + reply["bookmark"] = FlutterStandardTypedData(bytes: refreshed) + } + } + result(reply) + } + + private func handleEndScopedAccess(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any], + let path = args["path"] as? String else { + return result(FlutterError(code: "bad_args", message: "path is required", details: nil)) + } + scopedQueue.sync { + guard var entry = scoped[path] else { return } + entry.count -= 1 + if entry.count <= 0 { + entry.url.stopAccessingSecurityScopedResource() + scoped.removeValue(forKey: path) + } else { + scoped[path] = entry + } + } + result(nil) + } + + /// Resolves [bookmark] (when present) into a security-scoped URL and + /// starts access. Falls back to a plain `URL(fileURLWithPath:)` when no + /// bookmark is provided or resolution fails. + private func resolveURL(path: String, bookmark: Data?) -> (url: URL, scoped: Bool) { + guard let bookmark = bookmark else { + return (URL(fileURLWithPath: path), false) + } + var stale = false + guard let url = try? URL( + resolvingBookmarkData: bookmark, + options: [], + relativeTo: nil, + bookmarkDataIsStale: &stale, + ) else { + return (URL(fileURLWithPath: path), false) + } + let started = url.startAccessingSecurityScopedResource() + return (url, started) + } } private final class FilePreviewDataSource: NSObject, QLPreviewControllerDataSource { let url: URL - init(url: URL) { self.url = url } + let onDeinit: (() -> Void)? + init(url: URL, onDeinit: (() -> Void)? = nil) { + self.url = url + self.onDeinit = onDeinit + } + deinit { onDeinit?() } func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 } @@ -211,9 +318,10 @@ fileprivate func mimeFromExtension(_ ext: String) -> String? { return mime as String } -/// Picker delegate that converts a [UIDocumentPickerViewController] result -/// into the Dart-side reply map. `.import` mode copies the picked file -/// into the app's Documents/Inbox, so the URL is stable. +/// Picker delegate for `.open` mode. The returned URL is security-scoped; +/// we briefly start access to read attributes + create a bookmark, then +/// stop access. Persisted bookmark lets Dart re-acquire scope later via +/// `beginScopedAccess`. private final class UxDocumentPickerDelegate: NSObject, UIDocumentPickerDelegate { let result: FlutterResult let onDone: () -> Void @@ -232,16 +340,30 @@ private final class UxDocumentPickerDelegate: NSObject, UIDocumentPickerDelegate result(nil) return } + let started = url.startAccessingSecurityScopedResource() + defer { if started { url.stopAccessingSecurityScopedResource() } } + let path = url.path let attrs = try? FileManager.default.attributesOfItem(atPath: path) let size = (attrs?[.size] as? NSNumber)?.intValue let mime = mimeFromExtension(url.pathExtension) - result([ + // iOS bookmarks don't take `.withSecurityScope` (that's macOS-only); + // bookmarks created from a security-scoped URL stay scoped. + let bookmark = try? url.bookmarkData( + options: [], + includingResourceValuesForKeys: nil, + relativeTo: nil, + ) + var reply: [String: Any] = [ "path": path, "name": url.lastPathComponent, "mimeType": mime as Any, "size": size as Any, - ]) + ] + if let bookmark = bookmark { + reply["bookmark"] = FlutterStandardTypedData(bytes: bookmark) + } + result(reply) } func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { diff --git a/lib/src/file.dart b/lib/src/file.dart index 81cab1c..2d7f73d 100644 --- a/lib/src/file.dart +++ b/lib/src/file.dart @@ -1,3 +1,4 @@ +import 'dart:io' show Platform; import 'dart:typed_data'; import 'dart:ui' show Rect; @@ -20,22 +21,33 @@ class UxVideoThumbnail { } /// A file the user picked. [path] is on local disk and readable by -/// `dart:io File` — for Android content:// URIs the native side -/// stream-copies the source to the app's cache; for iOS/macOS picks the -/// file is copied to the temp dir so the path is stable after the picker -/// dismisses. Bytes are never marshalled across the platform channel. +/// `dart:io File` while the picker session's grant is active. +/// +/// On macOS / iOS the picker returns the user's original location (no +/// temp-dir copy); access across cold restarts is preserved by storing +/// [bookmark] and re-acquiring scope via [UxFile.withScopedAccess] / +/// [UxFile.open] / [UxFile.showInFolder]. On Android the native side +/// stream-copies a `content://` source into the app cache (since +/// `dart:io` can't open content URIs); [bookmark] holds the source URI +/// as UTF-8 bytes for symmetry but isn't required for reads. class UxPickedFile { const UxPickedFile({ required this.path, this.name, this.mimeType, this.size, + this.bookmark, }); final String path; final String? name; final String? mimeType; final int? size; + + /// macOS / iOS scoped bookmark, or Android URI bytes. Persist alongside + /// [path] so future opens can re-acquire access. Null on platforms that + /// don't need it (or when the platform supplied no bookmark). + final Uint8List? bookmark; } class UxFile { @@ -59,6 +71,34 @@ class UxFile { /// /// Returns true if the sheet was presented. Returns false if the host /// couldn't present it (no activity on Android, no window on macOS). + /// Run [body] with scoped access to a file. On macOS / iOS, when + /// [bookmark] is non-null, the plugin resolves the bookmark into a + /// security-scoped URL, starts access, hands the resolved path to + /// [body], and stops access in `finally`. On all other platforms (and + /// when [bookmark] is null) [body] is invoked with [path] directly. + /// + /// The plugin maintains a per-path begin counter so nested calls are + /// safe. Access is always released in `finally`, including on errors. + static Future withScopedAccess( + String path, + Uint8List? bookmark, + Future Function(String path) body, + ) async { + if (bookmark == null || !(Platform.isMacOS || Platform.isIOS)) { + return body(path); + } + final result = await _channel.invokeMapMethod( + 'beginScopedAccess', + {'bookmark': bookmark}, + ); + final resolvedPath = result?['path'] as String? ?? path; + try { + return await body(resolvedPath); + } finally { + await _channel.invokeMethod('endScopedAccess', {'path': resolvedPath}); + } + } + static Future share({ required String path, String? title, @@ -96,10 +136,12 @@ class UxFile { static Future open({ required String path, String? mimeType, + Uint8List? bookmark, }) async { final result = await _channel.invokeMethod('open', { 'path': path, if (mimeType != null) 'mimeType': mimeType, + if (bookmark != null) 'bookmark': bookmark, }); return result ?? false; } @@ -131,9 +173,34 @@ class UxFile { name: result['name'] as String?, mimeType: result['mimeType'] as String?, size: (result['size'] as num?)?.toInt(), + bookmark: result['bookmark'] as Uint8List?, ); } + /// Reveal a file on disk in the system's file browser. + /// + /// - macOS: opens (or surfaces) a Finder window with the file selected + /// (`NSWorkspace.activateFileViewerSelecting`). + /// - All other platforms: no-op, returns false. + /// + /// Returns true on macOS when Finder accepted the request. + static Future showInFolder({ + required String path, + Uint8List? bookmark, + }) async { + if (!Platform.isMacOS) return false; + final result = await _channel.invokeMethod('showInFolder', { + 'path': path, + if (bookmark != null) 'bookmark': bookmark, + }); + return result ?? false; + } + + /// Whether the host platform supports [showInFolder]. Currently macOS + /// only — call sites can use this to gate UI affordances ("Show in + /// Finder" buttons) so they don't appear where they're inert. + static bool get supportsShowInFolder => Platform.isMacOS; + /// Extract a single frame from the video at [path]. Returns null if the /// platform's media decoder couldn't open the file (unsupported codec / /// corrupt / not actually a video). diff --git a/macos/Classes/FilePlugin.swift b/macos/Classes/FilePlugin.swift index 412aeee..91b1dbe 100644 --- a/macos/Classes/FilePlugin.swift +++ b/macos/Classes/FilePlugin.swift @@ -7,6 +7,17 @@ import Quartz public class FilePlugin: NSObject, NativePlugin { private var channel: FlutterMethodChannel? + // Active scoped-resource grants keyed by the resolved path. Counter + // lets nested begin/end pairs nest safely; the URL is the actual + // access-bearing object so we keep a strong reference until the + // last release. + private struct ScopedEntry { + let url: URL + var count: Int + } + private var scoped: [String: ScopedEntry] = [:] + private let scopedQueue = DispatchQueue(label: "ux.file.scoped") + public func register(with registrar: FlutterPluginRegistrar) { let c = FlutterMethodChannel(name: "ux/file", binaryMessenger: registrar.messenger) c.setMethodCallHandler { [weak self] call, result in @@ -17,11 +28,14 @@ public class FilePlugin: NSObject, NativePlugin { private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { - case "share": handleShare(call, result: result) - case "open": handleOpen(call, result: result) - case "pick": handlePick(call, result: result) - case "videoThumbnail": handleVideoThumbnail(call, result: result) - default: result(FlutterMethodNotImplemented) + case "share": handleShare(call, result: result) + case "open": handleOpen(call, result: result) + case "pick": handlePick(call, result: result) + case "showInFolder": handleShowInFolder(call, result: result) + case "beginScopedAccess": handleBeginScopedAccess(call, result: result) + case "endScopedAccess": handleEndScopedAccess(call, result: result) + case "videoThumbnail": handleVideoThumbnail(call, result: result) + default: result(FlutterMethodNotImplemented) } } @@ -79,6 +93,9 @@ public class FilePlugin: NSObject, NativePlugin { panel.canChooseDirectories = false panel.allowsMultipleSelection = false panel.resolvesAliases = true + panel.canDownloadUbiquitousContents = true + panel.canResolveUbiquitousConflicts = true + panel.treatsFilePackagesAsDirectories = false let utis = utiStrings(forMimeTypes: mimeTypes) // Empty / `public.data` only means "any" — leave allowed types unset @@ -99,12 +116,21 @@ public class FilePlugin: NSObject, NativePlugin { let attrs = try? FileManager.default.attributesOfItem(atPath: path) let size = (attrs?[.size] as? NSNumber)?.intValue let mime = mimeFromExtension(url.pathExtension) - result([ + let bookmark = try? url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil, + ) + var reply: [String: Any] = [ "path": path, "name": url.lastPathComponent, "mimeType": mime as Any, "size": size as Any, - ]) + ] + if let bookmark = bookmark { + reply["bookmark"] = FlutterStandardTypedData(bytes: bookmark) + } + result(reply) } if let host = host { @@ -173,28 +199,135 @@ public class FilePlugin: NSObject, NativePlugin { result(true) } + private func handleShowInFolder(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any], + let path = args["path"] as? String else { + return result(FlutterError(code: "bad_args", message: "path is required", details: nil)) + } + let bookmarkData = (args["bookmark"] as? FlutterStandardTypedData)?.data + withScope(path: path, bookmark: bookmarkData) { url in + NSWorkspace.shared.activateFileViewerSelecting([url]) + result(true) + } + } + + private func handleBeginScopedAccess(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any], + let bookmarkData = (args["bookmark"] as? FlutterStandardTypedData)?.data else { + return result(FlutterError(code: "bad_args", message: "bookmark is required", details: nil)) + } + var stale = false + let url: URL + do { + url = try URL( + resolvingBookmarkData: bookmarkData, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &stale, + ) + } catch { + return result(FlutterError(code: "stale_bookmark", + message: "could not resolve bookmark: \(error.localizedDescription)", + details: nil)) + } + guard url.startAccessingSecurityScopedResource() else { + return result(FlutterError(code: "access_denied", + message: "startAccessingSecurityScopedResource failed", + details: nil)) + } + let path = url.path + scopedQueue.sync { + if var entry = scoped[path] { + entry.count += 1 + scoped[path] = entry + // Already had access — release the duplicate grant we just took. + url.stopAccessingSecurityScopedResource() + } else { + scoped[path] = ScopedEntry(url: url, count: 1) + } + } + var reply: [String: Any] = ["path": path] + if stale { + // Caller may want to refresh storage with a new bookmark. + if let refreshed = try? url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil, + ) { + reply["bookmark"] = FlutterStandardTypedData(bytes: refreshed) + } + } + result(reply) + } + + private func handleEndScopedAccess(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any], + let path = args["path"] as? String else { + return result(FlutterError(code: "bad_args", message: "path is required", details: nil)) + } + scopedQueue.sync { + guard var entry = scoped[path] else { + return + } + entry.count -= 1 + if entry.count <= 0 { + entry.url.stopAccessingSecurityScopedResource() + scoped.removeValue(forKey: path) + } else { + scoped[path] = entry + } + } + result(nil) + } + + /// Resolves [bookmark] (if any) into a security-scoped URL, runs [body], + /// then releases scope. When [bookmark] is nil falls back to a plain + /// `URL(fileURLWithPath:)` — used for paths the app can already read + /// freely (e.g. files inside its own cache). + private func withScope(path: String, bookmark: Data?, body: (URL) -> Void) { + guard let bookmark = bookmark else { + body(URL(fileURLWithPath: path)) + return + } + var stale = false + guard let url = try? URL( + resolvingBookmarkData: bookmark, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &stale, + ) else { + body(URL(fileURLWithPath: path)) + return + } + let started = url.startAccessingSecurityScopedResource() + defer { if started { url.stopAccessingSecurityScopedResource() } } + body(url) + } + private func handleOpen(_ call: FlutterMethodCall, result: @escaping FlutterResult) { guard let args = call.arguments as? [String: Any], let path = args["path"] as? String else { return result(FlutterError(code: "bad_args", message: "path is required", details: nil)) } - let url = URL(fileURLWithPath: path) + let bookmarkData = (args["bookmark"] as? FlutterStandardTypedData)?.data - // Prefer in-app Quick Look (keeps Banlu in the foreground). - // Fall back to NSWorkspace.open if there's no window to host the panel. - if let flutterView = UxWindow.flutterView, - let window = flutterView.window, - let panel = QLPreviewPanel.shared() { - let responder = UxQLPreviewResponder(url: url, window: window) - flutterView.addSubview(responder) - window.makeFirstResponder(responder) - panel.updateController() - panel.makeKeyAndOrderFront(nil) - result(true) - return + withScope(path: path, bookmark: bookmarkData) { url in + // Prefer in-app Quick Look (keeps the host app in the foreground). + // Fall back to NSWorkspace.open if there's no window to host the panel. + if let flutterView = UxWindow.flutterView, + let window = flutterView.window, + let panel = QLPreviewPanel.shared() { + let responder = UxQLPreviewResponder(url: url, window: window) + flutterView.addSubview(responder) + window.makeFirstResponder(responder) + panel.updateController() + panel.makeKeyAndOrderFront(nil) + result(true) + return + } + + result(NSWorkspace.shared.open(url)) } - - result(NSWorkspace.shared.open(url)) } }