Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions Sources/LockIME/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions Sources/LockIMEKit/Rules/RuleStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions Tests/LockIMEKitTests/RuleStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading