390 lines
12 KiB
Swift
390 lines
12 KiB
Swift
import Cocoa
|
|
import Darwin
|
|
import FlutterMacOS
|
|
|
|
@main
|
|
class AppDelegate: FlutterAppDelegate, NSWindowDelegate {
|
|
private let skillDirectoryChannelName = "plus.svc.xworkmate/skill_directory_access"
|
|
private let appLifecycleChannelName = "plus.svc.xworkmate/app_lifecycle"
|
|
private let dockDisplayMode: DockDisplayMode = .regular
|
|
private let statusProvider = FlutterAppStatusProvider()
|
|
private var directoryAccessSessions: [String: URL] = [:]
|
|
private var appLifecycleChannel: FlutterMethodChannel?
|
|
private var appLifecycleMessengerId: ObjectIdentifier?
|
|
private var skillDirectoryChannel: FlutterMethodChannel?
|
|
private var skillDirectoryMessengerId: ObjectIdentifier?
|
|
private lazy var statusViewModel = AppStatusViewModel(provider: statusProvider)
|
|
private var statusBarController: StatusBarController?
|
|
private var terminationInFlight = false
|
|
private var terminationTimeoutWorkItem: DispatchWorkItem?
|
|
|
|
override func applicationDidFinishLaunching(_ notification: Notification) {
|
|
super.applicationDidFinishLaunching(notification)
|
|
|
|
applyDockDisplayMode()
|
|
mainFlutterWindow?.delegate = self
|
|
|
|
if let controller = mainFlutterWindow?.contentViewController as? FlutterViewController {
|
|
registerApplicationChannels(for: controller)
|
|
}
|
|
setUpStatusBarController()
|
|
statusViewModel.startRefreshing()
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.showMainWindow()
|
|
}
|
|
}
|
|
|
|
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
|
return false
|
|
}
|
|
|
|
override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
|
|
return true
|
|
}
|
|
|
|
override func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
|
guard !terminationInFlight else {
|
|
return .terminateLater
|
|
}
|
|
terminationInFlight = true
|
|
var hasReplied = false
|
|
let finishTermination: () -> Void = { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
guard !hasReplied else {
|
|
return
|
|
}
|
|
hasReplied = true
|
|
self.terminationTimeoutWorkItem?.cancel()
|
|
self.terminationTimeoutWorkItem = nil
|
|
self.terminationInFlight = false
|
|
sender.reply(toApplicationShouldTerminate: true)
|
|
}
|
|
|
|
let timeoutWorkItem = DispatchWorkItem(block: finishTermination)
|
|
terminationTimeoutWorkItem = timeoutWorkItem
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: timeoutWorkItem)
|
|
statusViewModel.prepareForExit(completion: finishTermination)
|
|
return .terminateLater
|
|
}
|
|
|
|
override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
|
showMainWindow()
|
|
return true
|
|
}
|
|
|
|
override func applicationWillTerminate(_ notification: Notification) {
|
|
statusViewModel.stopRefreshing()
|
|
for (_, url) in directoryAccessSessions {
|
|
url.stopAccessingSecurityScopedResource()
|
|
}
|
|
directoryAccessSessions.removeAll()
|
|
super.applicationWillTerminate(notification)
|
|
}
|
|
|
|
func registerApplicationChannels(for controller: FlutterViewController) {
|
|
registerAppLifecycleChannel(for: controller)
|
|
registerSkillDirectoryChannel(for: controller)
|
|
}
|
|
|
|
func registerSkillDirectoryChannel(for controller: FlutterViewController) {
|
|
let messengerObject = controller.engine.binaryMessenger as AnyObject
|
|
let messengerId = ObjectIdentifier(messengerObject)
|
|
if skillDirectoryMessengerId == messengerId {
|
|
return
|
|
}
|
|
let channel = FlutterMethodChannel(
|
|
name: skillDirectoryChannelName,
|
|
binaryMessenger: controller.engine.binaryMessenger
|
|
)
|
|
channel.setMethodCallHandler { [weak self] call, result in
|
|
self?.handleSkillDirectoryCall(call, result: result)
|
|
}
|
|
skillDirectoryChannel = channel
|
|
skillDirectoryMessengerId = messengerId
|
|
}
|
|
|
|
func registerAppLifecycleChannel(for controller: FlutterViewController) {
|
|
let messengerObject = controller.engine.binaryMessenger as AnyObject
|
|
let messengerId = ObjectIdentifier(messengerObject)
|
|
if appLifecycleMessengerId == messengerId {
|
|
return
|
|
}
|
|
appLifecycleChannel = FlutterMethodChannel(
|
|
name: appLifecycleChannelName,
|
|
binaryMessenger: controller.engine.binaryMessenger
|
|
)
|
|
appLifecycleMessengerId = messengerId
|
|
statusViewModel.bind(channel: appLifecycleChannel)
|
|
}
|
|
|
|
private func setUpStatusBarController() {
|
|
guard statusBarController == nil else {
|
|
return
|
|
}
|
|
statusBarController = StatusBarController(
|
|
viewModel: statusViewModel,
|
|
openAppHandler: { [weak self] in
|
|
self?.showMainWindow()
|
|
},
|
|
quitAndPauseHandler: { [weak self] in
|
|
self?.requestTermination()
|
|
}
|
|
)
|
|
}
|
|
|
|
private func requestTermination() {
|
|
NSApp.terminate(nil)
|
|
}
|
|
|
|
@IBAction func showMainWindow(_ sender: Any?) {
|
|
showMainWindow()
|
|
}
|
|
|
|
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
|
sender.orderOut(nil)
|
|
return false
|
|
}
|
|
|
|
private func showMainWindow() {
|
|
guard let window = mainFlutterWindow else {
|
|
return
|
|
}
|
|
NSApp.unhide(nil)
|
|
if window.isMiniaturized {
|
|
window.deminiaturize(nil)
|
|
}
|
|
window.makeKeyAndOrderFront(nil)
|
|
window.orderFrontRegardless()
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
}
|
|
|
|
private func applyDockDisplayMode() {
|
|
switch dockDisplayMode {
|
|
case .regular:
|
|
NSApp.setActivationPolicy(.regular)
|
|
case .menuBarOnly:
|
|
NSApp.setActivationPolicy(.accessory)
|
|
}
|
|
}
|
|
|
|
private func handleSkillDirectoryCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
switch call.method {
|
|
case "resolveUserHomeDirectory":
|
|
result(resolveUserHomeDirectoryPath())
|
|
case "authorizeDirectory":
|
|
authorizeDirectory(call, result: result)
|
|
case "authorizeDirectories":
|
|
authorizeDirectories(call, result: result)
|
|
case "startDirectoryAccess":
|
|
startDirectoryAccess(call, result: result)
|
|
case "stopDirectoryAccess":
|
|
stopDirectoryAccess(call, result: result)
|
|
default:
|
|
result(FlutterMethodNotImplemented)
|
|
}
|
|
}
|
|
|
|
private func authorizeDirectory(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
let arguments = call.arguments as? [String: Any]
|
|
let suggestedPath = (arguments?["suggestedPath"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
let panel = buildAuthorizationPanel(
|
|
title: "授权技能目录",
|
|
message: "请选择要授予 XWorkmate 只读访问权限的技能目录。",
|
|
allowsMultipleSelection: false,
|
|
suggestedPath: suggestedPath
|
|
)
|
|
guard panel.runModal() == .OK, let selectedURL = panel.url else {
|
|
result(nil)
|
|
return
|
|
}
|
|
|
|
do {
|
|
result(try authorizationPayload(for: selectedURL))
|
|
} catch {
|
|
result(
|
|
FlutterError(
|
|
code: "bookmark_create_failed",
|
|
message: error.localizedDescription,
|
|
details: nil
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func authorizeDirectories(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
let arguments = call.arguments as? [String: Any]
|
|
let suggestedPaths = (arguments?["suggestedPaths"] as? [String] ?? [])
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty }
|
|
let panel = buildAuthorizationPanel(
|
|
title: "批量授权技能目录",
|
|
message: "请选择要授予 XWorkmate 只读访问权限的一个或多个技能目录。",
|
|
allowsMultipleSelection: true,
|
|
suggestedPath: suggestedPaths.first ?? ""
|
|
)
|
|
guard panel.runModal() == .OK else {
|
|
result([])
|
|
return
|
|
}
|
|
|
|
do {
|
|
let payload = try panel.urls.map(authorizationPayload(for:))
|
|
result(payload)
|
|
} catch {
|
|
result(
|
|
FlutterError(
|
|
code: "bookmark_create_failed",
|
|
message: error.localizedDescription,
|
|
details: nil
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func buildAuthorizationPanel(
|
|
title: String,
|
|
message: String,
|
|
allowsMultipleSelection: Bool,
|
|
suggestedPath: String
|
|
) -> NSOpenPanel {
|
|
let panel = NSOpenPanel()
|
|
panel.title = title
|
|
panel.message = message
|
|
panel.prompt = "授权"
|
|
panel.canChooseDirectories = true
|
|
panel.canChooseFiles = false
|
|
panel.allowsMultipleSelection = allowsMultipleSelection
|
|
panel.canCreateDirectories = false
|
|
panel.resolvesAliases = true
|
|
panel.showsHiddenFiles = true
|
|
if let initialURL = initialDirectoryURL(for: suggestedPath) {
|
|
panel.directoryURL = initialURL
|
|
}
|
|
return panel
|
|
}
|
|
|
|
private func authorizationPayload(for selectedURL: URL) throws -> [String: Any] {
|
|
let resolvedURL = selectedURL.standardizedFileURL
|
|
let bookmarkData = try resolvedURL.bookmarkData(
|
|
options: [.withSecurityScope],
|
|
includingResourceValuesForKeys: nil,
|
|
relativeTo: nil
|
|
)
|
|
return [
|
|
"path": resolvedURL.path,
|
|
"bookmark": bookmarkData.base64EncodedString(),
|
|
]
|
|
}
|
|
|
|
private func startDirectoryAccess(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
let arguments = call.arguments as? [String: Any]
|
|
let bookmark = (arguments?["bookmark"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !bookmark.isEmpty, let bookmarkData = Data(base64Encoded: bookmark) else {
|
|
result(
|
|
FlutterError(
|
|
code: "invalid_bookmark",
|
|
message: "Missing directory bookmark.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
do {
|
|
var isStale = false
|
|
let url = try URL(
|
|
resolvingBookmarkData: bookmarkData,
|
|
options: [.withSecurityScope],
|
|
relativeTo: nil,
|
|
bookmarkDataIsStale: &isStale
|
|
)
|
|
guard url.startAccessingSecurityScopedResource() else {
|
|
result(
|
|
FlutterError(
|
|
code: "directory_access_denied",
|
|
message: "Failed to start security-scoped access.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
let accessId = UUID().uuidString
|
|
directoryAccessSessions[accessId] = url
|
|
var payload: [String: Any] = [
|
|
"accessId": accessId,
|
|
"path": url.standardizedFileURL.path,
|
|
]
|
|
if isStale,
|
|
let refreshedBookmark = try? url.bookmarkData(
|
|
options: [.withSecurityScope],
|
|
includingResourceValuesForKeys: nil,
|
|
relativeTo: nil
|
|
) {
|
|
payload["bookmark"] = refreshedBookmark.base64EncodedString()
|
|
}
|
|
result(payload)
|
|
} catch {
|
|
result(
|
|
FlutterError(
|
|
code: "directory_access_failed",
|
|
message: error.localizedDescription,
|
|
details: nil
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func stopDirectoryAccess(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
let arguments = call.arguments as? [String: Any]
|
|
let accessId = (arguments?["accessId"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !accessId.isEmpty else {
|
|
result(nil)
|
|
return
|
|
}
|
|
if let url = directoryAccessSessions.removeValue(forKey: accessId) {
|
|
url.stopAccessingSecurityScopedResource()
|
|
}
|
|
result(nil)
|
|
}
|
|
|
|
private func initialDirectoryURL(for suggestedPath: String) -> URL? {
|
|
let trimmed = suggestedPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed.isEmpty {
|
|
return URL(fileURLWithPath: resolveUserHomeDirectoryPath(), isDirectory: true)
|
|
}
|
|
|
|
var candidate = URL(fileURLWithPath: expandUserPath(trimmed))
|
|
var isDirectory: ObjCBool = false
|
|
while true {
|
|
if FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory) {
|
|
return isDirectory.boolValue ? candidate.deletingLastPathComponent() : candidate.deletingLastPathComponent()
|
|
}
|
|
let parent = candidate.deletingLastPathComponent()
|
|
if parent.path == candidate.path || parent.path.isEmpty {
|
|
break
|
|
}
|
|
candidate = parent
|
|
}
|
|
return URL(fileURLWithPath: resolveUserHomeDirectoryPath(), isDirectory: true)
|
|
}
|
|
|
|
private func expandUserPath(_ path: String) -> String {
|
|
guard path.hasPrefix("~/") else {
|
|
return path
|
|
}
|
|
let relative = String(path.dropFirst(2))
|
|
return (resolveUserHomeDirectoryPath() as NSString).appendingPathComponent(relative)
|
|
}
|
|
|
|
private func resolveUserHomeDirectoryPath() -> String {
|
|
if let directoryPointer = getpwuid(getuid())?.pointee.pw_dir {
|
|
return String(cString: directoryPointer)
|
|
}
|
|
return FileManager.default.homeDirectoryForCurrentUser.path
|
|
}
|
|
}
|