2141 lines
77 KiB
Swift
2141 lines
77 KiB
Swift
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<PluginPanelSurface> = []
|
|
private var visiblePanelSurfacePluginIDs: [PluginPanelSurface: Set<String>] = [:]
|
|
private var isolatedPluginFailures: [String: String] = [:]
|
|
private var isHandlingPluginAction = false
|
|
private var didLoadDynamicPlugins = false
|
|
private var displayTopologyRefreshTask: Task<Void, Never>?
|
|
private var pluginStateChangeRebuildTask: Task<Void, Never>?
|
|
private var runtimeLocaleCancellable: AnyCancellable?
|
|
private var dirtyPluginIDs: Set<String> = []
|
|
private var cachedPanelStatesByID: [String: PluginPanelState] = [:]
|
|
private var cachedPrimaryPanelIndicatorsByID: [String: PluginPrimaryPanelIndicator] = [:]
|
|
private var evaluatedPrimaryPanelIndicatorPluginIDs: Set<String> = []
|
|
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<String>? = 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<String>()
|
|
: 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<T>(
|
|
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<T>(
|
|
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<String>) {
|
|
componentViewCache = componentViewCache.filter { visibleComponentIDs.contains($0.key) }
|
|
}
|
|
|
|
private func pluginIDs(for surface: PluginPanelSurface) -> Set<String> {
|
|
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<String>
|
|
) {
|
|
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<String>) {
|
|
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"
|
|
}
|
|
}
|
|
}
|