diff --git a/Apps/Keyty/Sources/Keyty/App/Composition/AppSettingsContainer.swift b/Apps/Keyty/Sources/Keyty/App/Composition/AppSettingsContainer.swift index 47a7c0ff..87ea1fc3 100644 --- a/Apps/Keyty/Sources/Keyty/App/Composition/AppSettingsContainer.swift +++ b/Apps/Keyty/Sources/Keyty/App/Composition/AppSettingsContainer.swift @@ -10,22 +10,38 @@ import Foundation final class AppSettingsContainer { let store: KeyValueStore - let appSettings: any AppSettingsProtocol - let pointerRingSettings: any PointerRingSettingsProtocol & ReactiveSettings - let pointerRipplesSettings: any PointerRipplesSettingsProtocol & ReactiveSettings - let pointerIconSettings: any PointerIconSettingsProtocol & ReactiveSettings - let shortcutSettings: any ShortcutSettingsProtocol + let appSettings: AppSettings + let pointerRingSettings: PointerRingSettings + let pointerRipplesSettings: PointerRipplesSettings + let pointerIconSettings: PointerIconSettings + let shortcutSettings: ShortcutSettings let keyboardVisualizerSettings: KeyboardVisualizerSettings + private let settingsOwners: [any HasSettingsStore] init(store: KeyValueStore) { self.store = store - self.appSettings = AppSettings(store: store) - self.pointerRingSettings = PointerRingSettings(store: store) - self.pointerRipplesSettings = PointerRipplesSettings(store: store) - self.pointerIconSettings = PointerIconSettings(store: store) - self.shortcutSettings = ShortcutSettings(store: store) - self.keyboardVisualizerSettings = KeyboardVisualizerSettings(store: store) + let appSettings = AppSettings(store: store) + let pointerRingSettings = PointerRingSettings(store: store) + let pointerRipplesSettings = PointerRipplesSettings(store: store) + let pointerIconSettings = PointerIconSettings(store: store) + let shortcutSettings = ShortcutSettings(store: store) + let keyboardVisualizerSettings = KeyboardVisualizerSettings(store: store) + + self.appSettings = appSettings + self.pointerRingSettings = pointerRingSettings + self.pointerRipplesSettings = pointerRipplesSettings + self.pointerIconSettings = pointerIconSettings + self.shortcutSettings = shortcutSettings + self.keyboardVisualizerSettings = keyboardVisualizerSettings + self.settingsOwners = [ + appSettings, + pointerRingSettings, + pointerRipplesSettings, + pointerIconSettings, + shortcutSettings, + keyboardVisualizerSettings + ] self.registerDefaults() } @@ -47,4 +63,8 @@ final class AppSettingsContainer { self.shortcutSettings.resetToDefaults() self.keyboardVisualizerSettings.resetToDefaults() } + + var transferableSettings: [AnyStoredSetting] { + self.settingsOwners.flatMap(\.storedSettings) + } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift index 4708a495..3a6bad47 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift @@ -159,6 +159,21 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { func finishCustomPositionSetting() { self.stopCustomPositionSetting() } + + func reloadFromSettings() { + self.screens = self.screensService.screens + self.selectedScreen = self.resolveSelectedScreen(for: self.keyboardVisualizerSettings.screenID) + self.selectedAnchor = self.keyboardVisualizerSettings.anchor + self.placementMode = self.keyboardVisualizerSettings.placementMode + self.windowPadding = Double(self.keyboardVisualizerSettings.windowPadding) + self.customPositionNormalizedX = Double(self.keyboardVisualizerSettings.customPositionNormalizedX) + self.customPositionNormalizedY = Double(self.keyboardVisualizerSettings.customPositionNormalizedY) + self.customHorizontalAlignment = self.keyboardVisualizerSettings.customHorizontalAlignment + self.customVerticalAlignment = self.keyboardVisualizerSettings.customVerticalAlignment + self.stackAxis = self.keyboardVisualizerSettings.stackAxis + self.scale = Double(self.keyboardVisualizerSettings.scale) + self.isSettingCustomPosition = false + } } extension DisplaysSettingsPaneViewModel { diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/General/GeneralSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/General/GeneralSettingsPane.swift index 4e577def..8fd222dd 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/General/GeneralSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/General/GeneralSettingsPane.swift @@ -15,12 +15,16 @@ struct GeneralSettingsPane: View { init( shortcutManager: ShortcutManager, appSettings: any AppSettingsProtocol, - onResetAllSettingsToDefaults: @escaping @MainActor () -> Void + onResetAllSettingsToDefaults: @escaping @MainActor () -> Void, + onExportSettings: @escaping @MainActor () throws -> Void, + onImportSettings: @escaping @MainActor () throws -> Void ) { _model = StateObject(wrappedValue: GeneralSettingsPaneViewModel( shortcutManager: shortcutManager, appSettings: appSettings, - onResetAllSettingsToDefaults: onResetAllSettingsToDefaults + onResetAllSettingsToDefaults: onResetAllSettingsToDefaults, + onExportSettings: onExportSettings, + onImportSettings: onImportSettings )) } @@ -61,6 +65,32 @@ struct GeneralSettingsPane: View { } SettingsSectionView(title: L10n.General.settingsSectionTitle) { + SettingsControlRow( + title: L10n.General.exportSettingsTitle, + subtitle: L10n.General.exportSettingsSubtitle + ) { + Button(L10n.General.exportSettingsButton) { + self.model.exportSettings() + } + .buttonStyle(.bordered) + .controlSize(.regular) + } + + Divider() + + SettingsControlRow( + title: L10n.General.importSettingsTitle, + subtitle: L10n.General.importSettingsSubtitle + ) { + Button(L10n.General.importSettingsButton) { + self.model.importSettings() + } + .buttonStyle(.bordered) + .controlSize(.regular) + } + + Divider() + SettingsControlRow( title: L10n.General.resetAllSettingsTitle, subtitle: L10n.General.resetAllSettingsSubtitle @@ -83,5 +113,12 @@ struct GeneralSettingsPane: View { secondaryButton: .cancel(Text(L10n.General.resetAllSettingsCancelButton)) ) } + .alert(item: self.$model.transferErrorAlert) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.message), + dismissButton: .default(Text(L10n.General.resetAllSettingsCancelButton)) + ) + } } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/General/GeneralSettingsPaneViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/General/GeneralSettingsPaneViewModel.swift index e7da74dd..f6188043 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/General/GeneralSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/General/GeneralSettingsPaneViewModel.swift @@ -13,21 +13,28 @@ final class GeneralSettingsPaneViewModel: ObservableObject { private let appSettings: any AppSettingsProtocol private let onResetAllSettingsToDefaults: @MainActor () -> Void + private let onExportSettings: @MainActor () throws -> Void + private let onImportSettings: @MainActor () throws -> Void @Published var visibleAtLaunch: Bool { didSet { self.appSettings.visibleAtLaunch = self.visibleAtLaunch } } @Published var shortcutValidationMessage: String? + @Published var transferErrorAlert: TransferErrorAlert? init( shortcutManager: ShortcutManager, appSettings: any AppSettingsProtocol, - onResetAllSettingsToDefaults: @escaping @MainActor () -> Void + onResetAllSettingsToDefaults: @escaping @MainActor () -> Void, + onExportSettings: @escaping @MainActor () throws -> Void, + onImportSettings: @escaping @MainActor () throws -> Void ) { self.shortcutManager = shortcutManager self.appSettings = appSettings self.onResetAllSettingsToDefaults = onResetAllSettingsToDefaults + self.onExportSettings = onExportSettings + self.onImportSettings = onImportSettings self.visibleAtLaunch = self.appSettings.visibleAtLaunch self.shortcutValidationMessage = self.shortcutManager.shortcutValidationMessage @@ -39,7 +46,48 @@ final class GeneralSettingsPaneViewModel: ObservableObject { @MainActor func resetAllSettingsToDefaults() { self.onResetAllSettingsToDefaults() + self.reloadFromSettings() + } + + @MainActor + func exportSettings() { + do { + try self.onExportSettings() + } catch { + self.presentTransferError(error) + } + } + + @MainActor + func importSettings() { + do { + try self.onImportSettings() + self.reloadFromSettings() + } catch { + self.presentTransferError(error) + } + } + + @MainActor + func reloadFromSettings() { self.visibleAtLaunch = self.appSettings.visibleAtLaunch self.shortcutValidationMessage = self.shortcutManager.shortcutValidationMessage } } + +extension GeneralSettingsPaneViewModel { + struct TransferErrorAlert: Identifiable { + let id = UUID() + let title: String + let message: String + } +} + +private extension GeneralSettingsPaneViewModel { + func presentTransferError(_ error: Error) { + self.transferErrorAlert = TransferErrorAlert( + title: L10n.General.settingsTransferErrorTitle, + message: error.localizedDescription + ) + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsContext.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsContext.swift index cf66a738..becbca77 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsContext.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsContext.swift @@ -11,6 +11,7 @@ import Sparkle @MainActor final class SettingsContext { let settings: AppSettingsContainer + let settingsTransferService: SettingsTransferService let shortcutManager: ShortcutManager let pointerRingVisualizer: PointerRingVisualizer let pointerRipplesVisualizer: PointerRipplesVisualizer @@ -34,6 +35,7 @@ final class SettingsContext { placementCoordinator: any KeyboardVisualizerPlacementCoordinating ) { self.settings = settings + self.settingsTransferService = SettingsTransferService(settings: settings) self.shortcutManager = shortcutManager self.pointerRingVisualizer = pointerRingVisualizer self.pointerRipplesVisualizer = pointerRipplesVisualizer @@ -45,4 +47,12 @@ final class SettingsContext { func resetAllSettingsToDefaults() { self.settings.resetAllSettingsToDefaults() } + + func exportSettings(to url: URL) throws { + try self.settingsTransferService.export(to: url) + } + + func importSettings(from url: URL) throws { + try self.settingsTransferService.import(from: url) + } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift index d62d2c29..d05b9c88 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift @@ -21,6 +21,18 @@ struct SettingsPaneRegistry { let entries: [SettingsPaneEntry] init(context: SettingsContext, displaysViewModel: DisplaysSettingsPaneViewModel) { + self.init( + context: context, + displaysViewModel: displaysViewModel, + settingsTransferPanelPresenter: .live + ) + } + + init( + context: SettingsContext, + displaysViewModel: DisplaysSettingsPaneViewModel, + settingsTransferPanelPresenter: SettingsTransferPanelPresenter + ) { self.entries = [ SettingsPaneEntry( id: .general, @@ -33,6 +45,16 @@ struct SettingsPaneRegistry { appSettings: context.appSettings, onResetAllSettingsToDefaults: { context.resetAllSettingsToDefaults() + displaysViewModel.reloadFromSettings() + }, + onExportSettings: { + guard let url = settingsTransferPanelPresenter.presentExportPanel() else { return } + try context.exportSettings(to: url) + }, + onImportSettings: { + guard let url = settingsTransferPanelPresenter.presentImportPanel() else { return } + try context.importSettings(from: url) + displaysViewModel.reloadFromSettings() } ) ) diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsTransferPanelPresenter.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsTransferPanelPresenter.swift new file mode 100644 index 00000000..829f7dc7 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsTransferPanelPresenter.swift @@ -0,0 +1,44 @@ +// +// SettingsTransferPanelPresenter.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +@MainActor +struct SettingsTransferPanelPresenter { + let presentExportPanel: () -> URL? + let presentImportPanel: () -> URL? +} + +extension SettingsTransferPanelPresenter { + static let live = SettingsTransferPanelPresenter( + presentExportPanel: { + let panel = NSSavePanel() + panel.canCreateDirectories = true + panel.isExtensionHidden = false + panel.allowedFileTypes = [SettingsTransferService.archiveFileExtension] + panel.nameFieldStringValue = SettingsTransferService.defaultArchiveFilename + panel.title = L10n.General.exportSettingsPanelTitle + panel.prompt = L10n.General.exportSettingsButton + + guard panel.runModal() == .OK else { return nil } + return panel.url + }, + presentImportPanel: { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.allowedFileTypes = [SettingsTransferService.archiveFileExtension] + panel.title = L10n.General.importSettingsPanelTitle + panel.prompt = L10n.General.importSettingsButton + + guard panel.runModal() == .OK else { return nil } + return panel.url + } + ) +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift index 0930ac15..5ebb0acb 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift @@ -92,6 +92,7 @@ extension SettingsWindowController: NSWindowDelegate { } } +// MARK: - Window extension SettingsWindowController { final class Window: NSWindow { private let titlebarToolbar = NSToolbar(identifier: "KeytySettingsToolbar") diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift index 3b9944cd..ae3da7df 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift @@ -114,6 +114,17 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS }, write: { store, key, value in store.set(value.storedValue, forKey: key) + }, + import: { _, key, value, defaultValue, apply in + if let intValue = value as? Int { + apply(KeyboardVisualizerStackAxis(storedValue: intValue)) + return + } + if let number = value as? NSNumber { + apply(KeyboardVisualizerStackAxis(storedValue: number.intValue)) + return + } + throw StoredSettingImportError.invalidValue(key: key, expected: "Int", actual: value) } )) var stackAxis: KeyboardVisualizerStackAxis @@ -269,6 +280,21 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS }, write: { store, key, value in store.set(Int(value), forKey: key) + }, + import: { _, key, value, defaultValue, apply in + if let intValue = value as? Int { + apply(CGDirectDisplayID(max(0, intValue))) + return + } + if let number = value as? NSNumber { + apply(CGDirectDisplayID(max(0, number.intValue))) + return + } + if let displayID = value as? CGDirectDisplayID { + apply(displayID) + return + } + apply(defaultValue) } )) var screenID: CGDirectDisplayID { diff --git a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings index 2e0cd57c..a46f101f 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings @@ -79,6 +79,17 @@ "general.reset_all_settings_confirmation_message" = "Dadurch werden die Tastatur-, Maus-, Anzeige-, App- und Tastaturkurzbefehl-Einstellungen auf ihre Standardwerte zurückgesetzt."; "general.reset_all_settings_confirmation_button" = "Einstellungen zurücksetzen"; "general.reset_all_settings_cancel_button" = "Abbrechen"; +"general.export_settings_title" = "Export Settings"; +"general.export_settings_subtitle" = "Save the current Keyty settings to a reusable archive file."; +"general.export_settings_button" = "Export…"; +"general.export_settings_panel_title" = "Export Settings"; +"general.import_settings_title" = "Import Settings"; +"general.import_settings_subtitle" = "Replace the current Keyty settings with values from an exported archive."; +"general.import_settings_button" = "Import…"; +"general.import_settings_panel_title" = "Import Settings"; +"general.settings_transfer_error_title" = "Settings Transfer Failed"; +"general.settings_transfer_invalid_archive_message" = "Die ausgewählte Datei ist kein gültiges Keyty-Einstellungsarchiv."; +"general.settings_transfer_unsupported_schema_version_message" = "Dieses Einstellungsarchiv verwendet eine nicht unterstützte Schemaversion (%@)."; /* Event capture */ "event_tap.creation_failed" = "Die Eingabeerfassung konnte nicht gestartet werden. Die Berechtigung für Bedienungshilfen ist erforderlich."; diff --git a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings index 628fc3a9..e6be8875 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings @@ -79,6 +79,17 @@ "general.reset_all_settings_confirmation_message" = "This will reset keyboard, mouse, display, app, and shortcut settings to their defaults."; "general.reset_all_settings_confirmation_button" = "Reset Settings"; "general.reset_all_settings_cancel_button" = "Cancel"; +"general.export_settings_title" = "Export Settings"; +"general.export_settings_subtitle" = "Save the current Keyty settings to a reusable archive file."; +"general.export_settings_button" = "Export…"; +"general.export_settings_panel_title" = "Export Settings"; +"general.import_settings_title" = "Import Settings"; +"general.import_settings_subtitle" = "Replace the current Keyty settings with values from an exported archive."; +"general.import_settings_button" = "Import…"; +"general.import_settings_panel_title" = "Import Settings"; +"general.settings_transfer_error_title" = "Settings Transfer Failed"; +"general.settings_transfer_invalid_archive_message" = "The selected file is not a valid Keyty settings archive."; +"general.settings_transfer_unsupported_schema_version_message" = "This settings archive uses an unsupported schema version (%@)."; /* Event capture */ "event_tap.creation_failed" = "Could not create event tap. Accessibility permission is required."; diff --git a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings index 86a40724..9a646f89 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings @@ -79,6 +79,17 @@ "general.reset_all_settings_confirmation_message" = "Esto restablecerá la configuración de teclado, ratón, pantalla, aplicación y atajos a sus valores predeterminados."; "general.reset_all_settings_confirmation_button" = "Restablecer ajustes"; "general.reset_all_settings_cancel_button" = "Cancelar"; +"general.export_settings_title" = "Export Settings"; +"general.export_settings_subtitle" = "Save the current Keyty settings to a reusable archive file."; +"general.export_settings_button" = "Export…"; +"general.export_settings_panel_title" = "Export Settings"; +"general.import_settings_title" = "Import Settings"; +"general.import_settings_subtitle" = "Replace the current Keyty settings with values from an exported archive."; +"general.import_settings_button" = "Import…"; +"general.import_settings_panel_title" = "Import Settings"; +"general.settings_transfer_error_title" = "Settings Transfer Failed"; +"general.settings_transfer_invalid_archive_message" = "El archivo seleccionado no es un archivo de configuración válido de Keyty."; +"general.settings_transfer_unsupported_schema_version_message" = "Este archivo de configuración usa una versión de esquema no compatible (%@)."; /* Event capture */ "event_tap.creation_failed" = "No se ha podido iniciar la captura de entradas. Se necesita el permiso de Accesibilidad."; diff --git a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings index 0cadea16..d5ae8570 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings @@ -79,6 +79,17 @@ "general.reset_all_settings_confirmation_message" = "Cela réinitialisera les réglages du clavier, de la souris, de l’affichage, de l’app et des raccourcis à leurs valeurs par défaut."; "general.reset_all_settings_confirmation_button" = "Réinitialiser les réglages"; "general.reset_all_settings_cancel_button" = "Annuler"; +"general.export_settings_title" = "Export Settings"; +"general.export_settings_subtitle" = "Save the current Keyty settings to a reusable archive file."; +"general.export_settings_button" = "Export…"; +"general.export_settings_panel_title" = "Export Settings"; +"general.import_settings_title" = "Import Settings"; +"general.import_settings_subtitle" = "Replace the current Keyty settings with values from an exported archive."; +"general.import_settings_button" = "Import…"; +"general.import_settings_panel_title" = "Import Settings"; +"general.settings_transfer_error_title" = "Settings Transfer Failed"; +"general.settings_transfer_invalid_archive_message" = "Le fichier sélectionné n’est pas une archive de réglages Keyty valide."; +"general.settings_transfer_unsupported_schema_version_message" = "Cette archive de réglages utilise une version de schéma non prise en charge (%@)."; /* Event capture */ "event_tap.creation_failed" = "Impossible de démarrer la détection des entrées. L’autorisation d’accessibilité est requise."; diff --git a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings index f1b25790..023d274c 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings @@ -79,6 +79,17 @@ "general.reset_all_settings_confirmation_message" = "キーボード、マウス、ディスプレイ、アプリ、ショートカットの設定がデフォルトに戻ります。"; "general.reset_all_settings_confirmation_button" = "設定をリセット"; "general.reset_all_settings_cancel_button" = "キャンセル"; +"general.export_settings_title" = "Export Settings"; +"general.export_settings_subtitle" = "Save the current Keyty settings to a reusable archive file."; +"general.export_settings_button" = "Export…"; +"general.export_settings_panel_title" = "Export Settings"; +"general.import_settings_title" = "Import Settings"; +"general.import_settings_subtitle" = "Replace the current Keyty settings with values from an exported archive."; +"general.import_settings_button" = "Import…"; +"general.import_settings_panel_title" = "Import Settings"; +"general.settings_transfer_error_title" = "Settings Transfer Failed"; +"general.settings_transfer_invalid_archive_message" = "選択したファイルは有効なKeyty設定アーカイブではありません。"; +"general.settings_transfer_unsupported_schema_version_message" = "この設定アーカイブは未対応のスキーマバージョン(%@)を使用しています。"; /* Event capture */ "event_tap.creation_failed" = "入力の検出を開始できませんでした。アクセシビリティへのアクセス権が必要です。"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings index 3f5f48b3..1e27d640 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings @@ -79,6 +79,17 @@ "general.reset_all_settings_confirmation_message" = "Hiermee worden toetsenbord-, muis-, beeldscherm-, app- en sneltoetsinstellingen teruggezet naar de standaardwaarden."; "general.reset_all_settings_confirmation_button" = "Instellingen resetten"; "general.reset_all_settings_cancel_button" = "Annuleren"; +"general.export_settings_title" = "Export Settings"; +"general.export_settings_subtitle" = "Save the current Keyty settings to a reusable archive file."; +"general.export_settings_button" = "Export…"; +"general.export_settings_panel_title" = "Export Settings"; +"general.import_settings_title" = "Import Settings"; +"general.import_settings_subtitle" = "Replace the current Keyty settings with values from an exported archive."; +"general.import_settings_button" = "Import…"; +"general.import_settings_panel_title" = "Import Settings"; +"general.settings_transfer_error_title" = "Settings Transfer Failed"; +"general.settings_transfer_invalid_archive_message" = "Het geselecteerde bestand is geen geldig instellingenarchief van Keyty."; +"general.settings_transfer_unsupported_schema_version_message" = "Dit instellingenarchief gebruikt een niet-ondersteunde schemaversie (%@)."; /* Event capture */ "event_tap.creation_failed" = "Kon geen invoergebeurtenis-tap maken. Toegankelijkheidstoestemming is vereist."; diff --git a/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings index 48340f24..dde398a0 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings @@ -79,6 +79,17 @@ "general.reset_all_settings_confirmation_message" = "Spowoduje to zresetowanie ustawień klawiatury, myszy, ekranu, aplikacji i skrótów do wartości domyślnych."; "general.reset_all_settings_confirmation_button" = "Zresetuj ustawienia"; "general.reset_all_settings_cancel_button" = "Anuluj"; +"general.export_settings_title" = "Export Settings"; +"general.export_settings_subtitle" = "Save the current Keyty settings to a reusable archive file."; +"general.export_settings_button" = "Export…"; +"general.export_settings_panel_title" = "Export Settings"; +"general.import_settings_title" = "Import Settings"; +"general.import_settings_subtitle" = "Replace the current Keyty settings with values from an exported archive."; +"general.import_settings_button" = "Import…"; +"general.import_settings_panel_title" = "Import Settings"; +"general.settings_transfer_error_title" = "Settings Transfer Failed"; +"general.settings_transfer_invalid_archive_message" = "Wybrany plik nie jest prawidłowym archiwum ustawień Keyty."; +"general.settings_transfer_unsupported_schema_version_message" = "To archiwum ustawień używa nieobsługiwanej wersji schematu (%@)."; /* Event capture */ "event_tap.creation_failed" = "Nie udało się utworzyć przechwytywania zdarzeń. Wymagane jest uprawnienie Dostępność."; diff --git a/Apps/Keyty/Sources/Keyty/Resources/pt-BR.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/pt-BR.lproj/Localizable.strings index 5e77ddc5..390282de 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/pt-BR.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/pt-BR.lproj/Localizable.strings @@ -79,6 +79,17 @@ "general.reset_all_settings_confirmation_message" = "Isso redefinirá teclado, mouse, tela, app e atalhos para os valores padrão."; "general.reset_all_settings_confirmation_button" = "Redefinir Ajustes"; "general.reset_all_settings_cancel_button" = "Cancelar"; +"general.export_settings_title" = "Export Settings"; +"general.export_settings_subtitle" = "Save the current Keyty settings to a reusable archive file."; +"general.export_settings_button" = "Export…"; +"general.export_settings_panel_title" = "Export Settings"; +"general.import_settings_title" = "Import Settings"; +"general.import_settings_subtitle" = "Replace the current Keyty settings with values from an exported archive."; +"general.import_settings_button" = "Import…"; +"general.import_settings_panel_title" = "Import Settings"; +"general.settings_transfer_error_title" = "Settings Transfer Failed"; +"general.settings_transfer_invalid_archive_message" = "O arquivo selecionado não é um arquivo de configurações válido do Keyty."; +"general.settings_transfer_unsupported_schema_version_message" = "Este arquivo de configurações usa uma versão de esquema sem suporte (%@)."; /* Event capture */ "event_tap.creation_failed" = "Não foi possível criar o event tap. A permissão de Acessibilidade é obrigatória."; diff --git a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings index 76afc31f..0ebe5da5 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings @@ -79,6 +79,17 @@ "general.reset_all_settings_confirmation_message" = "Буде скинуто параметри клавіатури, миші, дисплея, програми та клавіатурних скорочень до стандартних значень."; "general.reset_all_settings_confirmation_button" = "Скинути параметри"; "general.reset_all_settings_cancel_button" = "Скасувати"; +"general.export_settings_title" = "Export Settings"; +"general.export_settings_subtitle" = "Save the current Keyty settings to a reusable archive file."; +"general.export_settings_button" = "Export…"; +"general.export_settings_panel_title" = "Export Settings"; +"general.import_settings_title" = "Import Settings"; +"general.import_settings_subtitle" = "Replace the current Keyty settings with values from an exported archive."; +"general.import_settings_button" = "Import…"; +"general.import_settings_panel_title" = "Import Settings"; +"general.settings_transfer_error_title" = "Settings Transfer Failed"; +"general.settings_transfer_invalid_archive_message" = "Вибраний файл не є дійсним архівом налаштувань Keyty."; +"general.settings_transfer_unsupported_schema_version_message" = "Цей архів налаштувань використовує непідтримувану версію схеми (%@)."; /* Event capture */ "event_tap.creation_failed" = "Не вдалося почати відстеження введення. Потрібен дозвіл «Доступність»."; diff --git a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings index 0ea8e9b0..4b7476d5 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings @@ -79,6 +79,17 @@ "general.reset_all_settings_confirmation_message" = "这将把键盘、鼠标、显示、应用和快捷键设置恢复为默认值。"; "general.reset_all_settings_confirmation_button" = "重置设置"; "general.reset_all_settings_cancel_button" = "取消"; +"general.export_settings_title" = "Export Settings"; +"general.export_settings_subtitle" = "Save the current Keyty settings to a reusable archive file."; +"general.export_settings_button" = "Export…"; +"general.export_settings_panel_title" = "Export Settings"; +"general.import_settings_title" = "Import Settings"; +"general.import_settings_subtitle" = "Replace the current Keyty settings with values from an exported archive."; +"general.import_settings_button" = "Import…"; +"general.import_settings_panel_title" = "Import Settings"; +"general.settings_transfer_error_title" = "Settings Transfer Failed"; +"general.settings_transfer_invalid_archive_message" = "所选文件不是有效的 Keyty 设置归档文件。"; +"general.settings_transfer_unsupported_schema_version_message" = "此设置归档文件使用了不受支持的架构版本(%@)。"; /* Event capture */ "event_tap.creation_failed" = "无法开始监测输入。需要“辅助功能”权限。"; diff --git a/Apps/Keyty/Sources/Keyty/Services/Settings/SettingsTransferService.swift b/Apps/Keyty/Sources/Keyty/Services/Settings/SettingsTransferService.swift new file mode 100644 index 00000000..dea41bf4 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Services/Settings/SettingsTransferService.swift @@ -0,0 +1,236 @@ +// +// SettingsTransferService.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import Foundation + +final class SettingsTransferService { + private let settings: AppSettingsContainer + + init(settings: AppSettingsContainer) { + self.settings = settings + } +} + +// MARK: - Constants +extension SettingsTransferService { + static let schemaVersion = 1 + static let archiveBaseFilename = "keyty-settings" + static let archiveFileExtension = "json" + static let defaultArchiveFilename = "\(archiveBaseFilename).\(archiveFileExtension)" +} + +// MARK: - Export +extension SettingsTransferService { + func exportData() throws -> Data { + let values = self.settings.transferableSettings.reduce(into: [String: Any]()) { result, setting in + if let value = setting.exportedValue(from: self.settings.store) { + result[setting.key] = value + } + } + + let archive = Archive( + schemaVersion: Self.schemaVersion, + exportedAt: Date(), + values: values.mapValues(ArchiveValue.init) + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return try encoder.encode(archive) + } + + func export(to url: URL) throws { + let data = try self.exportData() + try data.write(to: url, options: .atomic) + } +} + +// MARK: - Import +extension SettingsTransferService { + func importData(_ data: Data) throws { + let archive = try self.decodeArchive(data) + let descriptorsByKey = Dictionary(self.settings.transferableSettings.map { ($0.key, $0) }, uniquingKeysWith: { current, _ in current }) + + try self.validateImport(values: archive.values, descriptorsByKey: descriptorsByKey) + + self.settings.resetAllSettingsToDefaults() + + for (key, value) in archive.values { + guard let setting = descriptorsByKey[key] else { + continue + } + + try setting.applyImportedValue(value.rawValue, in: self.settings.store) + } + } + + func `import`(from url: URL) throws { + try self.importData(Data(contentsOf: url)) + } +} + +// MARK: - Errors +extension SettingsTransferService { + enum Error: Swift.Error, LocalizedError { + case invalidArchive + case unsupportedSchemaVersion(Int) + + var errorDescription: String? { + switch self { + case .invalidArchive: + return L10n.General.settingsTransferInvalidArchiveMessage + case .unsupportedSchemaVersion(let version): + return L10n.General.settingsTransferUnsupportedSchemaVersionMessage(version) + } + } + } +} + +// MARK: - Archive +private extension SettingsTransferService { + struct Archive: Codable { + let schemaVersion: Int + let exportedAt: Date + let values: [String: ArchiveValue] + } +} + +private extension SettingsTransferService { + enum ArchiveValue: Codable { + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case data(Data) + + init(_ rawValue: Any) { + switch rawValue { + case let value as NSNumber: + if CFGetTypeID(value) == CFBooleanGetTypeID() { + self = .bool(value.boolValue) + } else if value.doubleValue.rounded(.towardZero) == value.doubleValue { + self = .int(value.intValue) + } else { + self = .double(value.doubleValue) + } + case let value as Bool: + self = .bool(value) + case let value as Int: + self = .int(value) + case let value as CGFloat: + self = .double(Double(value)) + case let value as Double: + self = .double(value) + case let value as Float: + self = .double(Double(value)) + case let value as String: + self = .string(value) + case let value as Data: + self = .data(value) + default: + self = .string(String(describing: rawValue)) + } + } + + var rawValue: Any { + switch self { + case .bool(let value): + return value + case .int(let value): + return value + case .double(let value): + return value + case .string(let value): + return value + case .data(let value): + return value + } + } + + private enum CodingKeys: String, CodingKey { + case type + case value + } + + private enum ValueType: String, Codable { + case bool + case int + case double + case string + case data + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(ValueType.self, forKey: .type) + switch type { + case .bool: + self = .bool(try container.decode(Bool.self, forKey: .value)) + case .int: + self = .int(try container.decode(Int.self, forKey: .value)) + case .double: + self = .double(try container.decode(Double.self, forKey: .value)) + case .string: + self = .string(try container.decode(String.self, forKey: .value)) + case .data: + self = .data(try container.decode(Data.self, forKey: .value)) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .bool(let value): + try container.encode(ValueType.bool, forKey: .type) + try container.encode(value, forKey: .value) + case .int(let value): + try container.encode(ValueType.int, forKey: .type) + try container.encode(value, forKey: .value) + case .double(let value): + try container.encode(ValueType.double, forKey: .type) + try container.encode(value, forKey: .value) + case .string(let value): + try container.encode(ValueType.string, forKey: .type) + try container.encode(value, forKey: .value) + case .data(let value): + try container.encode(ValueType.data, forKey: .type) + try container.encode(value, forKey: .value) + } + } + } + + func decodeArchive(_ data: Data) throws -> Archive { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let archive: Archive + do { + archive = try decoder.decode(Archive.self, from: data) + } catch { + throw Error.invalidArchive + } + + guard archive.schemaVersion == Self.schemaVersion else { + throw Error.unsupportedSchemaVersion(archive.schemaVersion) + } + + return archive + } + + func validateImport( + values: [String: ArchiveValue], + descriptorsByKey: [String: AnyStoredSetting] + ) throws { + let validationStore = InMemoryKeyValueStore() + + for (key, value) in values { + guard let setting = descriptorsByKey[key] else { continue } + try setting.applyImportedValue(value.rawValue, in: validationStore) + } + } +} diff --git a/Apps/Keyty/Sources/Keyty/Services/Settings/StoredSetting.swift b/Apps/Keyty/Sources/Keyty/Services/Settings/StoredSetting.swift index 7b5eb6c6..44fb7f28 100644 --- a/Apps/Keyty/Sources/Keyty/Services/Settings/StoredSetting.swift +++ b/Apps/Keyty/Sources/Keyty/Services/Settings/StoredSetting.swift @@ -23,16 +23,11 @@ protocol PlacementReactiveSettings: AnyObject { } protocol AnyStoredSetting { + var key: String { get } var defaultRegistration: (key: String, value: Any)? { get } func reset(in store: KeyValueStore) -} - -private protocol AnyOptional { - var isNil: Bool { get } -} - -extension Optional: AnyOptional { - var isNil: Bool { self == nil } + func exportedValue(from store: KeyValueStore) -> Any? + func applyImportedValue(_ value: Any, in store: KeyValueStore) throws } struct StoredDescriptor { @@ -41,6 +36,8 @@ struct StoredDescriptor { let registrationValue: Any let read: (KeyValueStore, String, Value) -> Value let write: (KeyValueStore, String, Value) -> Void + let export: (KeyValueStore, String) -> Any? + let `import`: (KeyValueStore, String, Any, Value, (Value) -> Void) throws -> Void func get(from store: KeyValueStore) -> Value { self.read(store, self.key, self.defaultValue) @@ -49,6 +46,16 @@ struct StoredDescriptor { func set(_ value: Value, in store: KeyValueStore) { self.write(store, self.key, value) } + + func exportedValue(from store: KeyValueStore) -> Any? { + self.export(store, self.key) + } + + func applyImportedValue(_ value: Any, in store: KeyValueStore) throws { + try self.import(store, self.key, value, self.defaultValue) { importedValue in + self.set(importedValue, in: store) + } + } } @propertyWrapper @@ -59,6 +66,8 @@ struct Stored: AnyStoredSetting { self.descriptor = descriptor } + var key: String { self.descriptor.key } + var defaultRegistration: (key: String, value: Any)? { if let optional = self.descriptor.registrationValue as? AnyOptional, optional.isNil { return nil @@ -70,6 +79,14 @@ struct Stored: AnyStoredSetting { store.removeObject(forKey: self.descriptor.key) } + func exportedValue(from store: KeyValueStore) -> Any? { + self.descriptor.exportedValue(from: store) + } + + func applyImportedValue(_ value: Any, in store: KeyValueStore) throws { + try self.descriptor.applyImportedValue(value, in: store) + } + @available(*, unavailable, message: "@Stored can only be used on reference types that conform to HasSettingsStore.") var wrappedValue: Value { get { fatalError() } @@ -103,17 +120,36 @@ enum StoredDefaults { } extension HasSettingsStore { + var storedSettings: [AnyStoredSetting] { + Mirror(reflecting: self).children.compactMap { $0.value as? AnyStoredSetting } + } + func registerStoredDefaults() { StoredDefaults.register(from: self, into: self.store) } func resetStoredSettingsToDefaults() { - Mirror(reflecting: self).children.forEach { child in - (child.value as? AnyStoredSetting)?.reset(in: self.store) + self.storedSettings.forEach { $0.reset(in: self.store) } + } +} + +enum StoredSettingImportError: Error, LocalizedError { + case invalidValue(key: String, expected: String, actual: Any.Type) + + var errorDescription: String? { + switch self { + case .invalidValue(let key, let expected, let actual): + return "Invalid value for setting \"\(key)\". Expected \(expected), got \(String(describing: actual))." } } } +extension StoredSettingImportError { + static func invalidValue(key: String, expected: String, actual value: Any) -> Self { + .invalidValue(key: key, expected: expected, actual: Swift.type(of: value)) + } +} + extension StoredDescriptor where Value == Bool { static func bool(_ key: String, default defaultValue: Bool) -> Self { Self( @@ -121,7 +157,19 @@ extension StoredDescriptor where Value == Bool { defaultValue: defaultValue, registrationValue: defaultValue, read: { store, key, _ in store.bool(forKey: key) }, - write: { store, key, value in store.set(value, forKey: key) } + write: { store, key, value in store.set(value, forKey: key) }, + export: { store, key in store.object(forKey: key) }, + import: { _, key, value, _, apply in + if let boolValue = value as? Bool { + apply(boolValue) + return + } + if let number = value as? NSNumber { + apply(number.boolValue) + return + } + throw StoredSettingImportError.invalidValue(key: key, expected: "Bool", actual: value) + } ) } } @@ -132,14 +180,18 @@ extension StoredDescriptor { default defaultValue: Value, registrationValue: Any, read: @escaping (KeyValueStore, String, Value) -> Value, - write: @escaping (KeyValueStore, String, Value) -> Void + write: @escaping (KeyValueStore, String, Value) -> Void, + export: @escaping (KeyValueStore, String) -> Any? = { store, key in store.object(forKey: key) }, + import: @escaping (KeyValueStore, String, Any, Value, (Value) -> Void) throws -> Void ) -> Self { Self( key: key, defaultValue: defaultValue, registrationValue: registrationValue, read: read, - write: write + write: write, + export: export, + import: `import` ) } } @@ -162,6 +214,18 @@ extension StoredDescriptor where Value == Int { write: { store, key, value in let sanitizedValue = range.map { min(max(value, $0.lowerBound), $0.upperBound) } ?? value store.set(sanitizedValue, forKey: key) + }, + export: { store, key in store.object(forKey: key) }, + import: { _, key, value, _, apply in + if let intValue = value as? Int { + apply(intValue) + return + } + if let number = value as? NSNumber { + apply(number.intValue) + return + } + throw StoredSettingImportError.invalidValue(key: key, expected: "Int", actual: value) } ) } @@ -189,6 +253,18 @@ extension StoredDescriptor where Value == CGFloat { write: { store, key, value in let sanitizedValue = range.map { min(max(value, $0.lowerBound), $0.upperBound) } ?? value store.set(sanitizedValue, forKey: key) + }, + export: { store, key in store.object(forKey: key) }, + import: { _, key, value, _, apply in + if let floatValue = value as? CGFloat { + apply(floatValue) + return + } + if let number = value as? NSNumber { + apply(CGFloat(number.doubleValue)) + return + } + throw StoredSettingImportError.invalidValue(key: key, expected: "CGFloat", actual: value) } ) } @@ -207,6 +283,13 @@ extension StoredDescriptor where Value == Data? { } else { store.removeObject(forKey: key) } + }, + export: { store, key in store.data(forKey: key) }, + import: { _, key, value, _, apply in + guard let dataValue = value as? Data else { + throw StoredSettingImportError.invalidValue(key: key, expected: "Data", actual: value) + } + apply(dataValue) } ) } @@ -219,7 +302,14 @@ extension StoredDescriptor where Value == NSColor { defaultValue: defaultValue, registrationValue: defaultValue.hexString, read: { store, key, defaultValue in store.color(forKey: key) ?? defaultValue }, - write: { store, key, value in store.set(value.hexString, forKey: key) } + write: { store, key, value in store.set(value.hexString, forKey: key) }, + export: { store, key in store.object(forKey: key) }, + import: { _, key, value, defaultValue, apply in + guard let stringValue = value as? String else { + throw StoredSettingImportError.invalidValue(key: key, expected: "String", actual: value) + } + apply(NSColor(hexString: stringValue) ?? defaultValue) + } ) } } @@ -233,7 +323,19 @@ extension StoredDescriptor where Value: RawRepresentable, Value.RawValue == Int read: { store, key, defaultValue in Value(rawValue: store.integer(forKey: key)) ?? defaultValue }, - write: { store, key, value in store.set(value.rawValue, forKey: key) } + write: { store, key, value in store.set(value.rawValue, forKey: key) }, + export: { store, key in store.object(forKey: key) }, + import: { _, key, value, defaultValue, apply in + if let rawValue = value as? Int { + apply(Value(rawValue: rawValue) ?? defaultValue) + return + } + if let number = value as? NSNumber { + apply(Value(rawValue: number.intValue) ?? defaultValue) + return + } + throw StoredSettingImportError.invalidValue(key: key, expected: "Int", actual: value) + } ) } } @@ -251,7 +353,14 @@ extension StoredDescriptor where Value: RawRepresentable, Value.RawValue == Stri } return value }, - write: { store, key, value in store.set(value.rawValue, forKey: key) } + write: { store, key, value in store.set(value.rawValue, forKey: key) }, + export: { store, key in store.object(forKey: key) }, + import: { _, key, value, defaultValue, apply in + guard let rawValue = value as? String else { + throw StoredSettingImportError.invalidValue(key: key, expected: "String", actual: value) + } + apply(Value(rawValue: rawValue) ?? defaultValue) + } ) } } diff --git a/Apps/Keyty/Sources/Keyty/Support/Extensions/Foundation/Optional+AnyOptional.swift b/Apps/Keyty/Sources/Keyty/Support/Extensions/Foundation/Optional+AnyOptional.swift new file mode 100644 index 00000000..6ded7fc1 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Support/Extensions/Foundation/Optional+AnyOptional.swift @@ -0,0 +1,15 @@ +// +// Optional+AnyOptional.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +protocol AnyOptional { + var isNil: Bool { get } +} + +extension Optional: AnyOptional { + var isNil: Bool { self == nil } +} diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Settings/General/GeneralSettingsPaneViewModelTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Settings/General/GeneralSettingsPaneViewModelTests.swift index 3eb5c4be..c6d5169e 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Settings/General/GeneralSettingsPaneViewModelTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Settings/General/GeneralSettingsPaneViewModelTests.swift @@ -34,7 +34,9 @@ final class GeneralSettingsPaneViewModelTests: XCTestCase { onResetAllSettingsToDefaults: { appSettings.resetToDefaults() shortcutSettings.resetToDefaults() - } + }, + onExportSettings: {}, + onImportSettings: {} ) model.resetAllSettingsToDefaults() diff --git a/Apps/Keyty/Tests/KeytyTests/Services/Settings/SettingsTransferServiceTests.swift b/Apps/Keyty/Tests/KeytyTests/Services/Settings/SettingsTransferServiceTests.swift new file mode 100644 index 00000000..c26916cc --- /dev/null +++ b/Apps/Keyty/Tests/KeytyTests/Services/Settings/SettingsTransferServiceTests.swift @@ -0,0 +1,136 @@ +// +// SettingsTransferServiceTests.swift +// KeytyTests +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit +import XCTest +@testable import Keyty + +final class SettingsTransferServiceTests: XCTestCase { + private var sourceStore: InMemoryKeyValueStore! + private var sourceSettings: AppSettingsContainer! + private var sourceService: SettingsTransferService! + + override func setUp() { + super.setUp() + self.sourceStore = InMemoryKeyValueStore() + self.sourceSettings = AppSettingsContainer(store: self.sourceStore) + self.sourceService = SettingsTransferService(settings: self.sourceSettings) + } + + override func tearDown() { + self.sourceService = nil + self.sourceSettings = nil + self.sourceStore = nil + super.tearDown() + } + + func testExportsAndImportsSettingsRoundTrip() throws { + self.sourceSettings.appSettings.visibleAtLaunch = false + self.sourceSettings.pointerRingSettings.alwaysVisible = true + self.sourceSettings.pointerRingSettings.color = .systemOrange + self.sourceSettings.pointerRipplesSettings.thickness = 11 + self.sourceSettings.pointerIconSettings.isEnabled = true + self.sourceSettings.pointerIconSettings.sizeIndex = 2 + self.sourceSettings.keyboardVisualizerSettings.isEnabled = false + self.sourceSettings.keyboardVisualizerSettings.scale = 1.6 + self.sourceSettings.keyboardVisualizerSettings.placementMode = .custom + self.sourceSettings.keyboardVisualizerSettings.customPositionNormalizedX = 0.25 + self.sourceSettings.keyboardVisualizerSettings.customPositionNormalizedY = 0.75 + + let data = try self.sourceService.exportData() + + let destinationStore = InMemoryKeyValueStore() + let destinationSettings = AppSettingsContainer(store: destinationStore) + let destinationService = SettingsTransferService(settings: destinationSettings) + + try destinationService.importData(data) + + XCTAssertFalse(destinationSettings.appSettings.visibleAtLaunch) + XCTAssertTrue(destinationSettings.pointerRingSettings.alwaysVisible) + XCTAssertEqual(destinationSettings.pointerRingSettings.color.hexString, NSColor.systemOrange.hexString) + XCTAssertEqual(destinationSettings.pointerRipplesSettings.thickness, 11, accuracy: 0.0001) + XCTAssertTrue(destinationSettings.pointerIconSettings.isEnabled) + XCTAssertEqual(destinationSettings.pointerIconSettings.sizeIndex, 2) + XCTAssertFalse(destinationSettings.keyboardVisualizerSettings.isEnabled) + XCTAssertEqual(destinationSettings.keyboardVisualizerSettings.scale, 1.6, accuracy: 0.0001) + XCTAssertEqual(destinationSettings.keyboardVisualizerSettings.placementMode, .custom) + XCTAssertEqual(destinationSettings.keyboardVisualizerSettings.customPositionNormalizedX, 0.25, accuracy: 0.0001) + XCTAssertEqual(destinationSettings.keyboardVisualizerSettings.customPositionNormalizedY, 0.75, accuracy: 0.0001) + } + + func testImportResetsMissingKeysToDefaultsAndIgnoresUnknownKeys() throws { + self.sourceSettings.pointerRingSettings.alwaysVisible = true + + let archive = try Self.makeArchive(values: [ + AppSettings.visibleAtLaunchKey: false, + "unknown.setting": "ignored" + ]) + + try self.sourceService.importData(archive) + + XCTAssertFalse(self.sourceSettings.appSettings.visibleAtLaunch) + XCTAssertEqual(self.sourceSettings.pointerRingSettings.alwaysVisible, PointerRingSettingsKeys.defaultAlwaysVisible) + } + + func testImportDoesNotChangeSettingsWhenArchiveContainsInvalidValues() throws { + self.sourceSettings.appSettings.visibleAtLaunch = false + self.sourceSettings.pointerRingSettings.color = .systemPink + + let archive = try Self.makeArchive(values: [ + AppSettings.visibleAtLaunchKey: "invalid" + ]) + + XCTAssertThrowsError(try self.sourceService.importData(archive)) + XCTAssertFalse(self.sourceSettings.appSettings.visibleAtLaunch) + XCTAssertEqual(self.sourceSettings.pointerRingSettings.color.hexString, NSColor.systemPink.hexString) + } + + func testExportKeepsNumericEnumRawValuesAsIntegers() throws { + self.sourceSettings.keyboardVisualizerSettings.customHorizontalAlignment = .trailing + + let data = try self.sourceService.exportData() + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let values = try XCTUnwrap(object["values"] as? [String: Any]) + let alignment = try XCTUnwrap(values[KeyboardVisualizerSettingsKeys.customHorizontalAlignment] as? [String: Any]) + + XCTAssertEqual(alignment["type"] as? String, "int") + XCTAssertEqual(alignment["value"] as? Int, KeyboardVisualizerAlignment.trailing.rawValue) + } +} + +private extension SettingsTransferServiceTests { + static func makeArchive(values: [String: Any]) throws -> Data { + let archiveValues = values.reduce(into: [String: [String: Any]]()) { result, entry in + let (key, value) = entry + let encodedValue: [String: Any] + switch value { + case let value as Bool: + encodedValue = ["type": "bool", "value": value] + case let value as Int: + encodedValue = ["type": "int", "value": value] + case let value as Double: + encodedValue = ["type": "double", "value": value] + case let value as String: + encodedValue = ["type": "string", "value": value] + case let value as Data: + encodedValue = ["type": "data", "value": value.base64EncodedString()] + default: + fatalError("Unsupported test archive value: \(value)") + } + result[key] = encodedValue + } + + let archive: [String: Any] = [ + "schemaVersion": SettingsTransferService.schemaVersion, + "exportedAt": "1970-01-01T00:00:00Z", + "values": archiveValues + ] + + return try JSONSerialization.data(withJSONObject: archive, options: [.prettyPrinted, .sortedKeys]) + } +}