import AppKit import Foundation import MacToolsPluginKit @MainActor final class LaunchControlController: ObservableObject { @Published private(set) var snapshot = LaunchControlSnapshot() var onStateChange: (() -> Void)? private let scanner: LaunchControlScanner private let runner: any LaunchControlCommandRunning private let favoritesStore: LaunchControlFavoritesStore private let notesStore: LaunchControlNotesStore private let localization: PluginLocalization private var refreshTask: Task? init( scanner: LaunchControlScanner? = nil, runner: any LaunchControlCommandRunning = ProcessLaunchControlCommandRunner(), favoritesStore: LaunchControlFavoritesStore? = nil, notesStore: LaunchControlNotesStore? = nil, context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "launch-control"), localization: PluginLocalization = PluginLocalization(bundle: .main) ) { self.runner = runner self.favoritesStore = favoritesStore ?? LaunchControlFavoritesStore(context: context) self.notesStore = notesStore ?? LaunchControlNotesStore(context: context) self.localization = localization self.scanner = scanner ?? LaunchControlScanner(runner: runner, localization: localization) } deinit { refreshTask?.cancel() } func cancelRefresh() { refreshTask?.cancel() refreshTask = nil guard snapshot.isRefreshing else { return } snapshot.isRefreshing = false snapshot.currentScanTarget = nil appendScanLog(localization.string("controller.scanLog.stopped", defaultValue: "扫描已停止")) onStateChange?() } func refresh() { guard !snapshot.isRefreshing else { return } snapshot.isRefreshing = true snapshot.items = [] snapshot.selectedItemID = nil snapshot.errorMessage = nil snapshot.operationMessage = nil snapshot.scanLogEntries = [ localization.string("controller.scanLog.started", defaultValue: "开始扫描 LaunchAgent / LaunchDaemon") ] snapshot.currentScanTarget = nil onStateChange?() let progressStream = AsyncStream.makeStream( bufferingPolicy: .bufferingNewest(120) ) let progressTask = Task { @MainActor [weak self] in for await event in progressStream.stream { self?.handleScanEvent(event) } } refreshTask = Task { [weak self, scanner, progressStream] in let result = await Task.detached(priority: .userInitiated) { scanner.scan { event in progressStream.continuation.yield(event) } }.value progressStream.continuation.finish() await progressTask.value guard let self, !Task.isCancelled else { return } self.apply(result: result) } } func selectItem(id: String?) { snapshot.selectedItemID = id onStateChange?() } func openInFinder(_ item: LaunchControlItem) { NSWorkspace.shared.activateFileViewerSelecting([item.plistURL]) } func setFavorite(_ isFavorite: Bool, for item: LaunchControlItem) { favoritesStore.setFavorite(isFavorite, for: item.id) refreshPersistedState(for: item) snapshot.operationMessage = isFavorite ? localization.format("controller.favorite.added", defaultValue: "已关注 %@", item.label) : localization.format("controller.favorite.removed", defaultValue: "已取消关注 %@", item.label) onStateChange?() } func setNote(_ note: String, for item: LaunchControlItem) { notesStore.setNote(note, for: item.id) refreshPersistedState(for: item) let isCleared = note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty snapshot.operationMessage = isCleared ? localization.format("controller.note.cleared", defaultValue: "已清除备注:%@", item.label) : localization.format("controller.note.saved", defaultValue: "已保存备注:%@", item.label) onStateChange?() } func bootstrap(_ item: LaunchControlItem) { performManagedAction( item: item, title: actionTitle(.bootstrap), arguments: ["bootstrap", item.launchctlDomain, item.plistURL.path] ) } func bootout(_ item: LaunchControlItem) { performManagedAction( item: item, title: actionTitle(.bootout), arguments: ["bootout", item.launchctlDomain, item.plistURL.path] ) } func enable(_ item: LaunchControlItem) { performManagedAction( item: item, title: actionTitle(.enable), arguments: ["enable", "\(item.launchctlDomain)/\(item.label)"] ) } func disable(_ item: LaunchControlItem) { performManagedAction( item: item, title: actionTitle(.disable), arguments: ["disable", "\(item.launchctlDomain)/\(item.label)"] ) } func start(_ item: LaunchControlItem) { performManagedAction( item: item, title: actionTitle(.start), arguments: ["kickstart", "\(item.launchctlDomain)/\(item.label)"] ) } func stop(_ item: LaunchControlItem) { performManagedAction( item: item, title: actionTitle(.stop), arguments: ["kill", "TERM", "\(item.launchctlDomain)/\(item.label)"] ) } func restart(_ item: LaunchControlItem) { performManagedAction( item: item, title: actionTitle(.restart), arguments: ["kickstart", "-k", "\(item.launchctlDomain)/\(item.label)"] ) } private func apply(result: LaunchControlScanResult) { let selectedID = snapshot.selectedItemID snapshot.items = sortedItems(result.items.map(applyingPersistedState)) snapshot.selectedItemID = result.items.contains(where: { $0.id == selectedID }) ? selectedID : result.items.first?.id snapshot.isRefreshing = false snapshot.lastRefreshDate = Date() snapshot.errorMessage = result.warnings.first snapshot.currentScanTarget = nil appendScanLog(localization.format("controller.scanLog.completed", defaultValue: "扫描完成:%d 项", result.items.count)) onStateChange?() } private func handleScanEvent(_ event: LaunchControlScanEvent) { switch event { case let .directory(path): snapshot.currentScanTarget = path appendScanLog(localization.format("controller.scanLog.directory", defaultValue: "目录:%@", path)) case let .file(path): snapshot.currentScanTarget = path appendScanLog(localization.format( "controller.scanLog.file", defaultValue: "读取:%@", URL(fileURLWithPath: path).lastPathComponent )) case let .found(item): upsertScannedItem(item) case let .message(message): appendScanLog(message) } onStateChange?() } private func upsertScannedItem(_ item: LaunchControlItem) { let item = applyingPersistedState(item) if let index = snapshot.items.firstIndex(where: { $0.id == item.id }) { snapshot.items[index] = item } else { snapshot.items.append(item) } snapshot.items = sortedItems(snapshot.items) if snapshot.selectedItemID == nil { snapshot.selectedItemID = snapshot.items.first?.id } } private func refreshPersistedState(for item: LaunchControlItem) { guard let index = snapshot.items.firstIndex(where: { $0.id == item.id }) else { return } snapshot.items[index] = applyingPersistedState(snapshot.items[index]) snapshot.items = sortedItems(snapshot.items) } private func applyingPersistedState(_ item: LaunchControlItem) -> LaunchControlItem { var updated = item updated.isFavorite = favoritesStore.isFavorite(item.id) updated.note = notesStore.note(for: item.id) return updated } private func sortedItems(_ items: [LaunchControlItem]) -> [LaunchControlItem] { items.sorted { lhs, rhs in if lhs.isFavorite != rhs.isFavorite { return lhs.isFavorite } if lhs.origin != rhs.origin { return lhs.origin.rawValue < rhs.origin.rawValue } if lhs.scope != rhs.scope { return lhs.scope.rawValue < rhs.scope.rawValue } return lhs.label.localizedCaseInsensitiveCompare(rhs.label) == .orderedAscending } } private func appendScanLog(_ message: String) { snapshot.scanLogEntries.append(message) if snapshot.scanLogEntries.count > 80 { snapshot.scanLogEntries.removeFirst(snapshot.scanLogEntries.count - 80) } } private func performManagedAction( item: LaunchControlItem, title: String, arguments: [String] ) { guard item.canManage else { snapshot.operationMessage = localization.string( "controller.operation.readOnly", defaultValue: "系统或全局启动项默认只读,避免误操作。" ) onStateChange?() return } snapshot.operationMessage = localization.format( "controller.operation.running", defaultValue: "%@ %@...", title, item.label ) snapshot.errorMessage = nil onStateChange?() Task { [weak self, runner] in let result: Result = await Task.detached(priority: .userInitiated) { do { return .success(try runner.runLaunchctl(arguments: arguments)) } catch { return .failure(error) } }.value guard let self else { return } switch result { case let .success(commandResult) where commandResult.exitCode == 0: self.snapshot.operationMessage = self.localization.format( "controller.operation.completed", defaultValue: "%@完成", title ) self.refresh() case let .success(commandResult): let message = commandResult.combinedOutput self.snapshot.operationMessage = self.localization.format( "controller.operation.failed", defaultValue: "%@失败", title ) self.snapshot.errorMessage = message.isEmpty ? self.localization.format( "controller.operation.exitCode", defaultValue: "launchctl 返回退出码 %d", commandResult.exitCode ) : message self.onStateChange?() case let .failure(error): self.snapshot.operationMessage = self.localization.format( "controller.operation.failed", defaultValue: "%@失败", title ) self.snapshot.errorMessage = error.localizedDescription self.onStateChange?() } } } private func actionTitle(_ action: LaunchControlManagedAction) -> String { action.title(localization: localization) } }