import Combine import Foundation import SwiftUI import MacToolsPluginKit enum FeatureSettingsPane: Hashable { case installed case marketplace case configuration(String) } struct PluginAutomaticUpdateStatus: Equatable { enum Phase: Equatable { case idle case checking case updating case completed case failed } let phase: Phase let pluginIDs: [String] let message: String? static let idle = PluginAutomaticUpdateStatus( phase: .idle, pluginIDs: [], message: nil ) var isActive: Bool { phase == .checking || phase == .updating } var isVisible: Bool { phase != .idle } func isUpdatingPlugin(id: String) -> Bool { phase == .updating && pluginIDs.contains(id) } var title: String { switch phase { case .idle: return "" case .checking: return AppL10n.plugins("plugin.autoUpdate.title.checking", defaultValue: "正在检查插件更新") case .updating: return AppL10n.plugins("plugin.autoUpdate.title.updating", defaultValue: "正在更新插件") case .completed: return AppL10n.plugins("plugin.autoUpdate.title.completed", defaultValue: "插件已更新") case .failed: return AppL10n.plugins("plugin.autoUpdate.title.failed", defaultValue: "插件自动更新失败") } } var detailText: String { if let message { return message } switch phase { case .idle: return "" case .checking: return AppL10n.plugins("plugin.autoUpdate.detail.checking", defaultValue: "新版首次启动时会先检查已安装插件。") case .updating: return AppL10n.pluginsFormat( "plugin.autoUpdate.detail.updatingFormat", defaultValue: "正在更新 %d 个已安装插件,完成后会继续加载。", pluginIDs.count ) case .completed: return pluginIDs.isEmpty ? AppL10n.plugins("plugin.autoUpdate.detail.noUpdates", defaultValue: "已是最新版本。") : AppL10n.pluginsFormat("plugin.autoUpdate.detail.completedFormat", defaultValue: "已更新 %d 个插件。", pluginIDs.count) case .failed: return AppL10n.plugins("plugin.autoUpdate.detail.failed", defaultValue: "稍后可在此页面重试。") } } } struct PluginAutomaticUpdateVersionStore { private enum DefaultsKey { static let lastCheckedAppVersion = "plugins.dynamic.lastAutomaticUpdateAppVersion" } var userDefaults: UserDefaults = .standard func needsAutomaticUpdateCheck(currentAppVersion: String) -> Bool { guard !currentAppVersion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } return userDefaults.string(forKey: DefaultsKey.lastCheckedAppVersion) != currentAppVersion } func markAutomaticUpdateChecked(currentAppVersion: String) { userDefaults.set(currentAppVersion, forKey: DefaultsKey.lastCheckedAppVersion) } } @MainActor final class PluginHost: ObservableObject { private struct PluginDescriptor { let metadata: PluginMetadata let plugin: any MacToolsPlugin let capabilities: PluginPackageManifest.Capabilities? var hasPrimaryPanel: Bool { capabilities?.primaryPanel ?? true } var hasComponentPanel: Bool { capabilities?.componentPanel ?? true } var hasConfiguration: Bool { capabilities?.configuration ?? true } } private struct ShortcutDescriptor { let itemID: String let pluginID: String let pluginTitle: String let definition: PluginShortcutDefinition let plugin: any MacToolsPlugin } private let builtInPlugins: [any MacToolsPlugin] private let shortcutStore: ShortcutStore private let pluginDisplayPreferencesStore: PluginDisplayPreferencesStore private let globalShortcutManager: GlobalShortcutManager private let displayConfigurationObserver: (any DisplayConfigurationObserving)? private let accessibilityPermissionObserver: (any AccessibilityPermissionObserving)? private let displayTopologyRefreshDelay: Duration private let pluginStateChangeRebuildDelay: Duration let dynamicPluginManager: DynamicPluginManager? private let pluginCatalogManager: PluginCatalogManager? private var dynamicPlugins: [any MacToolsPlugin] = [] private var dynamicPluginCapabilitiesByID: [String: PluginPackageManifest.Capabilities] = [:] private var dynamicPluginCategoriesByID: [String: String?] = [:] private var dynamicPluginReleaseChannelsByID: [String: String?] = [:] private var dynamicPluginManifestsByID: [String: PluginPackageManifest] = [:] private var shortcutErrors: [String: String] = [:] private var componentViewCache: [String: PluginComponentViewItem] = [:] private var configurationViewCache: [String: PluginConfigurationViewItem] = [:] private var visiblePanelSurfaces: Set = [] private var visiblePanelSurfacePluginIDs: [PluginPanelSurface: Set] = [:] private var isolatedPluginFailures: [String: String] = [:] private var isHandlingPluginAction = false private var didLoadDynamicPlugins = false private var displayTopologyRefreshTask: Task? private var pluginStateChangeRebuildTask: Task? private var runtimeLocaleCancellable: AnyCancellable? private var dirtyPluginIDs: Set = [] private var cachedPanelStatesByID: [String: PluginPanelState] = [:] private var cachedPrimaryPanelIndicatorsByID: [String: PluginPrimaryPanelIndicator] = [:] private var evaluatedPrimaryPanelIndicatorPluginIDs: Set = [] private var cachedComponentStatesByID: [String: PluginComponentState] = [:] @Published private(set) var panelItems: [PluginPanelItem] = [] @Published private(set) var primaryPanelIndicatorsByID: [String: PluginPrimaryPanelIndicator] = [:] @Published private(set) var componentItems: [PluginComponentItem] = [] @Published private(set) var featureManagementItems: [PluginFeatureManagementItem] = [] @Published private(set) var pluginConfigurationItems: [PluginConfigurationItem] = [] @Published private(set) var permissionCards: [PluginPermissionCard] = [] @Published private(set) var settingsCards: [PluginSettingsCard] = [] @Published private(set) var shortcutItems: [ShortcutSettingsItem] = [] @Published private(set) var pluginManagementItems: [PluginManagementItem] = [] @Published private(set) var pluginCatalogStatus: PluginCatalogStatus = .unavailable @Published private(set) var automaticPluginUpdateStatus: PluginAutomaticUpdateStatus = .idle @Published private(set) var hasActivePlugin = false @Published private(set) var settingsPresentationRequestCount = 0 @Published private(set) var localizationRevision = 0 @Published var selectedSettingsDestination: SettingsDestination = .general @Published var selectedFeatureSettingsPane: FeatureSettingsPane = .installed /// Injected by `MenuBarStatusItemController`; returns the status-item button frame in screen coordinates. var statusItemButtonFrameProvider: (() -> NSRect?)? = nil { didSet { configureHostStatusItemCallbacks(for: activePlugins) } } var resetStatusItemPosition: (() -> Void)? = nil { didSet { configureHostStatusItemCallbacks(for: activePlugins) } } convenience init(loadDynamicPluginsOnInit: Bool = true) { let dynamicPluginManager = DynamicPluginManager() let pluginCatalogManager = PluginCatalogManager.live(dynamicPluginManager: dynamicPluginManager) self.init( plugins: BuiltInPluginRegistry().makePlugins(), dynamicPluginManager: dynamicPluginManager, pluginCatalogManager: pluginCatalogManager, shortcutStore: ShortcutStore(), pluginDisplayPreferencesStore: PluginDisplayPreferencesStore(), globalShortcutManager: GlobalShortcutManager(), displayConfigurationObserver: SystemDisplayConfigurationObserver(), accessibilityPermissionObserver: AccessibilityPermissionObserver(), loadDynamicPluginsOnInit: loadDynamicPluginsOnInit ) } init( plugins: [any MacToolsPlugin], dynamicPluginManager: DynamicPluginManager? = nil, pluginCatalogManager: PluginCatalogManager? = nil, shortcutStore: ShortcutStore, pluginDisplayPreferencesStore: PluginDisplayPreferencesStore, globalShortcutManager: GlobalShortcutManager, displayConfigurationObserver: (any DisplayConfigurationObserving)? = nil, accessibilityPermissionObserver: (any AccessibilityPermissionObserving)? = nil, displayTopologyRefreshDelay: Duration = .milliseconds(180), pluginStateChangeRebuildDelay: Duration = .milliseconds(80), loadDynamicPluginsOnInit: Bool = true ) { self.builtInPlugins = plugins.sorted { if $0.metadata.order == $1.metadata.order { return $0.metadata.title.localizedCompare($1.metadata.title) == .orderedAscending } return $0.metadata.order < $1.metadata.order } self.shortcutStore = shortcutStore self.pluginDisplayPreferencesStore = pluginDisplayPreferencesStore self.globalShortcutManager = globalShortcutManager self.displayConfigurationObserver = displayConfigurationObserver self.accessibilityPermissionObserver = accessibilityPermissionObserver self.displayTopologyRefreshDelay = displayTopologyRefreshDelay self.pluginStateChangeRebuildDelay = pluginStateChangeRebuildDelay self.dynamicPluginManager = dynamicPluginManager self.pluginCatalogManager = pluginCatalogManager configureCallbacks(for: self.builtInPlugins) if let dynamicPluginManager { if loadDynamicPluginsOnInit { self.dynamicPlugins = dynamicPluginManager.loadInstalledPlugins() self.didLoadDynamicPlugins = true } else { dynamicPluginManager.prepareInstalledPluginsWithoutLoading() } self.dynamicPluginCapabilitiesByID = dynamicPluginManager.installedCapabilitiesByID() self.dynamicPluginCategoriesByID = dynamicPluginManager.installedCategoriesByID() self.dynamicPluginReleaseChannelsByID = dynamicPluginManager.installedReleaseChannelsByID() self.dynamicPluginManifestsByID = dynamicPluginManager.installedManifestsByID() self.pluginManagementItems = dynamicPluginManager.pluginManagementItems self.pluginCatalogStatus = pluginCatalogManager?.status ?? .unavailable configureCallbacks(for: self.dynamicPlugins) // Pause any dynamic plugin that was hidden before this launch. pauseHiddenDynamicPlugins() dynamicPluginManager.onPluginsChanged = { [weak self] plugins in self?.replaceDynamicPlugins(plugins) } } self.globalShortcutManager.onShortcutTriggered = { [weak self] shortcutID in self?.handleShortcutTrigger(shortcutID: shortcutID) } self.globalShortcutManager.onShortcutReleased = { [weak self] shortcutID in self?.handleShortcutRelease(shortcutID: shortcutID) } self.displayConfigurationObserver?.onConfigurationChange = { [weak self] in self?.scheduleDisplayTopologyRefresh() } self.accessibilityPermissionObserver?.onPermissionChange = { [weak self] in self?.refreshAccessibilityPermissionNow() } runtimeLocaleCancellable = PluginRuntimeLocalization.source.$revision .dropFirst() .sink { [weak self] _ in Task { @MainActor [weak self] in self?.refreshLocalization() } } pauseHiddenBuiltInVisibilityLifecyclePlugins() refreshAll() } isolated deinit { displayTopologyRefreshTask?.cancel() pluginStateChangeRebuildTask?.cancel() runtimeLocaleCancellable?.cancel() } func deactivateAllPlugins(reason: PluginDeactivationReason = .hostShutdown) { pluginStateChangeRebuildTask?.cancel() pluginStateChangeRebuildTask = nil hideAllPanelSurfaces() for plugin in activePlugins { guardPluginCall(plugin, operation: "deactivate plugin") { plugin.deactivate(reason: reason) } } } func refreshAll() { handlePluginAction(rebuildAfterAction: false) { for plugin in activePlugins { guardPluginCall(plugin, operation: "refresh") { plugin.refresh() } } } syncGlobalShortcuts() rebuildDerivedState() } /// Rebuild language-dependent host data without refreshing or reactivating /// plugins, which keeps active plugin sessions and external side effects intact. func refreshLocalization() { for plugin in activePlugins { guard let localizationRefreshing = plugin as? any PluginRuntimeLocalizationRefreshing else { continue } guardPluginCall(plugin, operation: "refresh localization") { localizationRefreshing.refreshLocalization() } } componentViewCache.removeAll() configurationViewCache.removeAll() cachedPanelStatesByID.removeAll() cachedComponentStatesByID.removeAll() syncPluginManagementState() localizationRevision &+= 1 rebuildDerivedState() } func refreshDisplayTopology() { displayTopologyRefreshTask?.cancel() refreshDisplayTopologyNow() } func isSwitchOn(for pluginID: String) -> Bool { panelItems.first(where: { $0.id == pluginID })?.isOn ?? false } func setSwitchValue(_ isOn: Bool, for pluginID: String) { guard let plugin = corePlugin(for: pluginID), let primaryPanel = plugin.primaryPanel else { return } handlePluginAction { guardPluginCall(plugin, operation: "set switch") { primaryPanel.handleAction(.setSwitch(isOn)) } } } func setDisclosureExpanded(_ isExpanded: Bool, for pluginID: String) { guard let plugin = corePlugin(for: pluginID), let primaryPanel = plugin.primaryPanel else { return } handlePluginAction { guardPluginCall(plugin, operation: "set disclosure") { primaryPanel.handleAction(.setDisclosureExpanded(isExpanded)) } } } func setPanelSelectionValue( _ optionID: String, controlID: String, for pluginID: String ) { guard let plugin = corePlugin(for: pluginID), let primaryPanel = plugin.primaryPanel else { return } handlePluginAction { guardPluginCall(plugin, operation: "set selection") { primaryPanel.handleAction(.setSelection(controlID: controlID, optionID: optionID)) } } } func setPanelNavigationSelectionValue( _ optionID: String, controlID: String, for pluginID: String ) { guard let plugin = corePlugin(for: pluginID), let primaryPanel = plugin.primaryPanel else { return } handlePluginAction { guardPluginCall(plugin, operation: "set navigation selection") { primaryPanel.handleAction( .setNavigationSelection(controlID: controlID, optionID: optionID) ) } } } func clearPanelNavigationSelection( controlID: String, for pluginID: String ) { guard let plugin = corePlugin(for: pluginID), let primaryPanel = plugin.primaryPanel else { return } handlePluginAction { guardPluginCall(plugin, operation: "clear navigation selection") { primaryPanel.handleAction(.clearNavigationSelection(controlID: controlID)) } } } func setPanelDateValue( _ date: Date, controlID: String, for pluginID: String ) { guard let plugin = corePlugin(for: pluginID), let primaryPanel = plugin.primaryPanel else { return } handlePluginAction { guardPluginCall(plugin, operation: "set date") { primaryPanel.handleAction(.setDate(controlID: controlID, value: date)) } } } func setPanelSliderValue( _ value: Double, controlID: String, for pluginID: String, phase: PluginPanelAction.SliderPhase ) { guard let plugin = corePlugin(for: pluginID), let primaryPanel = plugin.primaryPanel else { return } let isolatedPluginCountAtStart = isolatedPluginFailures.count handlePluginAction(rebuildAfterAction: phase == .ended) { guardPluginCall(plugin, operation: "set slider") { primaryPanel.handleAction(.setSlider(controlID: controlID, value: value, phase: phase)) } } if phase == .changed, isolatedPluginFailures.count > isolatedPluginCountAtStart { rebuildDerivedState() } } func invokePanelAction(controlID: String, for pluginID: String) { guard let plugin = corePlugin(for: pluginID), let primaryPanel = plugin.primaryPanel else { return } handlePluginAction { guardPluginCall(plugin, operation: "invoke panel action") { primaryPanel.handleAction(.invokeAction(controlID: controlID)) } } } func performSettingsAction(pluginID: String, actionID: String) { guard let plugin = corePlugin(for: pluginID) else { return } handlePluginAction { guardPluginCall(plugin, operation: "settings action") { plugin.handleSettingsAction(id: actionID) } } } func performPermissionAction(pluginID: String, permissionID: String) { guard let plugin = corePlugin(for: pluginID) else { return } handlePluginAction { guardPluginCall(plugin, operation: "permission action") { plugin.handlePermissionAction(id: permissionID) } } } func setShortcutBinding(_ binding: ShortcutBinding, for shortcutID: String) { guard let descriptor = shortcutDescriptor(for: shortcutID) else { return } applyShortcutCustomization(.custom(binding), for: descriptor) } func setShortcutBindingAndReturnError(_ binding: ShortcutBinding, for shortcutID: String) -> String? { guard let descriptor = shortcutDescriptor(for: shortcutID) else { return AppL10n.plugins("plugin.shortcut.unavailable", defaultValue: "快捷键不可用。") } return applyShortcutCustomization(.custom(binding), for: descriptor) } func clearShortcut(for shortcutID: String) { guard let descriptor = shortcutDescriptor(for: shortcutID) else { return } applyShortcutCustomization(.cleared, for: descriptor) } func resetShortcut(for shortcutID: String) { guard let descriptor = shortcutDescriptor(for: shortcutID) else { return } applyShortcutCustomization(.inheritDefault, for: descriptor) } func presentPluginConfiguration(pluginID: String) { rebuildDerivedState() guard pluginConfigurationItems.contains(where: { $0.id == pluginID }) else { return } selectFeatureSettingsPane(.configuration(pluginID)) selectedSettingsDestination = .pluginConfiguration settingsPresentationRequestCount += 1 } func presentPluginMarketplace() { selectedFeatureSettingsPane = .marketplace selectedSettingsDestination = .pluginConfiguration settingsPresentationRequestCount += 1 } func presentInstalledPlugins() { selectedFeatureSettingsPane = .installed selectedSettingsDestination = .pluginConfiguration settingsPresentationRequestCount += 1 } func selectFeatureSettingsPane(_ pane: FeatureSettingsPane) { switch pane { case .installed, .marketplace: selectedFeatureSettingsPane = pane case let .configuration(pluginID): guard pluginConfigurationItems.contains(where: { $0.id == pluginID }) else { return } selectedFeatureSettingsPane = pane } } func clearShortcutError(for shortcutID: String) { guard shortcutErrors.removeValue(forKey: shortcutID) != nil else { return } rebuildDerivedState() } func setFeatureVisibility(_ isVisible: Bool, for pluginID: String) { if !isVisible { removePluginFromVisiblePanelSurfaces(pluginID, notify: true) } pluginDisplayPreferencesStore.setVisibility( isVisible, for: pluginID, defaultPluginIDs: defaultPluginIDs ) // For dynamic plugins, pause/resume side effects with visibility. // The plugin stays loaded so it remains visible in the installed list. let isDynamicPlugin = dynamicPlugins.contains(where: { $0.metadata.id == pluginID }) if isDynamicPlugin { if isVisible { dynamicPluginManager?.resumePlugin(pluginID) } else { dynamicPluginManager?.pausePlugin(pluginID) } } else if let plugin = activePlugins.first(where: { $0.metadata.id == pluginID }) { notifyFeatureVisibilityLifecycle(isVisible, for: plugin) } rebuildDerivedState() } func canMoveFeatureManagementItem(id pluginID: String, by offset: Int) -> Bool { let orderedPluginIDs = orderedPluginIDs() guard let currentIndex = orderedPluginIDs.firstIndex(of: pluginID) else { return false } let targetIndex = currentIndex + offset return orderedPluginIDs.indices.contains(targetIndex) } func moveFeatureManagementItem(id pluginID: String, by offset: Int) { var orderedPluginIDs = orderedPluginIDs() guard let currentIndex = orderedPluginIDs.firstIndex(of: pluginID) else { return } let targetIndex = currentIndex + offset guard orderedPluginIDs.indices.contains(targetIndex) else { return } let movedPluginID = orderedPluginIDs.remove(at: currentIndex) orderedPluginIDs.insert(movedPluginID, at: targetIndex) pluginDisplayPreferencesStore.setOrderedPluginIDs( orderedPluginIDs, defaultPluginIDs: defaultPluginIDs ) rebuildDerivedState() } func moveFeatureManagementItem(id pluginID: String, toOffset targetOffset: Int) { var orderedPluginIDs = orderedPluginIDs() guard let currentIndex = orderedPluginIDs.firstIndex(of: pluginID) else { return } let clampedOffset = min(max(targetOffset, 0), orderedPluginIDs.count) guard currentIndex != clampedOffset, currentIndex + 1 != clampedOffset else { return } orderedPluginIDs.move( fromOffsets: IndexSet(integer: currentIndex), toOffset: clampedOffset ) pluginDisplayPreferencesStore.setOrderedPluginIDs( orderedPluginIDs, defaultPluginIDs: defaultPluginIDs ) rebuildDerivedState() } func moveFeatureManagementItems(fromOffsets: IndexSet, toOffset: Int) { var orderedPluginIDs = orderedPluginIDs() orderedPluginIDs.move(fromOffsets: fromOffsets, toOffset: toOffset) pluginDisplayPreferencesStore.setOrderedPluginIDs( orderedPluginIDs, defaultPluginIDs: defaultPluginIDs ) rebuildDerivedState() } func componentViewItem(for itemID: String, dismiss: @escaping () -> Void) -> PluginComponentViewItem { if let cachedItem = componentViewCache[itemID] { return cachedItem } guard let plugin = corePlugin(for: itemID), let componentPanel = plugin.componentPanel else { let item = PluginComponentViewItem(id: itemID, content: AnyView(EmptyView())) componentViewCache[itemID] = item return item } let context = PluginComponentContext( pluginID: itemID, dismiss: dismiss, isPanelVisible: true ) let content = guardedValue( for: plugin, operation: "make component view", componentPanel.makeView(context: context) ) ?? AnyView(EmptyView()) let item = PluginComponentViewItem( id: itemID, content: AnyView(content.id(localizationRevision)) ) componentViewCache[itemID] = item if isPluginIsolated(plugin) { rebuildDerivedState() } return item } func setPanelSurface(_ surface: PluginPanelSurface, visible isVisible: Bool) { if isVisible { visiblePanelSurfaces.insert(surface) } else { visiblePanelSurfaces.remove(surface) } let visiblePluginIDs = isVisible ? pluginIDs(for: surface) : [] updateVisiblePanelSurface(surface, visiblePluginIDs: visiblePluginIDs) } func isComponentViewCached(for itemID: String) -> Bool { componentViewCache[itemID] != nil } func prewarmComponentViews(dismiss: @escaping () -> Void) { for item in componentItems { _ = componentViewItem(for: item.id, dismiss: dismiss) } } func pluginConfigurationViewItem(for pluginID: String) -> PluginConfigurationViewItem { if let cachedItem = configurationViewCache[pluginID] { return cachedItem } let configurations = orderedPluginDescriptors() .filter { $0.metadata.id == pluginID && $0.hasConfiguration } .compactMap { descriptor -> (any MacToolsPlugin, PluginConfiguration)? in guard let configuration = guardedOptionalValue( for: descriptor.plugin, operation: "read plugin configuration", descriptor.plugin.configuration ) else { return nil } return (descriptor.plugin, configuration) } guard corePlugin(for: pluginID) != nil else { rebuildDerivedState() return PluginConfigurationViewItem(id: pluginID, content: AnyView(EmptyView())) } let context = PluginConfigurationContext(pluginID: pluginID) let content: AnyView switch configurations.count { case 0: content = AnyView(EmptyView()) case 1: content = guardedConfigurationView( for: configurations[0].0, configuration: configurations[0].1, context: context ) default: let views = configurations.map { guardedConfigurationView( for: $0.0, configuration: $0.1, context: context ) } content = AnyView( VStack(alignment: .leading, spacing: 16) { ForEach(Swift.Array(views.enumerated()), id: \.offset) { entry in entry.element } } ) } guard corePlugin(for: pluginID) != nil else { rebuildDerivedState() return PluginConfigurationViewItem(id: pluginID, content: AnyView(EmptyView())) } let item = PluginConfigurationViewItem( id: pluginID, content: AnyView(content.id(localizationRevision)) ) configurationViewCache[pluginID] = item return item } func discardComponentViews() { componentViewCache.removeAll() } func refreshPluginCatalog() async { await pluginCatalogManager?.refreshCatalog() syncPluginManagementState() } func loadDynamicPluginsIfNeeded() { guard !didLoadDynamicPlugins, let dynamicPluginManager else { return } dynamicPlugins = dynamicPluginManager.loadInstalledPlugins() didLoadDynamicPlugins = true configureCallbacks(for: dynamicPlugins) pauseHiddenDynamicPlugins() refreshAll() syncPluginManagementState() } var hasInstalledDynamicPlugins: Bool { guard let dynamicPluginManager else { return false } return !dynamicPluginManager.installedPackageVersionsByID().isEmpty } @discardableResult func automaticUpdateInstalledPluginsBeforeLoading() async -> Bool { guard let pluginCatalogManager else { loadDynamicPluginsIfNeeded() return true } automaticPluginUpdateStatus = PluginAutomaticUpdateStatus( phase: .checking, pluginIDs: [], message: nil ) await pluginCatalogManager.refreshCatalog() syncPluginManagementState() if let errorMessage = pluginCatalogManager.status.errorMessage { automaticPluginUpdateStatus = PluginAutomaticUpdateStatus( phase: .failed, pluginIDs: [], message: errorMessage ) loadDynamicPluginsIfNeeded() return false } let updatePlan = pluginCatalogManager.automaticUpdatePlanForInstalledPlugins() guard !updatePlan.isEmpty else { automaticPluginUpdateStatus = PluginAutomaticUpdateStatus( phase: .completed, pluginIDs: [], message: AppL10n.plugins("plugin.autoUpdate.message.noInstalledUpdates", defaultValue: "已安装插件都是最新版本。") ) loadDynamicPluginsIfNeeded() return true } presentPluginMarketplace() automaticPluginUpdateStatus = PluginAutomaticUpdateStatus( phase: .updating, pluginIDs: updatePlan.updateableInstalledPluginIDs, message: AppL10n.pluginsFormat( "plugin.autoUpdate.message.progressFormat", defaultValue: "已完成 %d/%d", 0, updatePlan.updateableInstalledPluginIDs.count ) ) syncPluginManagementState() do { try await pluginCatalogManager.updateInstalledPluginsToLatestBeforeLoading { [weak self] progress in self?.automaticPluginUpdateStatus = PluginAutomaticUpdateStatus( phase: .updating, pluginIDs: updatePlan.updateableInstalledPluginIDs, message: AppL10n.pluginsFormat( "plugin.autoUpdate.message.progressFormat", defaultValue: "已完成 %d/%d", progress.completedCount, progress.totalCount ) ) } syncPluginManagementState() automaticPluginUpdateStatus = PluginAutomaticUpdateStatus( phase: .completed, pluginIDs: updatePlan.updateableInstalledPluginIDs, message: AppL10n.pluginsFormat( "plugin.autoUpdate.message.updatedInstalledFormat", defaultValue: "已更新 %d 个插件。", updatePlan.updateableInstalledPluginIDs.count ) ) } catch { syncPluginManagementState() automaticPluginUpdateStatus = PluginAutomaticUpdateStatus( phase: .failed, pluginIDs: updatePlan.updateableInstalledPluginIDs, message: error.localizedDescription ) } loadDynamicPluginsIfNeeded() return automaticPluginUpdateStatus.phase == .completed } func installPluginFromCatalog(pluginID: String) async throws { guard let pluginCatalogManager else { return } try await pluginCatalogManager.installPlugin(id: pluginID) syncPluginManagementState() } func updatePluginFromCatalog(pluginID: String) async throws { guard let pluginCatalogManager else { return } try await pluginCatalogManager.updatePlugin(id: pluginID) syncPluginManagementState() } func updateAvailablePluginsFromCatalog( progress: ((PluginCatalogUpdateProgress) -> Void)? = nil ) async throws { guard let pluginCatalogManager else { return } defer { syncPluginManagementState() } try await pluginCatalogManager.updateAvailablePlugins(progress: progress) } func installPluginPackage(from sourceURL: URL) throws { try dynamicPluginManager?.installPluginPackage(from: sourceURL) pluginCatalogManager?.rebuildManagementItems() syncPluginManagementState() } func updatePluginPackage(from sourceURL: URL) throws { try dynamicPluginManager?.updatePluginPackage(from: sourceURL) pluginCatalogManager?.rebuildManagementItems() syncPluginManagementState() } func setDynamicPluginEnabled(_ isEnabled: Bool, pluginID: String) { if isEnabled { isolatedPluginFailures.removeValue(forKey: pluginID) } dynamicPluginManager?.setPluginEnabled(isEnabled, pluginID: pluginID) pluginCatalogManager?.rebuildManagementItems() syncPluginManagementState() } func uninstallDynamicPlugin(pluginID: String, removeData: Bool = false) throws { try dynamicPluginManager?.uninstallPlugin(pluginID: pluginID, removeData: removeData) pluginCatalogManager?.rebuildManagementItems() syncPluginManagementState() } private var plugins: [any MacToolsPlugin] { builtInPlugins + dynamicPlugins } private var activePlugins: [any MacToolsPlugin] { plugins.filter { isolatedPluginFailures[$0.metadata.id] == nil } } private func corePlugin(for pluginID: String) -> (any MacToolsPlugin)? { activePlugins.first(where: { $0.metadata.id == pluginID }) } private func configureCallbacks(for plugins: [any MacToolsPlugin]) { for plugin in plugins { let pluginID = plugin.metadata.id plugin.onStateChange = { [weak self] in self?.rebuildDerivedStateAfterPluginChange(pluginID: pluginID) } plugin.requestPermissionGuidance = { [weak self] permissionID in self?.requestPermissionGuidance(forPluginID: pluginID, permissionID: permissionID) } plugin.shortcutBindingResolver = { [weak self] shortcutDefinitionID in self?.resolvedBinding(forPluginID: pluginID, shortcutDefinitionID: shortcutDefinitionID) } if let anchorable = plugin as? any DropZoneAnchorProviding { anchorable.anchorRectProvider = { [weak self] in self?.statusItemButtonFrameProvider?() } } if let configurationPresenting = plugin as? any PluginConfigurationPresenting { configurationPresenting.requestConfigurationPresentation = { [weak self] in self?.presentPluginConfiguration(pluginID: pluginID) } } configureHostStatusItemCallbacks(for: [plugin]) } } private func configureHostStatusItemCallbacks(for plugins: [any MacToolsPlugin]) { for plugin in plugins { guard let recoverable = plugin as? any MenuBarHostStatusItemRecovering else { continue } recoverable.hostStatusItemFrameProvider = { [weak self] in self?.statusItemButtonFrameProvider?() } recoverable.resetHostStatusItemPosition = { [weak self] in self?.resetStatusItemPosition?() } } } private func replaceDynamicPlugins(_ plugins: [any MacToolsPlugin]) { let previouslyVisibleSurfaces = visiblePanelSurfaces hideAllPanelSurfaces() visiblePanelSurfaces = previouslyVisibleSurfaces discardComponentViews() configurationViewCache.removeAll() syncPluginManagementState() dynamicPlugins = plugins.sorted { if $0.metadata.order == $1.metadata.order { return $0.metadata.title.localizedCompare($1.metadata.title) == .orderedAscending } return $0.metadata.order < $1.metadata.order } configureCallbacks(for: dynamicPlugins) // Re-apply pause for any plugin that is currently hidden. pauseHiddenDynamicPlugins() rebuildDerivedState() syncGlobalShortcuts() } private func pauseHiddenDynamicPlugins() { for plugin in dynamicPlugins where !pluginDisplayPreferencesStore.isVisible( plugin.metadata.id, defaultPluginIDs: defaultPluginIDs ) { dynamicPluginManager?.pausePlugin(plugin.metadata.id) } } private func pauseHiddenBuiltInVisibilityLifecyclePlugins() { for plugin in builtInPlugins where !pluginDisplayPreferencesStore.isVisible( plugin.metadata.id, defaultPluginIDs: defaultPluginIDs ) { notifyFeatureVisibilityLifecycle(false, for: plugin) } } private func notifyFeatureVisibilityLifecycle(_ isVisible: Bool, for plugin: any MacToolsPlugin) { guard let visibilityLifecycle = plugin as? any PluginFeatureVisibilityLifecycleHandling else { return } guardPluginCall(plugin, operation: "feature visibility lifecycle") { visibilityLifecycle.featureVisibilityDidChange(isVisible) } } private func syncPluginManagementState() { dynamicPluginCapabilitiesByID = dynamicPluginManager?.installedCapabilitiesByID() ?? [:] dynamicPluginCategoriesByID = dynamicPluginManager?.installedCategoriesByID() ?? [:] dynamicPluginReleaseChannelsByID = dynamicPluginManager?.installedReleaseChannelsByID() ?? [:] dynamicPluginManifestsByID = dynamicPluginManager?.installedManifestsByID() ?? [:] pluginManagementItems = dynamicPluginManager?.pluginManagementItems ?? [] pluginCatalogStatus = pluginCatalogManager?.status ?? .unavailable } private func rebuildDerivedState(dirtyPluginIDs: Set? = nil) { if dirtyPluginIDs == nil { cancelScheduledPluginStateRebuild() } let isolatedPluginCountAtStart = isolatedPluginFailures.count let orderedDescriptors = orderedPluginDescriptors() let descriptorIDs = Set(orderedDescriptors.map(\.metadata.id)) var panelStatesByID = dirtyPluginIDs == nil ? [:] : cachedPanelStatesByID.filter { descriptorIDs.contains($0.key) } var primaryPanelIndicatorsByID = dirtyPluginIDs == nil ? [:] : cachedPrimaryPanelIndicatorsByID.filter { descriptorIDs.contains($0.key) } var evaluatedIndicatorPluginIDs = dirtyPluginIDs == nil ? Set() : evaluatedPrimaryPanelIndicatorPluginIDs.intersection(descriptorIDs) var componentStatesByID = dirtyPluginIDs == nil ? [:] : cachedComponentStatesByID.filter { descriptorIDs.contains($0.key) } for descriptor in orderedDescriptors { let pluginID = descriptor.metadata.id let shouldReadPlugin = dirtyPluginIDs?.contains(pluginID) ?? true let plugin = descriptor.plugin if descriptor.hasPrimaryPanel, !isPluginIsolated(plugin), let primaryPanel = plugin.primaryPanel { if shouldReadPlugin || panelStatesByID[pluginID] == nil { if let state = guardedValue( for: plugin, operation: "read primary panel state", primaryPanel.primaryPanelState ) { panelStatesByID[pluginID] = state } else { panelStatesByID.removeValue(forKey: pluginID) } } if let indicatorProvider = plugin as? any PluginPrimaryPanelIndicatorProviding { if shouldReadPlugin || !evaluatedIndicatorPluginIDs.contains(pluginID) { evaluatedIndicatorPluginIDs.insert(pluginID) if let indicator = guardedOptionalValue( for: plugin, operation: "read primary panel indicator", indicatorProvider.primaryPanelIndicator ) { primaryPanelIndicatorsByID[pluginID] = indicator } else { primaryPanelIndicatorsByID.removeValue(forKey: pluginID) } } } else { evaluatedIndicatorPluginIDs.remove(pluginID) primaryPanelIndicatorsByID.removeValue(forKey: pluginID) } } else { panelStatesByID.removeValue(forKey: pluginID) evaluatedIndicatorPluginIDs.remove(pluginID) primaryPanelIndicatorsByID.removeValue(forKey: pluginID) } if descriptor.hasComponentPanel, !isPluginIsolated(plugin), let componentPanel = plugin.componentPanel { if shouldReadPlugin || componentStatesByID[pluginID] == nil { if let state = guardedValue( for: plugin, operation: "read component panel state", componentPanel.componentPanelState ) { componentStatesByID[pluginID] = state } else { componentStatesByID.removeValue(forKey: pluginID) } } } else { componentStatesByID.removeValue(forKey: pluginID) } } cachedPanelStatesByID = panelStatesByID cachedPrimaryPanelIndicatorsByID = primaryPanelIndicatorsByID evaluatedPrimaryPanelIndicatorPluginIDs = evaluatedIndicatorPluginIDs cachedComponentStatesByID = componentStatesByID self.primaryPanelIndicatorsByID = primaryPanelIndicatorsByID panelItems = orderedDescriptors.compactMap { descriptor in guard descriptor.hasPrimaryPanel else { return nil } let plugin = descriptor.plugin let metadata = descriptor.metadata guard let primaryPanel = plugin.primaryPanel, let state = panelStatesByID[metadata.id] else { return nil } guard state.isVisible, pluginDisplayPreferencesStore.isVisible( metadata.id, defaultPluginIDs: defaultPluginIDs ) else { return nil } let description = localizedDescription( state.errorMessage ?? state.subtitle, pluginMetadata: plugin.metadata, localizedMetadata: metadata ) let descriptor = primaryPanel.primaryPanelDescriptor return PluginPanelItem( id: metadata.id, title: metadata.title, iconName: metadata.iconName, iconTint: metadata.iconTint, controlStyle: descriptor.controlStyle, menuActionBehavior: descriptor.menuActionBehavior, description: description.isEmpty ? metadata.defaultDescription : description, helpText: description.isEmpty ? metadata.defaultDescription : description, descriptionTone: state.errorMessage == nil ? .secondary : .error, isOn: state.isOn, isExpanded: state.isExpanded, isEnabled: state.isEnabled, detail: state.detail, buttonActionID: descriptor.controlStyle == .button ? "execute" : nil, buttonTitle: descriptor.buttonTitle ) } componentItems = orderedDescriptors.compactMap { descriptor in guard descriptor.hasComponentPanel else { return nil } let plugin = descriptor.plugin let metadata = descriptor.metadata guard let componentPanel = plugin.componentPanel, let state = componentStatesByID[metadata.id] else { return nil } guard state.isVisible, pluginDisplayPreferencesStore.isVisible( metadata.id, defaultPluginIDs: defaultPluginIDs ) else { return nil } let description = localizedDescription( state.errorMessage ?? state.subtitle, pluginMetadata: plugin.metadata, localizedMetadata: metadata ) return PluginComponentItem( id: metadata.id, title: metadata.title, iconName: metadata.iconName, iconTint: metadata.iconTint, description: description.isEmpty ? metadata.defaultDescription : description, helpText: description.isEmpty ? metadata.defaultDescription : description, descriptionTone: state.errorMessage == nil ? .secondary : .error, span: componentPanel.descriptor.span, isActive: state.isActive, isEnabled: state.isEnabled ) } trimComponentViewCache(keeping: Set(componentItems.map(\.id))) syncVisiblePanelSurfaces() featureManagementItems = orderedDescriptors.map { descriptor in let metadata = descriptor.metadata return PluginFeatureManagementItem( id: metadata.id, title: metadata.title, description: metadata.defaultDescription, iconName: metadata.iconName, iconTint: metadata.iconTint, isVisible: pluginDisplayPreferencesStore.isVisible( metadata.id, defaultPluginIDs: defaultPluginIDs ), isActive: panelStatesByID[metadata.id]?.isOn == true || componentStatesByID[metadata.id]?.isActive == true, presentation: presentation(for: descriptor), category: dynamicPluginCategoriesByID[metadata.id] ?? nil, releaseChannel: dynamicPluginReleaseChannelsByID[metadata.id] ?? nil ) } settingsCards = orderedCorePlugins().flatMap { plugin in let sections = guardedValue( for: plugin, operation: "read settings sections", plugin.settingsSections ) ?? [] return sections.map { section in PluginSettingsCard( id: "\(plugin.metadata.id).\(section.id)", pluginID: plugin.metadata.id, title: section.title, description: section.description, statusText: section.status.text, statusSystemImage: section.status.systemImage, statusTone: section.status.tone, footnote: section.footnote, buttonTitle: section.buttonTitle, actionID: section.actionID ) } } permissionCards = orderedCorePlugins().flatMap { plugin -> [PluginPermissionCard] in let requirements = guardedValue( for: plugin, operation: "read permission requirements", plugin.permissionRequirements ) ?? [] return requirements.compactMap { requirement -> PluginPermissionCard? in guard let state = guardedValue( for: plugin, operation: "read permission state", plugin.permissionState(for: requirement.id) ) else { return nil } return PluginPermissionCard( id: "\(plugin.metadata.id).permission.\(requirement.id)", pluginID: plugin.metadata.id, permissionID: requirement.id, title: requirement.title, description: requirement.description, iconSystemImage: permissionIconName(for: requirement.kind), statusText: state.statusText ?? (state.isGranted ? AppL10n.plugins("plugin.permission.granted", defaultValue: "已授权") : AppL10n.plugins("plugin.permission.notGranted", defaultValue: "未授权")), statusSystemImage: state.statusSystemImage ?? (state.isGranted ? "checkmark.shield.fill" : "exclamationmark.triangle.fill"), statusTone: state.statusTone ?? (state.isGranted ? .positive : .caution), footnote: state.footnote, buttonTitle: permissionActionTitle( for: requirement.kind, isGranted: state.isGranted ) ) } } shortcutItems = shortcutDescriptors().map { descriptor in let customization = shortcutStore.customization(for: descriptor.itemID) let binding = resolvedBinding(for: descriptor) return ShortcutSettingsItem( id: descriptor.itemID, pluginID: descriptor.pluginID, pluginTitle: descriptor.pluginTitle, title: descriptor.definition.title, description: descriptor.definition.description, bindingText: ShortcutFormatter.displayString(for: binding), isRequired: descriptor.definition.isRequired, canClear: !descriptor.definition.isRequired && binding != nil, usesDefaultValue: customization == .inheritDefault, errorMessage: shortcutErrors[descriptor.itemID], settingsGroupID: descriptor.definition.settingsGroupID, settingsGroupTitle: descriptor.definition.settingsGroupTitle, settingsGroupDescription: descriptor.definition.settingsGroupDescription, settingsControlTitle: descriptor.definition.settingsControlTitle, settingsControlSystemImage: descriptor.definition.settingsControlSystemImage ) } pluginConfigurationItems = buildPluginConfigurationItems( settingsCards: settingsCards, permissionCards: permissionCards, shortcutItems: shortcutItems ) if let dirtyPluginIDs { for pluginID in dirtyPluginIDs { configurationViewCache.removeValue(forKey: pluginID) } } else { configurationViewCache.removeAll() } trimConfigurationViewCache(keeping: Set(pluginConfigurationItems.map(\.id))) syncSelectedFeatureSettingsPane() let newHasActivePlugin = panelStatesByID.values.contains(where: \.isOn) || componentStatesByID.values.contains(where: \.isActive) if hasActivePlugin != newHasActivePlugin { hasActivePlugin = newHasActivePlugin } if isolatedPluginFailures.count > isolatedPluginCountAtStart { rebuildDerivedState() } } private func handlePluginAction(rebuildAfterAction: Bool = true, _ action: () -> Void) { guard !isHandlingPluginAction else { action() return } isHandlingPluginAction = true action() isHandlingPluginAction = false if rebuildAfterAction { rebuildDerivedState() } } private func rebuildDerivedStateAfterPluginChange(pluginID: String) { guard !isHandlingPluginAction else { return } dirtyPluginIDs.insert(pluginID) schedulePluginStateChangeRebuild() } private func schedulePluginStateChangeRebuild() { guard pluginStateChangeRebuildTask == nil else { return } pluginStateChangeRebuildTask = Task { @MainActor [weak self] in guard let self else { return } do { try await Task.sleep(for: pluginStateChangeRebuildDelay) } catch { return } guard !Task.isCancelled else { return } let pluginIDs = dirtyPluginIDs dirtyPluginIDs.removeAll() pluginStateChangeRebuildTask = nil guard !pluginIDs.isEmpty else { return } rebuildDerivedState(dirtyPluginIDs: pluginIDs) } } private func cancelScheduledPluginStateRebuild() { pluginStateChangeRebuildTask?.cancel() pluginStateChangeRebuildTask = nil dirtyPluginIDs.removeAll() } private func guardedValue( for plugin: any MacToolsPlugin, operation: String, _ value: @autoclosure () -> T ) -> T? { guard !isPluginIsolated(plugin) else { return nil } switch PluginInvocationGuard.value(operation: operation, value) { case let .success(value): return value case let .failure(failure): isolatePlugin(plugin, operation: operation, failure: failure) return nil } } private func guardedOptionalValue( for plugin: any MacToolsPlugin, operation: String, _ value: @autoclosure () -> T? ) -> T? { guard !isPluginIsolated(plugin) else { return nil } switch PluginInvocationGuard.value(operation: operation, value) { case let .success(value): return value case let .failure(failure): isolatePlugin(plugin, operation: operation, failure: failure) return nil } } private func guardPluginCall( _ plugin: any MacToolsPlugin, operation: String, _ action: () -> Void ) { guard !isPluginIsolated(plugin) else { return } switch PluginInvocationGuard.run(operation: operation, action) { case .success: break case let .failure(failure): isolatePlugin(plugin, operation: operation, failure: failure) } } private func guardedConfigurationView( for plugin: any MacToolsPlugin, configuration: PluginConfiguration, context: PluginConfigurationContext ) -> AnyView { guardedValue( for: plugin, operation: "make configuration view", configuration.makeView(context) ) ?? AnyView(EmptyView()) } private func isPluginIsolated(_ plugin: any MacToolsPlugin) -> Bool { isolatedPluginFailures[plugin.metadata.id] != nil } private func isolatePlugin( _ plugin: any MacToolsPlugin, operation: String, failure: PluginInvocationFailure ) { let pluginID = plugin.metadata.id let message = failure.localizedDescription guard isolatedPluginFailures[pluginID] == nil else { return } isolatedPluginFailures[pluginID] = message removePluginFromVisiblePanelSurfaces(pluginID, notify: false) cachedPanelStatesByID.removeValue(forKey: pluginID) cachedComponentStatesByID.removeValue(forKey: pluginID) componentViewCache.removeValue(forKey: pluginID) configurationViewCache.removeValue(forKey: pluginID) shortcutErrors = shortcutErrors.filter { !$0.key.hasPrefix("\(pluginID).shortcut.") } AppLog.pluginHost.error( "Plugin \(pluginID, privacy: .public) isolated after \(operation, privacy: .public): \(message, privacy: .public)" ) switch PluginInvocationGuard.run(operation: "deactivate isolated plugin \(pluginID)", { plugin.deactivate(reason: .disabled) }) { case .success: break case let .failure(deactivateFailure): AppLog.pluginHost.error( "Plugin \(pluginID, privacy: .public) failed during isolation deactivate: \(deactivateFailure.localizedDescription, privacy: .public)" ) } plugin.onStateChange = nil plugin.requestPermissionGuidance = nil plugin.shortcutBindingResolver = nil (plugin as? any PluginConfigurationPresenting)?.requestConfigurationPresentation = nil syncGlobalShortcuts() } private func scheduleDisplayTopologyRefresh() { displayTopologyRefreshTask?.cancel() let refreshDelay = displayTopologyRefreshDelay displayTopologyRefreshTask = Task { @MainActor [weak self, refreshDelay] in do { try await Task.sleep(for: refreshDelay) } catch { return } guard !Task.isCancelled else { return } self?.refreshDisplayTopologyNow() } } private func refreshDisplayTopologyNow() { displayTopologyRefreshTask = nil handlePluginAction { for plugin in activePlugins { if let displayTopologyRefreshing = plugin as? DisplayTopologyRefreshing { guardPluginCall(plugin, operation: "refresh display topology") { displayTopologyRefreshing.refreshDisplayTopology() } } } } } private func refreshAccessibilityPermissionNow() { handlePluginAction { for plugin in activePlugins { if let accessibilityRefreshing = plugin as? AccessibilityPermissionRefreshing { guardPluginCall(plugin, operation: "refresh accessibility permission") { accessibilityRefreshing.refreshAccessibilityPermission() } } } } } private func buildPluginConfigurationItems( settingsCards: [PluginSettingsCard], permissionCards: [PluginPermissionCard], shortcutItems: [ShortcutSettingsItem] ) -> [PluginConfigurationItem] { orderedPluginDescriptors().compactMap { descriptor in let pluginID = descriptor.metadata.id let matchingSettingsCards = settingsCards.filter { $0.pluginID == pluginID } let matchingPermissionCards = permissionCards.filter { $0.pluginID == pluginID } let matchingShortcutItems = shortcutItems.filter { $0.pluginID == pluginID } let configurations: [PluginConfiguration] if descriptor.hasConfiguration, let configuration = guardedOptionalValue( for: descriptor.plugin, operation: "read plugin configuration", descriptor.plugin.configuration ) { configurations = [configuration] } else { configurations = [] } let hasConfigurationSurface = !matchingSettingsCards.isEmpty || !matchingPermissionCards.isEmpty || !matchingShortcutItems.isEmpty || !configurations.isEmpty guard hasConfigurationSurface else { return nil } return PluginConfigurationItem( id: pluginID, pluginID: pluginID, title: descriptor.metadata.title, description: configurations.first?.description ?? descriptor.metadata.defaultDescription, iconName: descriptor.metadata.iconName, iconTint: descriptor.metadata.iconTint, settingsCards: matchingSettingsCards, permissionCards: matchingPermissionCards, shortcutItems: matchingShortcutItems, hasCustomConfiguration: !configurations.isEmpty, prefersFullHeight: configurations.first?.prefersFullHeight ?? false ) } } private func shortcutDescriptors() -> [ShortcutDescriptor] { orderedCorePlugins().flatMap { plugin in let definitions = guardedValue( for: plugin, operation: "read shortcut definitions", plugin.shortcutDefinitions ) ?? [] return definitions.map { definition in ShortcutDescriptor( itemID: shortcutItemID( pluginID: plugin.metadata.id, shortcutDefinitionID: definition.id ), pluginID: plugin.metadata.id, pluginTitle: plugin.metadata.title, definition: definition, plugin: plugin ) } } } private var defaultPluginIDs: [String] { defaultPluginDescriptors().map(\.metadata.id) } private func orderedPluginIDs() -> [String] { pluginDisplayPreferencesStore.orderedPluginIDs(defaultPluginIDs: defaultPluginIDs) } private func orderedPlugins() -> [any MacToolsPlugin] { let pluginsByID = pluginsByID() return orderedPluginIDs().compactMap { pluginsByID[$0] } } private func orderedCorePlugins() -> [any MacToolsPlugin] { orderedPluginDescriptors().map(\.plugin) } private func orderedPluginDescriptors() -> [PluginDescriptor] { let descriptorsByID = descriptorsByID() return orderedPluginIDs().compactMap { descriptorsByID[$0] } } private func pluginsByID() -> [String: any MacToolsPlugin] { activePlugins.reduce(into: [String: any MacToolsPlugin]()) { result, plugin in let id = plugin.metadata.id if result[id] == nil { result[id] = plugin } } } private func descriptorsByID() -> [String: PluginDescriptor] { defaultPluginDescriptors().reduce(into: [String: PluginDescriptor]()) { result, descriptor in let id = descriptor.metadata.id if result[id] == nil { result[id] = descriptor } } } private func defaultPluginDescriptors() -> [PluginDescriptor] { let descriptors = builtInPlugins .map { PluginDescriptor(metadata: $0.metadata, plugin: $0, capabilities: nil) } + dynamicPlugins.map { PluginDescriptor( metadata: localizedMetadata(for: $0.metadata), plugin: $0, capabilities: dynamicPluginCapabilitiesByID[$0.metadata.id] ) } return descriptors .filter { !isPluginIsolated($0.plugin) } .sorted { lhs, rhs in if lhs.metadata.order == rhs.metadata.order { return lhs.metadata.title.localizedCompare(rhs.metadata.title) == .orderedAscending } return lhs.metadata.order < rhs.metadata.order } } private func localizedMetadata(for metadata: PluginMetadata) -> PluginMetadata { guard let localized = PluginLocalizationMatcher.localizedMetadata( from: dynamicPluginManifestsByID[metadata.id]?.localizedMetadata ?? [:] ) else { return metadata } return PluginMetadata( id: metadata.id, title: localized.displayName ?? metadata.title, iconName: metadata.iconName, iconTint: metadata.iconTint, order: metadata.order, defaultDescription: localized.summary ?? metadata.defaultDescription ) } private func localizedDescription( _ description: String, pluginMetadata: PluginMetadata, localizedMetadata: PluginMetadata ) -> String { // Replace only the metadata default; panel-specific descriptions and // errors must remain intact even if their text happens to be localized. description == pluginMetadata.defaultDescription ? localizedMetadata.defaultDescription : description } private func presentation(for descriptor: PluginDescriptor) -> PluginFeaturePresentation { if let capabilities = descriptor.capabilities { switch (capabilities.primaryPanel, capabilities.componentPanel) { case (true, true): return .featureAndComponentPanel case (true, false): return .featurePanel case (false, true): return .componentPanel case (false, false): return .featurePanel } } let plugin = descriptor.plugin switch (plugin.primaryPanel, plugin.componentPanel) { case (.some, .some): return .featureAndComponentPanel case (.some, .none): return .featurePanel case (.none, .some): return .componentPanel case (.none, .none): return .featurePanel } } private func trimComponentViewCache(keeping visibleComponentIDs: Set) { componentViewCache = componentViewCache.filter { visibleComponentIDs.contains($0.key) } } private func pluginIDs(for surface: PluginPanelSurface) -> Set { switch surface { case .component: return Set(componentItems.map(\.id)) case .primary: return Set(panelItems.map(\.id)) } } private func syncVisiblePanelSurfaces() { for surface in visiblePanelSurfaces { updateVisiblePanelSurface( surface, visiblePluginIDs: pluginIDs(for: surface) ) } } private func hideAllPanelSurfaces() { for surface in PluginPanelSurface.allCases { updateVisiblePanelSurface(surface, visiblePluginIDs: []) } visiblePanelSurfaces.removeAll() } private func updateVisiblePanelSurface( _ surface: PluginPanelSurface, visiblePluginIDs nextVisiblePluginIDs: Set ) { let previousVisiblePluginIDs = visiblePanelSurfacePluginIDs[surface] ?? [] let hiddenPluginIDs = previousVisiblePluginIDs.subtracting(nextVisiblePluginIDs) let shownPluginIDs = nextVisiblePluginIDs.subtracting(previousVisiblePluginIDs) guard !hiddenPluginIDs.isEmpty || !shownPluginIDs.isEmpty else { return } for pluginID in hiddenPluginIDs { notifyPanelSurfaceHidden(surface, pluginID: pluginID) } for pluginID in shownPluginIDs { notifyPanelSurfaceVisible(surface, pluginID: pluginID) } let visiblePluginIDs = nextVisiblePluginIDs.filter { pluginID in guard let plugin = corePlugin(for: pluginID) else { return false } return !isPluginIsolated(plugin) } if visiblePluginIDs.isEmpty { visiblePanelSurfacePluginIDs.removeValue(forKey: surface) } else { visiblePanelSurfacePluginIDs[surface] = visiblePluginIDs } } private func notifyPanelSurfaceVisible(_ surface: PluginPanelSurface, pluginID: String) { guard let plugin = corePlugin(for: pluginID), let lifecycleHandler = plugin as? any PluginPanelSurfaceLifecycleHandling else { return } guardPluginCall(plugin, operation: "show \(surface) panel surface") { lifecycleHandler.panelSurfaceDidBecomeVisible(surface) } } private func notifyPanelSurfaceHidden(_ surface: PluginPanelSurface, pluginID: String) { guard let plugin = corePlugin(for: pluginID), let lifecycleHandler = plugin as? any PluginPanelSurfaceLifecycleHandling else { return } guardPluginCall(plugin, operation: "hide \(surface) panel surface") { lifecycleHandler.panelSurfaceDidBecomeHidden(surface) } } private func removePluginFromVisiblePanelSurfaces(_ pluginID: String, notify: Bool) { for surface in PluginPanelSurface.allCases { guard var pluginIDs = visiblePanelSurfacePluginIDs[surface] else { continue } guard pluginIDs.remove(pluginID) != nil else { continue } if notify { notifyPanelSurfaceHidden(surface, pluginID: pluginID) } if pluginIDs.isEmpty { visiblePanelSurfacePluginIDs.removeValue(forKey: surface) } else { visiblePanelSurfacePluginIDs[surface] = pluginIDs } } } private func trimConfigurationViewCache(keeping configurationPluginIDs: Set) { configurationViewCache = configurationViewCache.filter { configurationPluginIDs.contains($0.key) } } private func syncSelectedFeatureSettingsPane() { guard case let .configuration(pluginID) = selectedFeatureSettingsPane else { return } let availableIDs = Set(pluginConfigurationItems.map(\.id)) if !availableIDs.contains(pluginID) { selectedFeatureSettingsPane = .installed } } private func shortcutDescriptor(for shortcutID: String) -> ShortcutDescriptor? { shortcutDescriptors().first(where: { $0.itemID == shortcutID }) } private func shortcutItemID(pluginID: String, shortcutDefinitionID: String) -> String { "\(pluginID).shortcut.\(shortcutDefinitionID)" } private func resolvedBinding(for descriptor: ShortcutDescriptor) -> ShortcutBinding? { shortcutStore.resolvedBinding( for: descriptor.itemID, default: descriptor.definition.defaultBinding ) } private func resolvedBinding(forPluginID pluginID: String, shortcutDefinitionID: String) -> ShortcutBinding? { guard let descriptor = shortcutDescriptors().first(where: { $0.pluginID == pluginID && $0.definition.id == shortcutDefinitionID }) else { return nil } return resolvedBinding(for: descriptor) } @discardableResult private func applyShortcutCustomization( _ customization: ShortcutCustomization, for descriptor: ShortcutDescriptor ) -> String? { do { try validateShortcutCustomization(customization, for: descriptor) shortcutStore.setCustomization(customization, for: descriptor.itemID) shortcutErrors.removeValue(forKey: descriptor.itemID) rebuildDerivedState() syncGlobalShortcuts() return nil } catch let error as ShortcutValidationError { shortcutErrors[descriptor.itemID] = error.localizedDescription rebuildDerivedState() return error.localizedDescription } catch { shortcutErrors[descriptor.itemID] = error.localizedDescription rebuildDerivedState() return error.localizedDescription } } private func validateShortcutCustomization( _ customization: ShortcutCustomization, for descriptor: ShortcutDescriptor ) throws { let candidate = ShortcutStore.resolve( customization: customization, defaultBinding: descriptor.definition.defaultBinding ) if descriptor.definition.isRequired && candidate == nil { throw ShortcutValidationError.requiredShortcut } if let candidate { guard !candidate.modifiers.isEmpty else { throw ShortcutValidationError.missingModifier } guard !ShortcutKeyCode.isModifier(candidate.keyCode) else { throw ShortcutValidationError.modifierOnly } if let conflict = shortcutDescriptors().first(where: { $0.itemID != descriptor.itemID && resolvedBinding(for: $0) == candidate && !canShareShortcutBinding(descriptor, with: $0) }) { throw ShortcutValidationError.duplicate( ownerDescription: "\(conflict.pluginTitle) · \(conflict.definition.title)" ) } } } private func canShareShortcutBinding(_ lhs: ShortcutDescriptor, with rhs: ShortcutDescriptor) -> Bool { guard let groupID = lhs.definition.sharedBindingGroupID else { return false } return groupID == rhs.definition.sharedBindingGroupID } private func syncGlobalShortcuts() { let registrations = shortcutDescriptors().compactMap { descriptor -> GlobalShortcutManager.Registration? in guard descriptor.definition.scope == .global else { return nil } guard let binding = resolvedBinding(for: descriptor) else { return nil } return GlobalShortcutManager.Registration( shortcutID: descriptor.itemID, binding: binding ) } globalShortcutManager.updateBindings(registrations) } private func handleShortcutTrigger(shortcutID: String) { guard let descriptor = shortcutDescriptor(for: shortcutID) else { return } handlePluginAction { guardPluginCall(descriptor.plugin, operation: "shortcut action") { if let eventHandler = descriptor.plugin as? any PluginShortcutEventHandling { eventHandler.handleShortcutEvent(id: descriptor.definition.actionID, phase: .pressed) } else { descriptor.plugin.handleShortcutAction(id: descriptor.definition.actionID) } } } } private func handleShortcutRelease(shortcutID: String) { guard let descriptor = shortcutDescriptor(for: shortcutID), let eventHandler = descriptor.plugin as? any PluginShortcutEventHandling else { return } handlePluginAction { guardPluginCall(descriptor.plugin, operation: "shortcut release") { eventHandler.handleShortcutEvent(id: descriptor.definition.actionID, phase: .released) } } } private func requestPermissionGuidance(forPluginID pluginID: String, permissionID: String) { guard activePlugins.contains(where: { plugin in plugin.metadata.id == pluginID && (guardedValue( for: plugin, operation: "read permission requirements", plugin.permissionRequirements ) ?? []).contains(where: { $0.id == permissionID }) }) else { return } presentPluginConfiguration(pluginID: pluginID) } private func permissionActionTitle( for kind: PluginPermissionKind, isGranted: Bool ) -> String { switch kind { case .accessibility: return isGranted ? AppL10n.plugins("plugin.permission.checkStatus", defaultValue: "检查授权状态") : AppL10n.plugins("plugin.permission.openAuthorization", defaultValue: "前往授权") case .inputMonitoring: return isGranted ? AppL10n.plugins("plugin.permission.checkStatus", defaultValue: "检查授权状态") : AppL10n.plugins("plugin.permission.openAuthorization", defaultValue: "前往授权") case .calendarFullAccess: return isGranted ? AppL10n.plugins("plugin.permission.checkStatus", defaultValue: "检查授权状态") : AppL10n.plugins("plugin.permission.requestAuthorization", defaultValue: "请求授权") case .automation: return AppL10n.plugins("plugin.permission.openSettings", defaultValue: "打开设置") case .screenRecording: return isGranted ? AppL10n.plugins("plugin.permission.checkStatus", defaultValue: "检查授权状态") : AppL10n.plugins("plugin.permission.openAuthorization", defaultValue: "前往授权") } } private func permissionIconName(for kind: PluginPermissionKind) -> String { switch kind { case .accessibility: return "accessibility" case .inputMonitoring: return "keyboard.badge.eye" case .calendarFullAccess: return "calendar" case .automation: return "cursorarrow.click.2" case .screenRecording: return "rectangle.dashed.badge.record" } } }