xworkmate-app/macos/Runner/AppDelegate.swift
2026-04-02 10:28:05 +08:00

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
}
}