diff --git a/README.md b/README.md index 2427415..09342b6 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ Either way, the app keeps itself up to date via Sparkle. - **Instant re-lock** — switches the active input source back the moment you (or another app) change it, globally or per-app. +- **Lock or switch** — per-app and per-URL rules can *lock* an input source + (re-applied whenever it drifts) or just *switch* to it once when you focus the + app or page, then step out of the way and let you change it freely. - **Menu-bar control** — activate/deactivate, switch the locked input source, view the current source, and track the activation count from the menu bar. - **Keyboard shortcuts** — configurable global shortcuts to toggle locking and diff --git a/Sources/LockIME/AppState.swift b/Sources/LockIME/AppState.swift index b794d81..7bdb801 100644 --- a/Sources/LockIME/AppState.swift +++ b/Sources/LockIME/AppState.swift @@ -283,7 +283,9 @@ final class AppState { guard let next = SourceCycler.step( from: reference, in: availableSources.map(\.id), direction: direction ) else { return } - rule.mode = .locked + // Cycling pins a source; keep a `.switched` rule a switch (don't demote + // it to a continuous lock), and turn a non-pinning rule into a lock. + if !rule.mode.pinsSource { rule.mode = .locked } rule.lockedSourceID = next upsertRule(rule) } diff --git a/Sources/LockIME/Localizable.xcstrings b/Sources/LockIME/Localizable.xcstrings index 35c1499..5d3060f 100644 --- a/Sources/LockIME/Localizable.xcstrings +++ b/Sources/LockIME/Localizable.xcstrings @@ -2,6 +2,162 @@ "sourceLanguage": "en", "version": "1.0", "strings": { + "Switch to": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换为" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換為" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "切り替え先" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Basculer vers" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wechseln zu" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambiar a" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Alternar para" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переключить на" + } + } + } + }, + "Lock to %@": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "锁定为 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "鎖定為 %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ にロック" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Verrouiller sur %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sperren auf %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Bloquear en %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Bloquear em %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Блокировать на %@" + } + } + } + }, + "Switch to %@": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换为 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換為 %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ に切り替え" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Basculer vers %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wechseln zu %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambiar a %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Alternar para %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переключить на %@" + } + } + } + }, "Lock to previous input source": { "localizations": { "zh-Hans": { @@ -5254,54 +5410,54 @@ } } }, - "Pin a specific app to its own input source, ignore it, or fall back to the global default.": { + "Lock an app to its own input source, switch to one on activation, ignore it, or fall back to the global default.": { "localizations": { "zh-Hans": { "stringUnit": { "state": "translated", - "value": "将某个应用固定到它自己的输入法、忽略它,或回退到全局默认。" + "value": "将应用锁定到它自己的输入法、在激活时切换到某个输入法、忽略它,或回退到全局默认。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "將特定 App 固定到自己的輸入法、忽略它,或回退到全域預設。" + "value": "將 App 鎖定到自己的輸入法、在啟用時切換到某個輸入法、忽略它,或回退到全域預設。" } }, "ja": { "stringUnit": { "state": "translated", - "value": "特定のアプリを独自の入力ソースに固定するか、無視するか、グローバルの既定値を使用します。" + "value": "特定のアプリを独自の入力ソースにロックする、アクティブ化時に切り替える、無視する、またはグローバルの既定値を使用します。" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Épinglez une app à sa propre source de saisie, ignorez-la ou utilisez la valeur par défaut globale." + "value": "Verrouillez une app sur sa propre source de saisie, basculez vers une à l'activation, ignorez-la ou utilisez la valeur par défaut globale." } }, "de": { "stringUnit": { "state": "translated", - "value": "Eine bestimmte App auf eine eigene Eingabequelle festlegen, ignorieren oder auf den globalen Standard zurückfallen." + "value": "Eine App auf eine eigene Eingabequelle sperren, beim Aktivieren zu einer wechseln, ignorieren oder auf den globalen Standard zurückfallen." } }, "es": { "stringUnit": { "state": "translated", - "value": "Fija una app a su propia fuente de entrada, ignórala o usa el valor predeterminado global." + "value": "Bloquea una app en su propia fuente de entrada, cambia a una al activarse, ignórala o usa el valor predeterminado global." } }, "pt": { "stringUnit": { "state": "translated", - "value": "Fixe um app à sua própria fonte de entrada, ignore-o ou use o padrão global." + "value": "Bloqueie um app na sua própria fonte de entrada, alterne para uma ao ativar, ignore-o ou use o padrão global." } }, "ru": { "stringUnit": { "state": "translated", - "value": "Закрепите за приложением свой источник ввода, игнорируйте его или используйте глобальное значение по умолчанию." + "value": "Заблокируйте приложение на его источнике ввода, переключайтесь на него при активации, игнорируйте его или используйте глобальное значение по умолчанию." } } } diff --git a/Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift b/Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift index 43febc0..682a347 100644 --- a/Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift +++ b/Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift @@ -50,7 +50,7 @@ struct AppRulesSettingsPane: View { } header: { Text("Per-app rules") } footer: { - SectionFooter("Pin a specific app to its own input source, ignore it, or fall back to the global default.") + SectionFooter("Lock an app to its own input source, switch to one on activation, ignore it, or fall back to the global default.") } } .formStyle(.grouped) @@ -100,13 +100,14 @@ private struct AppRuleRow: View { Picker("", selection: modeBinding) { Text("Lock to").tag(AppRuleMode.locked) + Text("Switch to").tag(AppRuleMode.switched) Text("Ignore").tag(AppRuleMode.ignored) Text("Use default").tag(AppRuleMode.useDefault) } .labelsHidden() .fixedSize() - if rule.mode == .locked { + if rule.mode.pinsSource { Picker("", selection: sourceBinding) { Text("Default").tag(InputSourceID?.none) ForEach(state.availableSources) { source in diff --git a/Sources/LockIME/UI/Settings/ImportReviewSheet.swift b/Sources/LockIME/UI/Settings/ImportReviewSheet.swift index 48e887a..6d606bc 100644 --- a/Sources/LockIME/UI/Settings/ImportReviewSheet.swift +++ b/Sources/LockIME/UI/Settings/ImportReviewSheet.swift @@ -420,29 +420,52 @@ struct ImportReviewSheet: View { } } - /// 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`. + /// The file-side binding as composable `Text`. A source-pinning rule reads as + /// "Lock to %@" / "Switch to %@" so lock and switch are visibly parallel (and + /// a same-source lock-vs-switch conflict is distinguishable); a non-pinning + /// app mode reads as its mode word; the global default (always a lock, no + /// ambiguity) and a sourceless binding read as the bare name / "Default". + /// Returning `Text` keeps it recolorable inside `HStack`s while 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") + bindingText(subject: item.subject, mode: item.fileMode, action: item.fileAction, source: item.fileSource) } private func localBindingText(_ item: ImportItem) -> Text { - if case .app = item.subject, let mode = item.localMode, mode != .locked { - return Text(modeKey(mode)) + bindingText(subject: item.subject, mode: item.localMode, action: item.localAction, source: item.localSource) + } + + private func bindingText( + subject: ImportItem.Subject, + mode: AppRuleMode?, + action: RuleAction?, + source: InputSourceID? + ) -> Text { + switch subject { + case .globalDefault: + if let source { return Text(verbatim: model.displayName(for: source)) } + return Text("Default") + case .app: + if let mode, !mode.pinsSource { return Text(modeKey(mode)) } // ignore / use default + guard let source else { return Text("Default") } + return pinnedBindingText(isSwitch: mode == .switched, source: source) + case .url: + guard let source else { return Text("Default") } + return pinnedBindingText(isSwitch: action == .switchOnce, source: source) } - if let source = item.localSource { return Text(verbatim: model.displayName(for: source)) } - return Text("Default") + } + + /// A source-pinning binding: the localized "Lock to %@" / "Switch to %@" + /// phrase with the source name interpolated (a verbatim proper noun). + private func pinnedBindingText(isSwitch: Bool, source: InputSourceID) -> Text { + let name = model.displayName(for: source) + return Text(isSwitch ? "Switch to \(name)" : "Lock to \(name)") } private func modeKey(_ mode: AppRuleMode) -> LocalizedStringKey { switch mode { case .locked: "Lock to" + case .switched: "Switch to" case .ignored: "Ignore" case .useDefault: "Use default" } diff --git a/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift b/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift index 2d7151e..fa79494 100644 --- a/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift +++ b/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift @@ -10,6 +10,7 @@ struct URLRulesSettingsPane: View { @State private var newHost = "" @State private var newSourceID: InputSourceID? + @State private var newAction: RuleAction = .lock var body: some View { let enhancedBinding = Binding( @@ -100,6 +101,12 @@ struct URLRulesSettingsPane: View { HStack(spacing: DS.Spacing.md) { TextField("Host (e.g. github.com)", text: $newHost) .textFieldStyle(.roundedBorder) + Picker("", selection: $newAction) { + Text("Lock to").tag(RuleAction.lock) + Text("Switch to").tag(RuleAction.switchOnce) + } + .labelsHidden() + .fixedSize() Picker("", selection: $newSourceID) { Text("Default").tag(InputSourceID?.none) ForEach(state.availableSources) { source in @@ -112,10 +119,11 @@ struct URLRulesSettingsPane: View { let host = newHost.trimmingCharacters(in: .whitespaces) guard !host.isEmpty, let sourceID = newSourceID ?? state.config.defaultSourceID else { return } withAnimation(DS.Motion.list) { - state.upsertURLRule(URLRule(hostPattern: host, lockedSourceID: sourceID)) + state.upsertURLRule(URLRule(hostPattern: host, lockedSourceID: sourceID, action: newAction)) } newHost = "" newSourceID = nil + newAction = .lock } .disabled( newHost.trimmingCharacters(in: .whitespaces).isEmpty @@ -139,6 +147,12 @@ private struct URLRuleRow: View { Text(rule.hostPattern) .foregroundStyle(active ? .primary : .secondary) Spacer(minLength: DS.Spacing.md) + Picker("", selection: actionBinding) { + Text("Lock to").tag(RuleAction.lock) + Text("Switch to").tag(RuleAction.switchOnce) + } + .labelsHidden() + .fixedSize() Picker("", selection: sourceBinding) { ForEach(state.availableSources) { source in Text(source.localizedName).tag(source.id) @@ -162,7 +176,14 @@ private struct URLRuleRow: View { private var sourceBinding: Binding { Binding( get: { rule.lockedSourceID }, - set: { state.upsertURLRule(URLRule(id: rule.id, hostPattern: rule.hostPattern, lockedSourceID: $0)) } + set: { state.upsertURLRule(URLRule(id: rule.id, hostPattern: rule.hostPattern, lockedSourceID: $0, action: rule.action)) } + ) + } + + private var actionBinding: Binding { + Binding( + get: { rule.action }, + set: { state.upsertURLRule(URLRule(id: rule.id, hostPattern: rule.hostPattern, lockedSourceID: rule.lockedSourceID, action: $0)) } ) } } diff --git a/Sources/LockIMEKit/Backup/ConfigBackup.swift b/Sources/LockIMEKit/Backup/ConfigBackup.swift index 313339e..27e883a 100644 --- a/Sources/LockIMEKit/Backup/ConfigBackup.swift +++ b/Sources/LockIMEKit/Backup/ConfigBackup.swift @@ -6,10 +6,27 @@ import Foundation public struct BackupURLRule: Codable, Equatable, Sendable { public var hostPattern: String public var lockedSourceID: InputSourceID + /// Whether a matched URL locks to the source or just switches to it once. + public var action: RuleAction - public init(hostPattern: String, lockedSourceID: InputSourceID) { + public init(hostPattern: String, lockedSourceID: InputSourceID, action: RuleAction = .lock) { self.hostPattern = hostPattern self.lockedSourceID = lockedSourceID + self.action = action + } + + private enum CodingKeys: String, CodingKey { + case hostPattern, lockedSourceID, action + } + + // Lenient: a backup written before the lock/switch distinction (or a + // hand-authored file) carries no `action` → default `.lock`. Keeps reading + // robust even though the .lockime format itself is pre-release. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + hostPattern = try container.decode(String.self, forKey: .hostPattern) + lockedSourceID = try container.decode(InputSourceID.self, forKey: .lockedSourceID) + action = try container.decodeIfPresent(RuleAction.self, forKey: .action) ?? .lock } } @@ -123,9 +140,10 @@ public extension ConfigBackup { appVersion: String, sourceNames: [InputSourceID: String] ) -> ConfigBackup { - // Only locked app rules pin a source; ignore/use-default modes don't. + // Only source-pinning app rules (lock/switch) carry a source; the + // ignore/use-default modes don't. let appRuleSources = config.appRules.compactMap { rule in - rule.mode == .locked ? rule.lockedSourceID : nil + rule.mode.pinsSource ? rule.lockedSourceID : nil } let referenced: [InputSourceID] = ([config.defaultSourceID].compactMap { $0 }) @@ -140,7 +158,7 @@ public extension ConfigBackup { let payload = BackupPayload( defaultSourceID: config.defaultSourceID, appRules: config.appRules, - urlRules: config.urlRules.map { BackupURLRule(hostPattern: $0.hostPattern, lockedSourceID: $0.lockedSourceID) }, + urlRules: config.urlRules.map { BackupURLRule(hostPattern: $0.hostPattern, lockedSourceID: $0.lockedSourceID, action: $0.action) }, sourceNames: catalog ) return ConfigBackup(appVersion: appVersion, payload: payload) diff --git a/Sources/LockIMEKit/Backup/ImportPlan.swift b/Sources/LockIMEKit/Backup/ImportPlan.swift index 25b3e90..f785463 100644 --- a/Sources/LockIMEKit/Backup/ImportPlan.swift +++ b/Sources/LockIMEKit/Backup/ImportPlan.swift @@ -59,9 +59,14 @@ public struct ImportItem: Identifiable, Sendable, Equatable { /// 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? + /// File-side URL-rule action — lock vs one-shot switch (`nil` for the global + /// default and app rules, whose lock/switch distinction rides in the mode). + public let fileAction: RuleAction? /// Local-side mode/source, populated only for `.conflict` items. public let localMode: AppRuleMode? public let localSource: InputSourceID? + /// Local-side URL-rule action, populated only for URL `.conflict` items. + public let localAction: RuleAction? // MARK: user choices @@ -82,15 +87,19 @@ public struct ImportItem: Identifiable, Sendable, Equatable { localSource: InputSourceID?, include: Bool, resolution: ConflictResolution, - missingDisposition: MissingSourceDisposition + missingDisposition: MissingSourceDisposition, + fileAction: RuleAction? = nil, + localAction: RuleAction? = nil ) { self.id = id self.subject = subject self.status = status self.fileMode = fileMode self.fileSource = fileSource + self.fileAction = fileAction self.localMode = localMode self.localSource = localSource + self.localAction = localAction self.include = include self.resolution = resolution self.missingDisposition = missingDisposition @@ -190,9 +199,12 @@ public struct ImportPlan: Sendable, Equatable { current.appRules.map { ($0.bundleID, $0) }, uniquingKeysWith: { first, _ in first } ) for rule in backup.payload.appRules { - let fileSource = rule.mode == .locked ? rule.lockedSourceID : nil + // Lock and switch both pin a source; only the mode (carried in full) + // tells them apart, so a lock-vs-switch difference falls out of the + // `local.mode == rule.mode` comparison as a conflict automatically. + let fileSource = rule.mode.pinsSource ? rule.lockedSourceID : nil if let local = localByBundle[rule.bundleID] { - let localSource = local.mode == .locked ? local.lockedSourceID : nil + let localSource = local.mode.pinsSource ? local.lockedSourceID : nil let status: ImportItem.Status = (local.mode == rule.mode && localSource == fileSource) ? .unchanged : .conflict items.append(ImportItem( @@ -216,19 +228,23 @@ public struct ImportPlan: Sendable, Equatable { ) for rule in backup.payload.urlRules { if let local = localByHost[rule.hostPattern] { + // A lock-vs-switch difference on the same source is a conflict. let status: ImportItem.Status = - local.lockedSourceID == rule.lockedSourceID ? .unchanged : .conflict + (local.lockedSourceID == rule.lockedSourceID && local.action == rule.action) + ? .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 + include: true, resolution: .keepLocal, missingDisposition: .keep, + fileAction: rule.action, localAction: local.action )) } 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 + include: true, resolution: .keepLocal, missingDisposition: .keep, + fileAction: rule.action )) } } @@ -340,7 +356,7 @@ public struct ImportPlan: Sendable, Equatable { if drop { urlRules[host] = nil } else if let source = item.fileSource { - urlRules[host] = URLRule(hostPattern: host, lockedSourceID: source) + urlRules[host] = URLRule(hostPattern: host, lockedSourceID: source, action: item.fileAction ?? .lock) } } } @@ -402,11 +418,13 @@ public struct ImportPlan: Sendable, Equatable { 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 ?? "") : "" + // The mode rawValue already separates lock from switch; only a + // source-pinning mode contributes a source. + let source = rule.mode.pinsSource ? (rule.lockedSourceID?.rawValue ?? "") : "" map["app:\(rule.bundleID)"] = "\(rule.mode.rawValue)|\(source)" } for rule in config.urlRules { - map["url:\(rule.hostPattern)"] = rule.lockedSourceID.rawValue + map["url:\(rule.hostPattern)"] = "\(rule.action.rawValue)|\(rule.lockedSourceID.rawValue)" } return map } @@ -417,7 +435,7 @@ public struct ImportPlan: Sendable, Equatable { 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 { + for rule in config.appRules where rule.mode.pinsSource { if let source = rule.lockedSourceID { map["app:\(rule.bundleID)"] = source } } for rule in config.urlRules { map["url:\(rule.hostPattern)"] = rule.lockedSourceID } diff --git a/Sources/LockIMEKit/LockEngine/LockController.swift b/Sources/LockIMEKit/LockEngine/LockController.swift index ec33ebd..375fcfe 100644 --- a/Sources/LockIMEKit/LockEngine/LockController.swift +++ b/Sources/LockIMEKit/LockEngine/LockController.swift @@ -79,6 +79,35 @@ public final class LockController { enforceIfNeeded(reason: reason) } + /// Perform a **one-shot** switch to `id` without installing a standing lock. + /// + /// Unlike `setTarget`, this clears `target` (so `selectedSourceDidChange` has + /// nothing to revert to — the user may freely switch away afterward) and + /// forces the source exactly once, only if it actually differs. It deliberately + /// does **not** consult `isEnabled`: the engine gates the call on the *config* + /// being enabled, which it knows synchronously, whereas the controller's own + /// `isEnabled` lags during the enable path (`apply` enables only after + /// re-resolving). The switch is still logged and counted like any forced + /// switch, via the same `force` path. + public func switchOnce( + _ id: InputSourceID, + reason: ActivationReason = .appActivated, + bundleID: String? = nil, + ruleSource: RuleSource? = nil, + matchedHost: String? = nil + ) { + // A one-shot switch never holds the lock: drop any standing target so a + // later "source changed" notification is a no-op. + target = nil + targetBundleID = bundleID + targetRuleSource = ruleSource + targetMatchedHost = matchedHost + settleUntil = 0 + guard let current = provider.currentSourceID() else { return } + guard current != id else { return } // already there → nothing to switch + force(id, reason: reason, from: current) + } + /// Call when the system posts a "selected input source changed" notification. public func selectedSourceDidChange() { enforceIfNeeded(reason: .revertedSwitch) diff --git a/Sources/LockIMEKit/LockEngine/LockEngine.swift b/Sources/LockIMEKit/LockEngine/LockEngine.swift index b3668e1..a4e4b6e 100644 --- a/Sources/LockIMEKit/LockEngine/LockEngine.swift +++ b/Sources/LockIMEKit/LockEngine/LockEngine.swift @@ -32,6 +32,33 @@ public final class LockEngine { /// macOS leaves the frontmost app unchanged while an overlay is up. private var launcherBundleID: String? + /// Identity of the one-shot switch rule currently in effect, so the engine + /// fires a `.switchOnce` resolution only on a *genuine transition into* the + /// rule — never again on a re-activation, a URL poll over the same matched + /// pattern, or a config edit while the user is still in that rule. + private struct SwitchKey: Equatable { + let ruleSource: RuleSource + /// The frontmost/launcher bundle for an app rule, or the matched host + /// *pattern* for a URL rule (so a single wildcard rule fires once across + /// all its subdomains, matching how a lock treats the whole pattern). + let context: String? + let sourceID: InputSourceID + } + + /// In-memory only (never persisted): a fresh process re-fires the one-shot on + /// the first enable, which is the intended "switch me on engage" behavior. + private var lastSwitchKey: SwitchKey? + + /// The one-shot memory for a launcher overlay's *own* switch rule, kept + /// separate from `lastSwitchKey` so a launcher excursion never clobbers the + /// frontmost app's switch memory. Without this, focusing a launcher whose own + /// rule is `.switched` would overwrite `lastSwitchKey`, and dismissing it + /// would re-fire the frontmost app's one-shot — re-yanking a user who had + /// switched away. Cleared whenever no launcher is up, so each excursion is a + /// fresh re-entry. (The `.lock`/`.ignore` arms avoid this by simply not + /// touching `lastSwitchKey` while a launcher is up.) + private var lastLauncherSwitchKey: SwitchKey? + /// The app rules should resolve against right now: the focused launcher /// overlay when one is up, otherwise the `NSWorkspace` frontmost app. private var effectiveBundleID: String? { launcherBundleID ?? frontmostBundleID } @@ -151,40 +178,110 @@ public final class LockEngine { } private func reevaluate(reason: ActivationReason) { + // A launcher excursion uses its own one-shot slot; clear it whenever no + // launcher is up so the next excursion is a fresh re-entry and the + // frontmost slot below is the only memory consulted for the real app. + if launcherBundleID == nil { lastLauncherSwitchKey = nil } + let urlMatch = enhancedURLMatch() - switch RuleResolver.resolve(config: config, frontmostBundleID: effectiveBundleID, urlMatch: urlMatch?.id) { + switch RuleResolver.resolve( + config: config, + frontmostBundleID: effectiveBundleID, + urlMatch: urlMatch.map { (id: $0.id, action: $0.action) } + ) { case .lock(let id, let ruleSource): - // A URL match outranks a *trigger* reason (app switch, launcher, - // poll) — the URL is the why, so log .urlMatched. But an apply-driven - // reason (lock engaged / settings changed / startup restore) is the - // why itself; keep it, with the URL provenance carried by ruleSource. - let effectiveReason: ActivationReason - switch reason { - case .startupApplied, .lockEngaged, .configChanged: - effectiveReason = reason - default: - effectiveReason = ruleSource == .urlRule ? .urlMatched : reason - } controller.setTarget( id, - reason: effectiveReason, + reason: effectiveReason(for: reason, ruleSource: ruleSource), bundleID: effectiveBundleID, ruleSource: ruleSource, matchedHost: ruleSource == .urlRule ? urlMatch?.host : nil ) + // Re-arm the one-shot for a genuine frontmost/URL state — but NOT for a + // launcher overlay shadowing the app (see the `.switchOnce` arm). + if launcherBundleID == nil { lastSwitchKey = nil } + case .switchOnce(let id, let ruleSource): + // A one-shot switch never holds the lock: clear any standing target + // from a prior lock rule first, unconditionally. + controller.setTarget(nil) + let context = ruleSource == .urlRule ? urlMatch?.host : effectiveBundleID + let key = SwitchKey(ruleSource: ruleSource, context: context, sourceID: id) + // Dedup against the launcher slot during an excursion, the frontmost + // slot otherwise — so a launcher's own `.switched` rule firing while it + // shadows the app never overwrites the app's memory and re-yanks the + // user on dismiss. + if launcherBundleID != nil { + fireSwitchOnceIfNeeded(id, ruleSource: ruleSource, reason: reason, context: context, slot: &lastLauncherSwitchKey, key: key) + } else { + fireSwitchOnceIfNeeded(id, ruleSource: ruleSource, reason: reason, context: context, slot: &lastSwitchKey, key: key) + } case .ignore, .noTarget: controller.setTarget(nil) + // Re-arm only on a genuine state, never on a launcher excursion: a + // Spotlight/Raycast overlay (handleLauncherChange sets launcherBundleID + // before this runs) over an already-switched app resolves here, and + // resetting the key would re-yank the user back to the switch target + // on dismiss. Preserving it makes the return a no-op (key unchanged). + if launcherBundleID == nil { lastSwitchKey = nil } + } + } + + /// Fire the one-shot switch exactly once per genuine transition, tracked in + /// `slot`. A disabled config nils the slot (so a later enable re-enters and + /// fires — the OFF→ON escape hatch); a matching key is a no-op (already + /// switched: a re-activation, a same-pattern poll, or a config edit). + private func fireSwitchOnceIfNeeded( + _ id: InputSourceID, + ruleSource: RuleSource, + reason: ActivationReason, + context: String?, + slot: inout SwitchKey?, + key: SwitchKey + ) { + if !config.isEnabled { + slot = nil + } else if key != slot { + // The source must be readable to switch; if it can't be resolved yet + // (a transient TIS failure), do NOT consume the key — leave the + // one-shot eligible for the next reevaluation rather than marking it + // fired when it never ran. When the source *is* known but already + // equals the target, `switchOnce` no-ops and we still consume the key + // (the one-shot is satisfied, and re-arming would re-yank a user who + // later switches away from an app they entered already on target). + guard provider.currentSourceID() != nil else { return } + controller.switchOnce( + id, + reason: effectiveReason(for: reason, ruleSource: ruleSource), + bundleID: effectiveBundleID, + ruleSource: ruleSource, + matchedHost: ruleSource == .urlRule ? context : nil + ) + slot = key + } + } + + /// The reason to attribute the resulting forced switch to. A URL match + /// outranks a *trigger* reason (app switch, launcher, poll) — the URL is the + /// why, so log `.urlMatched`. But an apply-driven reason (lock engaged / + /// settings changed / startup restore) is the why itself; keep it, with the + /// URL provenance carried by `ruleSource`. Shared by the lock and switch arms. + private func effectiveReason(for reason: ActivationReason, ruleSource: RuleSource) -> ActivationReason { + switch reason { + case .startupApplied, .lockEngaged, .configChanged: + return reason + default: + return ruleSource == .urlRule ? .urlMatched : reason } } - /// The locked source and matched host from a URL rule, when enhanced mode is - /// on and the current page matches one. - private func enhancedURLMatch() -> (id: InputSourceID, host: String)? { + /// The targeted source, matched host, and action from a URL rule, when + /// enhanced mode is on and the current page matches one. + private func enhancedURLMatch() -> (id: InputSourceID, host: String, action: RuleAction)? { guard config.enhancedModeEnabled, let urlProvider, !config.urlRules.isEmpty else { return nil } let urlString = urlProvider.currentURL(forBundleID: effectiveBundleID) ?? "" guard let rule = URLMatcher.matchedRule(host: URLMatcher.host(from: urlString), rules: config.urlRules) else { return nil } - return (rule.lockedSourceID, rule.hostPattern) + return (rule.lockedSourceID, rule.hostPattern, rule.action) } /// Poll the URL only while a browser is frontmost and enhanced mode is on, diff --git a/Sources/LockIMEKit/Rules/LockConfiguration.swift b/Sources/LockIMEKit/Rules/LockConfiguration.swift index 214ce51..add2758 100644 --- a/Sources/LockIMEKit/Rules/LockConfiguration.swift +++ b/Sources/LockIMEKit/Rules/LockConfiguration.swift @@ -1,22 +1,48 @@ import Foundation +/// Whether a per-URL rule **continuously locks** its source or just **switches +/// to it once** on entry. +/// +/// `lock` is the original behavior — while the rule applies the engine keeps +/// re-applying the source, so any drift (the user, another app) is reverted. +/// `switchOnce` fires exactly once when the rule first becomes active, then steps +/// out of the way: the user may switch away and stays switched. For per-app rules +/// the equivalent distinction is carried by `AppRuleMode` (`.locked` vs +/// `.switched`); URL rules always pin a source, so they need only this 2-way axis. +public enum RuleAction: String, Codable, Sendable, CaseIterable, Identifiable { + /// Continuously enforce the source while the rule applies. + case lock + /// Switch to the source once on entry, then release (no enforcement). + case switchOnce + + public var id: String { rawValue } +} + /// How LockIME behaves while a particular app is frontmost. public enum AppRuleMode: String, Codable, Sendable, CaseIterable, Identifiable { - /// Lock to `AppRule.lockedSourceID` while this app is frontmost. + /// Continuously lock to `AppRule.lockedSourceID` while this app is frontmost. case locked + /// Switch to `AppRule.lockedSourceID` once when this app activates, then + /// release — the user may freely change the source afterward. + case switched /// Do not enforce any lock while this app is frontmost. case ignored /// Fall back to the global default source. case useDefault public var id: String { rawValue } + + /// Whether this mode targets a specific input source (`.locked`/`.switched`) + /// rather than deferring (`.ignored`/`.useDefault`). The two source-pinning + /// modes differ only in *how* — a continuous lock vs a one-shot switch. + public var pinsSource: Bool { self == .locked || self == .switched } } /// A per-app locking rule. public struct AppRule: Codable, Sendable, Hashable, Identifiable { public var bundleID: String public var mode: AppRuleMode - /// The locked source when `mode == .locked`. + /// The targeted source when `mode` pins one (`.locked` or `.switched`). public var lockedSourceID: InputSourceID? public var id: String { bundleID } @@ -34,11 +60,39 @@ public struct URLRule: Codable, Sendable, Hashable, Identifiable { /// Host pattern, e.g. `github.com` (matches subdomains) or `*.google.com`. public var hostPattern: String public var lockedSourceID: InputSourceID + /// Whether a matched URL locks to the source or just switches to it once. + public var action: RuleAction - public init(id: UUID = UUID(), hostPattern: String, lockedSourceID: InputSourceID) { + public init( + id: UUID = UUID(), + hostPattern: String, + lockedSourceID: InputSourceID, + action: RuleAction = .lock + ) { self.id = id self.hostPattern = hostPattern self.lockedSourceID = lockedSourceID + self.action = action + } + + // Explicit keys (preserving the v1.x names) so the custom decoder below can + // reference `.action`; `encode(to:)` stays synthesized off these. + private enum CodingKeys: String, CodingKey { + case id, hostPattern, lockedSourceID, action + } + + // Lenient decoding: rules persisted before the lock/switch distinction carry + // no `action`, so a missing key decodes to `.lock` (the original behavior). + // This matters load-bearingly: `LockConfiguration` decodes `[URLRule]` with + // `decodeIfPresent`, which *propagates* a per-element throw — a non-lenient + // decoder would make one legacy URL rule abort the whole config load and + // silently drop every rule (see `RuleStore.load`'s `try?`). + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + hostPattern = try container.decode(String.self, forKey: .hostPattern) + lockedSourceID = try container.decode(InputSourceID.self, forKey: .lockedSourceID) + action = try container.decodeIfPresent(RuleAction.self, forKey: .action) ?? .lock } } diff --git a/Sources/LockIMEKit/Rules/RuleResolver.swift b/Sources/LockIMEKit/Rules/RuleResolver.swift index 018bf98..39a0bd0 100644 --- a/Sources/LockIMEKit/Rules/RuleResolver.swift +++ b/Sources/LockIMEKit/Rules/RuleResolver.swift @@ -13,8 +13,12 @@ public enum RuleSource: String, Sendable, Codable, CaseIterable { /// The outcome of resolving which source (if any) to enforce right now. public enum LockResolution: Equatable, Sendable { - /// Enforce this source, produced by the given rule branch. + /// Continuously enforce this source, produced by the given rule branch. case lock(InputSourceID, RuleSource) + /// Switch to this source **once** (no standing enforcement), produced by the + /// given rule branch. A per-app `.switched` rule or a per-URL `.switchOnce` + /// rule yields this; the global default never does (it is lock-only). + case switchOnce(InputSourceID, RuleSource) /// The frontmost app is explicitly ignored — do not enforce. case ignore /// No applicable target — locking is effectively idle. @@ -27,11 +31,14 @@ public enum RuleResolver { public static func resolve( config: LockConfiguration, frontmostBundleID: String?, - urlMatch: InputSourceID? = nil + urlMatch: (id: InputSourceID, action: RuleAction)? = nil ) -> LockResolution { - // 1. Enhanced mode (P6): a matched browser-URL rule wins outright. + // 1. Enhanced mode (P6): a matched browser-URL rule wins outright. Its + // action decides lock vs one-shot switch. if let urlMatch { - return .lock(urlMatch, .urlRule) + return urlMatch.action == .switchOnce + ? .switchOnce(urlMatch.id, .urlRule) + : .lock(urlMatch.id, .urlRule) } // 2. Per-app rule. @@ -44,12 +51,18 @@ public enum RuleResolver { return .lock(id, .appRule) } // "locked" with no source set → fall through to the default. + case .switched: + if let id = rule.lockedSourceID { + return .switchOnce(id, .appRule) + } + // "switched" with no source set → fall through to the default + // (which is always a lock). case .useDefault: break } } - // 3. Global default. + // 3. Global default (always a lock; never a one-shot switch). if let def = config.defaultSourceID { return .lock(def, .globalDefault) } diff --git a/Tests/LockIMEKitTests/ConfigBackupTests.swift b/Tests/LockIMEKitTests/ConfigBackupTests.swift index 3ba1944..5bb586b 100644 --- a/Tests/LockIMEKitTests/ConfigBackupTests.swift +++ b/Tests/LockIMEKitTests/ConfigBackupTests.swift @@ -65,6 +65,33 @@ struct ConfigBackupTests { #expect(try result.get() == backup) } + @Test("switch rules (app .switched, url .switchOnce) survive make→encode→read") + func switchRulesRoundTrip() throws { + let config = LockConfiguration( + isEnabled: true, + defaultSourceID: "com.apple.keylayout.US", + appRules: [AppRule(bundleID: "com.apple.Terminal", mode: .switched, lockedSourceID: "com.apple.keylayout.ABC")], + enhancedModeEnabled: true, + urlRules: [URLRule(hostPattern: "github.com", lockedSourceID: "com.apple.inputmethod.SCIM.ITABC", action: .switchOnce)] + ) + let backup = ConfigBackup.make(from: config, appVersion: "1", sourceNames: names) + let decoded = try ConfigBackup.read(backup.encoded()).get() + #expect(decoded.payload.appRules.first?.mode == .switched) + #expect(decoded.payload.urlRules.first?.action == .switchOnce) + // A switched app rule pins a source, so it is catalogued like a lock. + #expect(decoded.payload.sourceNames["com.apple.keylayout.ABC"] == "ABC") + } + + @Test("a .lockime URL rule without an action decodes to .lock (lenient)") + func urlRuleWithoutActionDecodesAsLock() throws { + let json = """ + {"format": "\(ConfigBackup.formatIdentifier)", "minReader": 1, "appVersion": "1", + "payload": {"urlRules": [{"hostPattern": "github.com", "lockedSourceID": "com.apple.keylayout.ABC"}]}} + """ + let backup = try ConfigBackup.read(Data(json.utf8)).get() + #expect(backup.payload.urlRules.first?.action == .lock) + } + @Test("encoded() is human-readable pretty JSON with unescaped slashes") func prettyEncoding() throws { let backup = ConfigBackup.make(from: sampleConfig(), appVersion: "1", sourceNames: names) diff --git a/Tests/LockIMEKitTests/ImportPlanTests.swift b/Tests/LockIMEKitTests/ImportPlanTests.swift index 8512bcb..8259cfd 100644 --- a/Tests/LockIMEKitTests/ImportPlanTests.swift +++ b/Tests/LockIMEKitTests/ImportPlanTests.swift @@ -517,4 +517,119 @@ struct ImportPlanTests { #expect(!plan.summary().hasEffect) #expect(plan.resolvedConfiguration() == config) } + + // MARK: - Switch action + + @Test("a new switched app rule carries its mode and source") + func switchedAppRuleIsNew() { + let plan = ImportPlan(current: .default, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .switched, lockedSourceID: "ABC")] + ), installedSources: installed) + let item = item(plan, "app:com.a") + #expect(item?.status == .new) + #expect(item?.fileMode == .switched) + #expect(item?.fileSource == "ABC") + #expect(plan.resolvedConfiguration().rule(for: "com.a")?.mode == .switched) + } + + @Test("lock vs switch on the same app source is a conflict") + func lockVsSwitchAppIsConflict() { + 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: .switched, lockedSourceID: "ABC")] // same source, different action + ), installedSources: installed) + let conflict = item(plan, "app:com.a") + #expect(conflict?.status == .conflict) + #expect(conflict?.localMode == .locked) + #expect(conflict?.fileMode == .switched) + } + + @Test("a new switch URL rule carries its action; lock vs switch is a URL conflict") + func switchURLRuleNewAndConflict() { + // New switch URL rule. + let newPlan = ImportPlan(current: .default, backup: backup( + urlRules: [BackupURLRule(hostPattern: "github.com", lockedSourceID: "US", action: .switchOnce)] + ), installedSources: installed) + let newItem = item(newPlan, "url:github.com") + #expect(newItem?.status == .new) + #expect(newItem?.fileAction == .switchOnce) + #expect(newPlan.resolvedConfiguration().urlRules.first?.action == .switchOnce) + + // Same host + same source but different action → conflict. + let current = LockConfiguration(urlRules: [URLRule(hostPattern: "github.com", lockedSourceID: "US", action: .lock)]) + let conflictPlan = ImportPlan(current: current, backup: backup( + urlRules: [BackupURLRule(hostPattern: "github.com", lockedSourceID: "US", action: .switchOnce)] + ), installedSources: installed) + let conflict = item(conflictPlan, "url:github.com") + #expect(conflict?.status == .conflict) + #expect(conflict?.localAction == .lock) + #expect(conflict?.fileAction == .switchOnce) + } + + @Test("Replace preserves the file's switch action for app and URL rules") + func replacePreservesSwitchAction() { + let current = LockConfiguration( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "US")], + urlRules: [URLRule(hostPattern: "github.com", lockedSourceID: "US", action: .lock)] + ) + var plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .switched, lockedSourceID: "ABC")], + urlRules: [BackupURLRule(hostPattern: "github.com", lockedSourceID: "ABC", action: .switchOnce)] + ), installedSources: installed) + plan.mode = .replace + let resolved = plan.resolvedConfiguration() + #expect(resolved.rule(for: "com.a")?.mode == .switched) + #expect(resolved.rule(for: "com.a")?.lockedSourceID == "ABC") + #expect(resolved.urlRules.first?.action == .switchOnce) + #expect(resolved.urlRules.first?.lockedSourceID == "ABC") + } + + @Test("a lock→switch change tallies as updated, not added") + func lockToSwitchTalliesAsUpdated() { + let current = LockConfiguration( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "ABC")], + urlRules: [URLRule(hostPattern: "github.com", lockedSourceID: "US", action: .lock)] + ) + var plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .switched, lockedSourceID: "ABC")], + urlRules: [BackupURLRule(hostPattern: "github.com", lockedSourceID: "US", action: .switchOnce)] + ), installedSources: installed) + plan.mode = .replace + let summary = plan.summary() + #expect(summary.updated == 2) // app + url rebind + #expect(summary.added == 0) + } + + @Test("a missing source on a switched app rule surfaces like a lock") + func switchedMissingSourceSurfaces() { + let plan = ImportPlan(current: .default, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .switched, lockedSourceID: "Missing")] + ), installedSources: installed) + #expect(plan.missingItems.count == 1) + #expect(plan.summary().inactive == 1) + } + + @Test("round-trip of a switch config is a pure no-op (action survives every path)") + func switchExportImportRoundTrip() { + let config = LockConfiguration( + isEnabled: true, + defaultSourceID: "US", + appRules: [AppRule(bundleID: "com.a", mode: .switched, lockedSourceID: "ABC")], + enhancedModeEnabled: true, + urlRules: [URLRule(hostPattern: "github.com", lockedSourceID: "US", action: .switchOnce)] + ) + let exported = ConfigBackup.make(from: config, appVersion: "1", sourceNames: ["US": "U.S.", "ABC": "ABC"]) + var plan = ImportPlan(current: config, backup: exported, installedSources: installed) + #expect(!plan.summary().hasEffect) + // Merge keeps the local rules verbatim (including the URL rule's id). + #expect(plan.resolvedConfiguration() == config) + // Replace re-asserts the file's rules; it regenerates URL ids (the runtime + // identity isn't portable — see BackupURLRule), so compare the portable + // fields rather than full equality. The switch action must survive. + plan.mode = .replace + let replaced = plan.resolvedConfiguration() + #expect(replaced.rule(for: "com.a")?.mode == .switched) + #expect(replaced.urlRules.first?.action == .switchOnce) + #expect(replaced.urlRules.first?.lockedSourceID == "US") + } } diff --git a/Tests/LockIMEKitTests/LockConfigurationTests.swift b/Tests/LockIMEKitTests/LockConfigurationTests.swift index d354fcb..0138da5 100644 --- a/Tests/LockIMEKitTests/LockConfigurationTests.swift +++ b/Tests/LockIMEKitTests/LockConfigurationTests.swift @@ -11,10 +11,26 @@ struct LockConfigurationTests { #expect(mode.id == mode.rawValue) } #expect(AppRuleMode.locked.id == "locked") + #expect(AppRuleMode.switched.id == "switched") #expect(AppRuleMode.ignored.id == "ignored") #expect(AppRuleMode.useDefault.id == "useDefault") } + @Test("only .locked and .switched pin a source") + func appRuleModePinsSource() { + #expect(AppRuleMode.locked.pinsSource) + #expect(AppRuleMode.switched.pinsSource) + #expect(!AppRuleMode.ignored.pinsSource) + #expect(!AppRuleMode.useDefault.pinsSource) + } + + @Test("RuleAction.id is its raw value") + func ruleActionID() { + #expect(RuleAction.lock.id == "lock") + #expect(RuleAction.switchOnce.id == "switchOnce") + #expect(RuleAction.allCases.allSatisfy { $0.id == $0.rawValue }) + } + @Test("AppRule.id is its bundle identifier") func appRuleID() { let rule = AppRule(bundleID: "com.apple.Terminal", mode: .locked, lockedSourceID: "com.apple.keylayout.ABC") @@ -80,4 +96,51 @@ struct LockConfigurationTests { let decoded = try JSONDecoder().decode(LockConfiguration.self, from: data) #expect(decoded == original) } + + @Test("a configuration with switch rules round-trips through Codable") + func roundTripsSwitch() throws { + let original = LockConfiguration( + isEnabled: true, + defaultSourceID: "com.apple.keylayout.US", + appRules: [ + AppRule(bundleID: "com.apple.Terminal", mode: .switched, lockedSourceID: "com.apple.keylayout.ABC"), + AppRule(bundleID: "com.apple.Safari", mode: .locked, lockedSourceID: "com.apple.keylayout.US"), + ], + enhancedModeEnabled: true, + urlRules: [ + URLRule(hostPattern: "github.com", lockedSourceID: "com.apple.inputmethod.SCIM.ITABC", action: .switchOnce), + URLRule(hostPattern: "example.com", lockedSourceID: "com.apple.keylayout.US", action: .lock), + ] + ) + let decoded = try JSONDecoder().decode(LockConfiguration.self, from: try JSONEncoder().encode(original)) + #expect(decoded == original) + #expect(decoded.rule(for: "com.apple.Terminal")?.mode == .switched) + #expect(decoded.urlRules.first(where: { $0.hostPattern == "github.com" })?.action == .switchOnce) + } + + // The killer back-compat path: a v1.x LockConfiguration blob whose appRules / + // urlRules ARRAYS contain elements with NO `action` key. `init(from:)` decodes + // each array with `decodeIfPresent`, which *propagates* a per-element throw — + // so without the lenient URLRule decoder a single legacy URL rule would abort + // the whole load and silently drop every rule (see RuleStore's `try?`). + @Test("legacy config whose rule arrays omit action decodes every rule as .lock") + func decodesLegacyArraysWithoutAction() throws { + let json = """ + {"isEnabled": true, "defaultSourceID": "com.apple.keylayout.US", + "appRules": [ + {"bundleID": "com.a", "mode": "locked", "lockedSourceID": "com.apple.keylayout.ABC"}, + {"bundleID": "com.b", "mode": "ignored"} + ], + "enhancedModeEnabled": true, + "urlRules": [ + {"id": "\(UUID().uuidString)", "hostPattern": "github.com", "lockedSourceID": "com.apple.keylayout.ABC"}, + {"id": "\(UUID().uuidString)", "hostPattern": "example.com", "lockedSourceID": "com.apple.keylayout.US"} + ]} + """ + let config = try JSONDecoder().decode(LockConfiguration.self, from: Data(json.utf8)) + #expect(config.appRules.count == 2) // nothing dropped + #expect(config.urlRules.count == 2) // nothing dropped + #expect(config.urlRules.allSatisfy { $0.action == .lock }) + #expect(config.rule(for: "com.a")?.mode == .locked) + } } diff --git a/Tests/LockIMEKitTests/LockControllerTests.swift b/Tests/LockIMEKitTests/LockControllerTests.swift index 5c9f88d..b5444ba 100644 --- a/Tests/LockIMEKitTests/LockControllerTests.swift +++ b/Tests/LockIMEKitTests/LockControllerTests.swift @@ -207,4 +207,83 @@ struct LockControllerTests { #expect(provider.selectCalls == [us]) #expect(controller.activationCount == 0) } + + // MARK: - One-shot switch + + @Test("switchOnce switches once and installs NO standing target") + func switchOnceSwitchesAndLeavesNoTarget() { + let (controller, provider, _) = make(current: abc, enabled: true) + controller.switchOnce(us, reason: .appActivated) + #expect(provider.selectCalls == [us]) + #expect(provider.current == us) + #expect(controller.activationCount == 1) + #expect(controller.target == nil) // crucial: no standing lock + } + + @Test("switchOnce is a no-op when already on the target") + func switchOnceNoOpWhenOnTarget() { + let (controller, provider, _) = make(current: us, enabled: true) + controller.switchOnce(us) + #expect(provider.selectCalls.isEmpty) + #expect(controller.activationCount == 0) + #expect(controller.target == nil) + } + + @Test("switchOnce is a no-op (no crash) when the current source is unknown") + func switchOnceNoOpWhenCurrentNil() { + let provider = MockInputSourceProvider(current: nil, sources: [.stub(us.rawValue)]) + let controller = LockController(provider: provider, isEnabled: true) + controller.switchOnce(us) + #expect(provider.selectCalls.isEmpty) + #expect(controller.activationCount == 0) + #expect(controller.target == nil) + } + + @Test("after switchOnce a later source change is never reverted") + func switchOnceDoesNotRevert() { + let (controller, provider, uptime) = make(current: abc, enabled: true) + controller.switchOnce(us) + #expect(provider.selectCalls == [us]) + + // User switches away, well outside any settle window. + provider.current = abc + uptime.advance(by: 1.0) + controller.selectedSourceDidChange() + #expect(provider.selectCalls == [us]) // no revert — the user keeps abc + #expect(provider.current == abc) + } + + @Test("switchOnce ignores controller.isEnabled (the engine gates on config)") + func switchOnceForcesWhileControllerDisabled() { + // The enable path re-resolves before the controller flips on, so the + // one-shot must fire regardless of the controller's own isEnabled. + let (controller, provider, _) = make(current: abc, enabled: false) + controller.switchOnce(us) + #expect(provider.selectCalls == [us]) + #expect(controller.activationCount == 1) + } + + @Test("a failed switchOnce select does not count") + func switchOnceFailedSelectNotCounted() { + let (controller, provider, _) = make(current: abc, enabled: true) + provider.selectSucceeds[us] = false + controller.switchOnce(us) + #expect(provider.selectCalls == [us]) + #expect(controller.activationCount == 0) + } + + @Test("switchOnce emits one event with the rule context and given reason") + func switchOnceEmitsContext() { + let (controller, _, _) = make(current: abc, enabled: true) + var events: [ActivationEvent] = [] + controller.onActivation = { events.append($0) } + controller.switchOnce(us, reason: .urlMatched, bundleID: "com.foo.Bar", ruleSource: .urlRule, matchedHost: "github.com") + #expect(events.count == 1) + #expect(events.first?.inputSource == us) + #expect(events.first?.reason == .urlMatched) + #expect(events.first?.ruleSource == .urlRule) + #expect(events.first?.triggeringBundleID == "com.foo.Bar") + #expect(events.first?.matchedHost == "github.com") + #expect(events.first?.fromSourceName == abc.rawValue) + } } diff --git a/Tests/LockIMEKitTests/LockEngineTests.swift b/Tests/LockIMEKitTests/LockEngineTests.swift index 1675ba4..ca72647 100644 --- a/Tests/LockIMEKitTests/LockEngineTests.swift +++ b/Tests/LockIMEKitTests/LockEngineTests.swift @@ -560,3 +560,315 @@ struct LockEngineReasonTests { #expect(provider.current == us) // left where the user put it } } + +@MainActor +@Suite("LockEngine switch action") +struct LockEngineSwitchTests { + private let us: InputSourceID = "com.apple.keylayout.US" + private let abc: InputSourceID = "com.apple.keylayout.ABC" + private let pinyin: InputSourceID = "com.apple.inputmethod.SCIM.ITABC" + private let spotlight = "com.apple.Spotlight" + + private func makeEngine( + current: InputSourceID, + frontmost: String? + ) -> (LockEngine, MockInputSourceProvider, MockFrontmostMonitor, MockFloatingMonitor) { + let provider = MockInputSourceProvider( + current: current, + sources: [.stub(us.rawValue), .stub(abc.rawValue), .stub(pinyin.rawValue, cjkv: true)] + ) + let monitor = MockFrontmostMonitor(bundleID: frontmost) + let floating = MockFloatingMonitor() + let engine = LockEngine(provider: provider, appMonitor: monitor, floatingAppMonitor: floating) + engine.start() + return (engine, provider, monitor, floating) + } + + @Test("a switch app rule switches once on activation, installing no standing lock") + func switchAppFiresOnce() { + let (engine, provider, monitor, _) = makeEngine(current: us, frontmost: "com.foo.App") + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [AppRule(bundleID: "com.apple.Terminal", mode: .switched, lockedSourceID: abc)] + )) + #expect(provider.current == us) // foo → default lock + + monitor.activate("com.apple.Terminal") + #expect(provider.current == abc) // switched once + #expect(provider.selectCalls == [abc]) + } + + @Test("a switch is not re-fired on re-activation, so a manual switch-away sticks") + func switchNotReFiredOnReactivation() { + let (engine, provider, monitor, _) = makeEngine(current: us, frontmost: "com.apple.Terminal") + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [AppRule(bundleID: "com.apple.Terminal", mode: .switched, lockedSourceID: abc)] + )) + #expect(provider.current == abc) // switched on startup-apply + + // User manually switches away. + provider.current = us + // Re-activating the SAME app must NOT re-switch (same SwitchKey). + monitor.activate("com.apple.Terminal") + #expect(provider.current == us) // left where the user put it + #expect(provider.selectCalls == [abc]) // no second select + } + + @Test("leaving a switch app and returning re-arms the one-shot") + func switchReArmsAfterLeaving() { + let (engine, provider, monitor, _) = makeEngine(current: us, frontmost: "com.foo.App") + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [AppRule(bundleID: "com.apple.Terminal", mode: .switched, lockedSourceID: abc)] + )) + monitor.activate("com.apple.Terminal") + #expect(provider.current == abc) // switched + + provider.current = us // user switches away + monitor.activate("com.foo.App") // leave to a default-lock app → re-arm + #expect(provider.current == us) + monitor.activate("com.apple.Terminal") // return → genuine re-entry + #expect(provider.current == abc) // switched again + } + + @Test("the master toggle gates the switch (off = no switch)") + func masterOffGatesSwitch() { + let (engine, provider, _, _) = makeEngine(current: us, frontmost: "com.apple.Terminal") + engine.apply(LockConfiguration( + isEnabled: false, + defaultSourceID: us, + appRules: [AppRule(bundleID: "com.apple.Terminal", mode: .switched, lockedSourceID: abc)] + )) + #expect(provider.selectCalls.isEmpty) + #expect(provider.current == us) + } + + @Test("toggling the lock OFF then ON re-fires the one-shot; two ON applies do not") + func offOnReFiresButRepeatOnDoesNot() { + let (engine, provider, _, _) = makeEngine(current: us, frontmost: "com.apple.Terminal") + let on = LockConfiguration( + isEnabled: true, defaultSourceID: us, + appRules: [AppRule(bundleID: "com.apple.Terminal", mode: .switched, lockedSourceID: abc)] + ) + var off = on; off.isEnabled = false + + engine.apply(on, reason: .lockEngaged) + #expect(provider.current == abc) // first switch + #expect(provider.selectCalls.count == 1) + + // Same enabled config re-applied (e.g. a config edit): no re-fire. + provider.current = us + engine.apply(on, reason: .configChanged) + #expect(provider.current == us) + #expect(provider.selectCalls.count == 1) // key intact → not re-fired + + // OFF clears the key; ON is then a genuine re-entry → re-fires. + engine.apply(off, reason: .lockEngaged) + engine.apply(on, reason: .lockEngaged) + #expect(provider.current == abc) + #expect(provider.selectCalls.count == 2) + } + + // THE BLOCKER (issue-#9-shaped): a launcher overlay shadows the frontmost + // app, flipping effectiveBundleID to the launcher. The non-switch resolution + // it lands on must NOT re-arm the one-shot, or dismissing the overlay would + // yank a user who had manually switched away back to the switch target. + @Test("a launcher overlay over an already-switched app does not re-fire on dismiss") + func launcherOverlayDoesNotReFireSwitch() { + let (engine, provider, _, floating) = makeEngine(current: us, frontmost: "com.foo.App") + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [AppRule(bundleID: "com.foo.App", mode: .switched, lockedSourceID: abc)] + )) + #expect(provider.current == abc) // foo switched once on apply + + provider.current = us // user manually switches away + floating.setLauncher(spotlight) // overlay → resolves to default lock (us) + #expect(provider.current == us) + floating.setLauncher(nil) // dismiss → back to foo + #expect(provider.current == us) // NOT re-yanked to abc — the fix + } + + @Test("the launcher-excursion guard holds even with no global default") + func launcherOverlayNoDefaultDoesNotReFire() { + let (engine, provider, _, floating) = makeEngine(current: us, frontmost: "com.foo.App") + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: nil, // launcher resolves to .noTarget, not .lock + appRules: [AppRule(bundleID: "com.foo.App", mode: .switched, lockedSourceID: abc)] + )) + #expect(provider.current == abc) + + provider.current = us + floating.setLauncher(spotlight) // .noTarget arm — must also preserve the key + floating.setLauncher(nil) + #expect(provider.current == us) // still not re-yanked + } + + @Test("a switch rule keyed to the launcher itself fires on focus") + func launcherOwnSwitchRuleFires() { + let (engine, provider, _, floating) = makeEngine(current: us, frontmost: "com.foo.App") + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [AppRule(bundleID: spotlight, mode: .switched, lockedSourceID: abc)] + )) + #expect(provider.current == us) // foo → default; overlay not up yet + + floating.setLauncher(spotlight) + #expect(provider.current == abc) // the launcher's own switch fired + } + + // A launcher whose OWN rule is .switched must not clobber the underlying + // app's one-shot memory: its switch fires on focus, but on dismiss the + // underlying (already-switched, manually-changed-away) app must NOT re-fire. + // The launcher excursion uses a separate dedup slot for exactly this reason. + @Test("a launcher with its own switch rule does not re-yank the underlying app on dismiss") + func launcherOwnSwitchRuleDoesNotReYankUnderlying() { + let (engine, provider, _, floating) = makeEngine(current: us, frontmost: "com.foo.App") + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [ + AppRule(bundleID: "com.foo.App", mode: .switched, lockedSourceID: abc), + AppRule(bundleID: spotlight, mode: .switched, lockedSourceID: pinyin), + ] + )) + #expect(provider.current == abc) // foo switched once on apply + + provider.current = us // user manually switches away + floating.setLauncher(spotlight) // the launcher's OWN switch fires… + #expect(provider.current == pinyin) + provider.current = us // …user switches away again + floating.setLauncher(nil) // dismiss → back to foo + #expect(provider.current == us) // NOT re-yanked to abc — separate slots + } + + @Test("switch and lock apps coexist; leaving to a lock re-arms the switch") + func switchThenLockThenSwitch() { + let (engine, provider, monitor, _) = makeEngine(current: us, frontmost: "com.switch.App") + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [ + AppRule(bundleID: "com.switch.App", mode: .switched, lockedSourceID: abc), + AppRule(bundleID: "com.lock.App", mode: .locked, lockedSourceID: pinyin), + ] + )) + #expect(provider.current == abc) // switched once on apply + + monitor.activate("com.lock.App") + #expect(provider.current == pinyin) // the lock app pins its source + + // Returning to the switch app is a genuine re-entry (the .lock arm + // re-armed the key), so the one-shot fires again — and it clears the + // standing lock target left by the lock app along the way. + monitor.activate("com.switch.App") + #expect(provider.current == abc) + } + + @Test("a switch URL rule switches once; a re-resolve over the same host does not re-fire") + func switchURLRuleFiresOnce() { + let provider = MockInputSourceProvider( + current: us, + sources: [.stub(us.rawValue), .stub(abc.rawValue), .stub(pinyin.rawValue, cjkv: true)] + ) + let monitor = MockFrontmostMonitor(bundleID: "com.apple.Safari") + let urls = MockBrowserURLProvider(url: "https://github.com/x") + let engine = LockEngine(provider: provider, appMonitor: monitor, urlProvider: urls) + engine.start() + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + enhancedModeEnabled: true, + urlRules: [URLRule(hostPattern: "github.com", lockedSourceID: pinyin, action: .switchOnce)] + )) + #expect(provider.current == pinyin) // switched once on the URL match + + // User switches away; re-resolving the same host (a poll / re-activation) + // must not re-fire. + provider.current = us + monitor.activate("com.apple.Safari") + #expect(provider.current == us) + #expect(provider.selectCalls == [pinyin]) + } + + @Test("a switch is logged through the same funnel: app→appActivated, url→urlMatched") + func switchActivationReasons() { + // App switch on a real activation → .appActivated / .appRule. + let (engine, _, monitor, _) = makeEngine(current: us, frontmost: "com.foo.App") + var events: [ActivationEvent] = [] + engine.onActivation = { events.append($0) } + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [AppRule(bundleID: "com.apple.Terminal", mode: .switched, lockedSourceID: abc)] + ), reason: .startupApplied) + monitor.activate("com.apple.Terminal") + #expect(events.last?.reason == .appActivated) + #expect(events.last?.ruleSource == .appRule) + #expect(events.last?.inputSource == abc) + + // A URL switch fired by the apply itself keeps the apply reason… + let provider2 = MockInputSourceProvider( + current: us, sources: [.stub(us.rawValue), .stub(abc.rawValue), .stub(pinyin.rawValue, cjkv: true)] + ) + let monitor2 = MockFrontmostMonitor(bundleID: "com.apple.Safari") + let urls = MockBrowserURLProvider(url: "https://gist.github.com/x") + let engine2 = LockEngine(provider: provider2, appMonitor: monitor2, urlProvider: urls) + var events2: [ActivationEvent] = [] + engine2.onActivation = { events2.append($0) } + engine2.start() + engine2.apply(LockConfiguration( + isEnabled: true, defaultSourceID: us, enhancedModeEnabled: true, + urlRules: [ + URLRule(hostPattern: "github.com", lockedSourceID: pinyin, action: .switchOnce), + URLRule(hostPattern: "translate.google.com", lockedSourceID: abc, action: .switchOnce), + ] + ), reason: .startupApplied) + #expect(events2.last?.reason == .startupApplied) // the apply itself is the why + #expect(events2.last?.ruleSource == .urlRule) + #expect(events2.last?.matchedHost == "github.com") + + // …but a URL switch fired by a navigation (a non-apply trigger) is logged + // as .urlMatched — the URL is the why. Navigate to the other rule's host + // and re-activate so the engine re-resolves to a *different* SwitchKey. + urls.url = "https://translate.google.com/?sl=en" + monitor2.activate("com.apple.Safari") + #expect(events2.last?.reason == .urlMatched) + #expect(events2.last?.ruleSource == .urlRule) + #expect(events2.last?.matchedHost == "translate.google.com") + #expect(events2.last?.inputSource == abc) + } + + @Test("a switch is deferred (not lost) when the current source is unreadable") + func switchDeferredWhenCurrentUnknown() { + // currentSourceID() can transiently fail (TIS); the one-shot must stay + // eligible rather than be marked fired with no switch having happened. + let provider = MockInputSourceProvider( + current: nil, sources: [.stub(us.rawValue), .stub(abc.rawValue)] + ) + let monitor = MockFrontmostMonitor(bundleID: "com.apple.Terminal") + let engine = LockEngine(provider: provider, appMonitor: monitor) + engine.start() + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [AppRule(bundleID: "com.apple.Terminal", mode: .switched, lockedSourceID: abc)] + )) + #expect(provider.selectCalls.isEmpty) // source unknown → no switch yet + #expect(provider.current == nil) + + // The source becomes readable; the next reevaluation fires the deferred + // one-shot (the key was not consumed). + provider.current = us + monitor.activate("com.apple.Terminal") + #expect(provider.current == abc) + #expect(provider.selectCalls == [abc]) + } +} diff --git a/Tests/LockIMEKitTests/RuleResolverTests.swift b/Tests/LockIMEKitTests/RuleResolverTests.swift index 7930db8..8b4c09f 100644 --- a/Tests/LockIMEKitTests/RuleResolverTests.swift +++ b/Tests/LockIMEKitTests/RuleResolverTests.swift @@ -72,8 +72,35 @@ struct RuleResolverTests { appRules: [AppRule(bundleID: "com.apple.Safari", mode: .locked, lockedSourceID: abc)] ) #expect( - RuleResolver.resolve(config: config, frontmostBundleID: "com.apple.Safari", urlMatch: pinyin) + RuleResolver.resolve(config: config, frontmostBundleID: "com.apple.Safari", urlMatch: (pinyin, .lock)) == .lock(pinyin, .urlRule) ) + // A switch-action URL match yields a one-shot switch, still outranking the app lock. + #expect( + RuleResolver.resolve(config: config, frontmostBundleID: "com.apple.Safari", urlMatch: (pinyin, .switchOnce)) + == .switchOnce(pinyin, .urlRule) + ) + } + + @Test("a switched app rule yields a one-shot switch") + func switchedAppRule() { + let config = LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [AppRule(bundleID: "com.apple.Terminal", mode: .switched, lockedSourceID: abc)] + ) + #expect(RuleResolver.resolve(config: config, frontmostBundleID: "com.apple.Terminal") == .switchOnce(abc, .appRule)) + // A different app still uses the (lock-only) global default. + #expect(RuleResolver.resolve(config: config, frontmostBundleID: "com.other.App") == .lock(us, .globalDefault)) + } + + @Test("a switched rule with no source set falls back to the default lock") + func switchedWithoutSourceFallsBack() { + let config = LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [AppRule(bundleID: "com.foo.App", mode: .switched, lockedSourceID: nil)] + ) + #expect(RuleResolver.resolve(config: config, frontmostBundleID: "com.foo.App") == .lock(us, .globalDefault)) } } diff --git a/Tests/LockIMEKitTests/RuleStoreTests.swift b/Tests/LockIMEKitTests/RuleStoreTests.swift index 9556361..11279a6 100644 --- a/Tests/LockIMEKitTests/RuleStoreTests.swift +++ b/Tests/LockIMEKitTests/RuleStoreTests.swift @@ -24,14 +24,44 @@ struct RuleStoreTests { defaultSourceID: "com.apple.keylayout.US", appRules: [ AppRule(bundleID: "com.apple.Terminal", mode: .locked, lockedSourceID: "com.apple.keylayout.ABC"), + AppRule(bundleID: "com.switch.App", mode: .switched, lockedSourceID: "com.apple.keylayout.US"), AppRule(bundleID: "com.game.App", mode: .ignored), AppRule(bundleID: "com.foo.App", mode: .useDefault), + ], + enhancedModeEnabled: true, + urlRules: [ + URLRule(hostPattern: "github.com", lockedSourceID: "com.apple.keylayout.ABC", action: .switchOnce), + URLRule(hostPattern: "example.com", lockedSourceID: "com.apple.keylayout.US", action: .lock), ] ) store.save(config) #expect(store.load() == config) } + // The single most important back-compat test: write v1.x bytes (URL rules + // with NO `action` key) straight into UserDefaults, bypassing save(), and + // assert load() returns the rules with `.lock` — NOT `.default`. Without the + // lenient URLRule decoder, the per-element throw would propagate out of + // `decodeIfPresent([URLRule])`, RuleStore.load()'s `try?` would swallow it, + // and the upgrading user would silently lose EVERY rule. + @Test("legacy v1.x bytes without a URL action load with .lock, never .default") + func loadsLegacyBytesWithoutAction() { + let defaults = freshDefaults() + let store = RuleStore(defaults: defaults) + let json = """ + {"isEnabled": true, "defaultSourceID": "com.apple.keylayout.US", + "appRules": [{"bundleID": "com.a", "mode": "locked", "lockedSourceID": "com.apple.keylayout.ABC"}], + "enhancedModeEnabled": true, + "urlRules": [{"id": "\(UUID().uuidString)", "hostPattern": "github.com", "lockedSourceID": "com.apple.keylayout.ABC"}]} + """ + defaults.set(Data(json.utf8), forKey: "lockConfiguration") + let loaded = store.load() + #expect(loaded != .default) // nothing was swallowed + #expect(loaded.appRules.count == 1) + #expect(loaded.urlRules.count == 1) + #expect(loaded.urlRules.first?.action == .lock) + } + @Test("a later save overwrites an earlier one") func overwrite() { let defaults = freshDefaults() diff --git a/docs/DESIGN.md b/docs/DESIGN.md index fc66a98..67ab6b6 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -178,6 +178,17 @@ the grant watcher. `.body` + bundle ID `.caption2 .secondary`). Empty state = `ContentUnavailableView` with an action. Rows `.transition(.move(edge:.top) .combined(with:.opacity))`; wrap upsert/remove in `withAnimation(DS.Motion.list)`. + Each source-pinning rule carries a **lock vs switch** action: *lock* (`.locked` + / URL `.lock`) continuously re-applies the source while the rule is in force; + *switch* (`AppRuleMode.switched` / URL `RuleAction.switchOnce`) fires a one-shot + switch on entry and then releases, so the user may change the source and is + never reverted. App rules express it as a fourth mode-picker option ("Switch + to", beside Lock to / Ignore / Use default); URL rows carry a small Lock to / + Switch to picker. The **global default stays lock-only**. The one-shot's + fire-exactly-once-per-entry is owned by `LockEngine` (an in-memory transition + key, with a separate slot for a launcher overlay's own switch so an excursion + never re-yanks the underlying app); the kit's `LockController.switchOnce` + performs the switch without installing a standing target. - **Shortcuts:** native recorder rows in two sections — **Global** (toggle lock, lock to previous/next input source) and **Current app** (cycle, or remove, the frontmost app's rule). Recorder titles must be `LocalizedStringKey(...)`, not a diff --git a/docs/README/README.de.md b/docs/README/README.de.md index 47a21f6..ef49b55 100644 --- a/docs/README/README.de.md +++ b/docs/README/README.de.md @@ -50,6 +50,7 @@ Oder lade die zu deinem Mac passende `.dmg`-Datei (`-arm64` für Apple silicon, ## Features - **Sofortiges Wieder-Sperren** — schaltet die aktive Eingabequelle in dem Moment zurück, in dem du (oder eine andere App) sie wechselst, global oder pro App. +- **Sperren oder wechseln** — Regeln pro App und pro URL können eine Eingabequelle *sperren* (bei jeder Abweichung erneut angewendet) oder einmalig dorthin *wechseln*, sobald du die App oder Seite aktivierst, und dich danach frei wählen lassen. - **Steuerung über die Menüleiste** — aktivieren/deaktivieren, die gesperrte Eingabequelle wechseln, die aktuelle Eingabequelle einsehen und die Auslösungen direkt in der Menüleiste verfolgen. - **Tastatur-Kurzbefehle** — konfigurierbare globale Kurzbefehle zum Ein- und Ausschalten der Sperre und zum Durchschalten der gesperrten Eingabequelle sowie App-spezifische Kurzbefehle, um die Regel der vordersten App durchzuschalten oder zu entfernen. - **Start bei Anmeldung** — startet automatisch beim Anmelden (standardmäßig aus). diff --git a/docs/README/README.es.md b/docs/README/README.es.md index b9c39b7..a1cf797 100644 --- a/docs/README/README.es.md +++ b/docs/README/README.es.md @@ -54,6 +54,7 @@ En cualquier caso, la aplicación se mantiene actualizada mediante Sparkle. ## Features - **Rebloqueo instantáneo** — devuelve la fuente de entrada activa a la bloqueada en el momento en que tú (u otra aplicación) la cambias, globalmente o por aplicación. +- **Bloquear o cambiar** — las reglas por aplicación y por URL pueden *bloquear* una fuente de entrada (se vuelve a aplicar cada vez que se desvía) o solo *cambiar* a ella una vez cuando activas la aplicación o la página, y luego dejarte cambiarla libremente. - **Control desde la barra de menús** — activa/desactiva, cambia la fuente de entrada bloqueada, consulta la fuente actual y sigue el contador de activaciones desde la barra de menús. - **Atajos de teclado** — atajos globales configurables para activar/desactivar el bloqueo y recorrer la fuente de entrada bloqueada, además de atajos por aplicación para recorrer o eliminar la regla de la aplicación en primer plano. - **Arranque al iniciar sesión** — se inicia automáticamente al iniciar sesión (desactivado por defecto). diff --git a/docs/README/README.fr.md b/docs/README/README.fr.md index 64dafe9..a658ae5 100644 --- a/docs/README/README.fr.md +++ b/docs/README/README.fr.md @@ -50,6 +50,7 @@ Ou téléchargez le `.dmg` correspondant à votre Mac (`-arm64` pour Apple silic ## Features - **Reverrouillage instantané** — rebascule la source de saisie active dès que vous (ou une autre application) la changez, globalement ou par application. +- **Verrouiller ou basculer** — les règles par application et par URL peuvent *verrouiller* une source de saisie (réappliquée dès qu'elle dévie) ou simplement y *basculer* une fois lorsque vous activez l'application ou la page, puis vous laisser libre de la changer. - **Contrôle depuis la barre de menus** — activer/désactiver, changer la source de saisie verrouillée, voir la source actuelle et suivre le nombre d'activations depuis la barre de menus. - **Raccourcis clavier** — des raccourcis globaux configurables pour activer/désactiver le verrouillage et faire défiler la source de saisie verrouillée, ainsi que des raccourcis par application pour faire défiler ou supprimer la règle de l’application au premier plan. - **Lancement à la connexion** — démarre automatiquement à l'ouverture de session (désactivé par défaut). diff --git a/docs/README/README.ja.md b/docs/README/README.ja.md index 5a6b835..7787e11 100644 --- a/docs/README/README.ja.md +++ b/docs/README/README.ja.md @@ -50,6 +50,7 @@ brew install --cask oomol-lab/tap/lockime ## Features - **即時再ロック**——あなた(または他のアプリ)が入力ソースを切り替えた瞬間に、ロック中のものへ切り戻します。グローバルにも、アプリごとにも。 +- **ロックまたは切り替え**——アプリごと・URL ごとのルールは、入力ソースを*ロック*(ずれるたびに切り戻す)することも、アプリやページをアクティブにしたときに一度だけ*切り替え*て、その後は自由に変更できるようにすることもできます。 - **メニューバーからの操作**——メニューバーから有効化/無効化、ロック中の入力ソースの切り替え、現在の入力ソースの確認、作動回数の追跡。 - **キーボードショートカット**——設定可能なグローバルショートカットでロックのオン/オフやロック中の入力ソースの切り替え(前 / 次)ができ、さらに最前面のアプリのルールを切り替えたり解除したりするアプリごとのショートカットも利用できます。 - **ログイン時に起動**——ログイン時に自動的に起動(デフォルトはオフ)。 diff --git a/docs/README/README.pt.md b/docs/README/README.pt.md index afa25cf..800a5f9 100644 --- a/docs/README/README.pt.md +++ b/docs/README/README.pt.md @@ -54,6 +54,7 @@ De qualquer forma, o app se mantém atualizado sozinho via Sparkle. ## Features - **Rebloqueio instantâneo** — devolve a fonte de entrada ativa para a bloqueada no momento em que você (ou outro app) a troca, globalmente ou por app. +- **Bloquear ou alternar** — as regras por app e por URL podem *bloquear* uma fonte de entrada (reaplicada sempre que ela desvia) ou apenas *alternar* para ela uma vez quando você foca o app ou a página, deixando você livre para mudá-la depois. - **Controle pela barra de menus** — ative/desative, troque a fonte de entrada bloqueada, veja a fonte atual e acompanhe o contador de ativações pela barra de menus. - **Atalhos de teclado** — atalhos globais configuráveis para ativar/desativar o bloqueio e percorrer a fonte de entrada bloqueada, além de atalhos por app para percorrer ou remover a regra do app em primeiro plano. - **Iniciar no login** — inicia automaticamente quando você faz login (desativado por padrão). diff --git a/docs/README/README.ru.md b/docs/README/README.ru.md index d773a46..8a452ff 100644 --- a/docs/README/README.ru.md +++ b/docs/README/README.ru.md @@ -50,6 +50,7 @@ brew install --cask oomol-lab/tap/lockime ## Features - **Мгновенная повторная блокировка** — возвращает активный источник ввода к заблокированному в тот же момент, когда вы (или другое приложение) его меняете, — глобально или для каждого приложения. +- **Блокировать или переключать** — правила для приложений и для URL могут *блокировать* источник ввода (повторно применяя его при любом отклонении) или один раз *переключать* на него при открытии приложения или страницы, а затем не мешать вам его менять. - **Управление из строки меню** — включение/выключение, смена заблокированного источника ввода, просмотр текущего источника и счётчик срабатываний прямо в строке меню. - **Сочетания клавиш** — настраиваемые глобальные сочетания для включения/выключения блокировки и перебора заблокированного источника ввода, а также сочетания для отдельных приложений, позволяющие переключать или удалять правило активного приложения. - **Запуск при входе в систему** — стартует автоматически при входе (по умолчанию выключено). diff --git a/docs/README/README.zh-CN.md b/docs/README/README.zh-CN.md index 8d55a28..34f893a 100644 --- a/docs/README/README.zh-CN.md +++ b/docs/README/README.zh-CN.md @@ -53,6 +53,7 @@ Mac 匹配的 `.dmg`(Apple silicon 选 `-arm64`,Intel 选 `-x86_64`)。 ## Features - **即时重新锁定**——每当你(或其他应用)切换输入源时,立即切回被锁定的那个,可全局或按应用生效。 +- **锁定或切换**——按应用和按 URL 的规则既可以*锁定*某个输入源(一旦偏离就重新切回),也可以在你切到该应用或页面时只*切换*一次,之后任你自由更改。 - **菜单栏控制**——在菜单栏激活/停用、切换被锁定的输入源、查看当前输入源、追踪激活次数。 - **键盘快捷键**——可配置的全局快捷键用于开关锁定、切换被锁定的输入源(上一个 / 下一个),以及针对当前最前台应用的快捷键,用于切换或移除该应用的规则。 - **登录时启动**——登录后自动启动(默认关闭)。 diff --git a/docs/README/README.zh-TW.md b/docs/README/README.zh-TW.md index 604affc..fd2d37f 100644 --- a/docs/README/README.zh-TW.md +++ b/docs/README/README.zh-TW.md @@ -50,6 +50,7 @@ brew install --cask oomol-lab/tap/lockime ## Features - **即時重新鎖定**——每當你(或其他應用程式)切換輸入法時,立即切回被鎖定的那個,可全域或依應用程式生效。 +- **鎖定或切換**——各應用程式與各 URL 的規則既可*鎖定*某個輸入法(一旦偏離就重新切回),也可以在你切到該應用程式或頁面時只*切換*一次,之後任你自由變更。 - **選單列控制**——在選單列啟用/停用、切換被鎖定的輸入法、檢視目前輸入法、追蹤觸發次數。 - **鍵盤快速鍵**——可自訂的全域快速鍵用於開關鎖定、切換被鎖定的輸入法(上一個 / 下一個),以及針對目前最前台應用程式的快速鍵,用於切換或移除該應用程式的規則。 - **登入時啟動**——登入後自動啟動(預設關閉)。