From c0442bfa7e2a1b55b75d94900916542f07378f17 Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Sun, 14 Jun 2026 23:54:22 -0400 Subject: [PATCH 1/2] feat(backup): add config export/import via .lockime files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds file-level backup and migration: export the portable configuration (global default, app rules, URL rules) to a versioned .lockime JSON, and import it on another Mac through a single Review Import screen — Merge/Replace, per-row conflict resolution, and keep/remove for input sources missing on the target — committed only on Apply, with zero side effects on cancel. Per-device runtime state (master lock, enhanced mode, language, login item) is deliberately never exported or imported. The envelope carries a format id, schema version, and minReader so newer files are gated cleanly. Pure diff/merge/version logic lives in LockIMEKit with unit tests; all new UI strings are localized for every supported language. Signed-off-by: Kevin Cui --- Sources/LockIME/AppState.swift | 36 + Sources/LockIME/Localizable.xcstrings | 2392 +++++++++++++++++ .../UI/Settings/BackupSettingsPane.swift | 172 ++ .../UI/Settings/ImportReviewSheet.swift | 464 ++++ .../UI/Settings/URLRulesSettingsPane.swift | 42 +- Sources/LockIME/UI/SettingsRootView.swift | 8 +- Sources/LockIMEKit/Backup/ConfigBackup.swift | 205 ++ Sources/LockIMEKit/Backup/ImportPlan.swift | 434 +++ Tests/LockIMEKitTests/ConfigBackupTests.swift | 180 ++ Tests/LockIMEKitTests/ImportPlanTests.swift | 520 ++++ 10 files changed, 4442 insertions(+), 11 deletions(-) create mode 100644 Sources/LockIME/UI/Settings/BackupSettingsPane.swift create mode 100644 Sources/LockIME/UI/Settings/ImportReviewSheet.swift create mode 100644 Sources/LockIMEKit/Backup/ConfigBackup.swift create mode 100644 Sources/LockIMEKit/Backup/ImportPlan.swift create mode 100644 Tests/LockIMEKitTests/ConfigBackupTests.swift create mode 100644 Tests/LockIMEKitTests/ImportPlanTests.swift diff --git a/Sources/LockIME/AppState.swift b/Sources/LockIME/AppState.swift index df57a4a..b794d81 100644 --- a/Sources/LockIME/AppState.swift +++ b/Sources/LockIME/AppState.swift @@ -444,6 +444,42 @@ final class AppState { store.save(config) } + // MARK: - Backup (export / import) + + /// Snapshot the portable configuration (rules + binding intent) into a + /// backup envelope, capturing the current display name of every installed + /// source so a target machine missing one can still show a label. Per-device + /// runtime state (master lock, enhanced mode, language, login item) is not + /// included — see `ConfigBackup.make`. + func makeBackup() -> ConfigBackup { + var names: [InputSourceID: String] = [:] + for source in availableSources { names[source.id] = source.localizedName } + return ConfigBackup.make(from: config, appVersion: Bundle.main.shortVersion, sourceNames: names) + } + + /// Read and version-gate a backup file, building an in-memory staging plan + /// diffed against the live configuration and installed sources. **Nothing is + /// persisted here** — the plan is editable and only `applyImport` commits. + func loadImportPlan(from url: URL) -> Result { + guard let data = try? Data(contentsOf: url) else { return .failure(.unreadable) } + switch ConfigBackup.read(data) { + case .success(let backup): + return .success(ImportPlan(current: config, backup: backup, installedSources: availableSources)) + case .failure(let error): + return .failure(error) + } + } + + /// Commit a staging plan: fold it into the configuration, persist, and + /// re-apply the engine. The only state-changing step of the whole import. + @discardableResult + func applyImport(_ plan: ImportPlan) -> ImportOutcome { + let outcome = plan.outcome() + config = plan.resolvedConfiguration() + commit(reason: .configChanged) + return outcome + } + // MARK: - Windows & update presentation /// Bring the About window to the foreground (creating it on first use). diff --git a/Sources/LockIME/Localizable.xcstrings b/Sources/LockIME/Localizable.xcstrings index 59a5174..c3f6e74 100644 --- a/Sources/LockIME/Localizable.xcstrings +++ b/Sources/LockIME/Localizable.xcstrings @@ -6917,6 +6917,2398 @@ } } } + }, + "Review Import": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检查导入" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢查匯入" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートを確認" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérifier l’importation" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Import prüfen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Revisar importación" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Revisar importação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Просмотр импорта" + } + } + } + }, + "Nothing is changed until you apply.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用之前不会改动任何配置。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在你套用之前不會變更任何設定。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "適用するまで何も変更されません。" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rien n’est modifié tant que vous n’appliquez pas." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es wird nichts geändert, bis Sie übernehmen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se cambia nada hasta que apliques." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nada é alterado até você aplicar." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ничего не изменится, пока вы не примените." + } + } + } + }, + "Merge": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "合并" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "合併" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マージ" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fusionner" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zusammenführen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Combinar" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mesclar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Объединить" + } + } + } + }, + "Replace": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "替换" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取代" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "置き換え" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Remplacer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ersetzen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reemplazar" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Substituir" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Заменить" + } + } + } + }, + "Keeps your current rules and adds the file's. You decide each conflict below.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保留你当前的规则,并加入文件中的规则。下方的冲突由你逐项决定。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "保留你目前的規則並加入檔案中的規則。每個衝突由你在下方決定。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のルールを保持しつつ、ファイルのルールを追加します。各競合は下で選べます。" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Conserve vos règles actuelles et ajoute celles du fichier. Vous tranchez chaque conflit ci-dessous." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Behält Ihre aktuellen Regeln und fügt die der Datei hinzu. Jeden Konflikt entscheiden Sie unten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conserva tus reglas actuales y añade las del archivo. Tú decides cada conflicto a continuación." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mantém suas regras atuais e adiciona as do arquivo. Você decide cada conflito abaixo." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сохраняет ваши текущие правила и добавляет правила из файла. Каждый конфликт вы разрешаете ниже." + } + } + } + }, + "Makes your rules match the file. The file wins every conflict.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "让你的规则与文件保持一致。所有冲突均以文件为准。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "讓你的規則與檔案一致。所有衝突一律以檔案為準。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルールをファイルの内容に合わせます。競合はすべてファイルが優先されます。" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aligne vos règles sur le fichier. Le fichier l’emporte sur chaque conflit." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gleicht Ihre Regeln an die Datei an. Bei jedem Konflikt gewinnt die Datei." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Hace que tus reglas coincidan con el archivo. El archivo gana todos los conflictos." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Faz suas regras corresponderem ao arquivo. O arquivo vence todos os conflitos." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Приводит ваши правила в соответствие с файлом. В каждом конфликте побеждает файл." + } + } + } + }, + "Replace removes %lld of your rules that aren't in the file.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "替换将移除你的 %lld 条不在文件中的规则。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取代會移除你的 %lld 條不在檔案中的規則。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "置き換えると、ファイルにない %lld 件のルールが削除されます。" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Remplacer supprime %lld de vos règles absentes du fichier." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ersetzen entfernt %lld Ihrer Regeln, die nicht in der Datei enthalten sind." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reemplazar elimina %lld de tus reglas que no están en el archivo." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Substituir remove %lld das suas regras que não estão no arquivo." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "При замене удаляется ваших правил, отсутствующих в файле: %lld." + } + } + } + }, + "How to import": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导入方式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "匯入方式" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポート方法" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Méthode d’importation" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Importart" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cómo importar" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Como importar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Способ импорта" + } + } + } + }, + "Select All": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全選" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて選択" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tout sélectionner" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle auswählen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Seleccionar todo" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Selecionar tudo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбрать все" + } + } + } + }, + "Select None": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全不选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全部取消" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択を解除" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tout désélectionner" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine auswählen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No seleccionar nada" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não selecionar nada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Снять выбор" + } + } + } + }, + "Conflicts (%lld)": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "冲突 (%lld)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "衝突(%lld)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "競合(%lld)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Conflits (%lld)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konflikte (%lld)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conflictos (%lld)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Conflitos (%lld)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Конфликты (%lld)" + } + } + } + }, + "Keep Local": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保留本地" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "保留本機" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ローカルを保持" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Conserver le local" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lokal behalten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conservar local" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Manter local" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Оставить локальное" + } + } + } + }, + "Use File": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用文件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用檔案" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルを使用" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utiliser le fichier" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Datei verwenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Usar archivo" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Usar arquivo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Использовать из файла" + } + } + } + }, + "Your bindings are kept unless you choose the file's.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "除非你选择使用文件中的绑定,否则保留你的绑定。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "除非你選擇檔案中的設定,否則會保留你的繫結。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルを選ばない限り、現在の割り当てが保持されます。" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vos associations sont conservées, sauf si vous choisissez celles du fichier." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ihre Zuordnungen bleiben erhalten, sofern Sie nicht die der Datei wählen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tus asignaciones se conservan a menos que elijas las del archivo." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Seus vínculos são mantidos, a menos que você escolha os do arquivo." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ваши привязки сохраняются, если вы не выберете привязки из файла." + } + } + } + }, + "Local:": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "本地:" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "本機:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ローカル:" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Local :" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lokal:" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Local:" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Local:" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Локально:" + } + } + } + }, + "File:": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件:" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檔案:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル:" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fichier :" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Datei:" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Archivo:" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Arquivo:" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Файл:" + } + } + } + }, + "Input source not installed (%lld)": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输入法未安装 (%lld)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未安裝輸入法(%lld)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未インストールの入力ソース(%lld)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Source de saisie non installée (%lld)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eingabequelle nicht installiert (%lld)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fuente de entrada no instalada (%lld)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Fonte de entrada não instalada (%lld)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Источник ввода не установлен (%lld)" + } + } + } + }, + "Keep": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保留" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "保留" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保持" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Conserver" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Behalten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conservar" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Manter" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Оставить" + } + } + } + }, + "Remove All": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部移除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全部移除" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて削除" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tout supprimer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle entfernen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar todo" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Remover tudo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить все" + } + } + } + }, + "Rules aren't lost — they resume automatically once you install the matching input source.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "规则不会丢失——装好对应的输入法后会自动恢复生效。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "規則不會遺失,安裝對應的輸入法後即會自動恢復生效。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルールは失われません。対応する入力ソースをインストールすると自動的に再開します。" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les règles ne sont pas perdues — elles reprennent automatiquement dès que vous installez la source de saisie correspondante." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Regeln gehen nicht verloren – sie werden automatisch fortgesetzt, sobald Sie die passende Eingabequelle installieren." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las reglas no se pierden: se reanudan automáticamente cuando instalas la fuente de entrada correspondiente." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "As regras não são perdidas — elas voltam automaticamente assim que você instalar a fonte de entrada correspondente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Правила не теряются — они снова начнут действовать автоматически, как только вы установите соответствующий источник ввода." + } + } + } + }, + "Input source not installed": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输入法未安装" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未安裝輸入法" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未インストールの入力ソース" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Source de saisie non installée" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eingabequelle nicht installiert" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fuente de entrada no instalada" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Fonte de entrada não instalada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Источник ввода не установлен" + } + } + } + }, + "Remove": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entfernen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Remover" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить" + } + } + } + }, + "(default)": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "(默认)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "(預設)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "(デフォルト)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "(par défaut)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "(Standard)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "(predeterminado)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "(padrão)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "(по умолчанию)" + } + } + } + }, + "Added": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新增" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已新增" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "追加" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajoutées" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hinzugefügt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añadidas" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicionadas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавлено" + } + } + } + }, + "Updated": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已更新" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mises à jour" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktualisiert" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizadas" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizadas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновлено" + } + } + } + }, + "Kept": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保留" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已保留" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保持" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Conservées" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Behalten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conservadas" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mantidas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сохранено" + } + } + } + }, + "Removed": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已移除" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimées" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entfernt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminadas" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Removidas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалено" + } + } + } + }, + "Apply": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "套用" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "適用" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Appliquer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Übernehmen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aplicar" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Aplicar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Применить" + } + } + } + }, + "Export Configuration…": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导出配置…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "匯出設定…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "構成を書き出す…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Exporter la configuration…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konfiguration exportieren…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Exportar configuración…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Exportar configuração…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Экспортировать конфигурацию…" + } + } + } + }, + "Couldn't save the backup file.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法保存备份文件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法儲存備份檔案。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バックアップファイルを保存できませんでした。" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d’enregistrer le fichier de sauvegarde." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Backup-Datei konnte nicht gesichert werden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo guardar el archivo de copia de seguridad." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível salvar o arquivo de backup." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось сохранить файл резервной копии." + } + } + } + }, + "Export": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导出" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "匯出" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "書き出す" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Exporter" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Exportieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Exportar" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Exportar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Экспортировать" + } + } + } + }, + "Saves your global default source, app rules, and URL rules to a .lockime file. The master lock, enhanced mode, language, and login item aren't included.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将你的全局默认输入法、应用规则和网址规则保存到 .lockime 文件。不包含总开关锁、增强模式、语言和登录项。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將你的全域預設輸入法、App 規則和網址規則儲存到 .lockime 檔案。不包含主鎖定、增強模式、語言和登入項目。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グローバルデフォルトの入力ソース、アプリのルール、URL ルールを .lockime ファイルに保存します。マスターロック、拡張モード、言語、ログイン項目は含まれません。" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistre votre source par défaut globale, vos règles par app et vos règles d’URL dans un fichier .lockime. Le verrou principal, le mode avancé, la langue et l’élément d’ouverture ne sont pas inclus." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sichert Ihre globale Standardquelle, App-Regeln und URL-Regeln in einer .lockime-Datei. Die Hauptsperre, der erweiterte Modus, die Sprache und das Anmeldeobjekt sind nicht enthalten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Guarda tu fuente predeterminada global, las reglas por app y las reglas de URL en un archivo .lockime. No se incluyen el bloqueo principal, el modo avanzado, el idioma ni el elemento de inicio de sesión." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Salva sua fonte padrão global, regras por app e regras de URL em um arquivo .lockime. O bloqueio principal, o modo avançado, o idioma e o item de início de sessão não são incluídos." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сохраняет глобальный источник по умолчанию, правила приложений и правила URL в файл .lockime. Главная блокировка, расширенный режим, язык и автозапуск при входе не включаются." + } + } + } + }, + "Import Configuration…": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导入配置…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "匯入設定…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "構成を読み込む…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Importer la configuration…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konfiguration importieren…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Importar configuración…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Importar configuração…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Импортировать конфигурацию…" + } + } + } + }, + "Import": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导入" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "匯入" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み込む" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Importer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Importieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Importar" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Importar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Импортировать" + } + } + } + }, + "You'll review every change before it's applied. Nothing is modified until you tap Apply.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用之前你可以检查每一项更改。点按“应用”之前不会改动任何配置。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "套用之前你可以檢查每一項變更。在你點按「套用」之前不會修改任何設定。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべての変更は適用前に確認できます。「適用」をタップするまで何も変更されません。" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vous vérifierez chaque modification avant son application. Rien n’est modifié tant que vous n’appuyez pas sur Appliquer." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sie prüfen jede Änderung, bevor sie angewendet wird. Nichts wird geändert, bis Sie auf „Übernehmen“ tippen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Revisarás cada cambio antes de aplicarlo. No se modifica nada hasta que tocas Aplicar." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Você revisará todas as alterações antes de aplicá-las. Nada é modificado até você tocar em Aplicar." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вы просмотрите каждое изменение перед его применением. Ничего не изменится, пока вы не нажмёте «Применить»." + } + } + } + }, + "Backup": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "备份" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "備份" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バックアップ" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sauvegarde" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Backup" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copia de seguridad" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Backup" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Резервная копия" + } + } + } + }, + "Export Configuration": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导出配置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "匯出設定" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "構成を書き出す" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Exporter la configuration" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konfiguration exportieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Exportar configuración" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Exportar configuração" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Экспорт конфигурации" + } + } + } + }, + "Import Configuration": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导入配置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "匯入設定" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "構成を読み込む" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Importer la configuration" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konfiguration importieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Importar configuración" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Importar configuração" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Импорт конфигурации" + } + } + } + }, + "This file isn't a LockIME backup.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此文件不是 LockIME 备份文件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此檔案不是 LockIME 備份。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このファイルは LockIME のバックアップではありません。" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ce fichier n’est pas une sauvegarde LockIME." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Datei ist kein LockIME-Backup." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Este archivo no es una copia de seguridad de LockIME." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Este arquivo não é um backup do LockIME." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Этот файл не является резервной копией LockIME." + } + } + } + }, + "This backup file is damaged and can't be read.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此备份文件已损坏,无法读取。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此備份檔案已損毀,無法讀取。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このバックアップファイルは破損していて読み込めません。" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ce fichier de sauvegarde est endommagé et illisible." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Backup-Datei ist beschädigt und kann nicht gelesen werden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Este archivo de copia de seguridad está dañado y no se puede leer." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Este arquivo de backup está danificado e não pode ser lido." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Этот файл резервной копии повреждён и не может быть прочитан." + } + } + } + }, + "This backup was made by a newer LockIME (%@). Update LockIME, then try again.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此备份由较新版本的 LockIME (%@) 创建。请更新 LockIME 后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此備份是由較新版本的 LockIME(%@)所建立。請先更新 LockIME,再重試。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このバックアップは新しいバージョンの LockIME(%@)で作成されました。LockIME をアップデートしてから、もう一度お試しください。" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette sauvegarde a été créée par une version plus récente de LockIME (%@). Mettez LockIME à jour, puis réessayez." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieses Backup wurde mit einem neueren LockIME (%@) erstellt. Aktualisieren Sie LockIME und versuchen Sie es erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esta copia de seguridad se creó con una versión más reciente de LockIME (%@). Actualiza LockIME y vuelve a intentarlo." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Este backup foi feito por uma versão mais recente do LockIME (%@). Atualize o LockIME e tente novamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Эта резервная копия создана более новой версией LockIME (%@). Обновите LockIME и повторите попытку." + } + } + } + }, + "Rules imported: %lld": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已导入规则:%lld 条" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已匯入規則:%lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み込んだルール:%lld" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Règles importées : %lld" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Importierte Regeln: %lld" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reglas importadas: %lld" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Regras importadas: %lld" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Импортировано правил: %lld" + } + } + } + }, + "Not active until installed: %lld": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需安装后才生效:%lld 条" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安裝後才會生效:%lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インストールするまで無効:%lld" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Inactives jusqu’à l’installation : %lld" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erst nach Installation aktiv: %lld" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Inactivas hasta instalarse: %lld" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Inativas até a instalação: %lld" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не действуют до установки: %lld" + } + } + } + }, + "Enhanced mode is off, so your URL rules aren't active yet. Turn it on for them to take effect.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "增强模式未开启,因此你的网址规则尚未生效。开启后这些规则才会生效。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "增強模式未開啟,因此你的網址規則尚未生效。開啟後規則才會生效。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拡張モードがオフのため、URL ルールはまだ有効になっていません。有効にするにはオンにしてください。" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le mode avancé est désactivé, vos règles d’URL ne sont donc pas encore actives. Activez-le pour qu’elles prennent effet." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der erweiterte Modus ist deaktiviert, daher sind Ihre URL-Regeln noch nicht aktiv. Aktivieren Sie ihn, damit sie wirksam werden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El modo avanzado está desactivado, por lo que tus reglas de URL aún no están activas. Actívalo para que surtan efecto." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O modo avançado está desativado, então suas regras de URL ainda não estão ativas. Ative-o para que entrem em vigor." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Расширенный режим выключен, поэтому ваши правила URL пока не действуют. Включите его, чтобы они вступили в силу." + } + } + } + }, + "New app rules (%lld)": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新增应用规则 (%lld)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增 App 規則(%lld)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいアプリのルール(%lld)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvelles règles par app (%lld)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neue App-Regeln (%lld)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nuevas reglas por app (%lld)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Novas regras por app (%lld)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новые правила приложений (%lld)" + } + } + } + }, + "New URL rules (%lld)": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新增网址规则 (%lld)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增網址規則(%lld)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しい URL ルール(%lld)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvelles règles d’URL (%lld)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neue URL-Regeln (%lld)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nuevas reglas de URL (%lld)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Novas regras de URL (%lld)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новые правила URL (%lld)" + } + } + } } } } diff --git a/Sources/LockIME/UI/Settings/BackupSettingsPane.swift b/Sources/LockIME/UI/Settings/BackupSettingsPane.swift new file mode 100644 index 0000000..5cf45cb --- /dev/null +++ b/Sources/LockIME/UI/Settings/BackupSettingsPane.swift @@ -0,0 +1,172 @@ +import AppKit +import LockIMEKit +import OSLog +import SwiftUI +import UniformTypeIdentifiers + +/// The Backup tab: the single home for exporting and importing the portable +/// configuration. Export writes a `.lockime` file via `NSSavePanel`; import +/// reads one via `NSOpenPanel`, then opens the **one** Review Import sheet — +/// never a chain of alerts. Bad files are reported inline at this entry point, +/// never as a system-localized `error.localizedDescription`. +struct BackupSettingsPane: View { + @Environment(AppState.self) private var state + + @State private var reviewModel: ImportReviewModel? + @State private var importError: BackupReadError? + @State private var exportFailed = false + @State private var receipt: ImportOutcome? + + private static let log = Logger(subsystem: "com.oomol.LockIME", category: "backup") + + var body: some View { + Form { + Section { + Button { + exportConfiguration() + } label: { + Label("Export Configuration…", systemImage: "square.and.arrow.up") + } + if exportFailed { + inlineNote("Couldn't save the backup file.", systemImage: "exclamationmark.triangle", tint: DS.Palette.warning) + } + } header: { + Text("Export") + } footer: { + SectionFooter("Saves your global default source, app rules, and URL rules to a .lockime file. The master lock, enhanced mode, language, and login item aren't included.") + } + + Section { + Button { + importConfiguration() + } label: { + Label("Import Configuration…", systemImage: "square.and.arrow.down") + } + if let importError { + importErrorNote(importError) + } + if let receipt { + receiptNote(receipt) + } + } header: { + Text("Import") + } footer: { + SectionFooter("You'll review every change before it's applied. Nothing is modified until you tap Apply.") + } + } + .formStyle(.grouped) + .navigationTitle(state.loc("Backup")) + .sheet(item: $reviewModel) { model in + ImportReviewSheet(model: model) { outcome in + receipt = outcome + reviewModel = nil + } + // A sheet bridges into its own AppKit window, which doesn't reliably + // inherit the app's in-app language override — re-inject it (and + // rebuild on language change) so the Review screen isn't half-English. + .environment(\.locale, state.locale) + .id(state.localeIdentifier) + } + } + + // MARK: - Export + + private func exportConfiguration() { + exportFailed = false + let panel = NSSavePanel() + panel.title = state.loc("Export Configuration") + panel.prompt = state.loc("Export") + panel.nameFieldStringValue = "LockIME Backup.\(ConfigBackup.fileExtension)" + if let type = UTType(filenameExtension: ConfigBackup.fileExtension) { + panel.allowedContentTypes = [type] + } + panel.isExtensionHidden = false + NSApp.activate(ignoringOtherApps: true) + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + try state.makeBackup().encoded().write(to: url, options: .atomic) + } catch { + // Never surface a system-localized message; log the original, show a + // semantic note instead. + Self.log.error("Backup export failed: \(String(describing: error), privacy: .public)") + exportFailed = true + } + } + + // MARK: - Import + + private func importConfiguration() { + importError = nil + receipt = nil + let panel = NSOpenPanel() + panel.title = state.loc("Import Configuration") + panel.prompt = state.loc("Import") + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + if let type = UTType(filenameExtension: ConfigBackup.fileExtension) { + panel.allowedContentTypes = [type, .json] + } + NSApp.activate(ignoringOtherApps: true) + guard panel.runModal() == .OK, let url = panel.url else { return } + switch state.loadImportPlan(from: url) { + case .success(let plan): + reviewModel = ImportReviewModel(plan: plan) { state.applyImport($0) } + case .failure(let error): + importError = error + } + } + + // MARK: - Inline notes + + @ViewBuilder + private func importErrorNote(_ error: BackupReadError) -> some View { + let icon = "exclamationmark.triangle" + switch error { + case .unreadable: + inlineNote("This file isn't a LockIME backup.", systemImage: icon, tint: DS.Palette.warning) + case .damaged: + inlineNote("This backup file is damaged and can't be read.", systemImage: icon, tint: DS.Palette.warning) + case .incompatibleVersion(let appVersion): + HStack(spacing: DS.Spacing.md) { + Image(systemName: icon).foregroundStyle(DS.Palette.warning) + Text("This backup was made by a newer LockIME (\(appVersion)). Update LockIME, then try again.") + .font(DS.Font.sectionFooter) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + .padding(.vertical, DS.Spacing.xxs) + } + } + + private func receiptNote(_ outcome: ImportOutcome) -> some View { + HStack(spacing: DS.Spacing.md) { + // Neutral, not green: DESIGN.md confines success green to the update + // window; Settings content (e.g. Permissions "granted") stays secondary. + Image(systemName: "checkmark.circle") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: DS.Spacing.xxs) { + Text("Rules imported: \(outcome.imported)") + .font(DS.Font.sectionFooter) + if outcome.inactive > 0 { + Text("Not active until installed: \(outcome.inactive)") + .font(DS.Font.rowSubtitle) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 0) + } + .padding(.vertical, DS.Spacing.xxs) + } + + private func inlineNote(_ message: LocalizedStringKey, systemImage: String, tint: Color) -> some View { + HStack(spacing: DS.Spacing.md) { + Image(systemName: systemImage).foregroundStyle(tint) + Text(message) + .font(DS.Font.sectionFooter) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + .padding(.vertical, DS.Spacing.xxs) + } +} diff --git a/Sources/LockIME/UI/Settings/ImportReviewSheet.swift b/Sources/LockIME/UI/Settings/ImportReviewSheet.swift new file mode 100644 index 0000000..48e887a --- /dev/null +++ b/Sources/LockIME/UI/Settings/ImportReviewSheet.swift @@ -0,0 +1,464 @@ +import AppKit +import LockIMEKit +import SwiftUI + +/// View model for the single Review Import screen. Wraps the pure `ImportPlan` +/// and adds the cosmetic "(default)" tracking — which rows still follow a +/// section-header default vs. ones the user overrode individually. All choices +/// stay in memory; nothing is persisted until `applyImport()`. +@MainActor +@Observable +final class ImportReviewModel: Identifiable { + let id = UUID() + var plan: ImportPlan + + private(set) var conflictOverrides: Set = [] + private(set) var missingOverrides: Set = [] + + private let apply: (ImportPlan) -> ImportOutcome + + init(plan: ImportPlan, apply: @escaping (ImportPlan) -> ImportOutcome) { + self.plan = plan + self.apply = apply + } + + // MARK: Derived (pure functions of the current choices) + + var mode: ImportMode { plan.mode } + var newItems: [ImportItem] { plan.newItems } + /// New App rules (and the global default, which conceptually belongs with the + /// app side) — shown separately from URL rules per the New-rules split. + var newAppItems: [ImportItem] { + plan.newItems.filter { if case .url = $0.subject { return false }; return true } + } + /// New URL rules. + var newURLItems: [ImportItem] { + plan.newItems.filter { if case .url = $0.subject { return true }; return false } + } + var conflictItems: [ImportItem] { plan.conflictItems } + var missingItems: [ImportItem] { plan.missingItems } + var summary: ImportSummary { plan.summary() } + /// Local rules Replace would remove (the visible destruction scope). + var replaceRemovesCount: Int { plan.localOnlyAppRuleCount + plan.localOnlyURLRuleCount } + + func displayName(for id: InputSourceID) -> String { plan.displayName(for: id) } + func item(_ itemID: String) -> ImportItem? { plan.items.first { $0.id == itemID } } + /// Whether the item's chosen binding points at a source that isn't installed + /// (drives the inline warning on a merge conflict that picked the file). + func isEffectiveMissing(_ item: ImportItem) -> Bool { plan.effectiveFileSourceIsMissing(item) } + + // MARK: Mode + + func setMode(_ newMode: ImportMode) { plan.mode = newMode } + + // MARK: New-rule inclusion + + func setInclude(_ itemID: String, _ on: Bool) { mutate(itemID) { $0.include = on } } + /// Toggle inclusion for a specific set of new items (one section's rows). + func setAllInclude(_ on: Bool, ids: [String]) { + let set = Set(ids) + for index in plan.items.indices where set.contains(plan.items[index].id) { + plan.items[index].include = on + } + } + + // MARK: Conflict resolution (header default + per-row override) + + func resolution(_ itemID: String) -> ConflictResolution { item(itemID)?.resolution ?? .keepLocal } + func isConflictDefault(_ itemID: String) -> Bool { !conflictOverrides.contains(itemID) } + + func setResolution(_ itemID: String, _ resolution: ConflictResolution) { + mutate(itemID) { $0.resolution = resolution } + conflictOverrides.insert(itemID) + } + + func setAllConflicts(_ resolution: ConflictResolution) { + for index in plan.items.indices { plan.items[index].resolution = resolution } + conflictOverrides.removeAll() + } + + // MARK: Missing-source disposition (header default + per-row override) + + func disposition(_ itemID: String) -> MissingSourceDisposition { item(itemID)?.missingDisposition ?? .keep } + func isMissingDefault(_ itemID: String) -> Bool { !missingOverrides.contains(itemID) } + + func setDisposition(_ itemID: String, _ disposition: MissingSourceDisposition) { + mutate(itemID) { $0.missingDisposition = disposition } + missingOverrides.insert(itemID) + } + + func setAllMissing(_ disposition: MissingSourceDisposition) { + for index in plan.items.indices { plan.items[index].missingDisposition = disposition } + missingOverrides.removeAll() + } + + // MARK: Commit + + func applyImport() -> ImportOutcome { apply(plan) } + + // MARK: Private + + private func mutate(_ itemID: String, _ change: (inout ImportItem) -> Void) { + guard let index = plan.items.firstIndex(where: { $0.id == itemID }) else { return } + change(&plan.items[index]) + } +} + +/// The **single** Review Import surface. Everything an import needs — Merge vs. +/// Replace, same-key conflicts, missing input sources, and the one Apply — folds +/// into this one sheet. No `NSAlert`, no `confirmationDialog`, no cascading +/// sheets: changing any control only recomputes this screen, and only Apply +/// mutates state. Cancel/Esc discards everything. +struct ImportReviewSheet: View { + @Environment(\.dismiss) private var dismiss + @Bindable var model: ImportReviewModel + /// Called with the receipt after Apply commits. + let onApplied: (ImportOutcome) -> Void + + var body: some View { + VStack(spacing: 0) { + header + Divider() + Form { + modeSection + if !model.newAppItems.isEmpty { newAppSection } + if !model.newURLItems.isEmpty { newURLSection } + if model.mode == .merge, !model.conflictItems.isEmpty { conflictSection } + if !model.missingItems.isEmpty { missingSection } + } + .formStyle(.grouped) + Divider() + footer + } + .frame(width: 600, height: 620) + } + + // MARK: Header + + private var header: some View { + VStack(alignment: .leading, spacing: DS.Spacing.xs) { + Text("Review Import") + .font(DS.Font.windowTitle) + Text("Nothing is changed until you apply.") + .font(DS.Font.subtitle) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(DS.Spacing.xl) + } + + // MARK: Mode + + private var modeSection: some View { + Section { + Picker("", selection: Binding(get: { model.mode }, set: { model.setMode($0) })) { + Text("Merge").tag(ImportMode.merge) + Text("Replace").tag(ImportMode.replace) + } + .pickerStyle(.segmented) + .labelsHidden() + + Text(model.mode == .merge + ? "Keeps your current rules and adds the file's. You decide each conflict below." + : "Makes your rules match the file. The file wins every conflict.") + .font(DS.Font.sectionFooter) + .foregroundStyle(.secondary) + + if model.mode == .replace, model.replaceRemovesCount > 0 { + Label { + Text("Replace removes \(model.replaceRemovesCount) of your rules that aren't in the file.") + } icon: { + Image(systemName: "trash") + } + .font(DS.Font.sectionFooter) + .foregroundStyle(DS.Palette.warning) + } + } header: { + Text("How to import") + } + } + + // MARK: New rules (App and URL split into their own sections) + + private var newAppSection: some View { + Section { + ForEach(model.newAppItems) { item in newRow(item) } + } header: { + newHeader(Text("New app rules (\(model.newAppItems.count))"), items: model.newAppItems) + } + } + + private var newURLSection: some View { + Section { + ForEach(model.newURLItems) { item in newRow(item) } + } header: { + newHeader(Text("New URL rules (\(model.newURLItems.count))"), items: model.newURLItems) + } + } + + private func newHeader(_ title: Text, items: [ImportItem]) -> some View { + HStack { + title + Spacer() + let ids = items.map(\.id) + Button("Select All") { model.setAllInclude(true, ids: ids) } + Button("Select None") { model.setAllInclude(false, ids: ids) } + } + .buttonStyle(.link) + .font(DS.Font.sectionFooter) + } + + private func newRow(_ item: ImportItem) -> some View { + HStack(spacing: DS.Spacing.lg) { + Toggle("", isOn: Binding(get: { item.include }, set: { model.setInclude(item.id, $0) })) + .labelsHidden() + subjectLabel(item) + Spacer(minLength: DS.Spacing.md) + Image(systemName: "arrow.right") + .font(.caption2) + .foregroundStyle(.tertiary) + fileBindingText(item) + .foregroundStyle(.secondary) + } + .padding(.vertical, DS.Spacing.xxs) + } + + // MARK: Conflicts + + private var conflictSection: some View { + Section { + ForEach(model.conflictItems) { item in + conflictRow(item) + } + } header: { + HStack { + Text("Conflicts (\(model.conflictItems.count))") + Spacer() + Picker("", selection: Binding(get: { model.allConflictHeader }, set: { model.setAllConflicts($0) })) { + Text("Keep Local").tag(ConflictResolution.keepLocal) + Text("Use File").tag(ConflictResolution.useFile) + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + } + .font(DS.Font.sectionFooter) + } footer: { + SectionFooter("Your bindings are kept unless you choose the file's.") + } + } + + private func conflictRow(_ item: ImportItem) -> some View { + HStack(spacing: DS.Spacing.lg) { + VStack(alignment: .leading, spacing: DS.Spacing.xxs) { + subjectLabel(item) + bindingComparison(item) + if model.isEffectiveMissing(item) { + HStack(spacing: DS.Spacing.xs) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(DS.Palette.warning) + Text("Input source not installed") + } + .font(DS.Font.rowSubtitle) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: DS.Spacing.md) + VStack(alignment: .trailing, spacing: DS.Spacing.xxs) { + Picker("", selection: Binding( + get: { model.resolution(item.id) }, + set: { model.setResolution(item.id, $0) } + )) { + Text("Keep Local").tag(ConflictResolution.keepLocal) + Text("Use File").tag(ConflictResolution.useFile) + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + if model.isConflictDefault(item.id) { + Text("(default)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + .padding(.vertical, DS.Spacing.xxs) + } + + private func bindingComparison(_ item: ImportItem) -> some View { + HStack(spacing: DS.Spacing.xs) { + Text("Local:").foregroundStyle(.secondary) + localBindingText(item) + Text(verbatim: "·").foregroundStyle(.tertiary) + Text("File:").foregroundStyle(.secondary) + fileBindingText(item) + } + .font(DS.Font.rowSubtitle) + } + + // MARK: Missing input sources + + private var missingSection: some View { + Section { + ForEach(model.missingItems) { item in + missingRow(item) + } + } header: { + HStack { + Text("Input source not installed (\(model.missingItems.count))") + Spacer() + Picker("", selection: Binding(get: { model.allMissingHeader }, set: { model.setAllMissing($0) })) { + Text("Keep").tag(MissingSourceDisposition.keep) + Text("Remove All").tag(MissingSourceDisposition.remove) + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + } + .font(DS.Font.sectionFooter) + } footer: { + SectionFooter("Rules aren't lost — they resume automatically once you install the matching input source.") + } + } + + private func missingRow(_ item: ImportItem) -> some View { + HStack(spacing: DS.Spacing.lg) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(DS.Palette.warning) + VStack(alignment: .leading, spacing: DS.Spacing.xxs) { + subjectLabel(item) + .foregroundStyle(.secondary) + HStack(spacing: DS.Spacing.xs) { + fileBindingText(item) + Text(verbatim: "·").foregroundStyle(.tertiary) + Text("Input source not installed") + } + .font(DS.Font.rowSubtitle) + .foregroundStyle(.secondary) + } + Spacer(minLength: DS.Spacing.md) + VStack(alignment: .trailing, spacing: DS.Spacing.xxs) { + Picker("", selection: Binding( + get: { model.disposition(item.id) }, + set: { model.setDisposition(item.id, $0) } + )) { + Text("Keep").tag(MissingSourceDisposition.keep) + Text("Remove").tag(MissingSourceDisposition.remove) + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + if model.isMissingDefault(item.id) { + Text("(default)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + .padding(.vertical, DS.Spacing.xxs) + } + + // MARK: Footer + + private var footer: some View { + HStack(spacing: DS.Spacing.lg) { + summaryView + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Apply") { + let outcome = model.applyImport() + onApplied(outcome) + dismiss() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(!model.summary.hasEffect) + } + .padding(DS.Spacing.xl) + } + + private var summaryView: some View { + let summary = model.summary + return HStack(spacing: DS.Spacing.md) { + summaryChip("Added", summary.added) + summaryChip("Updated", summary.updated) + summaryChip("Kept", summary.kept) + summaryChip("Removed", summary.removed) + } + .font(DS.Font.rowSubtitle) + } + + private func summaryChip(_ label: LocalizedStringKey, _ count: Int) -> some View { + HStack(spacing: DS.Spacing.xs) { + Text(label).foregroundStyle(.secondary) + Text(count.formatted()) + .monospacedDigit() + .foregroundStyle(count > 0 ? .primary : .tertiary) + } + } + + // MARK: Shared row pieces + + @ViewBuilder + private func subjectLabel(_ item: ImportItem) -> some View { + switch item.subject { + case .globalDefault: + Label { + Text("Global default") + } icon: { + Image(systemName: "keyboard") + } + case .app(let bundleID): + AppRowLabel(bundleID: bundleID) + case .url(let host): + Label { + Text(verbatim: host) + } icon: { + Image(systemName: "globe").foregroundStyle(.secondary) + } + } + } + + /// The file-side binding as composable `Text`: a source name (a verbatim + /// proper noun) or the localized app-rule mode word when no source is pinned. + /// Returning `Text` keeps it usable inside `HStack`s and recolorable, while + /// still resolving catalog keys against the injected `\.locale`. + private func fileBindingText(_ item: ImportItem) -> Text { + if case .app = item.subject, let mode = item.fileMode, mode != .locked { + return Text(modeKey(mode)) + } + if let source = item.fileSource { return Text(verbatim: model.displayName(for: source)) } + return Text("Default") + } + + private func localBindingText(_ item: ImportItem) -> Text { + if case .app = item.subject, let mode = item.localMode, mode != .locked { + return Text(modeKey(mode)) + } + if let source = item.localSource { return Text(verbatim: model.displayName(for: source)) } + return Text("Default") + } + + private func modeKey(_ mode: AppRuleMode) -> LocalizedStringKey { + switch mode { + case .locked: "Lock to" + case .ignored: "Ignore" + case .useDefault: "Use default" + } + } +} + +private extension ImportReviewModel { + /// The conflict section header's segmented value: the common resolution when + /// all conflicts agree, defaulting to keep-local otherwise. + var allConflictHeader: ConflictResolution { + conflictItems.allSatisfy { $0.resolution == .useFile } && !conflictItems.isEmpty ? .useFile : .keepLocal + } + + /// The missing section header's segmented value: remove only when every + /// missing row is set to remove. + var allMissingHeader: MissingSourceDisposition { + missingItems.allSatisfy { $0.missingDisposition == .remove } && !missingItems.isEmpty ? .remove : .keep + } +} diff --git a/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift b/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift index c95b085..2d7151e 100644 --- a/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift +++ b/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift @@ -25,6 +25,20 @@ struct URLRulesSettingsPane: View { if !state.accessibilityGranted { AccessibilityRequiredNote("Enhanced mode requires Accessibility") } + + // After an import, URL rules can exist while enhanced mode is + // still off (import never flips per-device runtime state) — a + // light, one-line hint, no prompt or multi-step guidance. + if !state.config.enhancedModeEnabled, !state.config.urlRules.isEmpty { + HStack(spacing: DS.Spacing.md) { + Image(systemName: "info.circle").foregroundStyle(.secondary) + Text("Enhanced mode is off, so your URL rules aren't active yet. Turn it on for them to take effect.") + .font(DS.Font.sectionFooter) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + .padding(.vertical, DS.Spacing.xxs) + } } header: { Text("Enhanced mode") } footer: { @@ -32,16 +46,17 @@ struct URLRulesSettingsPane: View { } Section { - if state.config.enhancedModeEnabled { - if state.config.urlRules.isEmpty { - emptyState - } else { - ForEach(state.config.urlRules) { rule in - URLRuleRow(rule: rule) - .transition(.move(edge: .top).combined(with: .opacity)) - } + // Rules stay visible — and editable/removable — even when + // enhanced mode is off (e.g. right after an import), just shown + // dimmed since they aren't active yet. Only *adding* needs the + // mode (and its Accessibility permission) on. + if !state.config.urlRules.isEmpty { + ForEach(state.config.urlRules) { rule in + URLRuleRow(rule: rule) + .transition(.move(edge: .top).combined(with: .opacity)) } - addRow + } else if state.config.enhancedModeEnabled { + emptyState } else { HStack(spacing: DS.Spacing.md) { Image(systemName: "lock") @@ -51,6 +66,9 @@ struct URLRulesSettingsPane: View { } .padding(.vertical, DS.Spacing.xxs) } + if state.config.enhancedModeEnabled { + addRow + } } header: { Text("URL rules") } footer: { @@ -112,10 +130,14 @@ private struct URLRuleRow: View { let rule: URLRule var body: some View { - HStack(spacing: DS.Spacing.lg) { + // Dim the row when enhanced mode is off — the rule exists but isn't + // active yet — while keeping its controls usable (modify / remove). + let active = state.config.enhancedModeEnabled + return HStack(spacing: DS.Spacing.lg) { Image(systemName: "globe") .foregroundStyle(.secondary) Text(rule.hostPattern) + .foregroundStyle(active ? .primary : .secondary) Spacer(minLength: DS.Spacing.md) Picker("", selection: sourceBinding) { ForEach(state.availableSources) { source in diff --git a/Sources/LockIME/UI/SettingsRootView.swift b/Sources/LockIME/UI/SettingsRootView.swift index 005d8bc..7e3b6aa 100644 --- a/Sources/LockIME/UI/SettingsRootView.swift +++ b/Sources/LockIME/UI/SettingsRootView.swift @@ -4,7 +4,7 @@ import SwiftUI /// The Settings window's tabs. A stable identity lets one pane route the user to /// another — App Rules / URL Rules point at General's single Accessibility grant. enum SettingsTab: Hashable { - case general, appRules, urlRules, shortcuts, permissions, updates, log + case general, appRules, urlRules, shortcuts, permissions, updates, log, backup } /// Root of the Settings window — a standard multi-pane macOS settings TabView, @@ -62,6 +62,9 @@ struct SettingsRootView: View { Tab("Log", systemImage: "list.bullet.rectangle", value: SettingsTab.log) { ActivationLogPane() } + Tab("Backup", systemImage: "arrow.up.arrow.down.square", value: SettingsTab.backup) { + BackupSettingsPane() + } } } @@ -88,6 +91,9 @@ struct SettingsRootView: View { ActivationLogPane() .tabItem { Label("Log", systemImage: "list.bullet.rectangle") } .tag(SettingsTab.log) + BackupSettingsPane() + .tabItem { Label("Backup", systemImage: "arrow.up.arrow.down.square") } + .tag(SettingsTab.backup) } } } diff --git a/Sources/LockIMEKit/Backup/ConfigBackup.swift b/Sources/LockIMEKit/Backup/ConfigBackup.swift new file mode 100644 index 0000000..c647069 --- /dev/null +++ b/Sources/LockIMEKit/Backup/ConfigBackup.swift @@ -0,0 +1,205 @@ +import Foundation + +/// A URL rule as stored in a backup file. Unlike `URLRule` it carries no UUID: +/// the runtime identity is per-device and not portable, so backups key URL rules +/// solely by their `hostPattern` (the same key the import diff matches on). +public struct BackupURLRule: Codable, Equatable, Sendable { + public var hostPattern: String + public var lockedSourceID: InputSourceID + + public init(hostPattern: String, lockedSourceID: InputSourceID) { + self.hostPattern = hostPattern + self.lockedSourceID = lockedSourceID + } +} + +/// The portable part of a `LockConfiguration` — the "rules and binding intent" +/// a backup carries between machines: the global default source, per-app rules, +/// and per-URL rules. Per-device *runtime* state (the master lock, enhanced +/// mode, language preference, the login item) is deliberately **not** here, so +/// importing never flips those on someone else's machine. +/// +/// `sourceNames` is a display-name catalog (input-source identifier → its name +/// at export time) so a target machine that is missing an input source can +/// still show a human-readable label instead of a bare identifier. +public struct BackupPayload: Codable, Equatable, Sendable { + public var defaultSourceID: InputSourceID? + public var appRules: [AppRule] + public var urlRules: [BackupURLRule] + public var sourceNames: [String: String] + + public init( + defaultSourceID: InputSourceID? = nil, + appRules: [AppRule] = [], + urlRules: [BackupURLRule] = [], + sourceNames: [String: String] = [:] + ) { + self.defaultSourceID = defaultSourceID + self.appRules = appRules + self.urlRules = urlRules + self.sourceNames = sourceNames + } + + // Forward/backward-compatible decoding: a newer file may add fields (ignored + // by `Decoder` automatically) and an older/partial file may omit some — every + // key falls back to an empty default so reading stays lenient. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + defaultSourceID = try container.decodeIfPresent(InputSourceID.self, forKey: .defaultSourceID) + appRules = try container.decodeIfPresent([AppRule].self, forKey: .appRules) ?? [] + urlRules = try container.decodeIfPresent([BackupURLRule].self, forKey: .urlRules) ?? [] + sourceNames = try container.decodeIfPresent([String: String].self, forKey: .sourceNames) ?? [:] + } +} + +/// A versioned, file-level configuration backup (`.lockime` JSON). +/// +/// The envelope is built to evolve safely: +/// - `format` is a fixed identifier — a file without it isn't one of ours. +/// - `schemaVersion` is the integer schema this file was written against. +/// - `minReader` is the **lowest** reader capability that can safely read it; a +/// file whose `minReader` exceeds this build's `readerVersion` is cleanly +/// rejected ("please update LockIME"), shown via the human `appVersion` string +/// — never the raw integer. +/// - `appVersion` is the human-readable version that wrote the file. +/// +/// Evolution rule: only ever add *optional* fields, so older readers keep +/// loading newer files (and bump `minReader` only on a genuinely breaking +/// change). +public struct ConfigBackup: Codable, Equatable, Sendable { + public var format: String + public var schemaVersion: Int + public var minReader: Int + public var appVersion: String + public var payload: BackupPayload + + public init( + format: String = ConfigBackup.formatIdentifier, + schemaVersion: Int = ConfigBackup.writerSchemaVersion, + minReader: Int = ConfigBackup.writerMinReader, + appVersion: String, + payload: BackupPayload + ) { + self.format = format + self.schemaVersion = schemaVersion + self.minReader = minReader + self.appVersion = appVersion + self.payload = payload + } + + // Lenient envelope decoding: tolerate a compatible file that omits an + // envelope field (defaulting it) rather than rejecting it as damaged. Only + // `payload` is genuinely required; `read(_:)` has already verified `format`. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + format = try container.decodeIfPresent(String.self, forKey: .format) ?? ConfigBackup.formatIdentifier + schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? ConfigBackup.writerSchemaVersion + minReader = try container.decodeIfPresent(Int.self, forKey: .minReader) ?? ConfigBackup.writerMinReader + appVersion = try container.decodeIfPresent(String.self, forKey: .appVersion) ?? "" + payload = try container.decode(BackupPayload.self, forKey: .payload) + } +} + +public extension ConfigBackup { + /// Marks a file as a LockIME backup; a file lacking it isn't ours. + static let formatIdentifier = "com.oomol.LockIME.backup" + /// The schema version this build writes. + static let writerSchemaVersion = 1 + /// The `minReader` this build stamps into files it writes — i.e. the lowest + /// reader capability that can safely read today's files. + static let writerMinReader = 1 + /// This build's reading capability. A file is rejected as too new when its + /// `minReader` exceeds this value. + static let readerVersion = 1 + + /// The conventional file extension for exported backups. + static let fileExtension = "lockime" + + /// Build a backup envelope from a live configuration, dropping the per-device + /// runtime state and capturing a display-name catalog for every referenced + /// input source whose name is known. + static func make( + from config: LockConfiguration, + appVersion: String, + sourceNames: [InputSourceID: String] + ) -> ConfigBackup { + // Only locked app rules pin a source; ignore/use-default modes don't. + let appRuleSources = config.appRules.compactMap { rule in + rule.mode == .locked ? rule.lockedSourceID : nil + } + let referenced: [InputSourceID] = + ([config.defaultSourceID].compactMap { $0 }) + + appRuleSources + + config.urlRules.map(\.lockedSourceID) + + var catalog: [String: String] = [:] + for id in referenced where catalog[id.rawValue] == nil { + if let name = sourceNames[id] { catalog[id.rawValue] = name } + } + + let payload = BackupPayload( + defaultSourceID: config.defaultSourceID, + appRules: config.appRules, + urlRules: config.urlRules.map { BackupURLRule(hostPattern: $0.hostPattern, lockedSourceID: $0.lockedSourceID) }, + sourceNames: catalog + ) + return ConfigBackup(appVersion: appVersion, payload: payload) + } + + /// Encode to pretty-printed, key-sorted JSON so the file is human-readable. + func encoded() throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + return try encoder.encode(self) + } + + /// Parse and version-gate a backup file's bytes. + /// + /// The gate runs *before* fully decoding the payload, so a file written by a + /// newer LockIME (whose payload this build may not understand) still reports + /// `incompatibleVersion` rather than a confusing `damaged`. The original + /// parse error is never surfaced to the user — callers map the returned + /// category to a catalog key (see the i18n rules), the same shape as + /// `UpdateFailure`. + static func read(_ data: Data) -> Result { + // 1) Must be a JSON object. + guard let object = try? JSONSerialization.jsonObject(with: data), + let top = object as? [String: Any] + else { + return .failure(.unreadable) + } + // 2) Must carry our format identifier. + guard let format = top["format"] as? String, format == formatIdentifier else { + return .failure(.unreadable) + } + // 3) Version gate, reading only the envelope fields. A missing/invalid + // `minReader` is treated as the writer minimum (lenient — these are + // our own files), so it never spuriously rejects. + let minReader = (top["minReader"] as? Int) ?? writerMinReader + if minReader > readerVersion { + let appVersion = (top["appVersion"] as? String) ?? "" + return .failure(.incompatibleVersion(appVersion: appVersion)) + } + // 4) Compatible: decode in full (lenient payload). A structural failure + // here means the file is damaged. + guard let backup = try? JSONDecoder().decode(ConfigBackup.self, from: data) else { + return .failure(.damaged) + } + return .success(backup) + } +} + +/// A semantic category of failure when reading a backup file, mirroring +/// `UpdateFailure`: surfaces carry this value and resolve a catalog key at +/// render time, so the message follows the in-app language override instead of +/// leaking a system-localized `error.localizedDescription`. +public enum BackupReadError: Error, Equatable, Sendable { + /// Not a JSON file, or not a LockIME backup at all (wrong/absent format). + case unreadable + /// A LockIME backup whose contents are structurally broken. + case damaged + /// Written by a newer LockIME than this build can read. Carries the + /// human-readable `appVersion` for the "please update" message — never the + /// raw schema integer. + case incompatibleVersion(appVersion: String) +} diff --git a/Sources/LockIMEKit/Backup/ImportPlan.swift b/Sources/LockIMEKit/Backup/ImportPlan.swift new file mode 100644 index 0000000..25b3e90 --- /dev/null +++ b/Sources/LockIMEKit/Backup/ImportPlan.swift @@ -0,0 +1,434 @@ +import Foundation + +/// How an imported backup combines with the local configuration. +public enum ImportMode: String, Sendable, CaseIterable, Identifiable { + /// Non-destructive: keep local rules, add the file's new ones, resolve + /// conflicts per-row (defaulting to the local binding). + case merge + /// The file's rules win: local-only rules are dropped and every conflict + /// takes the file's binding. + case replace + + public var id: String { rawValue } +} + +/// Per-conflict resolution. The vocabulary is LockIME's own ("keep local / use +/// file"), never "ours/theirs". +public enum ConflictResolution: String, Sendable, Equatable { + case keepLocal + case useFile +} + +/// What to do with a rule whose target input source isn't installed on this +/// machine. "Not installed" is a *derived* state — there is no persisted flag — +/// so `keep` simply leaves the rule (it naturally stays inactive until the +/// source is installed) and `remove` deletes it. A missing source is **never** +/// silently substituted with another. +public enum MissingSourceDisposition: String, Sendable, Equatable { + case keep + case remove +} + +/// One reviewable binding from an imported backup — the global default, an app +/// rule, or a URL rule — unified so the Review screen and the resolver treat +/// them identically. +public struct ImportItem: Identifiable, Sendable, Equatable { + public enum Subject: Sendable, Equatable { + case globalDefault + case app(bundleID: String) + case url(hostPattern: String) + } + + public enum Status: Sendable, Equatable { + /// Present in the file, absent locally. + case new + /// Present in both, with a different binding. + case conflict + /// Present in both with an identical binding. Invisible in Merge (a + /// no-op), but in Replace the file's full rule set wins, so an + /// unchanged rule is re-asserted (and can surface as missing/removable). + case unchanged + } + + public let id: String + public let subject: Subject + public let status: Status + + /// File-side app-rule mode (`nil` for the global default and URL rules). + public let fileMode: AppRuleMode? + /// The file-side effective locked source (`nil` when the file binding pins + /// no source — e.g. an app rule in `.ignored`/`.useDefault`). + public let fileSource: InputSourceID? + /// Local-side mode/source, populated only for `.conflict` items. + public let localMode: AppRuleMode? + public let localSource: InputSourceID? + + // MARK: user choices + + /// Include this `.new` binding in the import (ignored for `.conflict`). + public var include: Bool + /// How to resolve a `.conflict` (ignored for `.new`). + public var resolution: ConflictResolution + /// What to do when the *effective* binding's source isn't installed. + public var missingDisposition: MissingSourceDisposition + + init( + id: String, + subject: Subject, + status: Status, + fileMode: AppRuleMode?, + fileSource: InputSourceID?, + localMode: AppRuleMode?, + localSource: InputSourceID?, + include: Bool, + resolution: ConflictResolution, + missingDisposition: MissingSourceDisposition + ) { + self.id = id + self.subject = subject + self.status = status + self.fileMode = fileMode + self.fileSource = fileSource + self.localMode = localMode + self.localSource = localSource + self.include = include + self.resolution = resolution + self.missingDisposition = missingDisposition + } +} + +/// A running tally of an import's effect, recomputed live as the user edits the +/// plan. `added`/`updated`/`removed` are derived by diffing the base config +/// against the resolved one; `kept` counts conflicts left at the local binding. +public struct ImportSummary: Equatable, Sendable { + public var added: Int + public var updated: Int + public var kept: Int + public var removed: Int + /// Imported (added or rebound) rules whose source isn't installed — kept but + /// inactive until it is. Scoped to what the import changed, never a + /// pre-existing local rule that was merely carried over. + public var inactive: Int + + /// Whether applying would change anything. A plan that only keeps local + /// bindings (or imports nothing) is a no-op, so Apply is disabled. + public var hasEffect: Bool { added > 0 || updated > 0 || removed > 0 } +} + +/// The result of applying an import, for the post-Apply receipt. +public struct ImportOutcome: Equatable, Sendable { + /// Rules added or rebound by the import. + public var imported: Int + /// Imported rules that won't take effect until their input source is + /// installed. + public var inactive: Int +} + +/// An in-memory, editable staging plan for importing a backup. Building it and +/// resolving it are pure — **nothing is persisted until a caller takes +/// `resolvedConfiguration()` and saves it**. Toggling any choice (mode, +/// include, resolution, disposition) only mutates this value. +public struct ImportPlan: Sendable, Equatable { + public var mode: ImportMode + public var items: [ImportItem] + + /// The local configuration the import merges into / preserves runtime flags + /// from. Held so `resolvedConfiguration()` can keep `isEnabled` and + /// `enhancedModeEnabled` untouched. + public let baseConfig: LockConfiguration + /// Local app rules absent from the file — removed by Replace. + public let localOnlyAppRuleCount: Int + /// Local URL rules absent from the file — removed by Replace. + public let localOnlyURLRuleCount: Int + public let installedSourceIDs: Set + /// Display names for every referenced source: the importing machine's own + /// names take precedence, with the file's catalog filling in those it lacks + /// (so a missing source still shows a human label). + public let sourceNames: [InputSourceID: String] + + public init( + current: LockConfiguration, + backup: ConfigBackup, + installedSources: [InputSource], + mode: ImportMode = .merge + ) { + self.mode = mode + self.baseConfig = current + self.installedSourceIDs = Set(installedSources.map(\.id)) + + var names: [InputSourceID: String] = [:] + for (raw, name) in backup.payload.sourceNames { + names[InputSourceID(raw)] = name + } + for source in installedSources { + names[source.id] = source.localizedName + } + self.sourceNames = names + + var items: [ImportItem] = [] + + // Global default. + if let fileDefault = backup.payload.defaultSourceID { + let status: ImportItem.Status + if current.defaultSourceID == nil { + status = .new + } else if current.defaultSourceID == fileDefault { + status = .unchanged + } else { + status = .conflict + } + items.append(ImportItem( + id: "default", subject: .globalDefault, status: status, + fileMode: nil, fileSource: fileDefault, + localMode: nil, localSource: current.defaultSourceID, + include: true, resolution: .keepLocal, missingDisposition: .keep + )) + } + + // App rules (keyed by bundle identifier). + let localByBundle = Dictionary( + current.appRules.map { ($0.bundleID, $0) }, uniquingKeysWith: { first, _ in first } + ) + for rule in backup.payload.appRules { + let fileSource = rule.mode == .locked ? rule.lockedSourceID : nil + if let local = localByBundle[rule.bundleID] { + let localSource = local.mode == .locked ? local.lockedSourceID : nil + let status: ImportItem.Status = + (local.mode == rule.mode && localSource == fileSource) ? .unchanged : .conflict + items.append(ImportItem( + id: "app:\(rule.bundleID)", subject: .app(bundleID: rule.bundleID), status: status, + fileMode: rule.mode, fileSource: fileSource, + localMode: local.mode, localSource: localSource, + include: true, resolution: .keepLocal, missingDisposition: .keep + )) + } else { + items.append(ImportItem( + id: "app:\(rule.bundleID)", subject: .app(bundleID: rule.bundleID), status: .new, + fileMode: rule.mode, fileSource: fileSource, localMode: nil, localSource: nil, + include: true, resolution: .keepLocal, missingDisposition: .keep + )) + } + } + + // URL rules (keyed by host pattern). + let localByHost = Dictionary( + current.urlRules.map { ($0.hostPattern, $0) }, uniquingKeysWith: { first, _ in first } + ) + for rule in backup.payload.urlRules { + if let local = localByHost[rule.hostPattern] { + let status: ImportItem.Status = + local.lockedSourceID == rule.lockedSourceID ? .unchanged : .conflict + items.append(ImportItem( + id: "url:\(rule.hostPattern)", subject: .url(hostPattern: rule.hostPattern), status: status, + fileMode: nil, fileSource: rule.lockedSourceID, + localMode: nil, localSource: local.lockedSourceID, + include: true, resolution: .keepLocal, missingDisposition: .keep + )) + } else { + items.append(ImportItem( + id: "url:\(rule.hostPattern)", subject: .url(hostPattern: rule.hostPattern), status: .new, + fileMode: nil, fileSource: rule.lockedSourceID, localMode: nil, localSource: nil, + include: true, resolution: .keepLocal, missingDisposition: .keep + )) + } + } + + self.items = items + + let fileBundleIDs = Set(backup.payload.appRules.map(\.bundleID)) + let fileHosts = Set(backup.payload.urlRules.map(\.hostPattern)) + self.localOnlyAppRuleCount = current.appRules.filter { !fileBundleIDs.contains($0.bundleID) }.count + self.localOnlyURLRuleCount = current.urlRules.filter { !fileHosts.contains($0.hostPattern) }.count + } + + // MARK: - Derived sections (pure functions of the current choices) + + /// New bindings (the file has them, the local config doesn't), **excluding** + /// any whose effective source is missing — for a brand-new rule the missing + /// section's keep/remove already subsumes an include toggle, so it lives + /// there instead and never appears twice. + public var newItems: [ImportItem] { + items.filter { $0.status == .new && !effectiveFileSourceIsMissing($0) } + } + + /// Conflicting bindings (present in both, different). Shown only in Merge + /// (Replace lets the file win silently). A conflict whose chosen binding + /// targets a missing source stays here — its keep-local escape hatch must + /// remain reachable — and the row carries an inline "not installed" warning + /// instead of moving to the missing section. + public var conflictItems: [ImportItem] { + items.filter { $0.status == .conflict } + } + + /// Items whose *effective* (file-sourced) binding targets a source that + /// isn't installed, surfaced for a keep/remove decision. Merge conflicts are + /// excluded (they keep their keep-local/use-file control in the conflict + /// section); everything else — new rules, and in Replace every file-won + /// binding — appears here. + public var missingItems: [ImportItem] { + items.filter { effectiveFileSourceIsMissing($0) && !($0.status == .conflict && mode == .merge) } + } + + /// Whether `item`'s binding, under the current mode and choices, comes from + /// the file (vs keeping the local one or being excluded). + public func usesFileBinding(_ item: ImportItem) -> Bool { + switch item.status { + case .new: + return item.include + case .conflict: + return mode == .replace || item.resolution == .useFile + case .unchanged: + // No-op in Merge (local already equals file); re-asserted in Replace + // where the file's full rule set wins. + return mode == .replace + } + } + + /// Whether `item`'s effective file binding pins a source that isn't + /// installed locally. False whenever the effective binding is the local one, + /// excluded, or pins no source. + public func effectiveFileSourceIsMissing(_ item: ImportItem) -> Bool { + guard usesFileBinding(item), let source = item.fileSource else { return false } + return !installedSourceIDs.contains(source) + } + + // MARK: - Resolution + + /// Fold the plan into a final configuration. Pure: the per-device runtime + /// flags (`isEnabled`, `enhancedModeEnabled`) are carried straight from the + /// base config and never imported. + public func resolvedConfiguration() -> LockConfiguration { + var appRules: [String: AppRule] + var urlRules: [String: URLRule] + var defaultSource: InputSourceID? + + switch mode { + case .merge: + appRules = Dictionary(baseConfig.appRules.map { ($0.bundleID, $0) }, uniquingKeysWith: { first, _ in first }) + urlRules = Dictionary(baseConfig.urlRules.map { ($0.hostPattern, $0) }, uniquingKeysWith: { first, _ in first }) + defaultSource = baseConfig.defaultSourceID + case .replace: + // Drop local-only rules. The global default is preserved unless the + // file specifies one (a default item below) — a file without a + // default never silently clears the user's. + appRules = [:] + urlRules = [:] + defaultSource = baseConfig.defaultSourceID + } + + for item in items { + guard usesFileBinding(item) else { continue } + let drop = effectiveFileSourceIsMissing(item) && item.missingDisposition == .remove + + switch item.subject { + case .globalDefault: + // A missing default set to "remove" falls back to the local + // default rather than clearing it (clearing the global default + // would strip the lock's target — too destructive to do here). + if !drop { defaultSource = item.fileSource } + case .app(let bundleID): + if drop { + appRules[bundleID] = nil + } else { + appRules[bundleID] = AppRule( + bundleID: bundleID, + mode: item.fileMode ?? .locked, + lockedSourceID: item.fileSource + ) + } + case .url(let host): + if drop { + urlRules[host] = nil + } else if let source = item.fileSource { + urlRules[host] = URLRule(hostPattern: host, lockedSourceID: source) + } + } + } + + var result = baseConfig + result.appRules = appRules.values.sorted { $0.bundleID < $1.bundleID } + result.urlRules = urlRules.values.sorted { $0.hostPattern < $1.hostPattern } + result.defaultSourceID = defaultSource + return result + } + + // MARK: - Summary + + /// A live tally of the import's effect under the current choices. + public func summary() -> ImportSummary { + let resolved = resolvedConfiguration() + let base = baseConfig + + let baseKeys = bindingKeys(of: base) + let resolvedKeys = bindingKeys(of: resolved) + let resolvedSources = bindingSources(of: resolved) + + var added = 0, updated = 0, removed = 0, inactive = 0 + for (key, binding) in resolvedKeys { + let changed: Bool + if let was = baseKeys[key] { + changed = was != binding + if changed { updated += 1 } + } else { + changed = true + added += 1 + } + // "Inactive" is scoped to what the import actually added or rebound — + // never a pre-existing local rule merely carried over — so the receipt + // ("其中 M 条…未生效") stays a true subset of the imported count. + if changed, let source = resolvedSources[key], !installedSourceIDs.contains(source) { + inactive += 1 + } + } + for key in baseKeys.keys where resolvedKeys[key] == nil { removed += 1 } + + let kept = items.filter { $0.status == .conflict && !usesFileBinding($0) }.count + + return ImportSummary(added: added, updated: updated, kept: kept, removed: removed, inactive: inactive) + } + + /// The receipt shown after Apply. + public func outcome() -> ImportOutcome { + let s = summary() + return ImportOutcome(imported: s.added + s.updated, inactive: s.inactive) + } + + // MARK: - Private helpers + + /// A comparable per-key binding snapshot of a configuration, so the same key + /// in two configs can be diffed for "changed". The global default is keyed + /// `"default"`; app rules `"app:"`; URL rules `"url:"`. + private func bindingKeys(of config: LockConfiguration) -> [String: String] { + var map: [String: String] = [:] + if let def = config.defaultSourceID { map["default"] = def.rawValue } + for rule in config.appRules { + let source = rule.mode == .locked ? (rule.lockedSourceID?.rawValue ?? "") : "" + map["app:\(rule.bundleID)"] = "\(rule.mode.rawValue)|\(source)" + } + for rule in config.urlRules { + map["url:\(rule.hostPattern)"] = rule.lockedSourceID.rawValue + } + return map + } + + /// The pinned source per binding key, so an added/updated key can be tested + /// for "source installed?" when tallying inactive imports. Keyed exactly like + /// `bindingKeys`. A binding that pins no source contributes no entry. + private func bindingSources(of config: LockConfiguration) -> [String: InputSourceID] { + var map: [String: InputSourceID] = [:] + if let def = config.defaultSourceID { map["default"] = def } + for rule in config.appRules where rule.mode == .locked { + if let source = rule.lockedSourceID { map["app:\(rule.bundleID)"] = source } + } + for rule in config.urlRules { map["url:\(rule.hostPattern)"] = rule.lockedSourceID } + return map + } + + // MARK: - Display + + /// Best human-readable name for a source: the local name, then the file's + /// captured name, falling back to the raw identifier. + public func displayName(for id: InputSourceID) -> String { + sourceNames[id] ?? id.rawValue + } +} diff --git a/Tests/LockIMEKitTests/ConfigBackupTests.swift b/Tests/LockIMEKitTests/ConfigBackupTests.swift new file mode 100644 index 0000000..355b19f --- /dev/null +++ b/Tests/LockIMEKitTests/ConfigBackupTests.swift @@ -0,0 +1,180 @@ +import Foundation +import Testing + +@testable import LockIMEKit + +@Suite("ConfigBackup envelope & codec") +struct ConfigBackupTests { + private func sampleConfig() -> LockConfiguration { + LockConfiguration( + isEnabled: true, + defaultSourceID: "com.apple.keylayout.US", + appRules: [ + AppRule(bundleID: "com.apple.Terminal", mode: .locked, lockedSourceID: "com.apple.keylayout.ABC"), + AppRule(bundleID: "com.game.App", mode: .ignored), + AppRule(bundleID: "com.other.App", mode: .useDefault), + ], + enhancedModeEnabled: true, + urlRules: [URLRule(hostPattern: "github.com", lockedSourceID: "com.apple.inputmethod.SCIM.ITABC")] + ) + } + + private let names: [InputSourceID: String] = [ + "com.apple.keylayout.US": "U.S.", + "com.apple.keylayout.ABC": "ABC", + "com.apple.inputmethod.SCIM.ITABC": "Pinyin - Simplified", + ] + + @Test("make() captures rules and binding intent, dropping per-device runtime state") + func makeDropsRuntimeState() { + let backup = ConfigBackup.make(from: sampleConfig(), appVersion: "1.2.3", sourceNames: names) + #expect(backup.format == ConfigBackup.formatIdentifier) + #expect(backup.schemaVersion == ConfigBackup.writerSchemaVersion) + #expect(backup.minReader == ConfigBackup.writerMinReader) + #expect(backup.appVersion == "1.2.3") + #expect(backup.payload.defaultSourceID == "com.apple.keylayout.US") + #expect(backup.payload.appRules.count == 3) + #expect(backup.payload.urlRules == [BackupURLRule(hostPattern: "github.com", lockedSourceID: "com.apple.inputmethod.SCIM.ITABC")]) + } + + @Test("make() catalogs only referenced sources with known names") + func makeBuildsNameCatalog() { + let backup = ConfigBackup.make(from: sampleConfig(), appVersion: "1", sourceNames: names) + // US (default), ABC (locked app rule), and the URL rule's source — but + // not the ignored/useDefault app rules (they pin no source). + #expect(backup.payload.sourceNames == [ + "com.apple.keylayout.US": "U.S.", + "com.apple.keylayout.ABC": "ABC", + "com.apple.inputmethod.SCIM.ITABC": "Pinyin - Simplified", + ]) + } + + @Test("make() omits catalog entries for sources without a known name") + func makeOmitsUnknownNames() { + let config = LockConfiguration(defaultSourceID: "com.unknown.source") + let backup = ConfigBackup.make(from: config, appVersion: "1", sourceNames: [:]) + #expect(backup.payload.sourceNames.isEmpty) + #expect(backup.payload.defaultSourceID == "com.unknown.source") + } + + @Test("encoded() round-trips through read()") + func roundTrip() throws { + let backup = ConfigBackup.make(from: sampleConfig(), appVersion: "1.2.3", sourceNames: names) + let data = try backup.encoded() + let result = ConfigBackup.read(data) + #expect(try result.get() == backup) + } + + @Test("encoded() is human-readable pretty JSON with unescaped slashes") + func prettyEncoding() throws { + let backup = ConfigBackup.make(from: sampleConfig(), appVersion: "1", sourceNames: names) + let text = try #require(String(data: backup.encoded(), encoding: .utf8)) + #expect(text.contains("\n")) + #expect(text.contains("com.oomol.LockIME.backup")) + // .withoutEscapingSlashes keeps bundle IDs readable. + #expect(!text.contains("\\/")) + } + + @Test("read() rejects non-JSON bytes as unreadable") + func readsNonJSON() { + #expect(ConfigBackup.read(Data("not json".utf8)) == .failure(.unreadable)) + } + + @Test("read() rejects valid JSON that isn't a LockIME backup") + func readsWrongFormat() { + let json = #"{"format": "com.someone.else", "payload": {}}"# + #expect(ConfigBackup.read(Data(json.utf8)) == .failure(.unreadable)) + // Also a JSON object with no format at all. + #expect(ConfigBackup.read(Data("{}".utf8)) == .failure(.unreadable)) + } + + @Test("read() rejects a file whose minReader exceeds this build") + func readsTooNew() { + let json = """ + {"format": "\(ConfigBackup.formatIdentifier)", "schemaVersion": 99, \ + "minReader": \(ConfigBackup.readerVersion + 1), "appVersion": "9.9.9", \ + "payload": {"appRules": [], "urlRules": []}} + """ + #expect(ConfigBackup.read(Data(json.utf8)) == .failure(.incompatibleVersion(appVersion: "9.9.9"))) + } + + @Test("version gate fires before payload decoding (unparseable future payload)") + func gateBeforeDecode() { + // A future file we can't decode must still report incompatibleVersion, + // never damaged — that's what tells the user to update. + let json = """ + {"format": "\(ConfigBackup.formatIdentifier)", \ + "minReader": \(ConfigBackup.readerVersion + 5), "appVersion": "10.0", \ + "payload": {"appRules": "this is not an array"}} + """ + #expect(ConfigBackup.read(Data(json.utf8)) == .failure(.incompatibleVersion(appVersion: "10.0"))) + } + + @Test("too-new file with no appVersion still reports an empty version") + func tooNewMissingAppVersion() { + let json = """ + {"format": "\(ConfigBackup.formatIdentifier)", \ + "minReader": \(ConfigBackup.readerVersion + 1), "payload": {}} + """ + #expect(ConfigBackup.read(Data(json.utf8)) == .failure(.incompatibleVersion(appVersion: ""))) + } + + @Test("read() reports a compatible-but-broken file as damaged") + func readsDamaged() { + // Correct format + compatible version, but the payload is the wrong type. + let json = #"{"format": "com.oomol.LockIME.backup", "minReader": 1, "payload": "broken"}"# + #expect(ConfigBackup.read(Data(json.utf8)) == .failure(.damaged)) + } + + @Test("read() defaults a missing minReader to the writer minimum (lenient)") + func readsMissingMinReader() throws { + let json = """ + {"format": "\(ConfigBackup.formatIdentifier)", \ + "payload": {"defaultSourceID": "com.apple.keylayout.US"}} + """ + let backup = try ConfigBackup.read(Data(json.utf8)).get() + #expect(backup.minReader == ConfigBackup.writerMinReader) + #expect(backup.payload.defaultSourceID == "com.apple.keylayout.US") + } + + @Test("read() ignores unknown fields and defaults absent payload arrays") + func forwardCompatibleDecode() throws { + let json = """ + {"format": "\(ConfigBackup.formatIdentifier)", "schemaVersion": 1, "minReader": 1, \ + "appVersion": "2.0", "futureFlag": true, \ + "payload": {"defaultSourceID": "com.apple.keylayout.US", "futureRules": [1,2,3]}} + """ + let backup = try ConfigBackup.read(Data(json.utf8)).get() + #expect(backup.payload.defaultSourceID == "com.apple.keylayout.US") + #expect(backup.payload.appRules.isEmpty) + #expect(backup.payload.urlRules.isEmpty) + #expect(backup.payload.sourceNames.isEmpty) + } + + @Test("a backup carrying per-device runtime fields ignores them (only rules are read)") + func ignoresRuntimeFields() throws { + // The portable format has no place for isEnabled / enhancedModeEnabled / + // language, so a file that smuggles them in is read as if they weren't + // there — import can never flip per-device runtime state. + let json = """ + {"format": "\(ConfigBackup.formatIdentifier)", "minReader": 1, "appVersion": "1", "isEnabled": true, + "payload": {"defaultSourceID": "com.apple.keylayout.US", + "appRules": [{"bundleID": "com.a", "mode": "locked", "lockedSourceID": "com.apple.keylayout.ABC"}], + "urlRules": [], "enhancedModeEnabled": true, "isEnabled": true, "languagePreference": "ja"}} + """ + let backup = try ConfigBackup.read(Data(json.utf8)).get() + #expect(backup.payload.defaultSourceID == "com.apple.keylayout.US") + #expect(backup.payload.appRules.count == 1) + // BackupPayload simply has no isEnabled / enhancedModeEnabled / language + // members — the smuggled keys decode to nothing. + } + + @Test("BackupPayload decodes an empty object to all-empty defaults") + func payloadEmptyDefaults() throws { + let payload = try JSONDecoder().decode(BackupPayload.self, from: Data("{}".utf8)) + #expect(payload.defaultSourceID == nil) + #expect(payload.appRules.isEmpty) + #expect(payload.urlRules.isEmpty) + #expect(payload.sourceNames.isEmpty) + } +} diff --git a/Tests/LockIMEKitTests/ImportPlanTests.swift b/Tests/LockIMEKitTests/ImportPlanTests.swift new file mode 100644 index 0000000..8512bcb --- /dev/null +++ b/Tests/LockIMEKitTests/ImportPlanTests.swift @@ -0,0 +1,520 @@ +import Foundation +import Testing + +@testable import LockIMEKit + +@Suite("ImportPlan diff, resolve & summary") +struct ImportPlanTests { + // MARK: fixtures + + private func source(_ id: InputSourceID, _ name: String) -> InputSource { + InputSource(id: id, localizedName: name, isSelectCapable: true, isEnabled: true, isCJKV: false) + } + + /// US and ABC are installed; "Missing" never is. + private var installed: [InputSource] { + [source("US", "U.S."), source("ABC", "ABC")] + } + + private func backup( + defaultSourceID: InputSourceID? = nil, + appRules: [AppRule] = [], + urlRules: [BackupURLRule] = [], + sourceNames: [String: String] = [:] + ) -> ConfigBackup { + ConfigBackup(appVersion: "1", payload: BackupPayload( + defaultSourceID: defaultSourceID, appRules: appRules, urlRules: urlRules, sourceNames: sourceNames + )) + } + + private func item(_ plan: ImportPlan, _ id: String) -> ImportItem? { + plan.items.first { $0.id == id } + } + + // MARK: - Builder categorization + + @Test("an empty local config makes every file binding a new item") + func allNewWhenLocalEmpty() { + let plan = ImportPlan( + current: .default, + backup: backup( + defaultSourceID: "US", + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "ABC")], + urlRules: [BackupURLRule(hostPattern: "github.com", lockedSourceID: "US")] + ), + installedSources: installed + ) + #expect(plan.items.count == 3) + #expect(plan.items.allSatisfy { $0.status == .new }) + #expect(plan.newItems.count == 3) + #expect(plan.conflictItems.isEmpty) + } + + @Test("identical bindings produce unchanged items, shown in neither edit section") + func identicalBindingsUnchanged() { + let current = LockConfiguration( + defaultSourceID: "US", + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "ABC")], + urlRules: [URLRule(hostPattern: "github.com", lockedSourceID: "US")] + ) + let plan = ImportPlan(current: current, backup: backup( + defaultSourceID: "US", + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "ABC")], + urlRules: [BackupURLRule(hostPattern: "github.com", lockedSourceID: "US")] + ), installedSources: installed) + #expect(plan.items.allSatisfy { $0.status == .unchanged }) + #expect(plan.newItems.isEmpty) + #expect(plan.conflictItems.isEmpty) + // An all-identical merge is a pure no-op. + #expect(plan.resolvedConfiguration() == current) + #expect(!plan.summary().hasEffect) + } + + @Test("Replace re-asserts an unchanged rule whose source went missing") + func replaceUnchangedMissingSurfaces() { + // File and local agree, but the pinned source isn't installed here. + let current = LockConfiguration(appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "Missing")]) + var plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "Missing")] + ), installedSources: installed) + // Merge: unchanged is a no-op, not surfaced as missing. + #expect(plan.missingItems.isEmpty) + // Replace: the rule is re-asserted from the file → surfaced as missing. + plan.mode = .replace + #expect(plan.missingItems.count == 1) + // Removing it drops the rule entirely. + plan.items[0].missingDisposition = .remove + #expect(plan.resolvedConfiguration().appRules.isEmpty) + } + + @Test("a differing default is a conflict; a differing app source is a conflict") + func conflictsDetected() { + let current = LockConfiguration( + defaultSourceID: "US", + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "US")] + ) + let plan = ImportPlan(current: current, backup: backup( + defaultSourceID: "ABC", + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "ABC")] + ), installedSources: installed) + #expect(item(plan, "default")?.status == .conflict) + #expect(item(plan, "default")?.localSource == "US") + #expect(item(plan, "default")?.fileSource == "ABC") + #expect(item(plan, "app:com.a")?.status == .conflict) + } + + @Test("an app rule differing only in mode is a conflict") + func appModeConflict() { + let current = LockConfiguration(appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "ABC")]) + let plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .ignored)] + ), installedSources: installed) + let conflict = item(plan, "app:com.a") + #expect(conflict?.status == .conflict) + #expect(conflict?.fileMode == .ignored) + #expect(conflict?.fileSource == nil) + #expect(conflict?.localMode == .locked) + #expect(conflict?.localSource == "ABC") + } + + @Test("local-only rule counts are tracked for Replace") + func localOnlyCounts() { + let current = LockConfiguration( + appRules: [ + AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "US"), + AppRule(bundleID: "com.localonly", mode: .locked, lockedSourceID: "US"), + ], + urlRules: [ + URLRule(hostPattern: "a.com", lockedSourceID: "US"), + URLRule(hostPattern: "localonly.com", lockedSourceID: "US"), + ] + ) + let plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "US")], + urlRules: [BackupURLRule(hostPattern: "a.com", lockedSourceID: "US")] + ), installedSources: installed) + #expect(plan.localOnlyAppRuleCount == 1) + #expect(plan.localOnlyURLRuleCount == 1) + } + + // MARK: - Merge resolution + + @Test("merge keeps local rules and adds new ones") + func mergeAddsAndKeeps() { + let current = LockConfiguration(appRules: [AppRule(bundleID: "com.local", mode: .locked, lockedSourceID: "US")]) + let plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.new", mode: .locked, lockedSourceID: "ABC")] + ), installedSources: installed) + let resolved = plan.resolvedConfiguration() + #expect(resolved.appRules.map(\.bundleID) == ["com.local", "com.new"]) + } + + @Test("merge conflict defaults to keeping the local binding") + func mergeConflictDefaultsKeepLocal() { + let current = LockConfiguration(appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "US")]) + let plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "ABC")] + ), installedSources: installed) + #expect(plan.resolvedConfiguration().rule(for: "com.a")?.lockedSourceID == "US") + } + + @Test("merge conflict set to useFile takes the file binding") + func mergeConflictUseFile() { + let current = LockConfiguration(appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "US")]) + var plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "ABC")] + ), installedSources: installed) + plan.items[0].resolution = .useFile + #expect(plan.resolvedConfiguration().rule(for: "com.a")?.lockedSourceID == "ABC") + } + + @Test("a new rule excluded via include is not imported") + func excludedNewRuleNotImported() { + var plan = ImportPlan(current: .default, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "US")] + ), installedSources: installed) + plan.items[0].include = false + #expect(plan.resolvedConfiguration().appRules.isEmpty) + } + + @Test("merge preserves the local default and a local-only rule") + func mergePreservesDefaultAndLocalOnly() { + let current = LockConfiguration( + defaultSourceID: "US", + appRules: [AppRule(bundleID: "com.localonly", mode: .locked, lockedSourceID: "ABC")] + ) + let plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.new", mode: .locked, lockedSourceID: "US")] + ), installedSources: installed) + let resolved = plan.resolvedConfiguration() + #expect(resolved.defaultSourceID == "US") + #expect(resolved.appRules.map(\.bundleID) == ["com.localonly", "com.new"]) + } + + // MARK: - Replace resolution + + @Test("replace drops local-only rules and lets the file win conflicts") + func replaceDropsLocalOnlyAndFileWins() { + let current = LockConfiguration( + appRules: [ + AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "US"), + AppRule(bundleID: "com.localonly", mode: .locked, lockedSourceID: "US"), + ] + ) + var plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "ABC")] + ), installedSources: installed) + plan.mode = .replace + let resolved = plan.resolvedConfiguration() + #expect(resolved.appRules.map(\.bundleID) == ["com.a"]) + #expect(resolved.rule(for: "com.a")?.lockedSourceID == "ABC") + } + + @Test("replace preserves the local default when the file specifies none") + func replaceKeepsDefaultWhenFileHasNone() { + let current = LockConfiguration(defaultSourceID: "US") + var plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "ABC")] + ), installedSources: installed) + plan.mode = .replace + #expect(plan.resolvedConfiguration().defaultSourceID == "US") + } + + @Test("replace sets the file's default when it specifies one") + func replaceSetsFileDefault() { + let current = LockConfiguration(defaultSourceID: "US") + var plan = ImportPlan(current: current, backup: backup(defaultSourceID: "ABC"), installedSources: installed) + plan.mode = .replace + #expect(plan.resolvedConfiguration().defaultSourceID == "ABC") + } + + @Test("import never touches the per-device runtime flags") + func runtimeFlagsPreserved() { + let current = LockConfiguration(isEnabled: true, defaultSourceID: "US", enhancedModeEnabled: true) + var plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "ABC")] + ), installedSources: installed) + plan.mode = .replace + let resolved = plan.resolvedConfiguration() + #expect(resolved.isEnabled == true) + #expect(resolved.enhancedModeEnabled == true) + } + + // MARK: - Missing sources + + @Test("a new rule targeting an uninstalled source is flagged missing") + func newMissingFlagged() { + let plan = ImportPlan(current: .default, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "Missing")] + ), installedSources: installed) + #expect(plan.missingItems.count == 1) + #expect(plan.effectiveFileSourceIsMissing(plan.items[0])) + } + + @Test("an app rule pinning no source is never missing") + func nonLockingRuleNeverMissing() { + let plan = ImportPlan(current: .default, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .ignored)] + ), installedSources: installed) + #expect(plan.missingItems.isEmpty) + } + + @Test("a kept-local conflict is not missing even if the file source is absent") + func keepLocalConflictNotMissing() { + let current = LockConfiguration(appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "US")]) + let plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "Missing")] + ), installedSources: installed) + // Default resolution is keepLocal → effective source is US (installed). + #expect(plan.missingItems.isEmpty) + } + + @Test("a merge conflict on a missing file source stays in the conflict section") + func useFileConflictStaysInConflictSection() { + // In Merge a conflict keeps its keep-local escape hatch, so it stays in + // the conflict section (with an inline warning) rather than moving to the + // missing section. The predicate still reports the source as missing. + let current = LockConfiguration(appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "US")]) + var plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "Missing")] + ), installedSources: installed) + plan.items[0].resolution = .useFile + #expect(plan.effectiveFileSourceIsMissing(plan.items[0])) + #expect(plan.missingItems.isEmpty) + #expect(plan.conflictItems.count == 1) + } + + @Test("replace surfaces a conflict's missing file source (file always wins)") + func replaceConflictMissing() { + let current = LockConfiguration(appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "US")]) + var plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "Missing")] + ), installedSources: installed) + plan.mode = .replace + #expect(plan.missingItems.count == 1) + } + + @Test("keep disposition leaves a missing rule in the config (inactive)") + func missingKeptStaysInConfig() { + let plan = ImportPlan(current: .default, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "Missing")] + ), installedSources: installed) + // default disposition is keep. + #expect(plan.resolvedConfiguration().rule(for: "com.a")?.lockedSourceID == "Missing") + } + + @Test("remove disposition drops a missing new rule entirely") + func missingRemovedDropsRule() { + var plan = ImportPlan(current: .default, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "Missing")] + ), installedSources: installed) + plan.items[0].missingDisposition = .remove + #expect(plan.resolvedConfiguration().appRules.isEmpty) + } + + @Test("useFile + missing + remove deletes the rule (neither binding survives)") + func useFileMissingRemoveDeletes() { + let current = LockConfiguration(appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "US")]) + var plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "Missing")] + ), installedSources: installed) + plan.items[0].resolution = .useFile + plan.items[0].missingDisposition = .remove + #expect(plan.resolvedConfiguration().appRules.isEmpty) + } + + @Test("a missing URL rule kept stays; removed drops") + func missingURLDisposition() { + var plan = ImportPlan(current: .default, backup: backup( + urlRules: [BackupURLRule(hostPattern: "x.com", lockedSourceID: "Missing")] + ), installedSources: installed) + #expect(plan.resolvedConfiguration().urlRules.count == 1) + plan.items[0].missingDisposition = .remove + #expect(plan.resolvedConfiguration().urlRules.isEmpty) + } + + @Test("a missing global default kept is set; removed falls back to local") + func missingDefaultDisposition() { + let current = LockConfiguration(defaultSourceID: "US") + var plan = ImportPlan(current: current, backup: backup(defaultSourceID: "Missing"), installedSources: installed) + // Conflict; flip to useFile so the missing file default is effective. + plan.items[0].resolution = .useFile + #expect(plan.resolvedConfiguration().defaultSourceID == "Missing") + plan.items[0].missingDisposition = .remove + #expect(plan.resolvedConfiguration().defaultSourceID == "US") + } + + // MARK: - Summary & outcome + + @Test("summary tallies added, updated, kept, removed and inactive") + func summaryCounts() { + let current = LockConfiguration( + appRules: [ + AppRule(bundleID: "com.conflict", mode: .locked, lockedSourceID: "US"), + AppRule(bundleID: "com.localonly", mode: .locked, lockedSourceID: "US"), + ] + ) + var plan = ImportPlan(current: current, backup: backup( + appRules: [ + AppRule(bundleID: "com.conflict", mode: .locked, lockedSourceID: "ABC"), + AppRule(bundleID: "com.new", mode: .locked, lockedSourceID: "Missing"), + ] + ), installedSources: installed) + // Merge, conflict→useFile so it counts as an update. + if let i = plan.items.firstIndex(where: { $0.id == "app:com.conflict" }) { + plan.items[i].resolution = .useFile + } + let s = plan.summary() + #expect(s.added == 1) // com.new (kept though missing → still added) + #expect(s.updated == 1) // com.conflict rebound to file + #expect(s.kept == 0) // the only conflict was set to useFile + #expect(s.removed == 0) // merge keeps local-only + #expect(s.inactive == 1) // com.new targets the missing source + #expect(s.hasEffect) + } + + @Test("a conflict left at keepLocal counts as kept and is a no-op") + func keepLocalIsNoOp() { + let current = LockConfiguration(appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "US")]) + let plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "ABC")] + ), installedSources: installed) + let s = plan.summary() + #expect(s.kept == 1) + #expect(s.added == 0 && s.updated == 0 && s.removed == 0) + #expect(!s.hasEffect) + } + + @Test("replace counts local-only rules as removed") + func replaceRemovedCount() { + let current = LockConfiguration(appRules: [ + AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "US"), + AppRule(bundleID: "com.localonly", mode: .locked, lockedSourceID: "US"), + ]) + var plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "US")] + ), installedSources: installed) + plan.mode = .replace + let s = plan.summary() + #expect(s.removed == 1) + #expect(s.hasEffect) + } + + @Test("a pre-existing local rule on a missing source isn't counted as imported-inactive") + func preExistingInactiveNotCounted() { + // Local already pins a rule to an uninstalled source; the import only adds + // a new, installed rule. The receipt must attribute inactivity solely to + // imported rules, not the carried-over local one. + let current = LockConfiguration(appRules: [AppRule(bundleID: "com.old", mode: .locked, lockedSourceID: "Missing")]) + let plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.new", mode: .locked, lockedSourceID: "US")] + ), installedSources: installed) + let outcome = plan.outcome() + #expect(outcome.imported == 1) + #expect(outcome.inactive == 0) + #expect(plan.summary().inactive == 0) + } + + @Test("Replace re-asserting an unchanged missing rule isn't counted as imported-inactive") + func replaceUnchangedMissingNotImportedInactive() { + let current = LockConfiguration(appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "Missing")]) + var plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "Missing")] + ), installedSources: installed) + plan.mode = .replace + let s = plan.summary() + #expect(s.added == 0 && s.updated == 0) // file == local: nothing imported + #expect(s.inactive == 0) // so nothing imported-inactive + } + + @Test("outcome reports imported and inactive counts") + func outcomeReceipt() { + var plan = ImportPlan(current: .default, backup: backup( + appRules: [ + AppRule(bundleID: "com.ok", mode: .locked, lockedSourceID: "US"), + AppRule(bundleID: "com.missing", mode: .locked, lockedSourceID: "Missing"), + ] + ), installedSources: installed) + let outcome = plan.outcome() + #expect(outcome.imported == 2) + #expect(outcome.inactive == 1) + } + + // MARK: - Display names + + @Test("displayName prefers the local name, then the file catalog, then the raw id") + func displayNamePrecedence() { + let plan = ImportPlan(current: .default, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "Missing")], + sourceNames: ["US": "米国", "Missing": "Zhuyin"] + ), installedSources: installed) + #expect(plan.displayName(for: "US") == "U.S.") // installed name wins + #expect(plan.displayName(for: "Missing") == "Zhuyin") // from file catalog + #expect(plan.displayName(for: "Unknown") == "Unknown") // raw fallback + } + + // MARK: - Coverage for manual review-screen scenarios + + @Test("an empty backup yields no items and no effect") + func emptyBackupNoEffect() { + let plan = ImportPlan(current: LockConfiguration(defaultSourceID: "US"), backup: backup(), installedSources: installed) + #expect(plan.items.isEmpty) + #expect(plan.newItems.isEmpty && plan.conflictItems.isEmpty && plan.missingItems.isEmpty) + #expect(!plan.summary().hasEffect) + } + + @Test("merge global-default conflict keeps local by default, takes file when chosen") + func mergeDefaultConflict() { + let current = LockConfiguration(defaultSourceID: "US") + var plan = ImportPlan(current: current, backup: backup(defaultSourceID: "ABC"), installedSources: installed) + #expect(item(plan, "default")?.status == .conflict) + #expect(plan.resolvedConfiguration().defaultSourceID == "US") // default: keep local + plan.items[0].resolution = .useFile + #expect(plan.resolvedConfiguration().defaultSourceID == "ABC") // chosen: use file + } + + @Test("new, conflict and missing items coexist as distinct sections") + func mixedSectionsCoexist() { + let current = LockConfiguration(appRules: [AppRule(bundleID: "com.conflict", mode: .locked, lockedSourceID: "US")]) + let plan = ImportPlan(current: current, backup: backup( + appRules: [ + AppRule(bundleID: "com.conflict", mode: .locked, lockedSourceID: "ABC"), // conflict + AppRule(bundleID: "com.new", mode: .locked, lockedSourceID: "US"), // new (installed) + AppRule(bundleID: "com.missing", mode: .locked, lockedSourceID: "Missing"), // missing + ], + urlRules: [BackupURLRule(hostPattern: "github.com", lockedSourceID: "US")] // new URL + ), installedSources: installed) + #expect(plan.newItems.count == 2) // com.new + github.com + #expect(plan.conflictItems.count == 1) // com.conflict + #expect(plan.missingItems.count == 1) // com.missing + } + + @Test("imported ignore / use-default rules resolve with their mode and no source") + func importedNonLockingModes() { + let plan = ImportPlan(current: .default, backup: backup( + appRules: [AppRule(bundleID: "com.ig", mode: .ignored), AppRule(bundleID: "com.def", mode: .useDefault)] + ), installedSources: installed) + let resolved = plan.resolvedConfiguration() + #expect(resolved.rule(for: "com.ig")?.mode == .ignored) + #expect(resolved.rule(for: "com.ig")?.lockedSourceID == nil) + #expect(resolved.rule(for: "com.def")?.mode == .useDefault) + #expect(plan.missingItems.isEmpty) // non-locking rules pin no source + } + + @Test("round-trip: exporting a config then importing it yields no changes") + func exportImportRoundTrip() { + let config = LockConfiguration( + isEnabled: true, + defaultSourceID: "US", + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "ABC"), + AppRule(bundleID: "com.b", mode: .ignored)], + enhancedModeEnabled: true, + urlRules: [URLRule(hostPattern: "github.com", lockedSourceID: "US")] + ) + let exported = ConfigBackup.make(from: config, appVersion: "1", sourceNames: ["US": "U.S.", "ABC": "ABC"]) + let plan = ImportPlan(current: config, backup: exported, installedSources: installed) + #expect(plan.newItems.isEmpty && plan.conflictItems.isEmpty && plan.missingItems.isEmpty) + #expect(!plan.summary().hasEffect) + #expect(plan.resolvedConfiguration() == config) + } +} From 3dbd6920f1154b29c4364de2650af9809b603315 Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Mon, 15 Jun 2026 00:08:25 -0400 Subject: [PATCH 2/2] fix(backup): redact export error log, split unreadable error Two CodeRabbit findings on the new backup flow. The export failure log interpolated the raw error with .public, which can embed the user's chosen file path into shared diagnostics; log it .private(mask: .hash) so identical failures still correlate without leaking the path. BackupReadError.unreadable conflated "couldn't read the bytes" with "read fine but isn't our format", so the inline note claimed a file "isn't a LockIME backup" even on an I/O/permission read failure. Split it into .unreadable (raised by the loader on a read failure) and .notABackup (raised by read() for non-JSON / wrong format), each with an accurate, localized message. Signed-off-by: Kevin Cui --- Sources/LockIME/Localizable.xcstrings | 52 +++++++++++++++++++ .../UI/Settings/BackupSettingsPane.swift | 7 ++- Sources/LockIMEKit/Backup/ConfigBackup.swift | 14 +++-- Tests/LockIMEKitTests/ConfigBackupTests.swift | 8 +-- 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/Sources/LockIME/Localizable.xcstrings b/Sources/LockIME/Localizable.xcstrings index c3f6e74..35c1499 100644 --- a/Sources/LockIME/Localizable.xcstrings +++ b/Sources/LockIME/Localizable.xcstrings @@ -9309,6 +9309,58 @@ } } } + }, + "Couldn't read the selected file. Check permissions and try again.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法读取所选文件。请检查权限后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法讀取所選檔案。請檢查權限後再試一次。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択したファイルを読み込めませんでした。権限を確認して、もう一度お試しください。" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de lire le fichier sélectionné. Vérifiez les autorisations, puis réessayez." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die ausgewählte Datei konnte nicht gelesen werden. Prüfen Sie die Berechtigungen und versuchen Sie es erneut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo leer el archivo seleccionado. Comprueba los permisos e inténtalo de nuevo." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível ler o arquivo selecionado. Verifique as permissões e tente novamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось прочитать выбранный файл. Проверьте разрешения и повторите попытку." + } + } + } } } } diff --git a/Sources/LockIME/UI/Settings/BackupSettingsPane.swift b/Sources/LockIME/UI/Settings/BackupSettingsPane.swift index 5cf45cb..9da5f76 100644 --- a/Sources/LockIME/UI/Settings/BackupSettingsPane.swift +++ b/Sources/LockIME/UI/Settings/BackupSettingsPane.swift @@ -87,8 +87,9 @@ struct BackupSettingsPane: View { try state.makeBackup().encoded().write(to: url, options: .atomic) } catch { // Never surface a system-localized message; log the original, show a - // semantic note instead. - Self.log.error("Backup export failed: \(String(describing: error), privacy: .public)") + // semantic note instead. Keep the error private (it can embed the + // user's file path) — hashed so identical failures still correlate. + Self.log.error("Backup export failed: \(String(describing: error), privacy: .private(mask: .hash))") exportFailed = true } } @@ -124,6 +125,8 @@ struct BackupSettingsPane: View { let icon = "exclamationmark.triangle" switch error { case .unreadable: + inlineNote("Couldn't read the selected file. Check permissions and try again.", systemImage: icon, tint: DS.Palette.warning) + case .notABackup: inlineNote("This file isn't a LockIME backup.", systemImage: icon, tint: DS.Palette.warning) case .damaged: inlineNote("This backup file is damaged and can't be read.", systemImage: icon, tint: DS.Palette.warning) diff --git a/Sources/LockIMEKit/Backup/ConfigBackup.swift b/Sources/LockIMEKit/Backup/ConfigBackup.swift index c647069..313339e 100644 --- a/Sources/LockIMEKit/Backup/ConfigBackup.swift +++ b/Sources/LockIMEKit/Backup/ConfigBackup.swift @@ -162,15 +162,17 @@ public extension ConfigBackup { /// category to a catalog key (see the i18n rules), the same shape as /// `UpdateFailure`. static func read(_ data: Data) -> Result { - // 1) Must be a JSON object. + // 1) Must be a JSON object. (Bytes that don't parse aren't one of ours; + // a genuine I/O failure to *read* the file is `.unreadable`, raised by + // the caller before it ever gets here.) guard let object = try? JSONSerialization.jsonObject(with: data), let top = object as? [String: Any] else { - return .failure(.unreadable) + return .failure(.notABackup) } // 2) Must carry our format identifier. guard let format = top["format"] as? String, format == formatIdentifier else { - return .failure(.unreadable) + return .failure(.notABackup) } // 3) Version gate, reading only the envelope fields. A missing/invalid // `minReader` is treated as the writer minimum (lenient — these are @@ -194,8 +196,12 @@ public extension ConfigBackup { /// render time, so the message follows the in-app language override instead of /// leaking a system-localized `error.localizedDescription`. public enum BackupReadError: Error, Equatable, Sendable { - /// Not a JSON file, or not a LockIME backup at all (wrong/absent format). + /// The file's bytes couldn't be read at all (I/O, permissions). Raised by the + /// caller that loads the file, not by `read(_:)`. case unreadable + /// The bytes were read fine, but it isn't a LockIME backup — not JSON, or + /// missing/wrong format identifier. + case notABackup /// A LockIME backup whose contents are structurally broken. case damaged /// Written by a newer LockIME than this build can read. Carries the diff --git a/Tests/LockIMEKitTests/ConfigBackupTests.swift b/Tests/LockIMEKitTests/ConfigBackupTests.swift index 355b19f..3ba1944 100644 --- a/Tests/LockIMEKitTests/ConfigBackupTests.swift +++ b/Tests/LockIMEKitTests/ConfigBackupTests.swift @@ -75,17 +75,17 @@ struct ConfigBackupTests { #expect(!text.contains("\\/")) } - @Test("read() rejects non-JSON bytes as unreadable") + @Test("read() rejects non-JSON bytes as not-a-backup") func readsNonJSON() { - #expect(ConfigBackup.read(Data("not json".utf8)) == .failure(.unreadable)) + #expect(ConfigBackup.read(Data("not json".utf8)) == .failure(.notABackup)) } @Test("read() rejects valid JSON that isn't a LockIME backup") func readsWrongFormat() { let json = #"{"format": "com.someone.else", "payload": {}}"# - #expect(ConfigBackup.read(Data(json.utf8)) == .failure(.unreadable)) + #expect(ConfigBackup.read(Data(json.utf8)) == .failure(.notABackup)) // Also a JSON object with no format at all. - #expect(ConfigBackup.read(Data("{}".utf8)) == .failure(.unreadable)) + #expect(ConfigBackup.read(Data("{}".utf8)) == .failure(.notABackup)) } @Test("read() rejects a file whose minReader exceeds this build")