...
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user