diff --git a/Sources/LockIME/AppState.swift b/Sources/LockIME/AppState.swift index 1ccac44..66a97fa 100644 --- a/Sources/LockIME/AppState.swift +++ b/Sources/LockIME/AppState.swift @@ -179,6 +179,10 @@ final class AppState { /// Build and start the engine. Called once at launch from the app delegate. func start() { guard engine == nil else { return } + // Capture this *before* loading (and before the save below writes one): + // it tells a genuine first run apart from a returning user who set the + // global default to "None". Both load as `defaultSourceID == nil`. + let isFirstRun = !store.hasPersistedConfiguration config = store.load() activationCount = activationStore.count @@ -210,8 +214,11 @@ final class AppState { engine.start() availableSources = engine.selectableSources() - // First run: default the global lock to the currently active source. - if config.defaultSourceID == nil, let current = engine.currentSourceID() { + // First run only: seed the global lock from the currently active source. + // Gated on `isFirstRun` — a returning user who set the default to "None" + // persists `nil`, and re-seeding that would silently turn "None" back + // into whatever source was active at launch (e.g. ABC) on every relaunch. + if isFirstRun, config.defaultSourceID == nil, let current = engine.currentSourceID() { config.defaultSourceID = current } engine.apply(config, reason: .startupApplied) diff --git a/Sources/LockIMEKit/Rules/RuleStore.swift b/Sources/LockIMEKit/Rules/RuleStore.swift index ccdaaa6..589a734 100644 --- a/Sources/LockIMEKit/Rules/RuleStore.swift +++ b/Sources/LockIMEKit/Rules/RuleStore.swift @@ -23,6 +23,17 @@ public final class RuleStore: @unchecked Sendable { return config } + /// Whether a configuration has ever been written to `defaults`. This is the + /// only way to tell a genuine **first run** — where the app seeds the global + /// default from the currently active input source — apart from a returning + /// user who deliberately set that default to **None**. Both surface as a + /// loaded config with `defaultSourceID == nil`, so gating the first-run seed + /// on `defaultSourceID == nil` alone would silently overwrite a chosen "None" + /// with whatever source happened to be active at launch, on *every* relaunch. + public var hasPersistedConfiguration: Bool { + defaults.data(forKey: key) != nil + } + public func save(_ config: LockConfiguration) { guard let data = try? JSONEncoder().encode(config) else { return } defaults.set(data, forKey: key) diff --git a/Tests/LockIMEKitTests/RuleStoreTests.swift b/Tests/LockIMEKitTests/RuleStoreTests.swift index 11279a6..e266950 100644 --- a/Tests/LockIMEKitTests/RuleStoreTests.swift +++ b/Tests/LockIMEKitTests/RuleStoreTests.swift @@ -62,6 +62,27 @@ struct RuleStoreTests { #expect(loaded.urlRules.first?.action == .lock) } + @Test("hasPersistedConfiguration is false until a save happens") + func hasPersistedConfigurationStartsFalse() { + let store = RuleStore(defaults: freshDefaults()) + #expect(store.hasPersistedConfiguration == false) + store.save(.default) + #expect(store.hasPersistedConfiguration == true) + } + + // Regression: a returning user who set the global default to "None" persists + // `defaultSourceID == nil`, yet the config *is* on disk — so the first-run + // seed (which turns a nil default into the live source) must NOT fire for + // them. `hasPersistedConfiguration` is what tells the two apart; assert it + // reports `true` even though the saved default is nil. + @Test("a saved config with a nil default still counts as persisted") + func nilDefaultStillCountsAsPersisted() { + let store = RuleStore(defaults: freshDefaults()) + store.save(LockConfiguration(isEnabled: true, defaultSourceID: nil)) + #expect(store.hasPersistedConfiguration == true) + #expect(store.load().defaultSourceID == nil) + } + @Test("a later save overwrites an earlier one") func overwrite() { let defaults = freshDefaults()