diff --git a/README.md b/README.md index 9a97af6..e2a1da3 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,13 @@ Either way, the app keeps itself up to date via Sparkle. - **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. -- **Lock globally, or just switch** — set the global default to one input - source to pin it everywhere, or set it to **None** to use LockIME as a pure - per-app/per-site switcher — it switches you in, then leaves you free, pinning - nothing. +- **Lock globally, or just switch** — give the global default input source one of + three behaviors: *lock* to pin it everywhere; *switch* to switch you into it + once whenever an app falls back to the global default (it has no rule of its + own, or its rule is *Use default*), then leave you free; or **None** for no + global behavior at all — LockIME then acts only through your per-app and per-URL + rules. *Switch* actively switches you in whenever the default applies; **None** + does nothing globally. - **Flexible URL matching** — per-URL rules (enhanced mode) match by a domain and its subdomains, an exact domain, a domain keyword, or a regular expression over the full URL, and apply in a priority order you drag to arrange — first match diff --git a/Sources/LockIME/API/URLCommandHandler.swift b/Sources/LockIME/API/URLCommandHandler.swift index cd2b380..79d1f05 100644 --- a/Sources/LockIME/API/URLCommandHandler.swift +++ b/Sources/LockIME/API/URLCommandHandler.swift @@ -60,9 +60,9 @@ final class URLCommandHandler { // Global source targeting case .lockToSource(let selector): return withResolved(selector) { state.lockToSource($0) } - case .setDefaultSource(let selector): - guard let selector else { state.setDefaultSource(nil); return .success(nil) } - return withResolved(selector) { state.setDefaultSource($0) } + case .setDefaultSource(let selector, let action): + guard let selector else { state.setDefault(source: nil, action: action); return .success(nil) } + return withResolved(selector) { state.setDefault(source: $0, action: action) } case .cycleSource(let direction): state.cycleGlobalSource(direction); return .success(nil) case .switchSource(let selector): @@ -251,7 +251,12 @@ final class URLCommandHandler { "build": Bundle.main.buildVersion, ] if let id = state.currentSourceID { payload["currentSource"] = sourceDict(id) } - if let id = state.config.defaultSourceID { payload["defaultSource"] = sourceDict(id) } + if let id = state.config.defaultSourceID { + payload["defaultSource"] = sourceDict(id) + // Mirror the global default's lock/switch behavior, matching the + // `defaultAction` field `get-config` re-encodes from the config. + payload["defaultAction"] = state.config.defaultAction.rawValue + } if let frontmost = state.liveFrontmostBundleID { payload["frontmostApp"] = frontmost } return payload } diff --git a/Sources/LockIME/AppState.swift b/Sources/LockIME/AppState.swift index 2f319b2..ae04b1a 100644 --- a/Sources/LockIME/AppState.swift +++ b/Sources/LockIME/AppState.swift @@ -365,11 +365,30 @@ final class AppState { commit() } - /// Lock to a specific source from the menu bar: make it the global target and - /// turn LockIME on in a single commit, so a one-tap menu pick always pins, - /// even from an off state. Clicking the already-locked source instead clears - /// the global target via `setDefaultSource(nil)` (leaving the app and - /// switching alive). + /// Set the global default's lock/switch behavior (the App Rules "Behavior" + /// picker write path). Inert until a default source is set. + func setDefaultAction(_ action: RuleAction) { + config.defaultAction = action + commit() + } + + /// Set the global default source **and** its behavior in one commit — the + /// URL-scheme `set-default-source` write path. Clearing the source (`nil`) + /// leaves `defaultAction` untouched (it is inert without a source anyway). + func setDefault(source id: InputSourceID?, action: RuleAction) { + config.defaultSourceID = id + if id != nil { config.defaultAction = action } + commit() + } + + /// Set the global target from the menu bar: make `id` the global default source + /// and turn LockIME on in a single commit, so a one-tap menu pick engages even + /// from an off state. It sets only the *source* and **preserves** the configured + /// `defaultAction` — under a `.switched` global default this switches you into + /// `id` once (then leaves you free); under the default `.lock` it pins `id`. The + /// lock/switch mode is a deliberate Settings choice, so the menu never silently + /// flips it. Clicking the already-targeted source instead clears the global + /// target via `setDefaultSource(nil)` (leaving the app and switching alive). func lockToSource(_ id: InputSourceID) { config.defaultSourceID = id config.isEnabled = true @@ -378,10 +397,12 @@ final class AppState { // MARK: - Shortcut-driven source cycling - /// Lock the *global* target to the previous/next input source in the list, + /// Move the *global* target to the previous/next input source in the list, /// wrapping around the ends, and turn LockIME on — the same write path as - /// `lockToSource`. Never lands on "none", and does nothing when fewer than - /// two input sources are installed (there's nowhere to cycle). + /// `lockToSource`, so it likewise sets only the source and **preserves** the + /// configured `defaultAction` (lock or switch). Never lands on "none", and does + /// nothing when fewer than two input sources are installed (there's nowhere to + /// cycle). func cycleGlobalSource(_ direction: CycleDirection) { let reference = config.defaultSourceID ?? engine?.currentSourceID() guard let next = SourceCycler.step( diff --git a/Sources/LockIME/UI/MenuBarView.swift b/Sources/LockIME/UI/MenuBarView.swift index 1655438..63106b0 100644 --- a/Sources/LockIME/UI/MenuBarView.swift +++ b/Sources/LockIME/UI/MenuBarView.swift @@ -45,8 +45,9 @@ struct MenuBarView: View { // the gutter at a constant width, so the menu doesn't grow/shrink as the // lock toggles. (A `Toggle`'s native checkmark lives in NSMenu's *state* // column, which collapses to zero width when nothing is checked — that - // is what made the menu jump.) Clicking an unchecked source locks to it - // (sets the global target + turns LockIME on, one commit); clicking the + // is what made the menu jump.) Clicking an unchecked source targets it + // (sets the global source + turns LockIME on, one commit, preserving the + // configured lock/switch default behavior); clicking the // checked source clears the global target (app and switch rules stay // live). No separate on/off toggle, no submenu. Source names are // verbatim system strings, not catalog keys. The global toggle-lock diff --git a/Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift b/Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift index 7e99ff6..cd69593 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 defaultActionBinding = Binding( + get: { state.config.defaultAction }, + set: { state.setDefaultAction($0) } + ) let newRuleModeBinding = Binding( get: { state.newAppRuleMode }, set: { state.setNewAppRuleMode($0) } @@ -18,12 +22,20 @@ struct AppRulesSettingsPane: View { Form { Section { - Picker("Locked input source", selection: defaultBinding) { + Picker("Input source", selection: defaultBinding) { Text("None").tag(InputSourceID?.none) ForEach(state.availableSources) { source in Text(source.localizedName).tag(InputSourceID?.some(source.id)) } } + // Lock vs one-shot switch only matters once a source is set — the + // picker is inert (and hidden) while the default is "None". + if state.config.defaultSourceID != nil { + Picker("Behavior", selection: defaultActionBinding) { + Text("Lock to").tag(RuleAction.lock) + Text("Switch to").tag(RuleAction.switchOnce) + } + } } header: { Text("Global default") } footer: { diff --git a/Sources/LockIME/UI/Settings/ImportReviewSheet.swift b/Sources/LockIME/UI/Settings/ImportReviewSheet.swift index 98beb38..f0b5443 100644 --- a/Sources/LockIME/UI/Settings/ImportReviewSheet.swift +++ b/Sources/LockIME/UI/Settings/ImportReviewSheet.swift @@ -456,8 +456,9 @@ struct ImportReviewSheet: View { /// 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". + /// app mode reads as its mode word; the global default now reads the same + /// "Lock to"/"Switch to" way too (it carries a lock/switch action); a + /// sourceless binding reads "Default". /// Returning `Text` keeps it recolorable inside `HStack`s while resolving /// catalog keys against the injected `\.locale`. private func fileBindingText(_ item: ImportItem) -> Text { @@ -477,8 +478,11 @@ struct ImportReviewSheet: View { ) -> Text { switch subject { case .globalDefault: - if let source { return Text(verbatim: model.displayName(for: source)) } - return Text("Default") + // The global default now carries a lock/switch action, so render it as + // "Lock to %@" / "Switch to %@" too — parallel to app/URL rules, so a + // same-source lock-vs-switch conflict stays distinguishable. + guard let source else { return Text("Default") } + return pinnedBindingText(isSwitch: action == .switchOnce, source: source) case .app: if let mode, !mode.pinsSource { return Text(modeKey(mode)) } // ignore / use default guard let source else { return Text("Default") } diff --git a/Sources/LockIMEKit/API/URLCommand.swift b/Sources/LockIMEKit/API/URLCommand.swift index adcb0ef..4c81f0a 100644 --- a/Sources/LockIMEKit/API/URLCommand.swift +++ b/Sources/LockIMEKit/API/URLCommand.swift @@ -39,7 +39,9 @@ public enum URLCommand: Equatable, Sendable { // Global source targeting case lockToSource(SourceSelector) - case setDefaultSource(SourceSelector?) // nil clears the global default + // `source` nil clears the global default; `action` picks lock vs one-shot + // switch (default `.lock`, ignored on the clear path). + case setDefaultSource(source: SourceSelector?, action: RuleAction) case cycleSource(CycleDirection) case switchSource(SourceSelector) // transient one-shot, no standing lock @@ -269,7 +271,15 @@ public enum URLCommandParser { case "lock-to-source": return requiredSource(params).map { .lockToSource($0) } case "set-default-source": - return .success(.setDefaultSource(optionalSource(params))) + let source = optionalSource(params) + let rawAction = params["action"] ?? "lock" + let action: RuleAction + switch rawAction.lowercased() { + case "lock": action = .lock + case "switch", "switchonce", "switch-once": action = .switchOnce + default: return .failure(.invalidParameter(name: "action", value: rawAction)) + } + return .success(.setDefaultSource(source: source, action: action)) case "cycle-source": return direction(params).map { .cycleSource($0) } case "switch-source": diff --git a/Sources/LockIMEKit/Backup/ConfigBackup.swift b/Sources/LockIMEKit/Backup/ConfigBackup.swift index 610f34f..6495089 100644 --- a/Sources/LockIMEKit/Backup/ConfigBackup.swift +++ b/Sources/LockIMEKit/Backup/ConfigBackup.swift @@ -48,27 +48,34 @@ public struct BackupURLRule: Codable, Equatable, Sendable { } /// 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. +/// a backup carries between machines: the global default source (and its +/// lock/switch action), 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? + /// The global default's lock/switch behavior; travels with `defaultSourceID` + /// on import. Default `.lock` (fully backward compatible). Mirrors + /// `LockConfiguration.defaultAction`. + public var defaultAction: RuleAction public var appRules: [AppRule] public var urlRules: [BackupURLRule] public var sourceNames: [String: String] public init( defaultSourceID: InputSourceID? = nil, + defaultAction: RuleAction = .lock, appRules: [AppRule] = [], urlRules: [BackupURLRule] = [], sourceNames: [String: String] = [:] ) { self.defaultSourceID = defaultSourceID + self.defaultAction = defaultAction self.appRules = appRules self.urlRules = urlRules self.sourceNames = sourceNames @@ -80,6 +87,12 @@ public struct BackupPayload: Codable, Equatable, Sendable { public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) defaultSourceID = try container.decodeIfPresent(InputSourceID.self, forKey: .defaultSourceID) + // Decode the action as a raw *string* and map it (same rationale as + // `BackupURLRule.action`): a missing key (a backup written before the + // global default carried an action) OR an unrecognized value both fall + // back to `.lock` rather than throwing and mis-reporting the file. + let rawDefaultAction = try container.decodeIfPresent(String.self, forKey: .defaultAction) + defaultAction = rawDefaultAction.flatMap(RuleAction.init(rawValue:)) ?? .lock 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) ?? [:] @@ -225,6 +238,7 @@ public extension ConfigBackup { let payload = BackupPayload( defaultSourceID: config.defaultSourceID, + defaultAction: config.defaultAction, appRules: config.appRules, urlRules: config.urlRules.map { BackupURLRule(hostPattern: $0.hostPattern, lockedSourceID: $0.lockedSourceID, action: $0.action, matchType: $0.matchType) }, sourceNames: catalog diff --git a/Sources/LockIMEKit/Backup/ImportPlan.swift b/Sources/LockIMEKit/Backup/ImportPlan.swift index 496d3e9..671a482 100644 --- a/Sources/LockIMEKit/Backup/ImportPlan.swift +++ b/Sources/LockIMEKit/Backup/ImportPlan.swift @@ -153,6 +153,11 @@ public struct ImportPlan: Sendable, Equatable { /// from. Held so `resolvedConfiguration()` can keep `isEnabled` and /// `enhancedModeEnabled` untouched. public let baseConfig: LockConfiguration + /// The file's global-default lock/switch action. The diff keys the global + /// default on its source alone (see the `.globalDefault` item), so the action + /// travels alongside — applied at the same site the file's default source is, + /// and only when that binding wins (never on keep-local). + public let fileDefaultAction: RuleAction /// Local app rules absent from the file — removed by Replace. public let localOnlyAppRuleCount: Int /// Local URL rules absent from the file — removed by Replace. @@ -186,6 +191,7 @@ public struct ImportPlan: Sendable, Equatable { ) { self.mode = mode self.baseConfig = current + self.fileDefaultAction = backup.payload.defaultAction self.installedSourceIDs = Set(installedSources.map(\.id)) var names: [InputSourceID: String] = [:] @@ -199,12 +205,18 @@ public struct ImportPlan: Sendable, Equatable { var items: [ImportItem] = [] - // Global default. + // Global default. Both the source AND its lock/switch action are part of + // the binding, so a same-source/different-action file default is a + // `.conflict` (not `.unchanged`) — mirroring how app rules compare `mode` + // and URL rules compare `action`. The action rides on `fileAction`/ + // `localAction` so the Review row can render "Lock to"/"Switch to" and + // `resolvedConfiguration()` / `summary()` see the change. if let fileDefault = backup.payload.defaultSourceID { + let fileAction = backup.payload.defaultAction let status: ImportItem.Status if current.defaultSourceID == nil { status = .new - } else if current.defaultSourceID == fileDefault { + } else if current.defaultSourceID == fileDefault && current.defaultAction == fileAction { status = .unchanged } else { status = .conflict @@ -213,7 +225,9 @@ public struct ImportPlan: Sendable, Equatable { id: "default", subject: .globalDefault, status: status, fileMode: nil, fileSource: fileDefault, localMode: nil, localSource: current.defaultSourceID, - include: true, resolution: .keepLocal, missingDisposition: .keep + include: true, resolution: .keepLocal, missingDisposition: .keep, + fileAction: fileAction, + localAction: current.defaultSourceID != nil ? current.defaultAction : nil )) } @@ -359,6 +373,9 @@ public struct ImportPlan: Sendable, Equatable { public func resolvedConfiguration() -> LockConfiguration { var appRules: [String: AppRule] var defaultSource: InputSourceID? + // The default's action rides with its source: it changes only when a file + // default binding actually wins below, otherwise the local action stands. + var defaultAction: RuleAction = baseConfig.defaultAction // URL rules carry an explicit user-controlled priority (first match wins), // so unlike app rules they are never alphabetized. `urlMap` holds the @@ -390,7 +407,10 @@ public struct ImportPlan: Sendable, Equatable { // 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 } + if !drop { + defaultSource = item.fileSource + defaultAction = fileDefaultAction + } case .app(let bundleID): if drop { appRules[bundleID] = nil @@ -419,6 +439,7 @@ public struct ImportPlan: Sendable, Equatable { result.appRules = appRules.values.sorted { $0.bundleID < $1.bundleID } result.urlRules = resolvedURLOrder(present: urlMap) result.defaultSourceID = defaultSource + result.defaultAction = defaultAction return result } @@ -505,7 +526,10 @@ public struct ImportPlan: Sendable, Equatable { /// `"default"`; app rules `"app:"`; URL rules `"url:"`. private func bindingKeys(of config: LockConfiguration, includeURLPosition: Bool) -> [String: String] { var map: [String: String] = [:] - if let def = config.defaultSourceID { map["default"] = def.rawValue } + // The default's action is part of its binding (like an app rule's mode or a + // URL rule's action), so an action-only change on the same source still + // reads as an update and flips `hasEffect` on. + if let def = config.defaultSourceID { map["default"] = "\(config.defaultAction.rawValue)|\(def.rawValue)" } for rule in config.appRules { // The mode rawValue already separates lock from switch; only a // source-pinning mode contributes a source. diff --git a/Sources/LockIMEKit/Rules/LockConfiguration.swift b/Sources/LockIMEKit/Rules/LockConfiguration.swift index 9e4b1c5..f765f07 100644 --- a/Sources/LockIMEKit/Rules/LockConfiguration.swift +++ b/Sources/LockIMEKit/Rules/LockConfiguration.swift @@ -169,6 +169,12 @@ public struct LockConfiguration: Codable, Sendable, Equatable { public var isEnabled: Bool /// Global default locked source, used when no app rule applies. public var defaultSourceID: InputSourceID? + /// Whether the global default **continuously locks** its source (the original + /// behavior) or **switches to it once** on entering an app with no rule of its + /// own — then releases, so the user may change it, and re-fires on each new + /// no-rule app. Mirrors `URLRule.action` / `addressBarAction`. Only meaningful + /// when `defaultSourceID` is set. Default `.lock`. + public var defaultAction: RuleAction /// Per-app overrides. public var appRules: [AppRule] /// Whether the Accessibility-gated enhanced mode is on. @@ -207,6 +213,7 @@ public struct LockConfiguration: Codable, Sendable, Equatable { public init( isEnabled: Bool = false, defaultSourceID: InputSourceID? = nil, + defaultAction: RuleAction = .lock, appRules: [AppRule] = [], enhancedModeEnabled: Bool = false, urlRules: [URLRule] = [], @@ -218,6 +225,7 @@ public struct LockConfiguration: Codable, Sendable, Equatable { ) { self.isEnabled = isEnabled self.defaultSourceID = defaultSourceID + self.defaultAction = defaultAction self.appRules = appRules self.enhancedModeEnabled = enhancedModeEnabled self.urlRules = urlRules @@ -234,6 +242,14 @@ public struct LockConfiguration: Codable, Sendable, Equatable { let container = try decoder.container(keyedBy: CodingKeys.self) isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? false defaultSourceID = try container.decodeIfPresent(InputSourceID.self, forKey: .defaultSourceID) + // Decode the action as a raw *string* and map it (same rationale as + // `addressBarAction` / `URLRule`): a missing key (a config written before + // the global default carried an action) OR an unrecognized value (a newer + // build wrote an action this one doesn't know, then a downgrade reads it) + // both fall back to `.lock` — fully backward compatible — instead of + // throwing and dropping the whole config. + let rawDefaultAction = try container.decodeIfPresent(String.self, forKey: .defaultAction) + defaultAction = rawDefaultAction.flatMap(RuleAction.init(rawValue:)) ?? .lock appRules = try container.decodeIfPresent([AppRule].self, forKey: .appRules) ?? [] enhancedModeEnabled = try container.decodeIfPresent(Bool.self, forKey: .enhancedModeEnabled) ?? false urlRules = try container.decodeIfPresent([URLRule].self, forKey: .urlRules) ?? [] diff --git a/Sources/LockIMEKit/Rules/RuleResolver.swift b/Sources/LockIMEKit/Rules/RuleResolver.swift index 02ca21e..9cb9549 100644 --- a/Sources/LockIMEKit/Rules/RuleResolver.swift +++ b/Sources/LockIMEKit/Rules/RuleResolver.swift @@ -18,8 +18,8 @@ public enum LockResolution: Equatable, Sendable { /// 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). + /// given rule branch. A per-app `.switched` rule, a per-URL `.switchOnce` + /// rule, or a global default whose `defaultAction` is `.switchOnce` yields this. case switchOnce(InputSourceID, RuleSource) /// The frontmost app is explicitly ignored — do not enforce. case ignore @@ -80,15 +80,18 @@ public enum RuleResolver { return .switchOnce(id, .appRule) } // "switched" with no source set → fall through to the default - // (which is always a lock). + // (which honors `defaultAction`). case .useDefault: break } } - // 3. Global default (always a lock; never a one-shot switch). + // 3. Global default. Continuously locks (the original behavior) or fires a + // one-shot switch on entering a no-rule app, per `config.defaultAction`. if let def = config.defaultSourceID { - return .lock(def, .globalDefault) + return config.defaultAction == .switchOnce + ? .switchOnce(def, .globalDefault) + : .lock(def, .globalDefault) } return .noTarget } diff --git a/Tests/LockIMEKitTests/ConfigBackupTests.swift b/Tests/LockIMEKitTests/ConfigBackupTests.swift index 86d801f..5014df7 100644 --- a/Tests/LockIMEKitTests/ConfigBackupTests.swift +++ b/Tests/LockIMEKitTests/ConfigBackupTests.swift @@ -82,6 +82,39 @@ struct ConfigBackupTests { #expect(decoded.payload.sourceNames["com.apple.keylayout.ABC"] == "ABC") } + @Test("the global default's action round-trips through make→encode→read") + func defaultActionRoundTrips() throws { + // make() defaults to lock when the config's action is lock. + let lockBackup = ConfigBackup.make(from: sampleConfig(), appVersion: "1", sourceNames: names) + #expect(lockBackup.payload.defaultAction == .lock) + + // A switch-action default survives export→read. + let config = LockConfiguration( + isEnabled: true, + defaultSourceID: "com.apple.keylayout.US", + defaultAction: .switchOnce + ) + let decoded = try ConfigBackup.read(ConfigBackup.make(from: config, appVersion: "1", sourceNames: names).encoded()).get() + #expect(decoded.payload.defaultSourceID == "com.apple.keylayout.US") + #expect(decoded.payload.defaultAction == .switchOnce) + } + + @Test("a backup missing defaultAction reads as .lock (lenient)") + func defaultActionMissingDecodesAsLock() throws { + let json = """ + {"format": "\(ConfigBackup.formatIdentifier)", "minReader": 1, "appVersion": "1", + "payload": {"defaultSourceID": "com.apple.keylayout.US"}} + """ + let backup = try ConfigBackup.read(Data(json.utf8)).get() + #expect(backup.payload.defaultAction == .lock) + // An unknown value likewise degrades to lock rather than mis-reporting the file. + let unknown = """ + {"format": "\(ConfigBackup.formatIdentifier)", "minReader": 1, "appVersion": "1", + "payload": {"defaultSourceID": "com.apple.keylayout.US", "defaultAction": "teleport"}} + """ + #expect(try ConfigBackup.read(Data(unknown.utf8)).get().payload.defaultAction == .lock) + } + @Test("a .lockime URL rule without an action decodes to .lock (lenient)") func urlRuleWithoutActionDecodesAsLock() throws { let json = """ @@ -244,6 +277,7 @@ struct ConfigBackupTests { func payloadEmptyDefaults() throws { let payload = try JSONDecoder().decode(BackupPayload.self, from: Data("{}".utf8)) #expect(payload.defaultSourceID == nil) + #expect(payload.defaultAction == .lock) #expect(payload.appRules.isEmpty) #expect(payload.urlRules.isEmpty) #expect(payload.sourceNames.isEmpty) diff --git a/Tests/LockIMEKitTests/ImportPlanTests.swift b/Tests/LockIMEKitTests/ImportPlanTests.swift index a145931..bc347b1 100644 --- a/Tests/LockIMEKitTests/ImportPlanTests.swift +++ b/Tests/LockIMEKitTests/ImportPlanTests.swift @@ -18,12 +18,14 @@ struct ImportPlanTests { private func backup( defaultSourceID: InputSourceID? = nil, + defaultAction: RuleAction = .lock, appRules: [AppRule] = [], urlRules: [BackupURLRule] = [], sourceNames: [String: String] = [:] ) -> ConfigBackup { ConfigBackup(appVersion: "1", payload: BackupPayload( - defaultSourceID: defaultSourceID, appRules: appRules, urlRules: urlRules, sourceNames: sourceNames + defaultSourceID: defaultSourceID, defaultAction: defaultAction, + appRules: appRules, urlRules: urlRules, sourceNames: sourceNames )) } @@ -651,6 +653,96 @@ struct ImportPlanTests { #expect(replaced.urlRules.first?.lockedSourceID == "US") } + // MARK: - Global default action + + @Test("a new switch-action global default carries its action into the resolved config") + func newSwitchDefaultCarriesAction() { + let plan = ImportPlan(current: .default, backup: backup( + defaultSourceID: "US", defaultAction: .switchOnce + ), installedSources: installed) + #expect(item(plan, "default")?.status == .new) + let resolved = plan.resolvedConfiguration() + #expect(resolved.defaultSourceID == "US") + #expect(resolved.defaultAction == .switchOnce) + } + + @Test("the default's action travels only with a winning file binding") + func defaultActionTravelsWithWinningBinding() { + // Local default US/lock; the file's default is a *different* source with a + // switch action → a conflict. Keep-local (the merge default) keeps both the + // local source AND the local action; useFile takes the file's source AND action. + let current = LockConfiguration(defaultSourceID: "US", defaultAction: .lock) + var plan = ImportPlan(current: current, backup: backup( + defaultSourceID: "ABC", defaultAction: .switchOnce + ), installedSources: installed) + #expect(item(plan, "default")?.status == .conflict) + // Keep local → local source and local action stand. + let kept = plan.resolvedConfiguration() + #expect(kept.defaultSourceID == "US") + #expect(kept.defaultAction == .lock) + // Use file → the file's source and its action both apply. + plan.items[0].resolution = .useFile + let used = plan.resolvedConfiguration() + #expect(used.defaultSourceID == "ABC") + #expect(used.defaultAction == .switchOnce) + } + + @Test("a file with no default never overwrites the local default's action") + func noFileDefaultKeepsLocalAction() { + let current = LockConfiguration(defaultSourceID: "US", defaultAction: .switchOnce) + var plan = ImportPlan(current: current, backup: backup( + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "ABC")] + ), installedSources: installed) + plan.mode = .replace // even Replace keeps the local default (file specifies none) + let resolved = plan.resolvedConfiguration() + #expect(resolved.defaultSourceID == "US") + #expect(resolved.defaultAction == .switchOnce) + } + + @Test("round-trip of a switch-action default is a no-op in merge; Replace re-asserts it") + func switchDefaultRoundTrip() { + let config = LockConfiguration( + isEnabled: true, + defaultSourceID: "US", + defaultAction: .switchOnce, + appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "ABC")] + ) + 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) + #expect(plan.resolvedConfiguration() == config) // merge no-op + plan.mode = .replace + #expect(plan.resolvedConfiguration().defaultAction == .switchOnce) // action re-asserted + } + + @Test("a same-source, different-action default is a conflict that applies, not a silently-dropped no-op") + func sameSourceDifferentActionDefaultIsConflict() { + // Local default US/lock; file default US/switchOnce — same source, different + // action. Before the fix the default was keyed on source alone, so this read + // as `.unchanged`: Merge silently dropped the switch action and Replace left + // `hasEffect == false`, disabling Apply so the action could never persist. + let current = LockConfiguration(defaultSourceID: "US", defaultAction: .lock) + var plan = ImportPlan(current: current, backup: backup( + defaultSourceID: "US", defaultAction: .switchOnce + ), installedSources: installed) + #expect(item(plan, "default")?.status == .conflict) + + // Merge keep-local (the default resolution) is a genuine no-op: local stands. + #expect(plan.resolvedConfiguration().defaultAction == .lock) + + // Merge use-file adopts the file's switch action on the same source, and the + // action-only change now registers as an effect (Apply is enabled). + plan.items[0].resolution = .useFile + #expect(plan.resolvedConfiguration().defaultSourceID == "US") + #expect(plan.resolvedConfiguration().defaultAction == .switchOnce) + #expect(plan.summary().hasEffect) + + // Replace applies the file's action even when it is the ONLY difference. + plan.mode = .replace + #expect(plan.resolvedConfiguration().defaultAction == .switchOnce) + #expect(plan.summary().hasEffect) + } + // MARK: - Match type @Test("a match-type-only difference on the same pattern is a URL conflict") diff --git a/Tests/LockIMEKitTests/LockConfigurationTests.swift b/Tests/LockIMEKitTests/LockConfigurationTests.swift index 7e4a66c..629d434 100644 --- a/Tests/LockIMEKitTests/LockConfigurationTests.swift +++ b/Tests/LockIMEKitTests/LockConfigurationTests.swift @@ -257,6 +257,40 @@ struct LockConfigurationTests { #expect(config.addressBarSourceID == "com.apple.keylayout.ABC") } + @Test("defaultAction defaults to lock and round-trips through Codable") + func defaultActionRoundTrips() throws { + // Default is lock (the original behavior; fully backward compatible). + #expect(LockConfiguration.default.defaultAction == .lock) + + let original = LockConfiguration( + isEnabled: true, + defaultSourceID: "com.apple.keylayout.US", + defaultAction: .switchOnce // non-default, to prove it round-trips + ) + let decoded = try JSONDecoder().decode(LockConfiguration.self, from: try JSONEncoder().encode(original)) + #expect(decoded == original) + #expect(decoded.defaultAction == .switchOnce) + } + + @Test("a config predating defaultAction decodes to the lock default") + func decodesLegacyWithoutDefaultAction() throws { + let json = #"{"isEnabled": true, "defaultSourceID": "com.apple.keylayout.US"}"# + let config = try JSONDecoder().decode(LockConfiguration.self, from: Data(json.utf8)) + #expect(config.defaultAction == .lock) + } + + @Test("defaultAction maps a known switch value and degrades an unknown one to lock") + func decodesDefaultActionLeniently() throws { + // A known "switchOnce" value maps through. + let known = #"{"defaultSourceID": "com.apple.keylayout.US", "defaultAction": "switchOnce"}"# + #expect(try JSONDecoder().decode(LockConfiguration.self, from: Data(known.utf8)).defaultAction == .switchOnce) + // An unknown value degrades to lock rather than aborting the whole decode. + let unknown = #"{"defaultSourceID": "com.apple.keylayout.US", "defaultAction": "teleport"}"# + let config = try JSONDecoder().decode(LockConfiguration.self, from: Data(unknown.utf8)) + #expect(config.defaultAction == .lock) + #expect(config.defaultSourceID == "com.apple.keylayout.US") + } + @Test("URLMatchType.id is its raw value (the stable persisted token)") func urlMatchTypeID() { #expect(URLMatchType.domainSuffix.rawValue == "domain-suffix") diff --git a/Tests/LockIMEKitTests/LockEngineTests.swift b/Tests/LockIMEKitTests/LockEngineTests.swift index ee1227d..2308fa6 100644 --- a/Tests/LockIMEKitTests/LockEngineTests.swift +++ b/Tests/LockIMEKitTests/LockEngineTests.swift @@ -889,6 +889,80 @@ struct LockEngineSwitchTests { #expect(events2.last?.inputSource == abc) } + // MARK: - Global default action (issue #56) + + // The requester's scenario: with the global default set to *switch*, entering + // a no-rule app switches you to the default once, then leaves you free; each + // new no-rule app re-fires, and returning to a previous one re-fires too. + @Test("a switch-action global default fires once per no-rule app and re-arms across apps") + func switchDefaultFiresPerNoRuleApp() { + let (engine, provider, monitor, _) = makeEngine(current: us, frontmost: "com.a.App") + engine.apply(LockConfiguration( + isEnabled: true, defaultSourceID: abc, defaultAction: .switchOnce + )) + #expect(provider.current == abc) // switched into A once (no standing lock) + #expect(provider.selectCalls == [abc]) + + // Manual switch away — a one-shot installs no lock, so it sticks; the same + // app re-activating does not re-fire (same SwitchKey context). + provider.current = us + monitor.activate("com.a.App") + #expect(provider.current == us) + #expect(provider.selectCalls == [abc]) + + // A different no-rule app re-fires the switch to the default. + monitor.activate("com.b.App") + #expect(provider.current == abc) + #expect(provider.selectCalls == [abc, abc]) + + // Switch away, then return to A → a genuine re-entry re-fires again. + provider.current = us + monitor.activate("com.a.App") + #expect(provider.current == abc) + #expect(provider.selectCalls == [abc, abc, abc]) + } + + // The lock default is the original behavior: it installs a standing lock, so + // it continuously re-pins the source on every no-rule app (here shown by a + // round-trip through a rule for a different source — each step is a genuine + // target change, so it enforces regardless of the settle window). + @Test("a lock-action global default keeps pinning the source (continuous enforcement)") + func lockDefaultRePins() { + let (engine, provider, monitor, _) = makeEngine(current: us, frontmost: "com.a.App") + engine.apply(LockConfiguration( + isEnabled: true, defaultSourceID: abc, defaultAction: .lock, + appRules: [AppRule(bundleID: "com.lock.App", mode: .locked, lockedSourceID: pinyin)] + )) + #expect(provider.current == abc) // pinned to the default on A + + monitor.activate("com.lock.App") + #expect(provider.current == pinyin) // the app rule pins pinyin + + // Back to a no-rule app: the lock default re-pins abc. + monitor.activate("com.a.App") + #expect(provider.current == abc) + #expect(provider.selectCalls == [abc, pinyin, abc]) + } + + // A launcher overlay over a no-rule app under a switch default: the switch + // fires for the overlay via its OWN dedup slot, and dismissing it must not + // re-yank the user who had switched away in the underlying app. + @Test("a launcher over a no-rule app under a switch default does not re-yank on dismiss") + func launcherOverSwitchDefaultDoesNotReYank() { + let (engine, provider, _, floating) = makeEngine(current: us, frontmost: "com.a.App") + engine.apply(LockConfiguration( + isEnabled: true, defaultSourceID: abc, defaultAction: .switchOnce + )) + #expect(provider.current == abc) // switch default fired for the underlying app + + provider.current = us // user manually switches away + floating.setLauncher(spotlight) // overlay (no rule) → switch default fires via its own slot + #expect(provider.current == abc) + provider.current = us // user switches away again in the overlay + floating.setLauncher(nil) // dismiss → underlying app's key intact → no re-fire + #expect(provider.current == us) // NOT re-yanked to 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 diff --git a/Tests/LockIMEKitTests/RuleResolverTests.swift b/Tests/LockIMEKitTests/RuleResolverTests.swift index fb4c4f0..06e6b78 100644 --- a/Tests/LockIMEKitTests/RuleResolverTests.swift +++ b/Tests/LockIMEKitTests/RuleResolverTests.swift @@ -15,6 +15,60 @@ struct RuleResolverTests { #expect(RuleResolver.resolve(config: config, frontmostBundleID: "com.foo.Bar") == .lock(us, .globalDefault)) } + @Test("a switch-action global default yields a one-shot switch") + func globalDefaultSwitch() { + let config = LockConfiguration( + isEnabled: true, defaultSourceID: us, defaultAction: .switchOnce, appRules: [] + ) + #expect(RuleResolver.resolve(config: config, frontmostBundleID: "com.foo.Bar") == .switchOnce(us, .globalDefault)) + // No frontmost app still resolves against the default. + #expect(RuleResolver.resolve(config: config, frontmostBundleID: nil) == .switchOnce(us, .globalDefault)) + } + + @Test("the default action defaults to lock (backward compatible)") + func globalDefaultActionDefaultsToLock() { + // Constructed without specifying defaultAction → the original lock behavior. + let config = LockConfiguration(isEnabled: true, defaultSourceID: us) + #expect(config.defaultAction == .lock) + #expect(RuleResolver.resolve(config: config, frontmostBundleID: "com.foo.Bar") == .lock(us, .globalDefault)) + } + + @Test("a useDefault app rule honors the default's switch action") + func useDefaultRuleHonorsSwitchDefault() { + let config = LockConfiguration( + isEnabled: true, defaultSourceID: us, defaultAction: .switchOnce, + appRules: [AppRule(bundleID: "com.foo.App", mode: .useDefault)] + ) + #expect(RuleResolver.resolve(config: config, frontmostBundleID: "com.foo.App") == .switchOnce(us, .globalDefault)) + } + + @Test("a sourceless locked/switched app rule falls through, honoring the switch default") + func sourcelessRuleFallsThroughToSwitchDefault() { + // A `.locked` rule with no source set falls through to the switch default. + let lockedFallthrough = LockConfiguration( + isEnabled: true, defaultSourceID: us, defaultAction: .switchOnce, + appRules: [AppRule(bundleID: "com.foo.App", mode: .locked, lockedSourceID: nil)] + ) + #expect(RuleResolver.resolve(config: lockedFallthrough, frontmostBundleID: "com.foo.App") == .switchOnce(us, .globalDefault)) + // Likewise a `.switched` rule with no source set. + let switchedFallthrough = LockConfiguration( + isEnabled: true, defaultSourceID: us, defaultAction: .switchOnce, + appRules: [AppRule(bundleID: "com.foo.App", mode: .switched, lockedSourceID: nil)] + ) + #expect(RuleResolver.resolve(config: switchedFallthrough, frontmostBundleID: "com.foo.App") == .switchOnce(us, .globalDefault)) + } + + @Test("an explicit app rule still overrides a switch-action global default") + func appRuleOverridesSwitchDefault() { + let config = LockConfiguration( + isEnabled: true, defaultSourceID: us, defaultAction: .switchOnce, + appRules: [AppRule(bundleID: "com.apple.Terminal", mode: .locked, lockedSourceID: abc)] + ) + #expect(RuleResolver.resolve(config: config, frontmostBundleID: "com.apple.Terminal") == .lock(abc, .appRule)) + // A different app takes the switch default. + #expect(RuleResolver.resolve(config: config, frontmostBundleID: "com.other.App") == .switchOnce(us, .globalDefault)) + } + @Test("no default and no rule yields noTarget") func noTarget() { let config = LockConfiguration(isEnabled: true, defaultSourceID: nil, appRules: []) diff --git a/Tests/LockIMEKitTests/URLCommandParserTests.swift b/Tests/LockIMEKitTests/URLCommandParserTests.swift index ef08614..ed14b86 100644 --- a/Tests/LockIMEKitTests/URLCommandParserTests.swift +++ b/Tests/LockIMEKitTests/URLCommandParserTests.swift @@ -66,10 +66,23 @@ struct URLCommandParserTests { #expect(failure("lockime://lock-to-source") == .missingParameter("id")) } - @Test("set-default-source with no selector clears the default") + @Test("set-default-source parses the source, the optional action, and the clear path") func setDefaultSourceClear() { - #expect(command("lockime://set-default-source") == .setDefaultSource(nil)) - #expect(command("lockime://set-default-source?id=com.apple.keylayout.ABC") == .setDefaultSource(.id(abc))) + // No selector clears the default; the action defaults to lock (back-compat). + #expect(command("lockime://set-default-source") == .setDefaultSource(source: nil, action: .lock)) + #expect(command("lockime://set-default-source?id=com.apple.keylayout.ABC") + == .setDefaultSource(source: .id(abc), action: .lock)) + // An explicit switch action rides alongside the source. + #expect(command("lockime://set-default-source?id=com.apple.keylayout.ABC&action=switch") + == .setDefaultSource(source: .id(abc), action: .switchOnce)) + #expect(command("lockime://set-default-source?id=com.apple.keylayout.ABC&action=lock") + == .setDefaultSource(source: .id(abc), action: .lock)) + // The action is parsed even on the clear path (the executor ignores it there). + #expect(command("lockime://set-default-source?action=switch") + == .setDefaultSource(source: nil, action: .switchOnce)) + // An unrecognized action is a parameter error. + #expect(failure("lockime://set-default-source?id=com.apple.keylayout.ABC&action=hop") + == .invalidParameter(name: "action", value: "hop")) } @Test("cycle-source parses direction and its aliases") diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 773f798..e4d082e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -141,15 +141,19 @@ menu**, bracketed by one divider above and one below — no master toggle, no submenu. Each source is a `Button` carrying a leading checkmark **image** (the `CheckmarkSlot` NSImages), shown on the locked one and a same-size transparent slot otherwise; a source is checked iff LockIME is on **and** it is the global -target (`config.defaultSourceID`). The image (not a `Toggle`'s native checkmark, +target (`config.defaultSourceID`) — the checkmark tracks the target regardless of +`config.defaultAction` (lock vs switch). The image (not a `Toggle`'s native checkmark, which lives in NSMenu's *state* column and collapses to zero width when nothing is checked) keeps the gutter reserved at a constant width, so the menu never grows or shrinks as the lock toggles — and NSMenu drops SwiftUI's `.opacity` on a Label's system-image icon, so the slot must swap the image itself, not hide a -symbol. Clicking an unchecked source locks to it (`AppState.lockToSource` sets -the target *and* turns LockIME on in one commit, -re-resolving and flipping the active source immediately); clicking the checked -source clears just the global lock target (`setDefaultSource(nil)`) — the app and +symbol. Clicking an unchecked source targets it (`AppState.lockToSource` sets +the global source *and* turns LockIME on in one commit, +re-resolving and flipping the active source immediately); it **preserves** the +configured global-default `defaultAction`, so under a *switch* default the pick +switches you into that source once rather than pinning it — the lock/switch mode +stays a deliberate Settings choice the menu never flips. Clicking the checked +source clears just the global target (`setDefaultSource(nil)`) — the app and any one-shot switch rules stay live. This is the same write path as the App Rules "Global default" picker. Source names are `Text(verbatim:)` system strings, not catalog keys. The @@ -204,7 +208,12 @@ the grant watcher. 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 + Switch to picker. The **global default carries the same lock/switch action** + (`config.defaultAction`, default `.lock`, fully backward compatible): the App + Rules **Global default** section shows a Lock to / Switch to picker once a + source is set, so a *switch* default switches you into each no-rule app once + then releases (re-firing on the next no-rule app), while a *lock* default is the + original continuous enforcement. 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` diff --git a/docs/README/README.de.md b/docs/README/README.de.md index 7d4f452..e707e7a 100644 --- a/docs/README/README.de.md +++ b/docs/README/README.de.md @@ -51,7 +51,7 @@ Oder lade die zu deinem Mac passende `.dmg`-Datei (`-arm64` für Apple silicon, - **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. -- **Global sperren oder einfach wechseln** — lege den globalen Standard auf eine Eingabequelle fest, um sie überall zu fixieren, oder setze ihn auf **Keine**, um LockIME als reinen Umschalter pro App/pro Seite zu nutzen — es schaltet dich hinein, lässt dich danach frei und fixiert nichts. +- **Global sperren oder einfach wechseln** — gib der globalen Standard-Eingabequelle eines von drei Verhalten: *sperren*, um sie überall zu fixieren; *wechseln*, um dich einmalig hineinzuschalten, sobald eine App auf den globalen Standard zurückgreift (sie hat keine eigene Regel oder ihre Regel ist *Standard verwenden*), und dich danach frei wählen zu lassen; oder **Keine** für gar kein globales Verhalten — LockIME wirkt dann nur über deine Regeln pro App und pro URL. *Wechseln* schaltet dich aktiv hinein, sobald der Standard greift; **Keine** tut global nichts. - **Flexibler URL-Abgleich** — Regeln pro URL (erweiterter Modus) passen über eine Domain und ihre Subdomains, eine exakte Domain, ein Domain-Schlüsselwort oder einen regulären Ausdruck über die vollständige URL und greifen in einer Prioritätsreihenfolge, die du per Ziehen anordnest — der erste Treffer gewinnt. - **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 von LockIME und zum Durchschalten der gesperrten Eingabequelle sowie App-spezifische Kurzbefehle, um die Regel der vordersten App durchzuschalten oder zu entfernen. diff --git a/docs/README/README.es.md b/docs/README/README.es.md index 6b8f57e..56a9734 100644 --- a/docs/README/README.es.md +++ b/docs/README/README.es.md @@ -55,7 +55,7 @@ En cualquier caso, la aplicación se mantiene actualizada mediante Sparkle. - **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. -- **Bloquear de forma global, o solo cambiar** — establece la fuente predeterminada global en una fuente de entrada para fijarla en todas partes, o establécela en **Ninguna** para usar LockIME como un cambiador puro por aplicación/por sitio — te cambia al entrar y luego te deja libre, sin fijar nada. +- **Bloquear de forma global, o solo cambiar** — asigna a la fuente de entrada predeterminada global uno de tres comportamientos: *bloquear* para fijarla en todas partes; *cambiar* para cambiarte a ella una vez cada vez que una aplicación recurre a la predeterminada global (no tiene regla propia, o su regla es *Usar predeterminada*) y luego dejarte libre; o **Ninguna** para no tener ningún comportamiento global en absoluto — entonces LockIME actúa solo a través de tus reglas por aplicación y por URL. *Cambiar* te cambia activamente cada vez que se aplica la predeterminada; **Ninguna** no hace nada de forma global. - **Coincidencia de URL flexible** — las reglas por URL (modo mejorado) coinciden por un dominio y sus subdominios, por un dominio exacto, por una palabra clave de dominio o por una expresión regular sobre la URL completa, y se aplican en el orden de prioridad que tú arrastras para organizar — la primera coincidencia gana. - **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 LockIME 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. diff --git a/docs/README/README.fr.md b/docs/README/README.fr.md index 930027c..5eabf3f 100644 --- a/docs/README/README.fr.md +++ b/docs/README/README.fr.md @@ -51,7 +51,7 @@ Ou téléchargez le `.dmg` correspondant à votre Mac (`-arm64` pour Apple silic - **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. -- **Verrouiller globalement, ou simplement basculer** — définissez la valeur par défaut globale sur une source de saisie pour l'épingler partout, ou réglez-la sur **Aucune** pour utiliser LockIME comme un simple commutateur par application/par site — il vous bascule en entrant, puis vous laisse libre, sans rien épingler. +- **Verrouiller globalement, ou simplement basculer** — attribuez à la source de saisie par défaut globale l'un des trois comportements : *verrouiller* pour l'épingler partout ; *basculer* pour vous y faire basculer une fois dès qu'une application se rabat sur la valeur par défaut globale, puis vous laisser libre (se redéclenchant à chaque fois) ; ou **Aucune** pour aucun comportement global — LockIME n'agit qu'à travers les règles par application et par URL. *Basculer* vous fait activement basculer chaque fois qu'une application se rabat sur la valeur par défaut globale ; **Aucune** ne fait rien globalement. - **Correspondance d'URL flexible** — les règles par URL (mode renforcé) correspondent par un domaine et ses sous-domaines, un domaine exact, un mot-clé de domaine, ou une expression régulière sur l'URL entière, et s'appliquent dans un ordre de priorité que vous organisez par glisser-déposer — la première correspondance l'emporte. - **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 LockIME 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. diff --git a/docs/README/README.ja.md b/docs/README/README.ja.md index 8a2ca55..1a6de84 100644 --- a/docs/README/README.ja.md +++ b/docs/README/README.ja.md @@ -51,7 +51,7 @@ brew install --cask oomol-lab/tap/lockime - **即時再ロック**——あなた(または他のアプリ)が入力ソースを切り替えた瞬間に、ロック中のものへ切り戻します。グローバルにも、アプリごとにも。 - **ロックまたは切り替え**——アプリごと・URL ごとのルールは、入力ソースを*ロック*(ずれるたびに切り戻す)することも、アプリやページをアクティブにしたときに一度だけ*切り替え*て、その後は自由に変更できるようにすることもできます。 -- **グローバルにロック、または切り替えだけ**——グローバルのデフォルトを 1 つの入力ソースに設定すればどこでもそれを固定し、**なし**に設定すれば LockIME を純粋なアプリごと / サイトごとの切り替え役として使えます——切り替えてから自由にさせ、何も固定しません。 +- **グローバルにロック、または切り替えだけ**——グローバルのデフォルト入力ソースに、3 つの挙動のいずれかを与えられます:*ロック*はどこでもそれを固定します。*切り替え*は、アプリがグローバルのデフォルトにフォールバックするたびに一度だけそれへ切り替えてから自由にさせます(独自のルールを持たないアプリだけでなく、ルールが*デフォルトを使用*のアプリも対象です)。**なし**はグローバルな挙動を一切持たず、LockIME はアプリごと・URL ごとのルールを通じてのみ作用します。 - **柔軟な URL マッチング**——URL ごとのルール(拡張モード)は、ドメインとそのサブドメイン、完全一致のドメイン、ドメインのキーワード、または URL 全体に対する正規表現でマッチし、ドラッグして並べ替える優先順位順に適用されます——最初にマッチしたものが優先されます。 - **メニューバーからの操作**——メニューバーから有効化/無効化、ロック中の入力ソースの切り替え、現在の入力ソースの確認、作動回数の追跡。 - **キーボードショートカット**——設定可能なグローバルショートカットで LockIME のオン/オフやロック中の入力ソースの切り替え(前 / 次)ができ、さらに最前面のアプリのルールを切り替えたり解除したりするアプリごとのショートカットも利用できます。 diff --git a/docs/README/README.pt.md b/docs/README/README.pt.md index b1ed88b..46b52f7 100644 --- a/docs/README/README.pt.md +++ b/docs/README/README.pt.md @@ -55,7 +55,7 @@ De qualquer forma, o app se mantém atualizado sozinho via Sparkle. - **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. -- **Bloquear globalmente, ou apenas alternar** — defina o padrão global como uma fonte de entrada para fixá-la em todo lugar, ou defina-o como **Nenhuma** para usar o LockIME como um alternador puro por app/por site — ele alterna você para a fonte, depois deixa você livre, sem fixar nada. +- **Bloquear globalmente, ou apenas alternar** — dê à fonte de entrada padrão global um de três comportamentos: *bloquear* para fixá-la em todo lugar; *alternar* para alternar você para ela uma vez sempre que um app recorre ao padrão global, depois deixá-lo livre (disparando de novo cada vez que um app recorre ao padrão global); ou **Nenhuma** para nenhum comportamento global — o LockIME age apenas através de regras por app e por URL. *Alternar* alterna você ativamente sempre que um app recorre ao padrão global; **Nenhuma** não faz nada globalmente. - **Correspondência flexível de URL** — as regras por URL (modo aprimorado) correspondem por um domínio e seus subdomínios, por um domínio exato, por uma palavra-chave de domínio, ou por uma expressão regular sobre a URL inteira, e se aplicam em uma ordem de prioridade que você arrasta para organizar — a primeira correspondência vence. - **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 LockIME e percorrer a fonte de entrada bloqueada, além de atalhos por app para percorrer ou remover a regra do app em primeiro plano. diff --git a/docs/README/README.ru.md b/docs/README/README.ru.md index 4b95145..3f6d879 100644 --- a/docs/README/README.ru.md +++ b/docs/README/README.ru.md @@ -51,7 +51,7 @@ brew install --cask oomol-lab/tap/lockime - **Мгновенная повторная блокировка** — возвращает активный источник ввода к заблокированному в тот же момент, когда вы (или другое приложение) его меняете, — глобально или для каждого приложения. - **Блокировать или переключать** — правила для приложений и для URL могут *блокировать* источник ввода (повторно применяя его при любом отклонении) или один раз *переключать* на него при открытии приложения или страницы, а затем не мешать вам его менять. -- **Блокировать глобально или просто переключать** — задайте в качестве глобального значения по умолчанию один источник ввода, чтобы закрепить его везде, или выберите **Нет**, чтобы использовать LockIME как чистый переключатель для отдельных приложений и сайтов — он переключает вас при входе, а затем оставляет вас свободными, ничего не закрепляя. +- **Блокировать глобально или просто переключать** — задайте глобальному источнику ввода по умолчанию одно из трёх поведений: *блокировать*, чтобы закрепить его везде; *переключать*, чтобы один раз переключить вас на него каждый раз, когда приложение опирается на глобальный источник по умолчанию (у него нет собственного правила или его правило — *Использовать по умолчанию*), а затем оставить вас свободными; или **Нет** — вообще без глобального поведения: тогда LockIME действует только через ваши правила для приложений и для URL. *Переключать* активно переключает вас на него каждый раз, когда применяется значение по умолчанию; **Нет** не делает глобально ничего. - **Гибкое сопоставление URL** — правила для URL (расширенный режим) сопоставляются по домену и его поддоменам, по точному домену, по ключевому слову домена или по регулярному выражению над всем URL, и применяются в порядке приоритета, который вы задаёте перетаскиванием, — побеждает первое совпадение. - **Управление из строки меню** — включение/выключение, смена заблокированного источника ввода, просмотр текущего источника и счётчик срабатываний прямо в строке меню. - **Сочетания клавиш** — настраиваемые глобальные сочетания для включения/выключения LockIME и перебора заблокированного источника ввода, а также сочетания для отдельных приложений, позволяющие переключать или удалять правило активного приложения. diff --git a/docs/README/README.zh-CN.md b/docs/README/README.zh-CN.md index 1b59fb9..1defc6a 100644 --- a/docs/README/README.zh-CN.md +++ b/docs/README/README.zh-CN.md @@ -54,7 +54,7 @@ Mac 匹配的 `.dmg`(Apple silicon 选 `-arm64`,Intel 选 `-x86_64`)。 - **即时重新锁定**——每当你(或其他应用)切换输入源时,立即切回被锁定的那个,可全局或按应用生效。 - **锁定或切换**——按应用和按 URL 的规则既可以*锁定*某个输入源(一旦偏离就重新切回),也可以在你切到该应用或页面时只*切换*一次,之后任你自由更改。 -- **全局锁定,或仅切换**——把全局默认设为某个输入源,就能在所有地方固定它;或把它设为**无**,将 LockIME 当作纯粹的按应用 / 按站点切换器来用——它会在你进入时为你切换,随后便放手让你自由,不固定任何东西。 +- **全局锁定,或仅切换**——给全局默认输入源设定三种行为之一:*锁定*,在所有地方把它钉住;*切换*,每当某个应用回落到全局默认(它没有自身规则,或其规则为*使用默认*)时为你切换一次,随后便放手让你自由;或**无**,即完全没有任何全局行为——此时 LockIME 只通过你的按应用和按 URL 规则发挥作用。*切换*会在每次采用全局默认时主动为你切入;**无**在全局层面什么都不做。 - **灵活的 URL 匹配**——按 URL 规则(增强模式)可以按某个域名及其子域名、某个精确域名、某个域名关键词,或针对完整 URL 的正则表达式来匹配,并按你拖动排列出的优先级顺序生效——第一个命中者胜出。 - **菜单栏控制**——在菜单栏激活/停用、切换被锁定的输入源、查看当前输入源、追踪激活次数。 - **键盘快捷键**——可配置的全局快捷键用于开关 LockIME、切换被锁定的输入源(上一个 / 下一个),以及针对当前最前台应用的快捷键,用于切换或移除该应用的规则。 diff --git a/docs/README/README.zh-TW.md b/docs/README/README.zh-TW.md index 173ddc1..3529fca 100644 --- a/docs/README/README.zh-TW.md +++ b/docs/README/README.zh-TW.md @@ -51,7 +51,7 @@ brew install --cask oomol-lab/tap/lockime - **即時重新鎖定**——每當你(或其他應用程式)切換輸入法時,立即切回被鎖定的那個,可全域或依應用程式生效。 - **鎖定或切換**——各應用程式與各 URL 的規則既可*鎖定*某個輸入法(一旦偏離就重新切回),也可以在你切到該應用程式或頁面時只*切換*一次,之後任你自由變更。 -- **全域鎖定,或僅切換**——把全域預設設為某個輸入法,就能在所有地方固定它;或者把它設為**無**,把 LockIME 當作純粹的依應用程式/依站台切換器使用——它會在你切入時幫你切好,之後便放手讓你自由,什麼也不固定。 +- **全域鎖定,或僅切換**——為全域預設輸入法設定三種行為之一:*鎖定*(lock)以在所有地方固定它;*切換*(switch)會在某個應用程式退回到全域預設(它沒有自己的規則,或其規則設為*使用預設*)時幫你切入一次,之後便放手讓你自由;或者**無**(None)——全域層級完全不做任何行為——此時 LockIME 只透過你的依應用程式與依 URL 規則生效。*切換*會在每次套用全域預設時主動幫你切入;**無**則在全域層級什麼也不做。 - **彈性的 URL 比對**——依 URL 規則(增強模式)可依一個網域及其子網域、一個確切網域、一個網域關鍵字,或一個涵蓋整個 URL 的正規表示式來比對,並依你拖曳排列的優先序套用——第一個比對到的勝出。 - **選單列控制**——在選單列啟用/停用、切換被鎖定的輸入法、檢視目前輸入法、追蹤觸發次數。 - **鍵盤快速鍵**——可自訂的全域快速鍵用於開關 LockIME、切換被鎖定的輸入法(上一個 / 下一個),以及針對目前最前台應用程式的快速鍵,用於切換或移除該應用程式的規則。 diff --git a/docs/URL-Scheme-API/README.de.md b/docs/URL-Scheme-API/README.de.md index 51b3967..43f3300 100644 --- a/docs/URL-Scheme-API/README.de.md +++ b/docs/URL-Scheme-API/README.de.md @@ -109,7 +109,7 @@ auswählbare Quelle benennen, sonst gibt der Befehl `unknown_source` zurück. | Command | Parameters | Effect | |---|---|---| | `lock-to-source` | `id` \| `name` | Die globale Standardquelle festlegen **und** LockIME einschalten. | -| `set-default-source` | `id` \| `name` *(beide weglassen zum Löschen)* | Die globale Standardquelle festlegen (oder löschen), ohne den Ein/Aus-Zustand zu ändern. | +| `set-default-source` | `id` \| `name` *(beide weglassen zum Löschen)*, `action` = `lock` \| `switch` *(Standard `lock`)* | Die globale Standardquelle festlegen (oder löschen), ohne den Ein/Aus-Zustand zu ändern. `action` bestimmt, ob der Standard **sperrt** (die Quelle kontinuierlich erzwingt) oder zu ihr **wechselt**, sobald eine App auf die globale Standardquelle zurückfällt (keine höherpriorisierte URL- oder App-Regel legt eine Quelle fest), und dann freigibt; auf dem Löschpfad wird er ignoriert. | | `cycle-source` | `direction` = `next` \| `previous` | Das globale Ziel zur nächsten/vorherigen installierten Quelle (umlaufend) weiterschalten und LockIME einschalten. | | `switch-source` | `id` \| `name` | Schaltet die aktuelle Eingabequelle **einmalig**, sofort, um — eine kontinuierliche Sperre wird dabei **weder aktiviert noch geändert**. Ist bereits eine kontinuierliche Sperre aktiv, gewinnt sie und schaltet die Quelle auf ihr Ziel zurück. | @@ -205,14 +205,16 @@ Abfragebefehle geben eine JSON-Nutzlast über den `x-success`-Rückruf zurück "build": "20260615", "currentSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, "defaultSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "defaultAction": "lock", "frontmostApp": "com.apple.Safari" } ``` -`enabled` ist der eine Schalter („LockIME aktivieren") — ist er eingeschaltet, +`enabled` ist der eine Schalter („LockIME aktivieren“) — ist er eingeschaltet, sind deine Regeln in Kraft. `currentSource`, `defaultSource` und `frontmostApp` sind nur vorhanden, wenn sie -bekannt sind. +bekannt sind; `defaultAction` (`lock` \| `switch`) begleitet `defaultSource`, wenn +eine globale Standardquelle gesetzt ist. --- @@ -245,6 +247,7 @@ in deine App und in Protokolle, daher wird er niemals lokalisiert. ```sh open "lockime://lock" open "lockime://lock-to-source?id=com.apple.keylayout.ABC" +open "lockime://set-default-source?id=com.apple.keylayout.ABC&action=switch" open "lockime://set-app-rule?bundle=com.apple.Terminal&mode=lock&source=com.apple.keylayout.ABC" open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch" open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain" diff --git a/docs/URL-Scheme-API/README.es.md b/docs/URL-Scheme-API/README.es.md index 7cf672f..8febb2c 100644 --- a/docs/URL-Scheme-API/README.es.md +++ b/docs/URL-Scheme-API/README.es.md @@ -102,7 +102,7 @@ instalada y seleccionable actualmente, o el comando devuelve `unknown_source`. | Command | Parameters | Effect | |---|---|---| | `lock-to-source` | `id` \| `name` | Establece la fuente predeterminada global **y** activa LockIME. | -| `set-default-source` | `id` \| `name` *(omite ambos para borrarla)* | Establece (o borra) la fuente predeterminada global sin cambiar el estado activado/desactivado. | +| `set-default-source` | `id` \| `name` *(omite ambos para borrarla)*, `action` = `lock` \| `switch` *(default `lock`)* | Establece (o borra) la fuente predeterminada global sin cambiar el estado activado/desactivado. `action` elige si la predeterminada **bloquea** (aplica la fuente de forma continua) o **cambia** a ella una sola vez cada vez que una aplicación recurre a la fuente predeterminada global (ninguna URL ni regla de aplicación de mayor prioridad fija una fuente) y luego la suelta; se ignora en la vía de borrado. | | `cycle-source` | `direction` = `next` \| `previous` | Avanza el objetivo global a la fuente instalada siguiente/anterior (con vuelta al inicio) y activa LockIME. | | `switch-source` | `id` \| `name` | Cambia la fuente de entrada actual **una sola vez**, ahora mismo: **no** activa ni modifica ningún bloqueo continuo. Si ya hay un bloqueo continuo activo, este prevalece y devuelve la fuente a su objetivo. | @@ -196,13 +196,16 @@ Los comandos de consulta devuelven una carga útil JSON a través del callback ` "build": "20260615", "currentSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, "defaultSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "defaultAction": "lock", "frontmostApp": "com.apple.Safari" } ``` `enabled` es el único interruptor «Activar LockIME» — cuando está activado, tus reglas están en vigor. -`currentSource`, `defaultSource` y `frontmostApp` están presentes solo cuando se conocen. +`currentSource`, `defaultSource` y `frontmostApp` están presentes solo cuando se conocen; +`defaultAction` (`lock` \| `switch`) acompaña a `defaultSource` cuando hay una predeterminada +global establecida. --- @@ -235,6 +238,7 @@ localiza. ```sh open "lockime://lock" open "lockime://lock-to-source?id=com.apple.keylayout.ABC" +open "lockime://set-default-source?id=com.apple.keylayout.ABC&action=switch" open "lockime://set-app-rule?bundle=com.apple.Terminal&mode=lock&source=com.apple.keylayout.ABC" open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch" open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain" diff --git a/docs/URL-Scheme-API/README.fr.md b/docs/URL-Scheme-API/README.fr.md index 012e778..05b591e 100644 --- a/docs/URL-Scheme-API/README.fr.md +++ b/docs/URL-Scheme-API/README.fr.md @@ -99,7 +99,7 @@ actuellement installée et sélectionnable, sinon la commande renvoie `unknown_s | Command | Parameters | Effect | |---|---|---| | `lock-to-source` | `id` \| `name` | Définir la source par défaut globale **et** activer LockIME. | -| `set-default-source` | `id` \| `name` *(omit both to clear)* | Définir (ou effacer) la source par défaut globale sans changer l'état activé/désactivé. | +| `set-default-source` | `id` \| `name` *(omit both to clear)*, `action` = `lock` \| `switch` *(default `lock`)* | Définir (ou effacer) la source par défaut globale sans changer l'état activé/désactivé. `action` détermine si la valeur par défaut **verrouille** (impose continuellement la source) ou **bascule** vers elle une seule fois chaque fois qu'une application retombe sur la valeur par défaut globale (aucune règle d'URL ou d'application de priorité supérieure n'épingle de source), puis relâche ; il est ignoré sur le chemin d'effacement. | | `cycle-source` | `direction` = `next` \| `previous` | Faire passer la cible globale à la source installée suivante/précédente (avec bouclage) et activer LockIME. | | `switch-source` | `id` \| `name` | Change la source de saisie actuelle **une seule fois**, maintenant — cela n'**active ni ne modifie** aucun verrou continu. Si un verrou continu est déjà actif, il l'emporte et rétablit la source sur sa cible. | @@ -193,12 +193,14 @@ Les commandes de requête retournent une charge utile JSON via le rappel `x-succ "build": "20260615", "currentSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, "defaultSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "defaultAction": "lock", "frontmostApp": "com.apple.Safari" } ``` `enabled` est l'unique interrupteur « Activer LockIME » — lorsqu'il est activé, vos règles sont en vigueur. -`currentSource`, `defaultSource` et `frontmostApp` ne sont présents que lorsqu'ils sont connus. +`currentSource`, `defaultSource` et `frontmostApp` ne sont présents que lorsqu'ils sont connus ; +`defaultAction` (`lock` \| `switch`) accompagne `defaultSource` lorsqu'une source par défaut globale est définie. --- @@ -231,6 +233,7 @@ vers les journaux, il n'est donc jamais localisé. ```sh open "lockime://lock" open "lockime://lock-to-source?id=com.apple.keylayout.ABC" +open "lockime://set-default-source?id=com.apple.keylayout.ABC&action=switch" open "lockime://set-app-rule?bundle=com.apple.Terminal&mode=lock&source=com.apple.keylayout.ABC" open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch" open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain" diff --git a/docs/URL-Scheme-API/README.ja.md b/docs/URL-Scheme-API/README.ja.md index 26c365c..8db1f88 100644 --- a/docs/URL-Scheme-API/README.ja.md +++ b/docs/URL-Scheme-API/README.ja.md @@ -102,7 +102,7 @@ myapp://got-status?result=%7B%22enabled%22%3Atrue%2C…%7D | Command | Parameters | Effect | |---|---|---| | `lock-to-source` | `id` \| `name` | グローバルのデフォルトソースを設定し、**かつ** LockIME をオンにします。 | -| `set-default-source` | `id` \| `name` *(omit both to clear)* | オン/オフの状態を変えずに、グローバルのデフォルトソースを設定(またはクリア)します。 | +| `set-default-source` | `id` \| `name` *(omit both to clear)*, `action` = `lock` \| `switch` *(default `lock`)* | オン/オフの状態を変えずに、グローバルのデフォルトソースを設定(またはクリア)します。`action` は、デフォルトがソースを**ロック**する(ソースを継続的に強制する)か、それとも、アプリがグローバルのデフォルトにフォールバックする(より優先度の高い URL やアプリのルールがソースを固定していない)たびに、一度だけそのソースへ**切り替えて**から解放するかを選びます。クリア処理の際には無視されます。 | | `cycle-source` | `direction` = `next` \| `previous` | グローバルのターゲットをインストール済みの次/前のソースへ(循環して)進め、LockIME をオンにします。 | | `switch-source` | `id` \| `name` | 現在の入力ソースを今ここで**一度だけ**切り替えます——継続ロックを有効化したり変更したりは**しません**。すでに継続ロックがアクティブな場合は、そちらが優先され、入力ソースをロックの目標に戻します。 | @@ -197,6 +197,7 @@ LockIME は設計上、**UI を開くコマンドを一切公開していませ "build": "20260615", "currentSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, "defaultSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "defaultAction": "lock", "frontmostApp": "com.apple.Safari" } ``` @@ -204,6 +205,7 @@ LockIME は設計上、**UI を開くコマンドを一切公開していませ `enabled` は唯一の「LockIME を有効にする」スイッチです——オンのとき、あなたのルールが 効いています。 `currentSource`、`defaultSource`、`frontmostApp` は、判明している場合にのみ含まれます。 +`defaultAction`(`lock` \| `switch`)は、グローバルのデフォルトが設定されているとき `defaultSource` に伴って含まれます。 --- @@ -235,6 +237,7 @@ LockIME は設計上、**UI を開くコマンドを一切公開していませ ```sh open "lockime://lock" open "lockime://lock-to-source?id=com.apple.keylayout.ABC" +open "lockime://set-default-source?id=com.apple.keylayout.ABC&action=switch" open "lockime://set-app-rule?bundle=com.apple.Terminal&mode=lock&source=com.apple.keylayout.ABC" open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch" open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain" diff --git a/docs/URL-Scheme-API/README.md b/docs/URL-Scheme-API/README.md index 678a878..17a53d9 100644 --- a/docs/URL-Scheme-API/README.md +++ b/docs/URL-Scheme-API/README.md @@ -102,7 +102,7 @@ installed, selectable source or the command returns `unknown_source`. | Command | Parameters | Effect | |---|---|---| | `lock-to-source` | `id` \| `name` | Set the global default source **and** turn LockIME on. | -| `set-default-source` | `id` \| `name` *(omit both to clear)* | Set (or clear) the global default source without changing the on/off state. | +| `set-default-source` | `id` \| `name` *(omit both to clear)*, `action` = `lock` \| `switch` *(default `lock`)* | Set (or clear) the global default source without changing the on/off state. `action` picks whether the default **locks** (continuously enforces the source) or **switches** to it once whenever an app falls back to the global default (no higher-priority URL or app rule pins a source), then releases; it is ignored on the clear path. | | `cycle-source` | `direction` = `next` \| `previous` | Step the global target to the next/previous installed source (wrapping) and turn LockIME on. | | `switch-source` | `id` \| `name` | Switch the current input source **once**, right now — it does **not** turn on or change a continuous lock. If a continuous lock is already active, it wins and switches the source back to its target. | @@ -195,13 +195,16 @@ Query commands return a JSON payload through the `x-success` callback (see "build": "20260615", "currentSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, "defaultSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "defaultAction": "lock", "frontmostApp": "com.apple.Safari" } ``` `enabled` is the single "Enable LockIME" switch — when it is on, your rules are in force. -`currentSource`, `defaultSource`, and `frontmostApp` are present only when known. +`currentSource`, `defaultSource`, and `frontmostApp` are present only when known; +`defaultAction` (`lock` \| `switch`) accompanies `defaultSource` when a global +default is set. --- @@ -234,6 +237,7 @@ localized. ```sh open "lockime://lock" open "lockime://lock-to-source?id=com.apple.keylayout.ABC" +open "lockime://set-default-source?id=com.apple.keylayout.ABC&action=switch" open "lockime://set-app-rule?bundle=com.apple.Terminal&mode=lock&source=com.apple.keylayout.ABC" open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch" open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain" diff --git a/docs/URL-Scheme-API/README.pt.md b/docs/URL-Scheme-API/README.pt.md index 670d552..e49d201 100644 --- a/docs/URL-Scheme-API/README.pt.md +++ b/docs/URL-Scheme-API/README.pt.md @@ -107,7 +107,7 @@ instalada e selecionável, ou o comando retorna `unknown_source`. | Command | Parameters | Effect | |---|---|---| | `lock-to-source` | `id` \| `name` | Define a fonte padrão global **e** liga o LockIME. | -| `set-default-source` | `id` \| `name` *(omita ambos para limpar)* | Define (ou limpa) a fonte padrão global sem alterar o estado ligado/desligado. | +| `set-default-source` | `id` \| `name` *(omita ambos para limpar)*, `action` = `lock` \| `switch` *(padrão `lock`)* | Define (ou limpa) a fonte padrão global sem alterar o estado ligado/desligado. `action` escolhe se o padrão **bloqueia** (impõe a fonte continuamente) ou **alterna** para ela sempre que um app recorre ao padrão global (nenhuma regra por URL ou por app de prioridade mais alta fixa uma fonte) e depois libera; é ignorado no caminho de limpeza. | | `cycle-source` | `direction` = `next` \| `previous` | Avança o alvo global para a próxima/anterior fonte instalada (com retorno cíclico) e liga o LockIME. | | `switch-source` | `id` \| `name` | Alterna a fonte de entrada atual **uma vez**, agora — **não** ativa nem modifica nenhum bloqueio contínuo. Se um bloqueio contínuo já estiver ativo, ele prevalece e devolve a fonte ao seu alvo. | @@ -203,13 +203,15 @@ Os comandos de consulta retornam um payload JSON através do callback "build": "20260615", "currentSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, "defaultSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "defaultAction": "lock", "frontmostApp": "com.apple.Safari" } ``` `enabled` é o único interruptor "Ativar o LockIME" — quando ele está ligado, suas regras estão em vigor. -`currentSource`, `defaultSource` e `frontmostApp` estão presentes apenas quando conhecidos. +`currentSource`, `defaultSource` e `frontmostApp` estão presentes apenas quando conhecidos; +`defaultAction` (`lock` \| `switch`) acompanha `defaultSource` quando um padrão global está definido. --- @@ -242,6 +244,7 @@ para os logs, portanto nunca é localizado. ```sh open "lockime://lock" open "lockime://lock-to-source?id=com.apple.keylayout.ABC" +open "lockime://set-default-source?id=com.apple.keylayout.ABC&action=switch" open "lockime://set-app-rule?bundle=com.apple.Terminal&mode=lock&source=com.apple.keylayout.ABC" open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch" open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain" diff --git a/docs/URL-Scheme-API/README.ru.md b/docs/URL-Scheme-API/README.ru.md index f212075..0bae0bc 100644 --- a/docs/URL-Scheme-API/README.ru.md +++ b/docs/URL-Scheme-API/README.ru.md @@ -100,7 +100,7 @@ myapp://got-status?result=%7B%22enabled%22%3Atrue%2C…%7D | Command | Parameters | Effect | |---|---|---| | `lock-to-source` | `id` \| `name` | Задать глобальный источник по умолчанию **и** включить LockIME. | -| `set-default-source` | `id` \| `name` *(omit both to clear)* | Задать (или сбросить) глобальный источник по умолчанию, не меняя состояние вкл/выкл. | +| `set-default-source` | `id` \| `name` *(omit both to clear)*, `action` = `lock` \| `switch` *(default `lock`)* | Задать (или сбросить) глобальный источник по умолчанию, не меняя состояние вкл/выкл. `action` определяет, будет ли значение по умолчанию **блокировать** (непрерывно применять источник) или **переключаться** на него один раз всякий раз, когда приложение возвращается к глобальному значению по умолчанию (когда ни одно правило для URL или приложения с более высоким приоритетом не закрепляет источник), а затем отпускать; на пути сброса он игнорируется. | | `cycle-source` | `direction` = `next` \| `previous` | Перейти к следующему/предыдущему установленному источнику в глобальной цели (по кругу) и включить LockIME. | | `switch-source` | `id` \| `name` | Переключает текущий источник ввода **один раз**, прямо сейчас — это **не** включает и не изменяет непрерывную блокировку. Если непрерывная блокировка уже активна, она берёт верх и возвращает источник к своей цели. | @@ -195,12 +195,14 @@ LockIME намеренно не предоставляет **никаких ко "build": "20260615", "currentSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, "defaultSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "defaultAction": "lock", "frontmostApp": "com.apple.Safari" } ``` `enabled` — это единственный переключатель «Включить LockIME»: когда он включён, действуют ваши правила. -`currentSource`, `defaultSource` и `frontmostApp` присутствуют только когда известны. +`currentSource`, `defaultSource` и `frontmostApp` присутствуют только когда известны; +`defaultAction` (`lock` \| `switch`) сопровождает `defaultSource`, когда задан глобальный источник по умолчанию. --- @@ -233,6 +235,7 @@ LockIME намеренно не предоставляет **никаких ко ```sh open "lockime://lock" open "lockime://lock-to-source?id=com.apple.keylayout.ABC" +open "lockime://set-default-source?id=com.apple.keylayout.ABC&action=switch" open "lockime://set-app-rule?bundle=com.apple.Terminal&mode=lock&source=com.apple.keylayout.ABC" open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch" open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain" diff --git a/docs/URL-Scheme-API/README.zh-CN.md b/docs/URL-Scheme-API/README.zh-CN.md index c8f044b..3893114 100644 --- a/docs/URL-Scheme-API/README.zh-CN.md +++ b/docs/URL-Scheme-API/README.zh-CN.md @@ -94,7 +94,7 @@ myapp://got-status?result=%7B%22enabled%22%3Atrue%2C…%7D | Command | Parameters | Effect | |---|---|---| | `lock-to-source` | `id` \| `name` | 设置全局默认输入源**并**开启 LockIME。 | -| `set-default-source` | `id` \| `name` *(omit both to clear)* | 设置(或清除)全局默认输入源,而不改变开/关状态。 | +| `set-default-source` | `id` \| `name` *(omit both to clear)*, `action` = `lock` \| `switch` *(default `lock`)* | 设置(或清除)全局默认输入源,而不改变开/关状态。`action` 决定该默认项是**锁定**(持续强制该输入源)还是在某个应用回退到全局默认(没有更高优先级的 URL 或应用规则固定某个输入源)时**切换**一次到它、随后放手;在清除路径上它会被忽略。 | | `cycle-source` | `direction` = `next` \| `previous` | 将全局目标切换到下一个/上一个已安装的输入源(循环),并开启 LockIME。 | | `switch-source` | `id` \| `name` | 立即将当前输入源切换**一次**,仅此一次——它**不会**开启或修改持续锁定。若此时已有持续锁定在生效,它会胜出,并把输入源切回锁定目标。 | @@ -186,12 +186,14 @@ LockIME 刻意**不提供任何打开其 UI 的命令**(设置、关于、更 "build": "20260615", "currentSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, "defaultSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "defaultAction": "lock", "frontmostApp": "com.apple.Safari" } ``` `enabled` 即那个唯一的“启用 LockIME”开关——它开启时,你的规则即处于生效状态。 -`currentSource`、`defaultSource` 和 `frontmostApp` 仅在已知时才会出现。 +`currentSource`、`defaultSource` 和 `frontmostApp` 仅在已知时才会出现; +当设置了全局默认输入源时,`defaultAction`(`lock` \| `switch`)会伴随 `defaultSource` 一同出现。 --- @@ -223,6 +225,7 @@ LockIME 刻意**不提供任何打开其 UI 的命令**(设置、关于、更 ```sh open "lockime://lock" open "lockime://lock-to-source?id=com.apple.keylayout.ABC" +open "lockime://set-default-source?id=com.apple.keylayout.ABC&action=switch" open "lockime://set-app-rule?bundle=com.apple.Terminal&mode=lock&source=com.apple.keylayout.ABC" open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch" open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain" diff --git a/docs/URL-Scheme-API/README.zh-TW.md b/docs/URL-Scheme-API/README.zh-TW.md index 79de2eb..999799f 100644 --- a/docs/URL-Scheme-API/README.zh-TW.md +++ b/docs/URL-Scheme-API/README.zh-TW.md @@ -95,7 +95,7 @@ myapp://got-status?result=%7B%22enabled%22%3Atrue%2C…%7D | Command | Parameters | Effect | |---|---|---| | `lock-to-source` | `id` \| `name` | 設定全域預設輸入法**並**開啟 LockIME。 | -| `set-default-source` | `id` \| `name` *(omit both to clear)* | 設定(或清除)全域預設輸入法,不改變開/關狀態。 | +| `set-default-source` | `id` \| `name` *(omit both to clear)*, `action` = `lock` \| `switch` *(default `lock`)* | 設定(或清除)全域預設輸入法,不改變開/關狀態。`action` 決定這個預設是要**鎖定**(持續強制使用該輸入法),還是在應用程式退回全域預設時(沒有更高優先序的 URL 或應用程式規則固定住某個輸入法)**切換**到它一次,然後放手;在清除路徑上它會被忽略。 | | `cycle-source` | `direction` = `next` \| `previous` | 把全域目標推進到下一個/上一個已安裝的輸入法(循環),並開啟 LockIME。 | | `switch-source` | `id` \| `name` | 立刻把目前的輸入法**切換一次**,僅此一次——它**不會**開啟或修改持續鎖定。若此時已有持續鎖定在生效,它會勝出,並把輸入法切回鎖定目標。 | @@ -188,12 +188,14 @@ LockIME 刻意**不提供任何開啟其 UI 的指令**(Settings、About、更 "build": "20260615", "currentSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, "defaultSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "defaultAction": "lock", "frontmostApp": "com.apple.Safari" } ``` `enabled` 就是那個唯一的「啟用 LockIME」開關——它開啟時,你的規則便會生效。 -`currentSource`、`defaultSource` 和 `frontmostApp` 只在已知時才會出現。 +`currentSource`、`defaultSource` 和 `frontmostApp` 只在已知時才會出現; +`defaultAction`(`lock` \| `switch`)會在設定了全域預設時,隨 `defaultSource` 一起出現。 --- @@ -225,6 +227,7 @@ LockIME 刻意**不提供任何開啟其 UI 的指令**(Settings、About、更 ```sh open "lockime://lock" open "lockime://lock-to-source?id=com.apple.keylayout.ABC" +open "lockime://set-default-source?id=com.apple.keylayout.ABC&action=switch" open "lockime://set-app-rule?bundle=com.apple.Terminal&mode=lock&source=com.apple.keylayout.ABC" open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch" open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain"