From 89505bb5dd8c48365e33c8136b72bde2b2d981d6 Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Sun, 5 Jul 2026 08:11:27 -0400 Subject: [PATCH] feat(rules): add default behavior for new app rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding an app to Per-app rules always seeded the rule as "Lock to", so anyone who mostly wants "Switch to" (or "Ignore") had to re-pick the mode on every app. Add a "New app rules > Default behavior" picker to the App Rules pane that seeds the mode of each newly added rule; the source stays the global default, so only the mode is chosen. The preference lives in `AppState.newAppRuleMode` under its own `UserDefaults` key, deliberately not part of `LockConfiguration`. It has no runtime locking effect — it only templates new rows — and mirrors the per-device `apiEnabled`/`menuBarIconHidden` precedent, so it never travels through config export/import (importing a shared config must not silently change your authoring default). It decodes leniently, falling back to `.locked` to preserve the original behavior. Only the "Add App…" flow honors the preference. The `lockime://set-app-rule` API keeps its own explicit `lock` default, since a programmatic interface must stay deterministic across devices. Closes #54 Signed-off-by: Kevin Cui --- Sources/LockIME/AppState.swift | 27 +++ Sources/LockIME/Localizable.xcstrings | 156 ++++++++++++++++++ .../UI/Settings/AppRulesSettingsPane.swift | 26 ++- 3 files changed, 208 insertions(+), 1 deletion(-) diff --git a/Sources/LockIME/AppState.swift b/Sources/LockIME/AppState.swift index c9f51aa..2f319b2 100644 --- a/Sources/LockIME/AppState.swift +++ b/Sources/LockIME/AppState.swift @@ -41,6 +41,19 @@ final class AppState { private(set) var menuBarIconHidden: Bool = false @ObservationIgnored static let menuBarIconHiddenKey = "menuBarIconHidden" + /// The `AppRuleMode` a newly added per-app rule starts in — the seed the App + /// Rules pane applies when you add an app, so someone who mostly wants + /// "Switch to" (or "Ignore") never has to re-pick it on every rule. Per-device + /// like `apiEnabled`/`menuBarIconHidden`: its own `UserDefaults` key, + /// deliberately **not** part of `LockConfiguration`, so it never travels + /// through config export/import (importing a shared config must not silently + /// change your authoring default) and carries **no runtime locking effect** — + /// it only templates new rows. Defaults to `.locked`, preserving the original + /// behavior. The source a new rule targets stays the global default; this + /// picks only the mode. + private(set) var newAppRuleMode: AppRuleMode = .locked + @ObservationIgnored private static let newAppRuleModeKey = "newAppRuleMode" + /// The configured global toggle-lock shortcut, mirrored as observable state /// so the menu-bar header re-renders the moment the user binds or clears it /// in Settings (a plain `getShortcut` read isn't tracked by `@Observable`). @@ -118,6 +131,11 @@ final class AppState { languagePreference = .load() apiEnabled = UserDefaults.standard.bool(forKey: Self.apiEnabledKey) // absent ⇒ false (opt-in) menuBarIconHidden = UserDefaults.standard.bool(forKey: Self.menuBarIconHiddenKey) // absent ⇒ false (icon shown) + // Stored as the raw string; an absent OR unrecognized value maps to + // `.locked` (the historical default), matching the lenient rawValue + // decoding used for `AppRuleMode` on disk. + newAppRuleMode = UserDefaults.standard.string(forKey: Self.newAppRuleModeKey) + .flatMap(AppRuleMode.init(rawValue:)) ?? .locked ThirdPartyBundleLocalization.apply(language: languagePreference.effectiveLanguage) } @@ -138,6 +156,15 @@ final class AppState { UserDefaults.standard.set(hidden, forKey: Self.menuBarIconHiddenKey) } + /// Choose the `AppRuleMode` a newly added app rule starts in. Persisted + /// immediately (its own key) so the choice survives relaunch. It only seeds + /// the next "Add App…"; it never rewrites an existing rule and never + /// re-applies the engine (nothing about the live lock changed). + func setNewAppRuleMode(_ mode: AppRuleMode) { + newAppRuleMode = mode + UserDefaults.standard.set(mode.rawValue, forKey: Self.newAppRuleModeKey) + } + /// GitHub URL of the URL-scheme API reference, in the app's current language /// (mirrors the `docs/URL-Scheme-API/README..md` naming). Points at /// `main`, so it resolves once this work lands there. diff --git a/Sources/LockIME/Localizable.xcstrings b/Sources/LockIME/Localizable.xcstrings index d7a748e..908611f 100644 --- a/Sources/LockIME/Localizable.xcstrings +++ b/Sources/LockIME/Localizable.xcstrings @@ -2,6 +2,162 @@ "sourceLanguage": "en", "version": "1.0", "strings": { + "New app rules": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新增应用规则" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增 App 規則" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいアプリのルール" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvelles règles d’app" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neue App-Regeln" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nuevas reglas de app" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Novas regras de app" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новые правила приложений" + } + } + } + }, + "Default behavior": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认行为" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設行為" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルトの動作" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Comportement par défaut" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standardverhalten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comportamiento predeterminado" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Comportamento padrão" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поведение по умолчанию" + } + } + } + }, + "The behavior a newly added app rule starts with. You can change any rule afterward.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新增应用规则时采用的初始行为。之后你仍可修改任意规则。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增 App 規則時採用的初始行為。之後你仍可修改任何規則。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しく追加したアプリのルールに最初に適用される動作です。追加後も各ルールを変更できます。" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le comportement appliqué à une règle d’app nouvellement ajoutée. Vous pouvez modifier chaque règle par la suite." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Verhalten, mit dem eine neu hinzugefügte App-Regel beginnt. Sie können jede Regel nachträglich ändern." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El comportamiento con el que empieza una regla de app recién añadida. Puedes cambiar cualquier regla después." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O comportamento com que uma regra de app recém-adicionada começa. Você pode alterar qualquer regra depois." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поведение, с которого начинается только что добавленное правило приложения. Любое правило можно изменить позже." + } + } + } + }, "By default, LockIME respects macOS secure input and doesn't change your input source in password fields. Turn this on to keep enforcing the locked source even there.": { "localizations": { "de": { diff --git a/Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift b/Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift index 6baddbc..7e99ff6 100644 --- a/Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift +++ b/Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift @@ -11,6 +11,10 @@ struct AppRulesSettingsPane: View { get: { state.config.defaultSourceID }, set: { state.setDefaultSource($0) } ) + let newRuleModeBinding = Binding( + get: { state.newAppRuleMode }, + set: { state.setNewAppRuleMode($0) } + ) Form { Section { @@ -26,6 +30,19 @@ struct AppRulesSettingsPane: View { SectionFooter("Used whenever the frontmost app has no rule of its own.") } + Section { + Picker("Default behavior", selection: newRuleModeBinding) { + Text("Lock to").tag(AppRuleMode.locked) + Text("Switch to").tag(AppRuleMode.switched) + Text("Ignore").tag(AppRuleMode.ignored) + Text("Use default").tag(AppRuleMode.useDefault) + } + } header: { + Text("New app rules") + } footer: { + SectionFooter("The behavior a newly added app rule starts with. You can change any rule afterward.") + } + Section { if state.config.appRules.isEmpty { emptyState @@ -62,8 +79,15 @@ struct AppRulesSettingsPane: View { .sheet(isPresented: $isPickingApp) { AppPickerSheet { app in withAnimation(DS.Motion.list) { + // Seed the row with the user's chosen default mode (issue #54); + // the source stays the global default, so switching the seeded + // mode to a non-pinning one later just ignores it. state.upsertRule( - AppRule(bundleID: app.bundleID, mode: .locked, lockedSourceID: state.config.defaultSourceID) + AppRule( + bundleID: app.bundleID, + mode: state.newAppRuleMode, + lockedSourceID: state.config.defaultSourceID + ) ) } }