...
This commit is contained in:
@@ -64,6 +64,15 @@ class FilePlugin : NativePlugin, MethodChannel.MethodCallHandler,
|
|||||||
"share" -> handleShare(call, result)
|
"share" -> handleShare(call, result)
|
||||||
"open" -> handleOpen(call, result)
|
"open" -> handleOpen(call, result)
|
||||||
"pick" -> handlePick(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<String>("path")
|
||||||
|
if (path != null) result.success(mapOf("path" to path)) else result.success(null)
|
||||||
|
}
|
||||||
|
"endScopedAccess" -> result.success(null)
|
||||||
"videoThumbnail" -> handleVideoThumbnail(call, result)
|
"videoThumbnail" -> handleVideoThumbnail(call, result)
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
@@ -171,6 +180,17 @@ class FilePlugin : NativePlugin, MethodChannel.MethodCallHandler,
|
|||||||
r.error("no_context", "lost application context", null)
|
r.error("no_context", "lost application context", null)
|
||||||
return true
|
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 {
|
try {
|
||||||
val picked = copyUriToCache(ctx, uri)
|
val picked = copyUriToCache(ctx, uri)
|
||||||
r.success(picked)
|
r.success(picked)
|
||||||
@@ -225,6 +245,11 @@ class FilePlugin : NativePlugin, MethodChannel.MethodCallHandler,
|
|||||||
"name" to displayName,
|
"name" to displayName,
|
||||||
"mimeType" to mimeType,
|
"mimeType" to mimeType,
|
||||||
"size" to (size ?: copied),
|
"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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ public class FilePlugin: NSObject, NativePlugin {
|
|||||||
private var previewDataSource: FilePreviewDataSource?
|
private var previewDataSource: FilePreviewDataSource?
|
||||||
private var pickerDelegate: UxDocumentPickerDelegate?
|
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) {
|
public func register(with registrar: FlutterPluginRegistrar) {
|
||||||
let c = FlutterMethodChannel(name: "ux/file", binaryMessenger: registrar.messenger())
|
let c = FlutterMethodChannel(name: "ux/file", binaryMessenger: registrar.messenger())
|
||||||
c.setMethodCallHandler { [weak self] call, result in
|
c.setMethodCallHandler { [weak self] call, result in
|
||||||
@@ -19,11 +26,14 @@ public class FilePlugin: NSObject, NativePlugin {
|
|||||||
|
|
||||||
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
switch call.method {
|
switch call.method {
|
||||||
case "share": handleShare(call, result: result)
|
case "share": handleShare(call, result: result)
|
||||||
case "open": handleOpen(call, result: result)
|
case "open": handleOpen(call, result: result)
|
||||||
case "pick": handlePick(call, result: result)
|
case "pick": handlePick(call, result: result)
|
||||||
case "videoThumbnail": handleVideoThumbnail(call, result: result)
|
case "showInFolder": result(false) // no Finder equivalent on iOS
|
||||||
default: result(FlutterMethodNotImplemented)
|
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 mimeTypes = (args?["mimeTypes"] as? [String]) ?? []
|
||||||
let utis = utiStrings(forMimeTypes: mimeTypes)
|
let utis = utiStrings(forMimeTypes: mimeTypes)
|
||||||
|
|
||||||
// `.import` mode copies the picked file into the app's Documents/Inbox
|
// `.open` returns a security-scoped URL pointing at the user's
|
||||||
// so the URL is stable after the picker dismisses (matches Android's
|
// original file (no Documents/Inbox copy). Persist the bookmark
|
||||||
// cache-copy semantics).
|
// returned to Dart so future opens can re-acquire scope across
|
||||||
let picker = UIDocumentPickerViewController(documentTypes: utis, in: .import)
|
// cold starts.
|
||||||
|
let picker = UIDocumentPickerViewController(documentTypes: utis, in: .open)
|
||||||
picker.allowsMultipleSelection = false
|
picker.allowsMultipleSelection = false
|
||||||
let delegate = UxDocumentPickerDelegate(result: result) { [weak self] in
|
let delegate = UxDocumentPickerDelegate(result: result) { [weak self] in
|
||||||
self?.pickerDelegate = nil
|
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))
|
return result(FlutterError(code: "no_view", message: "no top view controller", details: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = URL(fileURLWithPath: path)
|
let bookmarkData = (args["bookmark"] as? FlutterStandardTypedData)?.data
|
||||||
let ds = FilePreviewDataSource(url: url)
|
let resolved = resolveURL(path: path, bookmark: bookmarkData)
|
||||||
// QLPreviewController requires a strong-retained data source.
|
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
|
previewDataSource = ds
|
||||||
|
|
||||||
let vc = QLPreviewController()
|
let vc = QLPreviewController()
|
||||||
@@ -185,11 +202,101 @@ public class FilePlugin: NSObject, NativePlugin {
|
|||||||
result(true)
|
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 {
|
private final class FilePreviewDataSource: NSObject, QLPreviewControllerDataSource {
|
||||||
let url: URL
|
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 }
|
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
|
||||||
|
|
||||||
@@ -211,9 +318,10 @@ fileprivate func mimeFromExtension(_ ext: String) -> String? {
|
|||||||
return mime as String
|
return mime as String
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Picker delegate that converts a [UIDocumentPickerViewController] result
|
/// Picker delegate for `.open` mode. The returned URL is security-scoped;
|
||||||
/// into the Dart-side reply map. `.import` mode copies the picked file
|
/// we briefly start access to read attributes + create a bookmark, then
|
||||||
/// into the app's Documents/Inbox, so the URL is stable.
|
/// stop access. Persisted bookmark lets Dart re-acquire scope later via
|
||||||
|
/// `beginScopedAccess`.
|
||||||
private final class UxDocumentPickerDelegate: NSObject, UIDocumentPickerDelegate {
|
private final class UxDocumentPickerDelegate: NSObject, UIDocumentPickerDelegate {
|
||||||
let result: FlutterResult
|
let result: FlutterResult
|
||||||
let onDone: () -> Void
|
let onDone: () -> Void
|
||||||
@@ -232,16 +340,30 @@ private final class UxDocumentPickerDelegate: NSObject, UIDocumentPickerDelegate
|
|||||||
result(nil)
|
result(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let started = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { if started { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
|
||||||
let path = url.path
|
let path = url.path
|
||||||
let attrs = try? FileManager.default.attributesOfItem(atPath: path)
|
let attrs = try? FileManager.default.attributesOfItem(atPath: path)
|
||||||
let size = (attrs?[.size] as? NSNumber)?.intValue
|
let size = (attrs?[.size] as? NSNumber)?.intValue
|
||||||
let mime = mimeFromExtension(url.pathExtension)
|
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,
|
"path": path,
|
||||||
"name": url.lastPathComponent,
|
"name": url.lastPathComponent,
|
||||||
"mimeType": mime as Any,
|
"mimeType": mime as Any,
|
||||||
"size": size as Any,
|
"size": size as Any,
|
||||||
])
|
]
|
||||||
|
if let bookmark = bookmark {
|
||||||
|
reply["bookmark"] = FlutterStandardTypedData(bytes: bookmark)
|
||||||
|
}
|
||||||
|
result(reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:io' show Platform;
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'dart:ui' show Rect;
|
import 'dart:ui' show Rect;
|
||||||
|
|
||||||
@@ -20,22 +21,33 @@ class UxVideoThumbnail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A file the user picked. [path] is on local disk and readable by
|
/// A file the user picked. [path] is on local disk and readable by
|
||||||
/// `dart:io File` — for Android content:// URIs the native side
|
/// `dart:io File` while the picker session's grant is active.
|
||||||
/// 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
|
/// On macOS / iOS the picker returns the user's original location (no
|
||||||
/// dismisses. Bytes are never marshalled across the platform channel.
|
/// 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 {
|
class UxPickedFile {
|
||||||
const UxPickedFile({
|
const UxPickedFile({
|
||||||
required this.path,
|
required this.path,
|
||||||
this.name,
|
this.name,
|
||||||
this.mimeType,
|
this.mimeType,
|
||||||
this.size,
|
this.size,
|
||||||
|
this.bookmark,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String path;
|
final String path;
|
||||||
final String? name;
|
final String? name;
|
||||||
final String? mimeType;
|
final String? mimeType;
|
||||||
final int? size;
|
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 {
|
class UxFile {
|
||||||
@@ -59,6 +71,34 @@ class UxFile {
|
|||||||
///
|
///
|
||||||
/// Returns true if the sheet was presented. Returns false if the host
|
/// Returns true if the sheet was presented. Returns false if the host
|
||||||
/// couldn't present it (no activity on Android, no window on macOS).
|
/// 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<T> withScopedAccess<T>(
|
||||||
|
String path,
|
||||||
|
Uint8List? bookmark,
|
||||||
|
Future<T> Function(String path) body,
|
||||||
|
) async {
|
||||||
|
if (bookmark == null || !(Platform.isMacOS || Platform.isIOS)) {
|
||||||
|
return body(path);
|
||||||
|
}
|
||||||
|
final result = await _channel.invokeMapMethod<String, Object?>(
|
||||||
|
'beginScopedAccess',
|
||||||
|
{'bookmark': bookmark},
|
||||||
|
);
|
||||||
|
final resolvedPath = result?['path'] as String? ?? path;
|
||||||
|
try {
|
||||||
|
return await body(resolvedPath);
|
||||||
|
} finally {
|
||||||
|
await _channel.invokeMethod<void>('endScopedAccess', {'path': resolvedPath});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Future<bool> share({
|
static Future<bool> share({
|
||||||
required String path,
|
required String path,
|
||||||
String? title,
|
String? title,
|
||||||
@@ -96,10 +136,12 @@ class UxFile {
|
|||||||
static Future<bool> open({
|
static Future<bool> open({
|
||||||
required String path,
|
required String path,
|
||||||
String? mimeType,
|
String? mimeType,
|
||||||
|
Uint8List? bookmark,
|
||||||
}) async {
|
}) async {
|
||||||
final result = await _channel.invokeMethod<bool>('open', {
|
final result = await _channel.invokeMethod<bool>('open', {
|
||||||
'path': path,
|
'path': path,
|
||||||
if (mimeType != null) 'mimeType': mimeType,
|
if (mimeType != null) 'mimeType': mimeType,
|
||||||
|
if (bookmark != null) 'bookmark': bookmark,
|
||||||
});
|
});
|
||||||
return result ?? false;
|
return result ?? false;
|
||||||
}
|
}
|
||||||
@@ -131,9 +173,34 @@ class UxFile {
|
|||||||
name: result['name'] as String?,
|
name: result['name'] as String?,
|
||||||
mimeType: result['mimeType'] as String?,
|
mimeType: result['mimeType'] as String?,
|
||||||
size: (result['size'] as num?)?.toInt(),
|
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<bool> showInFolder({
|
||||||
|
required String path,
|
||||||
|
Uint8List? bookmark,
|
||||||
|
}) async {
|
||||||
|
if (!Platform.isMacOS) return false;
|
||||||
|
final result = await _channel.invokeMethod<bool>('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
|
/// Extract a single frame from the video at [path]. Returns null if the
|
||||||
/// platform's media decoder couldn't open the file (unsupported codec /
|
/// platform's media decoder couldn't open the file (unsupported codec /
|
||||||
/// corrupt / not actually a video).
|
/// corrupt / not actually a video).
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ import Quartz
|
|||||||
public class FilePlugin: NSObject, NativePlugin {
|
public class FilePlugin: NSObject, NativePlugin {
|
||||||
private var channel: FlutterMethodChannel?
|
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) {
|
public func register(with registrar: FlutterPluginRegistrar) {
|
||||||
let c = FlutterMethodChannel(name: "ux/file", binaryMessenger: registrar.messenger)
|
let c = FlutterMethodChannel(name: "ux/file", binaryMessenger: registrar.messenger)
|
||||||
c.setMethodCallHandler { [weak self] call, result in
|
c.setMethodCallHandler { [weak self] call, result in
|
||||||
@@ -17,11 +28,14 @@ public class FilePlugin: NSObject, NativePlugin {
|
|||||||
|
|
||||||
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
switch call.method {
|
switch call.method {
|
||||||
case "share": handleShare(call, result: result)
|
case "share": handleShare(call, result: result)
|
||||||
case "open": handleOpen(call, result: result)
|
case "open": handleOpen(call, result: result)
|
||||||
case "pick": handlePick(call, result: result)
|
case "pick": handlePick(call, result: result)
|
||||||
case "videoThumbnail": handleVideoThumbnail(call, result: result)
|
case "showInFolder": handleShowInFolder(call, result: result)
|
||||||
default: result(FlutterMethodNotImplemented)
|
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.canChooseDirectories = false
|
||||||
panel.allowsMultipleSelection = false
|
panel.allowsMultipleSelection = false
|
||||||
panel.resolvesAliases = true
|
panel.resolvesAliases = true
|
||||||
|
panel.canDownloadUbiquitousContents = true
|
||||||
|
panel.canResolveUbiquitousConflicts = true
|
||||||
|
panel.treatsFilePackagesAsDirectories = false
|
||||||
|
|
||||||
let utis = utiStrings(forMimeTypes: mimeTypes)
|
let utis = utiStrings(forMimeTypes: mimeTypes)
|
||||||
// Empty / `public.data` only means "any" — leave allowed types unset
|
// 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 attrs = try? FileManager.default.attributesOfItem(atPath: path)
|
||||||
let size = (attrs?[.size] as? NSNumber)?.intValue
|
let size = (attrs?[.size] as? NSNumber)?.intValue
|
||||||
let mime = mimeFromExtension(url.pathExtension)
|
let mime = mimeFromExtension(url.pathExtension)
|
||||||
result([
|
let bookmark = try? url.bookmarkData(
|
||||||
|
options: .withSecurityScope,
|
||||||
|
includingResourceValuesForKeys: nil,
|
||||||
|
relativeTo: nil,
|
||||||
|
)
|
||||||
|
var reply: [String: Any] = [
|
||||||
"path": path,
|
"path": path,
|
||||||
"name": url.lastPathComponent,
|
"name": url.lastPathComponent,
|
||||||
"mimeType": mime as Any,
|
"mimeType": mime as Any,
|
||||||
"size": size as Any,
|
"size": size as Any,
|
||||||
])
|
]
|
||||||
|
if let bookmark = bookmark {
|
||||||
|
reply["bookmark"] = FlutterStandardTypedData(bytes: bookmark)
|
||||||
|
}
|
||||||
|
result(reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let host = host {
|
if let host = host {
|
||||||
@@ -173,28 +199,135 @@ public class FilePlugin: NSObject, NativePlugin {
|
|||||||
result(true)
|
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) {
|
private func handleOpen(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
guard let args = call.arguments as? [String: Any],
|
guard let args = call.arguments as? [String: Any],
|
||||||
let path = args["path"] as? String else {
|
let path = args["path"] as? String else {
|
||||||
return result(FlutterError(code: "bad_args", message: "path is required", details: nil))
|
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).
|
withScope(path: path, bookmark: bookmarkData) { url in
|
||||||
// Fall back to NSWorkspace.open if there's no window to host the panel.
|
// Prefer in-app Quick Look (keeps the host app in the foreground).
|
||||||
if let flutterView = UxWindow.flutterView,
|
// Fall back to NSWorkspace.open if there's no window to host the panel.
|
||||||
let window = flutterView.window,
|
if let flutterView = UxWindow.flutterView,
|
||||||
let panel = QLPreviewPanel.shared() {
|
let window = flutterView.window,
|
||||||
let responder = UxQLPreviewResponder(url: url, window: window)
|
let panel = QLPreviewPanel.shared() {
|
||||||
flutterView.addSubview(responder)
|
let responder = UxQLPreviewResponder(url: url, window: window)
|
||||||
window.makeFirstResponder(responder)
|
flutterView.addSubview(responder)
|
||||||
panel.updateController()
|
window.makeFirstResponder(responder)
|
||||||
panel.makeKeyAndOrderFront(nil)
|
panel.updateController()
|
||||||
result(true)
|
panel.makeKeyAndOrderFront(nil)
|
||||||
return
|
result(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result(NSWorkspace.shared.open(url))
|
||||||
}
|
}
|
||||||
|
|
||||||
result(NSWorkspace.shared.open(url))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user