diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 46facefec..dcaf763c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,6 +79,7 @@ Follow the [plugin development standards](docs/plugins/development-guidelines.md - **Reuse typography roles.** Follow the [typography contract](docs/plugins/typography.md) for titles, supporting text, numbers, and AppKit text measurement. `PluginTypography` and `PluginMetricValue` require host 2.0.1; declare that minimum when consuming these new APIs. - **Build reusable widgets.** Support zero or multiple placements, isolated previews, view recycling, and independent per-placement presentation state. Preserve declared widget sizes when the popup width changes. The host sizes original rows independently and measures their details at the resolved width. Simple switch rows may opt in to `PluginPanelCompactSwitchProviding` with host 2.1.0 as their minimum; keep conditional-detail rows in their original presentation. See [panel items](docs/plugins/panel-items.md). - **Keep background work economical.** Use cached snapshots, event-driven updates, bounded asynchronous work, and visibility-aware presentation. Preserve intentional monitoring while hidden; stop owned work on deactivation. See [performance requirements](docs/plugins/development-guidelines.md#performance-and-energy). +- **Declare backup coverage.** Classify portable and excluded configuration in `plugin.json.preferencesBackup`, implement verified restore and meaningful change signals, and regenerate the [coverage inventory](docs/plugins/preferences-backup-inventory.md). Follow the [backup contract](docs/plugins/preferences-backup.md); never transfer credentials or local consent. - **Preserve user control.** Handle denied permissions, cancellation, unsupported hardware, and system changes. Keep existing confirmations, recovery paths, and destructive-operation safeguards. Trackpad input is owned by the host through `TrackpadInputService` (host 2.1.0). Trackpad Gestures, Middle Click, and the optional [experimental Trackpad Scale](docs/plugins/trackpad-scale.md) subscribe to the same listener; preserve temporary gesture suspension and callback lifecycle boundaries when extending this path. diff --git a/Makefile b/Makefile index 6c7fb6ca5..dc1de4d38 100644 --- a/Makefile +++ b/Makefile @@ -96,7 +96,10 @@ validate-generated-plugin-data: validate-localization: @$(PYTHON3) scripts/audit-localization.py --require-complete -script-tests: validate-changelog validate-generated-plugin-data validate-localization +validate-backup-inventory: + @$(PYTHON3) scripts/plugins/generate-preferences-backup-inventory.py --check + +script-tests: validate-changelog validate-generated-plugin-data validate-localization validate-backup-inventory @$(PYTHON3) -m unittest discover -s scripts/tests -p 'test_*.py' test: generate diff --git a/Plugins/AIAssistant/Resources/Localizable.xcstrings b/Plugins/AIAssistant/Resources/Localizable.xcstrings index 430d57b75..fece6019d 100644 --- a/Plugins/AIAssistant/Resources/Localizable.xcstrings +++ b/Plugins/AIAssistant/Resources/Localizable.xcstrings @@ -11627,6 +11627,234 @@ } } } + }, + "settings.backup.title": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preferences Backup" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "偏好设置备份" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "偏好設定備份" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copia de ajustes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sauvegarde des réglages" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Резервная копия настроек" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Backup dos ajustes" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungssicherung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定のバックアップ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정 백업" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نسخ الإعدادات احتياطيًا" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ayar Yedekleme" + } + } + } + }, + "settings.backup.includePromptText": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Include custom prompt text in backups" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将自定义提示词包含在备份中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將自訂提示詞包含在備份中" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Incluir textos de instrucciones personalizados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Inclure les instructions personnalisées" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включать текст собственных инструкций" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Incluir textos de instruções personalizados" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eigene Prompt-Texte einschließen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタムプロンプトをバックアップに含める" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 프롬프트 포함" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تضمين نصوص التعليمات المخصصة" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Özel istem metnini yedeklere dahil et" + } + } + } + }, + "settings.backup.scope": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Applies to manual backups, automatic backups, and cloud sync. API keys stay on this Mac. Imported services need local key setup." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "同时用于手动备份、自动备份和云同步。API 密钥保留在本机,导入的服务需在本机配置密钥。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "適用於手動備份、自動備份和雲端同步。API 金鑰保留在本機,匯入的服務須在本機設定金鑰。" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se aplica a copias manuales, automáticas y a la sincronización. Las claves API siguen en este Mac; configura las de los servicios importados." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "S’applique aux sauvegardes manuelles, automatiques et à la synchronisation. Les clés API restent sur ce Mac ; configurez celles des services importés." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Для ручных и автоматических копий и облачной синхронизации. Ключи API остаются на Mac; для импортированных служб настройте ключ локально." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Aplica-se a backups manuais, automáticos e à sincronização. As chaves API ficam neste Mac; configure as dos serviços importados." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gilt für manuelle und automatische Sicherungen sowie Cloud-Synchronisierung. API-Schlüssel bleiben lokal; importierte Dienste benötigen eine lokale Schlüsseleinrichtung." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "手動・自動バックアップとクラウド同期に適用します。APIキーはこのMacに保持されます。読み込んだサービスはキーの設定が必要です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수동·자동 백업과 클라우드 동기화에 적용됩니다. API 키는 이 Mac에 유지되며 가져온 서비스는 로컬 키 설정이 필요합니다." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ينطبق على النسخ اليدوي والتلقائي والمزامنة. تبقى مفاتيح API على هذا الـ Mac؛ تحتاج الخدمات المستوردة إلى إعداد مفتاح محلي." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Manuel ve otomatik yedekler ile bulut eşzamanlama için geçerlidir. API anahtarları bu Mac’te kalır; içe aktarılan hizmetlerde yerel anahtar kurulumu gerekir." + } + } + } } }, "version": "1.0" diff --git a/Plugins/AIAssistant/Sources/AIAssistantPlugin.swift b/Plugins/AIAssistant/Sources/AIAssistantPlugin.swift index 859e8a9fe..3dbdd1e5b 100644 --- a/Plugins/AIAssistant/Sources/AIAssistantPlugin.swift +++ b/Plugins/AIAssistant/Sources/AIAssistantPlugin.swift @@ -61,7 +61,11 @@ final class AIAssistantPlugin: var requestSettingsPresentation: (() -> Void)? var focusedWindowTargetProvider: (() -> PluginFocusedWindowTarget?)? + var onPersistentPreferencesChange: (() -> Void)? private let storage: PluginStorage + private var providerEditorRevision = 0 + private var promptEditorRevision = 0 + private var credentialAuthorization: PortableCredentialAuthorization { .init(storage: storage) } private let accessibilityTrustProvider: () -> Bool private let accessibilityTrustRequester: (Bool) -> Bool private let secretStore: any AIAssistantSecretStoring @@ -93,6 +97,7 @@ final class AIAssistantPlugin: let localization = localization ?? PluginLocalization(bundle: context.resourceBundle) self.localization = localization self.storage = context.storage + _ = Self.portableStore(storage: context.storage).recover() self.accessibilityTrustProvider = accessibilityTrustProvider self.accessibilityTrustRequester = accessibilityTrustRequester self.secretStore = secretStore @@ -106,6 +111,11 @@ final class AIAssistantPlugin: let providerProfileStore = AIAssistantProviderProfileStore(storage: context.storage, localization: localization) self.providerProfileStore = providerProfileStore self.providerProfiles = providerProfileStore.loadProfiles() + if !PortableCredentialAuthorization(storage: context.storage).seedIfNeeded( + profiles: Dictionary(self.providerProfiles.map { ($0.id, $0.baseURL) }, uniquingKeysWith: { first, _ in first }), + store: Self.portableStore(storage: context.storage)) { + AIAssistantLog.provider.error("Could not initialize local credential authorization.") + } self.promptStore = AIAssistantPromptStore(storage: context.storage, localization: localization) self.prompts = promptStore.loadPrompts() self.cachedAPIKey = nil @@ -312,6 +322,7 @@ final class AIAssistantPlugin: var settingsPage: PluginSettingsPage? { .form(description: metadata.defaultDescription, sections: [ + promptBackupSection, PluginSettingsSection( id: "ai-service", title: localization.string("settings.service.title", defaultValue: "AI 服务"), @@ -322,7 +333,7 @@ final class AIAssistantPlugin: let localization = self.localization AIAssistantServiceSettingsView( profiles: self.providerProfiles, - apiKey: self.cachedAPIKey ?? "", + apiKey: "", localization: localization, onSave: { [weak self] profiles, apiKey in self?.saveProviderConfiguration(profiles: profiles, apiKey: apiKey) @@ -350,6 +361,7 @@ final class AIAssistantPlugin: return await self.testConnection(profile: profile, apiKey: apiKey) } ) + .id(self.providerEditorRevision) } }, PluginSettingsSection( @@ -416,6 +428,7 @@ final class AIAssistantPlugin: ) } ) + .id(self.promptEditorRevision) } } ]) @@ -426,7 +439,9 @@ final class AIAssistantPlugin: return } - storage.set(isEnabled, forKey: AIAssistantConstants.StorageKey.shortcutEnabled) + guard isEnabled != isShortcutEnabled, + portableStore.commit([AIAssistantConstants.StorageKey.shortcutEnabled: .boolean(isEnabled)]) else { return } + onPersistentPreferencesChange?() onStateChange?() } @@ -473,10 +488,14 @@ final class AIAssistantPlugin: } func handleSettingsAction(_ action: PluginSettingsAction) { - guard case let .setBoolean(controlID, value) = action, - controlID == AIAssistantConstants.StorageKey.shortcutUsesClipboard + guard case let .setBoolean(controlID, value) = action else { return } + if controlID == Self.promptBackupKey { setIncludesPromptText(value); return } + guard controlID == AIAssistantConstants.StorageKey.shortcutUsesClipboard || controlID == AIAssistantConstants.StorageKey.allowsSimulatedCopy else { return } - storage.set(value, forKey: controlID) + if controlID == AIAssistantConstants.StorageKey.shortcutUsesClipboard { + guard portableStore.commit([controlID: .boolean(value)]) else { return } + onPersistentPreferencesChange?() + } else { storage.set(value, forKey: controlID) } onStateChange?() } @@ -628,6 +647,9 @@ final class AIAssistantPlugin: )) } + guard credentialAuthorization.allows(profileID: profile.id, baseURL: profile.baseURL) else { + return .failure(AIAssistantProviderError(message: localization.string("panelError.missingAPIKey", defaultValue: "请配置 API Key"))) + } do { let apiKey = try loadAPIKey() guard let trimmedKey = Self.normalizedAPIKey(apiKey) else { @@ -656,7 +678,7 @@ final class AIAssistantPlugin: profile: AIAssistantProviderProfile, apiKey: String ) async -> AIAssistantOutcome<[String]> { - let resolved = await resolveAPIKey(preferred: apiKey) + let resolved = await resolveAPIKey(preferred: apiKey, profile: profile) switch resolved { case let .failure(message): return .failure(message) @@ -685,7 +707,7 @@ final class AIAssistantPlugin: return .failure(validationError.errorDescription(localization: localization)) } - let resolved = await resolveAPIKey(preferred: apiKey) + let resolved = await resolveAPIKey(preferred: apiKey, profile: profile) switch resolved { case let .failure(message): return .failure(message) @@ -712,11 +734,14 @@ final class AIAssistantPlugin: /// Resolves the effective API key for a request, preferring the edited field /// and falling back to the stored Keychain entry. Runs on `@MainActor`. - private func resolveAPIKey(preferred: String) async -> AIAssistantOutcome { + private func resolveAPIKey(preferred: String, profile: AIAssistantProviderProfile) async -> AIAssistantOutcome { if let normalizedKey = Self.normalizedAPIKey(preferred) { return .success(normalizedKey) } + guard credentialAuthorization.allows(profileID: profile.id, baseURL: profile.baseURL) else { + return .failure(localization.string("panelError.missingAPIKey", defaultValue: "请配置 API Key")) + } do { let key = try loadAPIKey() if let normalizedKey = Self.normalizedAPIKey(key) { @@ -765,14 +790,25 @@ final class AIAssistantPlugin: } } + if Self.normalizedAPIKey(apiKey) == nil, + profiles.contains(where: { $0.isEnabled && !credentialAuthorization.allows(profileID: $0.id, baseURL: $0.baseURL) }) { + return localization.string("panelError.missingAPIKey", defaultValue: "请配置 API Key") + } do { - if let normalizedAPIKey = Self.normalizedAPIKey(apiKey) { - try secretStore.saveAPIKey(normalizedAPIKey) - cachedAPIKey = normalizedAPIKey - didLoadAPIKey = true + let previousBackup = makePortablePreferencesBackup() + let profilesData = try JSONEncoder().encode(profiles.map { $0.normalized() }) + var replacements: [PortableCredentialAuthorization.Replacement] = [] + var endpoints: [String: String] = [:] + if let key = Self.normalizedAPIKey(apiKey) { + cachedAPIKey = nil + didLoadAPIKey = false + replacements = [.init(profileIDs: nil, + load: { try self.secretStore.loadAPIKey() }, + save: { try self.secretStore.saveAPIKey(key) })] + endpoints = Dictionary(profiles.map { ($0.id, $0.baseURL) }, uniquingKeysWith: { first, _ in first }) } - - try providerProfileStore.saveProfiles(profiles) + try credentialAuthorization.replaceCredentials(replacements, authorizing: endpoints, + values: [AIAssistantConstants.StorageKey.providerProfiles: .data(profilesData)], store: portableStore) providerProfiles = providerProfileStore.loadProfiles() apiKeyState = .present if let coordinator { @@ -781,6 +817,7 @@ final class AIAssistantPlugin: panelController.close() } coordinator = nil + if previousBackup != makePortablePreferencesBackup() { onPersistentPreferencesChange?() } onStateChange?() return nil } catch { @@ -803,6 +840,8 @@ final class AIAssistantPlugin: } do { + guard portableStore.recover() else { throw CocoaError(.fileWriteUnknown) } + let previousBackup = makePortablePreferencesBackup() try promptStore.savePrompts(prompts) self.prompts = promptStore.loadPrompts() if let coordinator { @@ -811,6 +850,7 @@ final class AIAssistantPlugin: panelController.close() } coordinator = nil + if previousBackup != makePortablePreferencesBackup() { onPersistentPreferencesChange?() } onStateChange?() return nil } catch { @@ -842,3 +882,124 @@ final class AIAssistantPlugin: return trimmedKey.isEmpty ? nil : trimmedKey } } + +extension AIAssistantPlugin { + private static let promptBackupKey = "preferences-backup.include-prompt-text" + private var includesPromptText: Bool { storage.bool(forKey: Self.promptBackupKey) } + private var promptBackupSection: PluginSettingsSection { + PluginSettingsSection( + id: "preferences-backup", title: localization.string("settings.backup.title", defaultValue: "偏好设置备份"), + systemImage: "externaldrive", rows: [PluginSettingsRow( + id: Self.promptBackupKey, + title: localization.string("settings.backup.includePromptText", defaultValue: "将自定义提示词包含在备份中"), + description: localization.string("settings.backup.scope", defaultValue: "同时用于手动备份、自动备份和云同步。不包含 API 密钥。"), + control: .toggle(isOn: includesPromptText) + )] + ) + } + private func setIncludesPromptText(_ value: Bool) { + guard value != includesPromptText, + portableStore.commit([Self.promptBackupKey: .boolean(value)]) else { return } + onPersistentPreferencesChange?() + onStateChange?() + } +} + +extension AIAssistantPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling, PluginPortablePreferencesActionReferencesProviding, PluginActionReferenceBackupProviding { + private struct PortablePreferences: Codable { + let formatVersion: Int + let profiles: [AIAssistantProviderProfile] + let shortcutEnabled: Bool + let shortcutUsesClipboard: Bool + let prompts: [AIAssistantPrompt]? + } + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + AIAssistantConstants.StorageKey.providerProfiles: .data(Data("[]".utf8), maximumBytes: 1_024 * 1_024) { + (try? JSONDecoder().decode([AIAssistantProviderProfile].self, from: $0)).map { $0.count <= 64 } == true + }, + AIAssistantConstants.StorageKey.prompts: .data(Data("[]".utf8), maximumBytes: 1_024 * 1_024) { + (try? JSONDecoder().decode([AIAssistantPrompt].self, from: $0)).map { $0.count <= 256 } == true + }, + AIAssistantConstants.StorageKey.shortcutEnabled: .boolean(true), + AIAssistantConstants.StorageKey.shortcutUsesClipboard: .boolean(false), + Self.promptBackupKey: .boolean(false), + PortableCredentialAuthorization.key: PortableCredentialAuthorization.field + ]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + private func decodedPortable(_ data: Data) -> PortablePreferences? { + guard data.count <= 2 * 1_024 * 1_024, let payload = try? JSONDecoder().decode(PortablePreferences.self, from: data), payload.formatVersion == 1, + (1...64).contains(payload.profiles.count), Set(payload.profiles.map(\.id)).count == payload.profiles.count, + payload.profiles.allSatisfy({ profile in + PortablePreferencesValidation.identifier(profile.id) + && PortablePreferencesValidation.text(profile.name, maximumBytes: 256, allowsEmpty: !profile.isEnabled) + && PortablePreferencesValidation.endpoint(profile.baseURL, allowsIncomplete: !profile.isEnabled) + && PortablePreferencesValidation.text(profile.model, maximumBytes: 256, allowsEmpty: !profile.isEnabled) + && profile.temperature.isFinite && (0...2).contains(profile.temperature) + && (!profile.isEnabled || profile.validationError == nil) + }) else { return nil } + if let prompts = payload.prompts { + guard prompts.count <= 256, Set(prompts.map(\.id)).count == prompts.count, + prompts.allSatisfy({ prompt in + PortablePreferencesValidation.identifier(prompt.id) && PortablePreferencesValidation.text(prompt.name, maximumBytes: 256) + && prompt.template.utf8.count <= 65_536 && (prompt.systemPrompt?.utf8.count ?? 0) <= 65_536 + && prompt.temperature.isFinite && (0...2).contains(prompt.temperature) + && (!prompt.isEnabled || prompt.template.contains("{{text}}")) + }) else { return nil } + } + return payload + } + func makePortablePreferencesBackup() -> Data? { + var keys: Set = [AIAssistantConstants.StorageKey.providerProfiles, + AIAssistantConstants.StorageKey.shortcutEnabled, AIAssistantConstants.StorageKey.shortcutUsesClipboard] + if includesPromptText { keys.insert(AIAssistantConstants.StorageKey.prompts) } + guard portableStore.backup(including: keys) != nil else { return nil } + let payload = PortablePreferences(formatVersion: 1, profiles: providerProfiles, shortcutEnabled: isShortcutEnabled, + shortcutUsesClipboard: shortcutUsesClipboard, prompts: includesPromptText ? prompts : nil) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(payload), decodedPortable(data) != nil else { return nil } + return data + } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard credentialAuthorization.isInitialized, let payload = decodedPortable(data), let profileData = try? JSONEncoder().encode(payload.profiles) else { return false } + let before = makePortablePreferencesBackup() + var values: [String: PortablePreferenceValue] = [ + AIAssistantConstants.StorageKey.providerProfiles: .data(profileData), + AIAssistantConstants.StorageKey.shortcutEnabled: .boolean(payload.shortcutEnabled), + AIAssistantConstants.StorageKey.shortcutUsesClipboard: .boolean(payload.shortcutUsesClipboard) + ] + if let incoming = payload.prompts { + let protected = !includesPromptText && storage.data(forKey: AIAssistantConstants.StorageKey.prompts) != nil ? prompts : [] + guard !incoming.contains(where: { incoming in protected.contains { $0.id == incoming.id && $0 != incoming } }) else { return false } + let candidate = incoming + protected.filter { local in !incoming.contains { $0.id == local.id } } + guard candidate.count <= 256, let encoded = try? JSONEncoder().encode(candidate) else { return false } + values[AIAssistantConstants.StorageKey.prompts] = .data(encoded) + } + let previousPrompts = prompts + guard portableStore.commit(values) else { return false } + providerProfiles = providerProfileStore.loadProfiles() + prompts = promptStore.loadPrompts() + if prompts != previousPrompts { promptEditorRevision += 1 } + if let coordinator { coordinator.close() } else { panelController.close() } + coordinator = nil + providerEditorRevision += 1 + if let profile = enabledValidProfiles.first, !credentialAuthorization.allows(profileID: profile.id, baseURL: profile.baseURL) { apiKeyState = .missing } + // Keep destination credential state; import does not read or manufacture Keychain grants. + if before != makePortablePreferencesBackup() { onPersistentPreferencesChange?() } + onStateChange?() + return true + } + func actionReferences(inPortablePreferences data: Data) -> [ActionReference]? { + guard let payload = decodedPortable(data) else { return nil } + return (payload.prompts ?? []).map { ActionReference(key: ActionKey(providerID: metadata.id, actionID: $0.id)) } + } + func backupDisposition(for reference: ActionReference) -> PluginActionReferenceBackupDisposition { + guard reference.key.providerID == metadata.id, reference.parameters.entries.isEmpty, + PortablePreferencesValidation.identifier(reference.key.actionID) else { return .excluded } + return .requiresPluginPreferences + } +} diff --git a/Plugins/AIAssistant/Sources/Settings/AIAssistantPromptStore.swift b/Plugins/AIAssistant/Sources/Settings/AIAssistantPromptStore.swift index 1ec058549..34951112c 100644 --- a/Plugins/AIAssistant/Sources/Settings/AIAssistantPromptStore.swift +++ b/Plugins/AIAssistant/Sources/Settings/AIAssistantPromptStore.swift @@ -25,7 +25,13 @@ struct AIAssistantPromptStore { func savePrompts(_ prompts: [AIAssistantPrompt]) throws { let data = try JSONEncoder().encode(prompts) + let previous = storage.object(forKey: AIAssistantConstants.StorageKey.prompts) storage.set(data, forKey: AIAssistantConstants.StorageKey.prompts) + guard storage.data(forKey: AIAssistantConstants.StorageKey.prompts) == data else { + if let previous { storage.set(previous, forKey: AIAssistantConstants.StorageKey.prompts) } + else { storage.removeObject(forKey: AIAssistantConstants.StorageKey.prompts) } + throw CocoaError(.fileWriteUnknown) + } } func makeNewPrompt(existing: [AIAssistantPrompt]) -> AIAssistantPrompt { diff --git a/Plugins/AIAssistant/Sources/Settings/AIAssistantProviderProfile.swift b/Plugins/AIAssistant/Sources/Settings/AIAssistantProviderProfile.swift index 273ec4ace..b445cf5d9 100644 --- a/Plugins/AIAssistant/Sources/Settings/AIAssistantProviderProfile.swift +++ b/Plugins/AIAssistant/Sources/Settings/AIAssistantProviderProfile.swift @@ -152,6 +152,12 @@ struct AIAssistantProviderProfileStore { func saveProfiles(_ profiles: [AIAssistantProviderProfile]) throws { let normalizedProfiles = profiles.map { $0.normalized() } let data = try JSONEncoder().encode(normalizedProfiles) + let previous = storage.object(forKey: AIAssistantConstants.StorageKey.providerProfiles) storage.set(data, forKey: AIAssistantConstants.StorageKey.providerProfiles) + guard storage.data(forKey: AIAssistantConstants.StorageKey.providerProfiles) == data else { + if let previous { storage.set(previous, forKey: AIAssistantConstants.StorageKey.providerProfiles) } + else { storage.removeObject(forKey: AIAssistantConstants.StorageKey.providerProfiles) } + throw CocoaError(.fileWriteUnknown) + } } } diff --git a/Plugins/AIAssistant/Tests/AIAssistantInMemoryPluginStorage.swift b/Plugins/AIAssistant/Tests/AIAssistantInMemoryPluginStorage.swift index ef43646c4..8ea12dafd 100644 --- a/Plugins/AIAssistant/Tests/AIAssistantInMemoryPluginStorage.swift +++ b/Plugins/AIAssistant/Tests/AIAssistantInMemoryPluginStorage.swift @@ -4,6 +4,8 @@ import MacToolsPluginKit @MainActor final class AIAssistantInMemoryPluginStorage: PluginStorage { private var values: [String: Any] = [:] + var blockedSetKeys: Set = [] + var blockedRemoveKeys: Set = [] func object(forKey key: String) -> Any? { values[key] @@ -30,6 +32,7 @@ final class AIAssistantInMemoryPluginStorage: PluginStorage { } func set(_ value: Any?, forKey key: String) { + guard !blockedSetKeys.contains(key) else { return } guard let value else { removeObject(forKey: key) return @@ -39,6 +42,7 @@ final class AIAssistantInMemoryPluginStorage: PluginStorage { } func removeObject(forKey key: String) { + guard !blockedRemoveKeys.contains(key) else { return } values.removeValue(forKey: key) } diff --git a/Plugins/AIAssistant/Tests/AIAssistantPluginTests.swift b/Plugins/AIAssistant/Tests/AIAssistantPluginTests.swift index 7d0684e66..b554ed25f 100644 --- a/Plugins/AIAssistant/Tests/AIAssistantPluginTests.swift +++ b/Plugins/AIAssistant/Tests/AIAssistantPluginTests.swift @@ -2,9 +2,120 @@ import AppKit import MacToolsPluginKit import XCTest @testable import AIAssistantPlugin +@testable import class MacTools.PluginHost +@testable import class MacTools.ShortcutStore +@testable import class MacTools.PreferencesBackupStore @MainActor final class AIAssistantPluginTests: XCTestCase { + func testPromptSaveResolvesPendingRecoveryBeforeAcceptingEdits() throws { + let storage = AIAssistantInMemoryPluginStorage() + let plugin = makePlugin(storage: storage) + let original = AIAssistantPrompt(id: "existing", name: "Existing", template: "Original {{text}}", isEnabled: true) + XCTAssertNil(plugin.savePromptsConfiguration([original])) + plugin.handleSettingsAction(.setBoolean(controlID: "preferences-backup.include-prompt-text", value: true)) + let source = makePlugin() + var imported = original + imported.template = "Imported {{text}}" + XCTAssertNil(source.savePromptsConfiguration([imported])) + source.handleSettingsAction(.setBoolean(controlID: "preferences-backup.include-prompt-text", value: true)) + storage.blockedRemoveKeys = ["preferences-backup.restore.v2"] + XCTAssertFalse(plugin.restorePortablePreferencesReportingResult(from: try XCTUnwrap(source.makePortablePreferencesBackup()))) + XCTAssertNotNil(storage.data(forKey: "preferences-backup.restore.v2")) + + var edited = original + edited.template = "Local edit {{text}}" + XCTAssertNotNil(plugin.savePromptsConfiguration([edited])) + XCTAssertEqual(AIAssistantPromptStore(storage: storage).loadPrompts(), [original]) + storage.blockedRemoveKeys = [] + XCTAssertNil(plugin.savePromptsConfiguration([edited])) + _ = makePlugin(storage: storage) + XCTAssertEqual(AIAssistantPromptStore(storage: storage).loadPrompts(), [edited]) + XCTAssertNil(storage.object(forKey: "preferences-backup.restore.v2")) + } + + func testIncludedDisabledPromptRetainsCustomizedShortcutThroughBackup() async throws { + let plugin = makePlugin() + var prompt = AIAssistantPrompt(id: "custom", name: "Custom prompt", template: "Process {{text}}", + systemPrompt: nil, isEnabled: true) + XCTAssertNil(plugin.savePromptsConfiguration([prompt])) + plugin.handleSettingsAction(.setBoolean(controlID: "preferences-backup.include-prompt-text", value: true)) + let suite = "AIAssistantShortcutBackupTests." + UUID().uuidString + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let shortcuts = ShortcutStore(userDefaults: defaults) + let host = PluginHost(plugins: [plugin], shortcutStore: shortcuts, + pluginOrderingStore: .init(userDefaults: defaults), preferencesBackupStore: PreferencesBackupStore(userDefaults: defaults), + globalShortcutManager: .init(registrar: FakeCarbonHotKeyRegistrar()), loadDynamicPluginsOnInit: false) + defer { host.deactivateAllPlugins() } + let shortcutID = "ai-assistant.shortcut." + (try XCTUnwrap(plugin.shortcutDefinitions.first)).id + let binding = ShortcutBinding(keyCode: 97, modifiers: [.command, .option, .control, .shift]) + XCTAssertNil(host.setShortcutBindingAndReturnError(binding, for: shortcutID)) + let reference = ActionReference(key: .init(providerID: "ai-assistant", actionID: prompt.id)) + let canonicalBinding = ShortcutBinding(keyCode: 98, modifiers: [.command, .option, .control, .shift]) + guard case .success = host.setActionShortcutBinding(canonicalBinding, to: reference) else { + return XCTFail("Could not assign the canonical shortcut") + } + prompt.isEnabled = false + XCTAssertNil(plugin.savePromptsConfiguration([prompt])) + host.refreshAll() + for _ in 0..<100 where host.actionRegistry.definition(for: reference.key) != nil { + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertNil(host.actionRegistry.definition(for: reference.key)) + + let backup = try host.makePreferencesBackupExport().requiringSuccessfulSelection() + XCTAssertEqual(backup.shortcutCustomizations[shortcutID], .custom(binding)) + XCTAssertTrue(backup.actionShortcutAssignments.contains { $0.reference == reference }) + let result = try host.importPreferences(backup) + + XCTAssertTrue(result.shortcutErrors.isEmpty) + XCTAssertEqual(shortcuts.customization(for: shortcutID), .custom(binding)) + XCTAssertNotNil(host.shortcutAssignmentService.assignment(for: reference)) + XCTAssertEqual(plugin.shortcutDefinitions.first?.scope, .whilePluginActive) + XCTAssertTrue(plugin.actionDefinitions.isEmpty) + } + + func testFailedCredentialReplacementPreservesOnlyVerifiedUnchangedAccess() throws { + for (changesKey, readFails) in [(false, false), (true, false), (false, true)] { + let storage = AIAssistantInMemoryPluginStorage() + let secret = CountingAIAssistantSecretStore(apiKey: "original-local-key") + let plugin = makePlugin(storage: storage, secretStore: secret) + let profile = AIAssistantProviderProfile.defaultProfile() + var edited = profile + edited.baseURL = "https://changed.example/v1" + secret.saveFailure = (changesKey, readFails) + XCTAssertNotNil(plugin.saveProviderConfiguration(profiles: [edited], apiKey: "replacement-local-key")) + let authorization = PortableCredentialAuthorization(storage: storage) + let preservesAccess = !changesKey && !readFails + XCTAssertEqual(authorization.allows(profileID: profile.id, baseURL: profile.baseURL), preservesAccess) + XCTAssertFalse(authorization.allows(profileID: edited.id, baseURL: edited.baseURL)) + secret.saveFailure = nil + let reloaded = makePlugin(storage: storage, secretStore: secret) + XCTAssertEqual(AIAssistantProviderProfileStore(storage: storage).loadProfiles(), [profile]) + XCTAssertEqual(reloaded.saveProviderConfiguration(profiles: [profile], apiKey: "") == nil, preservesAccess) + } + } + + func testPreferenceWriteFailureRestoresOnlyUnchangedCredentialGrants() throws { + for key in ["original-local-key", "replacement-local-key"] { + let storage = AIAssistantInMemoryPluginStorage() + let secret = CountingAIAssistantSecretStore(apiKey: "original-local-key") + let plugin = makePlugin(storage: storage, secretStore: secret) + let original = AIAssistantProviderProfile.defaultProfile() + XCTAssertNil(plugin.saveProviderConfiguration(profiles: [original], apiKey: "")) + var edited = original + edited.name = "Edited name" + storage.blockedSetKeys = [AIAssistantConstants.StorageKey.providerProfiles] + XCTAssertNotNil(plugin.saveProviderConfiguration(profiles: [edited], apiKey: key)) + XCTAssertEqual(AIAssistantProviderProfileStore(storage: storage).loadProfiles(), [original]) + XCTAssertEqual(PortableCredentialAuthorization(storage: storage).allows(profileID: original.id, baseURL: original.baseURL), key == "original-local-key") + storage.blockedSetKeys = [] + let reloaded = makePlugin(storage: storage, secretStore: secret) + XCTAssertEqual(reloaded.saveProviderConfiguration(profiles: [original], apiKey: "") == nil, key == "original-local-key") + } + } + func testMetadataMatchesManifestContract() { let plugin = makePlugin() @@ -379,11 +490,199 @@ final class AIAssistantPluginTests: XCTestCase { XCTAssertTrue(panel.isVisible) } + func testImportedEndpointCannotReuseLocalKeyAcrossRepeatedRestoreAndRelaunch() async throws { + let storage = AIAssistantInMemoryPluginStorage() + let secret = CountingAIAssistantSecretStore(apiKey: "local-secret") + let plugin = makePlugin(storage: storage, secretStore: secret) + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(plugin.makePortablePreferencesBackup())) as? [String: Any]) + var profiles = try XCTUnwrap(object["profiles"] as? [[String: Any]]) + profiles[0]["baseURL"] = "https://imported.example/v1" + object["profiles"] = profiles + let incoming = try JSONSerialization.data(withJSONObject: object) + XCTAssertTrue(plugin.restorePortablePreferencesReportingResult(from: incoming)) + XCTAssertTrue(plugin.restorePortablePreferencesReportingResult(from: incoming)) + let reloaded = makePlugin(storage: storage, secretStore: secret) + let profile = try JSONDecoder().decode([AIAssistantProviderProfile].self, from: JSONSerialization.data(withJSONObject: profiles))[0] + if case .success = await reloaded.fetchModels(profile: profile, apiKey: "") { XCTFail("Imported endpoint reused a local key") } + if case .success = await reloaded.testConnection(profile: profile, apiKey: "") { XCTFail("Imported endpoint reused a local key") } + XCTAssertEqual(secret.loadCount, 0) + XCTAssertEqual(secret.saveCount, 0) + XCTAssertNotNil(reloaded.saveProviderConfiguration(profiles: [profile], apiKey: "")) + XCTAssertNil(reloaded.saveProviderConfiguration(profiles: [profile], apiKey: "explicit-new-key")) + XCTAssertTrue(PortableCredentialAuthorization(storage: storage).allows(profileID: profile.id, baseURL: profile.baseURL)) + } + + func testLocalCredentialMigrationAndRekeyIgnoreIncompleteDisabledDraft() throws { + let storage = AIAssistantInMemoryPluginStorage() + let secret = CountingAIAssistantSecretStore(apiKey: "existing-local-key") + let active = AIAssistantProviderProfile(id: "active", name: "Active", baseURL: "https://gateway.example/v1?api-version=2026-01-01") + let draft = AIAssistantProviderProfile(id: "draft", name: "Draft", isEnabled: false, baseURL: "") + let profiles = [active, draft] + storage.set(try JSONEncoder().encode(profiles), forKey: AIAssistantConstants.StorageKey.providerProfiles) + let plugin = makePlugin(storage: storage, secretStore: secret) + let grants = PortableCredentialAuthorization(storage: storage) + XCTAssertTrue(grants.isInitialized) + XCTAssertTrue(grants.allows(profileID: active.id, baseURL: active.baseURL)) + XCTAssertFalse(grants.allows(profileID: draft.id, baseURL: draft.baseURL)) + XCTAssertNil(plugin.saveProviderConfiguration(profiles: profiles, apiKey: "")) + XCTAssertNil(plugin.saveProviderConfiguration(profiles: profiles, apiKey: "replacement-local-key")) + XCTAssertTrue(grants.allows(profileID: active.id, baseURL: active.baseURL)) + XCTAssertEqual(try JSONDecoder().decode([AIAssistantProviderProfile].self, + from: XCTUnwrap(storage.data(forKey: AIAssistantConstants.StorageKey.providerProfiles))), profiles) + XCTAssertEqual(secret.loadCount, 1) + XCTAssertEqual(secret.saveCount, 1) + } + + func testSavedIncompleteDisabledDraftTransfersWithoutCredentialAccess() throws { + let source = makePlugin() + let active = AIAssistantProviderProfile.defaultProfile() + let draft = AIAssistantProviderProfile(id: "draft", name: "", isEnabled: false, baseURL: "", model: "") + XCTAssertNil(source.saveProviderConfiguration(profiles: [active, draft], apiKey: "")) + let data = try XCTUnwrap(source.makePortablePreferencesBackup()) + let storage = AIAssistantInMemoryPluginStorage() + let secret = CountingAIAssistantSecretStore(apiKey: "local-secret") + let destination = makePlugin(storage: storage, secretStore: secret) + XCTAssertTrue(destination.restorePortablePreferencesReportingResult(from: data)) + let saved = try XCTUnwrap(storage.data(forKey: AIAssistantConstants.StorageKey.providerProfiles)) + let profiles = try JSONDecoder().decode([AIAssistantProviderProfile].self, from: saved) + XCTAssertEqual(profiles.first { $0.id == draft.id }, draft) + + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + var incoming = try XCTUnwrap(object["profiles"] as? [[String: Any]]) + incoming[1]["isEnabled"] = true + object["profiles"] = incoming + XCTAssertFalse(destination.restorePortablePreferencesReportingResult(from: try JSONSerialization.data(withJSONObject: object))) + incoming[1]["isEnabled"] = false + incoming[1]["baseURL"] = "https://gateway.example/v1?api_key=secret" + object["profiles"] = incoming + XCTAssertFalse(destination.restorePortablePreferencesReportingResult(from: try JSONSerialization.data(withJSONObject: object))) + XCTAssertEqual(storage.data(forKey: AIAssistantConstants.StorageKey.providerProfiles), saved) + XCTAssertEqual(secret.loadCount, 0) + XCTAssertEqual(secret.saveCount, 0) + } + + func testExcludedLegacyPromptStorageDoesNotBlockConfigurationTransfer() throws { + let storage = AIAssistantInMemoryPluginStorage() + let prompts = [AIAssistantPrompt(id: "private", name: "Private", + template: String(repeating: "x", count: 2_097_153) + " {{text}}", systemPrompt: nil, isEnabled: true)] + let raw = try JSONEncoder().encode(prompts) + storage.set(raw, forKey: AIAssistantConstants.StorageKey.prompts) + let secret = CountingAIAssistantSecretStore(apiKey: "existing-local-key") + let plugin = makePlugin(storage: storage, secretStore: secret) + let profile = AIAssistantProviderProfile.defaultProfile() + let grants = PortableCredentialAuthorization(storage: storage) + XCTAssertTrue(grants.isInitialized) + XCTAssertTrue(grants.allows(profileID: profile.id, baseURL: profile.baseURL)) + XCTAssertNil(plugin.saveProviderConfiguration(profiles: [profile], apiKey: "")) + let backup = try XCTUnwrap(plugin.makePortablePreferencesBackup()) + XCTAssertNil(try XCTUnwrap(JSONSerialization.jsonObject(with: backup) as? [String: Any])["prompts"]) + XCTAssertTrue(plugin.restorePortablePreferencesReportingResult(from: backup)) + XCTAssertEqual(storage.data(forKey: AIAssistantConstants.StorageKey.prompts), raw) + XCTAssertTrue(grants.allows(profileID: profile.id, baseURL: profile.baseURL)) + XCTAssertEqual(secret.loadCount, 0) + XCTAssertEqual(secret.saveCount, 0) + plugin.handleSettingsAction(.setBoolean(controlID: "preferences-backup.include-prompt-text", value: true)) + XCTAssertNil(plugin.makePortablePreferencesBackup()) + XCTAssertEqual(storage.data(forKey: AIAssistantConstants.StorageKey.prompts), raw) + XCTAssertNil(plugin.savePromptsConfiguration(prompts)) + XCTAssertEqual(AIAssistantPromptStore(storage: storage).loadPrompts(), prompts) + } + + func testPromptOptOutPreservesPrivateTextAndOmittedDefinitionsHaveNoIndex() throws { + let plugin = makePlugin(storage: AIAssistantInMemoryPluginStorage()) + let local = AIAssistantPrompt(id: "private", name: "Private", template: "Private {{text}}", systemPrompt: nil, isEnabled: true) + XCTAssertNil(plugin.savePromptsConfiguration([local])) + let incomingPlugin = makePlugin(storage: AIAssistantInMemoryPluginStorage()) + let incoming = try XCTUnwrap(incomingPlugin.makePortablePreferencesBackup()) + XCTAssertEqual(plugin.actionReferences(inPortablePreferences: incoming), []) + XCTAssertTrue(plugin.restorePortablePreferencesReportingResult(from: incoming)) + XCTAssertEqual(plugin.shortcutDefinitions.map(\.actionID), ["private"]) + XCTAssertFalse(String(decoding: try XCTUnwrap(plugin.makePortablePreferencesBackup()), as: UTF8.self).contains("Private {{text}}")) + incomingPlugin.handleSettingsAction(.setBoolean(controlID: "preferences-backup.include-prompt-text", value: true)) + XCTAssertNil(incomingPlugin.savePromptsConfiguration([AIAssistantPrompt(id: "private", name: "Other", template: "Different {{text}}", systemPrompt: nil, isEnabled: true)])) + let before = plugin.makePortablePreferencesBackup() + XCTAssertFalse(plugin.restorePortablePreferencesReportingResult(from: try XCTUnwrap(incomingPlugin.makePortablePreferencesBackup()))) + XCTAssertEqual(plugin.makePortablePreferencesBackup(), before) + } + + func testRecorderImportRequiresSuccessfulSelectedDefinition() async throws { + for scenario in ["accepted", "rejected", "definition-unselected", "shortcuts-unselected"] { + let sourceStorage = AIAssistantInMemoryPluginStorage() + let source = makePlugin(storage: sourceStorage) + let destinationStorage = AIAssistantInMemoryPluginStorage() + let panel = RecordingAIAssistantPanelController() + let destination = makePlugin(storage: destinationStorage, panelController: panel, + providerFactoryOverride: { .success(ResolvedAIProvider(title: "Synthetic", client: ValidationEchoAIProcessor(), + configuration: AIAssistantProviderProfile.defaultProfile().configuration, apiKey: "synthetic-key")) }, + strategies: [StaticSelectionCapture(text: "synthetic")]) + let id = "shared" + let sourcePrompt = AIAssistantPrompt(id: id, name: "Source", template: "Source {{text}}", isEnabled: true) + let destinationPrompt = AIAssistantPrompt(id: id, name: "Destination", template: "Destination {{text}}", isEnabled: true) + XCTAssertNil(source.savePromptsConfiguration([sourcePrompt])) + XCTAssertNil(destination.savePromptsConfiguration([destinationPrompt])) + source.handleSettingsAction(.setBoolean(controlID: "preferences-backup.include-prompt-text", value: true)) + if scenario == "accepted" { + destination.handleSettingsAction(.setBoolean(controlID: "preferences-backup.include-prompt-text", value: true)) + } + let sourceSuite = "ValidationSource." + UUID().uuidString + let destinationSuite = "ValidationDestination." + UUID().uuidString + let sourceDefaults = try XCTUnwrap(UserDefaults(suiteName: sourceSuite)) + let destinationDefaults = try XCTUnwrap(UserDefaults(suiteName: destinationSuite)) + defer { sourceDefaults.removePersistentDomain(forName: sourceSuite); destinationDefaults.removePersistentDomain(forName: destinationSuite) } + let sourceHost = PluginHost(plugins: [source], shortcutStore: .init(userDefaults: sourceDefaults), + pluginOrderingStore: .init(userDefaults: sourceDefaults), preferencesBackupStore: PreferencesBackupStore(userDefaults: sourceDefaults), + globalShortcutManager: .init(registrar: FakeCarbonHotKeyRegistrar()), loadDynamicPluginsOnInit: false) + let destinationShortcuts = ShortcutStore(userDefaults: destinationDefaults) + let registrar = FakeCarbonHotKeyRegistrar() + let destinationHost = PluginHost(plugins: [destination], shortcutStore: destinationShortcuts, + pluginOrderingStore: .init(userDefaults: destinationDefaults), preferencesBackupStore: PreferencesBackupStore(userDefaults: destinationDefaults), + globalShortcutManager: .init(registrar: registrar), loadDynamicPluginsOnInit: false) + defer { sourceHost.deactivateAllPlugins(); destinationHost.deactivateAllPlugins() } + let shortcutID = "ai-assistant.shortcut." + (try XCTUnwrap(source.shortcutDefinitions.first)).id + let chord = ShortcutBinding(keyCode: 97, modifiers: [.command, .option, .control, .shift]) + let canonicalChord = ShortcutBinding(keyCode: 98, modifiers: [.command, .option, .control, .shift]) + let originalDestinationChord = ShortcutBinding(keyCode: 96, modifiers: [.command, .option, .control, .shift]) + XCTAssertNil(sourceHost.setShortcutBindingAndReturnError(chord, for: shortcutID)) + XCTAssertNil(destinationHost.setShortcutBindingAndReturnError(originalDestinationChord, for: shortcutID)) + let reference = ActionReference(key: .init(providerID: "ai-assistant", actionID: id)) + guard case .success = sourceHost.setActionShortcutBinding(canonicalChord, to: reference) else { + return XCTFail("Unable to create canonical control") + } + let archive = try sourceHost.makePreferencesBackupExport().requiringSuccessfulSelection() + var selection = archive.effectiveSelection + if scenario == "definition-unselected" { selection.pluginPreferenceIDs = [] } + if scenario == "shortcuts-unselected" { selection.includesShortcuts = false } + let result = try destinationHost.importPreferences(archive, selection: selection) + let rejected = result.shortcutErrors["plugin-preferences.ai-assistant"] != nil + let recorderAttached = destinationShortcuts.customization(for: shortcutID) == .custom(chord) + let canonicalAttached = destinationHost.shortcutAssignmentService.assignment(for: reference) != nil + XCTAssertEqual(rejected, scenario == "rejected" || scenario == "shortcuts-unselected") + XCTAssertEqual(recorderAttached, scenario == "accepted") + XCTAssertEqual(canonicalAttached, scenario == "accepted") + let expectedPrompt = scenario == "accepted" ? sourcePrompt : destinationPrompt + XCTAssertEqual(AIAssistantPromptStore(storage: destinationStorage).loadPrompts(), [expectedPrompt]) + if scenario == "shortcuts-unselected" { + XCTAssertEqual(destinationShortcuts.customization(for: shortcutID), .custom(originalDestinationChord)) + } else if scenario == "accepted" { + XCTAssertTrue(registrar.registeredBindings.contains(chord)) + } else { + XCTAssertEqual(destinationShortcuts.customization(for: shortcutID), .inheritDefault) + } + destination.handleShortcutEvent(id: id, phase: .pressed) + for _ in 0..<100 where panel.snapshot?.phase != .success { + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertEqual(panel.snapshot?.result?.text, scenario == "accepted" ? "Source synthetic" : "Destination synthetic") + + } + } + // MARK: - Helpers private func makePlugin( storage: AIAssistantInMemoryPluginStorage? = nil, accessibilityTrustProvider: @escaping () -> Bool = { true }, + secretStore: (any AIAssistantSecretStoring)? = nil, panelController: RecordingAIAssistantPanelController? = nil, clipboardTextProvider: @escaping () -> String? = { nil }, providerFactoryOverride: AIAssistantProviderFactory? = nil, @@ -394,7 +693,7 @@ final class AIAssistantPluginTests: XCTestCase { context: PluginRuntimeContext(pluginID: "ai-assistant", storage: storage), accessibilityTrustProvider: accessibilityTrustProvider, accessibilityTrustRequester: { _ in true }, - secretStore: CountingAIAssistantSecretStore(apiKey: "sk-test"), + secretStore: secretStore ?? CountingAIAssistantSecretStore(apiKey: "sk-test"), panelController: panelController ?? RecordingAIAssistantPanelController(), selectedTextCapturePipeline: SelectedTextCapturePipeline(strategies: strategies), providerFactoryOverride: providerFactoryOverride, @@ -438,6 +737,9 @@ private final class StaticSelectionCapture: SelectedTextCapturing { private final class CountingAIAssistantSecretStore: AIAssistantSecretStoring, @unchecked Sendable { private var apiKey: String? + private(set) var loadCount = 0 + private(set) var saveCount = 0 + var saveFailure: (changesKey: Bool, readFails: Bool)? init(apiKey: String?) { self.apiKey = apiKey @@ -448,10 +750,17 @@ private final class CountingAIAssistantSecretStore: AIAssistantSecretStoring, @u } func loadAPIKey() throws -> String? { - apiKey + loadCount += 1 + if saveCount > 0, saveFailure?.readFails == true { throw CocoaError(.fileReadUnknown) } + return apiKey } func saveAPIKey(_ apiKey: String) throws { + saveCount += 1 + if let saveFailure { + if saveFailure.changesKey { self.apiKey = apiKey } + throw CocoaError(.fileWriteUnknown) + } self.apiKey = apiKey } @@ -483,3 +792,9 @@ private final class RecordingAIAssistantPanelController: AIAssistantPanelControl isVisible = false } } + +private struct ValidationEchoAIProcessor: AIProcessing { + func complete(prompt: String, systemPrompt: String?, configuration: OpenAICompatibleConfiguration, apiKey: String) async throws -> AIProcessResult { + AIProcessResult(providerTitle: "Synthetic", text: prompt, reasoningText: nil, sourceText: "", promptName: "") + } +} diff --git a/Plugins/AIAssistant/Tests/PortablePreferencesStoreTests.swift b/Plugins/AIAssistant/Tests/PortablePreferencesStoreTests.swift new file mode 100644 index 000000000..47d74960b --- /dev/null +++ b/Plugins/AIAssistant/Tests/PortablePreferencesStoreTests.swift @@ -0,0 +1,67 @@ +import Foundation +import XCTest +@testable import AIAssistantPlugin + +@MainActor +final class PortablePreferencesStoreTests: XCTestCase { + func testFailedCommitRestoresTouchedValuesAndAbsentKeysWithoutCopyingExcludedData() { + let storage = AIAssistantInMemoryPluginStorage() + let privateData = Data(repeating: 42, count: 2_097_153) + storage.set(4, forKey: "changed") + storage.set(true, forKey: "removed") + storage.set(privateData, forKey: "private") + let store = PortablePreferencesStore(storage: storage, fields: [ + "changed": .integer(0, range: 0...10), "created": .integer(0, range: 0...10), + "removed": .boolean(false), "private": .data(Data(), maximumBytes: 1_024, validate: { _ in true }) + ]) + XCTAssertTrue(store.commit(["changed": .integer(6)])) + storage.blockedSetKeys = ["created"] + + XCTAssertFalse(store.commit(["changed": .integer(8), "created": .integer(3)], removing: ["removed"])) + + XCTAssertEqual(storage.integer(forKey: "changed"), 6) + XCTAssertNil(storage.object(forKey: "created")) + XCTAssertTrue(storage.bool(forKey: "removed")) + XCTAssertEqual(storage.data(forKey: "private"), privateData) + XCTAssertNil(storage.object(forKey: "preferences-backup.restore.v2")) + } + + func testScopedRecoveryRestoresOnlyJournaledKeys() throws { + let storage = AIAssistantInMemoryPluginStorage() + storage.set(8, forKey: "changed") + storage.set(3, forKey: "created") + storage.set(true, forKey: "untouched") + let journal: [String: Any] = ["formatVersion": 2, "keys": ["changed", "created"], "previous": ["changed": 4]] + storage.set(try PropertyListSerialization.data(fromPropertyList: journal, format: .binary, options: 0), + forKey: "preferences-backup.restore.v2") + let store = PortablePreferencesStore(storage: storage, fields: [ + "changed": .integer(0, range: 0...10), "created": .integer(0, range: 0...10), "untouched": .boolean(false) + ]) + + XCTAssertNil(store.backup()) + XCTAssertTrue(store.recover()) + XCTAssertEqual(storage.integer(forKey: "changed"), 4) + XCTAssertNil(storage.object(forKey: "created")) + XCTAssertTrue(storage.bool(forKey: "untouched")) + XCTAssertNotNil(store.backup()) + } + + func testLegacyRecoveryPreservesItsFullFieldAndAbsentKeySemantics() throws { + let storage = AIAssistantInMemoryPluginStorage() + storage.set(8, forKey: "changed") + storage.set(3, forKey: "created") + storage.set(true, forKey: "outsideAllowlist") + storage.set(try PropertyListSerialization.data(fromPropertyList: ["changed": 4], format: .binary, options: 0), + forKey: "preferences-backup.restore.v1") + let store = PortablePreferencesStore(storage: storage, fields: [ + "changed": .integer(0, range: 0...10), "created": .integer(0, range: 0...10) + ]) + + XCTAssertNil(store.backup()) + XCTAssertTrue(store.recover()) + XCTAssertEqual(storage.integer(forKey: "changed"), 4) + XCTAssertNil(storage.object(forKey: "created")) + XCTAssertTrue(storage.bool(forKey: "outsideAllowlist")) + XCTAssertNotNil(store.backup()) + } +} diff --git a/Plugins/AIAssistant/plugin.json b/Plugins/AIAssistant/plugin.json index 456083332..d7378e201 100644 --- a/Plugins/AIAssistant/plugin.json +++ b/Plugins/AIAssistant/plugin.json @@ -290,7 +290,8 @@ "ai-requests" ], "dataPersisted": [ - "plugin-configuration" + "plugin-configuration", + "custom-prompt-text" ], "retention": { "policy": "user-controlled" @@ -418,5 +419,13 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "credentials", + "permissions", + "private-content" + ] } } diff --git a/Plugins/AIAssistant/project.yml b/Plugins/AIAssistant/project.yml index f57c718e6..509cf6028 100644 --- a/Plugins/AIAssistant/project.yml +++ b/Plugins/AIAssistant/project.yml @@ -1,3 +1,9 @@ settings: base: OTHER_LDFLAGS: -framework Security -framework LocalAuthentication -framework ApplicationServices + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift + - ../../Sources/PluginSupport/PortablePreferencesValidation.swift + - ../../Sources/PluginSupport/PortableCredentialAuthorization.swift diff --git a/Plugins/AIUsage/Sources/AIUsagePlugin.swift b/Plugins/AIUsage/Sources/AIUsagePlugin.swift index 2af5e3d04..071d54fc6 100644 --- a/Plugins/AIUsage/Sources/AIUsagePlugin.swift +++ b/Plugins/AIUsage/Sources/AIUsagePlugin.swift @@ -121,3 +121,19 @@ final class AIUsagePlugin: MacToolsPlugin, PluginSettingsPresenting, PluginDashb interval: Double(preferences.refreshInterval), now: Date(), strings: strings), assets: assets) } } + +extension AIUsagePlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { model.onPersistentPreferencesChange } + set { model.onPersistentPreferencesChange = newValue } + } + func makePortablePreferencesBackup() -> Data? { model.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard model.restorePortableBackup(data) else { return false } + updateMenuBar() + onStateChange?() + return true + } +} diff --git a/Plugins/AIUsage/Sources/AIUsageViewModel.swift b/Plugins/AIUsage/Sources/AIUsageViewModel.swift index 28d443608..34ceba4d0 100644 --- a/Plugins/AIUsage/Sources/AIUsageViewModel.swift +++ b/Plugins/AIUsage/Sources/AIUsageViewModel.swift @@ -36,6 +36,53 @@ final class AIUsageViewModel: ObservableObject { self.preferences = preferences } + private struct PortablePreferences: Codable { + let formatVersion: Int + let codexEnabled: Bool + let claudeEnabled: Bool + let refreshInterval: Int + let showsMenuBar: Bool + } + var onPersistentPreferencesChange: (() -> Void)? + func portableBackup() -> Data? { + try? JSONEncoder().encode(PortablePreferences( + formatVersion: 1, codexEnabled: preferences.codexEnabled, claudeEnabled: preferences.claudeEnabled, + refreshInterval: preferences.refreshInterval, showsMenuBar: preferences.showsMenuBar + )) + } + func restorePortableBackup(_ data: Data) -> Bool { + guard data.count <= 16_384, + let incoming = try? JSONDecoder().decode(PortablePreferences.self, from: data), incoming.formatVersion == 1, + AIUsagePreferences.refreshIntervals.contains(incoming.refreshInterval) else { return false } + var candidate = preferences + candidate.codexEnabled = incoming.codexEnabled + candidate.claudeEnabled = incoming.claudeEnabled + candidate.refreshInterval = incoming.refreshInterval + candidate.showsMenuBar = incoming.showsMenuBar + guard persist(candidate) else { return false } + let changed = preferences != candidate + let intervalChanged = preferences.refreshInterval != candidate.refreshInterval + preferences = candidate + cancelWork() + if intervalChanged { updateSuccessfulRefreshDeadlines() } + // Install a future timer without an immediate credential read or network request. + if isActive, activityAllowsWork, !preferences.queryableProviders.isEmpty { installTimer() } + if changed { onPersistentPreferencesChange?() } + onChange?() + return true + } + private func persist(_ candidate: AIUsagePreferences) -> Bool { + guard let data = try? JSONEncoder().encode(candidate) else { return false } + let previous = storage.object(forKey: Self.preferencesKey) + storage.set(data, forKey: Self.preferencesKey) + guard storage.data(forKey: Self.preferencesKey) == data else { + if let previous { storage.set(previous, forKey: Self.preferencesKey) } + else { storage.removeObject(forKey: Self.preferencesKey) } + return false + } + return true + } + func start() { isActive = true resume() @@ -62,8 +109,12 @@ final class AIUsageViewModel: ObservableObject { update(&next) next.normalize() guard previous != next else { return } + guard persist(next) else { return } preferences = next - if let data = try? JSONEncoder().encode(next) { storage.set(data, forKey: Self.preferencesKey) } + if previous.codexEnabled != next.codexEnabled || previous.claudeEnabled != next.claudeEnabled + || previous.refreshInterval != next.refreshInterval || previous.showsMenuBar != next.showsMenuBar { + onPersistentPreferencesChange?() + } let accessChanged = previous.allowsCredentialAccess != next.allowsCredentialAccess || previous.allowsClaudeKeychain != next.allowsClaudeKeychain || previous.enabledProviders != next.enabledProviders @@ -86,15 +137,19 @@ final class AIUsageViewModel: ObservableObject { } } if previous.refreshInterval != next.refreshInterval { - for provider in next.enabledProviders { - guard states[provider]?.failure == nil, let snapshot = states[provider]?.snapshot else { continue } - states[provider]?.nextRefresh = snapshot.fetchedAt.addingTimeInterval(Double(next.refreshInterval)) - } + updateSuccessfulRefreshDeadlines() } onChange?() resume() } + private func updateSuccessfulRefreshDeadlines() { + for provider in preferences.enabledProviders { + guard states[provider]?.failure == nil, let snapshot = states[provider]?.snapshot else { continue } + states[provider]?.nextRefresh = snapshot.fetchedAt.addingTimeInterval(Double(preferences.refreshInterval)) + } + } + func setClaudeKeychainAccess(_ allowed: Bool) { guard allowed != preferences.allowsClaudeKeychain else { return } cancelAuthorization() diff --git a/Plugins/AIUsage/Tests/AIUsageViewModelTests.swift b/Plugins/AIUsage/Tests/AIUsageViewModelTests.swift index dc66ad159..1abf11487 100644 --- a/Plugins/AIUsage/Tests/AIUsageViewModelTests.swift +++ b/Plugins/AIUsage/Tests/AIUsageViewModelTests.swift @@ -6,8 +6,75 @@ import XCTest @MainActor final class AIUsageViewModelTests: XCTestCase { + func testPortableSettingsKeepConsentLocalAndDoNotFetchDuringRestore() async throws { + let source = makeModel(AIUsageTestClient()) + source.updatePreferences { $0.codexEnabled = false; $0.refreshInterval = 900; $0.showsMenuBar = true; $0.allowsCredentialAccess = true } + let client = AIUsageTestClient() + let destination = makeModel(client) + destination.start() + XCTAssertTrue(destination.restorePortableBackup(try XCTUnwrap(source.portableBackup()))) + XCTAssertFalse(destination.preferences.codexEnabled) + XCTAssertEqual(destination.preferences.refreshInterval, 900) + XCTAssertTrue(destination.preferences.showsMenuBar) + XCTAssertFalse(destination.preferences.allowsCredentialAccess) + XCTAssertFalse(destination.preferences.allowsClaudeKeychain) + let count = await client.count + XCTAssertEqual(count, 0) + destination.stop() + } + private var date = Date(timeIntervalSince1970: 1_800_000_000) + func testImportedIntervalReschedulesSuccessfulSnapshotWithoutImmediateFetch() async throws { + for (oldInterval, newInterval) in [(1800, 120), (120, 1800)] { + let client = AIUsageTestClient() + let fetchedAt = date + await client.setResult(.success(AIUsageSnapshot(windows: [], plan: nil, fetchedAt: fetchedAt))) + let model = makeModel(client) + model.updatePreferences { $0.refreshInterval = oldInterval } + enable(model) + await settle(model) + let source = makeModel(AIUsageTestClient()) + source.updatePreferences { $0.refreshInterval = newInterval; $0.claudeEnabled = false } + + XCTAssertTrue(model.restorePortableBackup(try XCTUnwrap(source.portableBackup()))) + XCTAssertEqual(model.states[.codex]?.nextRefresh, fetchedAt.addingTimeInterval(Double(newInterval))) + var count = await client.count + XCTAssertEqual(count, 1) + date = fetchedAt.addingTimeInterval(121) + model.refresh() + await settle(model) + count = await client.count + XCTAssertEqual(count, newInterval == 120 ? 2 : 1) + model.stop() + } + } + + func testImportedIntervalPreservesFailureAndRateLimitBackoff() async throws { + for failure in [AIUsageFailure.network, .rateLimited(retryAfter: 900)] { + let client = AIUsageTestClient() + let model = makeModel(client) + model.updatePreferences { $0.refreshInterval = 1800 } + enable(model) + await settle(model) + await client.setResult(.failure(failure)) + date.addTimeInterval(61) + model.refresh(manual: true) + await settle(model) + let deadline = model.states[.codex]?.nextRefresh + let count = await client.count + let source = makeModel(AIUsageTestClient()) + source.updatePreferences { $0.refreshInterval = 120; $0.claudeEnabled = false } + + XCTAssertTrue(model.restorePortableBackup(try XCTUnwrap(source.portableBackup()))) + XCTAssertEqual(model.states[.codex]?.nextRefresh, deadline) + XCTAssertEqual(model.states[.codex]?.failure, failure) + let after = await client.count + XCTAssertEqual(after, count) + model.stop() + } + } + func testConsentGatePreventsCredentialAndNetworkWork() async { let client = AIUsageTestClient() let model = makeModel(client) diff --git a/Plugins/AIUsage/plugin.json b/Plugins/AIUsage/plugin.json index 645b1cb2f..820f96cc8 100644 --- a/Plugins/AIUsage/plugin.json +++ b/Plugins/AIUsage/plugin.json @@ -212,5 +212,13 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "credentials", + "permissions", + "history" + ] } } diff --git a/Plugins/ActionGrid/plugin.json b/Plugins/ActionGrid/plugin.json index 7989d0289..ec8e1bb8b 100644 --- a/Plugins/ActionGrid/plugin.json +++ b/Plugins/ActionGrid/plugin.json @@ -240,5 +240,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "portable", + "exclusions": [] } } diff --git a/Plugins/ActivityBar/Sources/ActivityBarComponentView.swift b/Plugins/ActivityBar/Sources/ActivityBarComponentView.swift index 538d505d4..22c13bff3 100644 --- a/Plugins/ActivityBar/Sources/ActivityBarComponentView.swift +++ b/Plugins/ActivityBar/Sources/ActivityBarComponentView.swift @@ -29,7 +29,7 @@ enum ActivityBarTrendMode: String, CaseIterable { } } -private enum ActivityBarChartRange: String, CaseIterable { +enum ActivityBarChartRange: String, CaseIterable { case oneDay = "1d" case sevenDays = "7d" case fourteenDays = "14d" @@ -157,15 +157,24 @@ struct ActivityBarComponentView: View { @State private var hoveredDate: String? @State private var hoveredScreenTimeDate: String? - @AppStorage("activity-bar.stats-expanded") private var statsExpanded = true - @AppStorage("activity-bar.chart-range") private var chartRange = ActivityBarChartRange.sevenDays + @ObservedObject private var preferences: ActivityBarPresentationPreferences + private var statsExpanded: Bool { + get { preferences.statsExpanded } + nonmutating set { preferences.statsExpanded = newValue } + } + private var chartRange: ActivityBarChartRange { + get { preferences.chartRange } + nonmutating set { preferences.chartRange = newValue } + } init( controller: ActivityBarController, + preferences: ActivityBarPresentationPreferences, localization: PluginLocalization = PluginLocalization(bundle: .main), presentation: ActivityBarComponentPresentation = ActivityBarComponentPresentation(), onContentHeightChange: @escaping (CGFloat) -> Void = { _ in } ) { + self.preferences = preferences self.controller = controller self.localization = localization self.presentation = presentation diff --git a/Plugins/ActivityBar/Sources/ActivityBarPlugin.swift b/Plugins/ActivityBar/Sources/ActivityBarPlugin.swift index 3ab92d8be..2b786cd7f 100644 --- a/Plugins/ActivityBar/Sources/ActivityBarPlugin.swift +++ b/Plugins/ActivityBar/Sources/ActivityBarPlugin.swift @@ -93,6 +93,7 @@ final class ActivityBarPlugin: MacToolsPlugin, PluginActionProviding, PluginActi ) private let localization: PluginLocalization + let preferences: ActivityBarPresentationPreferences private let controller: ActivityBarController private final class PresentationReference { weak var value: ActivityBarComponentPresentation? @@ -115,6 +116,7 @@ final class ActivityBarPlugin: MacToolsPlugin, PluginActionProviding, PluginActi controller: ActivityBarController? = nil, localization: PluginLocalization = PluginLocalization(bundle: .main) ) { + self.preferences = ActivityBarPresentationPreferences(storage: context.storage) self.localization = localization self.controller = controller ?? ActivityBarController(context: context, localization: localization) self.controller.onStatisticsChange = { [weak self] in @@ -311,6 +313,7 @@ final class ActivityBarPlugin: MacToolsPlugin, PluginActionProviding, PluginActi return AnyView( ActivityBarComponentView( controller: controller, + preferences: preferences, localization: localization, presentation: presentation, onContentHeightChange: context.reportContentHeight @@ -551,3 +554,18 @@ final class ActivityBarPlugin: MacToolsPlugin, PluginActionProviding, PluginActi } } } + +extension ActivityBarPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { preferences.onPersistentPreferencesChange } + set { preferences.onPersistentPreferencesChange = newValue } + } + func makePortablePreferencesBackup() -> Data? { preferences.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard preferences.restorePortableBackup(data) else { return false } + onStateChange?() + return true + } +} diff --git a/Plugins/ActivityBar/Sources/ActivityBarPresentationPreferences.swift b/Plugins/ActivityBar/Sources/ActivityBarPresentationPreferences.swift new file mode 100644 index 000000000..94855847d --- /dev/null +++ b/Plugins/ActivityBar/Sources/ActivityBarPresentationPreferences.swift @@ -0,0 +1,49 @@ +import Combine +import Foundation +import MacToolsPluginKit + +@MainActor +final class ActivityBarPresentationPreferences: ObservableObject { + private let storage: any PluginStorage + private var isRestoring = false + var onPersistentPreferencesChange: (() -> Void)? + @Published var statsExpanded: Bool { + didSet { + guard !isRestoring, statsExpanded != oldValue else { return } + guard portableStore.commit(["stats-expanded": .boolean(statsExpanded)]) else { isRestoring = true; statsExpanded = oldValue; isRestoring = false; return } + onPersistentPreferencesChange?() + } + } + @Published var chartRange: ActivityBarChartRange { + didSet { + guard !isRestoring, chartRange != oldValue else { return } + guard portableStore.commit(["chart-range": .string(chartRange.rawValue)]) else { isRestoring = true; chartRange = oldValue; isRestoring = false; return } + onPersistentPreferencesChange?() + } + } + init(storage: any PluginStorage) { + self.storage = storage + _ = Self.portableStore(storage: storage).recover() + storage.migrateValueIfNeeded(fromLegacyKey: "activity-bar.stats-expanded", to: "stats-expanded") + storage.migrateValueIfNeeded(fromLegacyKey: "activity-bar.chart-range", to: "chart-range") + statsExpanded = storage.object(forKey: "stats-expanded") as? Bool ?? true + chartRange = storage.string(forKey: "chart-range").flatMap(ActivityBarChartRange.init(rawValue:)) ?? .sevenDays + } + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + "stats-expanded": .boolean(true), "chart-range": .choice("7d", allowed: ActivityBarChartRange.allCases.map(\.rawValue)) + ]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + func portableBackup() -> Data? { portableStore.backup() } + func restorePortableBackup(_ data: Data) -> Bool { + let previous = portableStore.currentValues() + guard portableStore.restore(data) else { return false } + isRestoring = true + statsExpanded = storage.object(forKey: "stats-expanded") as? Bool ?? true + chartRange = storage.string(forKey: "chart-range").flatMap(ActivityBarChartRange.init(rawValue:)) ?? .sevenDays + isRestoring = false + if previous != portableStore.currentValues() { onPersistentPreferencesChange?() } + return true + } +} diff --git a/Plugins/ActivityBar/Tests/ActivityBarPluginTests.swift b/Plugins/ActivityBar/Tests/ActivityBarPluginTests.swift index 4793b643f..9c347e78b 100644 --- a/Plugins/ActivityBar/Tests/ActivityBarPluginTests.swift +++ b/Plugins/ActivityBar/Tests/ActivityBarPluginTests.swift @@ -6,6 +6,21 @@ import MacToolsPluginKit @MainActor final class ActivityBarPluginTests: XCTestCase { + func testPortablePresentationDoesNotImportTrackingOrHistory() throws { + let source = ActivityBarPresentationPreferences(storage: ActivityBarMemoryStorage()) + source.statsExpanded = false + source.chartRange = .thirtyDays + let storage = ActivityBarMemoryStorage() + storage.set(false, forKey: "tracking.enabled") + storage.set(Data("private".utf8), forKey: "statistics") + let destination = ActivityBarPresentationPreferences(storage: storage) + XCTAssertTrue(destination.restorePortableBackup(try XCTUnwrap(source.portableBackup()))) + XCTAssertFalse(destination.statsExpanded) + XCTAssertEqual(destination.chartRange, .thirtyDays) + XCTAssertEqual(storage.object(forKey: "tracking.enabled") as? Bool, false) + XCTAssertEqual(storage.data(forKey: "statistics"), Data("private".utf8)) + } + func testSwitchStartsAndStopsRuntime() { let harness = makeHarness() diff --git a/Plugins/ActivityBar/plugin.json b/Plugins/ActivityBar/plugin.json index b7cd71c26..6f6d336d9 100644 --- a/Plugins/ActivityBar/plugin.json +++ b/Plugins/ActivityBar/plugin.json @@ -326,5 +326,12 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "local-activation", + "history" + ] } } diff --git a/Plugins/ActivityBar/project.yml b/Plugins/ActivityBar/project.yml index bb51d619c..fa9ca6038 100644 --- a/Plugins/ActivityBar/project.yml +++ b/Plugins/ActivityBar/project.yml @@ -1,3 +1,7 @@ settings: base: OTHER_LDFLAGS: -framework ApplicationServices -framework Charts + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/AppHotkey/Sources/AppHotkeyModels.swift b/Plugins/AppHotkey/Sources/AppHotkeyModels.swift index 0f78718ec..69abbbe47 100644 --- a/Plugins/AppHotkey/Sources/AppHotkeyModels.swift +++ b/Plugins/AppHotkey/Sources/AppHotkeyModels.swift @@ -8,18 +8,24 @@ struct AppShortcutEntry: Codable, Identifiable, Equatable { var bundleURLString: String var displayName: String var shortcut: ShortcutBinding? + var portableApplicationReference: PortableApplicationReference? - var bundleURL: URL? { URL(string: bundleURLString) } + var bundleURL: URL? { + guard !bundleURLString.isEmpty, let url = URL(string: bundleURLString), url.isFileURL, + url.pathExtension == "app" else { return nil } + return url + } init( id: UUID = UUID(), - bundleURL: URL, + bundleURL: URL?, displayName: String, shortcut: ShortcutBinding? = nil ) { self.id = id - self.bundleURLString = bundleURL.absoluteString + self.bundleURLString = bundleURL?.absoluteString ?? "" self.displayName = displayName self.shortcut = shortcut + self.portableApplicationReference = nil } } diff --git a/Plugins/AppHotkey/Sources/AppHotkeyPlugin.swift b/Plugins/AppHotkey/Sources/AppHotkeyPlugin.swift index 37c0003b7..54709c9f8 100644 --- a/Plugins/AppHotkey/Sources/AppHotkeyPlugin.swift +++ b/Plugins/AppHotkey/Sources/AppHotkeyPlugin.swift @@ -203,8 +203,8 @@ final class AppHotkeyPlugin: func handleAction(_ action: PluginPanelAction) { switch action { case let .setSwitch(value): + guard store.setEnabled(value) else { return } isEnabled = value - storage.set(value, forKey: "isEnabled") onStateChange?() default: break @@ -243,7 +243,7 @@ final class AppHotkeyPlugin: id: "entryID", title: localization.string("action.launch.app", defaultValue: "应用"), kind: .string, - portability: .localOnly + portability: .portable ), ], externalInvocationPolicy: .allowed, @@ -339,3 +339,31 @@ final class AppHotkeyPlugin: } } + +extension AppHotkeyPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling, PluginPortablePreferencesActionReferencesProviding, PluginActionReferenceBackupProviding { + var onPersistentPreferencesChange: (() -> Void)? { + get { store.onPersistentPreferencesChange } + set { store.onPersistentPreferencesChange = newValue } + } + func makePortablePreferencesBackup() -> Data? { store.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard store.restorePortableBackup(data) else { return false } + isEnabled = store.isEnabled + onStateChange?() + return true + } + func actionReferences(inPortablePreferences data: Data) -> [ActionReference]? { + store.actionEntryIDs(inPortableBackup: data)?.compactMap { id in + guard let parameters = try? ActionParameterSet(["entryID": .string(id.uuidString.lowercased())]) else { return nil } + return ActionReference(key: ActionKey(providerID: metadata.id, actionID: "launch"), parameters: parameters) + } + } + func backupDisposition(for reference: ActionReference) -> PluginActionReferenceBackupDisposition { + guard reference.key == launchDefinition.key, reference.parameters.entries.count == 1, + case let .string(raw)? = reference.parameters["entryID"], let id = UUID(uuidString: raw), + raw == id.uuidString.lowercased() else { return .excluded } + return .requiresPluginPreferences + } +} diff --git a/Plugins/AppHotkey/Sources/AppHotkeyStore.swift b/Plugins/AppHotkey/Sources/AppHotkeyStore.swift index efae50232..bc8e5dc31 100644 --- a/Plugins/AppHotkey/Sources/AppHotkeyStore.swift +++ b/Plugins/AppHotkey/Sources/AppHotkeyStore.swift @@ -15,6 +15,8 @@ final class AppHotkeyStore: ObservableObject { init(storage: PluginStorage) { self.storage = storage + encoder.outputFormatting = [.sortedKeys] + _ = Self.portableStore(storage: storage).recover() if let data = storage.data(forKey: Keys.entries), let loaded = try? JSONDecoder().decode([AppShortcutEntry].self, from: data) { self.entries = loaded @@ -24,30 +26,27 @@ final class AppHotkeyStore: ObservableObject { } func addEntry(_ entry: AppShortcutEntry) { - entries.append(entry) - persist() + _ = commitEntries(entries + [entry], portableChanged: true) } func updateShortcut(id: UUID, shortcut: ShortcutBinding?) { guard let idx = entries.firstIndex(where: { $0.id == id }) else { return } - entries[idx].shortcut = shortcut - persist() + var candidate = entries + candidate[idx].shortcut = shortcut + _ = commitEntries(candidate, portableChanged: false) } func deleteEntry(id: UUID) { - entries.removeAll { $0.id == id } - persist() + _ = commitEntries(entries.filter { $0.id != id }, portableChanged: true) } func clearAllShortcuts() { - var didChange = false - for index in entries.indices where entries[index].shortcut != nil { - entries[index].shortcut = nil - didChange = true - } - if didChange { - persist() + let candidate = entries.map { entry in + var copy = entry + copy.shortcut = nil + return copy } + _ = commitEntries(candidate, portableChanged: false) } /// Returns the existing entry that conflicts with the shortcut, excluding the current entry. @@ -55,8 +54,80 @@ final class AppHotkeyStore: ObservableObject { entries.first { $0.id != excludingID && $0.shortcut == shortcut } } - private func persist() { - guard let data = try? encoder.encode(entries) else { return } - storage.set(data, forKey: Keys.entries) + private struct PortableEntry: Codable, Equatable { + let id: UUID + let displayName: String + let application: PortableApplicationReference + } + private struct PortablePreferences: Codable { + let formatVersion: Int + let isEnabled: Bool + let entries: [PortableEntry] + } + var onPersistentPreferencesChange: (() -> Void)? + var isEnabled: Bool { storage.object(forKey: "isEnabled") as? Bool ?? true } + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + "isEnabled": .boolean(true), + Keys.entries: .data(Data("[]".utf8), maximumBytes: 1_024 * 1_024) { data in + guard let entries = try? JSONDecoder().decode([AppShortcutEntry].self, from: data) else { return false } + return entries.count <= 512 && Set(entries.map(\.id)).count == entries.count + } + ]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + @discardableResult + func setEnabled(_ value: Bool) -> Bool { + guard value != isEnabled else { return true } + guard portableStore.commit(["isEnabled": .boolean(value)]) else { return false } + onPersistentPreferencesChange?() + return true + } + private func decodedPortable(_ data: Data) -> PortablePreferences? { + guard data.count <= 1_024 * 1_024, let payload = try? decoder.decode(PortablePreferences.self, from: data), + payload.formatVersion == 1, payload.entries.count <= 512, + Set(payload.entries.map(\.id)).count == payload.entries.count, + payload.entries.allSatisfy({ $0.application.isValid && !$0.displayName.isEmpty && $0.displayName.utf8.count <= 256 + && !$0.displayName.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) }) else { return nil } + return payload + } + func portableBackup() -> Data? { + guard portableStore.backup() != nil else { return nil } + let payload = PortablePreferences(formatVersion: 1, isEnabled: isEnabled, entries: entries.map { + PortableEntry(id: $0.id, displayName: $0.displayName, + application: $0.portableApplicationReference ?? PortableApplicationReference(url: $0.bundleURL)) + }) + guard let data = try? encoder.encode(payload), decodedPortable(data) != nil else { return nil } + return data + } + func actionEntryIDs(inPortableBackup data: Data) -> [UUID]? { decodedPortable(data)?.entries.map(\.id) } + func restorePortableBackup(_ data: Data) -> Bool { + guard let payload = decodedPortable(data) else { return false } + var resolved = Set() + let candidate = payload.entries.map { incoming in + var url = incoming.application.resolvedURL() + if let candidateURL = url, !resolved.insert(candidateURL.path).inserted { url = nil } + var entry = AppShortcutEntry(id: incoming.id, bundleURL: url, displayName: incoming.displayName) + entry.portableApplicationReference = incoming.application + if let existing = entries.first(where: { $0.id == incoming.id }), + (existing.portableApplicationReference ?? PortableApplicationReference(url: existing.bundleURL)) == incoming.application { + entry.shortcut = existing.shortcut + } + return entry + } + guard let encoded = try? encoder.encode(candidate) else { return false } + let changed = entries != candidate || isEnabled != payload.isEnabled + guard portableStore.commit([Keys.entries: .data(encoded), "isEnabled": .boolean(payload.isEnabled)]) else { return false } + entries = candidate + if changed { onPersistentPreferencesChange?() } + return true + } + @discardableResult + private func commitEntries(_ candidate: [AppShortcutEntry], portableChanged: Bool) -> Bool { + guard entries != candidate else { return true } + guard let data = try? encoder.encode(candidate), portableStore.commit([Keys.entries: .data(data)]) else { return false } + entries = candidate + if portableChanged { onPersistentPreferencesChange?() } + return true } } diff --git a/Plugins/AppHotkey/Tests/AppHotkeyStoreTests.swift b/Plugins/AppHotkey/Tests/AppHotkeyStoreTests.swift index 9cc5d7328..0ac8255d6 100644 --- a/Plugins/AppHotkey/Tests/AppHotkeyStoreTests.swift +++ b/Plugins/AppHotkey/Tests/AppHotkeyStoreTests.swift @@ -44,6 +44,21 @@ final class AppHotkeyStoreTests: XCTestCase { XCTAssertTrue(AppHotkeyStore(storage: storage).entries.isEmpty) } + func testPortableEntriesKeepUUIDAndUnresolvedIdentityWithoutLegacyShortcut() throws { + let source = AppHotkeyStore(storage: InMemoryPluginStorage()) + let entry = AppShortcutEntry(bundleURL: URL(fileURLWithPath: "/missing/Example.app"), displayName: "Example", shortcut: .init(keyCode: 0, modifiers: [.command, .option])) + source.addEntry(entry) + let data = try XCTUnwrap(source.portableBackup()) + XCTAssertFalse(String(decoding: data, as: UTF8.self).contains("/missing/")) + let storage = InMemoryPluginStorage() + let destination = AppHotkeyStore(storage: storage) + XCTAssertTrue(destination.restorePortableBackup(data)) + XCTAssertEqual(destination.entries.first?.id, entry.id) + XCTAssertNil(destination.entries.first?.bundleURL) + XCTAssertNil(destination.entries.first?.shortcut) + XCTAssertEqual(AppHotkeyStore(storage: storage).portableBackup(), data) + } + func testConflictDetectionIgnoresExcludedEntry() { let store = AppHotkeyStore(storage: InMemoryPluginStorage()) let binding = ShortcutBinding(keyCode: 0, modifiers: [.command, .control]) diff --git a/Plugins/AppHotkey/plugin.json b/Plugins/AppHotkey/plugin.json index 91744d00e..b9de7f3ab 100644 --- a/Plugins/AppHotkey/plugin.json +++ b/Plugins/AppHotkey/plugin.json @@ -225,11 +225,11 @@ "id": "entryID", "kind": "string", "isRequired": true, - "portability": "localOnly" + "portability": "portable" } ], "parameterSummary": "@productStrings.action.launch.parameter-summary", - "localOnlyIdentity": true, + "localOnlyIdentity": false, "keywords": [ "app", "hotkey", @@ -247,7 +247,8 @@ "automatic-rule", "action-grid", "trackpad-gesture", - "manual" + "manual", + "app-intent" ], "automaticEligible": true, "externalInvocation": "allowed" @@ -255,5 +256,11 @@ ] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "application-paths" + ] } } diff --git a/Plugins/AppHotkey/project.yml b/Plugins/AppHotkey/project.yml new file mode 100644 index 000000000..137a0a089 --- /dev/null +++ b/Plugins/AppHotkey/project.yml @@ -0,0 +1,4 @@ +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift + - ../../Sources/PluginSupport/PortableApplicationReference.swift diff --git a/Plugins/AppVolume/Sources/AppVolumePlugin.swift b/Plugins/AppVolume/Sources/AppVolumePlugin.swift index 12d630107..14efd1fb7 100644 --- a/Plugins/AppVolume/Sources/AppVolumePlugin.swift +++ b/Plugins/AppVolume/Sources/AppVolumePlugin.swift @@ -1,4 +1,5 @@ import AppKit +import CoreAudio import Foundation import SwiftUI import MacToolsPluginKit @@ -85,6 +86,12 @@ final class AppVolumePlugin: MacToolsPlugin, PluginActionProviding, PluginAction private let openSystemAudioPrivacySettings: () -> Void private var snapshot = AudioApplicationSnapshot.empty + var onPersistentPreferencesChange: (() -> Void)? + private struct PlaybackVolume { + let processObjectIDs: Set + let gain: Double + } + private var preservedPlaybackVolumes: [String: PlaybackVolume] = [:] private var volumes: [String: Double] private var controlApplicationIDs: [String: String] = [:] private var isExpanded = false @@ -236,6 +243,7 @@ final class AppVolumePlugin: MacToolsPlugin, PluginActionProviding, PluginAction } func activate(context: PluginRuntimeContext) { + preservedPlaybackVolumes.removeAll() lifecycleGeneration &+= 1 isActive = true monitor.start() @@ -246,6 +254,7 @@ final class AppVolumePlugin: MacToolsPlugin, PluginActionProviding, PluginAction isActive = false canonicalRouteToken = nil canonicalRoutingVolumes = nil + preservedPlaybackVolumes.removeAll() pendingPanelVolumes.removeAll() lastRouteErrorMessage = nil accessTask?.cancel() @@ -381,14 +390,27 @@ final class AppVolumePlugin: MacToolsPlugin, PluginActionProviding, PluginAction private func receive(_ snapshot: AudioApplicationSnapshot) { guard isActive else { return } + preservedPlaybackVolumes = preservedPlaybackVolumes.filter { id, playback in + snapshot.applications.contains { + $0.id == id && Set($0.processObjectIDs) == playback.processObjectIDs + } + } self.snapshot = snapshot rebuildControlApplicationIDs() applyCurrentTargets() onStateChange?() } + private var playbackVolumes: [String: Double] { + volumes.merging(preservedPlaybackVolumes.mapValues(\.gain), uniquingKeysWith: { _, gain in gain }) + } + private func applyCurrentTargets() { - var effectiveVolumes = canonicalRoutingVolumes ?? volumes + var effectiveVolumes = playbackVolumes + if let canonicalRoutingVolumes { + // Only the edited app is overridden while an asynchronous route operation is pending. + for (id, gain) in canonicalRoutingVolumes { effectiveVolumes[id] = gain } + } for (applicationID, gain) in pendingPanelVolumes { effectiveVolumes[applicationID] = gain } @@ -504,8 +526,10 @@ final class AppVolumePlugin: MacToolsPlugin, PluginActionProviding, PluginAction let previousVolumes = volumes var candidateVolumes = volumes candidateVolumes[applicationID] = gain - canonicalRoutingVolumes = candidateVolumes - let targets = routingTargets(volumes: candidateVolumes) + var candidateRoutingVolumes = playbackVolumes + candidateRoutingVolumes[applicationID] = gain + canonicalRoutingVolumes = [applicationID: gain] + let targets = routingTargets(volumes: candidateRoutingVolumes) let applyResult = await router.applyAndWait( targets: targets, outputDeviceUID: snapshot.outputDeviceUID @@ -521,13 +545,15 @@ final class AppVolumePlugin: MacToolsPlugin, PluginActionProviding, PluginAction switch persistVolumes(candidateVolumes) { case .committed: volumes = candidateVolumes + preservedPlaybackVolumes.removeValue(forKey: applicationID) + if candidateVolumes != previousVolumes { onPersistentPreferencesChange?() } lastRouteErrorMessage = nil onStateChange?() return .succeeded() case let .rejected(rollbackSucceeded): - canonicalRoutingVolumes = previousVolumes + canonicalRoutingVolumes = nil let rollbackResult = await router.applyAndWait( - targets: routingTargets(volumes: previousVolumes), + targets: routingTargets(volumes: playbackVolumes), outputDeviceUID: snapshot.outputDeviceUID ) guard isCurrentCanonicalOperation( @@ -557,9 +583,9 @@ final class AppVolumePlugin: MacToolsPlugin, PluginActionProviding, PluginAction return .failed(message: message) } case .failed: - canonicalRoutingVolumes = previousVolumes + canonicalRoutingVolumes = nil let rollbackResult = await router.applyAndWait( - targets: routingTargets(volumes: previousVolumes), + targets: routingTargets(volumes: playbackVolumes), outputDeviceUID: snapshot.outputDeviceUID ) guard isCurrentCanonicalOperation( @@ -831,3 +857,44 @@ final class AppVolumePlugin: MacToolsPlugin, PluginActionProviding, PluginAction } } } + +extension AppVolumePlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + private struct PortableVolumes: Codable { + let formatVersion: Int + let applications: [String: Double] + } + private static func isPortableApplicationID(_ value: String) -> Bool { + value.utf8.count <= 255 && value.contains(".") && !value.hasPrefix("pid.") + && value.unicodeScalars.allSatisfy { CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-_")).contains($0) } + } + func makePortablePreferencesBackup() -> Data? { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let entries = volumes.filter { Self.isPortableApplicationID($0.key) } + guard entries.count <= 1_024, entries.values.allSatisfy({ $0.isFinite && (0...1).contains($0) }) else { return nil } + return try? encoder.encode(PortableVolumes(formatVersion: 1, applications: entries)) + } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard !isCanonicalRouteInProgress, pendingPanelVolumes.isEmpty, data.count <= 256 * 1_024, + let payload = try? JSONDecoder().decode(PortableVolumes.self, from: data), payload.formatVersion == 1, + payload.applications.count <= 1_024, + payload.applications.allSatisfy({ Self.isPortableApplicationID($0.key) && $0.value.isFinite && (0...1).contains($0.value) }) else { return false } + let candidate = volumes.filter { !Self.isPortableApplicationID($0.key) } + .merging(payload.applications, uniquingKeysWith: { _, incoming in incoming }) + guard case .committed = persistVolumes(candidate) else { return false } + if candidate != volumes { + if isActive { + let currentVolumes = playbackVolumes + preservedPlaybackVolumes = Dictionary(uniqueKeysWithValues: snapshot.applications.map { + ($0.id, PlaybackVolume(processObjectIDs: Set($0.processObjectIDs), gain: currentVolumes[$0.id] ?? 1)) + }) + } + volumes = candidate + onPersistentPreferencesChange?() + } + onStateChange?() + return true + } +} diff --git a/Plugins/AppVolume/Tests/AppVolumePluginTests.swift b/Plugins/AppVolume/Tests/AppVolumePluginTests.swift index 28343e131..be5505755 100644 --- a/Plugins/AppVolume/Tests/AppVolumePluginTests.swift +++ b/Plugins/AppVolume/Tests/AppVolumePluginTests.swift @@ -300,6 +300,142 @@ final class AppVolumePluginTests: XCTestCase { XCTAssertFalse(plugin.actionAvailability(for: reference).isAvailable) } + func testPortableRestorePreservesRunningAudioRouteAndSavesFuturePreference() async throws { + let storage = AppVolumeStorageMock() + let monitor = AppVolumeMonitorMock() + let router = AppVolumeRouterMock(accessResult: true) + let plugin = makePlugin(storage: storage, monitor: monitor, router: router) + plugin.activate(context: PluginRuntimeContext(pluginID: "app-volume")) + defer { plugin.deactivate(reason: .hostShutdown) } + let apps = [application(id: "com.example.music", name: "Music", objectID: 51)] + monitor.send(snapshot(applications: apps)) + plugin.handleAction(.setDisclosureExpanded(true)) + let sliderID = try XCTUnwrap(plugin.rowState.detail?.controls.first?.id) + plugin.handleAction(.setSlider(controlID: sliderID, value: 0.4, phase: .ended)) + for _ in 0..<100 where router.lastTargets.first?.gain != 0.4 { await Task.yield() } + XCTAssertEqual(router.lastTargets.first?.gain, 0.4) + let updateCount = router.updates.count + let accessCount = router.accessRequestCount + let data = try JSONSerialization.data(withJSONObject: ["formatVersion": 1, "applications": ["com.example.music": 0.8]]) + XCTAssertTrue(plugin.restorePortablePreferencesReportingResult(from: data)) + XCTAssertEqual(router.updates.count, updateCount) + XCTAssertEqual(router.accessRequestCount, accessCount) + monitor.send(snapshot(applications: apps)) + await Task.yield() + XCTAssertEqual(router.lastTargets.first?.gain, 0.4) + monitor.send(snapshot(applications: [])) + monitor.send(snapshot(applications: [application(id: "com.example.music", name: "Music", objectID: 52)])) + XCTAssertEqual(router.lastTargets.first?.gain, 0.8) + let reloaded = makePlugin(storage: storage) + let exported = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(reloaded.makePortablePreferencesBackup())) as? [String: Any]) + XCTAssertEqual((exported["applications"] as? [String: Double])?["com.example.music"], 0.8) + } + + func testImportAppliesToSilentAppsAndNewPlaybackProcesses() async throws { + for transition in ["silentAtImport", "newApp", "replacementProcess"] { + let storage = AppVolumeStorageMock() + storage.setRawValue(try JSONEncoder().encode(["com.example.music": 0.4, "com.example.video": 0.2]), + forKey: "applicationVolumes") + let monitor = AppVolumeMonitorMock() + let router = AppVolumeRouterMock(accessResult: true) + let plugin = makePlugin(storage: storage, monitor: monitor, router: router) + plugin.activate(context: PluginRuntimeContext(pluginID: "app-volume")) + defer { plugin.deactivate(reason: .hostShutdown) } + let music = application(id: "com.example.music", name: "Music", objectID: 51) + monitor.send(snapshot(applications: [music])) + plugin.handlePermissionAction(id: "system-audio-recording") + for _ in 0..<100 where !plugin.permissionState(for: "system-audio-recording").isGranted { await Task.yield() } + XCTAssertTrue(plugin.permissionState(for: "system-audio-recording").isGranted) + if transition == "silentAtImport" { monitor.send(snapshot(applications: [])) } + + let data = try JSONSerialization.data(withJSONObject: ["formatVersion": 1, + "applications": ["com.example.music": 0.8, "com.example.video": 0.9]]) + let updateCount = router.updates.count + let accessCount = router.accessRequestCount + XCTAssertTrue(plugin.restorePortablePreferencesReportingResult(from: data)) + XCTAssertEqual(router.updates.count, updateCount) + XCTAssertEqual(router.accessRequestCount, accessCount) + + let nextMusic = transition == "replacementProcess" + ? application(id: "com.example.music", name: "Music", objectID: 61) : music + monitor.send(snapshot(applications: [nextMusic, + application(id: "com.example.video", name: "Video", objectID: 52)])) + XCTAssertEqual(router.lastTargets.first { $0.id == music.id }?.gain, + transition == "newApp" ? 0.4 : 0.8, transition) + XCTAssertEqual(router.lastTargets.first { $0.id == "com.example.video" }?.gain, 0.9, transition) + } + } + + func testPlaybackTurnoverDuringSuspendedEditUsesImportedGainOnRollback() async throws { + let storage = AppVolumeStorageMock() + storage.setRawValue(try JSONEncoder().encode(["com.example.music": 0.4, "com.example.video": 0.2]), + forKey: "applicationVolumes") + let monitor = AppVolumeMonitorMock() + let router = AppVolumeRouterMock(accessResult: true) + let plugin = makePlugin(storage: storage, monitor: monitor, router: router) + plugin.activate(context: PluginRuntimeContext(pluginID: "app-volume")) + defer { plugin.deactivate(reason: .hostShutdown) } + let video = application(id: "com.example.video", name: "Video", objectID: 52) + monitor.send(snapshot(applications: [application(id: "com.example.music", name: "Music", objectID: 51), video])) + let data = try JSONSerialization.data(withJSONObject: ["formatVersion": 1, + "applications": ["com.example.music": 0.8, "com.example.video": 0.9]]) + XCTAssertTrue(plugin.restorePortablePreferencesReportingResult(from: data)) + router.suspendNextApply = true + let reference = try XCTUnwrap(plugin.actionCatalogEntries.first { + $0.reference.parameters["application"] == .string(video.id) + && $0.reference.parameters["volume"] == .double(0.5) + }?.reference) + let task = Task { try await plugin.beginAction(ActionInvocation( + reference: reference, source: .test, mode: .background)).result() } + for _ in 0..<100 where !router.hasSuspendedApply { await Task.yield() } + XCTAssertTrue(router.hasSuspendedApply) + monitor.send(snapshot(applications: [application(id: "com.example.music", name: "Music", objectID: 61), video])) + XCTAssertEqual(router.lastTargets.first { $0.id == "com.example.music" }?.gain, 0.8) + router.completeSuspendedApply(.failed) + let result = try await task.value + if case .failed = result {} else { XCTFail("Expected route failure") } + XCTAssertEqual(router.lastTargets.first { $0.id == "com.example.music" }?.gain, 0.8) + XCTAssertEqual(router.lastTargets.first { $0.id == video.id }?.gain, 0.2) + } + + func testPostImportVolumeEditPreservesOtherLiveGainsAndRollsBackFailures() async throws { + for failure in ["none", "route", "storage"] { + let storage = AppVolumeStorageMock() + storage.setRawValue(try JSONEncoder().encode(["com.example.music": 0.4, "com.example.video": 0.2]), + forKey: "applicationVolumes") + let monitor = AppVolumeMonitorMock() + let router = AppVolumeRouterMock(accessResult: true) + let plugin = makePlugin(storage: storage, monitor: monitor, router: router) + plugin.activate(context: PluginRuntimeContext(pluginID: "app-volume")) + defer { plugin.deactivate(reason: .hostShutdown) } + let apps = [application(id: "com.example.music", name: "Music", objectID: 51), + application(id: "com.example.video", name: "Video", objectID: 52)] + monitor.send(snapshot(applications: apps)) + let incoming = try JSONSerialization.data(withJSONObject: ["formatVersion": 1, + "applications": ["com.example.music": 0.8, "com.example.video": 0.9]]) + XCTAssertTrue(plugin.restorePortablePreferencesReportingResult(from: incoming)) + if failure == "route" { router.applyResults = [.failed, .succeeded] } + if failure == "storage" { storage.blockedSetKeys = ["applicationVolumes"] } + let reference = try XCTUnwrap(plugin.actionCatalogEntries.first { + $0.reference.parameters["application"] == .string("com.example.video") + && $0.reference.parameters["volume"] == .double(0.5) + }?.reference) + let result = try await plugin.beginAction(ActionInvocation( + reference: reference, source: .test, mode: .background)).result() + if failure == "none" { XCTAssertEqual(result, .succeeded()) } + else if case .failed = result {} else { XCTFail("Expected \(failure) failure") } + let expectedVideo = failure == "none" ? 0.5 : 0.2 + XCTAssertEqual(router.lastTargets.first { $0.id == "com.example.music" }?.gain, 0.4) + XCTAssertEqual(router.lastTargets.first { $0.id == "com.example.video" }?.gain, expectedVideo) + monitor.send(snapshot(applications: apps)) + XCTAssertEqual(router.lastTargets.first { $0.id == "com.example.music" }?.gain, 0.4) + XCTAssertEqual(router.lastTargets.first { $0.id == "com.example.video" }?.gain, expectedVideo) + let saved = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(plugin.makePortablePreferencesBackup())) as? [String: Any]) + XCTAssertEqual(saved["applications"] as? [String: Double], + ["com.example.music": 0.8, "com.example.video": failure == "none" ? 0.5 : 0.9]) + } + } + private func makePlugin( storage: AppVolumeStorageMock? = nil, monitor: AppVolumeMonitorMock? = nil, diff --git a/Plugins/AppVolume/plugin.json b/Plugins/AppVolume/plugin.json index c089f8b62..c692ff953 100644 --- a/Plugins/AppVolume/plugin.json +++ b/Plugins/AppVolume/plugin.json @@ -431,5 +431,12 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "permissions", + "device-state" + ] } } diff --git a/Plugins/Appearance/plugin.json b/Plugins/Appearance/plugin.json index 43e853402..76265a5d0 100644 --- a/Plugins/Appearance/plugin.json +++ b/Plugins/Appearance/plugin.json @@ -460,5 +460,9 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/AppleShortcuts/plugin.json b/Plugins/AppleShortcuts/plugin.json index 53c057182..33ce74723 100644 --- a/Plugins/AppleShortcuts/plugin.json +++ b/Plugins/AppleShortcuts/plugin.json @@ -288,5 +288,11 @@ ] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "private-content" + ] } } diff --git a/Plugins/AutoHideDock/plugin.json b/Plugins/AutoHideDock/plugin.json index f39628d26..6cfe28d48 100644 --- a/Plugins/AutoHideDock/plugin.json +++ b/Plugins/AutoHideDock/plugin.json @@ -281,5 +281,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/AutoHideMenuBar/plugin.json b/Plugins/AutoHideMenuBar/plugin.json index ff5e6b542..f39756e0a 100644 --- a/Plugins/AutoHideMenuBar/plugin.json +++ b/Plugins/AutoHideMenuBar/plugin.json @@ -330,5 +330,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/AutoInput/Sources/AutoInputController.swift b/Plugins/AutoInput/Sources/AutoInputController.swift index e1e85ee2c..6b80b03cc 100644 --- a/Plugins/AutoInput/Sources/AutoInputController.swift +++ b/Plugins/AutoInput/Sources/AutoInputController.swift @@ -199,6 +199,7 @@ final class AutoInputController: ObservableObject { private var isFocusMonitoringActive = false private var applicationActivationObserver: NSObjectProtocol? private var operationGeneration = 0 + private var defersAutomaticActionsUntilApplicationActivation = false private var inputSourceOwnerBundleIdentifier: String? private var hudTriggerPolicy: AutoInputHUDTriggerPolicy private var hudFrequencyPolicy = AutoInputHUDFrequencyPolicy() @@ -255,7 +256,11 @@ final class AutoInputController: ObservableObject { self?.handleSourcesChanged() } applicationMonitor.onApplicationActivated = { [weak self] application in - self?.handleApplicationActivated(application) + guard let self else { return } + let wasDeferred = self.defersAutomaticActionsUntilApplicationActivation + self.defersAutomaticActionsUntilApplicationActivation = false + if wasDeferred { self.reconcileActiveServices(processCurrentApplication: false) } + self.handleApplicationActivated(application) } focusObserver.onEditableFocusChanged = { [weak self] focus in self?.handleEditableFocusChanged(focus) @@ -270,6 +275,7 @@ final class AutoInputController: ObservableObject { } func stop() { + defersAutomaticActionsUntilApplicationActivation = false guard isStarted else { return } operationGeneration += 1 restoreStandardFunctionKeysIfNeeded() @@ -301,6 +307,7 @@ final class AutoInputController: ObservableObject { } func configurationDidChange(promptForAccessibility: Bool = false) { + defersAutomaticActionsUntilApplicationActivation = false if !store.isAutoSwitchEnabled { operationGeneration += 1 errorMessage = nil @@ -313,6 +320,24 @@ final class AutoInputController: ObservableObject { onStateChange?() } + func configurationDidRestore() { + operationGeneration += 1 + defersAutomaticActionsUntilApplicationActivation = true + currentApplication = applicationMonitor.frontmostApplication + inputSourceOwnerBundleIdentifier = currentApplication?.bundleIdentifier + focusedElement = nil + hudPresenter.dismiss() + hudTriggerPolicy.reset(currentSourceID: sourceController.currentSourceID) + hudFrequencyPolicy.reset() + sourceController.refresh() + sources = sourceController.sources + synchronizeHUDReminderPreferences() + refreshAccessibilityPermission(prompt: false) + reconcilePermissionObservation() + reconcileActiveServices(processCurrentApplication: false) + onStateChange?() + } + func refresh() { refreshAccessibilityPermission(prompt: false) reconcilePermissionObservation() @@ -464,7 +489,7 @@ final class AutoInputController: ObservableObject { } private func rememberCurrentSourceIfNeeded() { - guard autoSwitchActive, store.remembersLastInputSource, + guard !defersAutomaticActionsUntilApplicationActivation, autoSwitchActive, store.remembersLastInputSource, let bundleIdentifier = inputSourceOwnerBundleIdentifier ?? focusedApplicationBundleIdentifier ?? currentApplication?.bundleIdentifier @@ -476,7 +501,7 @@ final class AutoInputController: ObservableObject { } private func rememberCurrentSourceIfNeeded(for bundleIdentifier: String) { - guard autoSwitchActive, store.remembersLastInputSource, + guard !defersAutomaticActionsUntilApplicationActivation, autoSwitchActive, store.remembersLastInputSource, let sourceID = validCurrentSourceID else { return } @@ -512,21 +537,22 @@ final class AutoInputController: ObservableObject { isStarted && isInteractive && store.hasKeyboardModeRules } - private func reconcileActiveServices() { + private func reconcileActiveServices(processCurrentApplication: Bool = true) { // Canonical input-source actions remain available even when automatic // switching and the optional HUD are both off. let shouldMonitorSources = isStarted && isInteractive let shouldMonitorApplications = autoSwitchActive || hudPermissionObservationActive || keyboardModeActive + || (isStarted && isInteractive && defersAutomaticActionsUntilApplicationActivation && appliedStandardFunctionKeys != nil) setSourceMonitoring(active: shouldMonitorSources) setApplicationMonitoring(active: shouldMonitorApplications) - if shouldMonitorApplications, + if processCurrentApplication, !defersAutomaticActionsUntilApplicationActivation, shouldMonitorApplications, let application = applicationMonitor.frontmostApplication { handleApplicationActivated(application) } - if !keyboardModeActive { + if !defersAutomaticActionsUntilApplicationActivation, !keyboardModeActive { restoreStandardFunctionKeysIfNeeded() } setFocusMonitoring(active: hudActive) @@ -692,6 +718,7 @@ final class AutoInputController: ObservableObject { } private func transitionInputSourceOwnerIfNeeded(to bundleIdentifier: String) { + guard !defersAutomaticActionsUntilApplicationActivation else { return } guard inputSourceOwnerBundleIdentifier != bundleIdentifier else { return } if let previousBundleIdentifier = inputSourceOwnerBundleIdentifier { rememberCurrentSourceIfNeeded(for: previousBundleIdentifier) diff --git a/Plugins/AutoInput/Sources/AutoInputPlugin.swift b/Plugins/AutoInput/Sources/AutoInputPlugin.swift index 2c6d1df47..a7e4ba918 100644 --- a/Plugins/AutoInput/Sources/AutoInputPlugin.swift +++ b/Plugins/AutoInput/Sources/AutoInputPlugin.swift @@ -642,3 +642,18 @@ final class AutoInputPlugin: MacToolsPlugin, PluginApplicationActivityStateHandl return .succeeded() } } + +extension AutoInputPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { store.onPersistentPreferencesChange } + set { store.onPersistentPreferencesChange = newValue } + } + func makePortablePreferencesBackup() -> Data? { store.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard store.restorePortableBackup(data) else { return false } + controller.configurationDidRestore() + return true + } +} diff --git a/Plugins/AutoInput/Sources/AutoInputStore.swift b/Plugins/AutoInput/Sources/AutoInputStore.swift index 157862a2c..03786a65e 100644 --- a/Plugins/AutoInput/Sources/AutoInputStore.swift +++ b/Plugins/AutoInput/Sources/AutoInputStore.swift @@ -42,6 +42,7 @@ final class AutoInputStore: ObservableObject { init(storage: PluginStorage) { self.storage = storage + _ = Self.portableStore(storage: storage).recover() Self.migrateAutoSwitchPreferenceIfNeeded(storage: storage) self.isAutoSwitchEnabled = storage.object(forKey: Keys.isAutoSwitchEnabled) == nil ? true @@ -82,6 +83,80 @@ final class AutoInputStore: ObservableObject { self.memories = decodedMemories.filter { !$0.key.isEmpty && !$0.value.isEmpty } } + var onPersistentPreferencesChange: (() -> Void)? + private static func validIdentifier(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 512 + && value.unicodeScalars.allSatisfy { !CharacterSet.whitespacesAndNewlines.union(.controlCharacters).union(CharacterSet(charactersIn: "/\\")).contains($0) } + } + private static func validRules(_ data: Data) -> Bool { + guard let rules = try? JSONDecoder().decode([AutoInputRule].self, from: data), rules.count <= 256, + Set(rules.map(\.bundleIdentifier)).count == rules.count else { return false } + return rules.allSatisfy { validIdentifier($0.bundleIdentifier) && validIdentifier($0.inputSourceID) + && $0.displayName.utf8.count <= 256 && !$0.displayName.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) } + } + private static func validMemories(_ data: Data) -> Bool { + guard let values = try? JSONDecoder().decode([String: String].self, from: data), values.count <= 1_024 else { return false } + return values.allSatisfy { validIdentifier($0.key) && validIdentifier($0.value) } + } + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + Keys.isAutoSwitchEnabled: .boolean(true), + Keys.isInputHUDEnabled: .boolean(false), + Keys.reducesFrequentHUDPresentations: .boolean(false), + Keys.isInteractiveHUDEnabled: .boolean(false), + Keys.remembersLastInputSource: .boolean(true), + Keys.inputHUDReminderIntervalSeconds: .integer(60, range: 5...300), + Keys.inputHUDAppSwitchReminderCount: .integer(3, range: 1...10), + Keys.inputHUDSize: .choice("standard", allowed: AutoInputHUDSize.allCases.map(\.rawValue)), + Keys.inputHUDPosition: .choice("automatic", allowed: AutoInputHUDPosition.allCases.map(\.rawValue)), + Keys.rules: .data(Data("[]".utf8), maximumBytes: 1_024 * 1_024, validate: validRules), + Keys.memories: .data(Data("{}".utf8), maximumBytes: 1_024 * 1_024, validate: validMemories) + ]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + func portableBackup() -> Data? { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let portableRules = rules.map { rule in + var copy = rule + copy.bundleURLString = nil + return copy + } + guard let data = try? encoder.encode(portableRules) else { return nil } + return portableStore.backup(overriding: [Keys.rules: .data(data)]) + } + func restorePortableBackup(_ data: Data) -> Bool { + guard var incoming = portableStore.validatedValues(from: data) else { return false } + if case let .data(ruleData) = incoming[Keys.rules] { + guard let restored = try? JSONDecoder().decode([AutoInputRule].self, from: ruleData), + restored.allSatisfy({ $0.bundleURLString == nil }) else { return false } + let rebound = restored.map { rule in + var copy = rule + copy.bundleURLString = rules.first { $0.bundleIdentifier == rule.bundleIdentifier }?.bundleURLString + return copy + } + guard let persisted = try? encoder.encode(rebound) else { return false } + incoming[Keys.rules] = .data(persisted) + } + let previous = portableStore.currentValues() + guard portableStore.commit(incoming) else { return false } + let reloaded = AutoInputStore(storage: storage) + isAutoSwitchEnabled = reloaded.isAutoSwitchEnabled + isInputHUDEnabled = reloaded.isInputHUDEnabled + reducesFrequentHUDPresentations = reloaded.reducesFrequentHUDPresentations + isInteractiveHUDEnabled = reloaded.isInteractiveHUDEnabled + remembersLastInputSource = reloaded.remembersLastInputSource + inputHUDReminderIntervalSeconds = reloaded.inputHUDReminderIntervalSeconds + inputHUDAppSwitchReminderCount = reloaded.inputHUDAppSwitchReminderCount + inputHUDSize = reloaded.inputHUDSize + inputHUDPosition = reloaded.inputHUDPosition + rules = reloaded.rules + memories = reloaded.memories + persistenceFailure = nil + if previous != portableStore.currentValues() { onPersistentPreferencesChange?() } + return true + } + @discardableResult func setAutoSwitchEnabled(_ value: Bool) -> AutoInputStoreMutationResult { guard isAutoSwitchEnabled != value else { return record(.committed) } @@ -240,6 +315,7 @@ final class AutoInputStore: ObservableObject { restore(previous, forKey: key) return .rejected(rollbackSucceeded: rawValue(storage.object(forKey: key), equals: previous)) } + onPersistentPreferencesChange?() return .committed } @@ -250,6 +326,7 @@ final class AutoInputStore: ObservableObject { restore(previous, forKey: key) return .rejected(rollbackSucceeded: rawValue(storage.object(forKey: key), equals: previous)) } + onPersistentPreferencesChange?() return .committed } @@ -261,6 +338,7 @@ final class AutoInputStore: ObservableObject { restore(previous, forKey: key) return .rejected(rollbackSucceeded: rawValue(storage.object(forKey: key), equals: previous)) } + onPersistentPreferencesChange?() return .committed } @@ -279,6 +357,7 @@ final class AutoInputStore: ObservableObject { rollbackSucceeded: rawValue(storage.object(forKey: key), equals: previous) ) } + onPersistentPreferencesChange?() return .committed } diff --git a/Plugins/AutoInput/Tests/AutoInputPluginTests.swift b/Plugins/AutoInput/Tests/AutoInputPluginTests.swift index 8315762ed..86fea538c 100644 --- a/Plugins/AutoInput/Tests/AutoInputPluginTests.swift +++ b/Plugins/AutoInput/Tests/AutoInputPluginTests.swift @@ -441,6 +441,23 @@ final class AutoInputControllerTests: XCTestCase { XCTAssertEqual(fixture.functionKeys.writes, [false, true, true]) } + func testRestoreDefersInputAndFunctionKeyChangesUntilRealApplicationActivation() throws { + let fixture = makeFixture(currentSourceID: "en") + fixture.controller.start() + defer { fixture.controller.stop() } + let incoming = AutoInputStore(storage: AutoInputMemoryStorage()) + incoming.upsertRule(makeRule(bundleID: fixture.app.bundleIdentifier, sourceID: "zh", keyboardMode: .standardFunctionKeys)) + XCTAssertTrue(fixture.store.restorePortableBackup(try XCTUnwrap(incoming.portableBackup()))) + fixture.controller.configurationDidRestore() + fixture.controller.refresh() + fixture.focusObserver.refreshFocusedElement() + XCTAssertTrue(fixture.sources.selectedIDs.isEmpty) + XCTAssertTrue(fixture.functionKeys.writes.isEmpty) + fixture.applications.activate(fixture.app) + XCTAssertEqual(fixture.sources.selectedIDs, ["zh"]) + XCTAssertEqual(fixture.functionKeys.writes, [true]) + } + private func makeFixture( currentSourceID: String, accessibilityGranted: Bool = false, diff --git a/Plugins/AutoInput/plugin.json b/Plugins/AutoInput/plugin.json index e7a9caf4b..76f6a035b 100644 --- a/Plugins/AutoInput/plugin.json +++ b/Plugins/AutoInput/plugin.json @@ -330,5 +330,11 @@ ] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "application-paths" + ] } } diff --git a/Plugins/AutoInput/project.yml b/Plugins/AutoInput/project.yml new file mode 100644 index 000000000..b6e2b5ad1 --- /dev/null +++ b/Plugins/AutoInput/project.yml @@ -0,0 +1,3 @@ +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitPlugin.swift b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitPlugin.swift index 13feb5928..0509deb7c 100644 --- a/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitPlugin.swift +++ b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitPlugin.swift @@ -1108,3 +1108,18 @@ private extension BatteryChargeWriteError { return false } } + +extension BatteryChargeLimitPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { store.onPersistentPreferencesChange } + set { store.onPersistentPreferencesChange = newValue } + } + func makePortablePreferencesBackup() -> Data? { store.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard store.restorePortableBackup(data) else { return false } + onStateChange?() + return true + } +} diff --git a/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitStore.swift b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitStore.swift index ccfcd4c2f..2409e8dba 100644 --- a/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitStore.swift +++ b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitStore.swift @@ -38,6 +38,7 @@ final class BatteryChargeLimitStore: ObservableObject { init(storage: PluginStorage) { self.storage = storage + _ = Self.portableStore(storage: storage).recover() // isEnabled — default off; user must opt in. We store the inverse of // "explicitly disabled" so the first launch is a clean disabled state. @@ -61,6 +62,26 @@ final class BatteryChargeLimitStore: ObservableObject { } } + var onPersistentPreferencesChange: (() -> Void)? + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + Key.limitPercent: .integer(BatteryChargeLimits.defaultPercent, range: BatteryChargeLimits.minimumPercent...BatteryChargeLimits.maximumPercent), + Key.mode: .choice("holdAtLimit", allowed: ["holdAtLimit", "charging", "discharging"]) + ]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + func portableBackup() -> Data? { portableStore.backup() } + func restorePortableBackup(_ data: Data) -> Bool { + // An active session reads these values on timer/wake events. Preserve it until locally disabled. + guard !isEnabled else { return false } + let previous = portableStore.currentValues() + guard portableStore.restore(data) else { return false } + limitPercent = storage.object(forKey: Key.limitPercent) as? Int ?? BatteryChargeLimits.defaultPercent + mode = storage.string(forKey: Key.mode).flatMap(BatteryChargeMode.init(rawValue:)) ?? .holdAtLimit + if previous != portableStore.currentValues() { onPersistentPreferencesChange?() } + return true + } + // MARK: - Mutators @discardableResult @@ -106,9 +127,11 @@ final class BatteryChargeLimitStore: ObservableObject { return .rejected(rollbackSucceeded: rawStateMatches(previousRaw)) } + let portableChanged = limitPercent != normalized.limitPercent || mode != normalized.mode isEnabled = normalized.isEnabled limitPercent = normalized.limitPercent mode = normalized.mode + if portableChanged { onPersistentPreferencesChange?() } return .committed } diff --git a/Plugins/BatteryChargeLimit/Tests/BatteryChargeLimitPluginTests.swift b/Plugins/BatteryChargeLimit/Tests/BatteryChargeLimitPluginTests.swift index e5ff57fef..019fce359 100644 --- a/Plugins/BatteryChargeLimit/Tests/BatteryChargeLimitPluginTests.swift +++ b/Plugins/BatteryChargeLimit/Tests/BatteryChargeLimitPluginTests.swift @@ -70,6 +70,23 @@ private final class MockBatteryWriter: BatteryChargeLimitWriting { @MainActor final class BatteryChargeLimitPluginTests: XCTestCase { + func testPortableParametersDoNotActivateAndRejectAnActiveDestination() throws { + let source = BatteryChargeLimitStore(storage: BatteryChargeLimitMemoryStorage()) + _ = source.setLimitPercent(65) + _ = source.setMode(.charging) + let destination = BatteryChargeLimitStore(storage: BatteryChargeLimitMemoryStorage()) + let data = try XCTUnwrap(source.portableBackup()) + XCTAssertTrue(destination.restorePortableBackup(data)) + XCTAssertEqual(destination.limitPercent, 65) + XCTAssertEqual(destination.mode, .charging) + XCTAssertFalse(destination.isEnabled) + _ = destination.setEnabled(true) + _ = destination.setLimitPercent(80) + XCTAssertFalse(destination.restorePortableBackup(data)) + XCTAssertEqual(destination.limitPercent, 80) + XCTAssertTrue(destination.isEnabled) + } + // MARK: Metadata diff --git a/Plugins/BatteryChargeLimit/plugin.json b/Plugins/BatteryChargeLimit/plugin.json index c810609de..18d30160e 100644 --- a/Plugins/BatteryChargeLimit/plugin.json +++ b/Plugins/BatteryChargeLimit/plugin.json @@ -438,5 +438,12 @@ ] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "local-activation", + "device-state" + ] } } diff --git a/Plugins/BatteryChargeLimit/project.yml b/Plugins/BatteryChargeLimit/project.yml index 179a51efc..14fffd7ac 100644 --- a/Plugins/BatteryChargeLimit/project.yml +++ b/Plugins/BatteryChargeLimit/project.yml @@ -4,6 +4,7 @@ settings: core: sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift - path: SMCHelper/Sources/BatteryForceDischargePolicy.swift targets: diff --git a/Plugins/Calendar/Sources/CalendarPlugin.swift b/Plugins/Calendar/Sources/CalendarPlugin.swift index 5c0239dbe..7d81412da 100644 --- a/Plugins/Calendar/Sources/CalendarPlugin.swift +++ b/Plugins/Calendar/Sources/CalendarPlugin.swift @@ -425,3 +425,25 @@ final class CalendarPlugin: MacToolsPlugin { NSWorkspace.shared.open(url) } } + +// MARK: - Portable preferences + +extension CalendarPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { settingsStore.onPersistentPreferencesChange } + set { settingsStore.onPersistentPreferencesChange = newValue } + } + + func makePortablePreferencesBackup() -> Data? { settingsStore.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard settingsStore.restorePortableBackup(data) else { return false } + measuredComponentSpanHeight = nil + viewModel.setWeekStartDay(settingsStore.weekStartDay) + viewModel.setAlternateCalendar(settingsStore.alternateCalendar) + viewModel.configureAgenda(range: settingsStore.agendaRange, isVisible: settingsStore.showsRecentAgenda) + onStateChange?() + return true + } +} diff --git a/Plugins/Calendar/Sources/CalendarSettings.swift b/Plugins/Calendar/Sources/CalendarSettings.swift index 19ef43127..01da256da 100644 --- a/Plugins/Calendar/Sources/CalendarSettings.swift +++ b/Plugins/Calendar/Sources/CalendarSettings.swift @@ -94,6 +94,7 @@ final class CalendarSettingsStore: ObservableObject { init(storage: PluginStorage, languageIdentifier: String? = nil) { self.storage = storage + _ = Self.portableStore(storage: storage).recover() let legacySelection: CalendarAlternateCalendar? switch storage.string(forKey: StorageKey.legacyLunarDisplay) { case "shown": legacySelection = .chinese @@ -123,13 +124,40 @@ final class CalendarSettingsStore: ObservableObject { ) } + var onPersistentPreferencesChange: (() -> Void)? + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + StorageKey.weekStartDay: .choice("sunday", allowed: CalendarWeekStartDay.allCases.map(\.rawValue)), + StorageKey.showsRecentAgenda: .boolean(true), + StorageKey.agendaDayCount: .integer(3, range: 1...7), + StorageKey.agendaDirection: .choice("future", allowed: CalendarAgendaDirection.allCases.map(\.rawValue)), + StorageKey.alternateCalendar: .choice("none", allowed: CalendarAlternateCalendar.allCases.map(\.rawValue)), + ]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + func portableBackup() -> Data? { portableStore.backup() } + func restorePortableBackup(_ data: Data) -> Bool { + let previous = portableStore.currentValues() + guard portableStore.restore(data) else { return false } + weekStartDay = storage.string(forKey: StorageKey.weekStartDay).flatMap(CalendarWeekStartDay.init(rawValue:)) ?? .sunday + showsRecentAgenda = storage.object(forKey: StorageKey.showsRecentAgenda) as? Bool ?? true + agendaRange = CalendarAgendaRange( + dayCount: storage.object(forKey: StorageKey.agendaDayCount) as? Int ?? 3, + direction: storage.string(forKey: StorageKey.agendaDirection).flatMap(CalendarAgendaDirection.init(rawValue:)) ?? .future + ) + alternateCalendar = storage.string(forKey: StorageKey.alternateCalendar).flatMap(CalendarAlternateCalendar.init(rawValue:)) ?? .none + if previous != portableStore.currentValues() { onPersistentPreferencesChange?() } + return true + } + func setWeekStartDay(_ day: CalendarWeekStartDay) { guard weekStartDay != day else { return } + guard portableStore.commit([StorageKey.weekStartDay: .string(day.rawValue)]) else { return } weekStartDay = day - storage.set(day.rawValue, forKey: StorageKey.weekStartDay) + onPersistentPreferencesChange?() } func setShowsRecentAgenda(_ value: Bool) { @@ -137,20 +165,25 @@ final class CalendarSettingsStore: ObservableObject { return } + guard portableStore.commit([StorageKey.showsRecentAgenda: .boolean(value)]) else { return } showsRecentAgenda = value - storage.set(value, forKey: StorageKey.showsRecentAgenda) + onPersistentPreferencesChange?() } func setAgendaRange(_ range: CalendarAgendaRange) { guard agendaRange != range else { return } + guard portableStore.commit([ + StorageKey.agendaDayCount: .integer(range.dayCount), + StorageKey.agendaDirection: .string(range.direction.rawValue) + ]) else { return } agendaRange = range - storage.set(range.dayCount, forKey: StorageKey.agendaDayCount) - storage.set(range.direction.rawValue, forKey: StorageKey.agendaDirection) + onPersistentPreferencesChange?() } func setAlternateCalendar(_ calendar: CalendarAlternateCalendar) { guard alternateCalendar != calendar else { return } + guard portableStore.commit([StorageKey.alternateCalendar: .string(calendar.rawValue)]) else { return } alternateCalendar = calendar - storage.set(calendar.rawValue, forKey: StorageKey.alternateCalendar) + onPersistentPreferencesChange?() } } diff --git a/Plugins/Calendar/Tests/CalendarSettingsStoreTests.swift b/Plugins/Calendar/Tests/CalendarSettingsStoreTests.swift index fd8c4e749..fad5826ac 100644 --- a/Plugins/Calendar/Tests/CalendarSettingsStoreTests.swift +++ b/Plugins/Calendar/Tests/CalendarSettingsStoreTests.swift @@ -5,6 +5,23 @@ import MacToolsPluginKit @MainActor final class CalendarSettingsStoreTests: XCTestCase { + func testPortableSettingsPreserveExplicitCalendarAcrossLanguages() throws { + let source = CalendarSettingsStore(storage: CalendarSettingsMemoryStorage(), languageIdentifier: "en") + source.setWeekStartDay(.monday) + source.setAgendaRange(CalendarAgendaRange(dayCount: 5, direction: .past)) + source.setShowsRecentAgenda(false) + let storage = CalendarSettingsMemoryStorage() + storage.set("private", forKey: "local-calendar") + let destination = CalendarSettingsStore(storage: storage, languageIdentifier: "zh-Hans") + XCTAssertTrue(destination.restorePortableBackup(try XCTUnwrap(source.portableBackup()))) + let reloaded = CalendarSettingsStore(storage: storage, languageIdentifier: "zh-Hans") + XCTAssertEqual(reloaded.alternateCalendar, .none) + XCTAssertEqual(reloaded.weekStartDay, .monday) + XCTAssertFalse(reloaded.showsRecentAgenda) + XCTAssertEqual(reloaded.agendaRange, CalendarAgendaRange(dayCount: 5, direction: .past)) + XCTAssertEqual(storage.string(forKey: "local-calendar"), "private") + } + func testWeekStartPersistsAndReloads() { let storage = CalendarSettingsMemoryStorage() diff --git a/Plugins/Calendar/plugin.json b/Plugins/Calendar/plugin.json index 20d01d214..aad9d1a10 100644 --- a/Plugins/Calendar/plugin.json +++ b/Plugins/Calendar/plugin.json @@ -205,5 +205,12 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "permissions", + "private-content" + ] } } diff --git a/Plugins/Calendar/project.yml b/Plugins/Calendar/project.yml index 230356a19..0c2646c51 100644 --- a/Plugins/Calendar/project.yml +++ b/Plugins/Calendar/project.yml @@ -14,3 +14,7 @@ bundle: - "$(SRCROOT)/Plugins/Calendar/CalendarPluginResources/ChinaHolidayOverrides.json" outputFiles: - "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/CalendarPluginResources/ChinaHolidayOverrides.json" + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/ClipboardClear/plugin.json b/Plugins/ClipboardClear/plugin.json index fb5fd6424..d6a202c59 100644 --- a/Plugins/ClipboardClear/plugin.json +++ b/Plugins/ClipboardClear/plugin.json @@ -219,5 +219,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/ClipboardHistory/Sources/ClipboardHistoryController.swift b/Plugins/ClipboardHistory/Sources/ClipboardHistoryController.swift index 3404d7bb6..20fbcfcfa 100644 --- a/Plugins/ClipboardHistory/Sources/ClipboardHistoryController.swift +++ b/Plugins/ClipboardHistory/Sources/ClipboardHistoryController.swift @@ -813,7 +813,7 @@ final class ClipboardHistoryController: NSObject, ObservableObject { private var pendingDeletedItemIDs = Set() private var storageGeneration: UInt64 = 0 private var isSuspendedForBackup = false - private var preservesRestoredHistory = false + private var preservesRestoredHistory: Bool { settings.isRetentionDeferred } private var reloadAfterStop = false private var needsSettingsReconciliation = false private var lastSeenChangeCount: Int @@ -951,7 +951,12 @@ final class ClipboardHistoryController: NSObject, ObservableObject { func start(preservingRestoredItems: Bool = false) { guard !isSuspendedForBackup else { return } - preservesRestoredHistory = preservingRestoredItems + guard !preservingRestoredItems || settings.setRetentionDeferred(true) else { + errorMessage = errorMessageProvider(CocoaError(.fileWriteUnknown)) + notifyChanged(reschedulesRetention: false) + return + } + let preservesExistingItems = preservesRestoredHistory copyEventMonitor?.start { [weak self] in self?.scheduleEventAssistedCapture() } if reloadAfterStop { reloadAfterStop = false @@ -978,19 +983,23 @@ final class ClipboardHistoryController: NSObject, ObservableObject { settings: initialSettings, protectedItemIDs: initialProtectedItemIDs, shortcutRetainedItemIDs: initialShortcutRetainedItemIDs, - preservingRestoredItems: preservingRestoredItems + preservingRestoredItems: preservesExistingItems ) guard !Task.isCancelled, let self else { return } var retainedItems = outcome.retainedItems var appliedPolicyRevision = initialPolicyRevision while appliedPolicyRevision != self.capturePolicyRevision { appliedPolicyRevision = self.capturePolicyRevision - retainedItems = await worker.prune( - outcome.loadedItems, - settings: self.settings.snapshot, - protectedItemIDs: self.effectiveRetentionProtectedItemIDs, - shortcutRetainedItemIDs: self.shortcutRetainedItemIDs - ) + if self.preservesRestoredHistory { + retainedItems = outcome.loadedItems + } else { + retainedItems = await worker.prune( + outcome.loadedItems, + settings: self.settings.snapshot, + protectedItemIDs: self.effectiveRetentionProtectedItemIDs, + shortcutRetainedItemIDs: self.shortcutRetainedItemIDs + ) + } guard !Task.isCancelled else { return } } self.finishLoading(outcome.loadedItems, retainedItems: retainedItems) @@ -1046,9 +1055,23 @@ final class ClipboardHistoryController: NSObject, ObservableObject { } } + var canRestorePortablePreferences: Bool { + !isSuspendedForBackup && loadTask == nil && !isMutatingItems && !hasPendingDurableItemIDs + && durableMutationTask == nil && !needsSettingsReconciliation + } + + func portablePreferencesDidRestore() { + capturePolicyRevision &+= 1 + retentionTimer?.invalidate() + retentionTimer = nil + cancelPendingCaptureProcessing() + cancelPendingPasteboardPayloadRead() + refreshProtectedCapacityState() + notifyChanged() + } + func settingsDidChange() { guard !isSuspendedForBackup else { return } - preservesRestoredHistory = false capturePolicyRevision &+= 1 if hasPendingDurableItemIDs { needsSettingsReconciliation = true } if settings.isPaused { @@ -1074,6 +1097,11 @@ final class ClipboardHistoryController: NSObject, ObservableObject { notifyChanged() return } + guard !preservesRestoredHistory else { + refreshProtectedCapacityState() + notifyChanged(reschedulesRetention: false) + return + } let pruned = ClipboardRetentionPolicy.prune( items, settings: settings.snapshot, @@ -1124,7 +1152,6 @@ final class ClipboardHistoryController: NSObject, ObservableObject { currentHistoryItemPasteboardState = nil // Internal writes advance `lastSeenChangeCount` at their source. A delta that reaches the // monitor is therefore external and must end an implicit queue before retention runs. - preservesRestoredHistory = false onExternalPasteboardChange?() // Consume suppression before asking for types, source context, or text. Private copies must @@ -1504,6 +1531,11 @@ final class ClipboardHistoryController: NSObject, ObservableObject { guard updated != items else { return } + guard settings.setRetentionDeferred(false) else { + errorMessage = errorMessageProvider(CocoaError(.fileWriteUnknown)) + notifyChanged(reschedulesRetention: false) + return + } // A capture with no eviction changes exactly one identity, even when it // moves an existing clip to the front. Avoid diffing every unchanged row. let evictedItemCount = retention?.evictedItemCount ?? 0 diff --git a/Plugins/ClipboardHistory/Sources/ClipboardHistoryPlugin.swift b/Plugins/ClipboardHistory/Sources/ClipboardHistoryPlugin.swift index 385454986..e76d84bfa 100644 --- a/Plugins/ClipboardHistory/Sources/ClipboardHistoryPlugin.swift +++ b/Plugins/ClipboardHistory/Sources/ClipboardHistoryPlugin.swift @@ -3037,3 +3037,32 @@ final class ClipboardHistoryPlugin: } } } + +extension ClipboardHistoryPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { settingsStore.onPersistentPreferencesChange } + set { settingsStore.onPersistentPreferencesChange = newValue } + } + func makePortablePreferencesBackup() -> Data? { settingsStore.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard !isBackingUpClipboard, controller.canRestorePortablePreferences else { return false } + let revision = settingsStore.portableRevision + guard settingsStore.restorePortableBackup(data) else { return false } + if settingsStore.portableRevision != revision { + controller.portablePreferencesDidRestore() + if sequentialPasteHUD.isVisible { showSequentialPasteHUD() } + } + onStateChange?() + return true + } +} + + +extension ClipboardHistoryPlugin: PluginActionReferenceBackupProviding { + func backupDisposition(for reference: ActionReference) -> PluginActionReferenceBackupDisposition { + guard reference.key.providerID == metadata.id else { return .excluded } + return ClipboardItemShortcutStore.target(for: reference.key.actionID) == nil ? .selfContained : .excluded + } +} diff --git a/Plugins/ClipboardHistory/Sources/ClipboardHistorySettingsStore.swift b/Plugins/ClipboardHistory/Sources/ClipboardHistorySettingsStore.swift index a96146b57..b7e029707 100644 --- a/Plugins/ClipboardHistory/Sources/ClipboardHistorySettingsStore.swift +++ b/Plugins/ClipboardHistory/Sources/ClipboardHistorySettingsStore.swift @@ -22,6 +22,7 @@ enum ClipboardSnippetKeywordExpansionStatus: Equatable, Sendable { @MainActor final class ClipboardHistorySettingsStore: ObservableObject { private enum Key { + static let retentionDeferred = "retention-restoration-deferred.v1" static let isPaused = "collection-paused" static let maximumItemCount = "maximum-item-count" static let expirationDays = "expiration-days" @@ -64,7 +65,19 @@ final class ClipboardHistorySettingsStore: ObservableObject { @Published var maximumExpandedTextByteCount: Int { didSet { - storage.set(Self.validExpandedTextByteCount(maximumExpandedTextByteCount), forKey: Key.maximumExpandedTextByteCount) + guard !isApplyingPortableSnapshot, maximumExpandedTextByteCount != oldValue else { return } + let candidate = Self.validExpandedTextByteCount(maximumExpandedTextByteCount) + let previous = portableStore.currentValues() + guard portableStore.commit([Key.maximumExpandedTextByteCount: .integer(candidate)]) else { + isApplyingPortableSnapshot = true + maximumExpandedTextByteCount = oldValue + isApplyingPortableSnapshot = false + return + } + if previous != portableStore.currentValues() { + portableRevision &+= 1 + onPersistentPreferencesChange?() + } onChange?() } } @@ -82,41 +95,94 @@ final class ClipboardHistorySettingsStore: ObservableObject { @Published var maximumItemCount: Int { didSet { - storage.set(Self.validItemCount(maximumItemCount), forKey: Key.maximumItemCount) + guard !isApplyingPortableSnapshot, maximumItemCount != oldValue else { return } + let candidate = Self.validItemCount(maximumItemCount) + let previous = portableStore.currentValues() + guard portableStore.commit([Key.maximumItemCount: .integer(candidate), Key.retentionDeferred: .boolean(false)]) else { + isApplyingPortableSnapshot = true + maximumItemCount = oldValue + isApplyingPortableSnapshot = false + return + } + if previous != portableStore.currentValues() { + portableRevision &+= 1 + onPersistentPreferencesChange?() + } onChange?() } } @Published var expiration: ClipboardHistoryExpiration { didSet { - storage.set(expiration.rawValue, forKey: Key.expirationDays) + guard !isApplyingPortableSnapshot, expiration != oldValue else { return } + let candidate = expiration + let previous = portableStore.currentValues() + guard portableStore.commit([Key.expirationDays: .integer(candidate.rawValue), Key.retentionDeferred: .boolean(false)]) else { + isApplyingPortableSnapshot = true + expiration = oldValue + isApplyingPortableSnapshot = false + return + } + if previous != portableStore.currentValues() { + portableRevision &+= 1 + onPersistentPreferencesChange?() + } onChange?() } } @Published var maximumItemByteCount: Int { didSet { - storage.set(Self.validItemByteCount(maximumItemByteCount), forKey: Key.maximumItemByteCount) + guard !isApplyingPortableSnapshot, maximumItemByteCount != oldValue else { return } + let candidate = Self.validItemByteCount(maximumItemByteCount) + let previous = portableStore.currentValues() + guard portableStore.commit([Key.maximumItemByteCount: .integer(candidate), Key.retentionDeferred: .boolean(false)]) else { + isApplyingPortableSnapshot = true + maximumItemByteCount = oldValue + isApplyingPortableSnapshot = false + return + } + if previous != portableStore.currentValues() { + portableRevision &+= 1 + onPersistentPreferencesChange?() + } onChange?() } } @Published var maximumTotalPayloadByteCount: Int { didSet { - storage.set( - Self.validTotalPayloadByteCount(maximumTotalPayloadByteCount), - forKey: Key.maximumTotalPayloadByteCount - ) + guard !isApplyingPortableSnapshot, maximumTotalPayloadByteCount != oldValue else { return } + let candidate = Self.validTotalPayloadByteCount(maximumTotalPayloadByteCount) + let previous = portableStore.currentValues() + guard portableStore.commit([Key.maximumTotalPayloadByteCount: .integer(candidate), Key.retentionDeferred: .boolean(false)]) else { + isApplyingPortableSnapshot = true + maximumTotalPayloadByteCount = oldValue + isApplyingPortableSnapshot = false + return + } + if previous != portableStore.currentValues() { + portableRevision &+= 1 + onPersistentPreferencesChange?() + } onChange?() } } @Published var excludedApplications: [ClipboardExcludedApplication] { didSet { - if let data = try? JSONEncoder().encode( - Self.normalizedApplications(excludedApplications) - ) { - storage.set(data, forKey: Key.excludedApplications) + guard !isApplyingPortableSnapshot, excludedApplications != oldValue else { return } + let candidate = Self.normalizedApplications(excludedApplications) + let previous = portableStore.currentValues() + guard let data = try? JSONEncoder().encode(candidate), portableStore.commit([Key.excludedApplications: .data(data)]) else { + isApplyingPortableSnapshot = true + excludedApplications = oldValue + isApplyingPortableSnapshot = false + return + } + if previous != portableStore.currentValues() { + portableRevision &+= 1 + onPersistentPreferencesChange?() } onChange?() } @@ -127,14 +193,38 @@ final class ClipboardHistorySettingsStore: ObservableObject { @Published var sequentialHUDDismissal: ClipboardSequentialHUDDismissal { didSet { - storage.set(sequentialHUDDismissal.rawValue, forKey: Key.sequentialHUDDismissal) + guard !isApplyingPortableSnapshot, sequentialHUDDismissal != oldValue else { return } + let candidate = sequentialHUDDismissal + let previous = portableStore.currentValues() + guard portableStore.commit([Key.sequentialHUDDismissal: .integer(candidate.rawValue)]) else { + isApplyingPortableSnapshot = true + sequentialHUDDismissal = oldValue + isApplyingPortableSnapshot = false + return + } + if previous != portableStore.currentValues() { + portableRevision &+= 1 + onPersistentPreferencesChange?() + } onChange?() } } @Published var hidesSequentialHUDPreview: Bool { didSet { - storage.set(hidesSequentialHUDPreview, forKey: Key.hidesSequentialHUDPreview) + guard !isApplyingPortableSnapshot, hidesSequentialHUDPreview != oldValue else { return } + let candidate = hidesSequentialHUDPreview + let previous = portableStore.currentValues() + guard portableStore.commit([Key.hidesSequentialHUDPreview: .boolean(candidate)]) else { + isApplyingPortableSnapshot = true + hidesSequentialHUDPreview = oldValue + isApplyingPortableSnapshot = false + return + } + if previous != portableStore.currentValues() { + portableRevision &+= 1 + onPersistentPreferencesChange?() + } onChange?() } } @@ -149,6 +239,87 @@ final class ClipboardHistorySettingsStore: ObservableObject { @Published private(set) var keywordExpansionStatus: ClipboardSnippetKeywordExpansionStatus @Published var keywordExpansionDiagnostic: ClipboardSnippetExpansionDiagnostic? + private var isApplyingPortableSnapshot = false + private(set) var portableRevision: UInt64 = 0 + var onPersistentPreferencesChange: (() -> Void)? + + private static func integer(_ fallback: Int, allowed: [Int]) -> PortablePreferenceField { + PortablePreferenceField(fallback: .integer(fallback)) { + if case let .integer(value) = $0 { allowed.contains(value) } else { false } + } + } + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + let defaults = ClipboardHistorySettings.defaults + return PortablePreferencesStore(storage: storage, fields: [ + Key.retentionDeferred: .boolean(false), + Key.maximumItemCount: integer(defaults.maximumItemCount, allowed: allowedItemCounts), + Key.expirationDays: integer(defaults.expiration.rawValue, allowed: ClipboardHistoryExpiration.allCases.map(\.rawValue)), + Key.maximumItemByteCount: integer(defaults.maximumItemByteCount, allowed: allowedItemByteCounts), + Key.maximumTotalPayloadByteCount: integer(defaults.maximumTotalPayloadByteCount, allowed: allowedTotalPayloadByteCounts), + Key.sequentialHUDDismissal: integer(10, allowed: ClipboardSequentialHUDDismissal.allCases.map(\.rawValue)), + Key.hidesSequentialHUDPreview: .boolean(false), + Key.maximumExpandedTextByteCount: integer(ClipboardSnippetExpansionContext.defaultMaximumUTF8ByteCount, allowed: allowedExpandedTextByteCounts), + Key.excludedApplications: .data(try! JSONEncoder().encode(defaults.excludedApplications), maximumBytes: 256 * 1_024) { data in + guard let apps = try? JSONDecoder().decode([ClipboardExcludedApplication].self, from: data), apps.count <= 512, + Set(apps.map { $0.bundleIdentifier.lowercased() }).count == apps.count else { return false } + return apps.allSatisfy { !$0.bundleIdentifier.isEmpty && $0.bundleIdentifier.utf8.count <= 255 && !$0.name.isEmpty && $0.name.utf8.count <= 256 + && !$0.bundleIdentifier.unicodeScalars.contains(where: CharacterSet.whitespacesAndNewlines.union(.controlCharacters).contains) + && !$0.name.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) } + } + ]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + private static var portableKeys: Set { + [Key.maximumItemCount, Key.expirationDays, Key.maximumItemByteCount, Key.maximumTotalPayloadByteCount, + Key.sequentialHUDDismissal, Key.hidesSequentialHUDPreview, Key.maximumExpandedTextByteCount, Key.excludedApplications] + } + + var isRetentionDeferred: Bool { + guard portableStore.recover(), + let values = portableStore.currentValues(including: [Key.retentionDeferred]), + case let .boolean(deferred) = values[Key.retentionDeferred] else { return true } + return deferred + } + + @discardableResult + func setRetentionDeferred(_ deferred: Bool) -> Bool { + guard portableStore.recover() else { return false } + if portableStore.currentValues(including: [Key.retentionDeferred])?[Key.retentionDeferred] == .boolean(deferred) { return true } + return portableStore.commit([Key.retentionDeferred: .boolean(deferred)]) + } + + func portableBackup() -> Data? { + guard let apps = try? JSONEncoder().encode(Self.normalizedApplications(excludedApplications).sorted { $0.bundleIdentifier < $1.bundleIdentifier }) else { return nil } + return portableStore.backup(including: Self.portableKeys, overriding: [Key.excludedApplications: .data(apps)]) + } + func restorePortableBackup(_ data: Data) -> Bool { + let previous = portableBackup() + guard var values = portableStore.validatedValues(from: data), + Set(values.keys).isSubset(of: Self.portableKeys), portableStore.recover(), + let current = portableStore.currentValues(including: Self.portableKeys) else { return false } + let retentionKeys: Set = [Key.maximumItemCount, Key.expirationDays, Key.maximumItemByteCount, Key.maximumTotalPayloadByteCount] + if retentionKeys.contains(where: { values[$0] != nil && values[$0] != current[$0] }) { + values[Key.retentionDeferred] = .boolean(true) + } + guard portableStore.commit(values) else { return false } + let restored = ClipboardHistorySettingsStore(storage: storage) + isApplyingPortableSnapshot = true + maximumItemCount = restored.maximumItemCount + expiration = restored.expiration + maximumItemByteCount = restored.maximumItemByteCount + maximumTotalPayloadByteCount = restored.maximumTotalPayloadByteCount + excludedApplications = restored.excludedApplications + sequentialHUDDismissal = restored.sequentialHUDDismissal + hidesSequentialHUDPreview = restored.hidesSequentialHUDPreview + maximumExpandedTextByteCount = restored.maximumExpandedTextByteCount + isApplyingPortableSnapshot = false + if previous != portableBackup() { + portableRevision &+= 1 + onPersistentPreferencesChange?() + } + return true + } + var onChange: (() -> Void)? private let storage: PluginStorage @@ -156,6 +327,7 @@ final class ClipboardHistorySettingsStore: ObservableObject { init(storage: PluginStorage) { self.storage = storage + _ = Self.portableStore(storage: storage).recover() if let data = storage.data(forKey: Key.panelPositions), let positions = try? JSONDecoder().decode([String: ClipboardHistoryPanelPosition].self, from: data) { panelPositions = positions.filter { $0.value.isValid } diff --git a/Plugins/ClipboardHistory/Tests/ClipboardHistoryPluginTests.swift b/Plugins/ClipboardHistory/Tests/ClipboardHistoryPluginTests.swift index d02b8ac6e..6aa9ad9f0 100644 --- a/Plugins/ClipboardHistory/Tests/ClipboardHistoryPluginTests.swift +++ b/Plugins/ClipboardHistory/Tests/ClipboardHistoryPluginTests.swift @@ -831,6 +831,50 @@ final class ClipboardHistoryPluginTests: XCTestCase { plugin.controller.stop() } + func testPortableRetentionRestorePreservesHistoryAcrossTimerAndRestart() async throws { + let item = ClipboardHistoryItem(id: UUID(), text: "Retain private history", capturedAt: Date().addingTimeInterval(-40 * 86_400), sourceApplication: nil, isPinned: false, lastUsedAt: nil) + let persistence = BlockingClipboardHistoryPersistence(items: [item]) + persistence.allowSaveToFinish() + let storage = FakePluginStorage() + storage.set(0, forKey: "expiration-days") + let plugin = makePlugin(pasteboard: PluginTestClipboardPasteboard(), persistence: persistence, storage: storage) + defer { plugin.controller.stop() } + plugin.controller.start() + await waitUntilLoaded(plugin.controller) + let source = makePlugin(pasteboard: PluginTestClipboardPasteboard(), storage: FakePluginStorage()) + source.controller.settings.expiration = .oneDay + storage.set(true, forKey: "saved-keyword-expansion-enabled") + XCTAssertTrue(plugin.restorePortablePreferencesReportingResult(from: try XCTUnwrap(source.makePortablePreferencesBackup()))) + plugin.controller.processRetentionExpiration(now: Date()) + XCTAssertEqual(plugin.controller.items.map(\.id), [item.id]) + XCTAssertEqual(plugin.controller.items.first?.text, item.text) + XCTAssertTrue(storage.bool(forKey: "saved-keyword-expansion-enabled")) + plugin.controller.stop() + plugin.controller.start() + await waitUntilLoaded(plugin.controller) + plugin.controller.processRetentionExpiration(now: Date()) + XCTAssertEqual(plugin.controller.items.map(\.id), [item.id]) + plugin.controller.stop() + let pasteboard = PluginTestClipboardPasteboard() + let reloaded = makePlugin(pasteboard: pasteboard, persistence: persistence, storage: storage) + defer { reloaded.controller.stop() } + reloaded.controller.start() + await waitUntilLoaded(reloaded.controller) + XCTAssertEqual(reloaded.controller.items.map(\.id), [item.id]) + // Unrelated UI preferences must not resume retention. + reloaded.controller.settings.hidesSequentialHUDPreview.toggle() + XCTAssertEqual(reloaded.controller.items.map(\.id), [item.id]) + pasteboard.simulateCopy("A new ordinary capture") + reloaded.controller.processPasteboardChange() + await reloaded.controller.waitForCaptureProcessingForTesting() + XCTAssertFalse(reloaded.controller.items.contains { $0.id == item.id }) + XCTAssertFalse(reloaded.controller.settings.isRetentionDeferred) + let prunedPersisted = await waitUntil { !persistence.savedItems.contains { $0.id == item.id } } + XCTAssertTrue(prunedPersisted) + let localReference = ActionReference(key: ActionKey(providerID: "clipboard", actionID: ClipboardItemShortcutStore.definitionID(for: item.id))) + XCTAssertEqual(plugin.backupDisposition(for: localReference), .excluded) + } + private func makePlugin( pasteboard: (any ClipboardPasteboardAccess)? = nil, persistence: (any ClipboardHistoryPersisting)? = nil, diff --git a/Plugins/ClipboardHistory/Tests/ClipboardHistorySettingsStoreTests.swift b/Plugins/ClipboardHistory/Tests/ClipboardHistorySettingsStoreTests.swift new file mode 100644 index 000000000..b8ec2c263 --- /dev/null +++ b/Plugins/ClipboardHistory/Tests/ClipboardHistorySettingsStoreTests.swift @@ -0,0 +1,72 @@ +import Foundation +import MacToolsPluginKit +import XCTest +@testable import ClipboardHistoryPlugin + +@MainActor +final class ClipboardHistorySettingsStoreTests: XCTestCase { + private let deferralKey = "retention-restoration-deferred.v1" + + func testPortableRetentionDeferralIsDurableAndNeverTransfers() throws { + let source = ClipboardHistorySettingsStore(storage: RetentionSettingsStorage()) + source.expiration = .oneDay + let storage = RetentionSettingsStorage() + storage.set(0, forKey: "expiration-days") + let destination = ClipboardHistorySettingsStore(storage: storage) + XCTAssertTrue(destination.restorePortableBackup(try XCTUnwrap(source.portableBackup()))) + XCTAssertTrue(ClipboardHistorySettingsStore(storage: storage).isRetentionDeferred) + let backup = try XCTUnwrap(destination.portableBackup()) + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: backup) as? [String: Any]) + var values = try XCTUnwrap(object["values"] as? [String: Any]) + XCTAssertNil(values[deferralKey]) + values[deferralKey] = try XCTUnwrap(values["sequential-hud-hide-preview"]) + object["values"] = values + XCTAssertFalse(destination.restorePortableBackup(try JSONSerialization.data(withJSONObject: object))) + destination.expiration = .sevenDays + XCTAssertFalse(ClipboardHistorySettingsStore(storage: storage).isRetentionDeferred) + } + + func testFailedDeferralWriteRollsBackImportedRetention() throws { + let source = ClipboardHistorySettingsStore(storage: RetentionSettingsStorage()) + source.expiration = .oneDay + let storage = RetentionSettingsStorage() + storage.set(0, forKey: "expiration-days") + let destination = ClipboardHistorySettingsStore(storage: storage) + let previous = destination.expiration + storage.blockedKeys = [deferralKey] + XCTAssertFalse(destination.restorePortableBackup(try XCTUnwrap(source.portableBackup()))) + XCTAssertEqual(destination.expiration, previous) + XCTAssertEqual(ClipboardHistorySettingsStore(storage: storage).expiration, previous) + } + + func testFailedResumeAndPendingRecoveryKeepRetentionDeferred() throws { + let source = ClipboardHistorySettingsStore(storage: RetentionSettingsStorage()) + source.expiration = .oneDay + let storage = RetentionSettingsStorage() + storage.set(0, forKey: "expiration-days") + let destination = ClipboardHistorySettingsStore(storage: storage) + XCTAssertTrue(destination.restorePortableBackup(try XCTUnwrap(source.portableBackup()))) + storage.blockedKeys = [deferralKey] + XCTAssertFalse(destination.setRetentionDeferred(false)) + destination.expiration = .sevenDays + XCTAssertEqual(destination.expiration, .oneDay) + XCTAssertTrue(ClipboardHistorySettingsStore(storage: storage).isRetentionDeferred) + storage.set(Data(), forKey: "preferences-backup.restore.v2") + XCTAssertTrue(ClipboardHistorySettingsStore(storage: storage).isRetentionDeferred) + } +} + +@MainActor +private final class RetentionSettingsStorage: PluginStorage { + private var values: [String: Any] = [:] + var blockedKeys: Set = [] + func object(forKey key: String) -> Any? { values[key] } + func data(forKey key: String) -> Data? { values[key] as? Data } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func integer(forKey key: String) -> Int { values[key] as? Int ?? 0 } + func bool(forKey key: String) -> Bool { values[key] as? Bool ?? false } + func set(_ value: Any?, forKey key: String) { if !blockedKeys.contains(key) { values[key] = value } } + func removeObject(forKey key: String) { values.removeValue(forKey: key) } + func migrateValueIfNeeded(fromLegacyKey legacyKey: String, to key: String) {} +} diff --git a/Plugins/ClipboardHistory/plugin.json b/Plugins/ClipboardHistory/plugin.json index 452bf1484..2fe34c617 100644 --- a/Plugins/ClipboardHistory/plugin.json +++ b/Plugins/ClipboardHistory/plugin.json @@ -232,7 +232,8 @@ ], "dataPersisted": [ "encrypted-clipboard-history", - "local-encryption-key" + "local-encryption-key", + "plugin-configuration" ], "retention": { "policy": "user-controlled" @@ -496,5 +497,12 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "private-content", + "local-activation" + ] } } diff --git a/Plugins/ClipboardHistory/project.yml b/Plugins/ClipboardHistory/project.yml index c997c9f7b..0c652f244 100644 --- a/Plugins/ClipboardHistory/project.yml +++ b/Plugins/ClipboardHistory/project.yml @@ -19,3 +19,7 @@ targets: MACOSX_DEPLOYMENT_TARGET: '14.0' CODE_SIGN_STYLE: Automatic DEVELOPMENT_TEAM: "$(DEVELOPMENT_TEAM)" + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/CloudflareR2/Sources/CloudflareR2Plugin.swift b/Plugins/CloudflareR2/Sources/CloudflareR2Plugin.swift index 8f59e48cc..496241af9 100644 --- a/Plugins/CloudflareR2/Sources/CloudflareR2Plugin.swift +++ b/Plugins/CloudflareR2/Sources/CloudflareR2Plugin.swift @@ -661,3 +661,18 @@ enum R2UploadStatus: Equatable { } } } + +extension CloudflareR2Plugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { configurationStore.onPersistentPreferencesChange } + set { configurationStore.onPersistentPreferencesChange = newValue } + } + func makePortablePreferencesBackup() -> Data? { configurationStore.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard configurationStore.restorePortableBackup(data) else { return false } + onStateChange?() + return true + } +} diff --git a/Plugins/CloudflareR2/Sources/R2Configuration.swift b/Plugins/CloudflareR2/Sources/R2Configuration.swift index 32fbbb205..a79b3053e 100644 --- a/Plugins/CloudflareR2/Sources/R2Configuration.swift +++ b/Plugins/CloudflareR2/Sources/R2Configuration.swift @@ -44,6 +44,7 @@ final class R2ConfigurationStore: ObservableObject { localization: PluginLocalization = PluginLocalization(bundle: .main) ) { self.storage = storage + _ = Self.portableStore(storage: storage).recover() self.secrets = secrets self.localization = localization accountID = storage.string(forKey: Key.accountID) ?? "" @@ -90,11 +91,13 @@ final class R2ConfigurationStore: ObservableObject { } func save() { + let previous = portableStore.currentValues() + guard portableStore.commit([ + Key.bucket: .string(bucket.trimmed), Key.publicBaseURL: .string(publicBaseURL.trimmed), Key.objectPrefix: .string(objectPrefix.trimmed) + ]) else { errorMessage = PluginKitLocalization.actionInvalidParameters; return } + if previous != portableStore.currentValues() { onPersistentPreferencesChange?() } storage.set(accountID.trimmed, forKey: Key.accountID) - storage.set(bucket.trimmed, forKey: Key.bucket) storage.set(accessKeyID.trimmed, forKey: Key.accessKeyID) - storage.set(publicBaseURL.trimmed, forKey: Key.publicBaseURL) - storage.set(objectPrefix.trimmed, forKey: Key.objectPrefix) do { if !secretAccessKey.trimmed.isEmpty { try secrets.saveSecret(secretAccessKey) @@ -107,6 +110,38 @@ final class R2ConfigurationStore: ObservableObject { } } + var onPersistentPreferencesChange: (() -> Void)? + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + Key.bucket: PortablePreferenceField(fallback: .string("")) { + if case let .string(value) = $0 { + value.isEmpty || (value.utf8.count <= 63 && value.utf8.count >= 3 + && value.unicodeScalars.allSatisfy { CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-.").contains($0) }) + } else { false } + }, + Key.publicBaseURL: PortablePreferenceField(fallback: .string("")) { + guard case let .string(value) = $0, value.utf8.count <= 2_048 else { return false } + if value.isEmpty { return true } + guard let parts = URLComponents(string: value), parts.user == nil, parts.password == nil else { return false } + return R2PublicURLValidator.baseURL(from: value) != nil + }, + Key.objectPrefix: PortablePreferenceField(fallback: .string("")) { + if case let .string(value) = $0 { value.utf8.count <= 1_024 && R2ObjectPrefixValidator.isValid(value) } else { false } + } + ]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + func portableBackup() -> Data? { portableStore.backup() } + func restorePortableBackup(_ data: Data) -> Bool { + let previous = portableStore.currentValues() + guard portableStore.restore(data) else { return false } + bucket = storage.string(forKey: Key.bucket) ?? "" + publicBaseURL = storage.string(forKey: Key.publicBaseURL) ?? "" + objectPrefix = storage.string(forKey: Key.objectPrefix) ?? "" + if previous != portableStore.currentValues() { onPersistentPreferencesChange?() } + return true + } + func loadSecret() throws -> String { guard let secret = try secrets.loadSecret(), !secret.isEmpty else { throw R2UploadError.missingSecret diff --git a/Plugins/CloudflareR2/Tests/R2ConfigurationTests.swift b/Plugins/CloudflareR2/Tests/R2ConfigurationTests.swift index 2c0e98700..d3351a14b 100644 --- a/Plugins/CloudflareR2/Tests/R2ConfigurationTests.swift +++ b/Plugins/CloudflareR2/Tests/R2ConfigurationTests.swift @@ -6,6 +6,28 @@ import XCTest @testable import CloudflareR2Plugin final class R2ConfigurationTests: XCTestCase { + @MainActor + func testPortableConfigurationPreservesCredentialsAndPendingSecret() throws { + let source = R2ConfigurationStore(storage: R2MemoryStorage(), secrets: R2SecretStoreMock(secret: nil)) + source.bucket = "public-bucket" + source.publicBaseURL = "https://files.example.com" + source.objectPrefix = "images" + source.save() + let storage = R2MemoryStorage(values: ["account-id": "local-account", "access-key-id": "local-key"]) + let secrets = R2SecretStoreMock(secret: "local-secret") + let destination = R2ConfigurationStore(storage: storage, secrets: secrets) + destination.secretAccessKey = "unsaved-secret" + XCTAssertTrue(destination.restorePortableBackup(try XCTUnwrap(source.portableBackup()))) + XCTAssertEqual(destination.bucket, "public-bucket") + XCTAssertEqual(destination.accountID, "local-account") + XCTAssertEqual(destination.accessKeyID, "local-key") + XCTAssertEqual(destination.secretAccessKey, "unsaved-secret") + XCTAssertEqual(secrets.saveCount, 0) + source.publicBaseURL = "https://user:password@files.example.com" + source.save() + XCTAssertNotNil(source.errorMessage) + } + func testNightlyServiceNamePreservesExistingStableNamespace() { for channel in [nil, "stable", "development", "unknown"] { XCTAssertEqual( diff --git a/Plugins/CloudflareR2/plugin.json b/Plugins/CloudflareR2/plugin.json index d4ea5609f..006517101 100644 --- a/Plugins/CloudflareR2/plugin.json +++ b/Plugins/CloudflareR2/plugin.json @@ -268,5 +268,13 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "credentials", + "local-activation", + "history" + ] } } diff --git a/Plugins/CloudflareR2/project.yml b/Plugins/CloudflareR2/project.yml new file mode 100644 index 000000000..5a47746a5 --- /dev/null +++ b/Plugins/CloudflareR2/project.yml @@ -0,0 +1,4 @@ + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/ContainerIndicator/plugin.json b/Plugins/ContainerIndicator/plugin.json index 26d084f3d..23205ccc6 100644 --- a/Plugins/ContainerIndicator/plugin.json +++ b/Plugins/ContainerIndicator/plugin.json @@ -207,5 +207,9 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/DeviceBattery/Sources/DeviceBatteryLowBatteryNotifier.swift b/Plugins/DeviceBattery/Sources/DeviceBatteryLowBatteryNotifier.swift index a45b2da15..4a970e9e1 100644 --- a/Plugins/DeviceBattery/Sources/DeviceBatteryLowBatteryNotifier.swift +++ b/Plugins/DeviceBattery/Sources/DeviceBatteryLowBatteryNotifier.swift @@ -10,6 +10,7 @@ struct DeviceBatteryLowBatteryNotification: Equatable { @MainActor protocol DeviceBatteryLowBatteryNotifying { + func requestAuthorization(completion: @escaping @MainActor (_ newlyGranted: Bool) -> Void) func notifyLowBatteryDevices( _ items: [DeviceBatteryItem], threshold: Int, @@ -17,6 +18,10 @@ protocol DeviceBatteryLowBatteryNotifying { ) } +extension DeviceBatteryLowBatteryNotifying { + func requestAuthorization(completion: @escaping @MainActor (Bool) -> Void) { completion(false) } +} + @MainActor final class DeviceBatteryLowBatteryNotificationController { private struct NotificationKeyGroup { @@ -27,11 +32,23 @@ final class DeviceBatteryLowBatteryNotificationController { private let notifier: any DeviceBatteryLowBatteryNotifying private var notifiedDeviceKeyGroups: [Set] = [] + private var isRequestingAuthorization = false init(notifier: any DeviceBatteryLowBatteryNotifying) { self.notifier = notifier } + func requestAuthorization(reevaluate: @escaping @MainActor () -> Void = {}) { + guard !isRequestingAuthorization else { return } + isRequestingAuthorization = true + notifier.requestAuthorization { [weak self] newlyGranted in + guard let self else { return } + self.isRequestingAuthorization = false + if newlyGranted { self.notifiedDeviceKeyGroups.removeAll() } + reevaluate() + } + } + func evaluate( snapshot: DeviceBatterySnapshot, isEnabled: Bool, @@ -43,6 +60,7 @@ final class DeviceBatteryLowBatteryNotificationController { notifiedDeviceKeyGroups.removeAll() return } + guard !isRequestingAuthorization else { return } let lowBatteryItems = snapshot.lowBatteryItems(threshold: normalizedThreshold) for recoveredItem in snapshot.visibleItems @@ -146,14 +164,7 @@ final class DeviceBatteryUserNotificationCenterNotifier: DeviceBatteryLowBattery switch settings.authorizationStatus { case .authorized, .provisional, .ephemeral: Self.addNotification(title: title, body: body, identifier: identifier) - case .notDetermined: - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in - guard granted else { - return - } - Self.addNotification(title: title, body: body, identifier: identifier) - } - case .denied: + case .notDetermined, .denied: break @unknown default: break @@ -161,6 +172,20 @@ final class DeviceBatteryUserNotificationCenterNotifier: DeviceBatteryLowBattery } } + func requestAuthorization(completion: @escaping @MainActor (Bool) -> Void) { + let center = notificationCenter + center.getNotificationSettings { settings in + let wasAuthorized: Bool + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: wasAuthorized = true + default: wasAuthorized = false + } + center.requestAuthorization(options: [.alert, .sound]) { granted, _ in + Task { @MainActor in completion(granted && !wasAuthorized) } + } + } + } + private nonisolated static func addNotification(title: String, body: String, identifier: String) { let content = UNMutableNotificationContent() content.title = title diff --git a/Plugins/DeviceBattery/Sources/DeviceBatteryPlugin.swift b/Plugins/DeviceBattery/Sources/DeviceBatteryPlugin.swift index 6db99697f..819925af9 100644 --- a/Plugins/DeviceBattery/Sources/DeviceBatteryPlugin.swift +++ b/Plugins/DeviceBattery/Sources/DeviceBatteryPlugin.swift @@ -219,6 +219,16 @@ final class DeviceBatteryPlugin: MacToolsPlugin, PluginApplicationActivityStateH localization: localization, onChange: {}, onNotificationSettingsChange: { + if store.lowBatteryNotificationEnabled { + lowBatteryNotificationController.requestAuthorization { + lowBatteryNotificationController.evaluate( + snapshot: viewModel.snapshot, + isEnabled: store.lowBatteryNotificationEnabled, + threshold: store.lowBatteryNotificationThreshold, + localization: localization + ) + } + } viewModel.setLowBatteryMonitoringEnabled( store.lowBatteryNotificationEnabled ) @@ -418,3 +428,23 @@ final class DeviceBatteryPlugin: MacToolsPlugin, PluginApplicationActivityStateH NSWorkspace.shared.open(url) } } + +extension DeviceBatteryPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { store.onPersistentPreferencesChange } + set { store.onPersistentPreferencesChange = newValue } + } + func makePortablePreferencesBackup() -> Data? { store.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard store.restorePortableBackup(data) else { return false } + viewModel.setLowBatteryMonitoringEnabled(store.lowBatteryNotificationEnabled) + viewModel.updateSources( + includeInternalBattery: store.showInternalBattery, includeBluetoothDevices: store.showBluetoothDevices, + includeAppleMobileDevices: store.showAppleMobileDevices, includeVendorHIDDevices: store.showVendorHIDDevices + ) + onStateChange?() + return true + } +} diff --git a/Plugins/DeviceBattery/Sources/DeviceBatteryStore.swift b/Plugins/DeviceBattery/Sources/DeviceBatteryStore.swift index 1565d964d..d5bab8d60 100644 --- a/Plugins/DeviceBattery/Sources/DeviceBatteryStore.swift +++ b/Plugins/DeviceBattery/Sources/DeviceBatteryStore.swift @@ -25,6 +25,7 @@ final class DeviceBatteryStore: ObservableObject { init(storage: any PluginStorage) { self.storage = storage + _ = Self.portableStore(storage: storage).recover() layoutMode = DeviceBatteryLayoutMode( rawValue: storage.string(forKey: Key.layoutMode) ?? DeviceBatteryLayoutMode.grid.rawValue ) ?? .grid @@ -44,13 +45,42 @@ final class DeviceBatteryStore: ObservableObject { ) } + var onPersistentPreferencesChange: (() -> Void)? + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + Key.layoutMode: .choice("grid", allowed: DeviceBatteryLayoutMode.allCases.map(\.rawValue)), + Key.showInternalBattery: .boolean(true), + Key.showBluetoothDevices: .boolean(true), + Key.showAppleMobileDevices: .boolean(true), + Key.showVendorHIDDevices: .boolean(true), + Key.lowBatteryNotificationEnabled: .boolean(false), + Key.lowBatteryNotificationThreshold: .integer(DeviceBatteryLowBatteryThresholds.defaultValue, range: 1...99) + ]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + func portableBackup() -> Data? { portableStore.backup() } + func restorePortableBackup(_ data: Data) -> Bool { + let previous = portableStore.currentValues() + guard portableStore.restore(data) else { return false } + layoutMode = storage.string(forKey: Key.layoutMode).flatMap(DeviceBatteryLayoutMode.init(rawValue:)) ?? .grid + showInternalBattery = storage.object(forKey: Key.showInternalBattery) as? Bool ?? true + showBluetoothDevices = storage.object(forKey: Key.showBluetoothDevices) as? Bool ?? true + showAppleMobileDevices = storage.object(forKey: Key.showAppleMobileDevices) as? Bool ?? true + showVendorHIDDevices = storage.object(forKey: Key.showVendorHIDDevices) as? Bool ?? true + lowBatteryNotificationEnabled = storage.object(forKey: Key.lowBatteryNotificationEnabled) as? Bool ?? false + lowBatteryNotificationThreshold = storage.object(forKey: Key.lowBatteryNotificationThreshold) as? Int ?? DeviceBatteryLowBatteryThresholds.defaultValue + if previous != portableStore.currentValues() { onPersistentPreferencesChange?() } + return true + } + func setLayoutMode(_ mode: DeviceBatteryLayoutMode) { guard layoutMode != mode else { return } + guard portableStore.commit([Key.layoutMode: .string(mode.rawValue)]) else { return } layoutMode = mode - storage.set(mode.rawValue, forKey: Key.layoutMode) + onPersistentPreferencesChange?() } func setShowInternalBattery(_ isShown: Bool) { @@ -58,8 +88,9 @@ final class DeviceBatteryStore: ObservableObject { return } + guard portableStore.commit([Key.showInternalBattery: .boolean(isShown)]) else { return } showInternalBattery = isShown - storage.set(isShown, forKey: Key.showInternalBattery) + onPersistentPreferencesChange?() } func setShowBluetoothDevices(_ isShown: Bool) { @@ -67,8 +98,9 @@ final class DeviceBatteryStore: ObservableObject { return } + guard portableStore.commit([Key.showBluetoothDevices: .boolean(isShown)]) else { return } showBluetoothDevices = isShown - storage.set(isShown, forKey: Key.showBluetoothDevices) + onPersistentPreferencesChange?() } func setShowAppleMobileDevices(_ isShown: Bool) { @@ -76,8 +108,9 @@ final class DeviceBatteryStore: ObservableObject { return } + guard portableStore.commit([Key.showAppleMobileDevices: .boolean(isShown)]) else { return } showAppleMobileDevices = isShown - storage.set(isShown, forKey: Key.showAppleMobileDevices) + onPersistentPreferencesChange?() } func setShowVendorHIDDevices(_ isShown: Bool) { @@ -85,8 +118,9 @@ final class DeviceBatteryStore: ObservableObject { return } + guard portableStore.commit([Key.showVendorHIDDevices: .boolean(isShown)]) else { return } showVendorHIDDevices = isShown - storage.set(isShown, forKey: Key.showVendorHIDDevices) + onPersistentPreferencesChange?() } func setLowBatteryNotificationEnabled(_ isEnabled: Bool) { @@ -94,8 +128,9 @@ final class DeviceBatteryStore: ObservableObject { return } + guard portableStore.commit([Key.lowBatteryNotificationEnabled: .boolean(isEnabled)]) else { return } lowBatteryNotificationEnabled = isEnabled - storage.set(isEnabled, forKey: Key.lowBatteryNotificationEnabled) + onPersistentPreferencesChange?() } func setLowBatteryNotificationThreshold(_ threshold: Int) { @@ -104,8 +139,9 @@ final class DeviceBatteryStore: ObservableObject { return } + guard portableStore.commit([Key.lowBatteryNotificationThreshold: .integer(normalizedThreshold)]) else { return } lowBatteryNotificationThreshold = normalizedThreshold - storage.set(normalizedThreshold, forKey: Key.lowBatteryNotificationThreshold) + onPersistentPreferencesChange?() } static func normalizedLowBatteryNotificationThreshold(_ threshold: Int) -> Int { diff --git a/Plugins/DeviceBattery/Tests/DeviceBatteryPluginTests.swift b/Plugins/DeviceBattery/Tests/DeviceBatteryPluginTests.swift index 56361f579..9c2713395 100644 --- a/Plugins/DeviceBattery/Tests/DeviceBatteryPluginTests.swift +++ b/Plugins/DeviceBattery/Tests/DeviceBatteryPluginTests.swift @@ -4,6 +4,23 @@ import MacToolsPluginKit @MainActor final class DeviceBatteryPluginTests: XCTestCase { + func testPortableSettingsRetainLocalDeviceState() throws { + let source = DeviceBatteryStore(storage: DeviceBatteryMemoryStorage()) + source.setLayoutMode(.list) + source.setShowVendorHIDDevices(false) + source.setLowBatteryNotificationEnabled(true) + source.setLowBatteryNotificationThreshold(35) + let storage = DeviceBatteryMemoryStorage() + storage.set("private-device", forKey: "device-cache") + let destination = DeviceBatteryStore(storage: storage) + XCTAssertTrue(destination.restorePortableBackup(try XCTUnwrap(source.portableBackup()))) + XCTAssertEqual(destination.layoutMode, .list) + XCTAssertFalse(destination.showVendorHIDDevices) + XCTAssertTrue(destination.lowBatteryNotificationEnabled) + XCTAssertEqual(destination.lowBatteryNotificationThreshold, 35) + XCTAssertEqual(storage.string(forKey: "device-cache"), "private-device") + } + func testStorePersistsLayoutAndSources() { let storage = DeviceBatteryMemoryStorage() @@ -449,6 +466,55 @@ final class DeviceBatteryPluginTests: XCTestCase { XCTAssertEqual(notifier.notifications.count, 2) } + func testNewLocalAuthorizationRecoversWarningSuppressedAfterImportWithoutRepeating() { + let notifier = PendingAuthorizationLowBatteryNotifier() + let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) + let snapshot = makeSnapshot(items: [makeBatteryItem(id: "mouse", name: "Mouse", level: 10)]) + var threshold = 20 + func evaluate() { + controller.evaluate(snapshot: snapshot, isEnabled: true, threshold: threshold, + localization: PluginLocalization(bundle: .main)) + } + + evaluate() // Imported monitoring can run before the first local authorization request. + XCTAssertEqual(notifier.attempts, 1) + XCTAssertEqual(notifier.delivered, 0) + threshold = 21 + controller.requestAuthorization(reevaluate: evaluate) + controller.requestAuthorization(reevaluate: evaluate) + evaluate() + XCTAssertEqual(notifier.authorizationRequests, 1) + XCTAssertEqual(notifier.attempts, 1) + notifier.completeAuthorization(newlyGranted: true) + XCTAssertEqual(notifier.delivered, 1) + + threshold = 22 + controller.requestAuthorization(reevaluate: evaluate) + evaluate() + notifier.completeAuthorization(newlyGranted: false) + evaluate() + XCTAssertEqual(notifier.delivered, 1) + } + + func testAuthorizationCompletionUsesCurrentEnabledState() { + let notifier = PendingAuthorizationLowBatteryNotifier() + let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) + let snapshot = makeSnapshot(items: [makeBatteryItem(id: "mouse", name: "Mouse", level: 10)]) + var isEnabled = true + func evaluate() { + controller.evaluate(snapshot: snapshot, isEnabled: isEnabled, threshold: 20, + localization: PluginLocalization(bundle: .main)) + } + controller.requestAuthorization(reevaluate: evaluate) + isEnabled = false + evaluate() + notifier.completeAuthorization(newlyGranted: true) + XCTAssertEqual(notifier.delivered, 0) + isEnabled = true + evaluate() + XCTAssertEqual(notifier.delivered, 1) + } + func testLowBatteryNotificationIgnoresChargingDevicesAndBoundaryValue() { let notifier = RecordingLowBatteryNotifier() let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) @@ -575,6 +641,32 @@ private final class RecordingLowBatteryNotifier: DeviceBatteryLowBatteryNotifyin } } +@MainActor +private final class PendingAuthorizationLowBatteryNotifier: DeviceBatteryLowBatteryNotifying { + private var completion: (@MainActor (Bool) -> Void)? + private var isAuthorized = false + private(set) var authorizationRequests = 0 + private(set) var attempts = 0 + private(set) var delivered = 0 + + func requestAuthorization(completion: @escaping @MainActor (Bool) -> Void) { + authorizationRequests += 1 + self.completion = completion + } + + func completeAuthorization(newlyGranted: Bool) { + isAuthorized = isAuthorized || newlyGranted + let callback = completion + completion = nil + callback?(newlyGranted) + } + + func notifyLowBatteryDevices(_ items: [DeviceBatteryItem], threshold: Int, localization: PluginLocalization) { + attempts += 1 + if isAuthorized { delivered += 1 } + } +} + private extension Array where Element == UInt8 { func setting(_ value: UInt8, at index: Int) -> [UInt8] { var copy = self diff --git a/Plugins/DeviceBattery/plugin.json b/Plugins/DeviceBattery/plugin.json index 1fa32b870..e5f9e31e6 100644 --- a/Plugins/DeviceBattery/plugin.json +++ b/Plugins/DeviceBattery/plugin.json @@ -198,5 +198,12 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "device-state", + "permissions" + ] } } diff --git a/Plugins/DeviceBattery/project.yml b/Plugins/DeviceBattery/project.yml index 8c0bdb1a1..b0301eb9b 100644 --- a/Plugins/DeviceBattery/project.yml +++ b/Plugins/DeviceBattery/project.yml @@ -1,3 +1,7 @@ settings: base: OTHER_LDFLAGS: -framework IOKit -framework IOBluetooth -framework CoreBluetooth + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/DiskClean/plugin.json b/Plugins/DiskClean/plugin.json index 6cb2353e8..50ea4fffe 100644 --- a/Plugins/DiskClean/plugin.json +++ b/Plugins/DiskClean/plugin.json @@ -260,5 +260,11 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "localOnly", + "exclusions": [ + "local-policy" + ] } } diff --git a/Plugins/DisplayBrightness/Sources/DisplayBrightnessPlugin.swift b/Plugins/DisplayBrightness/Sources/DisplayBrightnessPlugin.swift index 6114a90ea..e70b88391 100644 --- a/Plugins/DisplayBrightness/Sources/DisplayBrightnessPlugin.swift +++ b/Plugins/DisplayBrightness/Sources/DisplayBrightnessPlugin.swift @@ -986,3 +986,20 @@ final class DisplayBrightnessPlugin: return screenNumber.uint32Value } } + + +extension DisplayBrightnessPlugin: PluginPortablePreferencesProviding, + PluginPortablePreferencesRestorationReporting, PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { shortcutPreferences.onPersistentPreferencesChange } + set { shortcutPreferences.onPersistentPreferencesChange = newValue } + } + + func makePortablePreferencesBackup() -> Data? { shortcutPreferences.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard shortcutPreferences.restorePortableBackup(data) else { return false } + onStateChange?() + return true + } +} diff --git a/Plugins/DisplayBrightness/Sources/DisplayBrightnessShortcutPreferences.swift b/Plugins/DisplayBrightness/Sources/DisplayBrightnessShortcutPreferences.swift index 06604a40d..8c1bbbfff 100644 --- a/Plugins/DisplayBrightness/Sources/DisplayBrightnessShortcutPreferences.swift +++ b/Plugins/DisplayBrightness/Sources/DisplayBrightnessShortcutPreferences.swift @@ -36,7 +36,37 @@ final class DisplayBrightnessShortcutPreferences: ObservableObject { } @Published var targetMode: TargetMode { - didSet { storage.set(targetMode.rawValue, forKey: Keys.targetMode) } + didSet { + guard !isRestoring, targetMode != oldValue else { return } + guard portableStore.commit([Keys.targetMode: .string(targetMode.rawValue)]) else { + isRestoring = true + targetMode = oldValue + isRestoring = false + return + } + onPersistentPreferencesChange?() + } + } + + private var isRestoring = false + var onPersistentPreferencesChange: (() -> Void)? + + private var portableStore: PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + Keys.targetMode: .choice("followsMouse", allowed: ["followsMouse", "allDisplays"]) + ]) + } + + func portableBackup() -> Data? { portableStore.backup() } + + func restorePortableBackup(_ data: Data) -> Bool { + guard portableStore.restore(data) else { return false } + let oldValue = targetMode + isRestoring = true + targetMode = TargetMode(rawValue: storage.string(forKey: Keys.targetMode) ?? "") ?? .followsMouse + isRestoring = false + if targetMode != oldValue { onPersistentPreferencesChange?() } + return true } private let storage: PluginStorage @@ -47,6 +77,7 @@ final class DisplayBrightnessShortcutPreferences: ObservableObject { init(storage: PluginStorage) { self.storage = storage + _ = PortablePreferencesStore(storage: storage, fields: [Keys.targetMode: .choice("followsMouse", allowed: ["followsMouse", "allDisplays"])]).recover() self.targetMode = TargetMode(rawValue: storage.string(forKey: Keys.targetMode) ?? "") ?? .followsMouse } diff --git a/Plugins/DisplayBrightness/Tests/DisplayBrightnessPluginTests.swift b/Plugins/DisplayBrightness/Tests/DisplayBrightnessPluginTests.swift index 9da558506..4c122a610 100644 --- a/Plugins/DisplayBrightness/Tests/DisplayBrightnessPluginTests.swift +++ b/Plugins/DisplayBrightness/Tests/DisplayBrightnessPluginTests.swift @@ -6,6 +6,45 @@ import MacToolsPluginKit @MainActor final class DisplayBrightnessPluginTests: XCTestCase { + func testPortableTargetModeIsDurableAndDoesNotWriteHardware() throws { + let source = DisplayBrightnessShortcutPreferences(storage: DisplayBrightnessMemoryStorage()) + source.targetMode = .allDisplays + let storage = DisplayBrightnessMemoryStorage() + storage.set("local-device", forKey: "recovery") + let preferences = DisplayBrightnessShortcutPreferences(storage: storage) + let controller = MockDisplayBrightnessController() + let plugin = DisplayBrightnessPlugin(controller: controller, shortcutPreferences: preferences) + var changes = 0 + plugin.onPersistentPreferencesChange = { changes += 1 } + let data = try XCTUnwrap(source.portableBackup()) + XCTAssertTrue(plugin.restorePortablePreferencesReportingResult(from: data)) + XCTAssertEqual(preferences.targetMode, .allDisplays) + XCTAssertEqual(changes, 1) + XCTAssertTrue(plugin.restorePortablePreferencesReportingResult(from: data)) + XCTAssertEqual(changes, 1) + XCTAssertTrue(controller.brightnessWrites.isEmpty) + XCTAssertEqual(storage.string(forKey: "recovery"), "local-device") + XCTAssertEqual(DisplayBrightnessShortcutPreferences(storage: storage).targetMode, .allDisplays) + } + + func testPortableTransactionRejectsFuturePayloadAndRollsBackFailedWrite() throws { + let storage = DisplayBrightnessMemoryStorage() + let preferences = DisplayBrightnessShortcutPreferences(storage: storage) + let source = DisplayBrightnessShortcutPreferences(storage: DisplayBrightnessMemoryStorage()) + source.targetMode = .allDisplays + let data = try XCTUnwrap(source.portableBackup()) + storage.rejectedWriteKey = "shortcutTargetMode" + XCTAssertFalse(preferences.restorePortableBackup(data)) + XCTAssertEqual(preferences.targetMode, .followsMouse) + XCTAssertNil(storage.object(forKey: "shortcutTargetMode")) + XCTAssertNil(storage.object(forKey: "preferences-backup.restore.v1")) + storage.rejectedWriteKey = nil + var future = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + future["formatVersion"] = 99 + XCTAssertFalse(preferences.restorePortableBackup(try JSONSerialization.data(withJSONObject: future))) + XCTAssertEqual(preferences.targetMode, .followsMouse) + } + func testPendingMouseDisplayDoesNotRedirectShortcutToAnotherDisplay() { let controller = MockDisplayBrightnessController() var pending = makeBrightnessDisplay(id: 9, name: "Pending Display", brightness: 1) diff --git a/Plugins/DisplayBrightness/Tests/DisplayBrightnessTestSupport.swift b/Plugins/DisplayBrightness/Tests/DisplayBrightnessTestSupport.swift index ef4de7df5..8fea90b75 100644 --- a/Plugins/DisplayBrightness/Tests/DisplayBrightnessTestSupport.swift +++ b/Plugins/DisplayBrightness/Tests/DisplayBrightnessTestSupport.swift @@ -98,6 +98,7 @@ final class MockDisplayBrightnessController: DisplayBrightnessControlling { @MainActor final class DisplayBrightnessMemoryStorage: PluginStorage { + var rejectedWriteKey: String? private var values: [String: Any] = [:] func object(forKey key: String) -> Any? { values[key] } @@ -108,6 +109,7 @@ final class DisplayBrightnessMemoryStorage: PluginStorage { func bool(forKey key: String) -> Bool { values[key] as? Bool ?? false } func set(_ value: Any?, forKey key: String) { + guard key != rejectedWriteKey else { return } guard let value else { removeObject(forKey: key) return diff --git a/Plugins/DisplayBrightness/plugin.json b/Plugins/DisplayBrightness/plugin.json index 756306a36..2e4ea6aef 100644 --- a/Plugins/DisplayBrightness/plugin.json +++ b/Plugins/DisplayBrightness/plugin.json @@ -344,5 +344,11 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "device-state" + ] } } diff --git a/Plugins/DisplayBrightness/project.yml b/Plugins/DisplayBrightness/project.yml index 3fd34d113..fad0c17f9 100644 --- a/Plugins/DisplayBrightness/project.yml +++ b/Plugins/DisplayBrightness/project.yml @@ -1,3 +1,6 @@ settings: base: OTHER_LDFLAGS: -framework IOKit +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/DisplayResolution/plugin.json b/Plugins/DisplayResolution/plugin.json index a6d5c5e77..44f43efc6 100644 --- a/Plugins/DisplayResolution/plugin.json +++ b/Plugins/DisplayResolution/plugin.json @@ -284,5 +284,9 @@ ] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/DisplaySleep/plugin.json b/Plugins/DisplaySleep/plugin.json index 0baf42614..ca321c2ad 100644 --- a/Plugins/DisplaySleep/plugin.json +++ b/Plugins/DisplaySleep/plugin.json @@ -240,5 +240,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/DisplayTrueColor/plugin.json b/Plugins/DisplayTrueColor/plugin.json index b60f1d7b7..8a195f00e 100644 --- a/Plugins/DisplayTrueColor/plugin.json +++ b/Plugins/DisplayTrueColor/plugin.json @@ -269,5 +269,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/DisplayVolume/Sources/DisplayVolumePlugin.swift b/Plugins/DisplayVolume/Sources/DisplayVolumePlugin.swift index 39e7fd6f0..780269469 100644 --- a/Plugins/DisplayVolume/Sources/DisplayVolumePlugin.swift +++ b/Plugins/DisplayVolume/Sources/DisplayVolumePlugin.swift @@ -719,3 +719,20 @@ final class DisplayVolumePlugin: return screenNumber.uint32Value } } + + +extension DisplayVolumePlugin: PluginPortablePreferencesProviding, + PluginPortablePreferencesRestorationReporting, PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { shortcutPreferences.onPersistentPreferencesChange } + set { shortcutPreferences.onPersistentPreferencesChange = newValue } + } + + func makePortablePreferencesBackup() -> Data? { shortcutPreferences.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard shortcutPreferences.restorePortableBackup(data) else { return false } + onStateChange?() + return true + } +} diff --git a/Plugins/DisplayVolume/Sources/DisplayVolumeShortcutPreferences.swift b/Plugins/DisplayVolume/Sources/DisplayVolumeShortcutPreferences.swift index 5345e60c5..1b9c871d0 100644 --- a/Plugins/DisplayVolume/Sources/DisplayVolumeShortcutPreferences.swift +++ b/Plugins/DisplayVolume/Sources/DisplayVolumeShortcutPreferences.swift @@ -36,7 +36,37 @@ final class DisplayVolumeShortcutPreferences: ObservableObject { } @Published var targetMode: TargetMode { - didSet { storage.set(targetMode.rawValue, forKey: Keys.targetMode) } + didSet { + guard !isRestoring, targetMode != oldValue else { return } + guard portableStore.commit([Keys.targetMode: .string(targetMode.rawValue)]) else { + isRestoring = true + targetMode = oldValue + isRestoring = false + return + } + onPersistentPreferencesChange?() + } + } + + private var isRestoring = false + var onPersistentPreferencesChange: (() -> Void)? + + private var portableStore: PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + Keys.targetMode: .choice("followsMouse", allowed: ["followsMouse", "allDisplays"]) + ]) + } + + func portableBackup() -> Data? { portableStore.backup() } + + func restorePortableBackup(_ data: Data) -> Bool { + guard portableStore.restore(data) else { return false } + let oldValue = targetMode + isRestoring = true + targetMode = TargetMode(rawValue: storage.string(forKey: Keys.targetMode) ?? "") ?? .followsMouse + isRestoring = false + if targetMode != oldValue { onPersistentPreferencesChange?() } + return true } private let storage: PluginStorage @@ -47,6 +77,7 @@ final class DisplayVolumeShortcutPreferences: ObservableObject { init(storage: PluginStorage) { self.storage = storage + _ = PortablePreferencesStore(storage: storage, fields: [Keys.targetMode: .choice("followsMouse", allowed: ["followsMouse", "allDisplays"])]).recover() self.targetMode = TargetMode(rawValue: storage.string(forKey: Keys.targetMode) ?? "") ?? .followsMouse } diff --git a/Plugins/DisplayVolume/plugin.json b/Plugins/DisplayVolume/plugin.json index a2c32d880..63417318d 100644 --- a/Plugins/DisplayVolume/plugin.json +++ b/Plugins/DisplayVolume/plugin.json @@ -274,5 +274,11 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "device-state" + ] } } diff --git a/Plugins/DisplayVolume/project.yml b/Plugins/DisplayVolume/project.yml index 3fd34d113..fad0c17f9 100644 --- a/Plugins/DisplayVolume/project.yml +++ b/Plugins/DisplayVolume/project.yml @@ -1,3 +1,6 @@ settings: base: OTHER_LDFLAGS: -framework IOKit +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/DockClickMinimize/Sources/DockClickMinimizePlugin.swift b/Plugins/DockClickMinimize/Sources/DockClickMinimizePlugin.swift index 03237c3f6..f0d5ef2d0 100644 --- a/Plugins/DockClickMinimize/Sources/DockClickMinimizePlugin.swift +++ b/Plugins/DockClickMinimize/Sources/DockClickMinimizePlugin.swift @@ -106,6 +106,7 @@ final class DockClickMinimizePlugin: MacToolsPlugin, AccessibilityPermissionRefr var requestPermissionGuidance: ((String) -> Void)? var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + var onPersistentPreferencesChange: (() -> Void)? private let storage: any PluginStorage private let monitor: any DockClickMonitoring private let applicationHider: any DockApplicationHiding @@ -181,6 +182,7 @@ final class DockClickMinimizePlugin: MacToolsPlugin, AccessibilityPermissionRefr notificationCenter: NotificationCenter = .default ) { self.storage = context.storage + _ = Self.portableStore(storage: context.storage).recover() self.monitor = monitor ?? DockClickMonitor() self.applicationHider = applicationHider ?? DockApplicationHider() self.frontmostApplicationProvider = frontmostApplicationProvider @@ -285,8 +287,10 @@ final class DockClickMinimizePlugin: MacToolsPlugin, AccessibilityPermissionRefr guard case let .setSwitch(isEnabled) = action else { return } + guard self.isEnabled != isEnabled, + portableStore.commit([StorageKey.isEnabled: .boolean(isEnabled)]) else { return } self.isEnabled = isEnabled - storage.set(isEnabled, forKey: StorageKey.isEnabled) + onPersistentPreferencesChange?() if !isEnabled { invalidatePendingActions() } @@ -503,3 +507,24 @@ final class DockClickMinimizePlugin: MacToolsPlugin, AccessibilityPermissionRefr self.applicationActivationObserver = nil } } + +extension DockClickMinimizePlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [StorageKey.isEnabled: .boolean(true)]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + func makePortablePreferencesBackup() -> Data? { portableStore.backup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + let before = portableStore.currentValues() + guard portableStore.restore(data) else { return false } + isEnabled = storage.object(forKey: StorageKey.isEnabled) as? Bool ?? true + invalidatePendingActions() + refreshPermissionState() + applyMonitoringState(requestMissingPermissions: false) + if before != portableStore.currentValues() { onPersistentPreferencesChange?() } + onStateChange?() + return true + } +} diff --git a/Plugins/DockClickMinimize/plugin.json b/Plugins/DockClickMinimize/plugin.json index ecbe47803..5b630c600 100644 --- a/Plugins/DockClickMinimize/plugin.json +++ b/Plugins/DockClickMinimize/plugin.json @@ -202,5 +202,9 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "portable", + "exclusions": [] } } diff --git a/Plugins/DockClickMinimize/project.yml b/Plugins/DockClickMinimize/project.yml new file mode 100644 index 000000000..5a47746a5 --- /dev/null +++ b/Plugins/DockClickMinimize/project.yml @@ -0,0 +1,4 @@ + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/DockLock/Sources/DockLockPlugin.swift b/Plugins/DockLock/Sources/DockLockPlugin.swift index e207779d7..454e18e97 100644 --- a/Plugins/DockLock/Sources/DockLockPlugin.swift +++ b/Plugins/DockLock/Sources/DockLockPlugin.swift @@ -309,6 +309,7 @@ final class DockLockPlugin: var requestPermissionGuidance: ((String) -> Void)? var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + var onPersistentPreferencesChange: (() -> Void)? private let storage: any PluginStorage private let monitor: any DockLockMonitoring private let localization: PluginLocalization @@ -353,6 +354,7 @@ final class DockLockPlugin: requestAccessibilityTrust: @escaping @MainActor (Bool) -> Bool = DockLockPlugin.requestAccessibilityTrust ) { self.storage = context.storage + _ = Self.portableStore(storage: context.storage).recover() self.monitor = monitor ?? DockLockMonitor() self.localization = localization self.accessibilityTrusted = accessibilityTrusted @@ -621,14 +623,17 @@ final class DockLockPlugin: @discardableResult private func setEnabled(_ enabled: Bool, promptForPermission: Bool) -> Bool { - isEnabled = enabled - storage.set(enabled, forKey: StorageKey.isEnabled) + if isEnabled != enabled { + guard portableStore.commit([StorageKey.isEnabled: .boolean(enabled)]) else { return false } + isEnabled = enabled + onPersistentPreferencesChange?() + } applyLockState(promptForPermission: promptForPermission) onStateChange?() return !enabled || (isAccessibilityGranted && lastErrorMessage == nil) } - private func applyLockState(promptForPermission: Bool) { + private func applyLockState(promptForPermission: Bool, showsGuidance: Bool = true) { guard isEnabled else { monitor.stop() runtimeError = nil @@ -642,7 +647,7 @@ final class DockLockPlugin: guard isAccessibilityGranted else { monitor.stop() runtimeError = .accessibilityRequired - requestPermissionGuidance?(PermissionID.accessibility) + if showsGuidance { requestPermissionGuidance?(PermissionID.accessibility) } return } @@ -664,3 +669,22 @@ final class DockLockPlugin: return AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) } } + +extension DockLockPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [StorageKey.isEnabled: .boolean(false)]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + func makePortablePreferencesBackup() -> Data? { portableStore.backup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + let before = portableStore.currentValues() + guard portableStore.restore(data) else { return false } + isEnabled = storage.object(forKey: StorageKey.isEnabled) as? Bool ?? false + applyLockState(promptForPermission: false, showsGuidance: false) + if before != portableStore.currentValues() { onPersistentPreferencesChange?() } + onStateChange?() + return true + } +} diff --git a/Plugins/DockLock/plugin.json b/Plugins/DockLock/plugin.json index 26e33bb2b..e70da35d7 100644 --- a/Plugins/DockLock/plugin.json +++ b/Plugins/DockLock/plugin.json @@ -285,5 +285,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "portable", + "exclusions": [] } } diff --git a/Plugins/DockLock/project.yml b/Plugins/DockLock/project.yml new file mode 100644 index 000000000..5a47746a5 --- /dev/null +++ b/Plugins/DockLock/project.yml @@ -0,0 +1,4 @@ + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/DuoStatus/Sources/DuoStatusIconOptionsStore.swift b/Plugins/DuoStatus/Sources/DuoStatusIconOptionsStore.swift index e42ac5222..f4ad60490 100644 --- a/Plugins/DuoStatus/Sources/DuoStatusIconOptionsStore.swift +++ b/Plugins/DuoStatus/Sources/DuoStatusIconOptionsStore.swift @@ -32,6 +32,7 @@ struct DuoStatusIconOptionsStore { init(storage: PluginStorage) { self.storage = storage + _ = Self.portableStore(storage: storage).recover() } /// A missing key keeps the default, so an option the user never touched @@ -85,27 +86,54 @@ struct DuoStatusIconOptionsStore { return options } - func save(_ options: DuoStatusIconOptions) { - storage.set(options.iconSize.rawValue, forKey: Key.iconSize) - storage.set(options.bottomIndicator.rawValue, forKey: Key.bottomIndicator) - storage.set(options.volumeStyle.rawValue, forKey: Key.volumeStyle) - storage.set(options.showsBatteryPercentage, forKey: Key.showsBatteryPercentage) - storage.set(options.showsPercentageWhenConnected, forKey: Key.showsPercentageWhenConnected) - storage.set(options.showsChargingIndicator, forKey: Key.showsChargingIndicator) - storage.set(options.batteryColorPolicy.rawValue, forKey: Key.batteryColorPolicy) - storage.set( - Self.clampedThreshold(options.batteryCriticalThreshold), - forKey: Key.batteryCriticalThreshold - ) - storage.set(options.distinguishesNoInternet, forKey: Key.distinguishesNoInternet) - storage.set(options.distinguishesHotspot, forKey: Key.distinguishesHotspot) - storage.set(options.distinguishesTemporaryNetwork, forKey: Key.distinguishesTemporaryNetwork) - storage.set(options.distinguishesInternetSharing, forKey: Key.distinguishesInternetSharing) - storage.set(options.showsBluetoothAudioGlyph, forKey: Key.showsBluetoothAudioGlyph) - storage.set( - options.bluetoothGlyphPrioritizesNetworkErrors, - forKey: Key.bluetoothGlyphPrioritizesNetworkErrors - ) + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + Key.iconSize: .choice("small", allowed: DuoStatusIconSize.allCases.map(\.rawValue)), + Key.bottomIndicator: .choice("wifi", allowed: DuoStatusBottomIndicator.allCases.map(\.rawValue)), + Key.volumeStyle: .choice("dots", allowed: DuoStatusVolumeStyle.allCases.map(\.rawValue)), + Key.batteryColorPolicy: .choice("legacy", allowed: DuoStatusBatteryColorPolicy.allCases.map(\.rawValue)), + Key.showsBatteryPercentage: .boolean(false), + Key.showsPercentageWhenConnected: .boolean(false), + Key.showsChargingIndicator: .boolean(true), + Key.distinguishesNoInternet: .boolean(false), + Key.distinguishesHotspot: .boolean(false), + Key.distinguishesTemporaryNetwork: .boolean(false), + Key.distinguishesInternetSharing: .boolean(false), + Key.showsBluetoothAudioGlyph: .boolean(false), + Key.bluetoothGlyphPrioritizesNetworkErrors: .boolean(true), + Key.batteryCriticalThreshold: PortablePreferenceField(fallback: .integer(20)) { + if case let .integer(value) = $0 { (5...50).contains(value) && value % 5 == 0 } else { false } + } + ]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + func portableBackup() -> Data? { + // Read through the legacy color-policy adapter without mutating old storage. + let options = load() + return portableStore.backup(overriding: values(options)) + } + func restorePortableBackup(_ data: Data) -> Bool { portableStore.restore(data) } + + @discardableResult + func save(_ options: DuoStatusIconOptions) -> Bool { portableStore.commit(values(options)) } + + private func values(_ options: DuoStatusIconOptions) -> [String: PortablePreferenceValue] { + [ + Key.iconSize: .string(options.iconSize.rawValue), + Key.bottomIndicator: .string(options.bottomIndicator.rawValue), + Key.volumeStyle: .string(options.volumeStyle.rawValue), + Key.batteryColorPolicy: .string(options.batteryColorPolicy.rawValue), + Key.showsBatteryPercentage: .boolean(options.showsBatteryPercentage), + Key.showsPercentageWhenConnected: .boolean(options.showsPercentageWhenConnected), + Key.showsChargingIndicator: .boolean(options.showsChargingIndicator), + Key.distinguishesNoInternet: .boolean(options.distinguishesNoInternet), + Key.distinguishesHotspot: .boolean(options.distinguishesHotspot), + Key.distinguishesTemporaryNetwork: .boolean(options.distinguishesTemporaryNetwork), + Key.distinguishesInternetSharing: .boolean(options.distinguishesInternetSharing), + Key.showsBluetoothAudioGlyph: .boolean(options.showsBluetoothAudioGlyph), + Key.bluetoothGlyphPrioritizesNetworkErrors: .boolean(options.bluetoothGlyphPrioritizesNetworkErrors), + Key.batteryCriticalThreshold: .integer(Self.clampedThreshold(options.batteryCriticalThreshold)) + ] } /// Snaps to the slider's step and clamps to its range so a stored value from diff --git a/Plugins/DuoStatus/Sources/DuoStatusPlugin.swift b/Plugins/DuoStatus/Sources/DuoStatusPlugin.swift index 13c894025..063b3e7a3 100644 --- a/Plugins/DuoStatus/Sources/DuoStatusPlugin.swift +++ b/Plugins/DuoStatus/Sources/DuoStatusPlugin.swift @@ -63,6 +63,7 @@ final class DuoStatusPlugin: MacToolsPlugin, PluginSettingsPresenting, private let localization: PluginLocalization private let monitor: any DuoSystemStatusMonitoring private let menuBar: any DuoStatusMenuBarPresenting + var onPersistentPreferencesChange: (() -> Void)? private let optionsStore: DuoStatusIconOptionsStore private var options: DuoStatusIconOptions private var localizationSubscription: AnyCancellable? @@ -490,8 +491,9 @@ final class DuoStatusPlugin: MacToolsPlugin, PluginSettingsPresenting, private func commit(_ updated: DuoStatusIconOptions) { guard updated != options else { return } + guard optionsStore.save(updated) else { return } options = updated - optionsStore.save(updated) + onPersistentPreferencesChange?() iconRevision &+= 1 cachedIcon = nil // The refresh scope may have gained or lost audio, so re-apply it before @@ -610,3 +612,22 @@ final class DuoStatusPlugin: MacToolsPlugin, PluginSettingsPresenting, onMenuBarIconChange?(Self.iconID) } } + +extension DuoStatusPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + func makePortablePreferencesBackup() -> Data? { optionsStore.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + let previous = options + guard optionsStore.restorePortableBackup(data) else { return false } + options = optionsStore.load() + if previous != options { onPersistentPreferencesChange?() } + iconRevision &+= 1 + cachedIcon = nil + monitor.setRefreshScope(refreshScope) + updateMenuBar(snapshot: monitor.snapshot) + onMenuBarIconChange?(Self.iconID) + onStateChange?() + return true + } +} diff --git a/Plugins/DuoStatus/Tests/DuoStatusPluginTests.swift b/Plugins/DuoStatus/Tests/DuoStatusPluginTests.swift index 5cd5f8c42..927c92e8d 100644 --- a/Plugins/DuoStatus/Tests/DuoStatusPluginTests.swift +++ b/Plugins/DuoStatus/Tests/DuoStatusPluginTests.swift @@ -5,6 +5,20 @@ import XCTest @MainActor final class DuoStatusPluginTests: XCTestCase { + func testPortableOptionsRestoreWithoutStartingMonitor() throws { + let source = Fixture() + let sourceStore = DuoStatusIconOptionsStore(storage: source.storage) + var options = DuoStatusIconOptions.default + options.iconSize = .large + options.batteryCriticalThreshold = 35 + options.showsBluetoothAudioGlyph = true + XCTAssertTrue(sourceStore.save(options)) + let destination = Fixture() + XCTAssertTrue(destination.plugin.restorePortablePreferencesReportingResult(from: try XCTUnwrap(sourceStore.portableBackup()))) + XCTAssertEqual(DuoStatusIconOptionsStore(storage: destination.storage).load(), options) + XCTAssertEqual(destination.monitor.startCount, 0) + } + func testSettingsOnlyPluginStartsOnlyOnActivationAndRoutesClickToSettings() throws { let fixture = Fixture() let plugin = fixture.plugin diff --git a/Plugins/DuoStatus/plugin.json b/Plugins/DuoStatus/plugin.json index b171ac5f8..4c18bda60 100644 --- a/Plugins/DuoStatus/plugin.json +++ b/Plugins/DuoStatus/plugin.json @@ -207,5 +207,11 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "device-state" + ] } } diff --git a/Plugins/DuoStatus/project.yml b/Plugins/DuoStatus/project.yml index 563e52095..8f1e424cf 100644 --- a/Plugins/DuoStatus/project.yml +++ b/Plugins/DuoStatus/project.yml @@ -1,3 +1,7 @@ settings: base: OTHER_LDFLAGS: -framework IOKit -framework CoreWLAN -framework Network + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/EjectDisk/plugin.json b/Plugins/EjectDisk/plugin.json index 8fcba7048..48ab72719 100644 --- a/Plugins/EjectDisk/plugin.json +++ b/Plugins/EjectDisk/plugin.json @@ -242,5 +242,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/EmptyTrash/plugin.json b/Plugins/EmptyTrash/plugin.json index 35ea0314b..37c1fb161 100644 --- a/Plugins/EmptyTrash/plugin.json +++ b/Plugins/EmptyTrash/plugin.json @@ -225,5 +225,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/FanControl/plugin.json b/Plugins/FanControl/plugin.json index 97dd9cac5..106bd432a 100644 --- a/Plugins/FanControl/plugin.json +++ b/Plugins/FanControl/plugin.json @@ -374,5 +374,11 @@ ] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "device-state" + ] } } diff --git a/Plugins/FixDamagedApp/Sources/FixDamagedAppPlugin.swift b/Plugins/FixDamagedApp/Sources/FixDamagedAppPlugin.swift index 04fd53f28..3973c7a8a 100644 --- a/Plugins/FixDamagedApp/Sources/FixDamagedAppPlugin.swift +++ b/Plugins/FixDamagedApp/Sources/FixDamagedAppPlugin.swift @@ -80,6 +80,7 @@ final class FixDamagedAppPlugin: MacToolsPlugin, DropZoneAnchorProviding, Plugin // MARK: Drag Detection State + var onPersistentPreferencesChange: (() -> Void)? private let storage: PluginStorage private var mouseDownMonitor: Any? private var dragMonitor: Any? @@ -151,6 +152,7 @@ final class FixDamagedAppPlugin: MacToolsPlugin, DropZoneAnchorProviding, Plugin try await runQuarantineRemoval(appPath: appPath, localization: localization) } self.storage = context.storage + _ = Self.portableStore(storage: context.storage).recover() self.rowDescriptor = PluginPanelRowDescriptor( controlStyle: .button, menuActionBehavior: .dismissBeforeHandling, @@ -206,7 +208,9 @@ final class FixDamagedAppPlugin: MacToolsPlugin, DropZoneAnchorProviding, Plugin } func setDragDetectionEnabled(_ enabled: Bool) { - storage.set(enabled, forKey: StorageKey.isDragDetectionEnabled) + guard enabled != isDragDetectionEnabled, + portableStore.commit([StorageKey.isDragDetectionEnabled: .boolean(enabled)]) else { return } + onPersistentPreferencesChange?() updateDragMonitoring() onStateChange?() } @@ -718,3 +722,21 @@ private func withFixDamagedCStringArray( body(buffer.baseAddress!) } } + +extension FixDamagedAppPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [StorageKey.isDragDetectionEnabled: .boolean(false)]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + func makePortablePreferencesBackup() -> Data? { portableStore.backup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + let before = portableStore.currentValues() + guard portableStore.restore(data) else { return false } + updateDragMonitoring() + if before != portableStore.currentValues() { onPersistentPreferencesChange?() } + onStateChange?() + return true + } +} diff --git a/Plugins/FixDamagedApp/plugin.json b/Plugins/FixDamagedApp/plugin.json index 10f6cd388..727976bce 100644 --- a/Plugins/FixDamagedApp/plugin.json +++ b/Plugins/FixDamagedApp/plugin.json @@ -226,5 +226,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "portable", + "exclusions": [] } } diff --git a/Plugins/FixDamagedApp/project.yml b/Plugins/FixDamagedApp/project.yml new file mode 100644 index 000000000..5a47746a5 --- /dev/null +++ b/Plugins/FixDamagedApp/project.yml @@ -0,0 +1,4 @@ + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/HideNotch/Sources/HideNotchController.swift b/Plugins/HideNotch/Sources/HideNotchController.swift index da78732f1..6869abe06 100644 --- a/Plugins/HideNotch/Sources/HideNotchController.swift +++ b/Plugins/HideNotch/Sources/HideNotchController.swift @@ -243,3 +243,19 @@ final class HideNotchController: HideNotchWallpaperControlling { } } } + +extension HideNotchController: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { (stateStore as? HideNotchStateStore)?.onPersistentPreferencesChange } + set { (stateStore as? HideNotchStateStore)?.onPersistentPreferencesChange = newValue } + } + func makePortablePreferencesBackup() -> Data? { (stateStore as? HideNotchStateStore)?.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard (stateStore as? HideNotchStateStore)?.restorePortableBackup(data) == true else { return false } + errorMessage = nil + scheduleSync(forceNotify: true) + return true + } +} diff --git a/Plugins/HideNotch/Sources/HideNotchPlugin.swift b/Plugins/HideNotch/Sources/HideNotchPlugin.swift index e556cce2c..30f0e7f1e 100644 --- a/Plugins/HideNotch/Sources/HideNotchPlugin.swift +++ b/Plugins/HideNotch/Sources/HideNotchPlugin.swift @@ -254,3 +254,18 @@ final class HideNotchPlugin: MacToolsPlugin, PluginActionProviding { ActionReference(key: ActionKey(providerID: metadata.id, actionID: ActionID.toggle)) } } + +extension HideNotchPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { (controller as? any PluginPersistentPreferencesChangeSignaling)?.onPersistentPreferencesChange } + set { (controller as? any PluginPersistentPreferencesChangeSignaling)?.onPersistentPreferencesChange = newValue } + } + func makePortablePreferencesBackup() -> Data? { + (controller as? any PluginPortablePreferencesProviding)?.makePortablePreferencesBackup() + } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + (controller as? any PluginPortablePreferencesRestorationReporting)?.restorePortablePreferencesReportingResult(from: data) ?? false + } +} diff --git a/Plugins/HideNotch/Sources/HideNotchStateStore.swift b/Plugins/HideNotch/Sources/HideNotchStateStore.swift index 1874dc21e..1a4e3227e 100644 --- a/Plugins/HideNotch/Sources/HideNotchStateStore.swift +++ b/Plugins/HideNotch/Sources/HideNotchStateStore.swift @@ -28,12 +28,29 @@ final class HideNotchStateStore: HideNotchStateStoring { fromLegacyKey: StorageKey.desiredEnabled, to: StorageKey.desiredEnabled ) + _ = portableStore.recover() removeObsoleteStateIfNeeded() } var desiredEnabled: Bool { get { storage.bool(forKey: StorageKey.desiredEnabled) } - set { storage.set(newValue, forKey: StorageKey.desiredEnabled) } + set { + guard desiredEnabled != newValue, + portableStore.commit([StorageKey.desiredEnabled: .boolean(newValue)]) else { return } + onPersistentPreferencesChange?() + } + } + + var onPersistentPreferencesChange: (() -> Void)? + private var portableStore: PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [StorageKey.desiredEnabled: .boolean(false)]) + } + func portableBackup() -> Data? { portableStore.backup() } + func restorePortableBackup(_ data: Data) -> Bool { + let previous = desiredEnabled + guard portableStore.restore(data) else { return false } + if desiredEnabled != previous { onPersistentPreferencesChange?() } + return true } private func removeObsoleteStateIfNeeded() { diff --git a/Plugins/HideNotch/plugin.json b/Plugins/HideNotch/plugin.json index 3609f22ac..2883589eb 100644 --- a/Plugins/HideNotch/plugin.json +++ b/Plugins/HideNotch/plugin.json @@ -266,5 +266,11 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "device-state" + ] } } diff --git a/Plugins/HideNotch/project.yml b/Plugins/HideNotch/project.yml new file mode 100644 index 000000000..5a47746a5 --- /dev/null +++ b/Plugins/HideNotch/project.yml @@ -0,0 +1,4 @@ + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/Homebrew/plugin.json b/Plugins/Homebrew/plugin.json index ea5f5fd90..b4d389dea 100644 --- a/Plugins/Homebrew/plugin.json +++ b/Plugins/Homebrew/plugin.json @@ -340,5 +340,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/IPOverview/Sources/IPOverviewPlugin.swift b/Plugins/IPOverview/Sources/IPOverviewPlugin.swift index 791785563..2307aac83 100644 --- a/Plugins/IPOverview/Sources/IPOverviewPlugin.swift +++ b/Plugins/IPOverview/Sources/IPOverviewPlugin.swift @@ -303,3 +303,21 @@ final class IPOverviewPlugin: ) } } + +// MARK: - Portable preferences + +extension IPOverviewPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { viewModel.onPersistentPreferencesChange } + set { viewModel.onPersistentPreferencesChange = newValue } + } + + func makePortablePreferencesBackup() -> Data? { viewModel.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard viewModel.restorePortableBackup(data) else { return false } + onStateChange?() + return true + } +} diff --git a/Plugins/IPOverview/Sources/IPOverviewViewModel.swift b/Plugins/IPOverview/Sources/IPOverviewViewModel.swift index 63c4615b1..4655e469d 100644 --- a/Plugins/IPOverview/Sources/IPOverviewViewModel.swift +++ b/Plugins/IPOverview/Sources/IPOverviewViewModel.swift @@ -127,6 +127,7 @@ final class IPOverviewViewModel: ObservableObject { self.leakTester = leakTester ?? IPOverviewLeakTestService(localization: localization) self.networkQualityMeasurer = networkQualityMeasurer ?? IPOverviewNetworkQualityService(localization: localization) self.storage = storage + _ = Self.portableStore(storage: storage).recover() self.currentDate = currentDate self.pasteboard = pasteboard self.hidesSensitiveInfo = storage.bool(forKey: StorageKey.hidesSensitiveInfo) @@ -245,9 +246,25 @@ final class IPOverviewViewModel: ObservableObject { isShowingDetails = false } + var onPersistentPreferencesChange: (() -> Void)? + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [StorageKey.hidesSensitiveInfo: .boolean(false)]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + func portableBackup() -> Data? { portableStore.backup() } + func restorePortableBackup(_ data: Data) -> Bool { + let before = hidesSensitiveInfo + guard portableStore.restore(data) else { return false } + hidesSensitiveInfo = storage.object(forKey: StorageKey.hidesSensitiveInfo) as? Bool ?? false + if before != hidesSensitiveInfo { onPersistentPreferencesChange?() } + return true + } + func toggleSensitiveInfoVisibility() { - hidesSensitiveInfo.toggle() - storage.set(hidesSensitiveInfo, forKey: StorageKey.hidesSensitiveInfo) + let candidate = !hidesSensitiveInfo + guard portableStore.commit([StorageKey.hidesSensitiveInfo: .boolean(candidate)]) else { return } + hidesSensitiveInfo = candidate + onPersistentPreferencesChange?() } func cancel() { diff --git a/Plugins/IPOverview/plugin.json b/Plugins/IPOverview/plugin.json index c399cdd06..36cfedc05 100644 --- a/Plugins/IPOverview/plugin.json +++ b/Plugins/IPOverview/plugin.json @@ -436,5 +436,12 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "history", + "local-policy" + ] } } diff --git a/Plugins/IPOverview/project.yml b/Plugins/IPOverview/project.yml new file mode 100644 index 000000000..5a47746a5 --- /dev/null +++ b/Plugins/IPOverview/project.yml @@ -0,0 +1,4 @@ + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/InputRemapping/Sources/InputRemappingModels.swift b/Plugins/InputRemapping/Sources/InputRemappingModels.swift index 2d9daedf9..5ae2ea5a0 100644 --- a/Plugins/InputRemapping/Sources/InputRemappingModels.swift +++ b/Plugins/InputRemapping/Sources/InputRemappingModels.swift @@ -586,6 +586,7 @@ struct InputRemappingEventProcessor { @MainActor final class InputRemappingStore: ObservableObject { @Published private(set) var rules: [InputRemappingRule] + @Published private(set) var portableRestoreRevision: UInt64 = 0 private static let storageKey = "input-remapping.rules.v1" private let storage: any PluginStorage private let logger: Logger @@ -635,14 +636,72 @@ final class InputRemappingStore: ObservableObject { update(rules.map { $0.id == rule.id ? rule : $0 }) } - private func update(_ rules: [InputRemappingRule]) { + private struct PortableRule: Codable { + let id: UUID + let isEnabled: Bool + let trigger: InputRemappingTrigger + let action: InputRemappingRule.Action + let isInputConfigured: Bool + let outputConfigurationState: InputRemappingOutputConfigurationState + + init(_ rule: InputRemappingRule) { + id = rule.id; isEnabled = rule.isEnabled; trigger = rule.trigger; action = rule.action + isInputConfigured = rule.isInputConfigured; outputConfigurationState = rule.outputConfigurationState + } + var restoredRule: InputRemappingRule { + InputRemappingRule(id: id, isEnabled: isEnabled, trigger: trigger, action: action, + isInputConfigured: isInputConfigured, outputConfigurationState: outputConfigurationState, + isUnsafeTriggerConfirmed: false) + } + var isValid: Bool { + if case let .keyboard(code, modifiers) = trigger { + guard code <= KeyboardKeyTap.maximumSupportedKeyCode, modifiers.containsOnlySupportedValues else { return false } + } + switch action { + case let .shortcut(binding): return binding.keyCode <= KeyboardKeyTap.maximumSupportedKeyCode && binding.modifiers.containsOnlySupportedValues + case let .keyTap(key): return key == nil || key?.isSupported == true + default: return true + } + } + } + private struct PortablePreferences: Codable { + let formatVersion: Int + let rules: [PortableRule] + } + var onPersistentPreferencesChange: (() -> Void)? + func portableBackup() -> Data? { + let candidate = rules.map(PortableRule.init) + guard candidate.count <= 256, candidate.allSatisfy(\.isValid), Set(candidate.map(\.id)).count == candidate.count, + let data = try? JSONEncoder().encode(PortablePreferences(formatVersion: 1, rules: candidate)), + data.count <= 1_024 * 1_024 else { return nil } + return data + } + func restorePortableBackup(_ data: Data) -> Bool { + guard data.count <= 1_024 * 1_024, + let payload = try? JSONDecoder().decode(PortablePreferences.self, from: data), payload.formatVersion == 1, + payload.rules.count <= 256, Set(payload.rules.map(\.id)).count == payload.rules.count, + payload.rules.allSatisfy(\.isValid) else { return false } + let previous = rules + guard update(payload.rules.map(\.restoredRule)) else { return false } + if rules != previous { portableRestoreRevision &+= 1 } + return true + } + + @discardableResult + private func update(_ rules: [InputRemappingRule]) -> Bool { let normalizedRules = rules.map { $0.normalized() } - do { - storage.set(try JSONEncoder().encode(normalizedRules), forKey: Self.storageKey) - self.rules = normalizedRules - onRulesChange?() - } catch { - logger.error("Failed to encode input remapping rules: \(String(describing: error), privacy: .public)") + guard normalizedRules != self.rules else { return true } + guard normalizedRules.count <= 256, let data = try? JSONEncoder().encode(normalizedRules), data.count <= 1_024 * 1_024 else { return false } + let previous = storage.object(forKey: Self.storageKey) + storage.set(data, forKey: Self.storageKey) + guard storage.data(forKey: Self.storageKey) == data else { + if let previous { storage.set(previous, forKey: Self.storageKey) } + else { storage.removeObject(forKey: Self.storageKey) } + return false } + self.rules = normalizedRules + onRulesChange?() + onPersistentPreferencesChange?() + return true } } diff --git a/Plugins/InputRemapping/Sources/InputRemappingPlugin.swift b/Plugins/InputRemapping/Sources/InputRemappingPlugin.swift index 4cee99abb..3e12583cd 100644 --- a/Plugins/InputRemapping/Sources/InputRemappingPlugin.swift +++ b/Plugins/InputRemapping/Sources/InputRemappingPlugin.swift @@ -234,6 +234,7 @@ final class InputRemappingPlugin: MacToolsPlugin, AccessibilityPermissionRefresh var onTrackpadGestureClaimsChange: (() -> Void)? var requestTrackpadGestureOwnership: ((TrackpadGesture) -> Void)? + private var isRestoringPortablePreferences = false let store: InputRemappingStore private let localization: PluginLocalization @@ -324,8 +325,14 @@ final class InputRemappingPlugin: MacToolsPlugin, AccessibilityPermissionRefresh self.tap.update(rules: store.rules) store.onRulesChange = { [weak self] in - self?.applyConfiguration() - self?.onTrackpadGestureClaimsChange?() + guard let self else { return } + if self.isRestoringPortablePreferences { + self.buttonCapture.cancel() + self.tap.stop() + self.refreshPermissionState() + } + applyConfiguration() + onTrackpadGestureClaimsChange?() } self.tap.emergencyStopHandler = { [weak self] in Task { @MainActor in @@ -690,6 +697,7 @@ private struct InputRemappingSettingsView: View { isTrackpadGestureOwned: isTrackpadGestureOwned ) } + .id(store.portableRestoreRevision) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -1568,3 +1576,18 @@ private final class DualClickButton: NSButton { sendAction(action, to: target) } } + +extension InputRemappingPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { store.onPersistentPreferencesChange } + set { store.onPersistentPreferencesChange = newValue } + } + func makePortablePreferencesBackup() -> Data? { store.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + isRestoringPortablePreferences = true + defer { isRestoringPortablePreferences = false } + return store.restorePortableBackup(data) + } +} diff --git a/Plugins/InputRemapping/Tests/InputRemappingModelsTests.swift b/Plugins/InputRemapping/Tests/InputRemappingModelsTests.swift index a6188f0b4..e22884d12 100644 --- a/Plugins/InputRemapping/Tests/InputRemappingModelsTests.swift +++ b/Plugins/InputRemapping/Tests/InputRemappingModelsTests.swift @@ -3,8 +3,12 @@ import Carbon.HIToolbox import CoreGraphics import Foundation import MacToolsPluginKit +import SwiftUI import XCTest @testable import InputRemappingPlugin +@testable import class MacTools.PluginHost +@testable import class MacTools.PreferencesBackupStore +@testable import struct MacTools.PreferencesBackup @MainActor private final class InputRemappingMemoryStorage: PluginStorage { @@ -118,6 +122,82 @@ private final class InputRemappingLockedCounter: @unchecked Sendable { } final class InputRemappingModelsTests: XCTestCase { + @MainActor + func testMountedEditorPreservesCloudRestoredRuleOnSubsequentToggle() async throws { + guard ProcessInfo.processInfo.environment["MACTOOLS_RUN_DESKTOP_TESTS"] == "1" else { + throw XCTSkip("Run on demand to verify the mounted native editor.") + } + let storage = InputRemappingMemoryStorage() + let rule = InputRemappingRule(buttonNumber: 4, action: .mouseBack) + storage.set(try JSONEncoder().encode([rule]), forKey: "input-remapping.rules.v1") + let plugin = InputRemappingPlugin(context: PluginRuntimeContext(pluginID: "input-remapping", storage: storage), + tap: InputRemappingTapSpy(), accessibilityTrusted: { true }, requestAccessibilityTrust: { _ in true }, + inputMonitoringStatus: { .granted }, openURL: { _ in }) + let suite = "InputRemappingEditorTests." + UUID().uuidString + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let host = PluginHost(plugins: [plugin], shortcutStore: .init(userDefaults: defaults), + pluginOrderingStore: .init(userDefaults: defaults), preferencesBackupStore: PreferencesBackupStore(userDefaults: defaults), + globalShortcutManager: .init(registrar: FakeCarbonHotKeyRegistrar()), loadDynamicPluginsOnInit: false) + defer { host.deactivateAllPlugins() } + guard case let .workspace(workspace) = try XCTUnwrap(plugin.settingsPage).body else { + return XCTFail("Expected the plugin workspace") + } + let view = NSHostingView(rootView: workspace.makeView(.init(pluginID: "input-remapping")).frame(width: 1100, height: 700)) + let window = NSWindow(contentRect: NSRect(x: -20000, y: -20000, width: 1100, height: 700), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = view + window.orderBack(nil) + defer { window.close() } + func findSwitch(_ node: NSView) -> NSSwitch? { + if let control = node as? NSSwitch { return control } + for child in node.subviews { if let control = findSwitch(child) { return control } } + return nil + } + try await Task.sleep(for: .milliseconds(200)) + view.layoutSubtreeIfNeeded() + _ = try XCTUnwrap(findSwitch(view)) + var incomingRule = rule + incomingRule.action = .mouseForward + let sourceStorage = InputRemappingMemoryStorage() + sourceStorage.set(try JSONEncoder().encode([incomingRule]), forKey: "input-remapping.rules.v1") + let source = InputRemappingStore(storage: sourceStorage) + let current = host.makePreferencesBackup() + let incoming = PreferencesBackup(application: current.application, pluginDisplay: current.pluginDisplay, + shortcutCustomizations: current.shortcutCustomizations, + pluginPreferences: ["input-remapping": try XCTUnwrap(source.portableBackup())]) + + _ = try host.importCloudPreferences(incoming) + XCTAssertEqual(plugin.store.rules.first?.action, .mouseForward) + try await Task.sleep(for: .milliseconds(200)) + view.layoutSubtreeIfNeeded() + let control = try XCTUnwrap(findSwitch(view)) + let event = try XCTUnwrap(NSEvent.mouseEvent(with: .rightMouseDown, + location: control.convert(.zero, to: nil), modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)) + control.rightMouseDown(with: event) + let saved = try XCTUnwrap(InputRemappingStore(storage: storage).rules.first) + XCTAssertEqual(saved.action, .mouseForward) + XCTAssertFalse(saved.isEnabled) + } + + @MainActor + func testPortableRulesKeepIdentityAndRequireLocalUnsafeAuthorization() throws { + let source = InputRemappingStore(storage: InputRemappingMemoryStorage()) + source.addRule() + var rule = try XCTUnwrap(source.rules.first) + rule.replaceTrigger(.keyboard(keyCode: 0, modifiers: [])) + rule.isUnsafeTriggerConfirmed = true + rule.isEnabled = true + source.replace(rule) + let destination = InputRemappingStore(storage: InputRemappingMemoryStorage()) + XCTAssertTrue(destination.restorePortableBackup(try XCTUnwrap(source.portableBackup()))) + XCTAssertEqual(destination.rules.first?.id, rule.id) + XCTAssertFalse(try XCTUnwrap(destination.rules.first).isUnsafeTriggerConfirmed) + XCTAssertFalse(try XCTUnwrap(destination.rules.first).isEnabled) + } + func testUnsupportedPersistedSingleKeyIsDisabledWithoutDroppingRule() throws { var rule = InputRemappingRule(buttonNumber: 4, action: .mouseBack) diff --git a/Plugins/InputRemapping/plugin.json b/Plugins/InputRemapping/plugin.json index ee2684e4f..78110f02d 100644 --- a/Plugins/InputRemapping/plugin.json +++ b/Plugins/InputRemapping/plugin.json @@ -274,5 +274,11 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "external-authorization" + ] } } diff --git a/Plugins/KeepAwake/Sources/KeepAwakePlugin.swift b/Plugins/KeepAwake/Sources/KeepAwakePlugin.swift index 6ce8506ee..356feb58b 100644 --- a/Plugins/KeepAwake/Sources/KeepAwakePlugin.swift +++ b/Plugins/KeepAwake/Sources/KeepAwakePlugin.swift @@ -168,6 +168,7 @@ final class KeepAwakePlugin: ) var onStateChange: (() -> Void)? + var onPersistentPreferencesChange: (() -> Void)? var requestPermissionGuidance: ((String) -> Void)? var shortcutBindingResolver: ((String) -> ShortcutBinding?)? @@ -214,6 +215,7 @@ final class KeepAwakePlugin: let resolvedPowerSourceMonitor = powerSourceMonitor ?? KeepAwakePowerSourceMonitor() self.localization = localization self.storage = context.storage + _ = Self.portableStore(storage: context.storage).recover() self.sessionFactory = sessionFactory self.powerSourceMonitor = resolvedPowerSourceMonitor self.displayProvider = displayProvider @@ -457,6 +459,7 @@ final class KeepAwakePlugin: func activate(context: PluginRuntimeContext) { storage = context.storage + _ = portableStore.recover() powerSourceMonitor.start() powerSourceState = powerSourceMonitor.currentState hasActiveExternalDisplay = Self.detectActiveExternalDisplay(using: displayProvider) @@ -1333,7 +1336,15 @@ final class KeepAwakePlugin: guard !preservesFuturePreferencePayload else { return } - Self.persist(preferences, to: storage) + let previous = portableStore.currentValues() + guard portableStore.commit([ + StorageKey.behavior: .string(preferences.behavior.rawValue), + StorageKey.preferenceVersion: .integer(PreferenceVersion.current) + ]) else { + lastErrorMessage = PluginKitLocalization.actionUnavailable + return + } + if previous != portableStore.currentValues() { onPersistentPreferencesChange?() } } private static func loadPreferences(from storage: PluginStorage) -> PreferenceLoadResult { @@ -1634,3 +1645,40 @@ enum KeepAwakeStopScheduleFormatting { return formatter.string(from: date) } } + +extension KeepAwakePlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + private struct PortableBehavior: Codable { + let formatVersion: Int + let behavior: String + } + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + StorageKey.behavior: .choice(KeepAwakeBehavior.allowDisplayToTurnOff.rawValue, allowed: KeepAwakeBehavior.allCases.map(\.rawValue)), + StorageKey.preferenceVersion: .integer(PreferenceVersion.current, range: PreferenceVersion.current...PreferenceVersion.current) + ]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + func makePortablePreferencesBackup() -> Data? { + guard !preservesFuturePreferencePayload, portableStore.backup() != nil, + case let .string(behavior) = portableStore.currentValues()?[StorageKey.behavior] else { return nil } + return try? JSONEncoder().encode(PortableBehavior(formatVersion: 1, behavior: behavior)) + } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + // Imported future choices must not alter an existing assertion, lid policy or virtual display. + guard session == nil, virtualDisplayStartTask == nil, !virtualDisplayManager.isActive, + pendingAutomaticFallback == nil, pendingUserActivityCleanupError == nil, + !preservesFuturePreferencePayload, data.count <= 16_384, + let payload = try? JSONDecoder().decode(PortableBehavior.self, from: data), payload.formatVersion == 1, + let behavior = KeepAwakeBehavior(rawValue: payload.behavior) else { return false } + let previous = portableStore.currentValues() + guard portableStore.commit([ + StorageKey.behavior: .string(behavior.rawValue), StorageKey.preferenceVersion: .integer(PreferenceVersion.current) + ]) else { return false } + preferences = KeepAwakePreferences(behavior: behavior) + if previous != portableStore.currentValues() { onPersistentPreferencesChange?() } + notifyChange() + return true + } +} diff --git a/Plugins/KeepAwake/plugin.json b/Plugins/KeepAwake/plugin.json index 2a6ba0edf..16a9be255 100644 --- a/Plugins/KeepAwake/plugin.json +++ b/Plugins/KeepAwake/plugin.json @@ -342,5 +342,11 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "local-activation" + ] } } diff --git a/Plugins/KeepAwake/project.yml b/Plugins/KeepAwake/project.yml index 45be6ab22..69ab91422 100644 --- a/Plugins/KeepAwake/project.yml +++ b/Plugins/KeepAwake/project.yml @@ -20,3 +20,7 @@ targets: CODE_SIGN_STYLE: Automatic DEVELOPMENT_TEAM: "$(DEVELOPMENT_TEAM)" OTHER_LDFLAGS: -framework CoreGraphics + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/KeyStats/plugin.json b/Plugins/KeyStats/plugin.json index 84a0894d1..bd12814fb 100644 --- a/Plugins/KeyStats/plugin.json +++ b/Plugins/KeyStats/plugin.json @@ -208,5 +208,12 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "localOnly", + "exclusions": [ + "local-activation", + "history" + ] } } diff --git a/Plugins/LaunchControl/Resources/Localizable.xcstrings b/Plugins/LaunchControl/Resources/Localizable.xcstrings index 93fcb4a1d..16ded264e 100644 --- a/Plugins/LaunchControl/Resources/Localizable.xcstrings +++ b/Plugins/LaunchControl/Resources/Localizable.xcstrings @@ -5163,76 +5163,76 @@ "manager.note.placeholder": { "extractionState": "manual", "localizations": { - "ar": { + "en": { "stringUnit": { "state": "translated", - "value": "أضف ملاحظة محلية لعنصر التشغيل هذا. تبقى على هذا الـ Mac ولا تُكتب في plist." + "value": "Add a note for this launch item (not written to the plist)." } }, - "de": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Fügen Sie eine lokale Notiz für dieses Startelement hinzu. Es bleibt auf diesem Mac und wird nicht in die Plist geschrieben." + "value": "为这个启动项添加备注(不写入 plist)。" } }, - "en": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Add a local note for this launch item. It stays on this Mac and is not written to the plist." + "value": "為此啟動項目新增備註(不會寫入 plist)。" } }, "es": { "stringUnit": { "state": "translated", - "value": "Añade una nota local para este elemento de inicio. Se queda en este Mac y no se escribe en el plist." + "value": "Añade una nota para este elemento (no se escribe en el plist)." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Ajoutez une note locale à cet élément de lancement. Elle reste sur ce Mac et n’est pas écrite dans le plist." + "value": "Ajoutez une note pour cet élément (non écrite dans le plist)." } }, - "ja": { + "ru": { "stringUnit": { "state": "translated", - "value": "この起動項目にローカルメモを追加します。この Mac にのみ保存され、plist には書き込まれません。" + "value": "Добавьте заметку к элементу (без записи в plist)." } }, - "ko": { + "pt": { "stringUnit": { "state": "translated", - "value": "이 시작 항목에 로컬 메모를 추가합니다. 이 Mac에만 저장되며 plist에는 기록되지 않습니다." + "value": "Adicione uma nota para este item (não é gravada no plist)." } }, - "pt": { + "de": { "stringUnit": { "state": "translated", - "value": "Adicione uma nota local para este item de inicialização. Ela fica neste Mac e não é gravada no plist." + "value": "Notiz für dieses Startobjekt hinzufügen (wird nicht in die plist geschrieben)." } }, - "ru": { + "ja": { "stringUnit": { "state": "translated", - "value": "Добавьте локальную заметку для этого элемента запуска. Она останется на этом Mac и не будет записана в plist." + "value": "この起動項目にメモを追加します(plistには書き込みません)。" } }, - "tr": { + "ko": { "stringUnit": { "state": "translated", - "value": "Bu başlatma öğesi için yerel bir not ekleyin. Not bu Mac'te kalır ve plist dosyasına yazılmaz." + "value": "이 시작 항목의 메모를 추가하세요(plist에는 기록하지 않음)." } }, - "zh-Hans": { + "ar": { "stringUnit": { "state": "translated", - "value": "为这个启动项添加本地备注(仅保存在本机,不写入 plist)。" + "value": "أضف ملاحظة لهذا العنصر (لا تُكتب في plist)." } }, - "zh-Hant": { + "tr": { "stringUnit": { "state": "translated", - "value": "為這個啟動項添加本地備註(僅保存在本機,不寫入 plist)。" + "value": "Bu başlangıç öğesine not ekleyin (plist dosyasına yazılmaz)." } } } @@ -8547,6 +8547,234 @@ } } } + }, + "backup.includeNotes": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Include notes in backups" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将备注包含在备份中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將備註包含在備份中" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Incluir notas en las copias" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Inclure les notes dans les sauvegardes" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включать заметки в резервные копии" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Incluir notas nos backups" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Notizen in Sicherungen einschließen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メモをバックアップに含める" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "백업에 메모 포함" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تضمين الملاحظات في النسخ الاحتياطية" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Notları yedeklere dahil et" + } + } + } + }, + "backup.includeNotes.help": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Applies to manual backups, automatic backups, and cloud sync. Only favorites are included by default." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "适用于手动备份、自动备份和云同步。默认仅备份关注项。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "適用於手動備份、自動備份和雲端同步。預設僅備份關注項目。" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se aplica a copias manuales, automáticas y a la sincronización. Solo se incluyen favoritos de forma predeterminada." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "S’applique aux sauvegardes manuelles, automatiques et à la synchronisation. Seuls les favoris sont inclus par défaut." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Для ручных и автоматических копий и облачной синхронизации. По умолчанию включены только избранные." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Aplica-se a backups manuais, automáticos e à sincronização. Por padrão, inclui apenas favoritos." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gilt für manuelle und automatische Sicherungen sowie Cloud-Synchronisierung. Standardmäßig werden nur Favoriten gesichert." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "手動・自動バックアップとクラウド同期に適用します。既定ではお気に入りのみ含まれます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수동·자동 백업과 클라우드 동기화에 적용됩니다. 기본적으로 즐겨찾기만 포함됩니다." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ينطبق على النسخ اليدوي والتلقائي والمزامنة. تُضمّن المفضلة فقط افتراضيًا." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Manuel ve otomatik yedekler ile bulut eşzamanlama için geçerlidir. Varsayılan olarak yalnızca favoriler dahil edilir." + } + } + } + }, + "backup.unmatched": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Launch item settings awaiting a match" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "待匹配的启动项配置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "待配對的啟動項目設定" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ajustes de inicio pendientes de coincidencia" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réglages de démarrage en attente de correspondance" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройки запуска без соответствующего элемента" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ajustes de inicialização aguardando correspondência" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Startobjekt-Einstellungen ohne Zuordnung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "対応する起動項目を待つ設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일치 항목 대기 중인 시작 설정" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعدادات بدء التشغيل التي تنتظر مطابقة" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Eşleşme bekleyen başlangıç öğesi ayarları" + } + } + } } }, "version": "1.0" diff --git a/Plugins/LaunchControl/Sources/LaunchControlController.swift b/Plugins/LaunchControl/Sources/LaunchControlController.swift index 5ce0ad8a1..e6b360b89 100644 --- a/Plugins/LaunchControl/Sources/LaunchControlController.swift +++ b/Plugins/LaunchControl/Sources/LaunchControlController.swift @@ -7,12 +7,16 @@ final class LaunchControlController: ObservableObject { @Published private(set) var snapshot = LaunchControlSnapshot() var onStateChange: (() -> Void)? + var onPersistentPreferencesChange: (() -> Void)? + @Published private(set) var noteRestorationRevisions: [String: UInt] = [:] + private let portablePreferences: LaunchControlPortablePreferences private let scanner: LaunchControlScanner private let runner: any LaunchControlCommandRunning private let favoritesStore: LaunchControlFavoritesStore private let notesStore: LaunchControlNotesStore private let localization: PluginLocalization + private var activeOperations = 0 private var refreshTask: Task? init( @@ -24,12 +28,19 @@ final class LaunchControlController: ObservableObject { localization: PluginLocalization = PluginLocalization(bundle: .main), initialSnapshot: LaunchControlSnapshot = LaunchControlSnapshot() ) { + self.portablePreferences = LaunchControlPortablePreferences(storage: context.storage) + _ = LaunchControlPortablePreferences.store(context.storage).recover() self.snapshot = initialSnapshot self.runner = runner self.favoritesStore = favoritesStore ?? LaunchControlFavoritesStore(context: context) self.notesStore = notesStore ?? LaunchControlNotesStore(context: context) self.localization = localization self.scanner = scanner ?? LaunchControlScanner(runner: runner, localization: localization) + self.favoritesStore.onMutation = { [weak self] in self?.onPersistentPreferencesChange?() } + self.notesStore.onMutation = { [weak self] in + guard let self, self.portablePreferences.includesNotes else { return } + self.onPersistentPreferencesChange?() + } } deinit { @@ -97,7 +108,7 @@ final class LaunchControlController: ObservableObject { } func setFavorite(_ isFavorite: Bool, for item: LaunchControlItem) { - favoritesStore.setFavorite(isFavorite, for: item.id) + guard favoritesStore.setFavorite(isFavorite, for: item.id) else { return } refreshPersistedState(for: item) snapshot.operationMessage = isFavorite ? localization.format("controller.favorite.added", defaultValue: "已关注 %@", item.label) @@ -106,7 +117,7 @@ final class LaunchControlController: ObservableObject { } func setNote(_ note: String, for item: LaunchControlItem) { - notesStore.setNote(note, for: item.id) + guard notesStore.setNote(note, for: item.id) else { return } refreshPersistedState(for: item) let isCleared = note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty snapshot.operationMessage = isCleared @@ -115,6 +126,35 @@ final class LaunchControlController: ObservableObject { onStateChange?() } + var includesNotesInBackup: Bool { portablePreferences.includesNotes } + + func setIncludesNotesInBackup(_ value: Bool) { + guard value != includesNotesInBackup, portablePreferences.setIncludesNotes(value) else { return } + onPersistentPreferencesChange?() + onStateChange?() + objectWillChange.send() + } + + var unmatchedBackupReferences: [LaunchControlPortablePreferences.Reference] { + portablePreferences.unmatchedReferences(matching: Set(snapshot.items.map(\.id))) + } + + func preferencesBackup() -> Data? { portablePreferences.backup() } + + func restorePreferencesBackup(_ data: Data) -> Bool { + guard !snapshot.isRefreshing, activeOperations == 0 else { return false } + let previous = portablePreferences.backup() + guard portablePreferences.restore(data) else { return false } + let previousNotes = Dictionary(uniqueKeysWithValues: snapshot.items.map { ($0.id, $0.note) }) + snapshot.items = sortedItems(snapshot.items.map(applyingPersistedState)) + for item in snapshot.items where previousNotes[item.id] != item.note { + noteRestorationRevisions[item.id, default: 0] &+= 1 + } + onStateChange?() + if previous != portablePreferences.backup() { onPersistentPreferencesChange?() } + return true + } + func bootstrap(_ item: LaunchControlItem) { scheduleManagedAction( item: item, @@ -310,6 +350,8 @@ final class LaunchControlController: ObservableObject { return false } + activeOperations += 1 + defer { activeOperations -= 1 } snapshot.operationMessage = localization.format( "controller.operation.running", defaultValue: "%@ %@...", diff --git a/Plugins/LaunchControl/Sources/LaunchControlManagerView.swift b/Plugins/LaunchControl/Sources/LaunchControlManagerView.swift index 3bb6f2c48..b6b82e765 100644 --- a/Plugins/LaunchControl/Sources/LaunchControlManagerView.swift +++ b/Plugins/LaunchControl/Sources/LaunchControlManagerView.swift @@ -80,6 +80,7 @@ struct LaunchControlManagerView: View { compactPane = .items } } + .onChange(of: controller.noteRestorationRevisions, reconcileNoteDrafts) .onChange(of: searchFocusController.requestID) { isSearchFocused = true } @@ -121,6 +122,31 @@ struct LaunchControlManagerView: View { summaryHeader(compact: usesCompactHeight) } toolbar(availableWidth: availableWidth) + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Toggle(localization.string("backup.includeNotes", defaultValue: "将备注包含在备份中"), isOn: Binding( + get: { controller.includesNotesInBackup }, set: { controller.setIncludesNotesInBackup($0) } + )) + .toggleStyle(.switch) + .controlSize(.small) + Text(localization.string("backup.includeNotes.help", defaultValue: "适用于手动备份、自动备份和云同步。默认仅备份关注项。")) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + if !controller.unmatchedBackupReferences.isEmpty { + DisclosureGroup(localization.string("backup.unmatched", defaultValue: "待匹配的启动项配置")) { + ScrollView { + VStack(alignment: .leading) { + ForEach(controller.unmatchedBackupReferences, id: \.self) { reference in + Text(reference.plistFileName) + .font(PluginSettingsTheme.Typography.rowDescription) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 120) + } + } + } if let message = controller.snapshot.errorMessage { statusBanner(message: message, systemImage: "exclamationmark.triangle.fill", color: .orange) @@ -521,6 +547,10 @@ struct LaunchControlManagerView: View { ) } + private func reconcileNoteDrafts(previous: [String: UInt], current: [String: UInt]) { + noteDrafts = noteDrafts.filter { previous[$0.key] == current[$0.key] } + } + private func detailHeader(_ item: LaunchControlItem) -> some View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .top, spacing: 12) { @@ -1106,7 +1136,7 @@ private struct LaunchControlNoteEditor: View { if draft.isEmpty { Text(localization.string( "manager.note.placeholder", - defaultValue: "为这个启动项添加本地备注(仅保存在本机,不写入 plist)。" + defaultValue: "为这个启动项添加备注(不写入 plist)。" )) .font(PluginTypography.body.font) .foregroundStyle(.tertiary) diff --git a/Plugins/LaunchControl/Sources/LaunchControlModels.swift b/Plugins/LaunchControl/Sources/LaunchControlModels.swift index 9f9f11957..b13230785 100644 --- a/Plugins/LaunchControl/Sources/LaunchControlModels.swift +++ b/Plugins/LaunchControl/Sources/LaunchControlModels.swift @@ -336,6 +336,7 @@ final class LaunchControlFavoritesStore { } private let storage: PluginStorage + var onMutation: (() -> Void)? init( context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "launch-control"), @@ -345,6 +346,7 @@ final class LaunchControlFavoritesStore { UserDefaultsPluginStorage(pluginID: context.pluginID, userDefaults: $0) } ?? context.storage storage.migrateValueIfNeeded(fromLegacyKey: StorageKey.storage, to: StorageKey.storage) + _ = LaunchControlPortablePreferences.store(storage).recover() } func favoriteItemIDs() -> Set { @@ -355,14 +357,18 @@ final class LaunchControlFavoritesStore { favoriteItemIDs().contains(itemID) } - func setFavorite(_ isFavorite: Bool, for itemID: String) { + @discardableResult + func setFavorite(_ isFavorite: Bool, for itemID: String) -> Bool { var favorites = favoriteItemIDs() if isFavorite { favorites.insert(itemID) } else { favorites.remove(itemID) } - storage.set(favorites.sorted(), forKey: StorageKey.storage) + guard favorites != favoriteItemIDs() else { return true } + guard LaunchControlPortablePreferences.store(storage).commit([StorageKey.storage: .strings(favorites.sorted())]) else { return false } + onMutation?() + return true } } @@ -375,6 +381,7 @@ final class LaunchControlNotesStore { } private let storage: PluginStorage + var onMutation: (() -> Void)? init( context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "launch-control"), @@ -399,7 +406,8 @@ final class LaunchControlNotesStore { allNotes()[itemID] ?? "" } - func setNote(_ note: String, for itemID: String) { + @discardableResult + func setNote(_ note: String, for itemID: String) -> Bool { var notes = allNotes() let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { @@ -407,8 +415,13 @@ final class LaunchControlNotesStore { } else { notes[itemID] = trimmed } - guard let data = try? JSONEncoder().encode(notes) else { return } - storage.set(data, forKey: StorageKey.storage) + guard notes != allNotes() else { return true } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(notes), + LaunchControlPortablePreferences.store(storage).commit([StorageKey.storage: .data(data)]) else { return false } + onMutation?() + return true } } diff --git a/Plugins/LaunchControl/Sources/LaunchControlPlugin.swift b/Plugins/LaunchControl/Sources/LaunchControlPlugin.swift index 50c927247..6b75459cc 100644 --- a/Plugins/LaunchControl/Sources/LaunchControlPlugin.swift +++ b/Plugins/LaunchControl/Sources/LaunchControlPlugin.swift @@ -62,6 +62,7 @@ final class LaunchControlPlugin: ) var onStateChange: (() -> Void)? + var onPersistentPreferencesChange: (() -> Void)? var requestPermissionGuidance: ((String) -> Void)? var shortcutBindingResolver: ((String) -> ShortcutBinding?)? var requestSettingsPresentation: (() -> Void)? @@ -79,6 +80,7 @@ final class LaunchControlPlugin: let localization = localization ?? PluginLocalization(bundle: context.resourceBundle) self.localization = localization self.controller = controller ?? LaunchControlController(context: context, localization: localization) + self.controller.onPersistentPreferencesChange = { [weak self] in self?.onPersistentPreferencesChange?() } self.controller.onStateChange = { [weak self] in self?.onStateChange?() } @@ -346,3 +348,11 @@ final class LaunchControlPlugin: } } } + + +extension LaunchControlPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + func makePortablePreferencesBackup() -> Data? { controller.preferencesBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { controller.restorePreferencesBackup(data) } +} diff --git a/Plugins/LaunchControl/Sources/LaunchControlPortablePreferences.swift b/Plugins/LaunchControl/Sources/LaunchControlPortablePreferences.swift new file mode 100644 index 000000000..1b8b8eaeb --- /dev/null +++ b/Plugins/LaunchControl/Sources/LaunchControlPortablePreferences.swift @@ -0,0 +1,158 @@ +import Foundation +import MacToolsPluginKit + +/// Portable metadata only. Resolving a launch directory never scans or changes launchd jobs. +@MainActor +struct LaunchControlPortablePreferences { + enum Key { + static let favorites = "launch-control.favorite-item-ids" + static let notes = "launch-control.item-notes" + static let includesNotes = "preferences-backup.include-notes" + } + + struct Reference: Codable, Hashable { + enum Location: String, Codable, CaseIterable { + case userAgents, globalAgents, globalDaemons, systemAgents, systemDaemons + } + let location: Location + let plistFileName: String + + var isValid: Bool { + plistFileName.utf8.count <= 255 && plistFileName.hasSuffix(".plist") + && plistFileName != ".plist" && !plistFileName.contains("/") + && !plistFileName.contains("\\") + && !plistFileName.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + } + } + + struct Note: Codable { + let reference: Reference + let text: String + } + + struct Payload: Codable { + let formatVersion: Int + let favorites: [Reference] + let notes: [Note]? + } + + let storage: any PluginStorage + var homeURL = FileManager.default.homeDirectoryForCurrentUser + + static func store(_ storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + Key.favorites: .strings([], maximumCount: 1_024, maximumStringBytes: 4_096), + Key.notes: .data(Data("{}".utf8), maximumBytes: 2_097_152) { data in + guard let notes = try? JSONDecoder().decode([String: String].self, from: data) else { return false } + return notes.count <= 1_024 && notes.allSatisfy { + !$0.key.isEmpty && $0.key.utf8.count <= 4_096 && validNote($0.value) + } + }, + Key.includesNotes: .boolean(false), + ]) + } + + static func validNote(_ text: String) -> Bool { + text.utf8.count <= 16_384 && !text.unicodeScalars.contains { + CharacterSet.controlCharacters.contains($0) && $0 != "\n" && $0 != "\r" && $0 != "\t" + } + } + + var includesNotes: Bool { storage.bool(forKey: Key.includesNotes) } + + func setIncludesNotes(_ value: Bool) -> Bool { + Self.store(storage).commit([Key.includesNotes: .boolean(value)]) + } + + func backup() -> Data? { + let store = Self.store(storage) + let keys: Set = includesNotes ? [Key.favorites, Key.notes] : [Key.favorites] + guard store.backup(including: keys) != nil, let values = store.currentValues(including: keys), + case let .strings(favorites)? = values[Key.favorites] else { return nil } + var portableNotes: [Note]? + if includesNotes { + guard case let .data(notesData)? = values[Key.notes], + let notes = try? JSONDecoder().decode([String: String].self, from: notesData) else { return nil } + portableNotes = notes.compactMap { key, text in + reference(for: key).map { Note(reference: $0, text: text) } + }.sorted { order($0.reference, $1.reference) } + } + let payload = Payload( + formatVersion: 1, + favorites: favorites.compactMap(reference).sorted(by: order), + notes: portableNotes + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(payload), data.count <= 2_097_152 else { return nil } + return data + } + + func restore(_ data: Data) -> Bool { + guard data.count <= 2_097_152, + let payload = try? JSONDecoder().decode(Payload.self, from: data), payload.formatVersion == 1, + payload.favorites.count <= 1_024, + payload.favorites.allSatisfy(\.isValid), Set(payload.favorites).count == payload.favorites.count, + (payload.notes?.count ?? 0) <= 1_024, + payload.notes?.allSatisfy({ $0.reference.isValid && Self.validNote($0.text) }) != false, + Set(payload.notes?.map(\.reference) ?? []).count == (payload.notes?.count ?? 0), + Self.store(storage).recover(), + let values = Self.store(storage).currentValues(including: [Key.favorites]), + case let .strings(previousFavorites)? = values[Key.favorites] + else { return false } + let favorites = previousFavorites.filter { reference(for: $0) == nil } + payload.favorites.map(path) + var candidate: [String: PortablePreferenceValue] = [Key.favorites: .strings(favorites.sorted())] + if let incoming = payload.notes { + guard let values = Self.store(storage).currentValues(including: [Key.notes]), + case let .data(previousNotes)? = values[Key.notes], + var notes = try? JSONDecoder().decode([String: String].self, from: previousNotes) else { return false } + if includesNotes { notes = notes.filter { reference(for: $0.key) == nil } } + for note in incoming { + let itemID = path(for: note.reference) + if !includesNotes, let protected = notes[itemID], protected != note.text { return false } + notes[itemID] = note.text + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let notesData = try? encoder.encode(notes) else { return false } + candidate[Key.notes] = .data(notesData) + } + return Self.store(storage).commit(candidate) + } + + func unmatchedReferences(matching itemIDs: Set) -> [Reference] { + let favorites = storage.stringArray(forKey: Key.favorites) ?? [] + let notes = storage.data(forKey: Key.notes).flatMap { + try? JSONDecoder().decode([String: String].self, from: $0) + } ?? [:] + return Set((favorites + notes.keys).filter { !itemIDs.contains($0) }.compactMap(reference)).sorted(by: order) + } + + func reference(for path: String) -> Reference? { + for location in Reference.Location.allCases { + let prefix = directory(for: location).path + "/" + guard path.hasPrefix(prefix) else { continue } + let reference = Reference(location: location, plistFileName: String(path.dropFirst(prefix.count))) + if reference.isValid { return reference } + } + return nil + } + + private func path(for reference: Reference) -> String { + directory(for: reference.location).appendingPathComponent(reference.plistFileName).path + } + + private func directory(for location: Reference.Location) -> URL { + switch location { + case .userAgents: homeURL.appendingPathComponent("Library/LaunchAgents", isDirectory: true) + case .globalAgents: URL(fileURLWithPath: "/Library/LaunchAgents", isDirectory: true) + case .globalDaemons: URL(fileURLWithPath: "/Library/LaunchDaemons", isDirectory: true) + case .systemAgents: URL(fileURLWithPath: "/System/Library/LaunchAgents", isDirectory: true) + case .systemDaemons: URL(fileURLWithPath: "/System/Library/LaunchDaemons", isDirectory: true) + } + } + + private func order(_ lhs: Reference, _ rhs: Reference) -> Bool { + (lhs.location.rawValue, lhs.plistFileName) < (rhs.location.rawValue, rhs.plistFileName) + } +} diff --git a/Plugins/LaunchControl/Tests/LaunchControlPluginTests.swift b/Plugins/LaunchControl/Tests/LaunchControlPluginTests.swift index 17e982607..289dac3a3 100644 --- a/Plugins/LaunchControl/Tests/LaunchControlPluginTests.swift +++ b/Plugins/LaunchControl/Tests/LaunchControlPluginTests.swift @@ -4,6 +4,37 @@ import MacToolsPluginKit @MainActor final class LaunchControlCanonicalActionTests: XCTestCase { + func testRestorationInvalidatesOnlyEditorsWhoseSavedNotesChanged() throws { + let path = "/Library/LaunchAgents/com.example.synthetic.plist" + let item = makeItem(id: path, label: "com.example.synthetic", isFavorite: false) + let other = makeItem(id: "/Library/LaunchAgents/com.example.other.plist", label: "com.example.other", isFavorite: false) + let storage = FakePluginStorage() + let runner = FakeLaunchControlCommandRunner() + let controller = LaunchControlController(runner: runner, + context: .init(pluginID: "launch-control", storage: storage), initialSnapshot: .init(items: [item, other])) + controller.setNote("Saved note", for: item) + let reference = LaunchControlPortablePreferences.Reference(location: .globalAgents, plistFileName: "com.example.synthetic.plist") + func restore(notes: [LaunchControlPortablePreferences.Note]?) throws { + let payload = LaunchControlPortablePreferences.Payload(formatVersion: 1, favorites: [reference], notes: notes) + XCTAssertTrue(controller.restorePreferencesBackup(try JSONEncoder().encode(payload))) + } + + try restore(notes: nil) + XCTAssertTrue(controller.noteRestorationRevisions.isEmpty) + XCTAssertEqual(controller.snapshot.items.first { $0.id == path }?.note, "Saved note") + controller.setIncludesNotesInBackup(true) + try restore(notes: [.init(reference: reference, text: "Saved note")]) + XCTAssertTrue(controller.noteRestorationRevisions.isEmpty) + try restore(notes: [.init(reference: reference, text: "Imported note")]) + XCTAssertEqual(controller.noteRestorationRevisions, [path: 1]) + try restore(notes: nil) + XCTAssertEqual(controller.noteRestorationRevisions, [path: 1]) + try restore(notes: []) + XCTAssertEqual(controller.noteRestorationRevisions, [path: 2]) + XCTAssertEqual(controller.snapshot.items.first { $0.id == path }?.note, "") + XCTAssertTrue(runner.calls.isEmpty) + } + func testOnlyFavoriteManageableItemsPublishCanonicalActions() throws { let favorite = makeItem(id: "favorite", label: "com.example.favorite", isFavorite: true) let ordinary = makeItem(id: "ordinary", label: "com.example.ordinary", isFavorite: false) diff --git a/Plugins/LaunchControl/Tests/LaunchControlPortablePreferencesTests.swift b/Plugins/LaunchControl/Tests/LaunchControlPortablePreferencesTests.swift new file mode 100644 index 000000000..a92172e0a --- /dev/null +++ b/Plugins/LaunchControl/Tests/LaunchControlPortablePreferencesTests.swift @@ -0,0 +1,78 @@ +import XCTest +import MacToolsPluginKit +@testable import LaunchControlPlugin + +@MainActor +final class LaunchControlPortablePreferencesTests: XCTestCase { + func testFavoritesRebaseWithoutScanAndOmittedNotesPreserveDestination() throws { + let source = FakePluginStorage() + let destination = FakePluginStorage() + let sourceHome = URL(fileURLWithPath: "/Users/source") + let destinationHome = URL(fileURLWithPath: "/Users/destination") + let sourcePath = "/Users/source/Library/LaunchAgents/example.plist" + let destinationPath = "/Users/destination/Library/LaunchAgents/example.plist" + source.set([sourcePath, "/Library/LaunchDaemons/global.plist"], forKey: LaunchControlPortablePreferences.Key.favorites) + source.set(try JSONEncoder().encode([sourcePath: "Private source note"]), forKey: LaunchControlPortablePreferences.Key.notes) + destination.set(try JSONEncoder().encode([destinationPath: "Local note"]), forKey: LaunchControlPortablePreferences.Key.notes) + destination.set(["unrecognized-legacy-id"], forKey: LaunchControlPortablePreferences.Key.favorites) + let exporter = LaunchControlPortablePreferences(storage: source, homeURL: sourceHome) + let importer = LaunchControlPortablePreferences(storage: destination, homeURL: destinationHome) + let data = try XCTUnwrap(exporter.backup()) + XCTAssertFalse(String(decoding: data, as: UTF8.self).contains("/Users/source")) + XCTAssertFalse(String(decoding: data, as: UTF8.self).contains("Private source note")) + XCTAssertTrue(importer.restore(data)) + XCTAssertEqual(Set(destination.stringArray(forKey: LaunchControlPortablePreferences.Key.favorites) ?? []), + [destinationPath, "/Library/LaunchDaemons/global.plist", "unrecognized-legacy-id"]) + XCTAssertEqual(importer.unmatchedReferences(matching: []).count, 2) + XCTAssertEqual(try JSONDecoder().decode([String: String].self, from: XCTUnwrap(destination.data(forKey: LaunchControlPortablePreferences.Key.notes))), [destinationPath: "Local note"]) + XCTAssertEqual(importer.backup(), data) + } + + func testOptedInNotesCannotOverwriteExcludedDestinationNotes() throws { + let source = FakePluginStorage() + let destination = FakePluginStorage() + let path = "/Library/LaunchAgents/example.plist" + let key = LaunchControlPortablePreferences.Key.notes + source.set(try JSONEncoder().encode([path: "Imported\nnote"]), forKey: key) + destination.set(try JSONEncoder().encode([path: "Protected"]), forKey: key) + let exporter = LaunchControlPortablePreferences(storage: source) + let importer = LaunchControlPortablePreferences(storage: destination) + XCTAssertTrue(exporter.setIncludesNotes(true)) + let data = try XCTUnwrap(exporter.backup()) + XCTAssertFalse(importer.restore(data)) + XCTAssertFalse(importer.includesNotes) + XCTAssertTrue(importer.setIncludesNotes(true)) + XCTAssertTrue(importer.restore(data)) + XCTAssertEqual(try JSONDecoder().decode([String: String].self, from: XCTUnwrap(destination.data(forKey: key))), [path: "Imported\nnote"]) + } + + func testExcludedLegacyNoteDoesNotBlockFavoritesAndRemainsByteIdentical() throws { + let storage = FakePluginStorage() + let path = "/Library/LaunchAgents/example.plist" + let raw = try JSONSerialization.data(withJSONObject: [path: String(repeating: "x", count: 16_385)], options: [.prettyPrinted]) + storage.set(raw, forKey: LaunchControlPortablePreferences.Key.notes) + let provider = LaunchControlPortablePreferences(storage: storage) + let data = try XCTUnwrap(provider.backup()) + XCTAssertNil(try JSONDecoder().decode(LaunchControlPortablePreferences.Payload.self, from: data).notes) + let incoming = LaunchControlPortablePreferences.Payload(formatVersion: 1, + favorites: [.init(location: .globalAgents, plistFileName: "example.plist")], notes: nil) + XCTAssertTrue(provider.restore(try JSONEncoder().encode(incoming))) + XCTAssertEqual(storage.stringArray(forKey: LaunchControlPortablePreferences.Key.favorites), [path]) + XCTAssertEqual(storage.data(forKey: LaunchControlPortablePreferences.Key.notes), raw) + XCTAssertTrue(provider.setIncludesNotes(true)) + XCTAssertNil(provider.backup()) + XCTAssertEqual(storage.data(forKey: LaunchControlPortablePreferences.Key.notes), raw) + } + + func testTraversalAndFutureVersionRejectBeforeMutation() throws { + let storage = FakePluginStorage() + let provider = LaunchControlPortablePreferences(storage: storage) + let before = storage.values + let bad = LaunchControlPortablePreferences.Payload(formatVersion: 1, + favorites: [.init(location: .userAgents, plistFileName: "../outside.plist")], notes: nil) + XCTAssertFalse(provider.restore(try JSONEncoder().encode(bad))) + let future = LaunchControlPortablePreferences.Payload(formatVersion: 2, favorites: [], notes: nil) + XCTAssertFalse(provider.restore(try JSONEncoder().encode(future))) + XCTAssertEqual(storage.values as NSDictionary, before as NSDictionary) + } +} diff --git a/Plugins/LaunchControl/plugin.json b/Plugins/LaunchControl/plugin.json index c53374afa..0417d8cf6 100644 --- a/Plugins/LaunchControl/plugin.json +++ b/Plugins/LaunchControl/plugin.json @@ -302,7 +302,8 @@ "productivity-state" ], "dataPersisted": [ - "plugin-configuration" + "plugin-configuration", + "user-notes" ], "retention": { "policy": "user-controlled" @@ -440,5 +441,13 @@ ] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "private-content", + "local-activation", + "local-policy" + ] } } diff --git a/Plugins/LaunchControl/project.yml b/Plugins/LaunchControl/project.yml new file mode 100644 index 000000000..b6e2b5ad1 --- /dev/null +++ b/Plugins/LaunchControl/project.yml @@ -0,0 +1,3 @@ +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/Launchpad/Sources/LaunchpadAppCatalog.swift b/Plugins/Launchpad/Sources/LaunchpadAppCatalog.swift index ee306bb2b..fe024c909 100644 --- a/Plugins/Launchpad/Sources/LaunchpadAppCatalog.swift +++ b/Plugins/Launchpad/Sources/LaunchpadAppCatalog.swift @@ -13,6 +13,7 @@ final class LaunchpadAppCatalog: ObservableObject { @Published private(set) var apps: [LaunchpadAppItem] = [] @Published private(set) var isLoading = false + var onAppsDidReload: (() -> Void)? private let iconCache = NSCache() private var reloadGeneration = 0 @@ -32,6 +33,7 @@ final class LaunchpadAppCatalog: ObservableObject { guard let self, generation == self.reloadGeneration else { return } // stale scan → drop self.apps = scanned self.isLoading = false + self.onAppsDidReload?() } } diff --git a/Plugins/Launchpad/Sources/LaunchpadFolderRenameField.swift b/Plugins/Launchpad/Sources/LaunchpadFolderRenameField.swift index 90d4975dc..f8a4d0172 100644 --- a/Plugins/Launchpad/Sources/LaunchpadFolderRenameField.swift +++ b/Plugins/Launchpad/Sources/LaunchpadFolderRenameField.swift @@ -185,6 +185,7 @@ struct LaunchpadFolderRenameField: NSViewRepresentable { /// Last applied resolution — test instrumentation for the forwarding table. private(set) var lastResolution: LaunchpadRenameEditSession.Resolution? private var focusRequestInFlight = false + private var focusGeneration = 0 /// True once a keystroke has been saved to the store in real time this session, so an Esc /// cancel knows it must roll the store back to the original name. Cleared on each begin. private var hasLiveCommitted = false @@ -245,6 +246,8 @@ struct LaunchpadFolderRenameField: NSViewRepresentable { func abandonSession() { session = nil + focusGeneration &+= 1 + focusRequestInFlight = false } // MARK: Programmatic focus (context-menu rename / post-creation auto-open) @@ -259,8 +262,9 @@ struct LaunchpadFolderRenameField: NSViewRepresentable { } private func attemptProgrammaticFocus(retries: Int) { + let generation = focusGeneration DispatchQueue.main.async { [weak self] in - guard let self else { return } + guard let self, generation == self.focusGeneration else { return } if let field = self.field, let window = field.window { self.focusRequestInFlight = false self.parent.onFocusRequestHandled() diff --git a/Plugins/Launchpad/Sources/LaunchpadLayoutStore.swift b/Plugins/Launchpad/Sources/LaunchpadLayoutStore.swift index 2e70ffc68..68df5d172 100644 --- a/Plugins/Launchpad/Sources/LaunchpadLayoutStore.swift +++ b/Plugins/Launchpad/Sources/LaunchpadLayoutStore.swift @@ -13,7 +13,7 @@ import MacToolsPluginKit /// back to alphabetical (`nil`) and never crash. @MainActor final class LaunchpadLayoutStore: ObservableObject { - private enum Keys { + enum Keys { static let customLayout = "customLayout" } @@ -30,6 +30,7 @@ final class LaunchpadLayoutStore: ObservableObject { init(storage: PluginStorage) { self.storage = storage + _ = LaunchpadPortablePreferences.store(storage: storage).recover() self.layout = Self.loadLayout(from: storage, decoder: decoder, logger: logger) } @@ -74,8 +75,9 @@ final class LaunchpadLayoutStore: ObservableObject { /// write) when already alphabetical. func resetToAlphabetical() { guard layout != nil else { return } + guard LaunchpadPortablePreferences.store(storage: storage).commit([:], removing: [Keys.customLayout]) else { return } layout = nil - storage.removeObject(forKey: Keys.customLayout) + onPersistentPreferencesChange?() } // MARK: - Folders (19b) @@ -333,7 +335,7 @@ final class LaunchpadLayoutStore: ObservableObject { /// duplicate root id or an app referenced twice would make moves target the wrong node and /// then persist that corruption back. (Empty folders stay valid — the reconcile tolerance /// window keeps them around deliberately.) - private static func isSemanticallyValid(_ layout: LaunchpadLayout) -> Bool { + static func isSemanticallyValid(_ layout: LaunchpadLayout) -> Bool { var rootIDs = Set() var appIDs = Set() for node in layout.nodes { @@ -367,16 +369,14 @@ final class LaunchpadLayoutStore: ObservableObject { setLayout(LaunchpadLayout(version: current.version, nodes: nodes)) } + var onPersistentPreferencesChange: (() -> Void)? + func reloadCommittedSnapshot() { layout = Self.loadLayout(from: storage, decoder: decoder, logger: logger) } + private func setLayout(_ newLayout: LaunchpadLayout) { + guard newLayout != layout, Self.isSemanticallyValid(newLayout), + let data = try? encoder.encode(newLayout), + LaunchpadPortablePreferences.store(storage: storage).commit([Keys.customLayout: .data(data)]) else { return } layout = newLayout - persist(newLayout) - } - - private func persist(_ layout: LaunchpadLayout) { - guard let data = try? encoder.encode(layout) else { - logger.error("Failed to encode custom layout; skipping persist.") - return - } - storage.set(data, forKey: Keys.customLayout) + onPersistentPreferencesChange?() } } diff --git a/Plugins/Launchpad/Sources/LaunchpadOverlayController.swift b/Plugins/Launchpad/Sources/LaunchpadOverlayController.swift index c3fcfcc92..48fe14b26 100644 --- a/Plugins/Launchpad/Sources/LaunchpadOverlayController.swift +++ b/Plugins/Launchpad/Sources/LaunchpadOverlayController.swift @@ -278,7 +278,21 @@ final class LaunchpadOverlayController: NSObject, NSWindowDelegate { /// - Parameter restoringFocus: hand focus back to the previously frontmost app. /// `true` for user dismissal (Esc / background / app switch); `false` when an app /// was just launched (it should come forward instead). - func close(restoringFocus: Bool = true) { + var isVisible: Bool { window?.isVisible == true } + var onAppsDidReload: (() -> Void)? { + get { catalog.onAppsDidReload } + set { catalog.onAppsDidReload = newValue } + } + private func abandonRenameSessions(in view: NSView?) { + guard let view else { return } + if let field = view as? LaunchpadRenameTextField, + let coordinator = field.delegate as? LaunchpadFolderRenameField.Coordinator { coordinator.abandonSession() } + for child in view.subviews { abandonRenameSessions(in: child) } + } + + var onDidClose: (() -> Void)? + + func close(restoringFocus: Bool = true, discardingEdits: Bool = false) { guard !isTearingDown else { return } // A carry may be mid-flight (mouse still down): drop its floating icon window before the // overlay goes — it's a separate window and would outlive us. Ahead of the window guard @@ -297,6 +311,7 @@ final class LaunchpadOverlayController: NSObject, NSWindowDelegate { // commits synchronously on this event stack (design §2.3 "unmount → commit, no data // loss"). Idempotent for every other responder (search field has no end-editing // hooks) and the session latch keeps any later dismantle call inert. + if discardingEdits { abandonRenameSessions(in: win.contentView) } win.makeFirstResponder(nil) win.delegate = nil win.orderOut(nil) @@ -307,6 +322,7 @@ final class LaunchpadOverlayController: NSObject, NSWindowDelegate { } previousApp = nil isTearingDown = false + onDidClose?() } private func launch(_ app: LaunchpadAppItem) { diff --git a/Plugins/Launchpad/Sources/LaunchpadPlugin.swift b/Plugins/Launchpad/Sources/LaunchpadPlugin.swift index a1b15b46d..3298fca61 100644 --- a/Plugins/Launchpad/Sources/LaunchpadPlugin.swift +++ b/Plugins/Launchpad/Sources/LaunchpadPlugin.swift @@ -82,6 +82,8 @@ final class LaunchpadPlugin: private let preferences: LaunchpadPreferences private let layoutStore: LaunchpadLayoutStore + private var isActive = false + private let portablePreferences: LaunchpadPortablePreferences private let overlay: LaunchpadOverlayController private let localization: PluginLocalization private let hotCornerMonitor = LaunchpadHotCornerMonitor() @@ -103,24 +105,44 @@ final class LaunchpadPlugin: let layoutStore = LaunchpadLayoutStore(storage: context.storage) self.preferences = preferences self.layoutStore = layoutStore - self.overlay = LaunchpadOverlayController( + let overlay = LaunchpadOverlayController( preferences: preferences, layoutStore: layoutStore, localization: localization ) + self.overlay = overlay + self.portablePreferences = LaunchpadPortablePreferences(storage: context.storage, preferences: preferences, layoutStore: layoutStore, + beforePublishing: { [weak overlay] in overlay?.close(restoringFocus: false, discardingEdits: true) }) + overlay.onDidClose = { [weak self] in + guard let self, !self.portablePreferences.isRestoring else { return } + self.portablePreferences.rebindAvailableApplications() + } + overlay.onAppsDidReload = { [weak self] in + guard let self, !self.overlay.isVisible else { return } + self.portablePreferences.rebindAvailableApplications() + } hotCornerMonitor.onTrigger = { [weak self] in self?.openLaunchpad() } // Apply the saved corner now and whenever the user changes it in settings. preferences.$hotCorner - .sink { [weak hotCornerMonitor] corner in hotCornerMonitor?.update(corner: corner) } + .sink { [weak self] corner in + guard let self, self.isActive, !self.portablePreferences.isRestoring else { return } + self.hotCornerMonitor.update(corner: corner) + } .store(in: &cancellables) layoutStore.$layout .dropFirst() - .sink { [weak self] _ in self?.onStateChange?() } + .sink { [weak self] _ in + guard let self, !self.portablePreferences.isRestoring else { return } + self.onStateChange?() + } .store(in: &cancellables) preferences.$hiddenAppIDs .dropFirst() - .sink { [weak self] _ in self?.onStateChange?() } + .sink { [weak self] _ in + guard let self, !self.portablePreferences.isRestoring else { return } + self.onStateChange?() + } .store(in: &cancellables) } @@ -203,7 +225,7 @@ final class LaunchpadPlugin: preferences: preferences, layoutStore: layoutStore, localization: localization, - sectionKind: .hiddenApps + sectionKind: .hiddenApps, retainedApplicationName: { self.portablePreferences.applicationName(for: $0) } ) } ]) @@ -293,10 +315,12 @@ final class LaunchpadPlugin: } private func openLaunchpad() { + if !overlay.isVisible { portablePreferences.rebindAvailableApplications() } overlay.toggle() } func activate(context: PluginRuntimeContext) { + isActive = true // Resume after a pause, such as re-enabling the plugin after hiding or disabling it: // `deactivate` stopped the cursor poll but // the corner preference kept its value, so the `$hotCorner` sink (fires on CHANGE) never @@ -308,9 +332,26 @@ final class LaunchpadPlugin: } func deactivate(reason: PluginDeactivationReason) { + isActive = false if reason.requiresStateCleanup { hotCornerMonitor.stop() // stop the cursor poll; no runaway timer overlay.close() } } } + +extension LaunchpadPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { portablePreferences.onPersistentPreferencesChange } + set { portablePreferences.onPersistentPreferencesChange = newValue } + } + func makePortablePreferencesBackup() -> Data? { portablePreferences.backup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard portablePreferences.restore(data) else { return false } + if isActive { hotCornerMonitor.update(corner: preferences.hotCorner) } + onStateChange?() + return true + } +} diff --git a/Plugins/Launchpad/Sources/LaunchpadPortablePreferences.swift b/Plugins/Launchpad/Sources/LaunchpadPortablePreferences.swift new file mode 100644 index 000000000..b162b33c2 --- /dev/null +++ b/Plugins/Launchpad/Sources/LaunchpadPortablePreferences.swift @@ -0,0 +1,165 @@ +import CryptoKit +import Foundation +import MacToolsPluginKit + +/// One journal covers both observable stores and unresolved application identities. +@MainActor +final class LaunchpadPortablePreferences { + private struct Application: Codable { + let id: String + let name: String + let reference: PortableApplicationReference + } + private struct Payload: Codable { + let formatVersion: Int + let settings: [String: PortablePreferenceValue] + let applications: [Application] + let hiddenApplicationIDs: [String] + let layout: LaunchpadLayout? + } + private static let bindingsKey = "portable-app-bindings.v1" + private typealias K = LaunchpadPreferences.Keys + private let storage: any PluginStorage + private let preferences: LaunchpadPreferences + private let layoutStore: LaunchpadLayoutStore + private let beforePublishing: () -> Void + private(set) var isRestoring = false + var onPersistentPreferencesChange: (() -> Void)? + + init(storage: any PluginStorage, preferences: LaunchpadPreferences, layoutStore: LaunchpadLayoutStore, beforePublishing: @escaping () -> Void) { + self.storage = storage; self.preferences = preferences; self.layoutStore = layoutStore; self.beforePublishing = beforePublishing + preferences.onPersistentPreferencesChange = { [weak self] in self?.onPersistentPreferencesChange?() } + layoutStore.onPersistentPreferencesChange = { [weak self] in self?.onPersistentPreferencesChange?() } + } + private static var settingsFields: [String: PortablePreferenceField] { + [ + K.windowMode: .choice("fullscreen", allowed: LaunchpadPreferences.WindowMode.allCases.map(\.rawValue)), + K.columns: PortablePreferenceField(fallback: .integer(0)) { if case let .integer(value) = $0 { value == 0 || (4...12).contains(value) } else { false } }, + K.hotCorner: .choice("off", allowed: LaunchpadPreferences.HotCorner.allCases.map(\.rawValue)), + K.iconSize: PortablePreferenceField(fallback: .integer(64)) { if case let .integer(value) = $0 { (48...96).contains(value) && value % 4 == 0 } else { false } }, + K.hidesAppNames: .boolean(false), K.compactScalePercent: .integer(72, range: 55...90), + K.labelColor: .choice("automatic", allowed: LaunchpadLabelColor.allCases.map(\.rawValue)), + K.labelWeight: .choice("regular", allowed: LaunchpadLabelWeight.allCases.map(\.rawValue)), + K.labelSize: .choice("medium", allowed: LaunchpadLabelSize.allCases.map(\.rawValue)), + K.backgroundStyle: .choice("standard", allowed: LaunchpadBackgroundStyle.allCases.map(\.rawValue)), + K.backgroundMaterial: .choice("launchpad", allowed: LaunchpadGlassMaterial.allCases.map(\.rawValue)), + K.backgroundDimPercent: .integer(LaunchpadBackgroundDim.defaultPercent, range: LaunchpadBackgroundDim.percentRange) + ] + } + static func store(storage: any PluginStorage) -> PortablePreferencesStore { + var fields = settingsFields + fields[K.hidden] = .strings(maximumCount: 1_024, maximumStringBytes: 2_048) + fields[LaunchpadLayoutStore.Keys.customLayout] = .data(try! JSONEncoder().encode(LaunchpadLayout(nodes: [])), maximumBytes: 1_024 * 1_024) { + guard let layout = try? JSONDecoder().decode(LaunchpadLayout.self, from: $0) else { return false } + return layout.version == LaunchpadLayout.currentVersion && LaunchpadLayoutStore.isSemanticallyValid(layout) + } + fields[bindingsKey] = .data(Data("{}".utf8), maximumBytes: 1_024 * 1_024) { + (try? JSONDecoder().decode([String: Application].self, from: $0)).map { $0.count <= 1_024 } == true + } + return PortablePreferencesStore(storage: storage, fields: fields) + } + private func bindings() -> [String: Application] { + storage.data(forKey: Self.bindingsKey).flatMap { try? JSONDecoder().decode([String: Application].self, from: $0) } ?? [:] + } + private func mapLayout(_ layout: LaunchpadLayout?, ids: [String: String]) -> LaunchpadLayout? { + layout.map { layout in + LaunchpadLayout(nodes: layout.nodes.map { node in + func app(_ ref: LaunchpadAppRef) -> LaunchpadAppRef { LaunchpadAppRef(id: ids[ref.id]!, name: ref.name) } + switch node { + case let .app(ref): return .app(app(ref)) + case let .folder(id, name, children): return .folder(id: id, name: name, children: children.map(app)) + } + }) + } + } + func backup() -> Data? { + guard let values = Self.store(storage: storage).currentValues(), Self.store(storage: storage).backup() != nil else { return nil } + let layout = layoutStore.layout + let used = Set((layout?.nodes.flatMap(\.appIDs) ?? [])).union(preferences.hiddenAppIDs) + guard used.count <= 1_024 else { return nil } + let previous = bindings() + let references = (layout?.nodes.flatMap { node -> [LaunchpadAppRef] in + switch node { case let .app(ref): [ref]; case let .folder(_, _, children): children } + } ?? []) + var tokenByID: [String: String] = [:] + var applications: [Application] = [] + for id in used.sorted() { + let retained = previous[id] + let token = retained?.id ?? "app-" + SHA256.hash(data: Data(id.utf8)).map { String(format: "%02x", $0) }.joined() + let url = id.hasPrefix("/") ? URL(fileURLWithPath: id) : nil + let name = retained?.name ?? references.first { $0.id == id }?.name ?? url?.deletingPathExtension().lastPathComponent ?? "Unavailable" + let application = retained ?? Application(id: token, name: name, reference: PortableApplicationReference(url: url)) + tokenByID[id] = token + applications.append(application) + } + let payload = Payload(formatVersion: 1, settings: values.filter { Self.settingsFields[$0.key] != nil }, applications: applications.sorted { $0.id < $1.id }, + hiddenApplicationIDs: preferences.hiddenAppIDs.compactMap { tokenByID[$0] }.sorted(), layout: mapLayout(layout, ids: tokenByID)) + let encoder = JSONEncoder(); encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(payload), decode(data) != nil else { return nil } + return data + } + private func decode(_ data: Data) -> Payload? { + guard data.count <= 2 * 1_024 * 1_024, let payload = try? JSONDecoder().decode(Payload.self, from: data), payload.formatVersion == 1, + Set(payload.settings.keys) == Set(Self.settingsFields.keys), payload.settings.allSatisfy({ Self.settingsFields[$0.key]?.accepts($0.value) == true }), + payload.applications.count <= 1_024, Set(payload.applications.map(\.id)).count == payload.applications.count, + payload.applications.allSatisfy({ $0.id.hasPrefix("app-") && PortablePreferencesValidation.identifier($0.id) + && PortablePreferencesValidation.text($0.name, maximumBytes: 256) && $0.reference.isValid }), + Set(payload.hiddenApplicationIDs).count == payload.hiddenApplicationIDs.count else { return nil } + let ids = Set(payload.applications.map(\.id)) + guard Set(payload.hiddenApplicationIDs).isSubset(of: ids) else { return nil } + if let layout = payload.layout { + guard layout.version == LaunchpadLayout.currentVersion, layout.nodes.count <= 1_280, LaunchpadLayoutStore.isSemanticallyValid(layout), + Set(layout.nodes.flatMap(\.appIDs)).isSubset(of: ids) else { return nil } + let refs = layout.nodes.flatMap { node -> [LaunchpadAppRef] in + switch node { case let .app(ref): [ref]; case let .folder(_, _, children): children } + } + guard refs.allSatisfy({ PortablePreferencesValidation.text($0.name, maximumBytes: 256) }) else { return nil } + var folders = 0 + for node in layout.nodes { + if case let .folder(id, name, _) = node { + folders += 1 + guard UUID(uuidString: id) != nil, PortablePreferencesValidation.text(name, maximumBytes: 256) else { return nil } + } + } + guard folders <= 256 else { return nil } + } + return payload + } + func restore(_ data: Data, dismissOverlay: Bool = true, signalsChange: Bool = true) -> Bool { + guard let payload = decode(data) else { return false } + let previous = backup() + var localIDs: [String: String] = [:] + var claimed = Set() + var metadata: [String: Application] = [:] + for app in payload.applications { + let id = app.reference.resolvedURL()?.resolvingSymlinksInPath().path ?? "portable-app:" + app.id + guard claimed.insert(id).inserted else { return false } + localIDs[app.id] = id; metadata[id] = app + } + let layout = mapLayout(payload.layout, ids: localIDs) + var candidate = payload.settings + candidate[K.hidden] = .strings(payload.hiddenApplicationIDs.compactMap { localIDs[$0] }.sorted()) + guard let bindingsData = try? JSONEncoder().encode(metadata) else { return false } + candidate[Self.bindingsKey] = .data(bindingsData) + var removing = Set() + if let layout { + guard let data = try? JSONEncoder().encode(layout) else { return false } + candidate[LaunchpadLayoutStore.Keys.customLayout] = .data(data) + } else { removing.insert(LaunchpadLayoutStore.Keys.customLayout) } + guard Self.store(storage: storage).commit(candidate, removing: removing) else { return false } + isRestoring = true + if dismissOverlay { beforePublishing() } + preferences.reloadCommittedSnapshot() + layoutStore.reloadCommittedSnapshot() + isRestoring = false + if signalsChange, previous != backup() { onPersistentPreferencesChange?() } + return true + } + func applicationName(for id: String) -> String? { bindings()[id]?.name } + + func rebindAvailableApplications() { + guard bindings().contains(where: { $0.key.hasPrefix("portable-app:") && $0.value.reference.resolvedURL() != nil }), + let data = backup() else { return } + _ = restore(data, dismissOverlay: false, signalsChange: false) + } +} diff --git a/Plugins/Launchpad/Sources/LaunchpadPreferences.swift b/Plugins/Launchpad/Sources/LaunchpadPreferences.swift index 9e39a836b..8d37c2aa0 100644 --- a/Plugins/Launchpad/Sources/LaunchpadPreferences.swift +++ b/Plugins/Launchpad/Sources/LaunchpadPreferences.swift @@ -72,22 +72,52 @@ final class LaunchpadPreferences: ObservableObject { static let maxCompactScale = 90 @Published var windowMode: WindowMode { - didSet { storage.set(windowMode.rawValue, forKey: Keys.windowMode) } + didSet { + guard !isApplyingCommittedSnapshot else { return } + guard windowMode != oldValue else { return } + guard LaunchpadPortablePreferences.store(storage: storage).commit([Keys.windowMode: .string(windowMode.rawValue)]) else { + isApplyingCommittedSnapshot = true + windowMode = oldValue + isApplyingCommittedSnapshot = false + return + } + onPersistentPreferencesChange?() + } } @Published var columns: Int { didSet { - // Clamp on the single write path so no out-of-range value is ever persisted, - // even from a programmatic set (Codex P2). Re-entry settles after one pass. - let valid = Self.normalizedColumns(columns) - guard valid == columns else { columns = valid; return } - storage.set(columns, forKey: Keys.columns) + guard !isApplyingCommittedSnapshot else { return } + let normalized = Self.normalizedColumns(columns) + if normalized != columns { + isApplyingCommittedSnapshot = true + columns = normalized + isApplyingCommittedSnapshot = false + } + guard columns != oldValue else { return } + guard LaunchpadPortablePreferences.store(storage: storage).commit([Keys.columns: .integer(normalized)]) else { + isApplyingCommittedSnapshot = true + columns = oldValue + isApplyingCommittedSnapshot = false + return + } + onPersistentPreferencesChange?() } } /// App ids (resolved absolute paths) the user has hidden from the grid. @Published private(set) var hiddenAppIDs: Set { - didSet { storage.set(Array(hiddenAppIDs), forKey: Keys.hidden) } + didSet { + guard !isApplyingCommittedSnapshot else { return } + guard hiddenAppIDs != oldValue else { return } + guard LaunchpadPortablePreferences.store(storage: storage).commit([Keys.hidden: .strings(hiddenAppIDs.sorted())]) else { + isApplyingCommittedSnapshot = true + hiddenAppIDs = oldValue + isApplyingCommittedSnapshot = false + return + } + onPersistentPreferencesChange?() + } } func hide(_ id: String) { hiddenAppIDs.insert(id) } @@ -95,7 +125,17 @@ final class LaunchpadPreferences: ObservableObject { func unhideAll() { hiddenAppIDs.removeAll() } @Published var hotCorner: HotCorner { - didSet { storage.set(hotCorner.rawValue, forKey: Keys.hotCorner) } + didSet { + guard !isApplyingCommittedSnapshot else { return } + guard hotCorner != oldValue else { return } + guard LaunchpadPortablePreferences.store(storage: storage).commit([Keys.hotCorner: .string(hotCorner.rawValue)]) else { + isApplyingCommittedSnapshot = true + hotCorner = oldValue + isApplyingCommittedSnapshot = false + return + } + onPersistentPreferencesChange?() + } } // MARK: Appearance (design §3.1, features 7+8) @@ -104,24 +144,58 @@ final class LaunchpadPreferences: ObservableObject { /// as `columns` — no out-of-range or off-step value is ever persisted. @Published var iconSize: Int { didSet { - let valid = Self.normalizedIconSize(iconSize) - guard valid == iconSize else { iconSize = valid; return } - storage.set(iconSize, forKey: Keys.iconSize) + guard !isApplyingCommittedSnapshot else { return } + let normalized = Self.normalizedIconSize(iconSize) + if normalized != iconSize { + isApplyingCommittedSnapshot = true + iconSize = normalized + isApplyingCommittedSnapshot = false + } + guard iconSize != oldValue else { return } + guard LaunchpadPortablePreferences.store(storage: storage).commit([Keys.iconSize: .integer(normalized)]) else { + isApplyingCommittedSnapshot = true + iconSize = oldValue + isApplyingCommittedSnapshot = false + return + } + onPersistentPreferencesChange?() } } /// Negated key on purpose (design §3.1): `bool(forKey:)` returns false when unset, /// so an unset key means "names shown" — today's behaviour, zero migration. @Published var hidesAppNames: Bool { - didSet { storage.set(hidesAppNames, forKey: Keys.hidesAppNames) } + didSet { + guard !isApplyingCommittedSnapshot else { return } + guard hidesAppNames != oldValue else { return } + guard LaunchpadPortablePreferences.store(storage: storage).commit([Keys.hidesAppNames: .boolean(hidesAppNames)]) else { + isApplyingCommittedSnapshot = true + hidesAppNames = oldValue + isApplyingCommittedSnapshot = false + return + } + onPersistentPreferencesChange?() + } } /// Compact-panel scale in whole percent (PluginStorage has no double accessor). @Published var compactScalePercent: Int { didSet { - let valid = Self.normalizedCompactScale(compactScalePercent) - guard valid == compactScalePercent else { compactScalePercent = valid; return } - storage.set(compactScalePercent, forKey: Keys.compactScalePercent) + guard !isApplyingCommittedSnapshot else { return } + let normalized = Self.normalizedCompactScale(compactScalePercent) + if normalized != compactScalePercent { + isApplyingCommittedSnapshot = true + compactScalePercent = normalized + isApplyingCommittedSnapshot = false + } + guard compactScalePercent != oldValue else { return } + guard LaunchpadPortablePreferences.store(storage: storage).commit([Keys.compactScalePercent: .integer(normalized)]) else { + isApplyingCommittedSnapshot = true + compactScalePercent = oldValue + isApplyingCommittedSnapshot = false + return + } + onPersistentPreferencesChange?() } } @@ -131,18 +205,48 @@ final class LaunchpadPreferences: ObservableObject { /// historical implicit colour — zero migration. Persisted as the raw value (a String /// PluginStorage stores natively, no NSColor/Data encoding). @Published var labelColor: LaunchpadLabelColor { - didSet { storage.set(labelColor.rawValue, forKey: Keys.labelColor) } + didSet { + guard !isApplyingCommittedSnapshot else { return } + guard labelColor != oldValue else { return } + guard LaunchpadPortablePreferences.store(storage: storage).commit([Keys.labelColor: .string(labelColor.rawValue)]) else { + isApplyingCommittedSnapshot = true + labelColor = oldValue + isApplyingCommittedSnapshot = false + return + } + onPersistentPreferencesChange?() + } } /// Label font-weight preset. Default `.regular` matches the historical system weight. @Published var labelWeight: LaunchpadLabelWeight { - didSet { storage.set(labelWeight.rawValue, forKey: Keys.labelWeight) } + didSet { + guard !isApplyingCommittedSnapshot else { return } + guard labelWeight != oldValue else { return } + guard LaunchpadPortablePreferences.store(storage: storage).commit([Keys.labelWeight: .string(labelWeight.rawValue)]) else { + isApplyingCommittedSnapshot = true + labelWeight = oldValue + isApplyingCommittedSnapshot = false + return + } + onPersistentPreferencesChange?() + } } /// Label font-size tier. Default `.medium` derives 12pt at the 64pt icon — the byte-compat /// baseline; the other tiers scale with the icon side. @Published var labelSize: LaunchpadLabelSize { - didSet { storage.set(labelSize.rawValue, forKey: Keys.labelSize) } + didSet { + guard !isApplyingCommittedSnapshot else { return } + guard labelSize != oldValue else { return } + guard LaunchpadPortablePreferences.store(storage: storage).commit([Keys.labelSize: .string(labelSize.rawValue)]) else { + isApplyingCommittedSnapshot = true + labelSize = oldValue + isApplyingCommittedSnapshot = false + return + } + onPersistentPreferencesChange?() + } } /// The single input to `LaunchpadGridMetrics.resolve(_:)` (and, later, the settings @@ -162,22 +266,53 @@ final class LaunchpadPreferences: ObservableObject { // MARK: Glass background (design §5) @Published var backgroundStyle: LaunchpadBackgroundStyle { - didSet { storage.set(backgroundStyle.rawValue, forKey: Keys.backgroundStyle) } + didSet { + guard !isApplyingCommittedSnapshot else { return } + guard backgroundStyle != oldValue else { return } + guard LaunchpadPortablePreferences.store(storage: storage).commit([Keys.backgroundStyle: .string(backgroundStyle.rawValue)]) else { + isApplyingCommittedSnapshot = true + backgroundStyle = oldValue + isApplyingCommittedSnapshot = false + return + } + onPersistentPreferencesChange?() + } } /// Only consulted while `backgroundStyle == .custom`; kept persisted across style /// switches so flipping back to custom restores the user's last tuning. @Published var backgroundMaterial: LaunchpadGlassMaterial { - didSet { storage.set(backgroundMaterial.rawValue, forKey: Keys.backgroundMaterial) } + didSet { + guard !isApplyingCommittedSnapshot else { return } + guard backgroundMaterial != oldValue else { return } + guard LaunchpadPortablePreferences.store(storage: storage).commit([Keys.backgroundMaterial: .string(backgroundMaterial.rawValue)]) else { + isApplyingCommittedSnapshot = true + backgroundMaterial = oldValue + isApplyingCommittedSnapshot = false + return + } + onPersistentPreferencesChange?() + } } /// Custom-style dim layer, in whole percent (PluginStorage has no double accessor). @Published var backgroundDimPercent: Int { didSet { - // Same single-write-path clamp discipline as `columns`. - let valid = LaunchpadBackgroundDim.normalized(backgroundDimPercent) - guard valid == backgroundDimPercent else { backgroundDimPercent = valid; return } - storage.set(backgroundDimPercent, forKey: Keys.backgroundDimPercent) + guard !isApplyingCommittedSnapshot else { return } + let normalized = LaunchpadBackgroundDim.normalized(backgroundDimPercent) + if normalized != backgroundDimPercent { + isApplyingCommittedSnapshot = true + backgroundDimPercent = normalized + isApplyingCommittedSnapshot = false + } + guard backgroundDimPercent != oldValue else { return } + guard LaunchpadPortablePreferences.store(storage: storage).commit([Keys.backgroundDimPercent: .integer(normalized)]) else { + isApplyingCommittedSnapshot = true + backgroundDimPercent = oldValue + isApplyingCommittedSnapshot = false + return + } + onPersistentPreferencesChange?() } } @@ -190,9 +325,30 @@ final class LaunchpadPreferences: ObservableObject { ) } + private var isApplyingCommittedSnapshot = false + var onPersistentPreferencesChange: (() -> Void)? + func reloadCommittedSnapshot() { + let restored = LaunchpadPreferences(storage: storage) + isApplyingCommittedSnapshot = true + windowMode = restored.windowMode + columns = restored.columns + hiddenAppIDs = restored.hiddenAppIDs + hotCorner = restored.hotCorner + iconSize = restored.iconSize + hidesAppNames = restored.hidesAppNames + compactScalePercent = restored.compactScalePercent + labelColor = restored.labelColor + labelWeight = restored.labelWeight + labelSize = restored.labelSize + backgroundStyle = restored.backgroundStyle + backgroundMaterial = restored.backgroundMaterial + backgroundDimPercent = restored.backgroundDimPercent + isApplyingCommittedSnapshot = false + } + private let storage: PluginStorage - private enum Keys { + enum Keys { static let windowMode = "windowMode" static let columns = "columns" static let hidden = "hiddenAppIDs" @@ -230,6 +386,7 @@ final class LaunchpadPreferences: ObservableObject { init(storage: PluginStorage) { self.storage = storage + _ = LaunchpadPortablePreferences.store(storage: storage).recover() self.windowMode = WindowMode(rawValue: storage.string(forKey: Keys.windowMode) ?? "") ?? .fullscreen // `integer(forKey:)` is 0 when unset → autoColumns, our default. diff --git a/Plugins/Launchpad/Sources/LaunchpadSettingsView.swift b/Plugins/Launchpad/Sources/LaunchpadSettingsView.swift index 1fcfa7f2b..e36f1ddf2 100644 --- a/Plugins/Launchpad/Sources/LaunchpadSettingsView.swift +++ b/Plugins/Launchpad/Sources/LaunchpadSettingsView.swift @@ -19,6 +19,7 @@ struct LaunchpadSettingsView: View { @ObservedObject var layoutStore: LaunchpadLayoutStore let localization: PluginLocalization let sectionKind: SectionKind + var retainedApplicationName: (String) -> String? = { _ in nil } private var isAutoColumns: Binding { Binding( @@ -79,7 +80,7 @@ struct LaunchpadSettingsView: View { /// Hidden ids are absolute paths; derive a display name without needing the catalog /// (the app may even be uninstalled). private func appName(for id: String) -> String { - URL(fileURLWithPath: id).deletingPathExtension().lastPathComponent + retainedApplicationName(id) ?? URL(fileURLWithPath: id).deletingPathExtension().lastPathComponent } @ViewBuilder diff --git a/Plugins/Launchpad/Tests/FakePluginStorage.swift b/Plugins/Launchpad/Tests/FakePluginStorage.swift index c1eb1479b..8a982382a 100644 --- a/Plugins/Launchpad/Tests/FakePluginStorage.swift +++ b/Plugins/Launchpad/Tests/FakePluginStorage.swift @@ -9,6 +9,7 @@ import MacToolsPluginKit /// assert that a no-op mutation does not touch storage (design risk R8). @MainActor final class FakePluginStorage: PluginStorage { + var rejectedKeys = Set() var values: [String: Any] = [:] private(set) var writeCount = 0 @@ -20,11 +21,13 @@ final class FakePluginStorage: PluginStorage { func bool(forKey key: String) -> Bool { values[key] as? Bool ?? false } func set(_ value: Any?, forKey key: String) { + guard !rejectedKeys.contains(key) else { return } values[key] = value writeCount += 1 } func removeObject(forKey key: String) { + guard !rejectedKeys.contains(key) else { return } values[key] = nil writeCount += 1 } diff --git a/Plugins/Launchpad/Tests/LaunchpadPortablePreferencesTests.swift b/Plugins/Launchpad/Tests/LaunchpadPortablePreferencesTests.swift new file mode 100644 index 000000000..59cd92eb3 --- /dev/null +++ b/Plugins/Launchpad/Tests/LaunchpadPortablePreferencesTests.swift @@ -0,0 +1,81 @@ +import XCTest +@testable import LaunchpadPlugin + +@MainActor +final class LaunchpadPortablePreferencesTests: XCTestCase { + func testUnresolvedFolderAndHiddenAppRetainIdentityAndAlphabeticalResetRemovesLayout() throws { + let source = FakePluginStorage() + let preferences = LaunchpadPreferences(storage: source) + let layout = LaunchpadLayoutStore(storage: source) + let folderID = UUID().uuidString + let a = LaunchpadAppRef(id: "/unavailable/Alpha.app", name: "Alpha") + let b = LaunchpadAppRef(id: "/unavailable/Beta.app", name: "Beta") + source.set(try JSONEncoder().encode(LaunchpadLayout(nodes: [.folder(id: folderID, name: "Folder", children: [b, a])])), forKey: "customLayout") + layout.reloadCommittedSnapshot() + preferences.hide(a.id) + preferences.hotCorner = .topLeft + let exporter = LaunchpadPortablePreferences(storage: source, preferences: preferences, layoutStore: layout, beforePublishing: {}) + let data = try XCTUnwrap(exporter.backup()) + XCTAssertFalse(String(decoding: data, as: UTF8.self).contains("/unavailable/")) + let destination = FakePluginStorage() + let restoredPreferences = LaunchpadPreferences(storage: destination) + let restoredLayout = LaunchpadLayoutStore(storage: destination) + var dismissed = 0 + let importer = LaunchpadPortablePreferences(storage: destination, preferences: restoredPreferences, layoutStore: restoredLayout, beforePublishing: { dismissed += 1 }) + var signals = 0 + importer.onPersistentPreferencesChange = { signals += 1 } + XCTAssertTrue(importer.restore(data)) + guard case let .folder(id, _, children)? = restoredLayout.layout?.nodes.first else { return XCTFail("Missing folder") } + XCTAssertEqual(id, folderID) + XCTAssertEqual(children.map(\.name), ["Beta", "Alpha"]) + XCTAssertTrue(restoredPreferences.hiddenAppIDs.contains(children[1].id)) + XCTAssertEqual(importer.applicationName(for: children[1].id), "Alpha") + XCTAssertEqual(restoredPreferences.hotCorner, .topLeft) + XCTAssertEqual(importer.backup(), data) + XCTAssertEqual(signals, 1) + XCTAssertEqual(dismissed, 1) + let empty = FakePluginStorage() + let alphabetical = LaunchpadPortablePreferences(storage: empty, preferences: .init(storage: empty), layoutStore: .init(storage: empty), beforePublishing: {}) + XCTAssertTrue(importer.restore(try XCTUnwrap(alphabetical.backup()))) + XCTAssertNil(destination.object(forKey: "customLayout")) + XCTAssertNil(restoredLayout.layout) + } + + func testApplicationAliasUsesCanonicalInstallationRootWithoutLosingBundleIdentity() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let applications = directory.appendingPathComponent("Applications") + let systemApplications = directory.appendingPathComponent("CryptexApplications") + let bundle = systemApplications.appendingPathComponent("Browser.app") + try FileManager.default.createDirectory(at: bundle.appendingPathComponent("Contents"), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: applications, withIntermediateDirectories: true) + let info = try PropertyListSerialization.data(fromPropertyList: ["CFBundleIdentifier": "com.example.browser", "CFBundlePackageType": "APPL"], format: .xml, options: 0) + try info.write(to: bundle.appendingPathComponent("Contents/Info.plist")) + let alias = applications.appendingPathComponent("Browser.app") + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: bundle) + let roots: [PortableApplicationReference.Location: URL] = [.applications: applications, .cryptexApplications: systemApplications] + let reference = PortableApplicationReference(url: alias, roots: roots) + XCTAssertEqual(reference.location, .cryptexApplications) + XCTAssertEqual(reference.bundleIdentifier, "com.example.browser") + XCTAssertEqual(reference.relativePath, "Browser.app") + XCTAssertEqual(reference.resolvedURL(roots: roots)?.standardizedFileURL, bundle.standardizedFileURL) + XCTAssertFalse(String(decoding: try JSONEncoder().encode(reference), as: UTF8.self).contains(directory.path)) + } + + func testRejectedWriteDoesNotPublishOrDismissOverlay() throws { + let source = FakePluginStorage() + let preferences = LaunchpadPreferences(storage: source) + preferences.hotCorner = .topLeft + let exporter = LaunchpadPortablePreferences(storage: source, preferences: preferences, layoutStore: .init(storage: source), beforePublishing: {}) + let destination = FakePluginStorage() + let restoredPreferences = LaunchpadPreferences(storage: destination) + var dismissed = 0 + let importer = LaunchpadPortablePreferences(storage: destination, preferences: restoredPreferences, layoutStore: .init(storage: destination), beforePublishing: { dismissed += 1 }) + let before = destination.values as NSDictionary + destination.rejectedKeys.insert("portable-app-bindings.v1") + XCTAssertFalse(importer.restore(try XCTUnwrap(exporter.backup()))) + XCTAssertEqual(destination.values as NSDictionary, before) + XCTAssertEqual(restoredPreferences.hotCorner, .off) + XCTAssertEqual(dismissed, 0) + } +} diff --git a/Plugins/Launchpad/plugin.json b/Plugins/Launchpad/plugin.json index 0ce9c1743..bbf535c39 100644 --- a/Plugins/Launchpad/plugin.json +++ b/Plugins/Launchpad/plugin.json @@ -277,5 +277,11 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "application-paths" + ] } } diff --git a/Plugins/Launchpad/project.yml b/Plugins/Launchpad/project.yml new file mode 100644 index 000000000..0af6b7cfa --- /dev/null +++ b/Plugins/Launchpad/project.yml @@ -0,0 +1,6 @@ + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift + - ../../Sources/PluginSupport/PortableApplicationReference.swift + - ../../Sources/PluginSupport/PortablePreferencesValidation.swift diff --git a/Plugins/LockScreen/plugin.json b/Plugins/LockScreen/plugin.json index ea0c2face..3fa91bd2c 100644 --- a/Plugins/LockScreen/plugin.json +++ b/Plugins/LockScreen/plugin.json @@ -222,5 +222,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/MacSettings/plugin.json b/Plugins/MacSettings/plugin.json index 1fd235553..e7f71fb40 100644 --- a/Plugins/MacSettings/plugin.json +++ b/Plugins/MacSettings/plugin.json @@ -711,5 +711,11 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "device-state" + ] } } diff --git a/Plugins/MenuBarHidden/Sources/MenuBarHiddenManager.swift b/Plugins/MenuBarHidden/Sources/MenuBarHiddenManager.swift index 24ee86786..43d0600ef 100644 --- a/Plugins/MenuBarHidden/Sources/MenuBarHiddenManager.swift +++ b/Plugins/MenuBarHidden/Sources/MenuBarHiddenManager.swift @@ -57,6 +57,8 @@ final class MenuBarHiddenManager: ObservableObject { private var shouldRestoreHiddenAfterDrag = false private var dragSessionID = 0 private var currentDisplayID = CGMainDisplayID() + private var activeInteractionCount = 0 + private var moveOperationID: UUID? private var moveTask: Task? private var clickTask: Task? private var pendingClickRequests: [PendingClickRequest] = [] @@ -120,6 +122,31 @@ final class MenuBarHiddenManager: ObservableObject { } #endif + var onPersistentPreferencesChange: (() -> Void)? { + get { store.onPersistentPreferencesChange } + set { store.onPersistentPreferencesChange = newValue } + } + func portableBackup() -> Data? { store.portableBackup() } + func restorePortableBackup(_ data: Data) -> Bool { + guard !isDraggingMenuBarItem, !isRecoveringControlItemOrder, activeInteractionCount == 0, + moveTask == nil, clickTask == nil, pendingClickRequests.isEmpty, + temporarilyShownItemContexts.isEmpty, menuBarDragCommitTask == nil else { return false } + guard store.restorePortableBackup(data) else { return false } + // Cancel only queued work: the busy guard excludes any synthesized mouse drag. + layoutRestoreTask?.cancel(); layoutRestoreTask = nil + alwaysHiddenRestoreTask?.cancel(); alwaysHiddenRestoreTask = nil + hiddenRestoreTask?.cancel(); hiddenRestoreTask = nil + refreshPermissions() + if isActive { + applyEnabledState(store.isEnabled) + updateAlwaysHiddenDividerVisibility() + rebuildSnapshot() + scheduleStoredLayoutRestoreIfNeeded() + scheduleAlwaysHiddenItemsRestoreIfNeeded() + } + return true + } + // MARK: - Lifecycle func activate() { @@ -209,37 +236,19 @@ final class MenuBarHiddenManager: ObservableObject { get { store.isAlwaysHiddenEnabled } set { guard store.isAlwaysHiddenEnabled != newValue else { return } - alwaysHiddenRestoreTask?.cancel() - alwaysHiddenRestoreTask = nil - guard permissions.canManageItems || !newValue else { - store.isAlwaysHiddenEnabled = false - updateAlwaysHiddenDividerVisibility() - rebuildSnapshot() - refreshIconsIfUIVisible() - return - } store.isAlwaysHiddenEnabled = newValue updateAlwaysHiddenDividerVisibility() rebuildSnapshot() - if newValue { - scheduleAlwaysHiddenItemsRestoreIfNeeded(delay: .milliseconds(500)) - } + scheduleAlwaysHiddenItemsRestoreIfNeeded(delay: .milliseconds(500)) scheduleStoredLayoutRestoreIfNeeded(delay: .milliseconds(500)) refreshIconsIfUIVisible() } } - var showsHiddenIconsInPanel: Bool { get { store.showsHiddenIconsInPanel } - set { - guard store.showsHiddenIconsInPanel != newValue else { return } - guard permissions.canManageItems || !newValue else { - store.showsHiddenIconsInPanel = false - return - } - store.showsHiddenIconsInPanel = newValue - } + set { store.showsHiddenIconsInPanel = newValue } } + private var effectiveAlwaysHiddenEnabled: Bool { permissions.canManageItems && store.isAlwaysHiddenEnabled } var canShowHiddenIconsInPanel: Bool { permissions.canManageItems && store.showsHiddenIconsInPanel @@ -302,9 +311,6 @@ final class MenuBarHiddenManager: ObservableObject { controlItemRecoveryTask = nil isRecoveringControlItemOrder = false shouldRestoreHiddenAfterDrag = shouldRestoreHidden - if store.isEnabled { - store.isEnabled = false - } installAndShow() divider.showSection(isDragging: true) updateAlwaysHiddenDividerVisibility() @@ -342,8 +348,13 @@ final class MenuBarHiddenManager: ObservableObject { layoutRestoreTask?.cancel() layoutRestoreTask = nil moveTask?.cancel() + let operationID = UUID() + moveOperationID = operationID moveTask = Task { [weak self] in guard let self else { return } + defer { + if self.moveOperationID == operationID { self.moveTask = nil; self.moveOperationID = nil } + } let moved = await self.performMove(item: item, toSection: section, placement: placement) guard moved else { return } self.updateStoredLayoutAfterMove( @@ -390,16 +401,7 @@ final class MenuBarHiddenManager: ObservableObject { let current = currentPermissions() guard permissions != current else { return } permissions = current - if !current.canManageItems { - if store.isAlwaysHiddenEnabled { - store.isAlwaysHiddenEnabled = false - updateAlwaysHiddenDividerVisibility() - rebuildSnapshot() - } - if store.showsHiddenIconsInPanel { - store.showsHiddenIconsInPanel = false - } - } + updateAlwaysHiddenDividerVisibility() } func currentPermissions() -> MenuBarHiddenPermissionsStatus { @@ -431,7 +433,7 @@ final class MenuBarHiddenManager: ObservableObject { private func updateAlwaysHiddenDividerVisibility() { guard isActive else { return } - if store.isAlwaysHiddenEnabled { + if effectiveAlwaysHiddenEnabled { if !alwaysHiddenDivider.isInstalled { alwaysHiddenDivider.install() } @@ -469,7 +471,7 @@ final class MenuBarHiddenManager: ObservableObject { } let layoutItems = MenuBarHiddenLayoutPolicy.layoutEditorItems(from: allItems) - let alwaysHiddenDividerBounds = store.isAlwaysHiddenEnabled ? result.alwaysHiddenDividerBounds : nil + let alwaysHiddenDividerBounds = effectiveAlwaysHiddenEnabled ? result.alwaysHiddenDividerBounds : nil let visibleItems = MenuBarHiddenLayoutPolicy.visibleItems( from: layoutItems, hiddenDividerBounds: hiddenDividerBounds, @@ -546,6 +548,8 @@ final class MenuBarHiddenManager: ObservableObject { toSection: MenuBarHiddenSection, placement: MenuBarHiddenMovePlacement ) async -> Bool { + activeInteractionCount += 1 + defer { activeInteractionCount -= 1 } await events.waitForUserInputPause() let live = enumerateMenuBarItems( @@ -610,8 +614,10 @@ final class MenuBarHiddenManager: ObservableObject { switch destination { case .alwaysHidden: store.recordAlwaysHiddenItem((movedItem ?? originalItem).tag) + let ordered = snapshot.alwaysHiddenItems.filter(\.canBeHidden).map { $0.tag.stableKey } + store.orderedAlwaysHiddenItemStableKeys = ordered + store.orderedAlwaysHiddenItemStableKeys.filter { !ordered.contains($0) } case .visible, .hidden: - if store.isAlwaysHiddenEnabled { + if effectiveAlwaysHiddenEnabled { store.removeAlwaysHiddenItem(originalItem.tag) if let movedItem { store.removeAlwaysHiddenItem(movedItem.tag) @@ -671,7 +677,7 @@ final class MenuBarHiddenManager: ObservableObject { visibleItemStableKeys: store.visibleItemStableKeys, hiddenItemStableKeys: store.hiddenItemStableKeys, alwaysHiddenItemStableKeys: store.alwaysHiddenItemStableKeys, - isAlwaysHiddenEnabled: store.isAlwaysHiddenEnabled + isAlwaysHiddenEnabled: effectiveAlwaysHiddenEnabled ) } @@ -764,7 +770,12 @@ final class MenuBarHiddenManager: ObservableObject { storedLayout: layout ) guard desiredSection != .alwaysHidden else { return nil } - guard currentSections[key] != desiredSection else { return nil } + let sectionKeys = desiredSection == .visible ? layout.visibleItemStableKeys : layout.hiddenItemStableKeys + let sectionItems = desiredSection == .visible ? snapshot.visibleItems : snapshot.hiddenItems + let currentKeys = sectionItems.map { $0.tag.stableKey } + let expected = sectionKeys.filter { currentKeys.contains($0) } + let actual = currentKeys.filter { expected.contains($0) } + guard currentSections[key] != desiredSection || actual != expected else { return nil } guard !MenuBarHiddenLayoutPolicy.shouldRejectMoveToSection(item: item, section: desiredSection) else { return nil } @@ -800,7 +811,8 @@ final class MenuBarHiddenManager: ObservableObject { sectionKeys = store.hiddenItemStableKeys sectionItems = snapshot.hiddenItems case .alwaysHidden: - return .end + sectionKeys = store.orderedAlwaysHiddenItemStableKeys + sectionItems = snapshot.alwaysHiddenItems } guard let index = sectionKeys.firstIndex(of: item.tag.stableKey) else { @@ -859,7 +871,7 @@ final class MenuBarHiddenManager: ObservableObject { return } - if await performMove(item: item, toSection: .alwaysHidden, placement: .end) { + if await performMove(item: item, toSection: .alwaysHidden, placement: storedVisibleHiddenPlacement(for: item, in: .alwaysHidden)) { restoredCount += 1 } try? await Task.sleep(for: .milliseconds(100)) @@ -873,7 +885,7 @@ final class MenuBarHiddenManager: ObservableObject { private var canRestoreAlwaysHiddenItems: Bool { isActive - && store.isAlwaysHiddenEnabled + && effectiveAlwaysHiddenEnabled && permissions.canManageItems && alwaysHiddenDivider.isInstalled && !isDraggingMenuBarItem @@ -882,10 +894,14 @@ final class MenuBarHiddenManager: ObservableObject { } private func alwaysHiddenRestoreCandidates() -> [MenuBarItem] { - MenuBarHiddenAlwaysHiddenRestorePolicy.restoreCandidates( - snapshot: snapshot, - recordedStableKeys: store.alwaysHiddenItemStableKeys - ) + let missing = MenuBarHiddenAlwaysHiddenRestorePolicy.restoreCandidates(snapshot: snapshot, recordedStableKeys: store.alwaysHiddenItemStableKeys) + let keys = store.orderedAlwaysHiddenItemStableKeys + let current = snapshot.alwaysHiddenItems.map { $0.tag.stableKey } + let expected = keys.filter { current.contains($0) } + let actual = current.filter { expected.contains($0) } + let reorder = actual == expected ? [] : snapshot.alwaysHiddenItems.filter { expected.contains($0.tag.stableKey) } + let byKey = Dictionary(grouping: missing + reorder, by: { $0.tag.stableKey }).compactMapValues(\.first) + return keys.compactMap { byKey[$0] } } private func computeMoveTarget( @@ -1801,7 +1817,7 @@ final class MenuBarHiddenManager: ObservableObject { private func recoverAlwaysHiddenDividerIfNeeded(sessionID: Int?) { guard - store.isAlwaysHiddenEnabled, + effectiveAlwaysHiddenEnabled, alwaysHiddenDivider.isInstalled, !isDraggingMenuBarItem, !isRecoveringControlItemOrder @@ -2242,7 +2258,7 @@ final class MenuBarHiddenManager: ObservableObject { let layoutItems = MenuBarHiddenLayoutPolicy.layoutEditorItems(from: result.items) let alwaysHiddenDividerBounds: CGRect? - if store.isAlwaysHiddenEnabled { + if effectiveAlwaysHiddenEnabled { guard let bounds = result.alwaysHiddenDividerBounds else { return } alwaysHiddenDividerBounds = bounds } else { @@ -2275,7 +2291,7 @@ final class MenuBarHiddenManager: ObservableObject { hiddenKeys: nextVisibleHidden.hiddenItemStableKeys ) - guard store.isAlwaysHiddenEnabled else { return } + guard effectiveAlwaysHiddenEnabled else { return } let currentAlwaysHiddenKeys = Set(alwaysHiddenItems.filter(\.canBeHidden).map(\.tag.stableKey)) let currentItemKeys = Set(layoutItems.map(\.tag.stableKey)) @@ -2283,8 +2299,8 @@ final class MenuBarHiddenManager: ObservableObject { let keysStillMissing = previousKeys.subtracting(currentItemKeys) let nextKeys = keysStillMissing.union(currentAlwaysHiddenKeys) - guard nextKeys != previousKeys else { return } - store.alwaysHiddenItemStableKeys = nextKeys + let ordered = alwaysHiddenItems.filter(\.canBeHidden).map { $0.tag.stableKey } + store.orderedAlwaysHiddenItemStableKeys = ordered + store.orderedAlwaysHiddenItemStableKeys.filter { nextKeys.contains($0) && !ordered.contains($0) } } private func finishMenuBarDragCommit(sessionID: Int) { @@ -2308,7 +2324,7 @@ final class MenuBarHiddenManager: ObservableObject { divider.showSection(isDragging: isDraggingMenuBarItem) } - if store.isAlwaysHiddenEnabled { + if effectiveAlwaysHiddenEnabled { if alwaysHiddenDivider.isInstalled { alwaysHiddenDivider.hideSection() } @@ -2336,7 +2352,6 @@ final class MenuBarHiddenManager: ObservableObject { } self.shouldRestoreHiddenAfterDrag = false - self.store.isEnabled = true self.installAndExpand() self.rebuildSnapshot() self.scheduleAlwaysHiddenItemsRestoreIfNeeded() diff --git a/Plugins/MenuBarHidden/Sources/MenuBarHiddenPlugin.swift b/Plugins/MenuBarHidden/Sources/MenuBarHiddenPlugin.swift index 5154f0d0a..6ec47e1aa 100644 --- a/Plugins/MenuBarHidden/Sources/MenuBarHiddenPlugin.swift +++ b/Plugins/MenuBarHidden/Sources/MenuBarHiddenPlugin.swift @@ -374,3 +374,18 @@ final class MenuBarHiddenPlugin: MacToolsPlugin, PluginActionProviding, MenuBarH ) } } + +extension MenuBarHiddenPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { controller.manager.onPersistentPreferencesChange } + set { controller.manager.onPersistentPreferencesChange = newValue } + } + func makePortablePreferencesBackup() -> Data? { controller.manager.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard controller.manager.restorePortableBackup(data) else { return false } + onStateChange?() + return true + } +} diff --git a/Plugins/MenuBarHidden/Sources/MenuBarHiddenStore.swift b/Plugins/MenuBarHidden/Sources/MenuBarHiddenStore.swift index 00154c90d..e42c2a0b5 100644 --- a/Plugins/MenuBarHidden/Sources/MenuBarHiddenStore.swift +++ b/Plugins/MenuBarHidden/Sources/MenuBarHiddenStore.swift @@ -25,124 +25,129 @@ final class MenuBarHiddenStore { init(storage: PluginStorage) { self.storage = storage + _ = Self.portableStore(storage: storage).recover() } + var onPersistentPreferencesChange: (() -> Void)? + private struct PortableLayout: Codable { + let visible: [String] + let hidden: [String] + } + private struct PortablePreferences: Codable { + let formatVersion: Int + let isEnabled: Bool + let isAlwaysHiddenEnabled: Bool + let showsHiddenIconsInPanel: Bool + let layout: PortableLayout? + let alwaysHidden: [String] + } + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + Key.isEnabled: .boolean(false), Key.isAlwaysHiddenEnabled: .boolean(false), Key.showsHiddenIconsInPanel: .boolean(false), + Key.visibleItemStableKeys: .strings(), Key.hiddenItemStableKeys: .strings(), Key.alwaysHiddenItemStableKeys: .strings() + ]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } var isEnabled: Bool { get { storage.object(forKey: Key.isEnabled) as? Bool ?? false } set { _ = setEnabled(newValue) } } - @discardableResult func setEnabled(_ enabled: Bool) -> MenuBarHiddenPersistenceMutationResult { - let previousRawValue = storage.object(forKey: Key.isEnabled) - guard previousRawValue == nil || previousRawValue is Bool else { - return .recoveryRequired - } - if let previous = previousRawValue as? Bool, previous == enabled { - return .committed - } - - storage.set(enabled, forKey: Key.isEnabled) - guard storage.object(forKey: Key.isEnabled) as? Bool == enabled else { - let rollbackSucceeded = restore(previousRawValue, forKey: Key.isEnabled) - return .rejected(rollbackSucceeded: rollbackSucceeded) - } - return .committed + let previous = storage.object(forKey: Key.isEnabled) + guard previous == nil || previous is Bool else { return .recoveryRequired } + guard isEnabled != enabled else { return .committed } + return commit([Key.isEnabled: .boolean(enabled)]) ? .committed : .rejected(rollbackSucceeded: storage.object(forKey: Key.isEnabled) as? Bool == previous as? Bool) } - var isAlwaysHiddenEnabled: Bool { get { storage.object(forKey: Key.isAlwaysHiddenEnabled) as? Bool ?? false } - set { storage.set(newValue, forKey: Key.isAlwaysHiddenEnabled) } + set { _ = commit([Key.isAlwaysHiddenEnabled: .boolean(newValue)]) } } - var showsHiddenIconsInPanel: Bool { get { storage.object(forKey: Key.showsHiddenIconsInPanel) as? Bool ?? false } - set { storage.set(newValue, forKey: Key.showsHiddenIconsInPanel) } + set { _ = commit([Key.showsHiddenIconsInPanel: .boolean(newValue)]) } } - var hasVisibleHiddenLayout: Bool { - storage.object(forKey: Key.visibleItemStableKeys) != nil - || storage.object(forKey: Key.hiddenItemStableKeys) != nil + storage.object(forKey: Key.visibleItemStableKeys) != nil || storage.object(forKey: Key.hiddenItemStableKeys) != nil } - var visibleItemStableKeys: [String] { - get { - normalizedStableKeys(storage.stringArray(forKey: Key.visibleItemStableKeys) ?? []) - } - set { - storage.set(normalizedStableKeys(newValue), forKey: Key.visibleItemStableKeys) - } + get { normalizedStableKeys(storage.stringArray(forKey: Key.visibleItemStableKeys) ?? []) } + set { _ = commit([Key.visibleItemStableKeys: .strings(normalizedStableKeys(newValue))]) } } - var hiddenItemStableKeys: [String] { - get { - normalizedStableKeys(storage.stringArray(forKey: Key.hiddenItemStableKeys) ?? []) - } + get { normalizedStableKeys(storage.stringArray(forKey: Key.hiddenItemStableKeys) ?? []) } + set { _ = commit([Key.hiddenItemStableKeys: .strings(normalizedStableKeys(newValue))]) } + } + var orderedAlwaysHiddenItemStableKeys: [String] { + get { normalizedStableKeys(storage.stringArray(forKey: Key.alwaysHiddenItemStableKeys) ?? []) } set { - storage.set(normalizedStableKeys(newValue), forKey: Key.hiddenItemStableKeys) + let keys = normalizedStableKeys(newValue) + if keys.isEmpty { _ = commit([:], removing: [Key.alwaysHiddenItemStableKeys]) } + else { _ = commit([Key.alwaysHiddenItemStableKeys: .strings(keys)]) } } } - var alwaysHiddenItemStableKeys: Set { - get { - Set(storage.stringArray(forKey: Key.alwaysHiddenItemStableKeys) ?? []) - } + get { Set(orderedAlwaysHiddenItemStableKeys) } set { - let keys = newValue.sorted() - if keys.isEmpty { - storage.removeObject(forKey: Key.alwaysHiddenItemStableKeys) - } else { - storage.set(keys, forKey: Key.alwaysHiddenItemStableKeys) - } + let retained = orderedAlwaysHiddenItemStableKeys.filter { newValue.contains($0) } + orderedAlwaysHiddenItemStableKeys = retained + newValue.subtracting(retained).sorted() } } - func recordVisibleHiddenLayout(visibleKeys: [String], hiddenKeys: [String]) { let visible = normalizedStableKeys(visibleKeys) let hidden = normalizedStableKeys(hiddenKeys.filter { !visible.contains($0) }) - storage.set(visible, forKey: Key.visibleItemStableKeys) - storage.set(hidden, forKey: Key.hiddenItemStableKeys) + _ = commit([Key.visibleItemStableKeys: .strings(visible), Key.hiddenItemStableKeys: .strings(hidden)]) } - func recordAlwaysHiddenItem(_ tag: MenuBarItemTag) { - var keys = alwaysHiddenItemStableKeys - guard keys.insert(tag.stableKey).inserted else { return } - alwaysHiddenItemStableKeys = keys + guard !alwaysHiddenItemStableKeys.contains(tag.stableKey) else { return } + orderedAlwaysHiddenItemStableKeys += [tag.stableKey] } - func removeAlwaysHiddenItem(_ tag: MenuBarItemTag) { - var keys = alwaysHiddenItemStableKeys - guard keys.remove(tag.stableKey) != nil else { return } - alwaysHiddenItemStableKeys = keys + orderedAlwaysHiddenItemStableKeys.removeAll { $0 == tag.stableKey } } - private func normalizedStableKeys(_ keys: [String]) -> [String] { var seen = Set() - var result: [String] = [] - for key in keys where !key.isEmpty { - guard seen.insert(key).inserted else { continue } - result.append(key) - } - return result - } - - private func restore(_ value: Any?, forKey key: String) -> Bool { - if let value { - storage.set(value, forKey: key) - } else { - storage.removeObject(forKey: key) - } - return valuesMatch(storage.object(forKey: key), value) - } - - private func valuesMatch(_ lhs: Any?, _ rhs: Any?) -> Bool { - switch (lhs, rhs) { - case (nil, nil): - return true - case let (lhs as NSObject, rhs as NSObject): - return lhs.isEqual(rhs) - default: - return false + return keys.filter { !$0.isEmpty && seen.insert($0).inserted } + } + private func decodedPortable(_ data: Data) -> PortablePreferences? { + guard data.count <= 2 * 1_024 * 1_024, + let payload = try? JSONDecoder().decode(PortablePreferences.self, from: data), payload.formatVersion == 1, + PortablePreferenceField.strings().accepts(.strings(payload.alwaysHidden)) else { return nil } + if let layout = payload.layout { + guard PortablePreferenceField.strings().accepts(.strings(layout.visible)), PortablePreferenceField.strings().accepts(.strings(layout.hidden)), + Set(layout.visible).isDisjoint(with: layout.hidden) else { return nil } } + return payload + } + func portableBackup() -> Data? { + guard portableStore.backup() != nil else { return nil } + let payload = PortablePreferences(formatVersion: 1, isEnabled: isEnabled, isAlwaysHiddenEnabled: isAlwaysHiddenEnabled, + showsHiddenIconsInPanel: showsHiddenIconsInPanel, + layout: hasVisibleHiddenLayout ? PortableLayout(visible: visibleItemStableKeys, hidden: hiddenItemStableKeys) : nil, + alwaysHidden: orderedAlwaysHiddenItemStableKeys) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(payload), decodedPortable(data) != nil else { return nil } + return data + } + func restorePortableBackup(_ data: Data) -> Bool { + guard let payload = decodedPortable(data) else { return false } + var candidate: [String: PortablePreferenceValue] = [ + Key.isEnabled: .boolean(payload.isEnabled), Key.isAlwaysHiddenEnabled: .boolean(payload.isAlwaysHiddenEnabled), + Key.showsHiddenIconsInPanel: .boolean(payload.showsHiddenIconsInPanel), Key.alwaysHiddenItemStableKeys: .strings(payload.alwaysHidden) + ] + var removing = Set() + if let layout = payload.layout { + candidate[Key.visibleItemStableKeys] = .strings(layout.visible) + candidate[Key.hiddenItemStableKeys] = .strings(layout.hidden) + } else { removing = [Key.visibleItemStableKeys, Key.hiddenItemStableKeys] } + return commit(candidate, removing: removing) + } + private func commit(_ candidate: [String: PortablePreferenceValue], removing: Set = []) -> Bool { + let previous = portableStore.currentValues() + let hadLayout = hasVisibleHiddenLayout + guard portableStore.commit(candidate, removing: removing) else { return false } + if previous != portableStore.currentValues() || hadLayout != hasVisibleHiddenLayout { onPersistentPreferencesChange?() } + return true } } diff --git a/Plugins/MenuBarHidden/Tests/MenuBarHiddenStoreTests.swift b/Plugins/MenuBarHidden/Tests/MenuBarHiddenStoreTests.swift index b308fd93e..e77b7688d 100644 --- a/Plugins/MenuBarHidden/Tests/MenuBarHiddenStoreTests.swift +++ b/Plugins/MenuBarHidden/Tests/MenuBarHiddenStoreTests.swift @@ -8,6 +8,28 @@ import MacToolsPluginKit @MainActor final class MenuBarHiddenStoreTests: XCTestCase { + func testPortableOrderingAndRequestedFlagsSurviveDeniedPermissions() throws { + let source = MenuBarHiddenStore(storage: FakePluginStorage()) + source.isEnabled = true + source.isAlwaysHiddenEnabled = true + source.showsHiddenIconsInPanel = true + source.recordVisibleHiddenLayout(visibleKeys: ["v2", "v1"], hiddenKeys: ["h2", "h1"]) + source.orderedAlwaysHiddenItemStableKeys = ["missing", "a2", "a1"] + let data = try XCTUnwrap(source.portableBackup()) + let destination = MenuBarHiddenStore(storage: FakePluginStorage()) + let manager = MenuBarHiddenManager(store: destination, permissionProvider: { + MenuBarHiddenPermissionsStatus(hasAccessibility: false, hasScreenRecording: false) + }) + XCTAssertTrue(manager.restorePortableBackup(data)) + XCTAssertTrue(destination.isEnabled) + XCTAssertTrue(destination.isAlwaysHiddenEnabled) + XCTAssertTrue(destination.showsHiddenIconsInPanel) + XCTAssertEqual(destination.visibleItemStableKeys, ["v2", "v1"]) + XCTAssertEqual(destination.hiddenItemStableKeys, ["h2", "h1"]) + XCTAssertEqual(destination.orderedAlwaysHiddenItemStableKeys, ["missing", "a2", "a1"]) + XCTAssertEqual(destination.portableBackup(), data) + } + func testVisibleHiddenLayoutPersistsStableKeysInOrderWithoutDuplicates() { let storage = MenuBarHiddenMemoryStorage() let store = MenuBarHiddenStore(storage: storage) @@ -34,7 +56,7 @@ final class MenuBarHiddenStoreTests: XCTestCase { ]) } - func testAlwaysHiddenItemStableKeysPersistSortedWithoutDuplicates() { + func testAlwaysHiddenItemStableKeysPreserveOrderWithoutDuplicates() { let storage = MenuBarHiddenMemoryStorage() let store = MenuBarHiddenStore(storage: storage) let first = MenuBarItemTag(namespace: "com.example.b", title: "Item", windowID: nil, instanceIndex: 0) @@ -45,8 +67,8 @@ final class MenuBarHiddenStoreTests: XCTestCase { store.recordAlwaysHiddenItem(first) XCTAssertEqual(storage.stringArray(forKey: "always-hidden-item-stable-keys"), [ - "com.example.a:Item:1", "com.example.b:Item", + "com.example.a:Item:1", ]) XCTAssertEqual(MenuBarHiddenStore(storage: storage).alwaysHiddenItemStableKeys, [ "com.example.a:Item:1", diff --git a/Plugins/MenuBarHidden/plugin.json b/Plugins/MenuBarHidden/plugin.json index 906735069..7ce119b04 100644 --- a/Plugins/MenuBarHidden/plugin.json +++ b/Plugins/MenuBarHidden/plugin.json @@ -258,5 +258,11 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "device-state" + ] } } diff --git a/Plugins/MenuBarHidden/project.yml b/Plugins/MenuBarHidden/project.yml new file mode 100644 index 000000000..b6e2b5ad1 --- /dev/null +++ b/Plugins/MenuBarHidden/project.yml @@ -0,0 +1,3 @@ +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/MicrophoneMute/plugin.json b/Plugins/MicrophoneMute/plugin.json index 0a201d7b0..6f7b81c0c 100644 --- a/Plugins/MicrophoneMute/plugin.json +++ b/Plugins/MicrophoneMute/plugin.json @@ -270,5 +270,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/MiddleClick/Sources/MiddleClickPlugin.swift b/Plugins/MiddleClick/Sources/MiddleClickPlugin.swift index 99c084181..d7689ca22 100644 --- a/Plugins/MiddleClick/Sources/MiddleClickPlugin.swift +++ b/Plugins/MiddleClick/Sources/MiddleClickPlugin.swift @@ -476,3 +476,20 @@ final class MiddleClickPlugin: MacToolsPlugin, TrackpadInputServiceConsuming, Ac ActionReference(key: ActionKey(providerID: metadata.id, actionID: ActionID.toggle)) } } + +extension MiddleClickPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { store.onPersistentPreferencesChange } + set { store.onPersistentPreferencesChange = newValue } + } + func makePortablePreferencesBackup() -> Data? { store.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard store.restorePortableBackup(data) else { return false } + isAccessibilityGranted = accessibilityTrusted() + applyCurrentConfiguration() + onStateChange?() + return true + } +} diff --git a/Plugins/MiddleClick/Sources/MiddleClickStore.swift b/Plugins/MiddleClick/Sources/MiddleClickStore.swift index dc0e9787d..d662921dc 100644 --- a/Plugins/MiddleClick/Sources/MiddleClickStore.swift +++ b/Plugins/MiddleClick/Sources/MiddleClickStore.swift @@ -15,6 +15,7 @@ final class MiddleClickStore { init(storage: any PluginStorage) { self.storage = storage + _ = Self.portableStore(storage: storage).recover() storage.migrateValueIfNeeded( fromLegacyKey: StorageKey.isEnabled, @@ -37,15 +38,34 @@ final class MiddleClickStore { func setEnabled(_ isEnabled: Bool) { guard self.isEnabled != isEnabled else { return } + guard portableStore.commit([StorageKey.isEnabled: .boolean(isEnabled)]) else { return } self.isEnabled = isEnabled - storage.set(isEnabled, forKey: StorageKey.isEnabled) + onPersistentPreferencesChange?() } func setRequiredFingerCount(_ count: Int) { let normalizedCount = Self.normalizedFingerCount(count) guard requiredFingerCount != normalizedCount else { return } + guard portableStore.commit([StorageKey.requiredFingerCount: .integer(normalizedCount)]) else { return } requiredFingerCount = normalizedCount - storage.set(normalizedCount, forKey: StorageKey.requiredFingerCount) + onPersistentPreferencesChange?() + } + + var onPersistentPreferencesChange: (() -> Void)? + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + StorageKey.isEnabled: .boolean(false), StorageKey.requiredFingerCount: .integer(3, range: 3...5) + ]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + func portableBackup() -> Data? { portableStore.backup() } + func restorePortableBackup(_ data: Data) -> Bool { + let previous = portableStore.currentValues() + guard portableStore.restore(data) else { return false } + isEnabled = storage.object(forKey: StorageKey.isEnabled) as? Bool ?? false + requiredFingerCount = storage.object(forKey: StorageKey.requiredFingerCount) as? Int ?? 3 + if previous != portableStore.currentValues() { onPersistentPreferencesChange?() } + return true } private static func normalizedFingerCount(_ count: Int) -> Int { diff --git a/Plugins/MiddleClick/Tests/MiddleClickPluginTests.swift b/Plugins/MiddleClick/Tests/MiddleClickPluginTests.swift index 39cfb8820..34621a610 100644 --- a/Plugins/MiddleClick/Tests/MiddleClickPluginTests.swift +++ b/Plugins/MiddleClick/Tests/MiddleClickPluginTests.swift @@ -45,6 +45,18 @@ private final class MockMiddleClickSession: MiddleClickSessionManaging { @MainActor final class MiddleClickPluginTests: XCTestCase { + func testPortableStorePreservesRequestedConfiguration() throws { + let source = MiddleClickStore(storage: MiddleClickMemoryStorage()) + source.setEnabled(true) + source.setRequiredFingerCount(5) + let storage = MiddleClickMemoryStorage() + let destination = MiddleClickStore(storage: storage) + XCTAssertTrue(destination.restorePortableBackup(try XCTUnwrap(source.portableBackup()))) + XCTAssertTrue(destination.isEnabled) + XCTAssertEqual(destination.requiredFingerCount, 5) + XCTAssertEqual(MiddleClickStore(storage: storage).requiredFingerCount, 5) + } + func testThreeFingerTapRecognizesAfterAllContactsRelease() { var recognizer = MiddleClickTapRecognizer(fingerCount: 3) diff --git a/Plugins/MiddleClick/plugin.json b/Plugins/MiddleClick/plugin.json index 2b930e2f8..1c2f88a7a 100644 --- a/Plugins/MiddleClick/plugin.json +++ b/Plugins/MiddleClick/plugin.json @@ -287,5 +287,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "portable", + "exclusions": [] } } diff --git a/Plugins/MiddleClick/project.yml b/Plugins/MiddleClick/project.yml new file mode 100644 index 000000000..5a47746a5 --- /dev/null +++ b/Plugins/MiddleClick/project.yml @@ -0,0 +1,4 @@ + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/MouseEnhancer/Sources/MouseEnhancerModels.swift b/Plugins/MouseEnhancer/Sources/MouseEnhancerModels.swift index 6d55f4e10..7fb8e317e 100644 --- a/Plugins/MouseEnhancer/Sources/MouseEnhancerModels.swift +++ b/Plugins/MouseEnhancer/Sources/MouseEnhancerModels.swift @@ -195,6 +195,7 @@ final class MouseEnhancerStore: ObservableObject { init(storage: any PluginStorage) { self.storage = storage + _ = Self.portableStore(storage: storage).recover() self.configuration = MouseEnhancerConfiguration( reverseMouseHorizontal: Self.bool( forKey: StorageKey.reverseMouseHorizontal, @@ -259,6 +260,33 @@ final class MouseEnhancerStore: ObservableObject { ) } + var onPersistentPreferencesChange: (() -> Void)? + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + StorageKey.reverseMouseHorizontal: .boolean(MouseEnhancerConfiguration.default.reverseMouseHorizontal), + StorageKey.reverseMouseVertical: .boolean(MouseEnhancerConfiguration.default.reverseMouseVertical), + StorageKey.reverseTrackpadHorizontal: .boolean(MouseEnhancerConfiguration.default.reverseTrackpadHorizontal), + StorageKey.reverseTrackpadVertical: .boolean(MouseEnhancerConfiguration.default.reverseTrackpadVertical), + StorageKey.middleClickEnabled: .boolean(MouseEnhancerConfiguration.default.middleClickEnabled), + StorageKey.smoothScrollingEnabled: .boolean(MouseEnhancerConfiguration.default.smoothScrollingEnabled), + StorageKey.mouseScrollStep: .number(MouseEnhancerConfiguration.defaultScrollStep, range: MouseEnhancerConfiguration.scrollStepRange), + StorageKey.mouseScrollGain: .number(MouseEnhancerConfiguration.defaultScrollGain, range: MouseEnhancerConfiguration.scrollGainRange), + StorageKey.trackpadScrollStep: .number(MouseEnhancerConfiguration.defaultScrollStep, range: MouseEnhancerConfiguration.scrollStepRange), + StorageKey.trackpadScrollGain: .number(MouseEnhancerConfiguration.defaultScrollGain, range: MouseEnhancerConfiguration.scrollGainRange), + StorageKey.mouseScrollDuration: .number(MouseEnhancerConfiguration.defaultScrollDuration, range: MouseEnhancerConfiguration.scrollDurationRange), + StorageKey.middleClickFingerCount: .integer(3, range: 3...5) + ]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + func portableBackup() -> Data? { portableStore.backup() } + func restorePortableBackup(_ data: Data) -> Bool { + let previous = configuration + guard portableStore.restore(data) else { return false } + configuration = MouseEnhancerStore(storage: storage).configuration + if previous != configuration { onPersistentPreferencesChange?() } + return true + } + func setReverseMouseHorizontal(_ isEnabled: Bool) { update(StorageKey.reverseMouseHorizontal, value: isEnabled) { $0.reverseMouseHorizontal = isEnabled @@ -348,8 +376,16 @@ final class MouseEnhancerStore: ObservableObject { return } - storage.set(value, forKey: key) + let candidate: PortablePreferenceValue + switch value { + case let value as Bool: candidate = .boolean(value) + case let value as Int: candidate = .integer(value) + case let value as Double: candidate = .number(value) + default: return + } + guard portableStore.commit([key: candidate]) else { return } configuration = next + onPersistentPreferencesChange?() } private static func bool( diff --git a/Plugins/MouseEnhancer/Sources/MouseEnhancerPlugin.swift b/Plugins/MouseEnhancer/Sources/MouseEnhancerPlugin.swift index 64341171e..1f54e2b49 100644 --- a/Plugins/MouseEnhancer/Sources/MouseEnhancerPlugin.swift +++ b/Plugins/MouseEnhancer/Sources/MouseEnhancerPlugin.swift @@ -825,3 +825,22 @@ final class MouseEnhancerPlugin: openURL(url) } } + +extension MouseEnhancerPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { store.onPersistentPreferencesChange } + set { store.onPersistentPreferencesChange = newValue } + } + func makePortablePreferencesBackup() -> Data? { store.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard store.restorePortableBackup(data) else { return false } + isAccessibilityGranted = accessibilityTrusted() + runtimeError = nil + applyCurrentConfiguration() + applyMiddleClickConfiguration() + onStateChange?() + return true + } +} diff --git a/Plugins/MouseEnhancer/Tests/MouseEnhancerPluginTests.swift b/Plugins/MouseEnhancer/Tests/MouseEnhancerPluginTests.swift index c80492735..dce0d88cb 100644 --- a/Plugins/MouseEnhancer/Tests/MouseEnhancerPluginTests.swift +++ b/Plugins/MouseEnhancer/Tests/MouseEnhancerPluginTests.swift @@ -113,6 +113,19 @@ private final class MockMouseEnhancerMiddleClickSession: MouseEnhancerMiddleClic @MainActor final class MouseEnhancerPluginTests: XCTestCase { + func testPortableConfigurationRoundTripsWithoutLegacyMigrationRecords() throws { + let source = MouseEnhancerStore(storage: MouseEnhancerMemoryStorage()) + source.setReverseTrackpadHorizontal(true) + source.setMouseScrollGain(2.5) + source.setSmoothScrollingEnabled(true) + let storage = MouseEnhancerMemoryStorage() + storage.set("local", forKey: "migration") + let destination = MouseEnhancerStore(storage: storage) + XCTAssertTrue(destination.restorePortableBackup(try XCTUnwrap(source.portableBackup()))) + XCTAssertEqual(destination.configuration, source.configuration) + XCTAssertEqual(storage.string(forKey: "migration"), "local") + } + func testConfigurationChangeEnablesSessionWhenAccessibilityGranted() { let session = MockMouseEnhancerSession() diff --git a/Plugins/MouseEnhancer/plugin.json b/Plugins/MouseEnhancer/plugin.json index 98ea51e92..cd9393f5d 100644 --- a/Plugins/MouseEnhancer/plugin.json +++ b/Plugins/MouseEnhancer/plugin.json @@ -228,5 +228,11 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "device-state" + ] } } diff --git a/Plugins/MouseEnhancer/project.yml b/Plugins/MouseEnhancer/project.yml index f3e2cb770..2c23028c1 100644 --- a/Plugins/MouseEnhancer/project.yml +++ b/Plugins/MouseEnhancer/project.yml @@ -1,3 +1,7 @@ settings: base: SWIFT_INCLUDE_PATHS: $(SRCROOT)/Sources/Core/MultitouchSupport + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/NightShift/plugin.json b/Plugins/NightShift/plugin.json index cd9ec6e44..0b5500c23 100644 --- a/Plugins/NightShift/plugin.json +++ b/Plugins/NightShift/plugin.json @@ -264,5 +264,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/PhysicalCleanMode/plugin.json b/Plugins/PhysicalCleanMode/plugin.json index 8f4243f4e..1a7318b5d 100644 --- a/Plugins/PhysicalCleanMode/plugin.json +++ b/Plugins/PhysicalCleanMode/plugin.json @@ -237,5 +237,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/PowerStatus/plugin.json b/Plugins/PowerStatus/plugin.json index 46f028098..c6091fb73 100644 --- a/Plugins/PowerStatus/plugin.json +++ b/Plugins/PowerStatus/plugin.json @@ -188,5 +188,9 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/QuitApps/plugin.json b/Plugins/QuitApps/plugin.json index f9782e9bf..0833e04f8 100644 --- a/Plugins/QuitApps/plugin.json +++ b/Plugins/QuitApps/plugin.json @@ -224,5 +224,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/RightClick/Resources/Localizable.xcstrings b/Plugins/RightClick/Resources/Localizable.xcstrings index 95a70dd6c..26774e22b 100644 --- a/Plugins/RightClick/Resources/Localizable.xcstrings +++ b/Plugins/RightClick/Resources/Localizable.xcstrings @@ -2079,6 +2079,83 @@ } } } + }, + "settings.applicationUnavailable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التطبيق غير موجود على هذا الـ Mac" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "App auf diesem Mac nicht gefunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Application unavailable on this Mac" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "App no disponible en este Mac" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Application introuvable sur ce Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このMacでアプリが見つかりません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 Mac에서 앱을 찾을 수 없음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "App indisponível neste Mac" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Приложение не найдено на этом Mac" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uygulama bu Mac’te bulunamadı" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此 Mac 上找不到应用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此 Mac 上找不到應用程式" + } + } + } } }, "version": "1.0" diff --git a/Plugins/RightClick/Sources/RightClickMenuSettingsView.swift b/Plugins/RightClick/Sources/RightClickMenuSettingsView.swift index ac004f825..45af20d6a 100644 --- a/Plugins/RightClick/Sources/RightClickMenuSettingsView.swift +++ b/Plugins/RightClick/Sources/RightClickMenuSettingsView.swift @@ -9,18 +9,38 @@ import MacToolsPluginKit /// on the next right-click — the extension reads the config on every menu build. @MainActor final class RightClickSettingsSession: ObservableObject { + private let fileURL: URL + private var isReloading = false + @Published private(set) var reloadRevision: UInt64 = 0 + var onCommit: (() -> Void)? @Published var configuration: RightClickConfiguration { didSet { + guard !isReloading else { return } var value = configuration - let current = RightClickConfigurationStore.load() + let current = RightClickConfigurationStore.load(from: fileURL) value.menuEnabled = current.menuEnabled value.preferredLanguages = current.preferredLanguages - RightClickConfigurationStore.save(value) + guard value != current else { return } + guard RightClickConfigurationStore.save(value, to: fileURL) else { + isReloading = true + configuration = oldValue + isReloading = false + return + } + onCommit?() } } - init(configuration: RightClickConfiguration = RightClickConfigurationStore.load()) { - self.configuration = configuration + init(configuration: RightClickConfiguration? = nil, fileURL: URL = RightClickConfigurationStore.configFileURL) { + self.fileURL = fileURL + self.configuration = configuration ?? RightClickConfigurationStore.load(from: fileURL) + } + + func reload() { + isReloading = true + configuration = RightClickConfigurationStore.load(from: fileURL) + isReloading = false + reloadRevision &+= 1 } } @@ -96,6 +116,7 @@ struct RightClickMenuSettingsView: View { PluginSettingsListDivider() } } + .id(session.reloadRevision) } } } @@ -185,10 +206,13 @@ private struct RightClickOpenWithAppRow: View { VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { Text(app.name) .font(PluginSettingsTheme.Typography.emphasizedRowTitle) - Text(app.appPath) + Text(app.isUnresolved + ? localization.string("settings.applicationUnavailable", defaultValue: "此 Mac 上找不到应用") + : app.appPath) .font(PluginSettingsTheme.Typography.rowDescription) .foregroundStyle(.secondary) - .lineLimit(1) + .lineLimit(app.isUnresolved ? 2 : 1) + .fixedSize(horizontal: false, vertical: true) .truncationMode(.middle) } diff --git a/Plugins/RightClick/Sources/RightClickPlugin.swift b/Plugins/RightClick/Sources/RightClickPlugin.swift index ddb6ed5aa..96248ac49 100644 --- a/Plugins/RightClick/Sources/RightClickPlugin.swift +++ b/Plugins/RightClick/Sources/RightClickPlugin.swift @@ -44,6 +44,9 @@ final class RightClickPlugin: MacToolsPlugin { var requestPermissionGuidance: ((String) -> Void)? var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + private let configurationFileURL: URL + private let settingsSession: RightClickSettingsSession + var onPersistentPreferencesChange: (() -> Void)? private let localization: PluginLocalization private let logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", @@ -51,12 +54,15 @@ final class RightClickPlugin: MacToolsPlugin { ) private var activationObserver: NSObjectProtocol? - init(localization: PluginLocalization = PluginLocalization(bundle: .main)) { + init(localization: PluginLocalization = PluginLocalization(bundle: .main), configurationFileURL: URL = RightClickConfigurationStore.configFileURL) { self.localization = localization + self.configurationFileURL = configurationFileURL + self.settingsSession = RightClickSettingsSession(fileURL: configurationFileURL) + self.settingsSession.onCommit = { [weak self] in self?.onPersistentPreferencesChange?() } } func activate(context _: PluginRuntimeContext) { - RightClickConfigurationStore.setMenuEnabled(true) + RightClickConfigurationStore.setMenuEnabled(true, fileURL: configurationFileURL) guard activationObserver == nil else { return @@ -75,7 +81,7 @@ final class RightClickPlugin: MacToolsPlugin { func deactivate(reason: PluginDeactivationReason) { if reason.requiresStateCleanup { - RightClickConfigurationStore.setMenuEnabled(false) + RightClickConfigurationStore.setMenuEnabled(false, fileURL: configurationFileURL) } if let activationObserver { @@ -99,7 +105,7 @@ final class RightClickPlugin: MacToolsPlugin { } var settingsPage: PluginSettingsPage? { - let session = RightClickSettingsSession() + let session = settingsSession return .form( description: localization.string( "configuration.description", @@ -199,3 +205,91 @@ final class RightClickPlugin: MacToolsPlugin { onStateChange?() } } + +extension RightClickPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + private struct PortableApp: Codable { + let id: UUID + let name: String + let application: PortableApplicationReference + let fileExtensions: [String] + } + private struct PortablePreferences: Codable { + let formatVersion: Int + let newFolder: Bool + let copyFileName: Bool + let copyAbsolutePath: Bool + let copyRelativePath: Bool + let copyShellEscapedPath: Bool + let copyFileURL: Bool + let newFile: Bool + let openInTerminal: Bool + let applications: [PortableApp] + } + private func decodedPortable(_ data: Data) -> PortablePreferences? { + guard data.count <= 1_024 * 1_024, + let payload = try? JSONDecoder().decode(PortablePreferences.self, from: data), payload.formatVersion == 1, + payload.applications.count <= 512, Set(payload.applications.map(\.id)).count == payload.applications.count, + payload.applications.allSatisfy({ entry in + entry.application.isValid && !entry.name.isEmpty && entry.name.utf8.count <= 256 + && !entry.name.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + && entry.fileExtensions.count <= 64 && Set(entry.fileExtensions).count == entry.fileExtensions.count + && entry.fileExtensions.allSatisfy { ext in + !ext.isEmpty && ext.utf8.count <= 64 && ext == ext.lowercased() + && ext.unicodeScalars.allSatisfy { CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-_")).contains($0) } + } + }) else { return nil } + return payload + } + func makePortablePreferencesBackup() -> Data? { + guard let current = RightClickConfigurationStore.loadForPreferencesBackup(from: configurationFileURL), + current.openWithApps.count <= 512 else { return nil } + var applications: [PortableApp] = [] + for entry in current.openWithApps { + let application: PortableApplicationReference + if let retainedData = entry.portableApplicationReference { + guard let retained = try? JSONDecoder().decode(PortableApplicationReference.self, from: retainedData) else { return nil } + application = retained + } else { + let url = entry.appPath.hasPrefix("/") ? URL(fileURLWithPath: entry.appPath) : nil + application = PortableApplicationReference(url: url) + } + applications.append(PortableApp(id: entry.id, name: entry.name, application: application, fileExtensions: entry.fileExtensions)) + } + let payload = PortablePreferences(formatVersion: 1, newFolder: current.newFolder, copyFileName: current.copyFileName, + copyAbsolutePath: current.copyAbsolutePath, copyRelativePath: current.copyRelativePath, + copyShellEscapedPath: current.copyShellEscapedPath, copyFileURL: current.copyFileURL, + newFile: current.newFile, openInTerminal: current.openInTerminal, applications: applications) + guard let data = try? JSONEncoder().encode(payload), decodedPortable(data) != nil else { return nil } + return data + } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard let payload = decodedPortable(data), + var candidate = RightClickConfigurationStore.loadForPreferencesBackup(from: configurationFileURL) else { return false } + let previous = candidate + candidate.newFolder = payload.newFolder + candidate.copyFileName = payload.copyFileName + candidate.copyAbsolutePath = payload.copyAbsolutePath + candidate.copyRelativePath = payload.copyRelativePath + candidate.copyShellEscapedPath = payload.copyShellEscapedPath + candidate.copyFileURL = payload.copyFileURL + candidate.newFile = payload.newFile + candidate.openInTerminal = payload.openInTerminal + var resolved = Set() + candidate.openWithApps = payload.applications.map { incoming in + var url = incoming.application.resolvedURL() + if let candidateURL = url, !resolved.insert(candidateURL.path).inserted { url = nil } + var entry = RightClickOpenWithApp(id: incoming.id, name: incoming.name, appPath: url?.path ?? "", fileExtensions: incoming.fileExtensions) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + entry.portableApplicationReference = try? encoder.encode(incoming.application) + return entry + } + guard candidate == previous || RightClickConfigurationStore.save(candidate, to: configurationFileURL) else { return false } + settingsSession.reload() + if candidate != previous { onPersistentPreferencesChange?() } + onStateChange?() + return true + } +} diff --git a/Plugins/RightClick/Tests/RightClickPreferencesBackupTests.swift b/Plugins/RightClick/Tests/RightClickPreferencesBackupTests.swift new file mode 100644 index 000000000..550e88a61 --- /dev/null +++ b/Plugins/RightClick/Tests/RightClickPreferencesBackupTests.swift @@ -0,0 +1,67 @@ +import XCTest +@testable import RightClickPlugin + +@MainActor +final class RightClickPreferencesBackupTests: XCTestCase { + func testMenuAndMissingApplicationRoundTripPreservesDestinationLifecycleAndLanguage() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let sourceURL = directory.appendingPathComponent("source.json") + let destinationURL = directory.appendingPathComponent("destination.json") + var source = RightClickConfiguration() + source.copyFileURL = false + let app = RightClickOpenWithApp(name: "Missing", appPath: "/missing/Example.app", fileExtensions: ["txt"]) + source.openWithApps = [app] + XCTAssertTrue(RightClickConfigurationStore.save(source, to: sourceURL)) + var local = RightClickConfiguration() + local.menuEnabled = false + local.preferredLanguages = ["ja"] + XCTAssertTrue(RightClickConfigurationStore.save(local, to: destinationURL)) + let exporter = RightClickPlugin(configurationFileURL: sourceURL) + let importer = RightClickPlugin(configurationFileURL: destinationURL) + let data = try XCTUnwrap(exporter.makePortablePreferencesBackup()) + XCTAssertFalse(String(decoding: data, as: UTF8.self).contains("/missing/")) + var signals = 0 + importer.onPersistentPreferencesChange = { signals += 1 } + XCTAssertTrue(importer.restorePortablePreferencesReportingResult(from: data)) + XCTAssertEqual(signals, 1) + XCTAssertTrue(importer.restorePortablePreferencesReportingResult(from: data)) + XCTAssertEqual(signals, 1) + let restored = RightClickConfigurationStore.load(from: destinationURL) + XCTAssertFalse(restored.menuEnabled) + XCTAssertEqual(restored.preferredLanguages, ["ja"]) + XCTAssertFalse(restored.copyFileURL) + XCTAssertEqual(restored.openWithApps.first?.id, app.id) + XCTAssertEqual(restored.openWithApps.first?.appPath, "") + XCTAssertNotNil(restored.openWithApps.first?.portableApplicationReference) + XCTAssertTrue(restored.openWithApplications(matching: [directory.appendingPathComponent("example.txt")]).isEmpty) + XCTAssertEqual(try JSONSerialization.jsonObject(with: XCTUnwrap(importer.makePortablePreferencesBackup())) as? NSDictionary, + try JSONSerialization.jsonObject(with: data) as? NSDictionary) + } + + func testInvalidSourceCannotExportDefaultsOrBeOverwrittenByImport() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("configuration.json") + let plugin = RightClickPlugin(configurationFileURL: fileURL) + XCTAssertNotNil(plugin.makePortablePreferencesBackup(), "A new installation has default portable preferences") + try Data(#"{"copyFileURL":false}"#.utf8).write(to: fileURL) + let backup = try XCTUnwrap(plugin.makePortablePreferencesBackup(), "Missing legacy fields retain their defaults") + for text in ["{", #"{"copyFileURL":"invalid"}"#, #"{"openWithApps":[{"name":"incomplete"}]}"#] { + let corrupt = Data(text.utf8) + try corrupt.write(to: fileURL) + XCTAssertNil(plugin.makePortablePreferencesBackup()) + XCTAssertFalse(plugin.restorePortablePreferencesReportingResult(from: backup)) + XCTAssertEqual(try Data(contentsOf: fileURL), corrupt) + } + var unresolved = RightClickOpenWithApp(name: "Missing", appPath: "") + unresolved.portableApplicationReference = Data("invalid identity".utf8) + XCTAssertTrue(RightClickConfigurationStore.save(.init(openWithApps: [unresolved]), to: fileURL)) + XCTAssertNil(plugin.makePortablePreferencesBackup(), "An unreadable retained identity must not become a new default identity") + try FileManager.default.removeItem(at: fileURL) + try FileManager.default.createDirectory(at: fileURL, withIntermediateDirectories: false) + XCTAssertNil(plugin.makePortablePreferencesBackup(), "An existing unreadable source is not an absent configuration") + } +} diff --git a/Plugins/RightClick/plugin.json b/Plugins/RightClick/plugin.json index 9831b569e..e2fcbabbf 100644 --- a/Plugins/RightClick/plugin.json +++ b/Plugins/RightClick/plugin.json @@ -226,5 +226,12 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "application-paths", + "local-activation" + ] } } diff --git a/Plugins/RightClick/project.yml b/Plugins/RightClick/project.yml index 632f1ee6c..1c9d73053 100644 --- a/Plugins/RightClick/project.yml +++ b/Plugins/RightClick/project.yml @@ -1,3 +1,4 @@ core: sources: + - ../../Sources/PluginSupport/PortableApplicationReference.swift - ../../Sources/Core/RightClick/RightClickConfiguration.swift diff --git a/Plugins/SavedScripts/plugin.json b/Plugins/SavedScripts/plugin.json index b26270386..74206b125 100644 --- a/Plugins/SavedScripts/plugin.json +++ b/Plugins/SavedScripts/plugin.json @@ -250,5 +250,12 @@ ] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "private-content", + "external-authorization" + ] } } diff --git a/Plugins/Screenshot/Sources/ScreenshotEnvironment.swift b/Plugins/Screenshot/Sources/ScreenshotEnvironment.swift index a5b20624d..7449170f6 100644 --- a/Plugins/Screenshot/Sources/ScreenshotEnvironment.swift +++ b/Plugins/Screenshot/Sources/ScreenshotEnvironment.swift @@ -18,6 +18,7 @@ final class ScreenshotEnvironment { init(storage: any PluginStorage, localization: PluginLocalization) { self.storage = storage + _ = Self.portableStore(storage: storage).recover() self.localization = localization } @@ -40,7 +41,24 @@ final class ScreenshotEnvironment { /// Recording auto zoom is on by default; the record bar and settings share this preference. var recordingAutoZoom: Bool { get { storage.object(forKey: "recordingAutoZoom") as? Bool ?? true } - set { storage.set(newValue, forKey: "recordingAutoZoom") } + set { + guard newValue != recordingAutoZoom, + portableStore.commit(["recordingAutoZoom": .boolean(newValue)]) else { return } + onPersistentPreferencesChange?() + } + } + + var onPersistentPreferencesChange: (() -> Void)? + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: ["recordingAutoZoom": .boolean(true)]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + func portableBackup() -> Data? { portableStore.backup() } + func restorePortableBackup(_ data: Data) -> Bool { + let before = recordingAutoZoom + guard portableStore.restore(data) else { return false } + if recordingAutoZoom != before { onPersistentPreferencesChange?() } + return true } var saveFolderDisplayName: String { FileManager.default.displayName(atPath: saveFolder.path) } diff --git a/Plugins/Screenshot/Sources/ScreenshotPlugin.swift b/Plugins/Screenshot/Sources/ScreenshotPlugin.swift index 06b2276a9..f8614b660 100644 --- a/Plugins/Screenshot/Sources/ScreenshotPlugin.swift +++ b/Plugins/Screenshot/Sources/ScreenshotPlugin.swift @@ -367,3 +367,21 @@ final class ScreenshotPlugin: MacToolsPlugin, PluginActionProviding, PluginActio return panel.runModal() == .OK ? panel.url : nil } } + +// MARK: - Portable preferences + +extension ScreenshotPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { environment.onPersistentPreferencesChange } + set { environment.onPersistentPreferencesChange = newValue } + } + + func makePortablePreferencesBackup() -> Data? { environment.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard environment.restorePortableBackup(data) else { return false } + onStateChange?() + return true + } +} diff --git a/Plugins/Screenshot/Tests/ScreenshotPluginTests.swift b/Plugins/Screenshot/Tests/ScreenshotPluginTests.swift index 47a43b5b0..2fb01dbf7 100644 --- a/Plugins/Screenshot/Tests/ScreenshotPluginTests.swift +++ b/Plugins/Screenshot/Tests/ScreenshotPluginTests.swift @@ -6,6 +6,18 @@ import XCTest @MainActor final class ScreenshotPluginTests: XCTestCase { + func testPortableRecordingPreferenceKeepsDestinationFolder() throws { + let source = ScreenshotEnvironment(storage: ScreenshotTestStorage(), localization: PluginLocalization(bundle: .main)) + source.recordingAutoZoom = false + let storage = ScreenshotTestStorage() + storage.set("/tmp/destination-captures", forKey: "saveFolder") + let destination = ScreenshotEnvironment(storage: storage, localization: PluginLocalization(bundle: .main)) + XCTAssertTrue(destination.restorePortableBackup(try XCTUnwrap(source.portableBackup()))) + XCTAssertFalse(destination.recordingAutoZoom) + XCTAssertEqual(storage.string(forKey: "saveFolder"), "/tmp/destination-captures") + XCTAssertEqual(destination.presentationGeneration, 0) + } + private var permissionGranted = true func testHostPanelActionsShortcutsAndPermissionContracts() { let plugin = makePlugin() diff --git a/Plugins/Screenshot/plugin.json b/Plugins/Screenshot/plugin.json index e584c01b7..f5faead50 100644 --- a/Plugins/Screenshot/plugin.json +++ b/Plugins/Screenshot/plugin.json @@ -384,5 +384,12 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "private-content", + "device-state" + ] } } diff --git a/Plugins/Screenshot/project.yml b/Plugins/Screenshot/project.yml index f5bc30ade..dacb96728 100644 --- a/Plugins/Screenshot/project.yml +++ b/Plugins/Screenshot/project.yml @@ -1,3 +1,7 @@ settings: base: OTHER_LDFLAGS: -framework ScreenCaptureKit -framework Vision -framework CoreImage -framework UniformTypeIdentifiers -framework QuartzCore -framework VideoToolbox + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/Sidecar/plugin.json b/Plugins/Sidecar/plugin.json index 614ac16aa..82821d33d 100644 --- a/Plugins/Sidecar/plugin.json +++ b/Plugins/Sidecar/plugin.json @@ -319,5 +319,11 @@ ] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "device-state" + ] } } diff --git a/Plugins/Siri/plugin.json b/Plugins/Siri/plugin.json index 307d025fa..cf93bacf4 100644 --- a/Plugins/Siri/plugin.json +++ b/Plugins/Siri/plugin.json @@ -244,5 +244,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/StageManager/plugin.json b/Plugins/StageManager/plugin.json index 668ea723a..a7ed76150 100644 --- a/Plugins/StageManager/plugin.json +++ b/Plugins/StageManager/plugin.json @@ -266,5 +266,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/StorageExplorer/plugin.json b/Plugins/StorageExplorer/plugin.json index 691aa2170..d0dd054a0 100644 --- a/Plugins/StorageExplorer/plugin.json +++ b/Plugins/StorageExplorer/plugin.json @@ -253,5 +253,9 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/SystemData/Sources/SystemDataDisplayPreferences.swift b/Plugins/SystemData/Sources/SystemDataDisplayPreferences.swift index 54398aa9e..e4abdece0 100644 --- a/Plugins/SystemData/Sources/SystemDataDisplayPreferences.swift +++ b/Plugins/SystemData/Sources/SystemDataDisplayPreferences.swift @@ -11,13 +11,34 @@ final class SystemDataDisplayPreferences: ObservableObject { @Published var showAllItems: Bool { didSet { - guard isAttached, let storage else { return } - storage.set(showAllItems, forKey: Self.showAllItemsKey) + guard isAttached, showAllItems != oldValue, let portableStore else { return } + guard portableStore.commit([Self.showAllItemsKey: .boolean(showAllItems)]) else { + isAttached = false + showAllItems = oldValue + isAttached = true + return + } + onPersistentPreferencesChange?() } } private var storage: PluginStorage? private var isAttached = false + var onPersistentPreferencesChange: (() -> Void)? + private var portableStore: PortablePreferencesStore? { + storage.map { PortablePreferencesStore(storage: $0, fields: [Self.showAllItemsKey: .boolean(false)]) } + } + func portableBackup() -> Data? { portableStore?.backup() } + func restorePortableBackup(_ data: Data) -> Bool { + guard let storage, let portableStore else { return false } + let before = showAllItems + guard portableStore.restore(data) else { return false } + isAttached = false + showAllItems = storage.object(forKey: Self.showAllItemsKey) as? Bool ?? false + isAttached = true + if before != showAllItems { onPersistentPreferencesChange?() } + return true + } init() { self.showAllItems = false @@ -27,6 +48,8 @@ final class SystemDataDisplayPreferences: ObservableObject { /// a missing key keeps the default (off) and is not written back. func attach(storage: PluginStorage) { self.storage = storage + _ = portableStore?.recover() + isAttached = false showAllItems = storage.bool(forKey: Self.showAllItemsKey) isAttached = true } diff --git a/Plugins/SystemData/Sources/SystemDataPlugin.swift b/Plugins/SystemData/Sources/SystemDataPlugin.swift index 6f701ca6e..2e16c610e 100644 --- a/Plugins/SystemData/Sources/SystemDataPlugin.swift +++ b/Plugins/SystemData/Sources/SystemDataPlugin.swift @@ -16,7 +16,7 @@ private struct SystemDataPluginProvider: PluginProvider { func makePlugins() -> [any MacToolsPlugin] { let localization = PluginLocalization(bundle: context.resourceBundle) let controller = SystemDataController(scanner: SystemDataScanner()) - return [SystemDataPlugin(controller: controller, localization: localization)] + return [SystemDataPlugin(controller: controller, localization: localization, storage: context.storage)] } } @@ -45,7 +45,8 @@ public final class SystemDataPlugin: MacToolsPlugin, PluginSettingsPresenting { static let widget = "overview" } - init(controller: SystemDataController, localization: PluginLocalization) { + init(controller: SystemDataController, localization: PluginLocalization, storage: (any PluginStorage)? = nil) { + if let storage { preferences.attach(storage: storage) } self.controller = controller self.localization = localization self.metadata = PluginMetadata( @@ -318,3 +319,21 @@ public final class SystemDataPlugin: MacToolsPlugin, PluginSettingsPresenting { controller.cancel() } } + +// MARK: - Portable preferences + +extension SystemDataPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + public var onPersistentPreferencesChange: (() -> Void)? { + get { preferences.onPersistentPreferencesChange } + set { preferences.onPersistentPreferencesChange = newValue } + } + + public func makePortablePreferencesBackup() -> Data? { preferences.portableBackup() } + public func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + public func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard preferences.restorePortableBackup(data) else { return false } + onStateChange?() + return true + } +} diff --git a/Plugins/SystemData/plugin.json b/Plugins/SystemData/plugin.json index 0c53f85e8..bb4e160e9 100644 --- a/Plugins/SystemData/plugin.json +++ b/Plugins/SystemData/plugin.json @@ -215,7 +215,9 @@ "filesystem-paths", "file-sizes" ], - "dataPersisted": [], + "dataPersisted": [ + "plugin-configuration" + ], "retention": { "policy": "none" }, @@ -238,5 +240,11 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "history" + ] } } diff --git a/Plugins/SystemData/project.yml b/Plugins/SystemData/project.yml index c55a8d903..c9ad2d364 100644 --- a/Plugins/SystemData/project.yml +++ b/Plugins/SystemData/project.yml @@ -1,4 +1,6 @@ core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift dependencies: - target: MacToolsFileSystem link: false diff --git a/Plugins/SystemMute/plugin.json b/Plugins/SystemMute/plugin.json index 06b36030c..d9ba82a2d 100644 --- a/Plugins/SystemMute/plugin.json +++ b/Plugins/SystemMute/plugin.json @@ -264,5 +264,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/SystemPower/plugin.json b/Plugins/SystemPower/plugin.json index 0bda91e0e..1cb6ac048 100644 --- a/Plugins/SystemPower/plugin.json +++ b/Plugins/SystemPower/plugin.json @@ -321,5 +321,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/SystemSoftRestart/Sources/SystemSoftRestartPlugin.swift b/Plugins/SystemSoftRestart/Sources/SystemSoftRestartPlugin.swift index aebadad53..159850f12 100644 --- a/Plugins/SystemSoftRestart/Sources/SystemSoftRestartPlugin.swift +++ b/Plugins/SystemSoftRestart/Sources/SystemSoftRestartPlugin.swift @@ -98,6 +98,7 @@ final class SystemSoftRestartPlugin: private let localization: PluginLocalization private let applicationURLProvider: () -> [URL] private var isExecuting = false + var onPersistentPreferencesChange: (() -> Void)? private var currentPhase: SystemSoftRestartPhase? private var lastErrorMessage: String? private var executionTask: Task? @@ -110,6 +111,7 @@ final class SystemSoftRestartPlugin: applicationURLProvider: (() -> [URL])? = nil ) { self.storage = storage + _ = Self.portableStore(storage: storage).recover() self.runner = runner self.presenter = presenter self.localization = localization @@ -289,9 +291,11 @@ final class SystemSoftRestartPlugin: switch controlID { case ControlID.reopensApplications: - storage.set(value, forKey: StorageKey.reopensApplications) + guard portableStore.commit([StorageKey.reopensApplications: .boolean(value)]) else { return } + onPersistentPreferencesChange?() case ControlID.preservesDockLayout: - storage.set(value, forKey: StorageKey.preservesDockLayout) + guard portableStore.commit([StorageKey.preservesDockLayout: .boolean(value)]) else { return } + onPersistentPreferencesChange?() default: return } @@ -424,3 +428,23 @@ final class SystemSoftRestartPlugin: } } } + +extension SystemSoftRestartPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + StorageKey.reopensApplications: .boolean(true), StorageKey.preservesDockLayout: .boolean(true) + ]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + func makePortablePreferencesBackup() -> Data? { portableStore.backup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard !isExecuting else { return false } + let previous = portableStore.currentValues() + guard portableStore.restore(data) else { return false } + if previous != portableStore.currentValues() { onPersistentPreferencesChange?() } + onStateChange?() + return true + } +} diff --git a/Plugins/SystemSoftRestart/plugin.json b/Plugins/SystemSoftRestart/plugin.json index c767ae4dc..0e8552f37 100644 --- a/Plugins/SystemSoftRestart/plugin.json +++ b/Plugins/SystemSoftRestart/plugin.json @@ -283,5 +283,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "portable", + "exclusions": [] } } diff --git a/Plugins/SystemSoftRestart/project.yml b/Plugins/SystemSoftRestart/project.yml index e03966e2a..c4ffdc83a 100644 --- a/Plugins/SystemSoftRestart/project.yml +++ b/Plugins/SystemSoftRestart/project.yml @@ -18,3 +18,7 @@ targets: CODE_SIGN_STYLE: Automatic DEVELOPMENT_TEAM: "$(DEVELOPMENT_TEAM)" OTHER_LDFLAGS: -framework AppKit + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/SystemStatus/plugin.json b/Plugins/SystemStatus/plugin.json index 18c3906ad..d513d687d 100644 --- a/Plugins/SystemStatus/plugin.json +++ b/Plugins/SystemStatus/plugin.json @@ -323,5 +323,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "portable", + "exclusions": [] } } diff --git a/Plugins/TrackpadGestures/Sources/TrackpadGestureModels.swift b/Plugins/TrackpadGestures/Sources/TrackpadGestureModels.swift index 44b92928d..1e95d485e 100644 --- a/Plugins/TrackpadGestures/Sources/TrackpadGestureModels.swift +++ b/Plugins/TrackpadGestures/Sources/TrackpadGestureModels.swift @@ -27,20 +27,20 @@ extension TrackpadGesture { } -enum TrackpadGestureMappingSort: String, CaseIterable, Sendable { +enum TrackpadGestureMappingSort: String, Codable, CaseIterable, Sendable { case gesture case enabledFirst case actionName case addedOrder } -enum TrackpadGestureMappingStatusFilter: String, CaseIterable, Sendable { +enum TrackpadGestureMappingStatusFilter: String, Codable, CaseIterable, Sendable { case all case enabled case disabled } -enum TrackpadGestureMappingActionFilter: String, CaseIterable, Sendable { +enum TrackpadGestureMappingActionFilter: String, Codable, CaseIterable, Sendable { case all case macToolsAction case keyboardShortcut @@ -150,12 +150,19 @@ final class TrackpadGestureStore: ObservableObject { let mappings: [TrackpadGestureMapping] let ignoresGesturesWhileTyping: Bool let typingGracePeriod: TimeInterval + var mappingSort: TrackpadGestureMappingSort? + var mappingStatusFilter: TrackpadGestureMappingStatusFilter? + var mappingActionFilter: TrackpadGestureMappingActionFilter? } private struct PortableRestoreTransaction: Codable { let mappings: Data? let ignoresGesturesWhileTyping: Bool? let typingGracePeriod: TimeInterval? + var includesViewPreferences: Bool? + var mappingSort: String? + var mappingStatusFilter: String? + var mappingActionFilter: String? } private static let portableBackupFormatVersion = 1 @@ -187,6 +194,7 @@ final class TrackpadGestureStore: ObservableObject { @Published private(set) var mappingActionFilter: TrackpadGestureMappingActionFilter private(set) var didPersistPortablePreferencesDuringInitialization = false + var onViewPreferencesPersisted: (() -> Void)? private let storage: any PluginStorage private let encoder = JSONEncoder() @@ -371,20 +379,38 @@ final class TrackpadGestureStore: ObservableObject { func setMappingSort(_ sort: TrackpadGestureMappingSort) { guard mappingSort != sort else { return } - mappingSort = sort + let previous = storage.object(forKey: Key.mappingSort) storage.set(sort.rawValue, forKey: Key.mappingSort) + guard storage.string(forKey: Key.mappingSort) == sort.rawValue else { + Self.setOptional(previous, forKey: Key.mappingSort, storage: storage) + return + } + mappingSort = sort + onViewPreferencesPersisted?() } func setMappingStatusFilter(_ filter: TrackpadGestureMappingStatusFilter) { guard mappingStatusFilter != filter else { return } - mappingStatusFilter = filter + let previous = storage.object(forKey: Key.mappingStatusFilter) storage.set(filter.rawValue, forKey: Key.mappingStatusFilter) + guard storage.string(forKey: Key.mappingStatusFilter) == filter.rawValue else { + Self.setOptional(previous, forKey: Key.mappingStatusFilter, storage: storage) + return + } + mappingStatusFilter = filter + onViewPreferencesPersisted?() } func setMappingActionFilter(_ filter: TrackpadGestureMappingActionFilter) { guard mappingActionFilter != filter else { return } - mappingActionFilter = filter + let previous = storage.object(forKey: Key.mappingActionFilter) storage.set(filter.rawValue, forKey: Key.mappingActionFilter) + guard storage.string(forKey: Key.mappingActionFilter) == filter.rawValue else { + Self.setOptional(previous, forKey: Key.mappingActionFilter, storage: storage) + return + } + mappingActionFilter = filter + onViewPreferencesPersisted?() } func resetMappingViewPreferences() { @@ -421,7 +447,8 @@ final class TrackpadGestureStore: ObservableObject { return context?.canExport(reference) ?? true }, ignoresGesturesWhileTyping: ignoresGesturesWhileTyping, - typingGracePeriod: typingGracePeriod + typingGracePeriod: typingGracePeriod, + mappingSort: mappingSort, mappingStatusFilter: mappingStatusFilter, mappingActionFilter: mappingActionFilter ) guard let data = try? encoder.encode(backup), data.count <= Self.maximumPortableBackupByteCount else { @@ -462,7 +489,10 @@ final class TrackpadGestureStore: ObservableObject { let gracePeriod = TrackpadTypingSuppressionGate.clamped(backup.typingGracePeriod) if backup.mappings == mappings, backup.ignoresGesturesWhileTyping == ignoresGesturesWhileTyping, - gracePeriod == typingGracePeriod { + gracePeriod == typingGracePeriod, + (backup.mappingSort ?? mappingSort) == mappingSort, + (backup.mappingStatusFilter ?? mappingStatusFilter) == mappingStatusFilter, + (backup.mappingActionFilter ?? mappingActionFilter) == mappingActionFilter { return true } guard recoverInterruptedPortableRestore(), @@ -470,7 +500,10 @@ final class TrackpadGestureStore: ObservableObject { writePortableValues( mappings: mappingData, ignoresGesturesWhileTyping: backup.ignoresGesturesWhileTyping, - typingGracePeriod: gracePeriod + typingGracePeriod: gracePeriod, + mappingSort: (backup.mappingSort ?? mappingSort).rawValue, + mappingStatusFilter: (backup.mappingStatusFilter ?? mappingStatusFilter).rawValue, + mappingActionFilter: (backup.mappingActionFilter ?? mappingActionFilter).rawValue ), finishPortableRestoreTransaction() else { rollbackPortableRestoreTransaction() @@ -479,6 +512,9 @@ final class TrackpadGestureStore: ObservableObject { mappings = backup.mappings ignoresGesturesWhileTyping = backup.ignoresGesturesWhileTyping typingGracePeriod = gracePeriod + mappingSort = backup.mappingSort ?? mappingSort + mappingStatusFilter = backup.mappingStatusFilter ?? mappingStatusFilter + mappingActionFilter = backup.mappingActionFilter ?? mappingActionFilter return true } @@ -497,7 +533,11 @@ final class TrackpadGestureStore: ObservableObject { typingGracePeriod: Self.storedDouble( forKey: Key.typingGracePeriod, storage: storage - ) + ), + includesViewPreferences: true, + mappingSort: storage.string(forKey: Key.mappingSort), + mappingStatusFilter: storage.string(forKey: Key.mappingStatusFilter), + mappingActionFilter: storage.string(forKey: Key.mappingActionFilter) ) guard let data = try? encoder.encode(transaction) else { return false } storage.set(data, forKey: Key.portableRestoreTransaction) @@ -536,13 +576,16 @@ final class TrackpadGestureStore: ObservableObject { private func writePortableValues( mappings: Data?, ignoresGesturesWhileTyping: Bool?, - typingGracePeriod: TimeInterval? + typingGracePeriod: TimeInterval?, + mappingSort: String?, mappingStatusFilter: String?, mappingActionFilter: String? ) -> Bool { Self.writePortableValues( PortableRestoreTransaction( mappings: mappings, ignoresGesturesWhileTyping: ignoresGesturesWhileTyping, - typingGracePeriod: typingGracePeriod + typingGracePeriod: typingGracePeriod, + includesViewPreferences: true, + mappingSort: mappingSort, mappingStatusFilter: mappingStatusFilter, mappingActionFilter: mappingActionFilter ), storage: storage ) @@ -563,6 +606,14 @@ final class TrackpadGestureStore: ObservableObject { forKey: Key.typingGracePeriod, storage: storage ) + if values.includesViewPreferences == true { + setOptional(values.mappingSort, forKey: Key.mappingSort, storage: storage) + setOptional(values.mappingStatusFilter, forKey: Key.mappingStatusFilter, storage: storage) + setOptional(values.mappingActionFilter, forKey: Key.mappingActionFilter, storage: storage) + guard storage.string(forKey: Key.mappingSort) == values.mappingSort, + storage.string(forKey: Key.mappingStatusFilter) == values.mappingStatusFilter, + storage.string(forKey: Key.mappingActionFilter) == values.mappingActionFilter else { return false } + } return storage.data(forKey: Key.mappings) == values.mappings && storedBool(forKey: Key.ignoreWhileTyping, storage: storage) == values.ignoresGesturesWhileTyping diff --git a/Plugins/TrackpadGestures/Sources/TrackpadGesturesPlugin.swift b/Plugins/TrackpadGestures/Sources/TrackpadGesturesPlugin.swift index 262e58495..194520b35 100644 --- a/Plugins/TrackpadGestures/Sources/TrackpadGesturesPlugin.swift +++ b/Plugins/TrackpadGestures/Sources/TrackpadGesturesPlugin.swift @@ -210,6 +210,10 @@ final class TrackpadGesturesPlugin: MacToolsPlugin, TrackpadInputServiceConsumin resolvedLocalization.string("panel.button.settings", defaultValue: "设置") } ) + store.onViewPreferencesPersisted = { [weak self] in + self?.persistentPreferencesChanges.didPersist() + self?.onStateChange?() + } if store.didPersistPortablePreferencesDuringInitialization { persistentPreferencesChanges.didPersist() } @@ -807,6 +811,7 @@ final class TrackpadGesturesPlugin: MacToolsPlugin, TrackpadInputServiceConsumin } private func restorePortablePreferencesAndNotify(from data: Data) -> Bool { + let previousView = [store.mappingSort.rawValue, store.mappingStatusFilter.rawValue, store.mappingActionFilter.rawValue] let previousMappings = store.mappings let previousIgnoresGesturesWhileTyping = store.ignoresGesturesWhileTyping let previousTypingGracePeriod = store.typingGracePeriod @@ -817,6 +822,9 @@ final class TrackpadGesturesPlugin: MacToolsPlugin, TrackpadInputServiceConsumin || store.ignoresGesturesWhileTyping != previousIgnoresGesturesWhileTyping || store.typingGracePeriod != previousTypingGracePeriod { configurationDidChange() + } else if previousView != [store.mappingSort.rawValue, store.mappingStatusFilter.rawValue, store.mappingActionFilter.rawValue] { + persistentPreferencesChanges.didPersist() + onStateChange?() } return true } diff --git a/Plugins/TrackpadGestures/Tests/TrackpadGesturesPluginTests.swift b/Plugins/TrackpadGestures/Tests/TrackpadGesturesPluginTests.swift index 8a39c2571..020951c60 100644 --- a/Plugins/TrackpadGestures/Tests/TrackpadGesturesPluginTests.swift +++ b/Plugins/TrackpadGestures/Tests/TrackpadGesturesPluginTests.swift @@ -199,6 +199,24 @@ private final class MockMultitouchFrameListener: MultitouchFrameListening { @MainActor final class TrackpadGestureStoreTests: XCTestCase { + func testPortableViewPreferencesRoundTripAndLegacyPayloadPreservesDestination() throws { + let source = TrackpadGestureStore(storage: TrackpadGestureMemoryStorage(), legacyMiddleClick: nil) + source.setMappingSort(.addedOrder) + source.setMappingStatusFilter(.disabled) + source.setMappingActionFilter(.singleKey) + let destination = TrackpadGestureStore(storage: TrackpadGestureMemoryStorage(), legacyMiddleClick: nil) + let data = try XCTUnwrap(source.portableBackup()) + XCTAssertTrue(destination.restorePortableBackup(data)) + XCTAssertEqual(destination.mappingSort, .addedOrder) + XCTAssertEqual(destination.mappingStatusFilter, .disabled) + XCTAssertEqual(destination.mappingActionFilter, .singleKey) + var legacy = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + for key in ["mappingSort", "mappingStatusFilter", "mappingActionFilter"] { legacy.removeValue(forKey: key) } + destination.setMappingSort(.actionName) + XCTAssertTrue(destination.restorePortableBackup(try JSONSerialization.data(withJSONObject: legacy))) + XCTAssertEqual(destination.mappingSort, .actionName) + } + func testAddEditToggleDeleteAndPersistence() throws { let storage = TrackpadGestureMemoryStorage() diff --git a/Plugins/TrackpadGestures/plugin.json b/Plugins/TrackpadGestures/plugin.json index 71bf83ab4..93cb9093c 100644 --- a/Plugins/TrackpadGestures/plugin.json +++ b/Plugins/TrackpadGestures/plugin.json @@ -217,5 +217,11 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "permissions" + ] } } diff --git a/Plugins/TrackpadScale/Sources/TrackpadScaleModel.swift b/Plugins/TrackpadScale/Sources/TrackpadScaleModel.swift index 49ba97375..143b77aac 100644 --- a/Plugins/TrackpadScale/Sources/TrackpadScaleModel.swift +++ b/Plugins/TrackpadScale/Sources/TrackpadScaleModel.swift @@ -106,6 +106,7 @@ final class TrackpadScaleModel: ObservableObject { init(storage: any PluginStorage, schedule: @escaping TrackpadScaleMailbox.Scheduler = TrackpadScaleMailbox.defaultScheduler) { self.storage = storage + _ = Self.portableStore(storage: storage).recover() self.schedule = schedule autoCaptureEnabled = storage.object(forKey: StorageKey.autoCaptureEnabled) as? Bool ?? true calibrationFactors = (storage.object(forKey: "calibration-factors.v1") as? [String: Double] ?? [:]) @@ -185,10 +186,26 @@ final class TrackpadScaleModel: ObservableObject { start() } + var onPersistentPreferencesChange: (() -> Void)? + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [StorageKey.autoCaptureEnabled: .boolean(true)]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + func portableBackup() -> Data? { portableStore.backup() } + func restorePortableBackup(_ data: Data) -> Bool { + let before = autoCaptureEnabled + guard portableStore.restore(data) else { return false } + autoCaptureEnabled = storage.object(forKey: StorageKey.autoCaptureEnabled) as? Bool ?? true + measurement.resetCapture() + if autoCaptureEnabled != before { onPersistentPreferencesChange?() } + return true + } + func setAutoCaptureEnabled(_ enabled: Bool) { guard enabled != autoCaptureEnabled else { return } + guard portableStore.commit([StorageKey.autoCaptureEnabled: .boolean(enabled)]) else { return } autoCaptureEnabled = enabled - storage.set(enabled, forKey: StorageKey.autoCaptureEnabled) + onPersistentPreferencesChange?() measurement.resetCapture() } diff --git a/Plugins/TrackpadScale/Sources/TrackpadScalePlugin.swift b/Plugins/TrackpadScale/Sources/TrackpadScalePlugin.swift index bf40bb966..35d59eed6 100644 --- a/Plugins/TrackpadScale/Sources/TrackpadScalePlugin.swift +++ b/Plugins/TrackpadScale/Sources/TrackpadScalePlugin.swift @@ -77,3 +77,21 @@ final class TrackpadScalePlugin: MacToolsPlugin, PluginSettingsPresenting, } func deactivate(reason: PluginDeactivationReason) { model.stop() } } + +// MARK: - Portable preferences + +extension TrackpadScalePlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { model.onPersistentPreferencesChange } + set { model.onPersistentPreferencesChange = newValue } + } + + func makePortablePreferencesBackup() -> Data? { model.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard model.restorePortableBackup(data) else { return false } + onStateChange?() + return true + } +} diff --git a/Plugins/TrackpadScale/Tests/TrackpadScaleModelTests.swift b/Plugins/TrackpadScale/Tests/TrackpadScaleModelTests.swift index a15bc589d..dbac06c2b 100644 --- a/Plugins/TrackpadScale/Tests/TrackpadScaleModelTests.swift +++ b/Plugins/TrackpadScale/Tests/TrackpadScaleModelTests.swift @@ -43,6 +43,21 @@ private final class ScaleTestService: TrackpadInputService { @MainActor final class TrackpadScaleModelTests: XCTestCase { + func testPortablePreferenceDoesNotStartCaptureOrReplaceCalibration() throws { + let source = TrackpadScaleModel(storage: ScaleTestStorage()) + source.setAutoCaptureEnabled(false) + let storage = ScaleTestStorage() + storage.set(["1": 2.0], forKey: "calibration-factors.v1") + let destination = TrackpadScaleModel(storage: storage) + let service = ScaleTestService() + destination.setService(service) + XCTAssertTrue(destination.restorePortableBackup(try XCTUnwrap(source.portableBackup()))) + XCTAssertFalse(destination.autoCaptureEnabled) + XCTAssertFalse(destination.isRunning) + XCTAssertTrue(service.handlers.isEmpty) + XCTAssertEqual(storage.object(forKey: "calibration-factors.v1") as? [String: Double], ["1": 2.0]) + } + private func frame(_ pressure: Double, time: Double, contactID: Int = 1, deviceID: UInt64 = 1) -> TrackpadSensorFrame { TrackpadSensorFrame(deviceID: deviceID, timestamp: time, contacts: [TrackpadSensorContact(identifier: contactID, x: 0.5, y: 0.5, pressure: pressure)]) diff --git a/Plugins/TrackpadScale/plugin.json b/Plugins/TrackpadScale/plugin.json index 7605ae42f..024723e69 100644 --- a/Plugins/TrackpadScale/plugin.json +++ b/Plugins/TrackpadScale/plugin.json @@ -194,5 +194,11 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "device-state" + ] } } diff --git a/Plugins/TrackpadScale/project.yml b/Plugins/TrackpadScale/project.yml new file mode 100644 index 000000000..5a47746a5 --- /dev/null +++ b/Plugins/TrackpadScale/project.yml @@ -0,0 +1,4 @@ + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/Translator/Resources/Localizable.xcstrings b/Plugins/Translator/Resources/Localizable.xcstrings index 23aa0c661..fbfecad3c 100644 --- a/Plugins/Translator/Resources/Localizable.xcstrings +++ b/Plugins/Translator/Resources/Localizable.xcstrings @@ -9856,6 +9856,234 @@ } } } + }, + "settings.backup.title": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preferences Backup" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "偏好设置备份" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "偏好設定備份" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copia de ajustes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sauvegarde des réglages" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Резервная копия настроек" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Backup dos ajustes" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungssicherung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定のバックアップ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정 백업" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نسخ الإعدادات احتياطيًا" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ayar Yedekleme" + } + } + } + }, + "settings.backup.includePromptText": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Include custom prompt text in backups" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将自定义提示词包含在备份中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將自訂提示詞包含在備份中" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Incluir textos de instrucciones personalizados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Inclure les instructions personnalisées" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включать текст собственных инструкций" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Incluir textos de instruções personalizados" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eigene Prompt-Texte einschließen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタムプロンプトをバックアップに含める" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 프롬프트 포함" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تضمين نصوص التعليمات المخصصة" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Özel istem metnini yedeklere dahil et" + } + } + } + }, + "settings.backup.scope": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Applies to manual backups, automatic backups, and cloud sync. API keys stay on this Mac. Imported services need local key setup." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "同时用于手动备份、自动备份和云同步。API 密钥保留在本机,导入的服务需在本机配置密钥。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "適用於手動備份、自動備份和雲端同步。API 金鑰保留在本機,匯入的服務須在本機設定金鑰。" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se aplica a copias manuales, automáticas y a la sincronización. Las claves API siguen en este Mac; configura las de los servicios importados." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "S’applique aux sauvegardes manuelles, automatiques et à la synchronisation. Les clés API restent sur ce Mac ; configurez celles des services importés." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Для ручных и автоматических копий и облачной синхронизации. Ключи API остаются на Mac; для импортированных служб настройте ключ локально." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Aplica-se a backups manuais, automáticos e à sincronização. As chaves API ficam neste Mac; configure as dos serviços importados." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gilt für manuelle und automatische Sicherungen sowie Cloud-Synchronisierung. API-Schlüssel bleiben lokal; importierte Dienste benötigen eine lokale Schlüsseleinrichtung." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "手動・自動バックアップとクラウド同期に適用します。APIキーはこのMacに保持されます。読み込んだサービスはキーの設定が必要です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수동·자동 백업과 클라우드 동기화에 적용됩니다. API 키는 이 Mac에 유지되며 가져온 서비스는 로컬 키 설정이 필요합니다." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ينطبق على النسخ اليدوي والتلقائي والمزامنة. تبقى مفاتيح API على هذا الـ Mac؛ تحتاج الخدمات المستوردة إلى إعداد مفتاح محلي." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Manuel ve otomatik yedekler ile bulut eşzamanlama için geçerlidir. API anahtarları bu Mac’te kalır; içe aktarılan hizmetlerde yerel anahtar kurulumu gerekir." + } + } + } } }, "version": "1.0" diff --git a/Plugins/Translator/Sources/Settings/TranslatorProviderProfile.swift b/Plugins/Translator/Sources/Settings/TranslatorProviderProfile.swift index 4090f8e48..b179d4098 100644 --- a/Plugins/Translator/Sources/Settings/TranslatorProviderProfile.swift +++ b/Plugins/Translator/Sources/Settings/TranslatorProviderProfile.swift @@ -118,7 +118,13 @@ struct TranslatorProviderProfileStore { func saveProfiles(_ profiles: [TranslatorProviderProfile]) throws { let normalizedProfiles = profiles.map { $0.normalized() } let data = try JSONEncoder().encode(normalizedProfiles) + let previous = storage.object(forKey: TranslatorConstants.StorageKey.providerProfiles) storage.set(data, forKey: TranslatorConstants.StorageKey.providerProfiles) + guard storage.data(forKey: TranslatorConstants.StorageKey.providerProfiles) == data else { + if let previous { storage.set(previous, forKey: TranslatorConstants.StorageKey.providerProfiles) } + else { storage.removeObject(forKey: TranslatorConstants.StorageKey.providerProfiles) } + throw CocoaError(.fileWriteUnknown) + } } func makeNewProfile(existingProfiles: [TranslatorProviderProfile]) -> TranslatorProviderProfile { diff --git a/Plugins/Translator/Sources/Settings/TranslatorSettingsView.swift b/Plugins/Translator/Sources/Settings/TranslatorSettingsView.swift index 7828eafa0..d41b1ae99 100644 --- a/Plugins/Translator/Sources/Settings/TranslatorSettingsView.swift +++ b/Plugins/Translator/Sources/Settings/TranslatorSettingsView.swift @@ -12,6 +12,7 @@ struct TranslatorSettingsView: View { @State private var messageIsError = false private let localization: PluginLocalization + private let languagePair: TranslatorLanguagePair private let onSave: ([TranslatorProviderProfile], [String: String], TranslatorLanguagePair) -> String? private let onMakeNewProfile: ([TranslatorProviderProfile]) -> TranslatorProviderProfile @@ -30,6 +31,7 @@ struct TranslatorSettingsView: View { _savedLanguagePair = State(initialValue: languagePair) _selectedProfileID = State(initialValue: profiles.first?.id ?? TranslatorProviderProfile.defaultID) self.localization = localization + self.languagePair = languagePair self.onSave = onSave self.onMakeNewProfile = onMakeNewProfile } @@ -41,6 +43,13 @@ struct TranslatorSettingsView: View { providerDetailSection actions } + .onChange(of: languagePair) { _, pair in + firstLanguage = pair.first + secondLanguage = pair.second + savedLanguagePair = pair + message = nil + messageIsError = false + } } private var languageSection: some View { diff --git a/Plugins/Translator/Sources/TranslatorPlugin.swift b/Plugins/Translator/Sources/TranslatorPlugin.swift index 0323c16a3..ab55b5dc4 100644 --- a/Plugins/Translator/Sources/TranslatorPlugin.swift +++ b/Plugins/Translator/Sources/TranslatorPlugin.swift @@ -72,7 +72,10 @@ final class TranslatorPlugin: var shortcutBindingResolver: ((String) -> ShortcutBinding?)? var requestSettingsPresentation: (() -> Void)? + var onPersistentPreferencesChange: (() -> Void)? private let storage: PluginStorage + private var providerEditorRevision = 0 + private var credentialAuthorization: PortableCredentialAuthorization { .init(storage: storage) } private let accessibilityTrustProvider: () -> Bool private let accessibilityTrustRequester: (Bool) -> Bool private let screenRecordingPermissionProvider: () -> Bool @@ -117,6 +120,7 @@ final class TranslatorPlugin: let localization = localization ?? PluginLocalization(bundle: context.resourceBundle) self.localization = localization self.storage = context.storage + _ = Self.portableStore(storage: context.storage).recover() self.accessibilityTrustProvider = accessibilityTrustProvider self.accessibilityTrustRequester = accessibilityTrustRequester self.screenRecordingPermissionProvider = screenRecordingPermissionProvider @@ -135,6 +139,11 @@ final class TranslatorPlugin: let providerProfiles = providerProfileStore.loadProfiles() self.providerProfileStore = providerProfileStore self.providerProfiles = providerProfiles + if !PortableCredentialAuthorization(storage: context.storage).seedIfNeeded( + profiles: Dictionary(providerProfiles.map { ($0.id, $0.baseURL) }, uniquingKeysWith: { first, _ in first }), + store: Self.portableStore(storage: context.storage)) { + TranslatorLog.provider.error("Could not initialize local credential authorization.") + } let languagePreferenceStore = LanguagePreferenceStore(storage: context.storage) self.languagePreferenceStore = languagePreferenceStore self.providerConfiguration = providerProfiles.first?.configuration @@ -324,6 +333,7 @@ final class TranslatorPlugin: var settingsPage: PluginSettingsPage? { .form(description: metadata.defaultDescription, sections: [ + promptBackupSection, PluginSettingsSection( id: "translator-settings", title: localization.string("settings.title", defaultValue: "翻译设置"), @@ -333,7 +343,7 @@ final class TranslatorPlugin: if let self { TranslatorSettingsView( profiles: self.providerProfiles, - apiKeys: self.cachedAPIKeys, + apiKeys: [:], languagePair: self.languagePair, localization: self.localization, onSave: { [weak self] profiles, apiKeys, languagePair in @@ -352,6 +362,7 @@ final class TranslatorPlugin: ) } ) + .id(self.providerEditorRevision) } } ]) @@ -362,7 +373,9 @@ final class TranslatorPlugin: return } - storage.set(isEnabled, forKey: TranslatorConstants.StorageKey.shortcutEnabled) + guard isEnabled != isShortcutEnabled, + portableStore.commit([TranslatorConstants.StorageKey.shortcutEnabled: .boolean(isEnabled)]) else { return } + onPersistentPreferencesChange?() onStateChange?() } @@ -424,7 +437,10 @@ final class TranslatorPlugin: } } - func handleSettingsAction(_ action: PluginSettingsAction) {} + func handleSettingsAction(_ action: PluginSettingsAction) { + guard case let .setBoolean(controlID, value) = action, controlID == Self.promptBackupKey else { return } + setIncludesPromptText(value) + } func deactivate(reason: PluginDeactivationReason) { guard reason.requiresStateCleanup else { @@ -558,50 +574,43 @@ final class TranslatorPlugin: } do { - // Compute removed profiles before saving because `providerProfiles` still reflects - // the persisted state at this point. - let retainedProfileIDs = Set(profiles.map(\.id)) - let removedProfileIDs = providerProfiles - .map(\.id) - .filter { !retainedProfileIDs.contains($0) } - - for profile in profiles where profile.isEnabled { - let apiKey = apiKeys[profile.id] - if let normalizedAPIKey = Self.normalizedAPIKey(apiKey) { - try secretStore.saveAPIKey(normalizedAPIKey, forProfileID: profile.id) - cachedAPIKeys[profile.id] = normalizedAPIKey - didLoadProfileAPIKeys.insert(profile.id) - } else if try secretStore.containsAPIKey(forProfileID: profile.id) { - cachedAPIKeys.removeValue(forKey: profile.id) - didLoadProfileAPIKeys.remove(profile.id) - } else if profile.id == TranslatorProviderProfile.defaultID, hasKnownAPIKey { - cachedAPIKeys.removeValue(forKey: profile.id) - didLoadProfileAPIKeys.remove(profile.id) - } else { + // Complete credential validation before the first Keychain or preferences write. + for profile in profiles where profile.isEnabled && Self.normalizedAPIKey(apiKeys[profile.id]) == nil { + guard credentialAuthorization.allows(profileID: profile.id, baseURL: profile.baseURL), + try secretStore.containsAPIKey(forProfileID: profile.id) + || (profile.id == TranslatorProviderProfile.defaultID && hasKnownAPIKey) else { apiKeyState = .missing onStateChange?() - if profiles.count == 1, profile.id == TranslatorProviderProfile.defaultID { - return localization.string("settings.error.blankAPIKey", defaultValue: "API Key 不能为空。") - } - return localization.format( - "settings.error.providerBlankAPIKeyFormat", - defaultValue: "%@:API Key 不能为空。", - profile.normalizedName.isEmpty - ? localization.string("settings.provider.fallbackName", defaultValue: "翻译服务") - : profile.normalizedName - ) + return localization.string("settings.error.blankAPIKey", defaultValue: "API Key 不能为空。") } } - - // Remove stale Keychain credentials and caches for deleted profiles. - for removedID in removedProfileIDs { - try secretStore.deleteAPIKey(forProfileID: removedID) - cachedAPIKeys.removeValue(forKey: removedID) - didLoadProfileAPIKeys.remove(removedID) + let profilesData = try JSONEncoder().encode(profiles.map { $0.normalized() }) + let retainedIDs = Set(profiles.map(\.id)) + let removedIDs = providerProfiles.map(\.id).filter { !retainedIDs.contains($0) } + var replacements: [PortableCredentialAuthorization.Replacement] = [] + var endpoints: [String: String] = [:] + for profile in profiles where profile.isEnabled { + guard let key = Self.normalizedAPIKey(apiKeys[profile.id]) else { continue } + endpoints[profile.id] = profile.baseURL + replacements.append(.init(profileIDs: [profile.id], + load: { try self.loadRawAPIKey(forProfileID: profile.id) }, + save: { try self.secretStore.saveAPIKey(key, forProfileID: profile.id) })) } - - try providerProfileStore.saveProfiles(profiles) - languagePreferenceStore.savePair(languagePair) + for id in removedIDs { + replacements.append(.init(profileIDs: [id], + load: { try self.loadRawAPIKey(forProfileID: id) }, + save: { try self.secretStore.deleteAPIKey(forProfileID: id) })) + } + let previousBackup = makePortablePreferencesBackup() + cachedAPIKeys.removeAll() + didLoadProfileAPIKeys.removeAll() + cachedAPIKey = nil + didLoadAPIKey = false + try credentialAuthorization.replaceCredentials(replacements, authorizing: endpoints, values: [ + TranslatorConstants.StorageKey.providerProfiles: .data(profilesData), + TranslatorConstants.StorageKey.firstPreferredLanguage: .string(languagePair.first.rawValue), + TranslatorConstants.StorageKey.secondPreferredLanguage: .string(languagePair.second.rawValue) + ], store: portableStore) providerProfiles = providerProfileStore.loadProfiles() providerConfiguration = providerProfiles.first?.configuration ?? OpenAICompatibleConfiguration( @@ -617,6 +626,7 @@ final class TranslatorPlugin: panelController.close() } coordinator = nil + if previousBackup != makePortablePreferencesBackup() { onPersistentPreferencesChange?() } onStateChange?() return nil } catch { @@ -690,7 +700,7 @@ final class TranslatorPlugin: } do { - let apiKey = try loadAPIKey(for: profile.id) + let apiKey = try loadAPIKey(for: profile) guard let trimmedKey = Self.normalizedAPIKey(apiKey) else { return ResolvedTranslationProvider( id: profile.id, @@ -788,27 +798,18 @@ final class TranslatorPlugin: } } - private func loadAPIKey(for profileID: String) throws -> String? { + private func loadAPIKey(for profile: TranslatorProviderProfile) throws -> String? { + guard credentialAuthorization.allows(profileID: profile.id, baseURL: profile.baseURL) else { return nil } + let profileID = profile.id if didLoadProfileAPIKeys.contains(profileID) { return cachedAPIKeys[profileID] } - let profileKey = try secretStore.loadAPIKey(forProfileID: profileID) - if Self.hasNonEmptyAPIKey(profileKey) { - cachedAPIKeys[profileID] = profileKey + if let key = try loadRawAPIKey(forProfileID: profileID), Self.hasNonEmptyAPIKey(key) { + cachedAPIKeys[profileID] = key didLoadProfileAPIKeys.insert(profileID) - updateLegacyAPIKeyCacheIfNeeded(profileID: profileID, apiKey: profileKey) - return profileKey - } - - if profileID == TranslatorProviderProfile.defaultID { - let legacyKey = try secretStore.loadAPIKey() - if Self.hasNonEmptyAPIKey(legacyKey) { - cachedAPIKeys[profileID] = legacyKey - didLoadProfileAPIKeys.insert(profileID) - updateLegacyAPIKeyCacheIfNeeded(profileID: profileID, apiKey: legacyKey) - return legacyKey - } + updateLegacyAPIKeyCacheIfNeeded(profileID: profileID, apiKey: key) + return key } cachedAPIKeys.removeValue(forKey: profileID) @@ -817,6 +818,13 @@ final class TranslatorPlugin: return nil } + /// Raw resolution must throw on an unreadable scoped entry instead of falling back. + private func loadRawAPIKey(forProfileID profileID: String) throws -> String? { + let scoped = try secretStore.loadAPIKey(forProfileID: profileID) + if Self.hasNonEmptyAPIKey(scoped) { return scoped } + return profileID == TranslatorProviderProfile.defaultID ? try secretStore.loadAPIKey() : nil + } + private func updateLegacyAPIKeyCacheIfNeeded(profileID: String, apiKey: String?) { guard profileID == TranslatorProviderProfile.defaultID else { return @@ -827,3 +835,121 @@ final class TranslatorPlugin: apiKeyState = Self.hasNonEmptyAPIKey(apiKey) ? .present : .missing } } + +extension TranslatorPlugin { + private static let promptBackupKey = "preferences-backup.include-prompt-text" + private var includesPromptText: Bool { storage.bool(forKey: Self.promptBackupKey) } + private var promptBackupSection: PluginSettingsSection { + PluginSettingsSection( + id: "preferences-backup", title: localization.string("settings.backup.title", defaultValue: "偏好设置备份"), + systemImage: "externaldrive", rows: [PluginSettingsRow( + id: Self.promptBackupKey, + title: localization.string("settings.backup.includePromptText", defaultValue: "将自定义提示词包含在备份中"), + description: localization.string("settings.backup.scope", defaultValue: "同时用于手动备份、自动备份和云同步。不包含 API 密钥。"), + control: .toggle(isOn: includesPromptText) + )] + ) + } + private func setIncludesPromptText(_ value: Bool) { + guard value != includesPromptText, + portableStore.commit([Self.promptBackupKey: .boolean(value)]) else { return } + onPersistentPreferencesChange?() + onStateChange?() + } +} + +extension TranslatorPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + private struct PortableProfile: Codable { + let id: String + let name: String + let isEnabled: Bool + let baseURL: String + let model: String + let promptTemplate: String? + } + private struct PortablePreferences: Codable { + let formatVersion: Int + let profiles: [PortableProfile] + let languagePair: TranslatorLanguagePair + let shortcutEnabled: Bool + } + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + TranslatorConstants.StorageKey.providerProfiles: .data(Data("[]".utf8), maximumBytes: 1_024 * 1_024) { + (try? JSONDecoder().decode([TranslatorProviderProfile].self, from: $0)).map { $0.count <= 64 } == true + }, + TranslatorConstants.StorageKey.shortcutEnabled: .boolean(true), + TranslatorConstants.StorageKey.firstPreferredLanguage: .choice("zh-Hans", allowed: TranslatorLanguage.allCases.map(\.rawValue)), + TranslatorConstants.StorageKey.secondPreferredLanguage: .choice("en", allowed: TranslatorLanguage.allCases.map(\.rawValue)), + Self.promptBackupKey: .boolean(false), + PortableCredentialAuthorization.key: PortableCredentialAuthorization.field + ]) + } + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + private func decodedPortable(_ data: Data) -> PortablePreferences? { + guard data.count <= 2 * 1_024 * 1_024, let payload = try? JSONDecoder().decode(PortablePreferences.self, from: data), payload.formatVersion == 1, + payload.languagePair.first != payload.languagePair.second, + (1...64).contains(payload.profiles.count), Set(payload.profiles.map(\.id)).count == payload.profiles.count, + payload.profiles.allSatisfy({ profile in + let template = profile.promptTemplate ?? OpenAICompatibleConfiguration.defaultPromptTemplate(localization: localization) + let configuration = OpenAICompatibleConfiguration(baseURL: profile.baseURL, model: profile.model, promptTemplate: template) + return PortablePreferencesValidation.identifier(profile.id) + && PortablePreferencesValidation.text(profile.name, maximumBytes: 256, allowsEmpty: !profile.isEnabled) + && PortablePreferencesValidation.endpoint(profile.baseURL, allowsIncomplete: !profile.isEnabled) + && PortablePreferencesValidation.text(profile.model, maximumBytes: 256, allowsEmpty: !profile.isEnabled) + && template.utf8.count <= 65_536 && (!profile.isEnabled || configuration.validationError == nil) + }) else { return nil } + return payload + } + func makePortablePreferencesBackup() -> Data? { + // Local profile records contain private prompt text; validate the portable projection below. + guard portableStore.backup(including: [TranslatorConstants.StorageKey.shortcutEnabled, + TranslatorConstants.StorageKey.firstPreferredLanguage, + TranslatorConstants.StorageKey.secondPreferredLanguage]) != nil else { return nil } + let payload = PortablePreferences(formatVersion: 1, profiles: providerProfiles.map { + PortableProfile(id: $0.id, name: $0.name, isEnabled: $0.isEnabled, baseURL: $0.baseURL, + model: $0.model, promptTemplate: includesPromptText ? $0.promptTemplate : nil) + }, languagePair: languagePair, shortcutEnabled: isShortcutEnabled) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(payload), decodedPortable(data) != nil else { return nil } + return data + } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard credentialAuthorization.isInitialized, let payload = decodedPortable(data) else { return false } + let before = makePortablePreferencesBackup() + let previousProfiles = providerProfiles + let defaultTemplate = OpenAICompatibleConfiguration.defaultPromptTemplate(localization: localization) + let privateProfiles = providerProfiles.filter { $0.promptTemplate != defaultTemplate } + let protected = includesPromptText ? [] : privateProfiles + // An archive without prompt text must preserve destination-only text, regardless of export opt-in. + let retained = payload.profiles.allSatisfy { $0.promptTemplate == nil } ? privateProfiles : protected + guard !payload.profiles.contains(where: { incoming in + guard let template = incoming.promptTemplate else { return false } + return protected.contains { $0.id == incoming.id && $0.promptTemplate != template } + }) else { return false } + let restored = payload.profiles.map { incoming in + TranslatorProviderProfile(id: incoming.id, name: incoming.name, isEnabled: incoming.isEnabled, baseURL: incoming.baseURL, + model: incoming.model, promptTemplate: incoming.promptTemplate ?? providerProfiles.first { $0.id == incoming.id }?.promptTemplate ?? defaultTemplate) + } + retained.filter { local in !payload.profiles.contains { $0.id == local.id } } + guard restored.count <= 64, restored.allSatisfy({ !$0.isEnabled || $0.validationError == nil }), + let profileData = try? JSONEncoder().encode(restored), portableStore.commit([ + TranslatorConstants.StorageKey.providerProfiles: .data(profileData), + TranslatorConstants.StorageKey.shortcutEnabled: .boolean(payload.shortcutEnabled), + TranslatorConstants.StorageKey.firstPreferredLanguage: .string(payload.languagePair.first.rawValue), + TranslatorConstants.StorageKey.secondPreferredLanguage: .string(payload.languagePair.second.rawValue) + ]) else { return false } + providerProfiles = providerProfileStore.loadProfiles() + languagePair = payload.languagePair + providerConfiguration = providerProfiles.first?.configuration ?? OpenAICompatibleConfiguration() + if let coordinator { coordinator.close() } else { panelController.close() } + coordinator = nil + if providerProfiles != previousProfiles { providerEditorRevision += 1 } + if !providerProfiles.contains(where: { $0.isEnabled && credentialAuthorization.allows(profileID: $0.id, baseURL: $0.baseURL) }) { apiKeyState = .missing } + if before != makePortablePreferencesBackup() { onPersistentPreferencesChange?() } + onStateChange?() + return true + } +} diff --git a/Plugins/Translator/Tests/TranslatorPluginTests.swift b/Plugins/Translator/Tests/TranslatorPluginTests.swift index a496ea6b0..25050daa4 100644 --- a/Plugins/Translator/Tests/TranslatorPluginTests.swift +++ b/Plugins/Translator/Tests/TranslatorPluginTests.swift @@ -5,6 +5,79 @@ import XCTest @MainActor final class TranslatorPluginTests: XCTestCase { + func testOmittedTextPreservesDestinationOnlyTemplateRegardlessOfExportOptIn() throws { + let source = makePlugin() + let payload = try XCTUnwrap(source.makePortablePreferencesBackup()) + for optedIn in [false, true] { + let storage = TranslatorInMemoryPluginStorage() + let destination = makePlugin(storage: storage) + let original = TranslatorProviderProfile.defaultProfile() + let draft = TranslatorProviderProfile(id: "private-draft", name: "Private draft", isEnabled: false, + baseURL: "", model: "", promptTemplate: "Destination-only private {{text}}") + XCTAssertNil(destination.saveConfiguration(profiles: [original, draft], apiKeys: [:], + languagePair: .init(first: .english, second: .simplifiedChinese))) + destination.handleSettingsAction(.setBoolean(controlID: "preferences-backup.include-prompt-text", value: optedIn)) + + XCTAssertTrue(destination.restorePortablePreferencesReportingResult(from: payload)) + XCTAssertEqual(TranslatorProviderProfileStore(storage: storage).loadProfiles(), [original, draft]) + XCTAssertEqual(storage.bool(forKey: "preferences-backup.include-prompt-text"), optedIn) + } + } + + func testPortableRestoreRejectsInvalidEnabledMergedTemplateWithoutMutation() throws { + let storage = TranslatorInMemoryPluginStorage() + let secret = CountingTranslatorSecretStore(apiKey: "local-key") + let destination = makePlugin(storage: storage, secretStore: secret) + let active = TranslatorProviderProfile.defaultProfile() + let draft = TranslatorProviderProfile(id: "draft", name: "Draft", isEnabled: false, + promptTemplate: "Unfinished private template") + XCTAssertNil(destination.saveConfiguration(profiles: [active, draft], apiKeys: [:], + languagePair: .init(first: .english, second: .simplifiedChinese))) + let before = storage.data(forKey: TranslatorConstants.StorageKey.providerProfiles) + var enabled = draft + enabled.isEnabled = true + enabled.promptTemplate = "Translate {{text}}" + let sourceStorage = TranslatorInMemoryPluginStorage() + try TranslatorProviderProfileStore(storage: sourceStorage).saveProfiles([active, enabled]) + let source = makePlugin(storage: sourceStorage) + let payload = try XCTUnwrap(source.makePortablePreferencesBackup()) + + XCTAssertFalse(destination.restorePortablePreferencesReportingResult(from: payload)) + XCTAssertEqual(storage.data(forKey: TranslatorConstants.StorageKey.providerProfiles), before) + XCTAssertEqual(secret.loadCount, 0) + XCTAssertEqual(secret.saveCount, 0) + + enabled.isEnabled = false + try TranslatorProviderProfileStore(storage: sourceStorage).saveProfiles([active, enabled]) + let disabledPayload = try XCTUnwrap(makePlugin(storage: sourceStorage).makePortablePreferencesBackup()) + XCTAssertTrue(destination.restorePortablePreferencesReportingResult(from: disabledPayload)) + XCTAssertEqual(TranslatorProviderProfileStore(storage: storage).loadProfiles().first { $0.id == draft.id }, draft) + } + + func testExcludedLegacyPromptSizeDoesNotBlockPortableExport() throws { + let storage = TranslatorInMemoryPluginStorage() + var profile = TranslatorProviderProfile.defaultProfile() + profile.promptTemplate = String(repeating: "x", count: 1_048_577) + " {{text}}" + try TranslatorProviderProfileStore(storage: storage).saveProfiles([profile]) + let raw = storage.data(forKey: TranslatorConstants.StorageKey.providerProfiles) + let plugin = makePlugin(storage: storage) + + let backup = try XCTUnwrap(plugin.makePortablePreferencesBackup()) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: backup) as? [String: Any]) + let profiles = try XCTUnwrap(object["profiles"] as? [[String: Any]]) + XCTAssertTrue(profiles.allSatisfy { $0["promptTemplate"] == nil }) + XCTAssertEqual(storage.data(forKey: TranslatorConstants.StorageKey.providerProfiles), raw) + plugin.handleSettingsAction(.setBoolean(controlID: "preferences-backup.include-prompt-text", value: true)) + XCTAssertNil(plugin.makePortablePreferencesBackup()) + plugin.handleSettingsAction(.setBoolean(controlID: "preferences-backup.include-prompt-text", value: false)) + + storage.set(Data(), forKey: "preferences-backup.restore.v2") + XCTAssertNil(plugin.makePortablePreferencesBackup()) + storage.removeObject(forKey: "preferences-backup.restore.v2") + XCTAssertNotNil(plugin.makePortablePreferencesBackup()) + XCTAssertEqual(storage.data(forKey: TranslatorConstants.StorageKey.providerProfiles), raw) + } + func testPrimaryPanelReflectsPermissionAndSetupState() { XCTAssertEqual( @@ -34,7 +107,7 @@ final class TranslatorPluginTests: XCTestCase { let message = plugin.saveConfiguration( OpenAICompatibleConfiguration( - baseURL: "https://gateway.example.com", + baseURL: TranslatorProviderProfile.defaultProfile().baseURL, model: "gpt-test", promptTemplate: "{{text}}" ), @@ -47,6 +120,59 @@ final class TranslatorPluginTests: XCTestCase { XCTAssertEqual(plugin.rowState.subtitle, "按 ⌥D 划词,⌥S 截图") } + func testCredentialUpgradePreservesActiveServiceBesideIncompleteDisabledDraft() throws { + for endpoint in ["https://gateway.example/v1", "HTTPS://gateway.example/v1?api-version=2026-01-01"] { + let storage = TranslatorInMemoryPluginStorage() + let secret = CountingTranslatorSecretStore(apiKey: "existing-local-key") + var active = TranslatorProviderProfile.defaultProfile() + active.baseURL = endpoint + let draft = TranslatorProviderProfile(id: "draft", name: "Draft", isEnabled: false, baseURL: "") + let profiles = [active, draft] + storage.set(try JSONEncoder().encode(profiles), forKey: TranslatorConstants.StorageKey.providerProfiles) + let plugin = makePlugin(storage: storage, secretStore: secret) + + let grants = PortableCredentialAuthorization(storage: storage) + XCTAssertTrue(grants.isInitialized) + XCTAssertTrue(grants.allows(profileID: active.id, baseURL: endpoint)) + XCTAssertFalse(grants.allows(profileID: draft.id, baseURL: draft.baseURL)) + XCTAssertNil(plugin.saveConfiguration(profiles: profiles, apiKeys: [:], + languagePair: .init(first: .english, second: .simplifiedChinese))) + XCTAssertEqual(try JSONDecoder().decode([TranslatorProviderProfile].self, + from: XCTUnwrap(storage.data(forKey: TranslatorConstants.StorageKey.providerProfiles))), profiles) + XCTAssertEqual(secret.loadCount, 0) + XCTAssertEqual(secret.saveCount, 0) + } + } + + func testSavedIncompleteDisabledDraftTransfersWithoutCredentialAccess() throws { + let source = makePlugin() + let active = TranslatorProviderProfile.defaultProfile() + let draft = TranslatorProviderProfile(id: "draft", name: "", isEnabled: false, baseURL: "https://", model: "") + XCTAssertNil(source.saveConfiguration(profiles: [active, draft], apiKeys: [:], + languagePair: .init(first: .english, second: .simplifiedChinese))) + let data = try XCTUnwrap(source.makePortablePreferencesBackup()) + let storage = TranslatorInMemoryPluginStorage() + let secret = CountingTranslatorSecretStore(apiKey: "local-secret") + let destination = makePlugin(storage: storage, secretStore: secret) + XCTAssertTrue(destination.restorePortablePreferencesReportingResult(from: data)) + let saved = try XCTUnwrap(storage.data(forKey: TranslatorConstants.StorageKey.providerProfiles)) + let profiles = try JSONDecoder().decode([TranslatorProviderProfile].self, from: saved) + XCTAssertEqual(profiles.first { $0.id == draft.id }, draft) + + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + var incoming = try XCTUnwrap(object["profiles"] as? [[String: Any]]) + incoming[1]["isEnabled"] = true + object["profiles"] = incoming + XCTAssertFalse(destination.restorePortablePreferencesReportingResult(from: try JSONSerialization.data(withJSONObject: object))) + incoming[1]["isEnabled"] = false + incoming[1]["baseURL"] = "https://user:secret@gateway.example/v1" + object["profiles"] = incoming + XCTAssertFalse(destination.restorePortablePreferencesReportingResult(from: try JSONSerialization.data(withJSONObject: object))) + XCTAssertEqual(storage.data(forKey: TranslatorConstants.StorageKey.providerProfiles), saved) + XCTAssertEqual(secret.loadCount, 0) + XCTAssertEqual(secret.saveCount, 0) + } + func testPrimaryPanelTogglePersistsDisabledStateAndNotifies() { let storage = TranslatorInMemoryPluginStorage() let plugin = makePlugin(storage: storage) @@ -87,6 +213,140 @@ final class TranslatorPluginTests: XCTestCase { XCTAssertEqual(selectStartCount, 1) } + func testImportedEndpointRemainsQuarantinedWithoutChangingLocalConsentOrKeys() throws { + let storage = TranslatorInMemoryPluginStorage() + let secret = CountingTranslatorSecretStore(apiKey: "local-secret") + let plugin = makePlugin(storage: storage, secretStore: secret) + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(plugin.makePortablePreferencesBackup())) as? [String: Any]) + var profiles = try XCTUnwrap(object["profiles"] as? [[String: Any]]) + profiles[0]["baseURL"] = "https://imported.example/v1" + object["profiles"] = profiles + let incoming = try JSONSerialization.data(withJSONObject: object) + XCTAssertTrue(plugin.restorePortablePreferencesReportingResult(from: incoming)) + XCTAssertTrue(plugin.restorePortablePreferencesReportingResult(from: incoming)) + let reloaded = makePlugin(storage: storage, secretStore: secret) + let id = try XCTUnwrap(profiles[0]["id"] as? String) + XCTAssertFalse(PortableCredentialAuthorization(storage: storage).allows(profileID: id, baseURL: "https://imported.example/v1")) + XCTAssertEqual(secret.loadCount, 0) + XCTAssertEqual(secret.saveCount, 0) + XCTAssertFalse(storage.bool(forKey: "preferences-backup.include-prompt-text")) + XCTAssertNotNil(reloaded.saveConfiguration(OpenAICompatibleConfiguration(baseURL: "https://imported.example/v1", model: "test", promptTemplate: "{{text}}"), apiKey: "", languagePair: .init(first: .english, second: .simplifiedChinese))) + } + + func testFailedCredentialReplacementPreservesOnlyVerifiedUnchangedAccess() throws { + for (changesKey, readFails) in [(false, false), (true, false), (false, true)] { + let storage = TranslatorInMemoryPluginStorage() + let secret = CountingTranslatorSecretStore(apiKey: "original-local-key") + let plugin = makePlugin(storage: storage, secretStore: secret) + let profile = TranslatorProviderProfile.defaultProfile() + var edited = profile + edited.baseURL = "https://changed.example/v1" + secret.saveFailure = (changesKey, readFails) + XCTAssertNotNil(plugin.saveConfiguration(profiles: [edited], apiKeys: [edited.id: "replacement-local-key"], + languagePair: .init(first: .english, second: .simplifiedChinese))) + let authorization = PortableCredentialAuthorization(storage: storage) + let preservesAccess = !changesKey && !readFails + XCTAssertEqual(authorization.allows(profileID: profile.id, baseURL: profile.baseURL), preservesAccess) + XCTAssertFalse(authorization.allows(profileID: edited.id, baseURL: edited.baseURL)) + secret.saveFailure = nil + let reloaded = makePlugin(storage: storage, secretStore: secret) + XCTAssertEqual(TranslatorProviderProfileStore(storage: storage).loadProfiles(), [profile]) + XCTAssertEqual(reloaded.saveConfiguration(profiles: [profile], apiKeys: [:], + languagePair: .init(first: .english, second: .simplifiedChinese)) == nil, preservesAccess) + } + } + + func testDisabledImportedProfileCannotAuthorizeUnsavedReplacementKey() throws { + let storage = TranslatorInMemoryPluginStorage() + let secret = CountingTranslatorSecretStore(apiKey: "existing-destination-secret") + var local = TranslatorProviderProfile.defaultProfile() + local.isEnabled = false + let other = TranslatorProviderProfile(id: "other", name: "Other", baseURL: "https://other.example/v1") + storage.set(try JSONEncoder().encode([local, other]), forKey: TranslatorConstants.StorageKey.providerProfiles) + let plugin = makePlugin(storage: storage, secretStore: secret) + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(plugin.makePortablePreferencesBackup())) as? [String: Any]) + var profiles = try XCTUnwrap(object["profiles"] as? [[String: Any]]) + local.baseURL = "https://imported.example/v1" + profiles[0]["baseURL"] = local.baseURL + object["profiles"] = profiles + let incoming = try JSONSerialization.data(withJSONObject: object) + XCTAssertTrue(plugin.restorePortablePreferencesReportingResult(from: incoming)) + + XCTAssertNil(plugin.saveConfiguration(profiles: [local, other], apiKeys: [local.id: "replacement-key"], + languagePair: .init(first: .english, second: .simplifiedChinese))) + XCTAssertEqual(secret.saveCount, 0) + XCTAssertFalse(PortableCredentialAuthorization(storage: storage).allows(profileID: local.id, baseURL: local.baseURL)) + + let reopened = makePlugin(storage: storage, secretStore: secret) + local.isEnabled = true + XCTAssertNotNil(reopened.saveConfiguration(profiles: [local, other], apiKeys: [:], + languagePair: .init(first: .english, second: .simplifiedChinese))) + XCTAssertEqual(secret.loadCount, 0) + XCTAssertEqual(secret.saveCount, 0) + XCTAssertFalse(PortableCredentialAuthorization(storage: storage).allows(profileID: local.id, baseURL: local.baseURL)) + + XCTAssertNil(reopened.saveConfiguration(profiles: [local, other], apiKeys: [local.id: "replacement-key"], + languagePair: .init(first: .english, second: .simplifiedChinese))) + XCTAssertEqual(secret.saveCount, 1) + XCTAssertTrue(PortableCredentialAuthorization(storage: storage).allows(profileID: local.id, baseURL: local.baseURL)) + } + + func testCredentialValidationPrecedesAllProviderWrites() throws { + for scenario in ["A-first-B-blank", "B-first-B-blank", "both-keys-present", "B-write-fails"] { + let storage = TranslatorInMemoryPluginStorage() + let profile = TranslatorProviderProfile.defaultProfile() + let other = TranslatorProviderProfile(id: "other", name: "Other", baseURL: "https://other.example/v1") + let secret = ValidationScopedTranslatorSecrets(legacy: nil, scoped: [profile.id: "original-local-key"]) + let plugin = makePlugin(storage: storage, secretStore: secret) + XCTAssertNil(plugin.saveConfiguration(profiles: [profile], apiKeys: [:], + languagePair: .init(first: .english, second: .simplifiedChinese))) + let profiles = scenario == "B-first-B-blank" ? [other, profile] : [profile, other] + var keys = [profile.id: "original-local-key"] + if scenario == "both-keys-present" || scenario == "B-write-fails" { keys[other.id] = "other-key" } + if scenario == "B-write-fails" { secret.rejectedProfileID = other.id } + let error = plugin.saveConfiguration(profiles: profiles, apiKeys: keys, + languagePair: .init(first: .english, second: .simplifiedChinese)) + let authorization = PortableCredentialAuthorization(storage: storage) + let allowed = authorization.allows(profileID: profile.id, baseURL: profile.baseURL) + XCTAssertEqual(error != nil, scenario != "both-keys-present") + XCTAssertTrue(allowed) + XCTAssertEqual(try secret.loadAPIKey(forProfileID: profile.id), "original-local-key") + if error != nil { XCTAssertEqual(TranslatorProviderProfileStore(storage: storage).loadProfiles(), [profile]) } + let reloaded = makePlugin(storage: storage, secretStore: secret) + let retryError = reloaded.saveConfiguration(profiles: [profile], apiKeys: [:], + languagePair: .init(first: .english, second: .simplifiedChinese)) + XCTAssertNil(retryError) + + } + } + + func testFailedScopedReplacementVerifiesEffectiveLegacyKey() throws { + for scenario in ["legacy-only-fail-before-write", "scoped-fail-before-write", "legacy-success", "legacy-partial-write-failure"] { + let storage = TranslatorInMemoryPluginStorage() + let profile = TranslatorProviderProfile.defaultProfile() + let isScoped = scenario == "scoped-fail-before-write" + let secret = ValidationScopedTranslatorSecrets(legacy: isScoped ? nil : "original-local-key", + scoped: isScoped ? [profile.id:"original-local-key"] : [:]) + let plugin = makePlugin(storage: storage, secretStore: secret) + XCTAssertNil(plugin.saveConfiguration(profiles: [profile], apiKeys: [:], + languagePair: .init(first: .english, second: .simplifiedChinese))) + secret.writeMode = scenario == "legacy-success" ? .success + : scenario == "legacy-partial-write-failure" ? .writeThenFail : .failBeforeWrite + let error = plugin.saveConfiguration(profiles: [profile], apiKeys: [profile.id:"replacement-local-key"], + languagePair: .init(first: .english, second: .simplifiedChinese)) + let allowed = PortableCredentialAuthorization(storage: storage).allows(profileID: profile.id, baseURL: profile.baseURL) + XCTAssertEqual(error != nil, scenario != "legacy-success") + XCTAssertEqual(allowed, scenario != "legacy-partial-write-failure") + if !isScoped { XCTAssertEqual(try secret.loadAPIKey(), "original-local-key") } + if scenario == "legacy-only-fail-before-write" { XCTAssertNil(try secret.loadAPIKey(forProfileID: profile.id)) } + let reloaded = makePlugin(storage: storage, secretStore: secret) + let retryError = reloaded.saveConfiguration(profiles: [profile], apiKeys: [:], + languagePair: .init(first: .english, second: .simplifiedChinese)) + XCTAssertEqual(retryError != nil, !allowed) + + } + } + private func makePlugin( storage: TranslatorInMemoryPluginStorage? = nil, accessibilityTrustProvider: @escaping () -> Bool = { true }, @@ -110,6 +370,8 @@ final class TranslatorPluginTests: XCTestCase { private final class CountingTranslatorSecretStore: TranslatorSecretStoring, @unchecked Sendable { private var apiKey: String? private(set) var saveCount = 0 + private(set) var loadCount = 0 + var saveFailure: (changesKey: Bool, readFails: Bool)? init(apiKey: String?) { self.apiKey = apiKey @@ -124,15 +386,23 @@ private final class CountingTranslatorSecretStore: TranslatorSecretStoring, @unc } func loadAPIKey() throws -> String? { - apiKey + loadCount += 1 + if saveCount > 0, saveFailure?.readFails == true { throw CocoaError(.fileReadUnknown) } + return apiKey } func loadAPIKey(forProfileID _: String) throws -> String? { - apiKey + loadCount += 1 + if saveCount > 0, saveFailure?.readFails == true { throw CocoaError(.fileReadUnknown) } + return apiKey } func saveAPIKey(_ apiKey: String) throws { saveCount += 1 + if let saveFailure { + if saveFailure.changesKey { self.apiKey = apiKey } + throw CocoaError(.fileWriteUnknown) + } self.apiKey = apiKey } @@ -157,3 +427,26 @@ private final class RecordingTranslatorPanelController: TranslatorPanelControlli func update(snapshot _: TranslatorPanelSnapshot) {} func close() {} } + +private final class ValidationScopedTranslatorSecrets: TranslatorSecretStoring, @unchecked Sendable { + enum WriteMode { case success, failBeforeWrite, writeThenFail } + private var legacy: String? + private var scoped: [String: String] + var rejectedProfileID: String? + var writeMode = WriteMode.success + private(set) var scopedWriteCount = 0 + init(legacy: String?, scoped: [String: String]) { self.legacy = legacy; self.scoped = scoped } + func containsAPIKey() throws -> Bool { legacy != nil } + func containsAPIKey(forProfileID profileID: String) throws -> Bool { scoped[profileID] != nil } + func loadAPIKey() throws -> String? { legacy } + func loadAPIKey(forProfileID profileID: String) throws -> String? { scoped[profileID] } + func saveAPIKey(_ apiKey: String) throws { legacy = apiKey } + func saveAPIKey(_ apiKey: String, forProfileID profileID: String) throws { + scopedWriteCount += 1 + if profileID == rejectedProfileID || writeMode == .failBeforeWrite { throw CocoaError(.fileWriteUnknown) } + scoped[profileID] = apiKey + if writeMode == .writeThenFail { throw CocoaError(.fileWriteUnknown) } + } + func deleteAPIKey() throws { legacy = nil } + func deleteAPIKey(forProfileID profileID: String) throws { scoped.removeValue(forKey: profileID) } +} diff --git a/Plugins/Translator/plugin.json b/Plugins/Translator/plugin.json index ba221ca6b..9c88bb00b 100644 --- a/Plugins/Translator/plugin.json +++ b/Plugins/Translator/plugin.json @@ -204,7 +204,8 @@ "translation-requests" ], "dataPersisted": [ - "plugin-configuration" + "plugin-configuration", + "custom-prompt-text" ], "retention": { "policy": "user-controlled" @@ -304,5 +305,13 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "credentials", + "permissions", + "private-content" + ] } } diff --git a/Plugins/Translator/project.yml b/Plugins/Translator/project.yml index c2c0bb7f3..6dd20d2e1 100644 --- a/Plugins/Translator/project.yml +++ b/Plugins/Translator/project.yml @@ -1,3 +1,9 @@ settings: base: OTHER_LDFLAGS: -framework Security -framework LocalAuthentication -framework NaturalLanguage -framework ApplicationServices -framework Vision + +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift + - ../../Sources/PluginSupport/PortablePreferencesValidation.swift + - ../../Sources/PluginSupport/PortableCredentialAuthorization.swift diff --git a/Plugins/WindowLayouts/Sources/WindowLayoutsPlugin.swift b/Plugins/WindowLayouts/Sources/WindowLayoutsPlugin.swift index 29744424a..0dd45a9c8 100644 --- a/Plugins/WindowLayouts/Sources/WindowLayoutsPlugin.swift +++ b/Plugins/WindowLayouts/Sources/WindowLayoutsPlugin.swift @@ -29,7 +29,9 @@ final class WindowLayoutsPlugin: MacToolsPlugin, AccessibilityPermissionRefreshi PluginActionShortcutPresetApplying, PluginActionShortcutReplacementTransactionApplying, PluginActionShortcutAssignmentChangeHandling, ObservableObject, PluginFocusedWindowTargetConsuming, PluginInputGestureClaimProviding, - PluginInputGestureConflictConsuming + PluginInputGestureConflictConsuming, PluginPortablePreferencesProviding, + PluginPortablePreferencesRestorationReporting, PluginPersistentPreferencesChangeSignaling, + PluginPortablePreferencesActionReferencesProviding, PluginActionReferenceBackupProviding { private enum PermissionID { static let accessibility = "accessibility" } private enum SettingsID { @@ -72,6 +74,12 @@ final class WindowLayoutsPlugin: MacToolsPlugin, AccessibilityPermissionRefreshi } var onStateChange: (() -> Void)? var onActionSafetyStateChange: (() -> Void)? + private let persistentPreferencesChanges = PluginPersistentPreferencesChangeEmitter() + var onPersistentPreferencesChange: (() -> Void)? { + get { persistentPreferencesChanges.onChange } + set { persistentPreferencesChanges.onChange = newValue } + } + private var isActive = false var requestPermissionGuidance: ((String) -> Void)? var shortcutBindingResolver: ((String) -> ShortcutBinding?)? var previewActionShortcutPreset: (( @@ -187,9 +195,14 @@ final class WindowLayoutsPlugin: MacToolsPlugin, AccessibilityPermissionRefreshi fullScreenWriter: frameAdapter ) } + var lastPortableSnapshot = store.portableBackup() self.store.onMutation = { [weak self] in + let snapshot = store.portableBackup() + let portableChanged = snapshot != lastPortableSnapshot + lastPortableSnapshot = snapshot self?.customCommandSettingsRevision &+= 1 self?.onStateChange?() + if portableChanged { self?.persistentPreferencesChanges.didPersist() } } self.store.onSafetyPolicyMutation = { [weak self] in self?.onActionSafetyStateChange?() @@ -204,6 +217,37 @@ final class WindowLayoutsPlugin: MacToolsPlugin, AccessibilityPermissionRefreshi return [modifierDragClaim] } + func makePortablePreferencesBackup() -> Data? { store.portableBackup() } + + func restorePortablePreferences(from data: Data) { + _ = restorePortablePreferencesReportingResult(from: data) + } + + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard store.restorePortableBackup(data) else { return false } + isAccessibilityGranted = accessibilityTrusted() + if isActive { applyModifierDragConfiguration() } + return true + } + + func actionReferences(inPortablePreferences data: Data) -> [ActionReference]? { + store.customActionIDs(inPortableBackup: data)?.map { + ActionReference(key: ActionKey(providerID: metadata.id, actionID: $0)) + } + } + + func backupDisposition(for reference: ActionReference) -> PluginActionReferenceBackupDisposition { + guard reference.key.providerID == metadata.id, reference.parameters.entries.isEmpty else { return .excluded } + if WindowLayoutOperation(rawValue: reference.key.actionID) != nil { return .selfContained } + let prefix = "custom." + if reference.key.actionID.hasPrefix(prefix), + let id = UUID(uuidString: String(reference.key.actionID.dropFirst(prefix.count))), + reference.key.actionID == prefix + id.uuidString.lowercased() { + return .requiresPluginPreferences + } + return .excluded + } + var permissionRequirements: [PluginPermissionRequirement] { [ PluginPermissionRequirement( @@ -349,11 +393,13 @@ final class WindowLayoutsPlugin: MacToolsPlugin, AccessibilityPermissionRefreshi } func activate(context: PluginRuntimeContext) { + isActive = true refreshAccessibilityPermission() applyModifierDragConfiguration() } func deactivate(reason: PluginDeactivationReason) { + isActive = false stopModifierDragSession() } diff --git a/Plugins/WindowLayouts/Sources/WindowLayoutsStore.swift b/Plugins/WindowLayouts/Sources/WindowLayoutsStore.swift index ad5ae9da8..9d3e3a0d3 100644 --- a/Plugins/WindowLayouts/Sources/WindowLayoutsStore.swift +++ b/Plugins/WindowLayouts/Sources/WindowLayoutsStore.swift @@ -16,6 +16,7 @@ final class WindowLayoutsStore { static let shortcutPreset = "shortcut-preset" static let library = "library.v1" static let quarantinedLibrary = "library.v1.quarantined" + static let portableRestore = "portable-restore.v1" } private struct LibraryEnvelope: Codable { @@ -23,6 +24,28 @@ final class WindowLayoutsStore { let customCommands: [WindowCustomCommand] } + private struct PortablePreferences: Codable, Equatable { + let formatVersion: Int + let gap: Double + let cyclesHalves: Bool + let respectsStageManager: Bool + let showsCommandFeedback: Bool + let centeredGuidesEnabled: Bool + let modifierDragEnabled: Bool + let modifierDragModifiers: ShortcutModifiers + let modifierDragShowsIndicator: Bool + let shortcutPreset: WindowShortcutPreset + let customCommands: [WindowCustomCommand] + } + + private static let portableKeys = [ + StorageKey.gap, StorageKey.cyclesHalves, StorageKey.respectsStageManager, + StorageKey.showsCommandFeedback, StorageKey.centeredGuidesEnabled, + StorageKey.modifierDragEnabled, StorageKey.modifierDragModifiers, + StorageKey.modifierDragShowsIndicator, StorageKey.shortcutPreset, StorageKey.library, + ] + private static let maximumPortableBytes = 2 * 1_024 * 1_024 + static let gapRange: ClosedRange = 0...40 static let defaultGap: Double = 0 static let maximumCustomCommandCount = 32 @@ -48,6 +71,7 @@ final class WindowLayoutsStore { init(storage: PluginStorage) { self.storage = storage + _ = Self.recoverPortableRestore(storage: storage) let storedGap = (storage.object(forKey: StorageKey.gap) as? NSNumber)?.doubleValue ?? Self.defaultGap self.gap = Self.clampedGap(storedGap) @@ -76,82 +100,204 @@ final class WindowLayoutsStore { static let defaultModifierDragModifiers: ShortcutModifiers = [.control, .option] + func portableBackup() -> Data? { + guard storage.object(forKey: StorageKey.portableRestore) == nil else { return nil } + let payload = portablePreferences + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard isValid(payload), let data = try? encoder.encode(payload), + data.count <= Self.maximumPortableBytes else { return nil } + return data + } + + func customActionIDs(inPortableBackup data: Data) -> [String]? { + decodedPortablePreferences(data)?.customCommands.map(\.actionID) + } + + @discardableResult + func restorePortableBackup(_ data: Data) -> Bool { + guard let payload = decodedPortablePreferences(data), + Self.recoverPortableRestore(storage: storage) else { return false } + guard payload != portablePreferences else { return true } + guard let library = try? JSONEncoder().encode(LibraryEnvelope( + formatVersion: 1, customCommands: payload.customCommands + )) else { return false } + let candidate: [String: Any] = [ + StorageKey.gap: payload.gap, + StorageKey.cyclesHalves: payload.cyclesHalves, + StorageKey.respectsStageManager: payload.respectsStageManager, + StorageKey.showsCommandFeedback: payload.showsCommandFeedback, + StorageKey.centeredGuidesEnabled: payload.centeredGuidesEnabled, + StorageKey.modifierDragEnabled: payload.modifierDragEnabled, + StorageKey.modifierDragModifiers: payload.modifierDragModifiers.rawValue, + StorageKey.modifierDragShowsIndicator: payload.modifierDragShowsIndicator, + StorageKey.shortcutPreset: payload.shortcutPreset.rawValue, + StorageKey.library: library, + ] + guard persistValues(candidate) else { return false } + gap = payload.gap + cyclesHalves = payload.cyclesHalves + respectsStageManager = payload.respectsStageManager + showsCommandFeedback = payload.showsCommandFeedback + centeredGuidesEnabled = payload.centeredGuidesEnabled + modifierDragEnabled = payload.modifierDragEnabled + modifierDragModifiers = payload.modifierDragModifiers + modifierDragShowsIndicator = payload.modifierDragShowsIndicator + shortcutPreset = payload.shortcutPreset + customCommands = payload.customCommands + recordMutation() + onSafetyPolicyMutation?() + return true + } + + private var portablePreferences: PortablePreferences { + PortablePreferences( + formatVersion: 1, gap: gap, cyclesHalves: cyclesHalves, + respectsStageManager: respectsStageManager, showsCommandFeedback: showsCommandFeedback, + centeredGuidesEnabled: centeredGuidesEnabled, modifierDragEnabled: modifierDragEnabled, + modifierDragModifiers: modifierDragModifiers, modifierDragShowsIndicator: modifierDragShowsIndicator, + shortcutPreset: shortcutPreset, customCommands: customCommands.map { command in + var copy = command + copy.allowExternalInvocation = false + return copy + } + ) + } + + private func decodedPortablePreferences(_ data: Data) -> PortablePreferences? { + guard data.count <= Self.maximumPortableBytes, + let payload = try? JSONDecoder().decode(PortablePreferences.self, from: data), + isValid(payload) else { return nil } + return payload + } + + private func isValid(_ payload: PortablePreferences) -> Bool { + payload.formatVersion == 1 && payload.gap.isFinite && Self.gapRange.contains(payload.gap) + && !payload.modifierDragModifiers.isEmpty && payload.modifierDragModifiers.containsOnlySupportedValues + && payload.customCommands.count <= Self.maximumCustomCommandCount + && normalizedCommands(payload.customCommands) == payload.customCommands + && payload.customCommands.allSatisfy { !$0.allowExternalInvocation } + } + + private static func matches(_ values: [String: Any], storage: PluginStorage) -> Bool { + portableKeys.allSatisfy { key in + switch (values[key] as? NSObject, storage.object(forKey: key) as? NSObject) { + case (nil, nil): true + case let (expected?, actual?): expected.isEqual(actual) + default: false + } + } + } + + private static func recoverPortableRestore(storage: PluginStorage) -> Bool { + guard let rawJournal = storage.object(forKey: StorageKey.portableRestore) else { return true } + guard let data = rawJournal as? Data, data.count <= maximumPortableBytes, + let previous = (try? PropertyListSerialization.propertyList(from: data, format: nil)) as? [String: Any], + Set(previous.keys).isSubset(of: Set(portableKeys)) else { return false } + for key in portableKeys { + if let value = previous[key] { storage.set(value, forKey: key) } + else { storage.removeObject(forKey: key) } + } + guard matches(previous, storage: storage) else { return false } + storage.removeObject(forKey: StorageKey.portableRestore) + return storage.object(forKey: StorageKey.portableRestore) == nil + } + func setGap(_ gap: Double) { let gap = Self.clampedGap(gap) + guard self.gap != gap, persistValues([StorageKey.gap: gap]) else { return } self.gap = gap - storage.set(gap, forKey: StorageKey.gap) recordMutation() } func setCyclesHalves(_ enabled: Bool) { + guard cyclesHalves != enabled, persistValues([StorageKey.cyclesHalves: enabled]) else { return } cyclesHalves = enabled - storage.set(enabled, forKey: StorageKey.cyclesHalves) recordMutation() } func setRespectsStageManager(_ enabled: Bool) { + guard respectsStageManager != enabled, persistValues([StorageKey.respectsStageManager: enabled]) else { return } respectsStageManager = enabled - storage.set(enabled, forKey: StorageKey.respectsStageManager) recordMutation() } func setShowsCommandFeedback(_ enabled: Bool) { + guard showsCommandFeedback != enabled, persistValues([StorageKey.showsCommandFeedback: enabled]) else { return } showsCommandFeedback = enabled - storage.set(enabled, forKey: StorageKey.showsCommandFeedback) recordMutation() } func setCenteredGuidesEnabled(_ enabled: Bool) { + guard centeredGuidesEnabled != enabled, persistValues([StorageKey.centeredGuidesEnabled: enabled]) else { return } centeredGuidesEnabled = enabled - storage.set(enabled, forKey: StorageKey.centeredGuidesEnabled) recordMutation() } func setModifierDragEnabled(_ enabled: Bool) { + guard modifierDragEnabled != enabled, persistValues([StorageKey.modifierDragEnabled: enabled]) else { return } modifierDragEnabled = enabled - storage.set(enabled, forKey: StorageKey.modifierDragEnabled) recordMutation() } func setModifierDragModifiers(_ modifiers: ShortcutModifiers) { guard !modifiers.isEmpty, modifiers.containsOnlySupportedValues else { return } + guard modifierDragModifiers != modifiers, persistValues([StorageKey.modifierDragModifiers: modifiers.rawValue]) else { return } modifierDragModifiers = modifiers - storage.set(modifiers.rawValue, forKey: StorageKey.modifierDragModifiers) recordMutation() } func setModifierDragShowsIndicator(_ enabled: Bool) { + guard modifierDragShowsIndicator != enabled, persistValues([StorageKey.modifierDragShowsIndicator: enabled]) else { return } modifierDragShowsIndicator = enabled - storage.set(enabled, forKey: StorageKey.modifierDragShowsIndicator) recordMutation() } func setShortcutPreset(_ preset: WindowShortcutPreset) { + guard shortcutPreset != preset, persistValues([StorageKey.shortcutPreset: preset.rawValue]) else { return } shortcutPreset = preset - storage.set(preset.rawValue, forKey: StorageKey.shortcutPreset) recordMutation() } func reset() { + let keys = Set(Self.portableKeys).subtracting([StorageKey.shortcutPreset, StorageKey.library]) + guard persistValues([:], removing: keys) else { return } gap = Self.defaultGap cyclesHalves = false respectsStageManager = true showsCommandFeedback = false centeredGuidesEnabled = false - storage.removeObject(forKey: StorageKey.centeredGuidesEnabled) modifierDragEnabled = false modifierDragModifiers = Self.defaultModifierDragModifiers modifierDragShowsIndicator = true - storage.removeObject(forKey: StorageKey.gap) - storage.removeObject(forKey: StorageKey.cyclesHalves) - storage.removeObject(forKey: StorageKey.respectsStageManager) - storage.removeObject(forKey: StorageKey.showsCommandFeedback) - storage.removeObject(forKey: StorageKey.modifierDragEnabled) - storage.removeObject(forKey: StorageKey.modifierDragModifiers) - storage.removeObject(forKey: StorageKey.modifierDragShowsIndicator) recordMutation() } + private func persistValues(_ candidate: [String: Any], removing: Set = []) -> Bool { + guard Self.recoverPortableRestore(storage: storage) else { return false } + var previous: [String: Any] = [:] + for key in Self.portableKeys { previous[key] = storage.object(forKey: key) } + guard let journal = try? PropertyListSerialization.data( + fromPropertyList: previous, format: .binary, options: 0 + ), journal.count <= Self.maximumPortableBytes else { return false } + storage.set(journal, forKey: StorageKey.portableRestore) + guard storage.data(forKey: StorageKey.portableRestore) == journal else { return false } + for (key, value) in candidate { storage.set(value, forKey: key) } + for key in removing { storage.removeObject(forKey: key) } + let expected = previous.merging(candidate, uniquingKeysWith: { _, incoming in incoming }) + .filter { !removing.contains($0.key) } + guard Self.matches(expected, storage: storage) else { + _ = Self.recoverPortableRestore(storage: storage) + return false + } + storage.removeObject(forKey: StorageKey.portableRestore) + guard storage.object(forKey: StorageKey.portableRestore) == nil else { + _ = Self.recoverPortableRestore(storage: storage) + return false + } + return true + } + @discardableResult func addCustomCommand(name: String) -> WindowCustomCommand? { guard customCommands.count < Self.maximumCustomCommandCount else { return nil } @@ -252,8 +398,7 @@ final class WindowLayoutsStore { )), data.count <= 2 * 1_024 * 1_024 else { return false } - storage.set(data, forKey: StorageKey.library) - return storage.data(forKey: StorageKey.library) == data + return persistValues([StorageKey.library: data]) } private func normalizedCommands( diff --git a/Plugins/WindowLayouts/Tests/WindowLayoutsPluginTests.swift b/Plugins/WindowLayouts/Tests/WindowLayoutsPluginTests.swift index fab514eaa..36a5f4669 100644 --- a/Plugins/WindowLayouts/Tests/WindowLayoutsPluginTests.swift +++ b/Plugins/WindowLayouts/Tests/WindowLayoutsPluginTests.swift @@ -5,6 +5,49 @@ import MacToolsPluginKit @MainActor final class WindowLayoutsPluginTests: XCTestCase { + func testPortableRestoreReconfiguresActiveFeaturesAndIndexesCustomActions() throws { + let sourceStorage = WindowLayoutsMemoryStorage() + let sourceStore = WindowLayoutsStore(storage: sourceStorage) + sourceStore.setModifierDragEnabled(true) + sourceStore.setCenteredGuidesEnabled(true) + sourceStore.setModifierDragModifiers([.command, .control]) + let command = try XCTUnwrap(sourceStore.addCustomCommand(name: "Reading")) + let source = makePlugin(storage: sourceStorage) + let data = try XCTUnwrap(source.makePortablePreferencesBackup()) + let session = MockWindowModifierDragSession() + let target = makePlugin(modifierDragSession: session) + target.activate(context: PluginRuntimeContext(pluginID: "window-layouts")) + var persistedChanges = 0 + target.onPersistentPreferencesChange = { persistedChanges += 1 } + + XCTAssertTrue(target.restorePortablePreferencesReportingResult(from: data)) + XCTAssertEqual(persistedChanges, 1) + XCTAssertTrue(session.centeredGuidesEnabled) + XCTAssertTrue(session.modifierDragEnabled) + XCTAssertEqual(session.configuredModifiers, [.command, .control]) + let references = try XCTUnwrap(target.actionReferences(inPortablePreferences: data)) + XCTAssertEqual(references.map(\.key.actionID), [command.actionID]) + XCTAssertEqual(target.backupDisposition(for: references[0]), .requiresPluginPreferences) + XCTAssertTrue(target.actionDefinitions.contains { $0.key == references[0].key }) + XCTAssertTrue(target.restorePortablePreferencesReportingResult(from: data)) + XCTAssertEqual(persistedChanges, 1) + } + + func testPortableRestoreKeepsPermissionDeniedFeaturesInactive() throws { + let sourceStorage = WindowLayoutsMemoryStorage() + let store = WindowLayoutsStore(storage: sourceStorage) + store.setModifierDragEnabled(true) + store.setCenteredGuidesEnabled(true) + let session = MockWindowModifierDragSession() + let target = makePlugin(modifierDragSession: session, accessibilityTrusted: { false }) + target.activate(context: PluginRuntimeContext(pluginID: "window-layouts")) + + XCTAssertTrue(target.restorePortablePreferencesReportingResult(from: try XCTUnwrap(store.portableBackup()))) + XCTAssertTrue(target.isModifierDragEnabled) + XCTAssertEqual(session.startCount, 0) + XCTAssertFalse(session.isRunning) + } + func testPermissionMembershipTracksCustomCommandsAndRejectsUnknownProviders() throws { let plugin = makePlugin() plugin.performActionShortcutReplacementTransaction = { _, _, commit in commit() } diff --git a/Plugins/WindowLayouts/Tests/WindowLayoutsStoreTests.swift b/Plugins/WindowLayouts/Tests/WindowLayoutsStoreTests.swift index 21e420acf..95a36f0bf 100644 --- a/Plugins/WindowLayouts/Tests/WindowLayoutsStoreTests.swift +++ b/Plugins/WindowLayouts/Tests/WindowLayoutsStoreTests.swift @@ -5,6 +5,56 @@ import MacToolsPluginKit @MainActor final class WindowLayoutsStoreTests: XCTestCase { + func testPortablePreferencesRoundTripPreservesCommandsAndBehavior() throws { + let source = WindowLayoutsStore(storage: StoreMemoryStorage()) + source.setGap(12) + source.setCyclesHalves(true) + source.setCenteredGuidesEnabled(true) + source.setModifierDragEnabled(true) + source.setModifierDragModifiers([.control, .option, .command]) + source.setModifierDragShowsIndicator(false) + source.setShowsCommandFeedback(true) + let command = try XCTUnwrap(source.addCustomCommand(name: "Reading")) + let data = try XCTUnwrap(source.portableBackup()) + let storage = StoreMemoryStorage() + let target = WindowLayoutsStore(storage: storage) + + XCTAssertTrue(target.restorePortableBackup(data)) + XCTAssertEqual(target.gap, 12) + XCTAssertTrue(target.cyclesHalves) + XCTAssertTrue(target.centeredGuidesEnabled) + XCTAssertTrue(target.modifierDragEnabled) + XCTAssertEqual(target.modifierDragModifiers, [.control, .option, .command]) + XCTAssertFalse(target.modifierDragShowsIndicator) + XCTAssertTrue(target.showsCommandFeedback) + XCTAssertEqual(target.customCommands.first?.id, command.id) + XCTAssertFalse(try XCTUnwrap(target.customCommands.first).allowExternalInvocation) + XCTAssertEqual(WindowLayoutsStore(storage: storage).portableBackup(), target.portableBackup()) + } + + func testPortableRestoreRejectsInvalidGeometryAndRollsBackFailedPersistence() throws { + let source = WindowLayoutsStore(storage: StoreMemoryStorage()) + source.setGap(15) + _ = source.addCustomCommand(name: "Reading") + let data = try XCTUnwrap(source.portableBackup()) + let storage = StoreMemoryStorage() + let target = WindowLayoutsStore(storage: storage) + target.setGap(3) + let previous = target.portableBackup() + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + object["gap"] = 100 + XCTAssertFalse(target.restorePortableBackup(try JSONSerialization.data(withJSONObject: object))) + XCTAssertEqual(target.portableBackup(), previous) + + storage.rejectedWriteKey = "library.v1" + XCTAssertFalse(target.restorePortableBackup(data)) + XCTAssertEqual(target.gap, 3) + XCTAssertTrue(target.customCommands.isEmpty) + XCTAssertEqual(WindowLayoutsStore(storage: storage).gap, 3) + XCTAssertNil(storage.object(forKey: "modifier-drag.enabled")) + XCTAssertNil(storage.object(forKey: "portable-restore.v1")) + } + func testPersistsCustomCommandsWithStableActionIDs() throws { let storage = StoreMemoryStorage() let store = WindowLayoutsStore(storage: storage) @@ -111,13 +161,17 @@ private struct StoreLibraryEnvelope: Codable { @MainActor private final class StoreMemoryStorage: PluginStorage { private var values: [String: Any] = [:] + var rejectedWriteKey: String? func object(forKey key: String) -> Any? { values[key] } func data(forKey key: String) -> Data? { values[key] as? Data } func string(forKey key: String) -> String? { values[key] as? String } func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } func integer(forKey key: String) -> Int { values[key] as? Int ?? 0 } func bool(forKey key: String) -> Bool { values[key] as? Bool ?? false } - func set(_ value: Any?, forKey key: String) { values[key] = value } + func set(_ value: Any?, forKey key: String) { + guard key != rejectedWriteKey else { return } + values[key] = value + } func removeObject(forKey key: String) { values.removeValue(forKey: key) } func migrateValueIfNeeded(fromLegacyKey legacyKey: String, to key: String) { guard values[key] == nil, let value = values[legacyKey] else { return } diff --git a/Plugins/WindowLayouts/plugin.json b/Plugins/WindowLayouts/plugin.json index 7af987dfe..d5693238c 100644 --- a/Plugins/WindowLayouts/plugin.json +++ b/Plugins/WindowLayouts/plugin.json @@ -1644,5 +1644,11 @@ ] } ] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "external-authorization" + ] } } diff --git a/Plugins/WindowSwitcher/Sources/WindowSwitcherModels.swift b/Plugins/WindowSwitcher/Sources/WindowSwitcherModels.swift index 04909926f..b08929d94 100644 --- a/Plugins/WindowSwitcher/Sources/WindowSwitcherModels.swift +++ b/Plugins/WindowSwitcher/Sources/WindowSwitcherModels.swift @@ -162,6 +162,7 @@ final class WindowSwitcherStore: ObservableObject { } ) { self.storage = storage + _ = Self.portableStore(storage: storage).recover() self.processIsRunning = processIsRunning if let data = storage.data(forKey: Keys.configuration), let loaded = try? decoder.decode(WindowSwitcherConfiguration.self, from: data) { @@ -192,74 +193,113 @@ final class WindowSwitcherStore: ObservableObject { persist() } - func setMode(_ mode: WindowSwitcherMode) { - guard configuration.mode != mode else { - return - } - - configuration.mode = mode - if mode == .keyWindow { configuration.protectsLegacyCommands = true } - persist() + private struct PortableConfiguration: Codable, Equatable { + let isEnabled: Bool + let mode: WindowSwitcherMode + let sortMode: WindowSwitcherSortMode + let showsPreview: Bool + let preferredLayout: WindowSwitcherLayout? + let includesMinimizedWindows: Bool + let includesOtherDesktopWindows: Bool + let includesFullscreenSpaceWindows: Bool } - - func setSortMode(_ sortMode: WindowSwitcherSortMode) { - guard configuration.sortMode != sortMode else { - return - } - - configuration.sortMode = sortMode - persist() + private struct PortablePreferences: Codable { + let formatVersion: Int + let configuration: PortableConfiguration + let manualAppBindings: [String: String] } - - func setEnabled(_ isEnabled: Bool) { - guard configuration.isEnabled != isEnabled else { - return - } - - configuration.isEnabled = isEnabled - persist() + var onPersistentPreferencesChange: (() -> Void)? + private static func portableStore(storage: any PluginStorage) -> PortablePreferencesStore { + PortablePreferencesStore(storage: storage, fields: [ + Keys.configuration: .data(try! JSONEncoder().encode(WindowSwitcherConfiguration.default), maximumBytes: 16_384) { + (try? JSONDecoder().decode(WindowSwitcherConfiguration.self, from: $0)) != nil + }, + Keys.shortcutBindings: .data(try! JSONEncoder().encode(WindowSwitcherShortcutBindingState()), maximumBytes: 256 * 1_024) { + (try? JSONDecoder().decode(WindowSwitcherShortcutBindingState.self, from: $0))?.version == WindowSwitcherShortcutBindingState.currentVersion + } + ]) } - - func useCompanionDefaults() { - configuration.usesCompanionDefaults = true - persist() + private var portableStore: PortablePreferencesStore { Self.portableStore(storage: storage) } + private static func isPortableIdentity(_ identity: String) -> Bool { + guard identity.hasPrefix("bundle:") else { return false } + let bundleID = identity.dropFirst("bundle:".count) + return !bundleID.isEmpty && bundleID.utf8.count <= 255 + && bundleID.unicodeScalars.allSatisfy { CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-_")).contains($0) } } - - func setShowsPreview(_ value: Bool) { - configuration.showsPreview = value - persist() + private func portableBindings(_ state: WindowSwitcherShortcutBindingState) -> [String: String] { + state.manual.filter { Self.isPortableIdentity($0.key) } } - - func setIncludesMinimizedWindows(_ value: Bool) { - guard configuration.includesMinimizedWindows != value else { return } - configuration.includesMinimizedWindows = value - persist() + private func portableConfiguration(_ value: WindowSwitcherConfiguration) -> PortableConfiguration { + PortableConfiguration(isEnabled: value.isEnabled, mode: value.mode, sortMode: value.sortMode, + showsPreview: value.showsPreview, preferredLayout: value.preferredLayout, + includesMinimizedWindows: value.includesMinimizedWindows, includesOtherDesktopWindows: value.includesOtherDesktopWindows, + includesFullscreenSpaceWindows: value.includesFullscreenSpaceWindows) } - - func setIncludesOtherDesktopWindows(_ value: Bool) { - guard configuration.includesOtherDesktopWindows != value else { return } - configuration.includesOtherDesktopWindows = value - persist() + private func decodedPortable(_ data: Data) -> PortablePreferences? { + guard data.count <= 128 * 1_024, let payload = try? decoder.decode(PortablePreferences.self, from: data), payload.formatVersion == 1, + payload.manualAppBindings.count <= WindowSwitcherShortcutAssignment.maximumShortcutCount, + payload.manualAppBindings.allSatisfy({ Self.isPortableIdentity($0.key) + && WindowSwitcherShortcutAssignment.normalizedManualToken($0.value) == $0.value + && !WindowSwitcherShortcutAssignment.reservedTokens.contains($0.value) }) else { return nil } + return payload } - - func setIncludesFullscreenSpaceWindows(_ value: Bool) { - guard configuration.includesFullscreenSpaceWindows != value else { return } - configuration.includesFullscreenSpaceWindows = value - persist() + func portableBackup() -> Data? { + guard portableStore.backup() != nil else { return nil } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(PortablePreferences(formatVersion: 1, configuration: portableConfiguration(configuration), manualAppBindings: portableBindings(shortcutBindings))), + decodedPortable(data) != nil else { return nil } + return data } - - func setPreferredLayout(_ layout: WindowSwitcherLayout) { - guard configuration.preferredLayout != layout else { return } - configuration.preferredLayout = layout - persist() + func restorePortableBackup(_ data: Data) -> Bool { + guard shortcutBindings.version == WindowSwitcherShortcutBindingState.currentVersion, + let payload = decodedPortable(data) else { return false } + var candidate = configuration + candidate.isEnabled = payload.configuration.isEnabled + candidate.mode = payload.configuration.mode + candidate.sortMode = payload.configuration.sortMode + candidate.showsPreview = payload.configuration.showsPreview + candidate.preferredLayout = payload.configuration.preferredLayout + candidate.includesMinimizedWindows = payload.configuration.includesMinimizedWindows + candidate.includesOtherDesktopWindows = payload.configuration.includesOtherDesktopWindows + candidate.includesFullscreenSpaceWindows = payload.configuration.includesFullscreenSpaceWindows + if candidate.mode == .keyWindow { candidate.protectsLegacyCommands = true } + var bindings = shortcutBindings + bindings.manual = bindings.manual.filter { !Self.isPortableIdentity($0.key) } + .merging(payload.manualAppBindings, uniquingKeysWith: { _, incoming in incoming }) + guard let configData = try? encoder.encode(candidate), let bindingData = try? encoder.encode(bindings) else { return false } + let changed = portableConfiguration(configuration) != payload.configuration || portableBindings(shortcutBindings) != payload.manualAppBindings + guard portableStore.commit([Keys.configuration: .data(configData), Keys.shortcutBindings: .data(bindingData)]) else { return false } + configuration = candidate + shortcutBindings = bindings + if changed { onPersistentPreferencesChange?() } + return true } - private func persist() { - guard let data = try? encoder.encode(configuration) else { - return - } - - storage.set(data, forKey: Keys.configuration) + func setMode(_ mode: WindowSwitcherMode) { + var candidate = configuration + candidate.mode = mode + if mode == .keyWindow { candidate.protectsLegacyCommands = true } + _ = commitConfiguration(candidate) + } + func setSortMode(_ value: WindowSwitcherSortMode) { var candidate = configuration; candidate.sortMode = value; _ = commitConfiguration(candidate) } + func setEnabled(_ value: Bool) { var candidate = configuration; candidate.isEnabled = value; _ = commitConfiguration(candidate) } + func useCompanionDefaults() { var candidate = configuration; candidate.usesCompanionDefaults = true; _ = commitConfiguration(candidate) } + func setShowsPreview(_ value: Bool) { var candidate = configuration; candidate.showsPreview = value; _ = commitConfiguration(candidate) } + func setIncludesMinimizedWindows(_ value: Bool) { var candidate = configuration; candidate.includesMinimizedWindows = value; _ = commitConfiguration(candidate) } + func setIncludesOtherDesktopWindows(_ value: Bool) { var candidate = configuration; candidate.includesOtherDesktopWindows = value; _ = commitConfiguration(candidate) } + func setIncludesFullscreenSpaceWindows(_ value: Bool) { var candidate = configuration; candidate.includesFullscreenSpaceWindows = value; _ = commitConfiguration(candidate) } + func setPreferredLayout(_ value: WindowSwitcherLayout) { var candidate = configuration; candidate.preferredLayout = value; _ = commitConfiguration(candidate) } + + private func persist() { _ = commitConfiguration(configuration) } + + @discardableResult + private func commitConfiguration(_ candidate: WindowSwitcherConfiguration) -> Bool { + let previous = storage.data(forKey: Keys.configuration).flatMap { try? decoder.decode(WindowSwitcherConfiguration.self, from: $0) } + guard let data = try? encoder.encode(candidate), portableStore.commit([Keys.configuration: .data(data)]) else { return false } + configuration = candidate + if previous.map({ portableConfiguration($0) != portableConfiguration(candidate) }) ?? true { onPersistentPreferencesChange?() } + return true } var protectedCommandKeys: Set { @@ -324,13 +364,18 @@ final class WindowSwitcherStore: ObservableObject { updatedState.manual.removeValue(forKey: targetIdentity) } + let appIdentity = entries[targetIndex].appIdentifier + if Self.isPortableIdentity(appIdentity), updatedState.manual[appIdentity] != nil, + entries.filter({ $0.appIdentifier == appIdentity }).count == 1 { + updatedState.manual[appIdentity] = rawToken.flatMap(WindowSwitcherShortcutAssignment.normalizedManualToken) + } let updated = WindowSwitcherShortcutAssignment.assignShortcuts( to: entries, bindingState: updatedState ) if updated.bindingState != shortcutBindings { shortcutBindings = updated.bindingState - persistShortcutBindings() + guard persistShortcutBindings() else { return .unavailable } } return .updated(updated.entries) } @@ -387,12 +432,15 @@ final class WindowSwitcherStore: ObservableObject { return conflictsWithSavedManualBinding || conflictsWithRunningEntry } - private func persistShortcutBindings() { - guard let data = try? encoder.encode(shortcutBindings) else { - return + @discardableResult + private func persistShortcutBindings() -> Bool { + let previous = storage.data(forKey: Keys.shortcutBindings).flatMap { try? decoder.decode(WindowSwitcherShortcutBindingState.self, from: $0) } ?? .init() + guard let data = try? encoder.encode(shortcutBindings), portableStore.commit([Keys.shortcutBindings: .data(data)]) else { + shortcutBindings = previous + return false } - - storage.set(data, forKey: Keys.shortcutBindings) + if portableBindings(previous) != portableBindings(shortcutBindings) { onPersistentPreferencesChange?() } + return true } private func removeExpiredWindowBindings(for entries: [WindowSwitcherAppEntry]) { @@ -809,7 +857,7 @@ enum WindowSwitcherShortcutAssignment { for target in targets { guard let legacyIdentity = target.legacyIdentity, state.manual[target.identity] == nil, - let token = state.manual.removeValue(forKey: legacyIdentity) + let token = state.manual[legacyIdentity] else { continue } diff --git a/Plugins/WindowSwitcher/Sources/WindowSwitcherPlugin.swift b/Plugins/WindowSwitcher/Sources/WindowSwitcherPlugin.swift index 90bf4aba7..0590fb8c1 100644 --- a/Plugins/WindowSwitcher/Sources/WindowSwitcherPlugin.swift +++ b/Plugins/WindowSwitcher/Sources/WindowSwitcherPlugin.swift @@ -926,3 +926,19 @@ final class WindowSwitcherPlugin: MacToolsPlugin, AccessibilityPermissionRefresh : WindowSwitcherShortcutBindingStore.legacyBinding } } + +extension WindowSwitcherPlugin: PluginPortablePreferencesProviding, PluginPortablePreferencesRestorationReporting, + PluginPersistentPreferencesChangeSignaling { + var onPersistentPreferencesChange: (() -> Void)? { + get { store.onPersistentPreferencesChange } + set { store.onPersistentPreferencesChange = newValue } + } + func makePortablePreferencesBackup() -> Data? { store.portableBackup() } + func restorePortablePreferences(from data: Data) { _ = restorePortablePreferencesReportingResult(from: data) } + func restorePortablePreferencesReportingResult(from data: Data) -> Bool { + guard store.restorePortableBackup(data) else { return false } + refreshAccessibilityPermission() + configurationDidChange() + return true + } +} diff --git a/Plugins/WindowSwitcher/Tests/WindowSwitcherPluginTests.swift b/Plugins/WindowSwitcher/Tests/WindowSwitcherPluginTests.swift index c5ed00474..8ebfa1eab 100644 --- a/Plugins/WindowSwitcher/Tests/WindowSwitcherPluginTests.swift +++ b/Plugins/WindowSwitcher/Tests/WindowSwitcherPluginTests.swift @@ -563,6 +563,45 @@ final class WindowSwitcherPluginTests: XCTestCase { XCTAssertTrue(didRaise) } + func testPortableCanonicalAppBindingSurvivesDifferentProcessAndWindowIdentities() throws { + let source = WindowSwitcherStore(storage: WindowSwitcherMemoryStorage()) + let app = makeEntry(index: 0, appName: "Editor", bundleIdentifier: "com.example.editor") + _ = source.setManualShortcut("q", for: app.id, in: [app]) + let data = try XCTUnwrap(source.portableBackup()) + let storage = WindowSwitcherMemoryStorage() + let destination = WindowSwitcherStore(storage: storage) + XCTAssertTrue(destination.restorePortableBackup(data)) + var firstWindow = app + firstWindow.windowNumber = 42 + XCTAssertEqual(destination.assignShortcuts(to: [firstWindow]).first?.shortcutToken, "q") + XCTAssertEqual(destination.portableBackup(), data) + let reloaded = WindowSwitcherStore(storage: storage) + var secondWindow = makeEntry(index: 20, appName: "Editor", bundleIdentifier: "com.example.editor") + secondWindow.windowNumber = 900 + let entries = reloaded.assignShortcuts(to: [secondWindow]) + XCTAssertEqual(entries.first?.shortcutToken, "q") + XCTAssertEqual(reloaded.portableBackup(), data) + _ = reloaded.setManualShortcut(nil, for: secondWindow.id, in: entries) + XCTAssertNil(reloaded.shortcutBindings.manual["bundle:com.example.editor"]) + } + + func testFailedManualWriteReportsFailureAndInactiveDuplicateTokensRemainPortable() throws { + let storage = FakePluginStorage() + let store = WindowSwitcherStore(storage: storage) + let a = makeEntry(index: 0, appName: "A", bundleIdentifier: "com.example.a") + let b = makeEntry(index: 1, appName: "B", bundleIdentifier: "com.example.b") + _ = store.setManualShortcut("q", for: a.id, in: [a]) + _ = store.setManualShortcut("q", for: b.id, in: [b]) + XCTAssertNotNil(store.portableBackup()) + let before = store.shortcutBindings + var signals = 0 + store.onPersistentPreferencesChange = { signals += 1 } + storage.rejectedKeys.insert("shortcut-bindings") + if case .updated = store.setManualShortcut("w", for: b.id, in: [b]) { XCTFail("Failed write reported success") } + XCTAssertEqual(store.shortcutBindings, before) + XCTAssertEqual(signals, 0) + } + private func makeEntry( index: Int, appName: String, diff --git a/Plugins/WindowSwitcher/plugin.json b/Plugins/WindowSwitcher/plugin.json index 1aa54457f..51cc04758 100644 --- a/Plugins/WindowSwitcher/plugin.json +++ b/Plugins/WindowSwitcher/plugin.json @@ -436,5 +436,11 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "partial", + "exclusions": [ + "device-state" + ] } } diff --git a/Plugins/WindowSwitcher/project.yml b/Plugins/WindowSwitcher/project.yml new file mode 100644 index 000000000..b6e2b5ad1 --- /dev/null +++ b/Plugins/WindowSwitcher/project.yml @@ -0,0 +1,3 @@ +core: + sources: + - ../../Sources/PluginSupport/PortablePreferencesStore.swift diff --git a/Plugins/XcodeClean/plugin.json b/Plugins/XcodeClean/plugin.json index 0130a2049..9b18207cf 100644 --- a/Plugins/XcodeClean/plugin.json +++ b/Plugins/XcodeClean/plugin.json @@ -235,5 +235,9 @@ "dynamicTemplates": [] } ] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Plugins/ZshConfig/plugin.json b/Plugins/ZshConfig/plugin.json index c97e7c4c3..c52ac1496 100644 --- a/Plugins/ZshConfig/plugin.json +++ b/Plugins/ZshConfig/plugin.json @@ -220,5 +220,9 @@ "includedPackIDs": [], "suggestedRecipeIDs": [], "supersedesPluginIDs": [] + }, + "preferencesBackup": { + "status": "none", + "exclusions": [] } } diff --git a/Sources/App/PreferencesImportFlow.swift b/Sources/App/PreferencesImportFlow.swift index 4142433a5..090ea40fd 100644 --- a/Sources/App/PreferencesImportFlow.swift +++ b/Sources/App/PreferencesImportFlow.swift @@ -115,9 +115,11 @@ final class PreferencesImportController: ObservableObject { + result.shortcutErrors .values .sorted() - alertMessage = warnings.isEmpty - ? importedMessage - : ([importedMessage] + warnings).joined(separator: "\n") + let outcomes = result.pluginOutcomes.filter { $0.status == "restored" || $0.status == "needsAttention" }.map { outcome in + let title = pluginHost.pluginManagementItems.first { $0.id == outcome.pluginID }?.title ?? outcome.pluginID + return "\(title): \(outcome.localizedStatus)" + } + alertMessage = ([importedMessage] + outcomes + warnings).joined(separator: "\n") } catch { pendingImport = nil alertMessage = Self.errorMessage(error) @@ -180,7 +182,7 @@ struct PreferencesImportPresentation: ViewModifier { selection: selection ) }, - pluginOptions: controller.pluginOptions(for: Set(pending.backup.pluginPreferences.keys)), + pluginOptions: controller.pluginOptions(for: Set(pending.backup.pluginPreferences.keys).union((pending.backup.pluginDiagnostics ?? []).map(\.pluginID))), isImporting: controller.isImporting, importProgress: controller.importProgress, isPreparingPlugins: host.isPreparingPlugins, @@ -207,3 +209,73 @@ struct PreferencesImportPresentation: ViewModifier { } message: { Text(controller.alertMessage ?? "") } } } + +extension PreferencesPluginBackupDiagnostic { + var localizedStatus: String { + switch status { + case "portable", "included": AppL10n.preferencesBackup("preferencesBackup.coverage.included", defaultValue: "可备份") + case "partial": AppL10n.preferencesBackup("preferencesBackup.coverage.partial", defaultValue: "部分内容保留在本机") + case "localOnly": AppL10n.preferencesBackup("preferencesBackup.coverage.localOnly", defaultValue: "保留在本机") + case "none": AppL10n.preferencesBackup("preferencesBackup.coverage.none", defaultValue: "无可移植设置") + case "notSelected": AppL10n.preferencesBackup("preferencesBackup.coverage.notSelected", defaultValue: "未选择") + case "failed", "failedRestore": AppL10n.preferencesBackup("preferencesBackup.coverage.failed", defaultValue: "失败") + case "unavailable", "unsupported": AppL10n.preferencesBackup("preferencesBackup.coverage.unavailable", defaultValue: "当前不可用") + case "readyToRestore": AppL10n.preferencesBackup("preferencesBackup.coverage.readyToRestore", defaultValue: "将导入设置") + case "restored": AppL10n.preferencesBackup("preferencesBackup.coverage.restored", defaultValue: "设置已恢复") + case "needsAttention": AppL10n.preferencesBackup("preferencesBackup.coverage.needsAttention", defaultValue: "设置已保存,需要权限") + case "deferred": AppL10n.preferencesBackup("preferencesBackup.coverage.deferred", defaultValue: "重新启动后再次导入") + default: AppL10n.preferencesBackup("preferencesBackup.coverage.unknown", defaultValue: "备份范围未声明") + } + } + + var localizedExclusions: [String] { + exclusions.compactMap { code in + switch code { + case "credentials": AppL10n.preferencesBackup("preferencesBackup.exclusion.credentials", defaultValue: "凭据保留在本机") + case "permissions": AppL10n.preferencesBackup("preferencesBackup.exclusion.permissions", defaultValue: "权限和访问授权保留在本机") + case "history": AppL10n.preferencesBackup("preferencesBackup.exclusion.history", defaultValue: "历史记录和缓存保留在本机") + case "private-content": AppL10n.preferencesBackup("preferencesBackup.exclusion.privateContent", defaultValue: "私密内容仅在单独允许后备份,或使用专用归档") + case "device-state": AppL10n.preferencesBackup("preferencesBackup.exclusion.deviceState", defaultValue: "设备状态和校准保留在本机") + case "local-activation": AppL10n.preferencesBackup("preferencesBackup.exclusion.localActivation", defaultValue: "运行状态和本机启用授权保留在本机") + case "application-paths": AppL10n.preferencesBackup("preferencesBackup.exclusion.applicationPaths", defaultValue: "应用按身份恢复,缺失的目标保持未解析") + case "external-authorization": AppL10n.preferencesBackup("preferencesBackup.exclusion.externalAuthorization", defaultValue: "外部执行和高风险输入需在本机授权") + case "local-policy": AppL10n.preferencesBackup("preferencesBackup.exclusion.localPolicy", defaultValue: "本机目录和操作策略不包含在备份中") + default: nil + } + } + } +} + +struct PreferencesBackupCoverageView: View { + let coverage: [PreferencesPluginBackupDiagnostic] + let pluginOptions: [PreferencesPluginOption] + + var body: some View { + if !coverage.isEmpty { + DisclosureGroup { + VStack(alignment: .leading, spacing: 10) { + ForEach(coverage) { item in + VStack(alignment: .leading, spacing: 3) { + Text("\(pluginOptions.first { $0.id == item.pluginID }?.title ?? item.pluginID): \(item.localizedStatus)") + .foregroundStyle(item.blocksSelectedExport ? Color.red : Color.primary) + ForEach(item.localizedExclusions, id: \.self) { Text($0).foregroundStyle(.secondary) } + } + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.top, 6) + } label: { + VStack(alignment: .leading, spacing: 3) { + Text(AppL10n.preferencesBackup("preferencesBackup.coverage.title", defaultValue: "备份范围与排除项")) + Text(AppL10n.preferencesBackupFormat("preferencesBackup.coverage.summary", defaultValue: "包含 %d · 仅本机 %d · 无设置 %d · 不可用 %d", + coverage.filter { ["portable", "partial", "included", "readyToRestore", "restored", "needsAttention"].contains($0.status) }.count, + coverage.filter { $0.status == "localOnly" }.count, + coverage.filter { $0.status == "none" }.count, + coverage.filter { ["failed", "failedRestore", "unavailable", "unsupported"].contains($0.status) }.count)) + .foregroundStyle(.secondary) + } + } + .font(PluginSettingsTheme.Typography.rowDescription) + } + } +} diff --git a/Sources/App/SettingsView.swift b/Sources/App/SettingsView.swift index 00d6164b0..d71d88863 100644 --- a/Sources/App/SettingsView.swift +++ b/Sources/App/SettingsView.swift @@ -1171,6 +1171,9 @@ private struct PreferencesBackupSettingsRow: View { @State private var isChoosingExport = false @State private var exportSelection = PreferencesBackupSelection.all(pluginPreferenceIDs: []) @State private var exportPluginOptions: [PreferencesPluginOption] = [] + @State private var exportCoverage: [PreferencesPluginBackupDiagnostic] = [] + @State private var exportCoverageOptions: [PreferencesPluginOption] = [] + @State private var exportFailure: String? @State private var deviceLocalAutomationRuleCount = 0 @State private var alertMessage: String? @State private var isBackingUp = false @@ -1326,12 +1329,12 @@ private struct PreferencesBackupSettingsRow: View { PreferencesExportSelectionSheet( selection: $exportSelection, pluginOptions: exportPluginOptions, + coverage: exportCoverage, + coverageOptions: exportCoverageOptions, + exportFailure: exportFailure, deviceLocalAutomationRuleCount: deviceLocalAutomationRuleCount, onCancel: { isChoosingExport = false }, - onExport: { - isChoosingExport = false - savePreferences(selection: exportSelection) - } + onExport: { savePreferences(selection: exportSelection) } ) .environment(\.locale, locale) .environment(\.layoutDirection, layoutDirection) @@ -1475,9 +1478,12 @@ private struct PreferencesBackupSettingsRow: View { } private func exportPreferences() { - let backup = pluginHost.makePreferencesBackup() - exportSelection = .all(pluginPreferenceIDs: Set(backup.pluginPreferences.keys)) - exportPluginOptions = importer.pluginOptions(for: Set(backup.pluginPreferences.keys)) + let candidates = pluginHost.preferencesBackupCandidateIDs + exportSelection = .all(pluginPreferenceIDs: candidates) + exportPluginOptions = importer.pluginOptions(for: candidates) + exportCoverage = pluginHost.preferencesBackupCoverage + exportCoverageOptions = importer.pluginOptions(for: Set(exportCoverage.map(\.pluginID))) + exportFailure = nil deviceLocalAutomationRuleCount = pluginHost.deviceLocalAutomationRuleCount isChoosingExport = true } @@ -1485,14 +1491,17 @@ private struct PreferencesBackupSettingsRow: View { private func savePreferences(selection: PreferencesBackupSelection) { let data: Data do { + let result = pluginHost.makePreferencesBackupExport(selection: selection) + exportCoverage = result.pluginOutcomes data = try PreferencesArchiveDocument( scope: .full, - backup: pluginHost.makePreferencesBackup(selection: selection) + backup: result.requiringSuccessfulSelection() ).encodedJSON() } catch { - alertMessage = preferencesBackupErrorMessage(error) + exportFailure = preferencesBackupErrorMessage(error) return } + isChoosingExport = false let panel = NSSavePanel() panel.allowedContentTypes = [.json] @@ -1913,13 +1922,24 @@ enum PreferencesBackupStatusFormatter { } } -private struct PreferencesExportSelectionSheet: View { +struct PreferencesExportSelectionSheet: View { @Binding var selection: PreferencesBackupSelection let pluginOptions: [PreferencesPluginOption] + let coverage: [PreferencesPluginBackupDiagnostic] + let coverageOptions: [PreferencesPluginOption] + let exportFailure: String? let deviceLocalAutomationRuleCount: Int let onCancel: () -> Void let onExport: () -> Void + private var displayedCoverage: [PreferencesPluginBackupDiagnostic] { + coverage.map { diagnostic in + guard pluginOptions.contains(where: { $0.id == diagnostic.pluginID }), + !selection.pluginPreferenceIDs.contains(diagnostic.pluginID) else { return diagnostic } + return .init(pluginID: diagnostic.pluginID, status: "notSelected", exclusions: diagnostic.exclusions) + } + } + var body: some View { VStack(alignment: .leading, spacing: 18) { Text(AppL10n.preferencesBackup( @@ -1935,10 +1955,21 @@ private struct PreferencesExportSelectionSheet: View { .font(PluginSettingsTheme.Typography.rowDescription) .foregroundStyle(.secondary) - PreferencesSelectionFields( - selection: $selection, - pluginOptions: pluginOptions - ) + ScrollView { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) { + PreferencesSelectionFields(selection: $selection, pluginOptions: pluginOptions) + PreferencesBackupCoverageView(coverage: displayedCoverage, pluginOptions: coverageOptions) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 440) + + if let exportFailure { + Text(exportFailure) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } if selection.includesAutomation, deviceLocalAutomationRuleCount > 0 { Label { @@ -1958,11 +1989,13 @@ private struct PreferencesExportSelectionSheet: View { Spacer() Button(AppL10n.settings("common.cancel", defaultValue: "取消"), action: onCancel) .buttonStyle(.bordered) + .keyboardShortcut(.cancelAction) Button(AppL10n.preferencesBackup( "preferencesBackup.exportSelection.confirm", defaultValue: "继续导出…" ), action: onExport) .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) .disabled(selection.isEmpty) } } @@ -2369,10 +2402,12 @@ struct PreferencesImportPreviewSheet: View { PreferencesSelectionFields( selection: $selection, - pluginOptions: pluginOptions, + pluginOptions: pluginOptions.filter { preview.selection.pluginPreferenceIDs.contains($0.id) }, availableSelection: preview.selection ) + PreferencesBackupCoverageView(coverage: currentPreview.pluginDiagnostics, pluginOptions: pluginOptions) + if let previewErrorMessage { Text(previewErrorMessage) .font(PluginSettingsTheme.Typography.rowDescription) @@ -2381,7 +2416,7 @@ struct PreferencesImportPreviewSheet: View { Text(AppL10n.preferencesBackup( "preferencesBackup.preview.replaceNotice", - defaultValue: "将替换以上偏好类别;备份中未包含的设置会恢复为默认值。" + defaultValue: "仅替换所选类别;未包含的插件设置和本机数据会保留。导入快捷键时,备份中未包含的绑定会恢复为默认值。" )) .font(PluginSettingsTheme.Typography.rowDescription) .foregroundStyle(.secondary) diff --git a/Sources/Core/Plugins/Dynamic/Catalog/PluginCatalog.swift b/Sources/Core/Plugins/Dynamic/Catalog/PluginCatalog.swift index 7b78d67dd..97ed472fb 100644 --- a/Sources/Core/Plugins/Dynamic/Catalog/PluginCatalog.swift +++ b/Sources/Core/Plugins/Dynamic/Catalog/PluginCatalog.swift @@ -62,6 +62,7 @@ struct PluginCatalogEntry: Codable, Identifiable, Equatable { let actions: PluginProductMetadata.Actions? let setup: PluginProductMetadata.Setup? let relationships: PluginProductMetadata.Relationships? + let preferencesBackup: PluginPreferencesBackupCoverage? init( id: String, @@ -83,7 +84,8 @@ struct PluginCatalogEntry: Codable, Identifiable, Equatable { privacy: PluginProductMetadata.Privacy? = nil, actions: PluginProductMetadata.Actions? = nil, setup: PluginProductMetadata.Setup? = nil, - relationships: PluginProductMetadata.Relationships? = nil + relationships: PluginProductMetadata.Relationships? = nil, + preferencesBackup: PluginPreferencesBackupCoverage? = nil ) { self.id = id self.displayName = displayName @@ -105,6 +107,7 @@ struct PluginCatalogEntry: Codable, Identifiable, Equatable { self.actions = actions self.setup = setup self.relationships = relationships + self.preferencesBackup = preferencesBackup } var localizedDisplayName: String { diff --git a/Sources/Core/Plugins/Dynamic/PluginPackageManifest.swift b/Sources/Core/Plugins/Dynamic/PluginPackageManifest.swift index e5ce9daff..c86d01e93 100644 --- a/Sources/Core/Plugins/Dynamic/PluginPackageManifest.swift +++ b/Sources/Core/Plugins/Dynamic/PluginPackageManifest.swift @@ -96,6 +96,7 @@ struct PluginPackageManifest: Codable, Equatable { let actions: PluginProductMetadata.Actions? let setup: PluginProductMetadata.Setup? let relationships: PluginProductMetadata.Relationships? + let preferencesBackup: PluginPreferencesBackupCoverage? init( id: String, @@ -117,7 +118,8 @@ struct PluginPackageManifest: Codable, Equatable { privacy: PluginProductMetadata.Privacy? = nil, actions: PluginProductMetadata.Actions? = nil, setup: PluginProductMetadata.Setup? = nil, - relationships: PluginProductMetadata.Relationships? = nil + relationships: PluginProductMetadata.Relationships? = nil, + preferencesBackup: PluginPreferencesBackupCoverage? = nil ) { self.id = id self.displayName = displayName @@ -139,6 +141,7 @@ struct PluginPackageManifest: Codable, Equatable { self.actions = actions self.setup = setup self.relationships = relationships + self.preferencesBackup = preferencesBackup } var effectiveUninstallDataPolicy: UninstallDataPolicy { @@ -302,3 +305,25 @@ enum PluginPackageManifestLoader { } } + +/// The installed package's declaration. Runtime provider availability is checked separately. +struct PluginPreferencesBackupCoverage: Codable, Equatable { + enum Status: String, Codable, CaseIterable { + case portable, partial, localOnly, none, unsupported + } + + enum Exclusion: String, Codable, CaseIterable { + case credentials, permissions, history + case privateContent = "private-content" + case deviceState = "device-state" + case localActivation = "local-activation" + case applicationPaths = "application-paths" + case externalAuthorization = "external-authorization" + case localPolicy = "local-policy" + } + + let status: Status + let exclusions: [Exclusion] + + var expectsProvider: Bool { status == .portable || status == .partial } +} diff --git a/Sources/Core/Plugins/PluginHost.swift b/Sources/Core/Plugins/PluginHost.swift index cf9616444..a9351ee9f 100644 --- a/Sources/Core/Plugins/PluginHost.swift +++ b/Sources/Core/Plugins/PluginHost.swift @@ -441,6 +441,7 @@ final class PluginHost: ObservableObject { #if DEBUG var actionShortcutBurstUptimeProviderForTests: (() -> TimeInterval)? #endif + private var preferencesBackupExportActionReferences: [String: Set]? private var preferencesBackupExportSelection: PreferencesBackupSelection? private var preferencesBackupRestoreContext: PreferencesActionRestoreContext? @@ -789,7 +790,8 @@ final class PluginHost: ObservableObject { if let automaticPreferencesBackupCoordinator { automaticPreferencesBackupEnabled = automaticPreferencesBackupCoordinator.isEnabled automaticPreferencesBackupCoordinator.snapshotProvider = { [weak self] in - self?.makePreferencesBackup() + guard let self else { return nil } + return try self.makePreferencesBackupExport().requiringSuccessfulSelection() } automaticPreferencesBackupCoordinator.failureHandler = { error in AppLog.preferencesBackup.error( @@ -812,7 +814,8 @@ final class PluginHost: ObservableObject { cloudPreferencesSyncStatus = cloudPreferencesSyncCoordinator.status cloudPreferencesSyncDirectoryURL = cloudPreferencesSyncCoordinator.syncDirectoryURL cloudPreferencesSyncCoordinator.snapshotProvider = { [weak self] in - self?.makePreferencesBackup() + guard let self else { return nil } + return try self.makePreferencesBackupExport().requiringSuccessfulSelection() } cloudPreferencesSyncCoordinator.importHandler = { [weak self] backup in guard let self else { return } @@ -977,6 +980,12 @@ final class PluginHost: ObservableObject { func makePreferencesBackup( selection requestedSelection: PreferencesBackupSelection? = nil ) -> PreferencesBackup { + makePreferencesBackupExport(selection: requestedSelection).backup + } + + func makePreferencesBackupExport( + selection requestedSelection: PreferencesBackupSelection? = nil + ) -> PreferencesBackupExportResult { let shortcutDescriptors = shortcutDescriptors() var shortcutCustomizations = shortcutStore.customizations( for: shortcutDescriptors.map(\.itemID) @@ -989,14 +998,31 @@ final class PluginHost: ObservableObject { let proposedSelection = requestedSelection ?? allPortablePreferencesSelection preferencesBackupExportSelection = proposedSelection - defer { preferencesBackupExportSelection = nil } - let proposedPortablePreferences = portablePluginPreferences() - let selection = requestedSelection - ?? .all(pluginPreferenceIDs: Set(proposedPortablePreferences.keys)) + defer { preferencesBackupExportSelection = nil; preferencesBackupExportActionReferences = nil } + preferencesBackupExportActionReferences = [:] + let exported = portablePluginPreferences(selectedIDs: proposedSelection.pluginPreferenceIDs) + let proposedPortablePreferences = exported.preferences + var selection = proposedSelection + selection.pluginPreferenceIDs.formIntersection(proposedPortablePreferences.keys) var dependencySelection = selection dependencySelection.pluginPreferenceIDs.formIntersection(proposedPortablePreferences.keys) preferencesBackupExportSelection = dependencySelection + for descriptor in shortcutDescriptors { + guard let provider = descriptor.plugin as? any PluginActionReferenceBackupProviding else { continue } + let reference = ActionReference(key: ActionKey(providerID: descriptor.pluginID, actionID: descriptor.definition.actionID)) + let disposition = guardedValue(for: descriptor.plugin, operation: "classify shortcut preference backup", provider.backupDisposition(for: reference)) ?? .excluded + let included: Bool + switch disposition { + case .selfContained: included = true + case .excluded: included = false + case .requiresPluginPreferences: + included = dependencySelection.pluginPreferenceIDs.contains(descriptor.pluginID) + && (!(descriptor.plugin is any PluginPortablePreferencesActionReferencesProviding) + || (exported.references[descriptor.pluginID] ?? []).contains(reference)) + } + if !included { shortcutCustomizations.removeValue(forKey: descriptor.itemID) } + } let automationSnapshot = automationController.preferencesBackupSnapshot() let portableWorkflowIDs = WorkflowPortabilityAnalysis.portableWorkflowIDs( in: automationSnapshot.workflows, @@ -1023,7 +1049,7 @@ final class PluginHost: ObservableObject { selection: dependencySelection ) == .portable } - return PreferencesBackup( + let backup = PreferencesBackup( application: preferencesBackupStore.applicationPreferences(), pluginDisplay: PluginDisplayPreferencesBackup( orderedPluginIDs: orderedPluginIDs(), hiddenPluginIDs: [], @@ -1035,9 +1061,7 @@ final class PluginHost: ObservableObject { } : [], pluginPreferences: selectedPortablePreferences, - pluginPreferenceActionReferences: portablePreferenceActionReferences( - in: selectedPortablePreferences - ), + pluginPreferenceActionReferences: exported.references, actionInvocationPresets: selection.includesRunLinks ? actionPresetStore.presets().compactMap { preset in let migratedReference = migratedActionReferenceForRestore(preset.reference) @@ -1059,8 +1083,10 @@ final class PluginHost: ObservableObject { && AutomationRulePortabilityAnalysis.isPortable($0) } : [], - selection: selection + selection: selection, + pluginDiagnostics: exported.outcomes ) + return PreferencesBackupExportResult(backup: backup, pluginOutcomes: exported.outcomes) } var deviceLocalAutomationRuleCount: Int { @@ -1083,7 +1109,7 @@ final class PluginHost: ObservableObject { in: backup, selection: effectiveSelection ) - return try PreferencesImportPreview.make( + var preview = try PreferencesImportPreview.make( backup: backup, availablePluginIDs: Set(defaultPluginIDs), availableShortcutIDs: Set( @@ -1108,6 +1134,12 @@ final class PluginHost: ObservableObject { selection: effectiveSelection, applicationPreferencesAreValid: preferencesBackupStore.validates ) + let availableProviders = Set(activePlugins.filter { $0 is any PluginPortablePreferencesProviding }.map { $0.metadata.id }) + preview.pluginDiagnostics = preview.pluginDiagnostics.map { diagnostic in + guard diagnostic.status == "readyToRestore", !availableProviders.contains(diagnostic.pluginID) else { return diagnostic } + return .init(pluginID: diagnostic.pluginID, status: "unavailable", exclusions: diagnostic.exclusions) + } + return preview } func importPreferences( @@ -1164,6 +1196,10 @@ final class PluginHost: ObservableObject { let result = PreferencesImportResult( installedPluginIDs: installedPluginIDs, deferredPluginPreferenceIDs: deferredPluginPreferenceIDs, + pluginOutcomes: importedResult.pluginOutcomes.map { outcome in + deferredPluginPreferenceIDs.contains(outcome.pluginID) + ? .init(pluginID: outcome.pluginID, status: "deferred", exclusions: outcome.exclusions) : outcome + }, pluginInstallationFailures: pluginInstallationFailures, shortcutErrors: shortcutErrors ) @@ -1185,7 +1221,7 @@ final class PluginHost: ObservableObject { /// Cloud updates replace portable records only. Local records are captured /// from their stores, since a portable backup deliberately omits them. func importCloudPreferences(_ backup: PreferencesBackup) throws -> PreferencesImportResult { - let exported = makePreferencesBackup() + let exported = try makePreferencesBackupExport().requiringSuccessfulSelection() let portable = CloudPreferencesSyncCoordinator.filterMachineSpecificPreferences(exported) let automation = automationController.preferencesBackupSnapshot() let portableWorkflowIDs = Set((portable.workflows ?? []).map(\.id)) @@ -1276,9 +1312,6 @@ final class PluginHost: ObservableObject { ) } else { entry.value } } - let shortcutCustomizations = backup.shortcutCustomizations.merging( - local?.shortcutCustomizations ?? [:], uniquingKeysWith: { _, local in local } - ) preferencesBackupRestoreContext = restoreContext defer { preferencesBackupRestoreContext = nil @@ -1373,6 +1406,11 @@ final class PluginHost: ObservableObject { ) } if selection.includesShortcuts { + // Source definitions must succeed before their recorder bindings can transfer. + // Merge destination-only cloud bindings afterward; they are not incoming content. + let shortcutCustomizations = restorableShortcutCustomizations( + backup.shortcutCustomizations, context: restoreContext + ).merging(local?.shortcutCustomizations ?? [:], uniquingKeysWith: { _, local in local }) if backup.actionShortcutAssignmentsWereEncoded { let descriptors = shortcutDescriptors() let appCustomizations = Dictionary( @@ -1471,8 +1509,20 @@ final class PluginHost: ObservableObject { shortcutErrors["run-links"] = FeatureL10n.string("无法保存运行链接预设。") } rebuildDerivedState(synchronizingShortcuts: true) + let restoredIDs = restoredProviderIDs.union(restoredSurfaceIDs) + let outcomes = selection.pluginPreferenceIDs.sorted().map { id in + var status = restoredIDs.contains(id) ? "restored" : "failedRestore" + if local?.protectedPluginIDs.contains(id) == true { status = "localOnly" } + else if !activePlugins.contains(where: { $0.metadata.id == id && $0 is any PluginPortablePreferencesProviding }) { status = "unavailable" } + if restoredIDs.contains(id), let plugin = corePlugin(for: id), + guardedValue(for: plugin, operation: "check restored preference permissions", { + plugin.permissionRequirements.contains { !plugin.permissionState(for: $0.id).isGranted } + }()) == true { status = "needsAttention" } + return PreferencesPluginBackupDiagnostic(pluginID: id, status: status, exclusions: []) + } return PreferencesImportResult( installedPluginIDs: [], + pluginOutcomes: outcomes, pluginInstallationFailures: [:], shortcutErrors: shortcutErrors ) @@ -2742,11 +2792,29 @@ final class PluginHost: ObservableObject { } private var portablePluginPreferenceIDs: Set { - Set(activePlugins.compactMap { plugin in + Set(plugins.compactMap { plugin in plugin is any PluginPortablePreferencesProviding ? plugin.metadata.id : nil + }).union(dynamicPluginManifestsByID.compactMap { id, manifest in + manifest.preferencesBackup?.expectsProvider == true ? id : nil }) } + var preferencesBackupCandidateIDs: Set { portablePluginPreferenceIDs } + + var preferencesBackupCoverage: [PreferencesPluginBackupDiagnostic] { + let loadedIDs = Set(plugins.map { $0.metadata.id }) + return loadedIDs.union(dynamicPluginManifestsByID.keys).sorted().map { id in + let declaration = dynamicPluginManifestsByID[id]?.preferencesBackup + let hasProvider = plugins.contains { $0.metadata.id == id && $0 is any PluginPortablePreferencesProviding } + let providerAvailable = activePlugins.contains { $0.metadata.id == id && $0 is any PluginPortablePreferencesProviding } + let status = (declaration?.expectsProvider == true || hasProvider) && !providerAvailable + ? "unavailable" : (declaration?.status.rawValue ?? (hasProvider ? "unspecified" : "unknown")) + return PreferencesPluginBackupDiagnostic( + pluginID: id, status: status, exclusions: declaration?.exclusions.map(\.rawValue) ?? [] + ) + } + } + private var allPortablePreferencesSelection: PreferencesBackupSelection { .all(pluginPreferenceIDs: portablePluginPreferenceIDs) } @@ -2756,11 +2824,11 @@ final class PluginHost: ObservableObject { selection: PreferencesBackupSelection ) -> ActionReferencePortability { let schemaPortability = actionRegistry.portability(of: reference) - guard schemaPortability == .portable else { return schemaPortability } + guard schemaPortability != .knownNonPortable else { return schemaPortability } guard let plugin = activePlugins.first(where: { $0.metadata.id == reference.key.providerID }), let provider = plugin as? any PluginActionReferenceBackupProviding else { - return .portable + return schemaPortability } switch guardedValue( @@ -2769,11 +2837,16 @@ final class PluginHost: ObservableObject { provider.backupDisposition(for: reference) ) ?? .excluded { case .selfContained: - return .portable + return schemaPortability case .requiresPluginPreferences: - return selection.pluginPreferenceIDs.contains(plugin.metadata.id) - ? .portable - : .knownNonPortable + guard selection.pluginPreferenceIDs.contains(plugin.metadata.id) else { return .knownNonPortable } + if plugin is any PluginPortablePreferencesActionReferencesProviding, + let indexes = preferencesBackupExportActionReferences { + let migrated = migratedActionReferenceForRestore(reference) + let definitions = indexes[plugin.metadata.id] ?? [] + return definitions.contains(reference) || definitions.contains(migrated) ? .portable : .knownNonPortable + } + return schemaPortability case .excluded: return .knownNonPortable } @@ -3030,19 +3103,56 @@ final class PluginHost: ObservableObject { return migrated } - private func portablePluginPreferences() -> [String: Data] { - activePlugins.reduce(into: [String: Data]()) { result, plugin in - guard let portablePreferences = plugin as? any PluginPortablePreferencesProviding, - let data = guardedOptionalValue( - for: plugin, - operation: "export portable preferences", - portablePreferences.makePortablePreferencesBackup() - ) - else { - return + private func portablePluginPreferences(selectedIDs: Set) -> ( + preferences: [String: Data], references: [String: [ActionReference]], outcomes: [PreferencesPluginBackupDiagnostic] + ) { + // Capture intention before guarded calls can isolate an exporter. + let candidates = preferencesBackupCoverage + let active = Dictionary(activePlugins.map { ($0.metadata.id, $0) }, uniquingKeysWith: { first, _ in first }) + var preferences: [String: Data] = [:] + var references: [String: [ActionReference]] = [:] + var outcomes: [PreferencesPluginBackupDiagnostic] = [] + func usesActionContext(_ id: String) -> Bool { + active[id] is any ActionGridHostContextConsuming || active[id] is any TrackpadActionHostContextConsuming + } + let allIDs = Set(candidates.map(\.pluginID)).union(selectedIDs).sorted { + if usesActionContext($0) != usesActionContext($1) { return !usesActionContext($0) } + return $0 < $1 + } + for id in allIDs { + let declaration = candidates.first { $0.pluginID == id } + let exclusions = declaration?.exclusions ?? [] + guard selectedIDs.contains(id) else { + outcomes.append(.init(pluginID: id, + status: portablePluginPreferenceIDs.contains(id) ? "notSelected" : (declaration?.status ?? "unknown"), exclusions: exclusions)) + continue + } + guard let plugin = active[id], let provider = plugin as? any PluginPortablePreferencesProviding else { + outcomes.append(.init(pluginID: id, status: "unavailable", exclusions: exclusions)) + continue + } + guard let data = guardedOptionalValue(for: plugin, operation: "export portable preferences", provider.makePortablePreferencesBackup()), + data.count <= PreferencesBackup.maximumFileSize else { + outcomes.append(.init(pluginID: id, status: "failed", exclusions: exclusions)) + continue } - result[plugin.metadata.id] = data + if let indexer = plugin as? any PluginPortablePreferencesActionReferencesProviding { + guard let indexed = guardedOptionalValue(for: plugin, operation: "index portable preference actions", indexer.actionReferences(inPortablePreferences: data)), + indexed.count <= 10_000 else { + outcomes.append(.init(pluginID: id, status: "failed", exclusions: exclusions)) + continue + } + references[id] = uniqueActionReferences(indexed) + } + preferences[id] = data + preferencesBackupExportActionReferences = references.mapValues(Set.init) + if var selection = preferencesBackupExportSelection { + selection.pluginPreferenceIDs = Set(preferences.keys) + preferencesBackupExportSelection = selection + } + outcomes.append(.init(pluginID: id, status: "included", exclusions: exclusions)) } + return (preferences, references, outcomes) } private func portablePreferenceActionReferences( @@ -5215,6 +5325,20 @@ final class PluginHost: ObservableObject { return legacyResolvedBinding(for: descriptor) } + private func restorableShortcutCustomizations( + _ incoming: [String: ShortcutCustomization], + context: PreferencesActionRestoreContext + ) -> [String: ShortcutCustomization] { + var result = incoming + for descriptor in shortcutDescriptors() where descriptor.plugin is any PluginActionReferenceBackupProviding { + let reference = ActionReference(key: .init(providerID: descriptor.pluginID, actionID: descriptor.definition.actionID)) + if actionReferenceRestorePortability(reference, context: context) == .knownNonPortable { + result.removeValue(forKey: descriptor.itemID) + } + } + return result + } + private func applyImportedShortcutCustomizations( _ importedCustomizations: [String: ShortcutCustomization], bridgesLegacyActionAssignments: Bool, @@ -5226,6 +5350,8 @@ final class PluginHost: ObservableObject { (action, importedCustomizations[action.rawValue] ?? .inheritDefault) } ) + // Importing Shortcuts replaces the entire selected category, including local item + // bindings omitted from the portable archive. Missing bindings return to defaults. let targetCustomizations = Dictionary( descriptors.map { descriptor in (descriptor.itemID, importedCustomizations[descriptor.itemID] ?? .inheritDefault) diff --git a/Sources/Core/Preferences/AutomaticPreferencesBackup.swift b/Sources/Core/Preferences/AutomaticPreferencesBackup.swift index 68a949466..39253fc4c 100644 --- a/Sources/Core/Preferences/AutomaticPreferencesBackup.swift +++ b/Sources/Core/Preferences/AutomaticPreferencesBackup.swift @@ -300,7 +300,7 @@ final class AutomaticPreferencesBackupCoordinator { private var latestRelevantRevision: UInt64 = 0 private var completedRelevantRevision: UInt64 = 0 - var snapshotProvider: (() -> PreferencesBackup?)? + var snapshotProvider: (() throws -> PreferencesBackup?)? var failureHandler: ((Error) -> Void)? var summaryHandler: ((AutomaticPreferencesBackupSummary) -> Void)? private(set) var isEnabled: Bool @@ -353,7 +353,7 @@ final class AutomaticPreferencesBackupCoordinator { guard !Task.isCancelled else { return } pendingTask = nil let revision = latestRelevantRevision - guard let backup = snapshotProvider?() else { + guard let backup = try snapshotProvider?() else { throw CocoaError(.fileNoSuchFile) } let attempt = try await performBackupNow( @@ -388,7 +388,7 @@ final class AutomaticPreferencesBackupCoordinator { pendingTask = nil let revision = latestRelevantRevision do { - guard let backup = snapshotProvider?() else { + guard let backup = try snapshotProvider?() else { throw CocoaError(.fileNoSuchFile) } let attempt = try await performBackupNow( @@ -421,7 +421,7 @@ final class AutomaticPreferencesBackupCoordinator { backup providedBackup: PreferencesBackup? = nil, revision: UInt64 ) async throws -> (result: AutomaticPreferencesBackupWriteResult, wasAccepted: Bool) { - guard let backup = providedBackup ?? snapshotProvider?() else { + guard let backup = try providedBackup ?? snapshotProvider?() else { throw CocoaError(.fileNoSuchFile) } let (outcome, summary) = try await Task.detached(priority: .utility) { [store] in @@ -453,7 +453,7 @@ final class AutomaticPreferencesBackupCoordinator { pendingTask = nil let revision = latestRelevantRevision do { - guard let backup = snapshotProvider?() else { + guard let backup = try snapshotProvider?() else { reconcilePendingBackup() return } @@ -482,8 +482,8 @@ final class AutomaticPreferencesBackupCoordinator { pendingTask?.cancel() pendingTask = nil let revision = latestRelevantRevision - guard let backup = snapshotProvider?() else { return } do { + guard let backup = try snapshotProvider?() else { return } let outcome = try store.writeIfCurrent( backup, relevantRevision: revision diff --git a/Sources/Core/Preferences/CloudPreferencesSyncCoordinator.swift b/Sources/Core/Preferences/CloudPreferencesSyncCoordinator.swift index 8a43e6fdf..e666aa8af 100644 --- a/Sources/Core/Preferences/CloudPreferencesSyncCoordinator.swift +++ b/Sources/Core/Preferences/CloudPreferencesSyncCoordinator.swift @@ -23,7 +23,7 @@ final class CloudPreferencesSyncCoordinator { private(set) var lastSyncedAt: Date? private(set) var status: CloudPreferencesSyncStatus = .offline(reason: .disabled) - var snapshotProvider: (() -> PreferencesBackup?)? + var snapshotProvider: (() throws -> PreferencesBackup?)? var importHandler: ((PreferencesBackup) throws -> Void)? var statusHandler: ((CloudPreferencesSyncStatus) -> Void)? var directoryURLHandler: ((URL?) -> Void)? @@ -181,9 +181,10 @@ final class CloudPreferencesSyncCoordinator { case .shared: try applyShared(shared) case .local: + guard let local = try currentBackup() else { throw CocoaError(.fileReadUnknown) } state.shared = shared state.conflict = nil - state.pending = currentBackup() + state.pending = local state.needsPublication = true currentGeneration = max(currentGeneration, shared.generation) try persistState() @@ -283,7 +284,7 @@ final class CloudPreferencesSyncCoordinator { } updateStatus(to: .syncing) try session.checkCancellation() - guard let backup = currentBackup() else { throw CocoaError(.fileReadUnknown) } + guard let backup = try currentBackup() else { throw CocoaError(.fileReadUnknown) } let revision = localRevision guard currentGeneration < UInt64.max else { throw CocoaError(.fileWriteUnknown) } let snapshot = CloudPreferencesSnapshot( @@ -339,7 +340,7 @@ final class CloudPreferencesSyncCoordinator { try persistState() return } - if let current = currentBackup(), current.hasSameMeaningfulContent(as: incoming) { + if let current = try currentBackup(), current.hasSameMeaningfulContent(as: incoming) { try accept(snapshot) } else if state.pending != nil || state.needsPublication || isSibling || (snapshot.isCloudSnapshot && snapshot.generation < base.generation) { @@ -353,7 +354,7 @@ final class CloudPreferencesSyncCoordinator { // Legacy clients persisted only a counter, so ancestry is unknown. try recordConflict(snapshot) } else if state.pending != nil { - if currentBackup()?.hasSameMeaningfulContent(as: incoming) == true { + if try currentBackup()?.hasSameMeaningfulContent(as: incoming) == true { try accept(snapshot) } else { try recordConflict(snapshot) } } else if snapshot.deviceID == localDeviceID { @@ -372,8 +373,9 @@ final class CloudPreferencesSyncCoordinator { } private func accept(_ snapshot: CloudPreferencesSnapshot) throws { + let baseline = try currentBackup() ?? Self.filterMachineSpecificPreferences(snapshot.backup) state.shared = snapshot - state.localBaseline = currentBackup() ?? Self.filterMachineSpecificPreferences(snapshot.backup) + state.localBaseline = baseline state.pending = nil state.conflict = nil state.needsPublication = !snapshot.isCloudSnapshot @@ -388,7 +390,7 @@ final class CloudPreferencesSyncCoordinator { } private func recordConflict(_ snapshot: CloudPreferencesSnapshot) throws { - guard let local = currentBackup() else { throw CocoaError(.fileReadUnknown) } + guard let local = try currentBackup() else { throw CocoaError(.fileReadUnknown) } state.conflict = CloudPreferencesConflict(local: local, shared: snapshot) state.pending = local try persistState() @@ -399,13 +401,13 @@ final class CloudPreferencesSyncCoordinator { lhs.documentID == rhs.documentID && lhs.backup.hasSameMeaningfulContent(as: rhs.backup) } - private func currentBackup() -> PreferencesBackup? { - snapshotProvider?().map(Self.filterMachineSpecificPreferences) + private func currentBackup() throws -> PreferencesBackup? { + try snapshotProvider?().map(Self.filterMachineSpecificPreferences) } private func captureLocalChanges(isCommit: Bool = false) throws { if let stateLoadError { throw stateLoadError } - guard let current = currentBackup(), syncDirectoryURL != nil else { return } + guard let current = try currentBackup(), syncDirectoryURL != nil else { return } if let baseline = state.localBaseline { state.pending = baseline.hasSameMeaningfulContent(as: current) ? nil : current } else if isCommit || state.pending != nil { @@ -615,7 +617,12 @@ final class CloudPreferencesSyncCoordinator { actionInvocationPresets: actionInvocationPresets, workflows: workflows, automationRules: automationRules, - selection: backup.selection, + selection: backup.selection.map { selection in + var sanitized = selection + sanitized.pluginPreferenceIDs.formIntersection(pluginPreferences.keys) + return sanitized + }, + pluginDiagnostics: backup.pluginDiagnostics, exportedAt: backup.exportedAt ) } diff --git a/Sources/Core/Preferences/PreferencesBackup.swift b/Sources/Core/Preferences/PreferencesBackup.swift index 60bc04905..92e8a26db 100644 --- a/Sources/Core/Preferences/PreferencesBackup.swift +++ b/Sources/Core/Preferences/PreferencesBackup.swift @@ -72,6 +72,7 @@ struct PreferencesBackup: Codable, Equatable, Sendable { let workflows: [WorkflowDefinition]? let automationRules: [AutomationRule]? let selection: PreferencesBackupSelection? + let pluginDiagnostics: [PreferencesPluginBackupDiagnostic]? init( application: ApplicationPreferences, @@ -84,6 +85,7 @@ struct PreferencesBackup: Codable, Equatable, Sendable { workflows: [WorkflowDefinition]? = [], automationRules: [AutomationRule]? = [], selection: PreferencesBackupSelection? = nil, + pluginDiagnostics: [PreferencesPluginBackupDiagnostic]? = nil, exportedAt: Date = .now ) { self.formatVersion = Self.currentFormatVersion @@ -98,6 +100,7 @@ struct PreferencesBackup: Codable, Equatable, Sendable { self.workflows = workflows self.automationRules = automationRules self.selection = selection ?? .all(pluginPreferenceIDs: Set(pluginPreferences.keys)) + self.pluginDiagnostics = PreferencesPluginBackupDiagnostic.bounded(pluginDiagnostics) } private enum CodingKeys: String, CodingKey { @@ -113,6 +116,7 @@ struct PreferencesBackup: Codable, Equatable, Sendable { case workflows case automationRules case selection + case pluginDiagnostics } init(from decoder: Decoder) throws { @@ -155,6 +159,10 @@ struct PreferencesBackup: Codable, Equatable, Sendable { PreferencesBackupSelection.self, forKey: .selection ) + // Display-only metadata must never prevent restoration of an otherwise valid archive. + pluginDiagnostics = PreferencesPluginBackupDiagnostic.bounded( + try? container.decode([PreferencesPluginBackupDiagnostic].self, forKey: .pluginDiagnostics) + ) if formatVersion >= 4, actionInvocationPresets == nil || workflows == nil || automationRules == nil { throw DecodingError.dataCorrupted( @@ -450,6 +458,7 @@ struct PreferencesImportPreview: Equatable { let retainedUnavailableActionReferences: [ActionReference] let installablePlugins: [PreferencesImportInstallablePlugin] let selection: PreferencesBackupSelection + var pluginDiagnostics: [PreferencesPluginBackupDiagnostic] = [] var installableMissingPluginIDs: [String] { installablePlugins.map(\.id) @@ -564,7 +573,18 @@ struct PreferencesImportPreview: Equatable { unavailableActionReferences: unavailableActionReferences, retainedUnavailableActionReferences: retainedUnavailableActionReferences, installablePlugins: installablePlugins.sorted { $0.title.localizedCompare($1.title) == .orderedAscending }, - selection: selection + selection: selection, + pluginDiagnostics: Set(backup.pluginPreferences.keys).union((backup.pluginDiagnostics ?? []).map(\.pluginID)).sorted().map { id in + let source = backup.pluginDiagnostics?.first { $0.pluginID == id } + let status: String + if backup.pluginPreferences[id] != nil { + status = !selection.pluginPreferenceIDs.contains(id) ? "notSelected" + : (availablePluginIDs.contains(id) ? "readyToRestore" : "unavailable") + } else { + status = ["localOnly", "none"].contains(source?.status ?? "") ? source!.status : "notSelected" + } + return PreferencesPluginBackupDiagnostic(pluginID: id, status: status, exclusions: source?.exclusions ?? []) + } ) } } @@ -579,22 +599,66 @@ struct PreferencesImportInstallablePlugin: Identifiable, Equatable { struct PreferencesImportResult: Equatable { let installedPluginIDs: [String] let deferredPluginPreferenceIDs: [String] + let pluginOutcomes: [PreferencesPluginBackupDiagnostic] let pluginInstallationFailures: [String: String] let shortcutErrors: [String: String] init( installedPluginIDs: [String], deferredPluginPreferenceIDs: [String] = [], + pluginOutcomes: [PreferencesPluginBackupDiagnostic] = [], pluginInstallationFailures: [String: String], shortcutErrors: [String: String] ) { self.installedPluginIDs = installedPluginIDs self.deferredPluginPreferenceIDs = deferredPluginPreferenceIDs + self.pluginOutcomes = pluginOutcomes self.pluginInstallationFailures = pluginInstallationFailures self.shortcutErrors = shortcutErrors } } +/// Contains stable identifiers and reason codes only. It never authorizes imported content. +struct PreferencesPluginBackupDiagnostic: Codable, Equatable, Sendable, Identifiable { + let pluginID: String + let status: String + let exclusions: [String] + var id: String { pluginID } + var blocksSelectedExport: Bool { status == "failed" || status == "unavailable" } + + static func bounded(_ values: [Self]?) -> [Self]? { + guard let values, values.count <= 512 else { return nil } + var seen = Set() + return values.filter { value in + value.pluginID.utf8.count <= 128 && PluginPackageManifestLoader.isValidPluginID(value.pluginID) + && value.status.utf8.count <= 32 && !value.status.isEmpty + && value.exclusions.count <= 16 + && value.exclusions.allSatisfy { $0.utf8.count <= 64 && !$0.isEmpty } + && seen.insert(value.pluginID).inserted + } + } +} + +struct PreferencesBackupExportResult { + let backup: PreferencesBackup + let pluginOutcomes: [PreferencesPluginBackupDiagnostic] + + func requiringSuccessfulSelection() throws -> PreferencesBackup { + let failed = pluginOutcomes.filter(\.blocksSelectedExport).map(\.pluginID) + guard failed.isEmpty else { throw PreferencesBackupExportError(pluginIDs: failed) } + return backup + } +} + +struct PreferencesBackupExportError: LocalizedError { + let pluginIDs: [String] + var errorDescription: String? { + AppL10n.preferencesBackupFormat( + "preferencesBackup.export.failedPlugins", defaultValue: "以下插件无法备份。请取消选择后重试:%@", pluginIDs.joined(separator: ", ") + ) + } +} + enum PreferencesBackupError: Error, Equatable { case unsupportedFormatVersion(Int) case invalidApplicationPreferences diff --git a/Sources/Core/RightClick/RightClickConfiguration.swift b/Sources/Core/RightClick/RightClickConfiguration.swift index 1ecac52aa..a44b6d64c 100644 --- a/Sources/Core/RightClick/RightClickConfiguration.swift +++ b/Sources/Core/RightClick/RightClickConfiguration.swift @@ -61,25 +61,43 @@ struct RightClickConfiguration: Codable, Equatable { copyFileName || copyAbsolutePath || copyRelativePath || copyShellEscapedPath || copyFileURL } + /// Unresolved imported apps stay in settings but cannot become Finder actions. + func openWithApplications(matching targets: [URL]) -> [RightClickOpenWithApp] { + openWithApps.filter { app in + !app.isUnresolved && targets.contains { app.matches(fileExtension: $0.pathExtension) } + } + } + + fileprivate static let backupDecodingKey = CodingUserInfoKey(rawValue: "right-click.preferences-backup.strict")! + /// Decode tolerantly: keys added after a config was first written fall back /// to their defaults instead of failing the whole decode. init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let fallback = RightClickConfiguration.activeDefault - func flag(_ key: CodingKeys, _ defaultValue: Bool) -> Bool { - (try? container.decode(Bool.self, forKey: key)) ?? defaultValue + let strict = decoder.userInfo[Self.backupDecodingKey] as? Bool == true + func flag(_ key: CodingKeys, _ defaultValue: Bool) throws -> Bool { + if strict, container.contains(key) { + return try container.decode(Bool.self, forKey: key) + } + return (try? container.decode(Bool.self, forKey: key)) ?? defaultValue + } + menuEnabled = try flag(.menuEnabled, fallback.menuEnabled) + newFolder = try flag(.newFolder, fallback.newFolder) + copyFileName = try flag(.copyFileName, fallback.copyFileName) + copyAbsolutePath = try flag(.copyAbsolutePath, fallback.copyAbsolutePath) + copyRelativePath = try flag(.copyRelativePath, fallback.copyRelativePath) + copyShellEscapedPath = try flag(.copyShellEscapedPath, fallback.copyShellEscapedPath) + copyFileURL = try flag(.copyFileURL, fallback.copyFileURL) + newFile = try flag(.newFile, fallback.newFile) + openInTerminal = try flag(.openInTerminal, fallback.openInTerminal) + if strict { + preferredLanguages = try container.decodeIfPresent([String].self, forKey: .preferredLanguages) + openWithApps = container.contains(.openWithApps) ? try container.decode([RightClickOpenWithApp].self, forKey: .openWithApps) : [] + } else { + preferredLanguages = try? container.decodeIfPresent([String].self, forKey: .preferredLanguages) + openWithApps = (try? container.decode([RightClickOpenWithApp].self, forKey: .openWithApps)) ?? [] } - menuEnabled = flag(.menuEnabled, fallback.menuEnabled) - preferredLanguages = try? container.decodeIfPresent([String].self, forKey: .preferredLanguages) - newFolder = flag(.newFolder, fallback.newFolder) - copyFileName = flag(.copyFileName, fallback.copyFileName) - copyAbsolutePath = flag(.copyAbsolutePath, fallback.copyAbsolutePath) - copyRelativePath = flag(.copyRelativePath, fallback.copyRelativePath) - copyShellEscapedPath = flag(.copyShellEscapedPath, fallback.copyShellEscapedPath) - copyFileURL = flag(.copyFileURL, fallback.copyFileURL) - newFile = flag(.newFile, fallback.newFile) - openInTerminal = flag(.openInTerminal, fallback.openInTerminal) - openWithApps = (try? container.decode([RightClickOpenWithApp].self, forKey: .openWithApps)) ?? [] } } @@ -90,12 +108,17 @@ struct RightClickOpenWithApp: Codable, Equatable, Identifiable { var appPath: String /// Lowercased file extensions this entry applies to; empty = all files. var fileExtensions: [String] + /// Opaque portable identity retained when the app is missing on this Mac. + var portableApplicationReference: Data? + + var isUnresolved: Bool { appPath.isEmpty } init(id: UUID = UUID(), name: String, appPath: String, fileExtensions: [String] = []) { self.id = id self.name = name self.appPath = appPath self.fileExtensions = fileExtensions + self.portableApplicationReference = nil } /// Whether this entry should appear for a file with the given (lowercased) @@ -151,6 +174,20 @@ enum RightClickConfigurationStore { return configuration } + /// Missing files use defaults; existing unreadable or malformed state must not become a backup. + static func loadForPreferencesBackup(from fileURL: URL = configFileURL) -> RightClickConfiguration? { + do { + let data = try Data(contentsOf: fileURL) + let decoder = JSONDecoder() + decoder.userInfo[RightClickConfiguration.backupDecodingKey] = true + return try decoder.decode(RightClickConfiguration.self, from: data) + } catch let error as CocoaError where error.code == .fileReadNoSuchFile { + return .inactiveDefault + } catch { + return nil + } + } + static func setMenuEnabled(_ enabled: Bool, fileURL: URL = configFileURL) { var configuration = load(from: fileURL) if enabled, configuration == .inactiveDefault { @@ -168,7 +205,13 @@ enum RightClickConfigurationStore { withIntermediateDirectories: true ) let data = try JSONEncoder().encode(configuration) + let previous = try? Data(contentsOf: fileURL) try data.write(to: fileURL, options: .atomic) + guard (try? Data(contentsOf: fileURL)) == data else { + if let previous { try? previous.write(to: fileURL, options: .atomic) } + else { try? FileManager.default.removeItem(at: fileURL) } + return false + } return true } catch { return false diff --git a/Sources/Extensions/RightClickFinderSync/RightClickFinderSync.swift b/Sources/Extensions/RightClickFinderSync/RightClickFinderSync.swift index 4384eae59..e7a780876 100644 --- a/Sources/Extensions/RightClickFinderSync/RightClickFinderSync.swift +++ b/Sources/Extensions/RightClickFinderSync/RightClickFinderSync.swift @@ -282,9 +282,7 @@ final class RightClickFinderSync: FIFinderSync { guard !configuration.openWithApps.isEmpty, !targets.isEmpty else { return } - let matching = configuration.openWithApps.filter { app in - targets.contains { app.matches(fileExtension: $0.pathExtension) } - } + let matching = configuration.openWithApplications(matching: targets) guard !matching.isEmpty else { return } diff --git a/Sources/PluginSupport/PortableApplicationReference.swift b/Sources/PluginSupport/PortableApplicationReference.swift new file mode 100644 index 000000000..c9bfa4c29 --- /dev/null +++ b/Sources/PluginSupport/PortableApplicationReference.swift @@ -0,0 +1,67 @@ +import Foundation + +/// A destination-resolved application identity. Paths are relative to a named installation root. +struct PortableApplicationReference: Codable, Equatable { + enum Location: String, Codable { + case applications, userApplications, systemApplications, cryptexApplications, unresolved + + var root: URL? { + switch self { + case .applications: URL(fileURLWithPath: "/Applications", isDirectory: true) + case .userApplications: FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Applications", isDirectory: true) + case .systemApplications: URL(fileURLWithPath: "/System/Applications", isDirectory: true) + case .cryptexApplications: URL(fileURLWithPath: "/System/Volumes/Preboot/Cryptexes/App/System/Applications", isDirectory: true) + case .unresolved: nil + } + } + } + + let bundleIdentifier: String? + let location: Location + let relativePath: String? + let bundleName: String + + init(url: URL?, roots: [Location: URL] = [:]) { + let url = url?.standardizedFileURL.resolvingSymlinksInPath() + bundleIdentifier = url.flatMap { Bundle(url: $0)?.bundleIdentifier } + bundleName = url?.lastPathComponent ?? "Unavailable.app" + var matched: (Location, String)? + for role in [Location.applications, .userApplications, .systemApplications, .cryptexApplications] { + guard let root = (roots[role] ?? role.root)?.resolvingSymlinksInPath(), let path = url?.path, path.hasPrefix(root.path + "/") else { continue } + matched = (role, String(path.dropFirst(root.path.count + 1))) + break + } + if let matched, bundleIdentifier != nil { + location = matched.0 + relativePath = matched.1 + } else { + location = .unresolved + relativePath = nil + } + } + + var isValid: Bool { + guard !bundleName.isEmpty, bundleName.utf8.count <= 256, bundleName.hasSuffix(".app"), + !bundleName.contains("/"), !bundleName.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) else { return false } + if let bundleIdentifier { + guard !bundleIdentifier.isEmpty, bundleIdentifier.utf8.count <= 256, + bundleIdentifier.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")).contains($0) }) else { return false } + } + if location == .unresolved { return relativePath == nil } + guard bundleIdentifier != nil, let relativePath, relativePath.utf8.count <= 1_024, + !relativePath.hasPrefix("/"), !relativePath.contains("\\"), relativePath.hasSuffix(".app"), + !relativePath.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) else { return false } + let parts = relativePath.split(separator: "/", omittingEmptySubsequences: false) + return parts.allSatisfy { !$0.isEmpty && $0 != "." && $0 != ".." } + && parts.dropLast().allSatisfy { !$0.hasSuffix(".app") } + } + + func resolvedURL(roots: [Location: URL] = [:]) -> URL? { + guard isValid, location != .unresolved, let root = roots[location] ?? location.root, let relativePath, let bundleIdentifier else { return nil } + let candidate = root.appendingPathComponent(relativePath).standardizedFileURL + guard candidate.resolvingSymlinksInPath().path.hasPrefix(root.resolvingSymlinksInPath().path + "/"), + FileManager.default.fileExists(atPath: candidate.path), + Bundle(url: candidate)?.bundleIdentifier == bundleIdentifier else { return nil } + return candidate + } +} diff --git a/Sources/PluginSupport/PortableCredentialAuthorization.swift b/Sources/PluginSupport/PortableCredentialAuthorization.swift new file mode 100644 index 000000000..5b7263bda --- /dev/null +++ b/Sources/PluginSupport/PortableCredentialAuthorization.swift @@ -0,0 +1,105 @@ +import Foundation +import MacToolsPluginKit + +/// Destination-only endpoint grants. Import cannot attach an existing credential to a new service. +@MainActor +struct PortableCredentialAuthorization { + static let key = "preferences-backup.credential-endpoints.v1" + let storage: any PluginStorage + + static var field: PortablePreferenceField { + .data(Data("{}".utf8), maximumBytes: 262_144) { data in + guard let grants = try? JSONDecoder().decode([String: String].self, from: data) else { return false } + return grants.count <= 256 && grants.allSatisfy { + validGrant(profileID: $0.key, endpoint: $0.value) + } + } + } + + // These destination-only grants retain URL components supported by existing local clients. + // Portable payloads still use the stricter endpoint validator and cannot supply this map. + private static func validGrant(profileID: String, endpoint: String) -> Bool { + guard PortablePreferencesValidation.identifier(profileID), endpoint.utf8.count <= 262_144, + let url = URLComponents(string: endpoint.trimmingCharacters(in: .whitespacesAndNewlines)), + url.host?.isEmpty == false, url.url != nil else { return false } + return url.scheme?.lowercased() == "https" || url.scheme?.lowercased() == "http" + } + + var isInitialized: Bool { + guard let data = storage.data(forKey: Self.key) else { return false } + return Self.field.accepts(.data(data)) + } + + struct Replacement { + /// Nil covers every grant sharing one key, as in AI Assistant. + let profileIDs: Set? + let load: () throws -> String? + let save: () throws -> Void + + func covers(_ profileID: String) -> Bool { profileIDs?.contains(profileID) ?? true } + } + + /// Keep key writes quarantined until profile settings and final grants commit together. + /// On any failure, restore only previous grants backed by a verified unchanged secret. + func replaceCredentials( + _ replacements: [Replacement], + authorizing endpoints: [String: String], + values: [String: PortablePreferenceValue], + store: PortablePreferencesStore + ) throws { + guard store.recover() else { throw CocoaError(.fileWriteUnknown) } + guard !replacements.isEmpty else { + guard store.commit(values) else { throw CocoaError(.fileWriteUnknown) } + return + } + let previousGrants = grants + let previousKeys = replacements.map { try? $0.load() } + let retainedGrants = previousGrants.filter { profileID, _ in + !replacements.contains { $0.covers(profileID) } + } + guard let revoked = try? JSONEncoder().encode(retainedGrants), + store.commit([Self.key: .data(revoked)]) else { throw CocoaError(.fileWriteUnknown) } + do { + for replacement in replacements { try replacement.save() } + let authorized = endpoints.filter { Self.validGrant(profileID: $0.key, endpoint: $0.value) } + let finalGrants = retainedGrants.merging(authorized, uniquingKeysWith: { _, local in local }) + var candidate = values + candidate[Self.key] = .data(try JSONEncoder().encode(finalGrants)) + guard store.commit(candidate) else { throw CocoaError(.fileWriteUnknown) } + } catch { + var recovered = retainedGrants + for (replacement, previousKey) in zip(replacements, previousKeys) { + guard let previousKey, let currentKey = try? replacement.load(), currentKey == previousKey else { continue } + recovered.merge(previousGrants.filter { replacement.covers($0.key) }, uniquingKeysWith: { old, _ in old }) + } + if let data = try? JSONEncoder().encode(recovered) { + _ = store.commit([Self.key: .data(data)]) + } + throw error + } + } + + private var grants: [String: String] { + storage.data(forKey: Self.key).flatMap { try? JSONDecoder().decode([String: String].self, from: $0) } ?? [:] + } + + func allows(profileID: String, baseURL: String) -> Bool { + guard let granted = grants[profileID] else { return false } + return canonical(granted) == canonical(baseURL) + } + + /// Call before any import, once for existing destination profiles; no Keychain access. + func seedIfNeeded(profiles: [String: String], store: PortablePreferencesStore) -> Bool { + guard storage.object(forKey: Self.key) == nil else { return isInitialized } + guard let data = try? JSONEncoder().encode(profiles.filter { Self.validGrant(profileID: $0.key, endpoint: $0.value) }) else { return false } + return store.commit([Self.key: .data(data)]) + } + + private func canonical(_ endpoint: String) -> String { + guard var components = URLComponents(string: endpoint.trimmingCharacters(in: .whitespacesAndNewlines)) else { return endpoint } + components.scheme = components.scheme?.lowercased() + components.host = components.host?.lowercased() + while components.path.hasSuffix("/") { components.path.removeLast() } + return components.string ?? endpoint + } +} diff --git a/Sources/PluginSupport/PortablePreferencesStore.swift b/Sources/PluginSupport/PortablePreferencesStore.swift new file mode 100644 index 000000000..9008b85c1 --- /dev/null +++ b/Sources/PluginSupport/PortablePreferencesStore.swift @@ -0,0 +1,203 @@ +import Foundation +import MacToolsPluginKit + +/// Compiled privately into participating plugin targets; this is not a PluginKit runtime API. +enum PortablePreferenceValue: Codable, Equatable { + case boolean(Bool) + case integer(Int) + case number(Double) + case string(String) + case data(Data) + case strings([String]) + + var object: Any { + switch self { + case let .boolean(value): value + case let .integer(value): value + case let .number(value): value + case let .string(value): value + case let .data(value): value + case let .strings(value): value + } + } +} + +struct PortablePreferenceField { + let fallback: PortablePreferenceValue + let accepts: (PortablePreferenceValue) -> Bool + + static func boolean(_ fallback: Bool) -> Self { + Self(fallback: .boolean(fallback)) { if case .boolean = $0 { true } else { false } } + } + + static func integer(_ fallback: Int, range: ClosedRange) -> Self { + Self(fallback: .integer(fallback)) { + if case let .integer(value) = $0 { range.contains(value) } else { false } + } + } + + static func number(_ fallback: Double, range: ClosedRange) -> Self { + Self(fallback: .number(fallback)) { + if case let .number(value) = $0 { value.isFinite && range.contains(value) } else { false } + } + } + + static func choice(_ fallback: String, allowed: [String]) -> Self { + Self(fallback: .string(fallback)) { + if case let .string(value) = $0 { allowed.contains(value) } else { false } + } + } + + static func strings(_ fallback: [String] = [], maximumCount: Int = 512, maximumStringBytes: Int = 1_024) -> Self { + Self(fallback: .strings(fallback)) { + guard case let .strings(values) = $0 else { return false } + return values.count <= maximumCount && Set(values).count == values.count && values.allSatisfy { + !$0.isEmpty && $0.utf8.count <= maximumStringBytes && !$0.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + } + } + } + + static func data(_ fallback: Data, maximumBytes: Int, validate: @escaping (Data) -> Bool) -> Self { + Self(fallback: .data(fallback)) { + if case let .data(value) = $0 { value.count <= maximumBytes && validate(value) } else { false } + } + } + + func read(_ object: Any?) -> PortablePreferenceValue? { + guard let object else { return fallback } + let value: PortablePreferenceValue? + switch fallback { + case .boolean: value = (object as? Bool).map(PortablePreferenceValue.boolean) + case .integer: value = (object as? Int).map(PortablePreferenceValue.integer) + case .number: value = (object as? Double).map(PortablePreferenceValue.number) + case .string: value = (object as? String).map(PortablePreferenceValue.string) + case .data: value = (object as? Data).map(PortablePreferenceValue.data) + case .strings: value = (object as? [String]).map(PortablePreferenceValue.strings) + } + return value.flatMap { accepts($0) ? $0 : nil } + } +} + +/// A bounded field allowlist and read-back-verified transaction over one plugin's storage. +/// It never discovers keys or reads credentials, files, or another plugin's state. +@MainActor +struct PortablePreferencesStore { + private struct Envelope: Codable { + let formatVersion: Int + let values: [String: PortablePreferenceValue] + } + + let storage: any PluginStorage + let fields: [String: PortablePreferenceField] + private let journalKey = "preferences-backup.restore.v2" + private let legacyJournalKey = "preferences-backup.restore.v1" + private let maximumBytes = 2 * 1_024 * 1_024 + + func backup(including keys: Set? = nil, overriding overrides: [String: PortablePreferenceValue] = [:]) -> Data? { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard storage.object(forKey: journalKey) == nil, storage.object(forKey: legacyJournalKey) == nil, + var values = currentValues(including: keys), + Set(overrides.keys).isSubset(of: keys ?? Set(fields.keys)), + overrides.allSatisfy({ fields[$0.key]?.accepts($0.value) == true }) else { return nil } + values.merge(overrides, uniquingKeysWith: { _, incoming in incoming }) + guard + let data = try? encoder.encode(Envelope(formatVersion: 1, values: values)), + data.count <= maximumBytes else { return nil } + return data + } + + func currentValues(including keys: Set? = nil) -> [String: PortablePreferenceValue]? { + let selected = keys ?? Set(fields.keys) + guard selected.isSubset(of: Set(fields.keys)) else { return nil } + var values: [String: PortablePreferenceValue] = [:] + for (key, field) in fields where selected.contains(key) { + guard let value = field.read(storage.object(forKey: key)) else { return nil } + values[key] = value + } + return values + } + + func validatedValues(from data: Data) -> [String: PortablePreferenceValue]? { + guard data.count <= maximumBytes, + let envelope = try? JSONDecoder().decode(Envelope.self, from: data), + envelope.formatVersion == 1, + Set(envelope.values.keys).isSubset(of: Set(fields.keys)), + envelope.values.allSatisfy({ fields[$0.key]?.accepts($0.value) == true }) else { return nil } + return envelope.values + } + + func restore(_ data: Data) -> Bool { + guard let incoming = validatedValues(from: data), recover() else { return false } + var candidate = incoming + for (key, field) in fields where candidate[key] == nil { + guard let value = field.read(storage.object(forKey: key)) else { return false } + candidate[key] = value + } + return commit(candidate) + } + + func commit(_ candidate: [String: PortablePreferenceValue], removing: Set = []) -> Bool { + guard Set(candidate.keys).isSubset(of: Set(fields.keys)), + removing.isSubset(of: Set(fields.keys)), Set(candidate.keys).isDisjoint(with: removing), + candidate.allSatisfy({ fields[$0.key]?.accepts($0.value) == true }), recover() else { return false } + let keys = Set(candidate.keys).union(removing) + guard !keys.isEmpty else { return true } + var previous: [String: Any] = [:] + for key in keys { previous[key] = storage.object(forKey: key) } + // Explicit keys distinguish an absent old value from an untouched field during recovery. + let envelope: [String: Any] = ["formatVersion": 2, "keys": keys.sorted(), "previous": previous] + guard let journal = try? PropertyListSerialization.data(fromPropertyList: envelope, format: .binary, options: 0), + journal.count <= maximumBytes else { return false } + storage.set(journal, forKey: journalKey) + guard storage.data(forKey: journalKey) == journal else { return false } + for (key, value) in candidate { storage.set(value.object, forKey: key) } + for key in removing { storage.removeObject(forKey: key) } + guard candidate.allSatisfy({ storage.object(forKey: $0.key) != nil && fields[$0.key]?.read(storage.object(forKey: $0.key)) == $0.value }), + removing.allSatisfy({ storage.object(forKey: $0) == nil }) else { + _ = recover() + return false + } + storage.removeObject(forKey: journalKey) + guard storage.object(forKey: journalKey) == nil else { _ = recover(); return false } + return true + } + + @discardableResult + func recover() -> Bool { + if let legacy = storage.object(forKey: legacyJournalKey) { + guard storage.object(forKey: journalKey) == nil, + let previous = journalValues(legacy), Set(previous.keys).isSubset(of: Set(fields.keys)) else { return false } + return recover(previous: previous, keys: Set(fields.keys), journalKey: legacyJournalKey) + } + guard let raw = storage.object(forKey: journalKey) else { return true } + guard let envelope = journalValues(raw), Set(envelope.keys) == ["formatVersion", "keys", "previous"], + envelope["formatVersion"] as? Int == 2, let keys = envelope["keys"] as? [String], + Set(keys).count == keys.count, Set(keys).isSubset(of: Set(fields.keys)), + let previous = envelope["previous"] as? [String: Any], + Set(previous.keys).isSubset(of: Set(keys)) else { return false } + return recover(previous: previous, keys: Set(keys), journalKey: journalKey) + } + + private func journalValues(_ raw: Any) -> [String: Any]? { + guard let data = raw as? Data, data.count <= maximumBytes, + let values = (try? PropertyListSerialization.propertyList(from: data, format: nil)) as? [String: Any] else { return nil } + return values + } + + private func recover(previous: [String: Any], keys: Set, journalKey: String) -> Bool { + for key in keys { + if let value = previous[key] { storage.set(value, forKey: key) } + else { storage.removeObject(forKey: key) } + } + guard keys.allSatisfy({ key in + switch (previous[key] as? NSObject, storage.object(forKey: key) as? NSObject) { + case (nil, nil): true + case let (before?, after?): before.isEqual(after) + default: false + } + }) else { return false } + storage.removeObject(forKey: journalKey) + return storage.object(forKey: journalKey) == nil + } +} diff --git a/Sources/PluginSupport/PortablePreferencesValidation.swift b/Sources/PluginSupport/PortablePreferencesValidation.swift new file mode 100644 index 000000000..bf4b63e2b --- /dev/null +++ b/Sources/PluginSupport/PortablePreferencesValidation.swift @@ -0,0 +1,24 @@ +import Foundation + +enum PortablePreferencesValidation { + static func identifier(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 256 && value.unicodeScalars.allSatisfy { + CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")).contains($0) + } + } + + static func text(_ value: String, maximumBytes: Int, allowsEmpty: Bool = false) -> Bool { + (allowsEmpty || !value.isEmpty) && value.utf8.count <= maximumBytes + && !value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + } + + static func endpoint(_ value: String, allowsIncomplete: Bool = false) -> Bool { + guard text(value, maximumBytes: 2_048, allowsEmpty: allowsIncomplete), + let url = URLComponents(string: value), + url.user == nil, url.password == nil, url.query == nil, url.fragment == nil else { return false } + // Disabled drafts need not be executable, but still exclude credential-bearing components. + if allowsIncomplete { return true } + guard url.host?.isEmpty == false, url.url != nil else { return false } + return url.scheme == "https" || url.scheme == "http" + } +} diff --git a/Sources/Resources/Localization/PreferencesBackup.xcstrings b/Sources/Resources/Localization/PreferencesBackup.xcstrings index aa7349019..04cb241a8 100644 --- a/Sources/Resources/Localization/PreferencesBackup.xcstrings +++ b/Sources/Resources/Localization/PreferencesBackup.xcstrings @@ -5536,76 +5536,76 @@ "preferencesBackup.preview.replaceNotice": { "extractionState": "manual", "localizations": { - "ar": { + "en": { "stringUnit": { "state": "translated", - "value": "تُستبدل الفئات المعروضة من التفضيلات؛ وتعود الإعدادات غير الموجودة في النسخة الاحتياطية إلى قيمها الافتراضية." + "value": "Only selected categories are replaced. Omitted plugin settings and local data are preserved. Importing Shortcuts resets omitted bindings to defaults." } }, - "de": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Die angezeigten Einstellungskategorien werden ersetzt; Einstellungen, die nicht in der Sicherung enthalten sind, werden auf ihre Standardwerte zurückgesetzt." + "value": "仅替换所选类别;未包含的插件设置和本机数据会保留。导入快捷键时,备份中未包含的绑定会恢复为默认值。" } }, - "en": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "The listed preference categories are replaced; settings absent from the backup return to their defaults." + "value": "僅取代所選類別;未包含的外掛設定與本機資料會保留。匯入快捷鍵時,備份中未包含的綁定會回復為預設值。" } }, "es": { "stringUnit": { "state": "translated", - "value": "Se reemplazan las categorías de preferencias indicadas; los ajustes que no están en la copia vuelven a sus valores predeterminados." + "value": "Solo se reemplazan las categorías seleccionadas. Se conservan los ajustes de plugins omitidos y los datos locales. Al importar Atajos, las asignaciones omitidas vuelven a sus valores predeterminados." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Les catégories de préférences indiquées sont remplacées ; les réglages absents de la sauvegarde reviennent à leurs valeurs par défaut." + "value": "Seules les catégories sélectionnées sont remplacées. Les réglages des plugins omis et les données locales sont conservés. L’importation des raccourcis réinitialise les associations absentes de la sauvegarde à leurs valeurs par défaut." } }, - "ja": { + "ru": { "stringUnit": { "state": "translated", - "value": "表示された環境設定のカテゴリが置き換えられます。バックアップにない設定はデフォルト値に戻ります。" + "value": "Заменяются только выбранные категории. Пропущенные настройки плагинов и локальные данные сохраняются. При импорте сочетаний клавиш пропущенные назначения сбрасываются к значениям по умолчанию." } }, - "ko": { + "pt": { "stringUnit": { "state": "translated", - "value": "표시된 환경설정 범주가 대체되며 백업에 없는 설정은 기본값으로 돌아갑니다." + "value": "Somente as categorias selecionadas são substituídas. Os ajustes de plugins omitidos e os dados locais são preservados. Ao importar Atalhos, as associações omitidas voltam aos valores padrão." } }, - "pt": { + "de": { "stringUnit": { "state": "translated", - "value": "As categorias de preferências listadas são substituídas; as configurações ausentes do backup voltam aos valores padrão." + "value": "Nur ausgewählte Kategorien werden ersetzt. Ausgelassene Plugin-Einstellungen und lokale Daten bleiben erhalten. Beim Import von Kurzbefehlen werden fehlende Tastenzuweisungen auf ihre Standardwerte zurückgesetzt." } }, - "ru": { + "ja": { "stringUnit": { "state": "translated", - "value": "Перечисленные категории настроек заменяются; параметры, отсутствующие в резервной копии, возвращаются к значениям по умолчанию." + "value": "選択したカテゴリのみ置き換えます。含まれないプラグイン設定とローカルデータは保持されます。ショートカットを読み込むと、バックアップにないキー割り当てはデフォルトに戻ります。" } }, - "tr": { + "ko": { "stringUnit": { "state": "translated", - "value": "Listelenen tercih kategorileri değiştirilir; yedekte bulunmayan ayarlar varsayılan değerlerine döner." + "value": "선택한 카테고리만 대체합니다. 포함되지 않은 플러그인 설정과 로컬 데이터는 유지됩니다. 단축키를 가져오면 백업에 없는 키 할당은 기본값으로 재설정됩니다." } }, - "zh-Hans": { + "ar": { "stringUnit": { "state": "translated", - "value": "将替换以上偏好类别;备份中未包含的设置会恢复为默认值。" + "value": "تُستبدل الفئات المحددة فقط. تُحفظ إعدادات الإضافات غير المضمنة والبيانات المحلية. يؤدي استيراد اختصارات لوحة المفاتيح إلى إعادة تعيينات المفاتيح غير المضمنة إلى قيمها الافتراضية." } }, - "zh-Hant": { + "tr": { "stringUnit": { "state": "translated", - "value": "將取代以上偏好類別;備份中未包含的設定會回復為預設值。" + "value": "Yalnızca seçili kategoriler değiştirilir. Dahil edilmeyen eklenti ayarları ve yerel veriler korunur. Kısayollar içe aktarılırken dahil edilmeyen tuş atamaları varsayılanlara döner." } } } @@ -7303,6 +7303,1830 @@ } } } + }, + "preferencesBackup.coverage.included": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Available for backup" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可备份" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "可備份" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Disponible para copia de seguridad" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sauvegarde disponible" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Доступно для резервного копирования" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Disponível para backup" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Für die Sicherung verfügbar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バックアップ可能" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "백업 가능" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "متاح للنسخ الاحتياطي" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yedeklenebilir" + } + } + } + }, + "preferencesBackup.coverage.partial": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Some content stays on this Mac" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "部分内容保留在本机" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "部分內容保留在此 Mac" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Parte del contenido se conserva en este Mac" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Une partie du contenu reste sur ce Mac" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Часть содержимого остаётся на этом Mac" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Parte do conteúdo permanece neste Mac" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einige Inhalte bleiben auf diesem Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一部の内容はこのMacに保持されます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일부 내용은 이 Mac에 유지됩니다" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يبقى بعض المحتوى على هذا الـ Mac" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bazı içerikler bu Mac’te kalır" + } + } + } + }, + "preferencesBackup.coverage.localOnly": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kept on this Mac" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保留在本机" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "保留在此 Mac" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se conserva en este Mac" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Conservé sur ce Mac" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Остаётся на этом Mac" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mantido neste Mac" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bleibt auf diesem Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このMacに保持されます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 Mac에 유지됩니다" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يُحتفظ به على هذا الـ Mac" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu Mac’te tutulur" + } + } + } + }, + "preferencesBackup.coverage.none": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No portable settings" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无可移植设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有可移轉的設定" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin ajustes transferibles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun réglage transférable" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет переносимых настроек" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhum ajuste transferível" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine übertragbaren Einstellungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "移行可能な設定はありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "옮길 수 있는 설정 없음" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد إعدادات قابلة للنقل" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Taşınabilir ayar yok" + } + } + } + }, + "preferencesBackup.coverage.notSelected": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not selected" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未选择" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未選取" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin seleccionar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Non sélectionné" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не выбрано" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não selecionado" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht ausgewählt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未選択" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택 안 됨" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير محدد" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Seçilmedi" + } + } + } + }, + "preferencesBackup.coverage.failed": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "失敗" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сбой" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehlgeschlagen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실패" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Başarısız" + } + } + } + }, + "preferencesBackup.coverage.unavailable": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Currently unavailable" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前不可用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "目前無法使用" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No disponible actualmente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actuellement indisponible" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сейчас недоступно" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Indisponível no momento" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Derzeit nicht verfügbar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在利用できません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 사용 불가" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير متاح حاليًا" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Şu anda kullanılamıyor" + } + } + } + }, + "preferencesBackup.coverage.restored": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings restored" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置已恢复" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定已回復" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ajustes restaurados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réglages restaurés" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройки восстановлены" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ajustes restaurados" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen wiederhergestellt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定を復元しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정 복원됨" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تمت استعادة الإعدادات" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ayarlar geri yüklendi" + } + } + } + }, + "preferencesBackup.coverage.needsAttention": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings saved; permission required" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置已保存,需要权限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定已儲存,需要權限" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ajustes guardados; se requiere permiso" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réglages enregistrés ; autorisation requise" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройки сохранены; требуется разрешение" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ajustes salvos; permissão necessária" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen gespeichert; Berechtigung erforderlich" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定は保存済みです。権限が必要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정 저장됨; 권한 필요" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم حفظ الإعدادات؛ يلزم إذن" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ayarlar kaydedildi; izin gerekiyor" + } + } + } + }, + "preferencesBackup.coverage.deferred": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart and import again" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新启动后再次导入" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新啟動後再次匯入" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reinicia e importa de nuevo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrez, puis importez à nouveau" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустите приложение и повторите импорт" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Reinicie e importe novamente" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu starten und erneut importieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再起動してから再度読み込んでください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재시동한 후 다시 가져오세요" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أعد التشغيل ثم استورد مجددًا" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeniden başlatıp tekrar içe aktarın" + } + } + } + }, + "preferencesBackup.coverage.unknown": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Backup coverage unspecified" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "备份范围未声明" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未宣告備份範圍" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alcance de la copia sin especificar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Étendue de la sauvegarde non précisée" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Состав резервной копии не указан" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Escopo do backup não informado" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sicherungsumfang nicht angegeben" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バックアップ範囲は未指定です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "백업 범위가 지정되지 않음" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نطاق النسخ الاحتياطي غير محدد" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yedekleme kapsamı belirtilmedi" + } + } + } + }, + "preferencesBackup.coverage.title": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Backup coverage and exclusions" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "备份范围与排除项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "備份範圍與排除項目" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alcance de la copia y exclusiones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Contenu sauvegardé et exclusions" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Состав резервной копии и исключения" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Escopo do backup e exclusões" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sicherungsumfang und Ausschlüsse" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バックアップ範囲と除外項目" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "백업 범위 및 제외 항목" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نطاق النسخ الاحتياطي والاستثناءات" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yedekleme kapsamı ve hariç tutulanlar" + } + } + } + }, + "preferencesBackup.exclusion.credentials": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Credentials stay on this Mac" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "凭据保留在本机" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "登入憑證保留在此 Mac" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las credenciales se conservan en este Mac" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les identifiants restent sur ce Mac" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Учётные данные остаются на этом Mac" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "As credenciais permanecem neste Mac" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anmeldedaten bleiben auf diesem Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認証情報はこのMacに保持されます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인증 정보는 이 Mac에 유지됩니다" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبقى بيانات الاعتماد على هذا الـ Mac" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kimlik bilgileri bu Mac’te kalır" + } + } + } + }, + "preferencesBackup.exclusion.permissions": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Permissions and access consent stay on this Mac" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "权限和访问授权保留在本机" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "權限與存取授權保留在此 Mac" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Los permisos y autorizaciones se conservan en este Mac" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les permissions et autorisations restent sur ce Mac" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрешения и согласия остаются на этом Mac" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "As permissões e autorizações permanecem neste Mac" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Berechtigungen und Zugriffszustimmungen bleiben auf diesem Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "権限とアクセスの許可はこのMacに保持されます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "권한과 접근 승인은 이 Mac에 유지됩니다" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبقى الأذونات وموافقات الوصول على هذا الـ Mac" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İzinler ve erişim onayları bu Mac’te kalır" + } + } + } + }, + "preferencesBackup.exclusion.history": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "History and caches stay on this Mac" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "历史记录和缓存保留在本机" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "歷史記錄與快取保留在此 Mac" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El historial y las cachés se conservan en este Mac" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’historique et les caches restent sur ce Mac" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "История и кеши остаются на этом Mac" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O histórico e os caches permanecem neste Mac" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlauf und Caches bleiben auf diesem Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴とキャッシュはこのMacに保持されます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기록과 캐시는 이 Mac에 유지됩니다" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يبقى السجل وذاكرات التخزين المؤقت على هذا الـ Mac" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçmiş ve önbellekler bu Mac’te kalır" + } + } + } + }, + "preferencesBackup.exclusion.privateContent": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Private content requires separate backup consent or its dedicated archive" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "私密内容仅在单独允许后备份,或使用专用归档" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "私密內容須另行同意備份,或使用專用封存檔" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El contenido privado requiere permiso aparte o su archivo específico" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le contenu privé nécessite une autorisation distincte ou son archive dédiée" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Для личного содержимого нужно отдельное согласие или специальный архив" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O conteúdo privado exige autorização separada ou um arquivo dedicado" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Private Inhalte erfordern eine separate Zustimmung oder ihr eigenes Archiv" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "個人の内容は別途許可するか、専用アーカイブを使用する必要があります" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비공개 내용은 별도 승인 또는 전용 아카이브가 필요합니다" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتطلب المحتوى الخاص موافقة منفصلة أو أرشيفه المخصص" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Özel içerik için ayrıca onay veya kendisine özel arşiv gerekir" + } + } + } + }, + "preferencesBackup.exclusion.deviceState": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Device state and calibration stay on this Mac" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设备状态和校准保留在本机" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "裝置狀態與校正資料保留在此 Mac" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El estado y la calibración se conservan en este Mac" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’état et l’étalonnage restent sur ce Mac" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Состояние устройств и калибровка остаются на этом Mac" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O estado e a calibração permanecem neste Mac" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gerätestatus und Kalibrierung bleiben auf diesem Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバイスの状態と校正情報はこのMacに保持されます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기기 상태와 보정 정보는 이 Mac에 유지됩니다" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبقى حالة الأجهزة وبيانات المعايرة على هذا الـ Mac" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Aygıt durumu ve kalibrasyon bu Mac’te kalır" + } + } + } + }, + "preferencesBackup.exclusion.localActivation": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Running state and local activation consent stay on this Mac" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行状态和本机启用授权保留在本机" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "執行狀態與本機啟用授權保留在此 Mac" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El estado de ejecución y las autorizaciones locales se conservan en este Mac" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’état d’exécution et les autorisations locales restent sur ce Mac" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Состояние выполнения и локальные разрешения остаются на этом Mac" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O estado de execução e as autorizações locais permanecem neste Mac" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Laufzeitstatus und lokale Freigaben bleiben auf diesem Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行状態とこのMacでの有効化の許可は保持されます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행 상태와 로컬 활성화 승인은 이 Mac에 유지됩니다" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبقى حالة التشغيل وموافقات التفعيل المحلية على هذا الـ Mac" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma durumu ve yerel onaylar bu Mac’te kalır" + } + } + } + }, + "preferencesBackup.exclusion.applicationPaths": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Apps restore by identity; missing targets remain unresolved" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用按身份恢复,缺失的目标保持未解析" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "依應用程式身分回復;找不到的目標會保留為未解析" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las apps se restauran por identidad; los destinos ausentes quedan sin resolver" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les apps sont restaurées selon leur identité ; les cibles absentes restent non résolues" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Приложения восстанавливаются по идентификаторам; отсутствующие цели сохраняются без привязки" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Os apps são restaurados por identidade; destinos ausentes permanecem sem resolução" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Apps werden anhand ihrer Identität wiederhergestellt; fehlende Ziele bleiben nicht zugeordnet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリは識別情報で復元されます。見つからない対象は未解決で保持されます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱은 식별 정보로 복원되며, 없는 대상은 미해결 상태로 유지됩니다" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تُستعاد التطبيقات حسب هويتها؛ وتبقى الأهداف المفقودة غير محلولة" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uygulamalar kimliklerine göre geri yüklenir; eksik hedefler çözümlenmeden kalır" + } + } + } + }, + "preferencesBackup.exclusion.externalAuthorization": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "External execution and unsafe inputs require local authorization" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "外部执行和高风险输入需在本机授权" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "外部執行與高風險輸入須在本機授權" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La ejecución externa y las entradas de riesgo requieren autorización local" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’exécution externe et les entrées à risque nécessitent une autorisation locale" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Внешнее выполнение и опасный ввод требуют локального разрешения" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A execução externa e as entradas de risco exigem autorização local" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Externe Ausführung und riskante Eingaben erfordern eine lokale Freigabe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "外部からの実行と危険な入力には、このMacでの許可が必要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "외부 실행과 위험한 입력에는 로컬 승인이 필요합니다" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتطلب التنفيذ الخارجي والإدخال الخطر إذنًا محليًا" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Harici yürütme ve riskli girdiler yerel yetkilendirme gerektirir" + } + } + } + }, + "preferencesBackup.exclusion.localPolicy": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Local directories and operation policy are excluded" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "本机目录和操作策略不包含在备份中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "備份不包含本機目錄與操作原則" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se excluyen los directorios y las políticas locales" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les dossiers et règles d’opération locaux sont exclus" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Локальные каталоги и правила операций не включаются" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Os diretórios e as políticas locais são excluídos" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lokale Verzeichnisse und Vorgangsrichtlinien sind ausgeschlossen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このMacのディレクトリと操作ポリシーは含まれません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로컬 디렉터리와 작업 정책은 제외됩니다" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تُستثنى المجلدات المحلية وسياسات العمليات" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yerel dizinler ve işlem ilkeleri hariç tutulur" + } + } + } + }, + "preferencesBackup.export.failedPlugins": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "These plugins could not be backed up. Deselect them and retry: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "以下插件无法备份。请取消选择后重试:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "以下外掛無法備份。請取消選取後重試:%@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo crear una copia de estos plugins. Desmárcalos e inténtalo de nuevo: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ces modules n’ont pas pu être sauvegardés. Désélectionnez-les, puis réessayez : %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось создать резервную копию этих плагинов. Снимите выделение и повторите попытку: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível fazer backup destes plugins. Desmarque-os e tente novamente: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Plugins konnten nicht gesichert werden. Auswahl aufheben und erneut versuchen: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次のプラグインをバックアップできませんでした。選択を解除して再試行してください:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다음 플러그인을 백업할 수 없습니다. 선택을 해제한 후 다시 시도하세요: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر نسخ هذه الإضافات احتياطيًا. ألغِ تحديدها ثم أعد المحاولة: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu eklentiler yedeklenemedi. Seçimlerini kaldırıp tekrar deneyin: %@" + } + } + } + }, + "preferencesBackup.coverage.readyToRestore": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings will be imported" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将导入设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將匯入設定" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se importarán los ajustes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les réglages seront importés" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройки будут импортированы" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Os ajustes serão importados" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen werden importiert" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定を読み込みます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정을 가져옵니다" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيتم استيراد الإعدادات" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ayarlar içe aktarılacak" + } + } + } + }, + "preferencesBackup.coverage.summary": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Included %d · Local only %d · No settings %d · Unavailable %d" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "包含 %d · 仅本机 %d · 无设置 %d · 不可用 %d" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "包含 %d · 僅本機 %d · 無設定 %d · 無法使用 %d" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Incluidos %d · Solo locales %d · Sin ajustes %d · No disponibles %d" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Inclus %d · Locaux %d · Sans réglages %d · Indisponibles %d" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включено %d · Локальных %d · Без настроек %d · Недоступных %d" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Incluídos %d · Locais %d · Sem ajustes %d · Indisponíveis %d" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Enthalten %d · Nur lokal %d · Ohne Einstellungen %d · Nicht verfügbar %d" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "含む %d · このMacのみ %d · 設定なし %d · 利用不可 %d" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포함 %d · 로컬 전용 %d · 설정 없음 %d · 사용 불가 %d" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مضمنة %d · محلية فقط %d · بلا إعدادات %d · غير متاحة %d" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Dahil %d · Yalnızca yerel %d · Ayarsız %d · Kullanılamayan %d" + } + } + } } }, "version": "1.0" diff --git a/Tests/App/AutomaticPreferencesBackupTests.swift b/Tests/App/AutomaticPreferencesBackupTests.swift index 6eca14bcf..7bd6cdefe 100644 --- a/Tests/App/AutomaticPreferencesBackupTests.swift +++ b/Tests/App/AutomaticPreferencesBackupTests.swift @@ -4,6 +4,21 @@ import MacToolsPluginKit @MainActor final class AutomaticPreferencesBackupTests: XCTestCase { + func testExportFailurePreservesLastGoodBackupAndPreventsSafetySnapshot() async throws { + let directory = makeTemporaryDirectoryURL() + let store = AutomaticPreferencesBackupStore(directoryURL: directory) + let good = makeBackup(marker: "last-good") + _ = try store.write(good) + let files = try backupFiles(in: directory) + let bytes = try Data(contentsOf: XCTUnwrap(files.first)) + let coordinator = AutomaticPreferencesBackupCoordinator(userDefaults: makeDefaults(), store: store) + coordinator.snapshotProvider = { throw PreferencesBackupExportError(pluginIDs: ["failed"]) } + do { _ = try await coordinator.createBackupNow(); XCTFail("An incomplete export must fail") } catch {} + XCTAssertThrowsError(try coordinator.createSafetySnapshotBeforeImport()) + XCTAssertEqual(try backupFiles(in: directory), files) + XCTAssertEqual(try Data(contentsOf: XCTUnwrap(files.first)), bytes) + } + private var temporaryURLs: [URL] = [] private var defaultsSuiteNames: [String] = [] diff --git a/Tests/App/LegacyFormat7PreferencesBackup.swift b/Tests/App/LegacyFormat7PreferencesBackup.swift new file mode 100644 index 000000000..7171b633e --- /dev/null +++ b/Tests/App/LegacyFormat7PreferencesBackup.swift @@ -0,0 +1,140 @@ +import Foundation +import MacToolsPluginKit +@testable import MacTools + +// Frozen release 2.1.0 fields and decoder from 08754ad4, with only the enclosing type renamed. +struct LegacyFormat7PreferencesBackup: Decodable, Sendable { + static let currentFormatVersion = 7 + static let maximumFileSize = 16 * 1024 * 1024 + + struct ApplicationPreferences: Codable, Equatable, Sendable { + let appearancePreference: String + let floatingPanelAppearance: String? + let languagePreference: String + // Decode older backups only; current exports store the panel order instead. + let menuBarClickBehavior: String? + let settingsSidebarPluginSortMode: String? + let settingsSidebarCustomPluginOrder: [String]? + + init( + appearancePreference: String, + floatingPanelAppearance: String? = nil, + languagePreference: String, + menuBarClickBehavior: String? = nil, + settingsSidebarPluginSortMode: String? = nil, + settingsSidebarCustomPluginOrder: [String]? = nil + ) { + self.appearancePreference = appearancePreference + self.floatingPanelAppearance = floatingPanelAppearance + self.languagePreference = languagePreference + self.menuBarClickBehavior = menuBarClickBehavior + self.settingsSidebarPluginSortMode = settingsSidebarPluginSortMode + self.settingsSidebarCustomPluginOrder = settingsSidebarCustomPluginOrder + } + } + + let formatVersion: Int + let exportedAt: Date + let application: ApplicationPreferences + let pluginDisplay: PluginDisplayPreferencesBackup + let shortcutCustomizations: [String: ShortcutCustomization] + let actionShortcutAssignments: [ActionShortcutAssignmentRecord] + private(set) var actionShortcutAssignmentsWereEncoded = true + let pluginPreferences: [String: Data] + let pluginPreferenceActionReferences: [String: [ActionReference]] + let actionInvocationPresets: [ActionInvocationPreset]? + let workflows: [WorkflowDefinition]? + let automationRules: [AutomationRule]? + let selection: PreferencesBackupSelection? + + private enum CodingKeys: String, CodingKey { + case formatVersion + case exportedAt + case application + case pluginDisplay + case shortcutCustomizations + case actionShortcutAssignments + case pluginPreferences + case pluginPreferenceActionReferences + case actionInvocationPresets + case workflows + case automationRules + case selection + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + formatVersion = try container.decode(Int.self, forKey: .formatVersion) + exportedAt = try container.decode(Date.self, forKey: .exportedAt) + application = try container.decode(ApplicationPreferences.self, forKey: .application) + pluginDisplay = try container.decode(PluginDisplayPreferencesBackup.self, forKey: .pluginDisplay) + shortcutCustomizations = try container.decode( + [String: ShortcutCustomization].self, + forKey: .shortcutCustomizations + ) + actionShortcutAssignmentsWereEncoded = container.contains(.actionShortcutAssignments) + actionShortcutAssignments = try container.decodeIfPresent( + [ActionShortcutAssignmentRecord].self, + forKey: .actionShortcutAssignments + ) ?? [] + pluginPreferences = try container.decodeIfPresent( + [String: Data].self, + forKey: .pluginPreferences + ) ?? [:] + let decodedPluginPreferenceActionReferences = try container.decodeIfPresent( + [String: [ActionReference]].self, + forKey: .pluginPreferenceActionReferences + ) + pluginPreferenceActionReferences = decodedPluginPreferenceActionReferences ?? [:] + actionInvocationPresets = try container.decodeIfPresent( + [ActionInvocationPreset].self, + forKey: .actionInvocationPresets + ) + workflows = try container.decodeIfPresent( + [WorkflowDefinition].self, + forKey: .workflows + ) + automationRules = try container.decodeIfPresent( + [AutomationRule].self, + forKey: .automationRules + ) + selection = try container.decodeIfPresent( + PreferencesBackupSelection.self, + forKey: .selection + ) + if formatVersion >= 4, + actionInvocationPresets == nil || workflows == nil || automationRules == nil { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "Format 4 backups must include Run Link presets, workflows, and automation rules." + ) + ) + } + if formatVersion >= 5, selection == nil { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "Format 5 backups must describe the selected preference categories." + ) + ) + } + if formatVersion >= 6, decodedPluginPreferenceActionReferences == nil { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "Format 6 backups must include plugin action dependencies." + ) + ) + } + if formatVersion >= 6, !actionShortcutAssignmentsWereEncoded { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "Format 6 backups must include action shortcut assignments." + ) + ) + } + } + +} diff --git a/Tests/App/PreferencesBackupDesktopEvidenceTests.swift b/Tests/App/PreferencesBackupDesktopEvidenceTests.swift new file mode 100644 index 000000000..bcac72736 --- /dev/null +++ b/Tests/App/PreferencesBackupDesktopEvidenceTests.swift @@ -0,0 +1,156 @@ +import AppKit +import SwiftUI +import XCTest +import MacToolsPluginKit +@testable import MacTools +@testable import WindowLayoutsPlugin +@testable import RightClickPlugin + +/// On-demand native UI evidence; full CI skips this desktop-dependent check. +@MainActor +final class PreferencesBackupDesktopEvidenceTests: XCTestCase { + func testCaptureIsolatedBackupFlowAndNativeCoverage() async throws { + guard let path = ProcessInfo.processInfo.environment["MACTOOLS_BACKUP_EVIDENCE_DIR"] else { + throw XCTSkip("Set MACTOOLS_BACKUP_EVIDENCE_DIR for isolated desktop evidence.") + } + let directory = URL(fileURLWithPath: path, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let token = UUID().uuidString + let sourceName = "org.mactools.backup-evidence.source.\(token)" + let destinationName = "org.mactools.backup-evidence.destination.\(token)" + let sourceDefaults = try XCTUnwrap(UserDefaults(suiteName: sourceName)) + let destinationDefaults = try XCTUnwrap(UserDefaults(suiteName: destinationName)) + defer { + sourceDefaults.removePersistentDomain(forName: sourceName) + destinationDefaults.removePersistentDomain(forName: destinationName) + PluginRuntimeLocalization.source.setPreference(UserDefaults.standard.string(forKey: AppLanguagePreference.userDefaultsKey)) + } + let storage = UserDefaultsPluginStorage(pluginID: "window-layouts", userDefaults: sourceDefaults) + let store = WindowLayoutsStore(storage: storage) + store.setGap(8) + store.setCyclesHalves(false) + store.setRespectsStageManager(false) + store.setShowsCommandFeedback(false) + store.setCenteredGuidesEnabled(true) + store.setModifierDragEnabled(true) + store.setModifierDragModifiers([.control, .option, .command]) + let command = try XCTUnwrap(store.addCustomCommand(name: "Synthetic reading layout")) + let sourcePlugin = WindowLayoutsPlugin(context: .init(pluginID: "window-layouts", storage: storage), accessibilityTrusted: { false }, requestAccessibilityTrust: { _ in XCTFail("Unexpected permission prompt"); return false }) + let sourceHost = makeHost(plugin: sourcePlugin, defaults: sourceDefaults) + defer { sourceHost.deactivateAllPlugins() } + let reference = ActionReference(key: .init(providerID: "window-layouts", actionID: command.actionID)) + guard case .success = sourceHost.setActionShortcutBinding(.init(keyCode: 80, modifiers: [.command, .control, .option, .shift]), to: reference) else { + return XCTFail("Unable to set synthetic shortcut") + } + let backup = try sourceHost.makePreferencesBackupExport().requiringSuccessfulSelection() + let data = try JSONEncoder().encode(backup) + try data.write(to: directory.appendingPathComponent("synthetic-format7.json"), options: .atomic) + let destinationStorage = UserDefaultsPluginStorage(pluginID: "window-layouts", userDefaults: destinationDefaults) + WindowLayoutsStore(storage: destinationStorage).setGap(3) + let destinationPlugin = WindowLayoutsPlugin(context: .init(pluginID: "window-layouts", storage: destinationStorage), accessibilityTrusted: { false }, requestAccessibilityTrust: { _ in XCTFail("Unexpected permission prompt"); return false }) + let destinationHost = makeHost(plugin: destinationPlugin, defaults: destinationDefaults) + defer { destinationHost.deactivateAllPlugins() } + let result = try destinationHost.importPreferences(try JSONDecoder().decode(PreferencesBackup.self, from: data)) + XCTAssertTrue(result.shortcutErrors.isEmpty) + XCTAssertEqual(result.pluginOutcomes.first?.status, "needsAttention") + XCTAssertEqual(destinationHost.shortcutAssignmentService.assignment(for: reference)?.reference, reference) + let reloaded = WindowLayoutsStore(storage: destinationStorage) + XCTAssertEqual(reloaded.gap, 8) + XCTAssertTrue(reloaded.centeredGuidesEnabled) + XCTAssertTrue(reloaded.modifierDragEnabled) + XCTAssertEqual(reloaded.modifierDragModifiers, [.control, .option, .command]) + XCTAssertEqual(reloaded.customCommands.first?.id, command.id) + let coverage: [PreferencesPluginBackupDiagnostic] = [ + .init(pluginID: "window-layouts", status: "partial", exclusions: ["permissions", "external-authorization"]), + .init(pluginID: "ai-assistant", status: "partial", exclusions: ["credentials", "private-content"]), + .init(pluginID: "disk-clean", status: "localOnly", exclusions: ["local-policy"]), + .init(pluginID: "appearance", status: "none", exclusions: []), + .init(pluginID: "missing", status: "unavailable", exclusions: []), + ] + let options = [PreferencesPluginOption(id: "window-layouts", title: "Window Layouts"), .init(id: "ai-assistant", title: "AI Assistant"), .init(id: "disk-clean", title: "Disk Clean"), .init(id: "appearance", title: "Appearance"), .init(id: "missing", title: "Unavailable Plugin")] + var accessibilityReport: [String: [String]] = [:] + for language in ["en", "zh-Hans", "de", "ar"] { + PluginRuntimeLocalization.source.setPreference(language) + var exports = 0 + var cancellations = 0 + let root = PreferencesExportSelectionSheet(selection: .constant(.all(pluginPreferenceIDs: ["window-layouts", "ai-assistant", "missing"])), pluginOptions: Array(options.prefix(2)) + [options[4]], coverage: coverage, coverageOptions: options, exportFailure: nil, deviceLocalAutomationRuleCount: 0, onCancel: { cancellations += 1 }, onExport: { exports += 1 }) + .environment(\.locale, Locale(identifier: language)) + .environment(\.layoutDirection, language == "ar" ? .rightToLeft : .leftToRight) + accessibilityReport[language] = try await capture(root, name: "export-\(language)", in: directory) + XCTAssertEqual(exports, 1, "Return must invoke the default export action") + XCTAssertEqual(cancellations, 1, "Escape must invoke Cancel") + } + PluginRuntimeLocalization.source.setPreference("en") + let rightClickBundle = try XCTUnwrap(Bundle(url: Bundle.main.bundleURL.deletingLastPathComponent().appendingPathComponent("RightClick.bundle"))) + let rightClickSession = RightClickSettingsSession( + configuration: .init(openWithApps: [.init(name: "Missing Editor", appPath: "", fileExtensions: ["txt"])]), + fileURL: directory.appendingPathComponent("synthetic-right-click.json")) + let rightClickView = RightClickMenuSettingsView(session: rightClickSession, + localization: PluginLocalization(bundle: rightClickBundle), section: .openWith) + .padding() + .environment(\.locale, Locale(identifier: "en")) + accessibilityReport["right-click-unresolved"] = try await capture(rightClickView, name: "right-click-unresolved", in: directory) { + var restored = rightClickSession.configuration + restored.openWithApps[0].fileExtensions = ["md"] + let fileURL = directory.appendingPathComponent("synthetic-right-click.json") + let encoder = JSONEncoder() + try encoder.encode(restored).write(to: fileURL, options: .atomic) + rightClickSession.reload() + } + let rightClickReport = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: directory.appendingPathComponent("right-click-unresolved-ax.json"))) as? [String: Any]) + let elements = try XCTUnwrap(rightClickReport["elements"] as? [[String: String]]) + XCTAssertEqual(elements.filter { $0["role"] == "AXTextField" }.compactMap { $0["value"] }, ["md"], + "The open editor must show the restored filter for the same application UUID") + try JSONEncoder().encode(accessibilityReport).write(to: directory.appendingPathComponent("native-accessibility.json"), options: .atomic) + } + + private func makeHost(plugin: WindowLayoutsPlugin, defaults: UserDefaults) -> PluginHost { + PluginHost(plugins: [plugin], shortcutStore: .init(userDefaults: defaults), pluginOrderingStore: .init(userDefaults: defaults), preferencesBackupStore: PreferencesBackupStore(userDefaults: defaults), globalShortcutManager: .init()) + } + + private func capture(_ content: Content, name: String, in directory: URL, + afterAppearance: (() throws -> Void)? = nil) async throws -> [String] { + let view = NSHostingView(rootView: content) + let size = NSSize(width: 520, height: 720) + let previousPolicy = NSApp.activationPolicy() + NSApp.setActivationPolicy(.regular) + defer { NSApp.setActivationPolicy(previousPolicy) } + let window = NSWindow(contentRect: NSRect(origin: .zero, size: size), styleMask: [.titled, .closable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.title = "MacTools synthetic backup evidence" + window.contentView = view + window.center() + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + defer { window.close() } + try await Task.sleep(for: .milliseconds(200)) + view.layoutSubtreeIfNeeded() + if let afterAppearance { + try afterAppearance() + try await Task.sleep(for: .milliseconds(200)) + view.layoutSubtreeIfNeeded() + } + var labels: [String] = [] + func inspect(_ element: any NSAccessibilityProtocol, depth: Int = 0) { + guard depth < 30 else { return } + if let label = element.accessibilityLabel(), !label.isEmpty { labels.append(label) } + if let title = element.accessibilityTitle(), !title.isEmpty { labels.append(title) } + for child in element.accessibilityChildren() ?? [] { + if let child = child as? any NSAccessibilityProtocol { inspect(child, depth: depth + 1) } + } + } + inspect(window) + window.selectNextKeyView(nil) + labels.append("Keyboard first responder: \(String(describing: window.firstResponder))") + let done = directory.appendingPathComponent(name + ".done") + try? FileManager.default.removeItem(at: done) + let request: [String: Any] = ["name": name, "pid": ProcessInfo.processInfo.processIdentifier, "windowID": window.windowNumber] + try JSONSerialization.data(withJSONObject: request).write(to: directory.appendingPathComponent("capture-request.json"), options: .atomic) + for _ in 0..<200 { + if FileManager.default.fileExists(atPath: done.path) { break } + try await Task.sleep(for: .milliseconds(50)) + } + XCTAssertEqual(try String(contentsOf: done, encoding: .utf8), "0", "Run the isolated capture helper alongside this on-demand check") + return labels + } +} diff --git a/Tests/App/PreferencesBackupTests.swift b/Tests/App/PreferencesBackupTests.swift index f32e422d3..1d6f5e9d6 100644 --- a/Tests/App/PreferencesBackupTests.swift +++ b/Tests/App/PreferencesBackupTests.swift @@ -7,6 +7,51 @@ import MacToolsPluginKit @MainActor final class PreferencesBackupTests: XCTestCase { + func testSelectedExporterFailureIsVisibleAndUnselectedFailureDoesNotBlock() throws { + let good = BackupTestPlugin(id: "good", order: 0, shortcutID: "good", portablePreferences: Data("good".utf8)) + let failed = BackupTestPlugin(id: "failed", order: 1, shortcutID: "failed") + let host = makeHost(plugins: [good, failed], defaults: makeDefaults()) + XCTAssertEqual(host.preferencesBackupCandidateIDs, ["good", "failed"]) + let result = host.makePreferencesBackupExport() + XCTAssertThrowsError(try result.requiringSuccessfulSelection()) + XCTAssertEqual(result.backup.effectiveSelection.pluginPreferenceIDs, ["good"]) + XCTAssertEqual(result.pluginOutcomes.first { $0.pluginID == "failed" }?.status, "failed") + XCTAssertThrowsError(try host.makePreferencesBackupExport().requiringSuccessfulSelection()) + let selected = PreferencesBackupSelection.all(pluginPreferenceIDs: ["good"]) + let partial = host.makePreferencesBackupExport(selection: selected) + XCTAssertNoThrow(try partial.requiringSuccessfulSelection()) + XCTAssertEqual(partial.pluginOutcomes.first { $0.pluginID == "failed" }?.status, "notSelected") + } + + func testDiagnosticsDoNotChangeMeaningOrRejectOtherwiseValidFormatSevenBackup() throws { + let host = makeHost(plugins: [], defaults: makeDefaults()) + let original = host.makePreferencesBackup() + var json = try XCTUnwrap(JSONSerialization.jsonObject(with: original.encodedJSON()) as? [String: Any]) + json["pluginDiagnostics"] = [["pluginID": "future-plugin", "status": "futureStatus", "exclusions": ["future-code"]]] + let future = try PreferencesBackup.decodeJSON(JSONSerialization.data(withJSONObject: json)) + XCTAssertTrue(original.hasSameMeaningfulContent(as: future)) + XCTAssertEqual(future.pluginDiagnostics?.first?.status, "futureStatus") + json["pluginDiagnostics"] = "malformed display metadata" + let malformed = try PreferencesBackup.decodeJSON(JSONSerialization.data(withJSONObject: json)) + XCTAssertNil(malformed.pluginDiagnostics) + XCTAssertTrue(original.hasSameMeaningfulContent(as: malformed)) + } + + func testCoveragePreservesPackageIdentitiesWithoutPreferencePayloads() throws { + let host = makeHost(plugins: [], defaults: makeDefaults()) + let diagnostics: [PreferencesPluginBackupDiagnostic] = [ + .init(pluginID: "com.mactools.plugin.storage-explorer", status: "none", exclusions: []), + .init(pluginID: "Example.Custom_plugin", status: "localOnly", exclusions: ["local-policy"]) + ] + let backup = PreferencesBackup(application: validApplicationPreferences, + pluginDisplay: .init(orderedPluginIDs: [], hiddenPluginIDs: []), shortcutCustomizations: [:], + pluginPreferences: [:], pluginDiagnostics: diagnostics) + let decoded = try PreferencesBackup.decodeJSON(backup.encodedJSON()) + XCTAssertEqual(decoded.pluginDiagnostics, diagnostics) + let preview = try host.preferencesImportPreview(for: decoded) + XCTAssertEqual(preview.pluginDiagnostics, diagnostics.sorted { $0.pluginID < $1.pluginID }) + } + private let suiteName = "PreferencesBackupTests" override func tearDown() { @@ -388,6 +433,70 @@ final class PreferencesBackupTests: XCTestCase { ) } + func testPreviousFormat7ReleaseDecoderIgnoresNewCoverageMetadata() throws { + let backup = PreferencesBackup(application: validApplicationPreferences, + pluginDisplay: .init(orderedPluginIDs: [], hiddenPluginIDs: []), shortcutCustomizations: [:], + pluginPreferences: ["window-layouts": Data("synthetic settings".utf8)], + pluginDiagnostics: [.init(pluginID: "window-layouts", status: "included", exclusions: ["permissions"])]) + let previous = try JSONDecoder().decode(LegacyFormat7PreferencesBackup.self, from: JSONEncoder().encode(backup)) + XCTAssertEqual(previous.formatVersion, 7) + XCTAssertEqual(previous.pluginPreferences, backup.pluginPreferences) + XCTAssertEqual(previous.selection, backup.selection) + } + + func testIsolatedExporterRemainsARequiredCandidateOnNextAttempt() { + let plugin = BackupTestPlugin(id: "broken", order: 0, shortcutID: "a", portablePreferences: Data("value".utf8)) + let host = makeHost(plugins: [plugin], defaults: makeDefaults()) + plugin.raisesExportException = true + let first = host.makePreferencesBackupExport() + XCTAssertThrowsError(try first.requiringSuccessfulSelection()) + XCTAssertEqual(first.pluginOutcomes.first { $0.pluginID == "broken" }?.status, "failed") + XCTAssertTrue(host.preferencesBackupCandidateIDs.contains("broken")) + let second = host.makePreferencesBackupExport() + XCTAssertThrowsError(try second.requiringSuccessfulSelection()) + XCTAssertEqual(second.pluginOutcomes.first { $0.pluginID == "broken" }?.status, "unavailable") + } + + func testExportDropsReferencesWhoseDefinitionWasOmittedFromSuccessfulPayload() throws { + let provider = BackupPreferenceDefinedActionPlugin() + provider.restorePortablePreferences(from: Data("enabled".utf8)) + let surface = BackupActionSurfacePlugin() + surface.references = [provider.reference] + let host = makeHost(plugins: [surface, provider], defaults: makeDefaults()) + let workflow = WorkflowDefinition(name: "Omitted definition", steps: [WorkflowStep(reference: provider.reference)]) + let backup = PreferencesBackup(application: validApplicationPreferences, + pluginDisplay: .init(orderedPluginIDs: [], hiddenPluginIDs: []), shortcutCustomizations: [:], + actionShortcutAssignments: [.init(reference: provider.reference, binding: .init(keyCode: 45, modifiers: [.command, .control]))], + pluginPreferences: [provider.metadata.id: Data("enabled".utf8)], + actionInvocationPresets: [.init(reference: provider.reference)], workflows: [workflow]) + _ = try host.importPreferences(backup) + surface.references = [provider.reference] + provider.omitsDefinitionFromBackup = true + let exported = try host.makePreferencesBackupExport().requiringSuccessfulSelection() + XCTAssertNotNil(exported.pluginPreferences[provider.metadata.id]) + XCTAssertTrue(exported.actionShortcutAssignments.isEmpty) + XCTAssertTrue(exported.actionInvocationPresets?.isEmpty ?? true) + XCTAssertTrue(exported.workflows?.isEmpty ?? true) + let surfaceData = try XCTUnwrap(exported.pluginPreferences[surface.metadata.id]) + XCTAssertTrue(try JSONDecoder().decode([ActionReference].self, from: surfaceData).isEmpty) + } + + func testPreviewDoesNotTrustSourceIncludedClaimsOverPayloadAndSelection() throws { + let plugin = BackupTestPlugin(id: "available", order: 0, shortcutID: "a", portablePreferences: Data("a".utf8)) + let host = makeHost(plugins: [plugin], defaults: makeDefaults()) + let backup = PreferencesBackup(application: validApplicationPreferences, + pluginDisplay: .init(orderedPluginIDs: [], hiddenPluginIDs: []), shortcutCustomizations: [:], + pluginPreferences: ["available": Data("a".utf8), "missing": Data("b".utf8)], + pluginDiagnostics: [.init(pluginID: "available", status: "included", exclusions: []), + .init(pluginID: "claimed", status: "included", exclusions: [])]) + var selection = backup.effectiveSelection + selection.pluginPreferenceIDs.remove("available") + let preview = try host.preferencesImportPreview(for: backup, selection: selection) + XCTAssertEqual(preview.pluginDiagnostics.first { $0.pluginID == "available" }?.status, "notSelected") + XCTAssertEqual(preview.pluginDiagnostics.first { $0.pluginID == "missing" }?.status, "unavailable") + XCTAssertEqual(preview.pluginDiagnostics.first { $0.pluginID == "claimed" }?.status, "notSelected") + } + func testFailedProviderRestoreDropsAllDependentImportedReferences() throws { let provider = BackupPreferenceDefinedActionPlugin() provider.shouldFailRestore = true @@ -646,6 +755,25 @@ final class PreferencesBackupTests: XCTestCase { XCTAssertEqual(targetHost.panelItems.map(\.pluginID), ["second", "first"]) } + func testManualShortcutImportReplacesOmittedBindingsOnlyWhenSelected() throws { + let defaults = makeDefaults() + let store = ShortcutStore(userDefaults: defaults) + let host = makeHost(plugins: [BackupTestPlugin(id: "local-item", order: 1, shortcutID: "paste")], defaults: defaults) + defer { host.deactivateAllPlugins() } + let id = "local-item.shortcut.paste" + let binding = ShortcutBinding(keyCode: 12, modifiers: [.command, .option]) + host.setShortcutBinding(binding, for: id) + let backup = PreferencesBackup(application: validApplicationPreferences, + pluginDisplay: .init(orderedPluginIDs: [], hiddenPluginIDs: []), shortcutCustomizations: [:]) + var selection = backup.effectiveSelection + selection.includesShortcuts = false + XCTAssertTrue(try host.importPreferences(backup, selection: selection).shortcutErrors.isEmpty) + XCTAssertEqual(store.customization(for: id), .custom(binding)) + selection.includesShortcuts = true + XCTAssertTrue(try host.importPreferences(backup, selection: selection).shortcutErrors.isEmpty) + XCTAssertEqual(store.customization(for: id), .inheritDefault) + } + func testInvalidShortcutImportLeavesAllShortcutCustomizationsUntouched() throws { let defaults = makeDefaults() let host = makeHost( @@ -993,6 +1121,7 @@ private final class BackupTestPlugin: MacToolsPlugin, PluginPortablePreferencesP var requestPermissionGuidance: ((String) -> Void)? var shortcutBindingResolver: ((String) -> ShortcutBinding?)? private let portablePreferences: Data? + var raisesExportException = false private(set) var restoredPortablePreferences: Data? init(id: String, order: Int, shortcutID: String, portablePreferences: Data? = nil) { @@ -1023,7 +1152,8 @@ private final class BackupTestPlugin: MacToolsPlugin, PluginPortablePreferencesP } func makePortablePreferencesBackup() -> Data? { - portablePreferences + if raisesExportException { raiseTestPluginException(reason: "Synthetic exporter failure") } + return portablePreferences } func restorePortablePreferences(from data: Data) { @@ -1235,6 +1365,7 @@ private final class BackupPreferenceDefinedActionPlugin: MacToolsPlugin, PluginA var requestPermissionGuidance: ((String) -> Void)? var shortcutBindingResolver: ((String) -> ShortcutBinding?)? var shouldFailRestore = false + var omitsDefinitionFromBackup = false private var preferencesRestored = false var reference: ActionReference { @@ -1278,6 +1409,7 @@ private final class BackupPreferenceDefinedActionPlugin: MacToolsPlugin, PluginA func actionReferences(inPortablePreferences data: Data) -> [ActionReference]? { guard let actionID = decodedActionID(from: data) else { return nil } + if omitsDefinitionFromBackup { return [] } return [ActionReference( key: ActionKey(providerID: metadata.id, actionID: actionID) )] diff --git a/Tests/Core/Preferences/CloudPreferencesSyncCoordinatorTests.swift b/Tests/Core/Preferences/CloudPreferencesSyncCoordinatorTests.swift index 213674f52..b7ef22189 100644 --- a/Tests/Core/Preferences/CloudPreferencesSyncCoordinatorTests.swift +++ b/Tests/Core/Preferences/CloudPreferencesSyncCoordinatorTests.swift @@ -4,6 +4,24 @@ import MacToolsPluginKit @MainActor final class CloudPreferencesSyncCoordinatorTests: XCTestCase { + func testExportFailurePreservesCanonicalCloudSnapshot() async throws { + let directory = makeTemporaryDirectoryURL() + let coordinator = CloudPreferencesSyncCoordinator(userDefaults: makeDefaults(), debounceDelay: .seconds(60)) + let good = makeBackup(marker: "last-good") + coordinator.snapshotProvider = { good } + coordinator.setSyncDirectoryURL(directory) + coordinator.setEnabled(true) + try await coordinator.syncNow() + let file = directory.appendingPathComponent(CloudPreferencesSnapshot.defaultFileName) + let bytes = try Data(contentsOf: file) + let generation = coordinator.currentGeneration + coordinator.snapshotProvider = { throw PreferencesBackupExportError(pluginIDs: ["failed"]) } + do { try await coordinator.syncNow(); XCTFail("An incomplete export must fail") } catch {} + XCTAssertEqual(try Data(contentsOf: file), bytes) + XCTAssertEqual(coordinator.currentGeneration, generation) + coordinator.setEnabled(false) + } + private var temporaryURLs: [URL] = [] private var defaultsSuiteNames: [String] = [] diff --git a/changes/unreleased/2026-10-24-preferences-backup-coverage.md b/changes/unreleased/2026-10-24-preferences-backup-coverage.md new file mode 100644 index 000000000..a99d872bb --- /dev/null +++ b/changes/unreleased/2026-10-24-preferences-backup-coverage.md @@ -0,0 +1,5 @@ +--- +release: app +type: fixed +--- +Preferences Backup now shows plugin coverage, exclusions, and restore results. Failed exports preserve the last good automatic or cloud backup, and dependent shortcuts follow successfully included definitions. diff --git a/changes/unreleased/2026-10-24-window-layouts-preferences-backup.md b/changes/unreleased/2026-10-24-window-layouts-preferences-backup.md new file mode 100644 index 000000000..78e4220bc --- /dev/null +++ b/changes/unreleased/2026-10-24-window-layouts-preferences-backup.md @@ -0,0 +1,5 @@ +--- +release: plugin +type: fixed +--- +Backups now include more plugin settings, custom layouts, and stable app mappings. Private prompt text and launch-item notes require a separate opt-in; local data and permissions stay on your Mac. diff --git a/changes/unreleased/503-backup-failure-boundaries.md b/changes/unreleased/503-backup-failure-boundaries.md new file mode 100644 index 000000000..bb0911fef --- /dev/null +++ b/changes/unreleased/503-backup-failure-boundaries.md @@ -0,0 +1,5 @@ +--- +release: plugin +type: fixed +--- +Clipboard imports preserve history across relaunches. Failed AI service saves preserve verified existing credentials, including legacy Translator keys. diff --git a/changes/unreleased/503-backup-private-editor-preservation.md b/changes/unreleased/503-backup-private-editor-preservation.md new file mode 100644 index 000000000..0d5c24d67 --- /dev/null +++ b/changes/unreleased/503-backup-private-editor-preservation.md @@ -0,0 +1,5 @@ +--- +release: plugin +type: fixed +--- +Preference imports preserve unrelated Translator and Launch Control drafts and omitted private templates. AI prompt edits wait for pending recovery to finish. diff --git a/changes/unreleased/503-backup-restoration-followups.md b/changes/unreleased/503-backup-restoration-followups.md new file mode 100644 index 000000000..9f2d1a6cf --- /dev/null +++ b/changes/unreleased/503-backup-restoration-followups.md @@ -0,0 +1,5 @@ +--- +release: plugin +type: fixed +--- +Open editors retain restored input rules and AI prompts, included disabled prompts keep their shortcuts, and failed key saves preserve verified existing access. Translator rejects unusable restored services. diff --git a/changes/unreleased/503-backup-session-recovery.md b/changes/unreleased/503-backup-session-recovery.md new file mode 100644 index 000000000..012fe1849 --- /dev/null +++ b/changes/unreleased/503-backup-session-recovery.md @@ -0,0 +1,5 @@ +--- +release: plugin +type: fixed +--- +Imported app volumes apply to new playback while current playback keeps its gain. Large excluded prompts no longer block existing AI credentials or provider settings import. diff --git a/changes/unreleased/503-backup-shortcut-dependencies.md b/changes/unreleased/503-backup-shortcut-dependencies.md new file mode 100644 index 000000000..a651cb72b --- /dev/null +++ b/changes/unreleased/503-backup-shortcut-dependencies.md @@ -0,0 +1,5 @@ +--- +release: app +type: fixed +--- +Imported prompt shortcuts apply only when their template definitions restore successfully. diff --git a/changes/unreleased/503-import-shortcut-replacement.md b/changes/unreleased/503-import-shortcut-replacement.md new file mode 100644 index 000000000..adde43f71 --- /dev/null +++ b/changes/unreleased/503-import-shortcut-replacement.md @@ -0,0 +1,5 @@ +--- +release: app +type: changed +--- +Import now clarifies that selecting Shortcuts resets omitted bindings to defaults. Unselected categories and local data are preserved. diff --git a/changes/unreleased/503-live-volume-and-disabled-drafts.md b/changes/unreleased/503-live-volume-and-disabled-drafts.md new file mode 100644 index 000000000..d3f38ed13 --- /dev/null +++ b/changes/unreleased/503-live-volume-and-disabled-drafts.md @@ -0,0 +1,5 @@ +--- +release: plugin +type: fixed +--- +Per-app volume edits preserve other live routes after import. Saved disabled AI service drafts now transfer without blocking backups. diff --git a/changes/unreleased/503-plugin-backup-followups.md b/changes/unreleased/503-plugin-backup-followups.md new file mode 100644 index 000000000..cedd9d643 --- /dev/null +++ b/changes/unreleased/503-plugin-backup-followups.md @@ -0,0 +1,5 @@ +--- +release: plugin +type: fixed +--- +Settings transfer preserves working AI credentials and excluded private text, applies usage refresh intervals, and resumes low-battery warnings after notification access is granted. diff --git a/docs/plugins/development-guidelines.md b/docs/plugins/development-guidelines.md index 5af21dcda..8dbf7b644 100644 --- a/docs/plugins/development-guidelines.md +++ b/docs/plugins/development-guidelines.md @@ -30,6 +30,10 @@ Custom SwiftUI sheets must explicitly forward their presenter's `locale` and `la Independent AppKit hosting views and controllers must observe the runtime locale and forward both environments to their SwiftUI roots. Refresh localized native window labels and cached previews without recreating editing or selection state. Keep physical grids, pointer coordinates, and menu-bar item ordering stable; apply the language direction to their semantic text and controls. +## Preferences Backup + +Follow the [backup contract](preferences-backup.md) when persisting configuration. Keep the source manifest’s coverage/privacy declarations consistent with actual payloads and local exclusions. Regenerate the [inventory](preferences-backup-inventory.md), preserve custom identities and excluded private records, and verify failure/permission behavior without operating on real user data. + ## Widgets A widget is a reusable presentation of plugin data. It can have no placements, one placement, or multiple independent copies. diff --git a/docs/plugins/plugin-manifest.schema.json b/docs/plugins/plugin-manifest.schema.json index f85162000..40c3a14e9 100644 --- a/docs/plugins/plugin-manifest.schema.json +++ b/docs/plugins/plugin-manifest.schema.json @@ -26,6 +26,22 @@ "capabilities": {"$ref": "#/$defs/capabilities"}, "permissions": {"type": "array", "items": {"$ref": "#/$defs/permission"}, "uniqueItems": true}, "category": {"enum": ["display", "audio", "system", "storage", "productivity", "monitoring", "other"]}, + "preferencesBackup": { + "type": "object", + "required": ["status", "exclusions"], + "additionalProperties": false, + "properties": { + "status": {"enum": ["portable", "partial", "localOnly", "none", "unsupported"]}, + "exclusions": { + "type": "array", "uniqueItems": true, "maxItems": 9, + "items": {"enum": ["credentials", "permissions", "history", "private-content", "device-state", "local-activation", "application-paths", "external-authorization", "local-policy"]} + } + }, + "allOf": [ + {"if": {"properties": {"status": {"const": "partial"}}}, "then": {"properties": {"exclusions": {"minItems": 1}}}}, + {"if": {"properties": {"status": {"enum": ["portable", "none"]}}}, "then": {"properties": {"exclusions": {"maxItems": 0}}}} + ] + }, "uninstallDataPolicy": {"enum": ["preserve", "removePrivateData"]}, "releaseChannel": {"type": "string", "minLength": 1}, "releaseNotesURL": {"type": "string", "format": "uri", "pattern": "^https://"}, diff --git a/docs/plugins/preferences-backup-inventory.md b/docs/plugins/preferences-backup-inventory.md new file mode 100644 index 000000000..4e6738d0d --- /dev/null +++ b/docs/plugins/preferences-backup-inventory.md @@ -0,0 +1,80 @@ +# Preferences Backup coverage inventory + +Generated from `Plugins/*/plugin.json`; do not edit this table by hand. +Run `python3 scripts/plugins/generate-preferences-backup-inventory.py` after changing a declaration. + +Declarations describe supported field boundaries. Runtime availability and export/restore failures are reported separately. +See [the backup contract](preferences-backup.md) for exclusions and recovery limits. + +localOnly: 2; none: 24; partial: 35; portable: 7. + +| Plugin | ID | Coverage | Exclusion codes | +| --- | --- | --- | --- | +| [AI Assistant](../../Plugins/AIAssistant) | `ai-assistant` | partial | credentials, permissions, private-content | +| [AI Usage](../../Plugins/AIUsage) | `ai-usage` | partial | credentials, permissions, history | +| [Action Grid](../../Plugins/ActionGrid) | `action-grid` | portable | — | +| [Activity Stats](../../Plugins/ActivityBar) | `activity-bar` | partial | local-activation, history | +| [App Hotkeys](../../Plugins/AppHotkey) | `app-hotkey` | partial | application-paths | +| [App Volume](../../Plugins/AppVolume) | `app-volume` | partial | permissions, device-state | +| [Dark Mode](../../Plugins/Appearance) | `appearance` | none | — | +| [Apple Shortcuts](../../Plugins/AppleShortcuts) | `apple-shortcuts` | partial | private-content | +| [Auto-hide Dock](../../Plugins/AutoHideDock) | `auto-hide-dock` | none | — | +| [Auto-hide Menu Bar](../../Plugins/AutoHideMenuBar) | `auto-hide-menu-bar` | none | — | +| [Auto Input](../../Plugins/AutoInput) | `auto-input` | partial | application-paths | +| [Battery Charge Limit](../../Plugins/BatteryChargeLimit) | `battery-charge-limit` | partial | local-activation, device-state | +| [Calendar](../../Plugins/Calendar) | `calendar` | partial | permissions, private-content | +| [Clear Clipboard](../../Plugins/ClipboardClear) | `clipboard-clear` | none | — | +| [Clipboard](../../Plugins/ClipboardHistory) | `clipboard` | partial | private-content, local-activation | +| [Cloudflare R2 Upload](../../Plugins/CloudflareR2) | `cloudflare-r2` | partial | credentials, local-activation, history | +| [Apple Container Status](../../Plugins/ContainerIndicator) | `container-indicator` | none | — | +| [Device Battery](../../Plugins/DeviceBattery) | `device-battery` | partial | device-state, permissions | +| [Disk Cleanup](../../Plugins/DiskClean) | `disk-clean` | localOnly | local-policy | +| [Display Brightness](../../Plugins/DisplayBrightness) | `display-brightness` | partial | device-state | +| [Display Resolution](../../Plugins/DisplayResolution) | `display-resolution` | none | — | +| [Display Sleep](../../Plugins/DisplaySleep) | `display-sleep` | none | — | +| [True Tone](../../Plugins/DisplayTrueColor) | `display-true-color` | none | — | +| [Display Volume](../../Plugins/DisplayVolume) | `display-volume` | partial | device-state | +| [Hide Active App on Dock Click](../../Plugins/DockClickMinimize) | `dock-click-minimize` | portable | — | +| [Dock Lock](../../Plugins/DockLock) | `dock-lock` | portable | — | +| [Duo Status](../../Plugins/DuoStatus) | `duo-status` | partial | device-state | +| [Eject Disks](../../Plugins/EjectDisk) | `eject-disk` | none | — | +| [Empty Trash](../../Plugins/EmptyTrash) | `empty-trash` | none | — | +| [Fan Control](../../Plugins/FanControl) | `fan-control` | partial | device-state | +| [Fix Damaged Apps](../../Plugins/FixDamagedApp) | `fix-damaged-app` | portable | — | +| [Hide Notch](../../Plugins/HideNotch) | `hide-notch` | partial | device-state | +| [Homebrew Manager](../../Plugins/Homebrew) | `homebrew` | none | — | +| [IP Check](../../Plugins/IPOverview) | `ip-overview` | partial | history, local-policy | +| [Custom Shortcuts: Keyboard, Trackpad, Mouse](../../Plugins/InputRemapping) | `input-remapping` | partial | external-authorization | +| [Keep Awake](../../Plugins/KeepAwake) | `keep-awake` | partial | local-activation | +| [Key Stats](../../Plugins/KeyStats) | `key-stats` | localOnly | local-activation, history | +| [Launch Items](../../Plugins/LaunchControl) | `launch-control` | partial | private-content, local-activation, local-policy | +| [Launchpad](../../Plugins/Launchpad) | `launchpad` | partial | application-paths | +| [Lock Screen](../../Plugins/LockScreen) | `lock-screen` | none | — | +| [Mac Settings](../../Plugins/MacSettings) | `mac-settings` | partial | device-state | +| [Hide Menu Bar Icons](../../Plugins/MenuBarHidden) | `menu-bar-hidden` | partial | device-state | +| [Microphone Mute](../../Plugins/MicrophoneMute) | `microphone-mute` | none | — | +| [Middle Click](../../Plugins/MiddleClick) | `middle-click` | portable | — | +| [Mouse Enhancer](../../Plugins/MouseEnhancer) | `mouse-enhancer` | partial | device-state | +| [Night Shift](../../Plugins/NightShift) | `night-shift` | none | — | +| [Clean Mode](../../Plugins/PhysicalCleanMode) | `physical-clean-mode` | none | — | +| [Power Status](../../Plugins/PowerStatus) | `power-status` | none | — | +| [Quit Apps](../../Plugins/QuitApps) | `quit-apps` | none | — | +| [Right Click](../../Plugins/RightClick) | `right-click` | partial | application-paths, local-activation | +| [Saved Scripts](../../Plugins/SavedScripts) | `saved-scripts` | partial | private-content, external-authorization | +| [Screenshot](../../Plugins/Screenshot) | `screenshot` | partial | private-content, device-state | +| [Sidecar](../../Plugins/Sidecar) | `sidecar` | partial | device-state | +| [Siri](../../Plugins/Siri) | `siri` | none | — | +| [Stage Manager](../../Plugins/StageManager) | `stage-manager` | none | — | +| [Storage Explorer](../../Plugins/StorageExplorer) | `com.mactools.plugin.storage-explorer` | none | — | +| [System Data](../../Plugins/SystemData) | `system-data` | partial | history | +| [System Mute](../../Plugins/SystemMute) | `system-mute` | none | — | +| [Power Actions](../../Plugins/SystemPower) | `system-power` | none | — | +| [System Soft Restart](../../Plugins/SystemSoftRestart) | `system-soft-restart` | portable | — | +| [System Status](../../Plugins/SystemStatus) | `system-status` | portable | — | +| [Trackpad Gestures](../../Plugins/TrackpadGestures) | `trackpad-gestures` | partial | permissions | +| [Trackpad Scale](../../Plugins/TrackpadScale) | `trackpad-scale` | partial | device-state | +| [Translator](../../Plugins/Translator) | `translator` | partial | credentials, permissions, private-content | +| [Window Layouts](../../Plugins/WindowLayouts) | `window-layouts` | partial | external-authorization | +| [Window Switcher](../../Plugins/WindowSwitcher) | `window-switcher` | partial | device-state | +| [Xcode Cleanup](../../Plugins/XcodeClean) | `xcode-clean` | none | — | +| [zsh Config](../../Plugins/ZshConfig) | `zsh-config` | none | — | diff --git a/docs/plugins/preferences-backup.md b/docs/plugins/preferences-backup.md new file mode 100644 index 000000000..6bd4118c3 --- /dev/null +++ b/docs/plugins/preferences-backup.md @@ -0,0 +1,45 @@ +# Preferences Backup + +Preferences Backup transfers supported configuration between installations and channels. It does not clone a Mac or restore every local database. Export a fresh backup after installing plugin versions with this support: an older backup cannot recover settings it never contained. + +The export sheet lists the selected plugins and their exclusions. Expand coverage details before exporting. A selected plugin that fails to export blocks saving the file until it succeeds or is deselected. Automatic, cloud, and pre-import safety snapshots keep the last good backup when any selected exporter fails. An unavailable plugin is distinct from a plugin with no portable settings. + +Manual import replaces the selected preference categories. Missing and unselected plugin sections preserve destination settings. Selecting Shortcuts replaces the entire shortcut category: bindings omitted from the backup return to defaults, including local Clipboard item and excluded prompt bindings. Leave Shortcuts unselected to preserve existing bindings. Cloud sync separately preserves omitted machine-specific bindings. Restore results distinguish saved configuration, configuration needing permission, unavailable plugins, deferred imports, and failures. The whole import is not one transaction: unrelated sections may succeed when another fails. Source coverage annotations are informational; they cannot authorize a restore or create a missing payload. + +## What transfers + +The [generated inventory](preferences-backup-inventory.md) accounts for every source plugin. `portable` means the declared configuration transfers; `partial` lists exclusions; `localOnly` deliberately keeps its state here; `none` has no ordinary portable configuration; `unsupported` declares an unsupported boundary. Runtime results reconcile the installed package with the actual provider, rather than substituting current catalog claims for an older installed binary. + +- Window Layouts transfers behavior, modifier dragging, centered guides, preset selection, and custom layout identities. Import reconfigures the existing session without moving a window or applying a shortcut preset. Accessibility permission remains local. +- Auto Input retains rules and input-source identities, including unavailable sources. Import updates observers and HUD configuration; automatic input/function-key changes wait for the next actual application activation. Input Remapping keeps unsafe-trigger confirmations local. +- App Hotkey, Launchpad, and Right Click use application identities with paths relative to named installation roots. They validate the destination bundle without launching it. Missing apps retain their definitions and placement as unresolved; distinct copies retain distinct records. Launchpad keeps folder order and UUIDs. Window Switcher transfers stable manual bundle mappings and leaves process/window assignments local. +- AI Assistant and Translator transfer provider/model settings, including saved disabled drafts with incomplete fields. Drafts remain disabled after restoration; enabling them requires a usable configuration. Unsaved editor contents are not backed up. Unrelated preference imports preserve open Translator prompt and Launch Control note drafts; changed saved definitions refresh their editors. AI prompt saves must finish pending recovery before accepting edits. Structural bounds and credential-component exclusions still apply to every profile. Custom prompt text has a separate, persisted opt-in in each plugin's settings. It governs manual backups, automatic backups, and cloud sync. The destination's opt-in never changes on import. Omitted destination prompt text, including destination-only Translator templates, is preserved regardless of the destination export opt-in; a conflicting included record rejects the section instead of silently overwriting excluded private text. API keys never transfer. Local service saves validate every enabled provider before writing keys, then commit profile settings and endpoint grants together. Failures restore only previous grants backed by freshly verified unchanged effective credentials, including Translator’s legacy fallback; uncertain or changed writes stay unauthorized. Imported endpoints cannot reuse a destination key until local key setup authorizes that service. +- Launch Control transfers favorites using a named launch directory plus plist filename, without commands, raw plists, user IDs, or `launchctl` operations. Missing items remain metadata awaiting a match. Notes have a separate persisted opt-in. Unrecognized legacy paths stay local; excluded destination notes are preserved and conflicting incoming notes reject the section. +- Clipboard transfers retention/display/filter preferences. Its encrypted history, Saved library, content-dependent item shortcut definitions, collection state, and keyword activation are excluded from the portable archive. Import does not prune the library or start collection/expansion. Importing the Shortcuts category can still clear the excluded items' bindings under the replacement policy above. Retention deferral is destination-local and survives app relaunch. Ordinary later captures or explicit local retention edits resume normal retention behavior; unrelated UI changes preserve it. Failed deferral writes reject the import without applying shorter retention. Use the [dedicated clipboard archive](clipboard-backup.md) for content recovery. +- Display target modes, Calendar presentation, Device Battery preferences, Duo Status icons, Screenshot zoom behavior, Trackpad Scale auto-capture choice, and similar presentation settings transfer without importing readings, authorization, capture sessions, calibration, or hardware state. +- Battery Charge Limit transfers behavior parameters while inactive; it never activates charge control. Keep Awake transfers behavior without starting assertions or virtual displays. Active local sessions reject parameter restoration until stopped. App Volume preserves running playback during import and applies imported preferences when an app next starts playback or its audio processes change. +- Disk Clean roots, permanent-delete policy, and execution/recovery data remain local. Key Stats tracking consent and usage history remain local. Credentials, capture/access consent, OS permission grants, recovery journals, caches, and local activation policies are excluded throughout. + +## Provider contract + +Use existing `PluginPortablePreferencesProviding`, `PluginPortablePreferencesRestorationReporting`, and `PluginPersistentPreferencesChangeSignaling` hooks. Optional manifest metadata adds no PluginKit ABI requirement. Keep each payload independently versioned and bounded; the enclosing archive remains format 7 with its existing 16 MiB limit. Optional bounded diagnostics contain only plugin IDs, status codes, and exclusion codes; previous format-7 readers may ignore them. + +Validate the whole section, including identifiers, counts, strings, finite numbers, enums, and URL/path boundaries, before mutation. Do not discover/export arbitrary storage keys. Preserve absent optional fields according to the version contract. Commit through a verified atomic file or an allowlisted transaction with rollback/recovery, preserving prior raw values and absent keys. Journal only fields being changed; untouched excluded data must not block unrelated transactions. Publish live state only after durable success. Recovery journals stay local and prevent incomplete state from being exported. + +Reconcile imports through normal permission and conflict paths without prompting or triggering unrelated operations. Keep requested configuration when permission is denied. Emit a persistent-change signal only after a meaningful durable portable change, including edits from custom views; scans, sampling, live refreshes, and excluded-only changes do not count. + +Open settings editors must reconcile their drafts after a durable restore, including cloud imports that keep the same record identities. Included disabled definitions retain both per-prompt recorder and Actions & Shortcuts bindings while remaining inactive. Preference-only imports that do not change definitions preserve open prompt drafts. Validate enabled configurations after merging any preserved destination text. + +Definition providers additionally implement `PluginPortablePreferencesActionReferencesProviding` and `PluginActionReferenceBackupProviding`. Index exactly the included definitions. The host exports/restores dependent shortcuts, Run Links, workflows, Grid entries, and gestures only when the required definition payload succeeds and contains that reference. Action-surface providers export after definition providers. Classify content-dependent legacy shortcuts through the same backup-disposition hook; excluded Clipboard item references cannot masquerade as portable shared shortcuts. + +Add `preferencesBackup: {"status": "partial", "exclusions": [...]}` to the canonical source manifest. Use the schema's stable codes, keep privacy declarations accurate, regenerate the inventory and website data, and run `make script-tests`. New consumers must satisfy the existing minimum-host inventory; do not bump the ABI for descriptive metadata or plugin-private codecs. + +Keep compact tests for round trips, excluded/local-state preservation, invalid/future payloads, durable-write failure, and consequential runtime reconciliation. Use fake storage or temporary directories. Live desktop checks must use isolated identities and synthetic data; store tests do not establish real Accessibility or hardware behavior. + +### On-demand desktop evidence + +After a focused test build, run `scripts/testing/preferences-backup/run.sh` from an Accessibility-authorized terminal. It creates a new temporary evidence directory, synthetic preference suites, a target window, and a separate denied-permission app identity. It does not install, sign, publish, replace installed apps, copy user settings, or change permission grants. The granted check briefly moves the pointer over its owned window; run it while not interacting with the desktop. + +The check covers actual modifier movement, centered-guide display and snap, relaunch persistence, denied-permission preservation, real host action dependencies, native export screenshots in four languages, an unresolved Right Click app row, accessibility labels, Return/Escape, and disclosure activation. Review the generated images and AX reports; ordinary Tab traversal follows the Mac's Keyboard Navigation setting. The on-demand XCTest is skipped during normal CI. Synthetic preference domains are removed on exit; evidence files remain for review. + +Run `TEST_RUNNER_MACTOOLS_RUN_DESKTOP_TESTS=1 make test TEST_FILTER=InputRemappingModelsTests/testMountedEditorPreservesCloudRestoredRuleOnSubsequentToggle` to verify that an open Input Remapping editor retains a cloud-restored action after its next toggle. It uses a synthetic settings window and injected input services, with no global input events or live cloud transport. diff --git a/scripts/plugins/generate-plugin-catalog.py b/scripts/plugins/generate-plugin-catalog.py index ac6d44d13..9cfbfd732 100755 --- a/scripts/plugins/generate-plugin-catalog.py +++ b/scripts/plugins/generate-plugin-catalog.py @@ -503,7 +503,7 @@ def main() -> None: } for section in ( "presentation", "discovery", "requirements", "privacy", - "actions", "setup", "relationships", + "actions", "setup", "relationships", "preferencesBackup", ): if section in projected: entry[section] = projected[section] diff --git a/scripts/plugins/generate-preferences-backup-inventory.py b/scripts/plugins/generate-preferences-backup-inventory.py new file mode 100644 index 000000000..c4d194273 --- /dev/null +++ b/scripts/plugins/generate-preferences-backup-inventory.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Generate the backup coverage inventory from canonical source manifests.""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from plugin_source_manifest import load_known_plugin_ids, validate_and_project_manifest + +ROOT = Path(__file__).resolve().parents[2] +OUTPUT = ROOT / "docs/plugins/preferences-backup-inventory.md" + + +def render() -> str: + known_ids = load_known_plugin_ids(ROOT / "Plugins") + rows = [] + counts: dict[str, int] = {} + for path in sorted((ROOT / "Plugins").glob("*/plugin.json")): + source = json.loads(path.read_text()) + manifest, _ = validate_and_project_manifest(source, path, known_ids) + coverage = manifest["preferencesBackup"] + status = coverage["status"] + counts[status] = counts.get(status, 0) + 1 + title = manifest["localizedMetadata"]["en"]["displayName"].replace("|", "\\|") + reasons = ", ".join(coverage["exclusions"]) or "—" + rows.append(f"| [{title}](../../Plugins/{path.parent.name}) | `{manifest['id']}` | {status} | {reasons} |") + return "\n".join([ + "# Preferences Backup coverage inventory", "", + "Generated from `Plugins/*/plugin.json`; do not edit this table by hand.", + "Run `python3 scripts/plugins/generate-preferences-backup-inventory.py` after changing a declaration.", "", + "Declarations describe supported field boundaries. Runtime availability and export/restore failures are reported separately.", + "See [the backup contract](preferences-backup.md) for exclusions and recovery limits.", "", + "; ".join(f"{status}: {count}" for status, count in sorted(counts.items())) + ".", "", + "| Plugin | ID | Coverage | Exclusion codes |", "| --- | --- | --- | --- |", *rows, "", + ]) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + expected = render() + if args.check: + if not OUTPUT.exists() or OUTPUT.read_text() != expected: + parser.exit(1, "Backup inventory is stale; regenerate it.\n") + else: + OUTPUT.write_text(expected) + + +if __name__ == "__main__": + main() diff --git a/scripts/plugins/plugin_source_manifest.py b/scripts/plugins/plugin_source_manifest.py index 97e1085ce..ea53908f0 100755 --- a/scripts/plugins/plugin_source_manifest.py +++ b/scripts/plugins/plugin_source_manifest.py @@ -404,6 +404,22 @@ def validate_runtime_envelope( ): _fail(plugin_id, "id", "must be a valid non-reserved plugin identifier") plugin_id = manifest["id"] + if "preferencesBackup" in manifest: + coverage = manifest["preferencesBackup"] + if not isinstance(coverage, dict) or set(coverage) != {"status", "exclusions"}: + _fail(plugin_id, "preferencesBackup", "must contain status and exclusions") + if not isinstance(coverage["status"], str) or coverage["status"] not in {"portable", "partial", "localOnly", "none", "unsupported"}: + _fail(plugin_id, "preferencesBackup.status", "must be a supported coverage status") + _unique_strings(coverage["exclusions"], plugin_id, "preferencesBackup.exclusions") + if set(coverage["exclusions"]) - { + "credentials", "permissions", "history", "private-content", "device-state", + "local-activation", "application-paths", "external-authorization", "local-policy", + }: + _fail(plugin_id, "preferencesBackup.exclusions", "contains an unsupported exclusion") + if coverage["status"] == "partial" and not coverage["exclusions"]: + _fail(plugin_id, "preferencesBackup.exclusions", "partial coverage requires an exclusion") + if coverage["status"] in {"portable", "none"} and coverage["exclusions"]: + _fail(plugin_id, "preferencesBackup.exclusions", "this status cannot declare exclusions") if "displayName" in manifest: _non_empty_string(manifest["displayName"], plugin_id, "displayName") if "summary" in manifest: diff --git a/scripts/testing/preferences-backup/capture-window.swift b/scripts/testing/preferences-backup/capture-window.swift new file mode 100644 index 000000000..68edaa346 --- /dev/null +++ b/scripts/testing/preferences-backup/capture-window.swift @@ -0,0 +1,73 @@ +import AppKit +import ApplicationServices +import Foundation + +guard CommandLine.arguments.count == 2 else { exit(64) } +let directory = URL(fileURLWithPath: CommandLine.arguments[1]) +let requestURL = directory.appendingPathComponent("capture-request.json") +let expectedNames: Set = ["export-en", "export-zh-Hans", "export-de", "export-ar", "right-click-unresolved"] +var completed = Set() +func attribute(_ element: AXUIElement, _ key: String) -> CFTypeRef? { + var value: CFTypeRef? + return AXUIElementCopyAttributeValue(element, key as CFString, &value) == .success ? value : nil +} +func tree(_ element: AXUIElement, depth: Int = 0) -> [AXUIElement] { + guard depth < 25 else { return [] } + return [element] + ((attribute(element, kAXChildrenAttribute) as? [AXUIElement]) ?? []).flatMap { tree($0, depth: depth + 1) } +} +let deadline = Date().addingTimeInterval(300) +while completed.count < expectedNames.count, Date() < deadline { + guard let data = try? Data(contentsOf: requestURL), + let request = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let name = request["name"] as? String, expectedNames.contains(name), !completed.contains(name), + let pid = request["pid"] as? Int32, let windowID = request["windowID"] as? UInt32 else { + Thread.sleep(forTimeInterval: 0.05); continue + } + let info = (CGWindowListCopyWindowInfo(.optionIncludingWindow, windowID) as? [[String: Any]]) ?? [] + guard info.contains(where: { ($0[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value == pid }) else { exit(2) } + let application = AXUIElementCreateApplication(pid) + NSRunningApplication(processIdentifier: pid)?.activate() + Thread.sleep(forTimeInterval: 0.15) + let nodes = tree(application) + var report: [String: Any] = ["pid": pid, "windowID": windowID, "accessibilityGranted": AXIsProcessTrusted()] + report["elements"] = nodes.map { node in + ["role": attribute(node, kAXRoleAttribute) as? String ?? "", "title": attribute(node, kAXTitleAttribute) as? String ?? "", "description": attribute(node, kAXDescriptionAttribute) as? String ?? "", "value": attribute(node, kAXValueAttribute) as? String ?? ""] + } + let capture = Process() + capture.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") + capture.arguments = ["-x", "-o", "-l", String(windowID), directory.appendingPathComponent(name + ".png").path] + try capture.run(); capture.waitUntilExit() + report["captureStatus"] = capture.terminationStatus + var focus: [[String: String]] = [] + for _ in 0..<3 { + let event = CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)! + event.postToPid(pid) + CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: false)?.postToPid(pid) + Thread.sleep(forTimeInterval: 0.1) + if let element = attribute(application, kAXFocusedUIElementAttribute), CFGetTypeID(element) == AXUIElementGetTypeID() { + let focused = element as! AXUIElement + focus.append(["role": attribute(focused, kAXRoleAttribute) as? String ?? "", "title": attribute(focused, kAXTitleAttribute) as? String ?? "", "description": attribute(focused, kAXDescriptionAttribute) as? String ?? ""]) + } + } + report["keyboardFocus"] = focus + for key: CGKeyCode in [36, 53] { + CGEvent(keyboardEventSource: nil, virtualKey: key, keyDown: true)?.postToPid(pid) + CGEvent(keyboardEventSource: nil, virtualKey: key, keyDown: false)?.postToPid(pid) + Thread.sleep(forTimeInterval: 0.1) + } + if let disclosure = nodes.first(where: { attribute($0, kAXRoleAttribute) as? String == kAXDisclosureTriangleRole }) { + report["disclosurePress"] = AXUIElementPerformAction(disclosure, kAXPressAction as CFString).rawValue + Thread.sleep(forTimeInterval: 0.15) + let scrollbars = tree(application).filter { attribute($0, kAXRoleAttribute) as? String == kAXScrollBarRole } + for bar in scrollbars { _ = AXUIElementSetAttributeValue(bar, kAXValueAttribute as CFString, NSNumber(value: 1.0)) } + Thread.sleep(forTimeInterval: 0.15) + let expanded = Process(); expanded.executableURL = capture.executableURL + expanded.arguments = ["-x", "-o", "-l", String(windowID), directory.appendingPathComponent(name + "-expanded.png").path] + try expanded.run(); expanded.waitUntilExit() + } + let reportData = try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) + try reportData.write(to: directory.appendingPathComponent(name + "-ax.json"), options: .atomic) + try Data(String(capture.terminationStatus).utf8).write(to: directory.appendingPathComponent(name + ".done"), options: .atomic) + completed.insert(name) +} +exit(completed.count == expectedNames.count ? 0 : 1) diff --git a/scripts/testing/preferences-backup/desktop-acceptance.swift b/scripts/testing/preferences-backup/desktop-acceptance.swift new file mode 100644 index 000000000..5bdf0967d --- /dev/null +++ b/scripts/testing/preferences-backup/desktop-acceptance.swift @@ -0,0 +1,169 @@ +import AppKit +import ApplicationServices +import Foundation +import MacToolsPluginKit +@testable import WindowLayoutsPlugin + +@main @MainActor +struct Acceptance { + enum Failure: Error { case check(String) } + static func require(_ value: Bool, _ message: String) throws { if !value { throw Failure.check(message) } } + static let directory = URL(fileURLWithPath: CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : "/dev/null") + static var report: [String: Any] = [:] + static func writeReport(_ name: String) { + if let data = try? JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) { + try? data.write(to: directory.appendingPathComponent(name + ".json"), options: .atomic) + } + } + static func main() { + guard CommandLine.arguments.count == 3, ["granted", "relaunch", "denied", "cleanup"].contains(CommandLine.arguments[1]) else { exit(64) } + let app = NSApplication.shared + app.setActivationPolicy(.accessory) + Task { @MainActor in + let mode = CommandLine.arguments.dropFirst().first ?? "granted" + do { + try await run(mode) + report["result"] = "passed" + writeReport("desktop-" + mode) + print("PASS " + mode) + exit(0) + } catch { + report["result"] = "failed" + report["failure"] = String(describing: error) + writeReport("desktop-" + mode) + print("FAIL " + mode + ": " + String(describing: error)) + exit(1) + } + } + app.run() + } + static func run(_ mode: String) async throws { + report["accessibilityGranted"] = AXIsProcessTrusted() + report["screenCaptureGranted"] = CGPreflightScreenCaptureAccess() + report["processID"] = ProcessInfo.processInfo.processIdentifier + let token = try String(contentsOf: directory.appendingPathComponent("suite-name.txt"), encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) + try require(UUID(uuidString: token) != nil, "Invalid evidence suite token") + let suite = "org.mactools.backup-acceptance." + token + let defaults = UserDefaults(suiteName: suite)! + if mode == "cleanup" { defaults.removePersistentDomain(forName: suite); return } + let storage = UserDefaultsPluginStorage(pluginID: "window-layouts", userDefaults: defaults) + let context = PluginRuntimeContext(pluginID: "window-layouts", storage: storage) + if mode != "relaunch" { defaults.removePersistentDomain(forName: suite) } + if mode == "granted" { WindowLayoutsStore(storage: storage).setGap(3) } + let payloadObject = try JSONSerialization.jsonObject(with: Data(contentsOf: directory.appendingPathComponent("native/synthetic-format7.json"))) as! [String: Any] + let encoded = (payloadObject["pluginPreferences"] as! [String: String])["window-layouts"]! + let payload = Data(base64Encoded: encoded)! + var promptCalls = 0 + let plugin = WindowLayoutsPlugin(context: context, requestAccessibilityTrust: { _ in promptCalls += 1; return false }) + if mode != "relaunch" { try require(plugin.restorePortablePreferencesReportingResult(from: payload), "Restore failed") } + let reloaded = WindowLayoutsStore(storage: storage) + try require(reloaded.gap == 8 && reloaded.centeredGuidesEnabled && reloaded.modifierDragEnabled, "Restored settings differ") + try require(reloaded.modifierDragModifiers == [.control, .option, .command], "Modifier combination differs") + try require(!reloaded.showsCommandFeedback && !reloaded.cyclesHalves, "Behavior differs") + try require(reloaded.customCommands.count == 1, "Custom layout missing") + report["settings"] = ["gap": reloaded.gap, "guides": reloaded.centeredGuidesEnabled, "modifierDrag": reloaded.modifierDragEnabled, + "modifiers": reloaded.modifierDragModifiers.rawValue, "customCommandID": reloaded.customCommands[0].id.uuidString] + try require(promptCalls == 0, "Import requested permission") + if mode == "relaunch" { return } + plugin.activate(context: context) + defer { plugin.deactivate(reason: .hostShutdown) } + if mode == "denied" { + try require(!AXIsProcessTrusted(), "Denied test identity unexpectedly has Accessibility") + try require(!plugin.permissionState(for: "accessibility").isGranted, "Denied permission reported granted") + try require(plugin.isModifierDragEnabled, "Denied permission erased requested setting") + return + } + try require(AXIsProcessTrusted(), "Granted desktop acceptance needs Accessibility") + try require(CGEventSource.flagsState(.combinedSessionState).intersection([.maskControl, .maskAlternate, .maskCommand, .maskShift]).isEmpty, "User is holding a modifier") + let previousApp = NSWorkspace.shared.frontmostApplication + let oldPointer = CGEvent(source: nil)!.location + let target = Process() + target.executableURL = directory.appendingPathComponent("target-window") + try target.run() + var mouseIsDown = false + defer { + if mouseIsDown { + CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: CGEvent(source: nil)!.location, mouseButton: .left)?.postToPid(target.processIdentifier) + } + let release = CGEvent(source: nil)! + release.type = .flagsChanged + release.flags = [] + release.postToPid(target.processIdentifier) + if NSWorkspace.shared.frontmostApplication?.processIdentifier == target.processIdentifier { + CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: oldPointer, mouseButton: .left)?.post(tap: .cghidEventTap) + previousApp?.activate() + } + if target.isRunning { target.terminate() } + } + var windows: CFTypeRef? + let application = AXUIElementCreateApplication(target.processIdentifier) + for _ in 0..<40 { + _ = AXUIElementCopyAttributeValue(application, kAXWindowsAttribute as CFString, &windows) + if let items = windows as? [AXUIElement], !items.isEmpty { break } + try await Task.sleep(for: .milliseconds(50)) + } + guard let window = (windows as? [AXUIElement])?.first else { throw Failure.check("No owned target window") } + NSRunningApplication(processIdentifier: target.processIdentifier)?.activate() + try await Task.sleep(for: .milliseconds(200)) + func owned() throws { try require(NSWorkspace.shared.frontmostApplication?.processIdentifier == target.processIdentifier, "Target lost focus; stop desktop input") } + func frame() throws -> CGRect { + var position: CFTypeRef?; var size: CFTypeRef? + try require(AXUIElementCopyAttributeValue(window, kAXPositionAttribute as CFString, &position) == .success, "Read target position") + try require(AXUIElementCopyAttributeValue(window, kAXSizeAttribute as CFString, &size) == .success, "Read target size") + var p = CGPoint.zero; var s = CGSize.zero + try require(AXValueGetValue(position as! AXValue, .cgPoint, &p) && AXValueGetValue(size as! AXValue, .cgSize, &s), "Decode target frame") + return CGRect(origin: p, size: s) + } + func post(_ type: CGEventType, _ point: CGPoint, flags: CGEventFlags = []) throws { + try owned() + let event: CGEvent + if type == .flagsChanged { + event = CGEvent(source: nil)! + event.type = .flagsChanged + event.location = point + event.setIntegerValueField(.keyboardEventKeycode, value: 55) + } else { event = CGEvent(mouseEventSource: nil, mouseType: type, mouseCursorPosition: point, mouseButton: .left)! } + event.flags = flags + event.post(tap: .cghidEventTap) + } + let initial = try frame() + let inside = CGPoint(x: initial.midX, y: initial.midY) + try post(.mouseMoved, inside) + try await Task.sleep(for: .milliseconds(80)) + let flags: CGEventFlags = [.maskControl, .maskAlternate, .maskCommand] + try post(.flagsChanged, inside, flags: flags) + try await Task.sleep(for: .milliseconds(350)) + for step in 1...5 { + try post(.mouseMoved, CGPoint(x: inside.x + CGFloat(step * 16), y: inside.y + CGFloat(step * 10)), flags: flags) + try await Task.sleep(for: .milliseconds(70)) + } + try post(.flagsChanged, CGPoint(x: inside.x + 80, y: inside.y + 50)) + try await Task.sleep(for: .milliseconds(200)) + let moved = try frame() + report["modifierDragDelta"] = ["x": moved.minX - initial.minX, "y": moved.minY - initial.minY] + try require(abs(moved.minX - initial.minX - 80) < 3 && abs(moved.minY - initial.minY - 50) < 3, "Actual modifier drag did not reposition the owned window") + let anchor = NSScreen.screens.map(\.frame.maxY).max()! + let visible = NSScreen.main!.visibleFrame + let usable = CGRect(x: visible.minX, y: anchor - visible.maxY, width: visible.width, height: visible.height) + let centered = WindowSnapGeometry.defaultFrame(contentSize: moved.size, visibleFrame: usable) + let titlePoint = CGPoint(x: moved.midX, y: moved.minY + 12) + let delta = CGPoint(x: centered.minX + 6 - moved.minX, y: centered.minY + 7 - moved.minY) + try post(.mouseMoved, titlePoint) + try await Task.sleep(for: .milliseconds(100)) + try post(.leftMouseDown, titlePoint) + mouseIsDown = true + for step in 1...12 { + try post(.leftMouseDragged, CGPoint(x: titlePoint.x + delta.x * CGFloat(step) / 12, y: titlePoint.y + delta.y * CGFloat(step) / 12)) + try await Task.sleep(for: .milliseconds(60)) + } + try await Task.sleep(for: .milliseconds(200)) + report["guideOverlayVisible"] = NSApp.windows.contains { $0.isVisible && $0 is NSPanel } + try post(.leftMouseUp, CGPoint(x: titlePoint.x + delta.x, y: titlePoint.y + delta.y)) + mouseIsDown = false + try await Task.sleep(for: .milliseconds(400)) + let snapped = try frame() + report["centeredGuideFinalOffset"] = ["x": snapped.minX - centered.minX, "y": snapped.minY - centered.minY] + try require(abs(snapped.minX - centered.minX) < 2 && abs(snapped.minY - centered.minY) < 2, "Actual centered guide release did not snap") + try require(report["guideOverlayVisible"] as? Bool == true, "Centered guide overlay not visible during drag") + } +} diff --git a/scripts/testing/preferences-backup/run.sh b/scripts/testing/preferences-backup/run.sh new file mode 100755 index 000000000..2ae0e7cd9 --- /dev/null +++ b/scripts/testing/preferences-backup/run.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# On-demand desktop evidence. Uses only new temporary files, synthetic suites and an owned window. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +EVIDENCE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/MacToolsBackupEvidence.XXXXXX")" +PRODUCTS="$ROOT/build/DerivedDataTests/Build/Products/Debug" +CAPTURE_PID="" +cleanup() { + if [[ -n "$CAPTURE_PID" ]]; then kill "$CAPTURE_PID" 2>/dev/null || true; fi + if [[ -x "$EVIDENCE_DIR/desktop-acceptance" ]]; then + "$EVIDENCE_DIR/desktop-acceptance" cleanup "$EVIDENCE_DIR" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT +cd "$ROOT" +printf 'Evidence directory: %s\n' "$EVIDENCE_DIR" +uuidgen > "$EVIDENCE_DIR/suite-name.txt" +mkdir "$EVIDENCE_DIR/native" +xcrun swiftc scripts/testing/preferences-backup/capture-window.swift -o "$EVIDENCE_DIR/capture-window" +"$EVIDENCE_DIR/capture-window" "$EVIDENCE_DIR/native" > "$EVIDENCE_DIR/capture.log" 2>&1 & +CAPTURE_PID=$! +TEST_RUNNER_MACTOOLS_BACKUP_EVIDENCE_DIR="$EVIDENCE_DIR/native" \ +MACTOOLS_BACKUP_EVIDENCE_DIR="$EVIDENCE_DIR/native" \ +make test TEST_FILTER=PreferencesBackupDesktopEvidenceTests > "$EVIDENCE_DIR/native.log" 2>&1 +wait "$CAPTURE_PID" +CAPTURE_PID="" +xcrun swiftc scripts/testing/preferences-backup/target-window.swift -o "$EVIDENCE_DIR/target-window" +xcrun swiftc -parse-as-library -target "$(uname -m)-apple-macos14.0" \ + -I "$PRODUCTS" -F "$PRODUCTS" -L "$PRODUCTS" -lWindowLayoutsPluginCore \ + -framework MacToolsPluginKit -framework AppKit -framework ApplicationServices \ + -Xlinker -rpath -Xlinker "$PRODUCTS" \ + scripts/testing/preferences-backup/desktop-acceptance.swift -o "$EVIDENCE_DIR/desktop-acceptance" +"$EVIDENCE_DIR/desktop-acceptance" granted "$EVIDENCE_DIR" +"$EVIDENCE_DIR/desktop-acceptance" relaunch "$EVIDENCE_DIR" +python3 - "$EVIDENCE_DIR" <<'PY' +from pathlib import Path +import plistlib +import shutil +import sys +root = Path(sys.argv[1]) +contents = root / "Backup Denied QA.app" / "Contents" +(contents / "MacOS").mkdir(parents=True) +shutil.copy2(root / "desktop-acceptance", contents / "MacOS" / "backup-acceptance") +token = (root / "suite-name.txt").read_text().strip().lower() +(contents / "Info.plist").write_bytes(plistlib.dumps({ + "CFBundleIdentifier": "org.mactools.backup-evidence.denied." + token, + "CFBundleExecutable": "backup-acceptance", "CFBundleName": "MacTools Backup Denied QA", + "CFBundlePackageType": "APPL", "LSUIElement": True, +})) +PY +open -n -W "$EVIDENCE_DIR/Backup Denied QA.app" --args denied "$EVIDENCE_DIR" +python3 - "$EVIDENCE_DIR" <<'PY' +import json +from pathlib import Path +import sys +root = Path(sys.argv[1]) +for mode in ("granted", "relaunch", "denied"): + report = json.loads((root / ("desktop-" + mode + ".json")).read_text()) + if report["result"] != "passed": + raise SystemExit(f"Desktop acceptance failed: {mode}: {report}") +print(f"Desktop acceptance passed. Inspect native PNGs and AX reports in {root}.") +PY diff --git a/scripts/testing/preferences-backup/target-window.swift b/scripts/testing/preferences-backup/target-window.swift new file mode 100644 index 000000000..b12b857be --- /dev/null +++ b/scripts/testing/preferences-backup/target-window.swift @@ -0,0 +1,12 @@ +import AppKit +import Foundation +let application = NSApplication.shared +application.setActivationPolicy(.regular) +let window = NSWindow(contentRect: NSRect(x: 160, y: 240, width: 500, height: 320), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) +window.title = "MacTools Backup Synthetic Target" +window.isReleasedWhenClosed = false +window.contentView = NSTextField(labelWithString: "Disposable window for backup acceptance. No user documents.") +window.makeKeyAndOrderFront(nil) +application.activate(ignoringOtherApps: true) +Timer.scheduledTimer(withTimeInterval: 40, repeats: false) { _ in application.terminate(nil) } +application.run() diff --git a/scripts/tests/test_changelog.py b/scripts/tests/test_changelog.py index 1303c0505..548be4fdf 100644 --- a/scripts/tests/test_changelog.py +++ b/scripts/tests/test_changelog.py @@ -37,12 +37,17 @@ def test_local_command_accepts_limit_and_rejects_overlong_current_fragment(self) # Give this minimal checkout a valid empty plugin projection. plugin_scripts = root / "scripts" / "plugins" plugin_scripts.mkdir() - for name in ["generate_website_plugin_data.py", "plugin_source_manifest.py"]: + for name in ["generate_website_plugin_data.py", "plugin_source_manifest.py", "generate-preferences-backup-inventory.py"]: shutil.copy2(SCRIPT_PATH.parent / "plugins" / name, plugin_scripts / name) subprocess.run( [sys.executable, str(plugin_scripts / "generate_website_plugin_data.py")], cwd=root, capture_output=True, text=True, check=True, ) + (root / "docs" / "plugins").mkdir(parents=True) + subprocess.run( + [sys.executable, str(plugin_scripts / "generate-preferences-backup-inventory.py")], + cwd=root, capture_output=True, text=True, check=True, + ) fragment = root / "changes" / "unreleased" / "example.md" # Exercise real local entry points with current files, not only the parser. for target in ["validate-changelog", "script-tests"]: diff --git a/scripts/tests/test_plugin_source_manifest.py b/scripts/tests/test_plugin_source_manifest.py index 714ece215..9b9475267 100644 --- a/scripts/tests/test_plugin_source_manifest.py +++ b/scripts/tests/test_plugin_source_manifest.py @@ -38,7 +38,7 @@ def test_every_repository_manifest_passes_semantic_validation(self) -> None: def test_all_repository_manifests_publish_complete_product_metadata(self) -> None: known_ids = load_known_plugin_ids(PLUGINS_ROOT) required_sections = { - "presentation", "discovery", "requirements", "privacy", "setup", "relationships" + "presentation", "discovery", "requirements", "privacy", "setup", "relationships", "preferencesBackup" } for path in sorted(PLUGINS_ROOT.glob("*/plugin.json")): manifest = json.loads(path.read_text(encoding="utf-8")) @@ -51,6 +51,34 @@ def test_all_repository_manifests_publish_complete_product_metadata(self) -> Non SUPPORTED_LOCALES, ) + def test_backup_coverage_rejects_unknown_or_contradictory_declarations(self) -> None: + path = PLUGINS_ROOT / "WindowLayouts" / "plugin.json" + original = json.loads(path.read_text(encoding="utf-8")) + invalid = [ + {"status": "complete", "exclusions": []}, + {"status": [], "exclusions": []}, + {"status": "partial", "exclusions": []}, + {"status": "portable", "exclusions": ["credentials"]}, + {"status": "partial", "exclusions": ["unknown"]}, + {"status": "partial", "exclusions": ["credentials", "credentials"]}, + ] + for coverage in invalid: + manifest = copy.deepcopy(original) + manifest["preferencesBackup"] = coverage + with self.subTest(coverage=coverage), self.assertRaises(ManifestValidationError): + validate_and_project_manifest(manifest, path, load_known_plugin_ids(PLUGINS_ROOT)) + # Legacy packages remain valid without a declaration. + original.pop("preferencesBackup") + validate_and_project_manifest(original, path, load_known_plugin_ids(PLUGINS_ROOT)) + + def test_backup_declaration_matches_source_provider_conformance(self) -> None: + for path in sorted(PLUGINS_ROOT.glob("*/plugin.json")): + manifest = json.loads(path.read_text()) + expects = manifest["preferencesBackup"]["status"] in {"portable", "partial"} + sources = "\n".join(p.read_text() for p in (path.parent / "Sources").rglob("*.swift")) + with self.subTest(plugin=manifest["id"]): + self.assertEqual(expects, bool(re.search(r"\bPluginPortablePreferencesProviding\b", sources))) + def test_source_localization_references_expand_before_projection(self) -> None: path = PLUGINS_ROOT / "ActivityBar" / "plugin.json" manifest = json.loads(path.read_text(encoding="utf-8")) diff --git a/site/src/generated/plugins.json b/site/src/generated/plugins.json index bce6f4c03..317dcb532 100644 --- a/site/src/generated/plugins.json +++ b/site/src/generated/plugins.json @@ -1186,7 +1186,8 @@ "ai-requests" ], "dataPersisted": [ - "plugin-configuration" + "plugin-configuration", + "custom-prompt-text" ], "diagnosticExportsContainUserData": false, "networkDomains": [], @@ -1608,7 +1609,7 @@ "installed", "applications" ], - "localOnlyIdentity": true, + "localOnlyIdentity": false, "parameterSummary": { "ar": "التطبيق", "de": "App", @@ -1628,7 +1629,7 @@ "id": "entryID", "isRequired": true, "kind": "string", - "portability": "localOnly" + "portability": "portable" } ], "permissionIDs": [], @@ -1641,7 +1642,8 @@ "automatic-rule", "action-grid", "trackpad-gesture", - "manual" + "manual", + "app-intent" ], "title": { "ar": "فتح التطبيق", @@ -6003,7 +6005,8 @@ ], "dataPersisted": [ "encrypted-clipboard-history", - "local-encryption-key" + "local-encryption-key", + "plugin-configuration" ], "diagnosticExportsContainUserData": false, "networkDomains": [], @@ -14976,7 +14979,8 @@ "productivity-state" ], "dataPersisted": [ - "plugin-configuration" + "plugin-configuration", + "user-notes" ], "diagnosticExportsContainUserData": false, "networkDomains": [], @@ -21686,7 +21690,9 @@ "filesystem-paths", "file-sizes" ], - "dataPersisted": [], + "dataPersisted": [ + "plugin-configuration" + ], "diagnosticExportsContainUserData": false, "networkDomains": [], "networkUse": "none", @@ -24182,7 +24188,8 @@ "translation-requests" ], "dataPersisted": [ - "plugin-configuration" + "plugin-configuration", + "custom-prompt-text" ], "diagnosticExportsContainUserData": false, "networkDomains": [