Skip to content

fix(rules): keep a global default of None across relaunch#48

Merged
BlackHole1 merged 1 commit into
mainfrom
fix/global-default-none-persist
Jul 1, 2026
Merged

fix(rules): keep a global default of None across relaunch#48
BlackHole1 merged 1 commit into
mainfrom
fix/global-default-none-persist

Conversation

@BlackHole1

Copy link
Copy Markdown
Member

Setting App Rules → Global default → Locked input source to None didn't survive a restart: on every launch it reverted to the currently active source (e.g. U.S./ABC), forcing that input method on any app without a per-app rule.

The cause is an overloaded nil. AppState.start() seeded the global default from the live input source whenever config.defaultSourceID == nil, but that nil means two different things — "genuine first run, never configured" and "the user deliberately chose None". Both load identically, so a chosen None was re-seeded to whatever source was active at launch and then persisted over the user's choice, every relaunch. A concrete source persisted fine because it isn't nil, which matches the report.

The fix adds RuleStore.hasPersistedConfiguration — whether a config has ever been written to defaults — as the one signal that tells a true first run apart from a returning user. start() captures isFirstRun before loading and gates the seed on it, so a persisted None is left untouched.

Added a regression test asserting a saved config with a nil default still counts as persisted.

Closes #46

`AppState.start()` seeded the global default lock from the live input
source whenever `config.defaultSourceID == nil`. That `nil` is
overloaded: it means both "genuine first run, never configured" and "the
user deliberately chose None". So every relaunch re-seeded a chosen None
back to whatever source was active at launch (e.g. ABC), then persisted
it — the user's None silently became ABC.

Add `RuleStore.hasPersistedConfiguration`, which reports whether a config
has ever been written to `defaults`. That is the only signal that tells a
true first run apart from a returning user, since both load as
`defaultSourceID == nil`. Gate the seed on it: `start()` captures
`isFirstRun` before loading and seeds only then, so a persisted None
survives every relaunch.

Signed-off-by: Kevin Cui <bh@bugs.cc>
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

Summary by CodeRabbit

  • Bug Fixes

    • Fixed an issue where the app could mistakenly treat a returning setup with no global default as a first launch.
    • Prevented the app from reapplying a saved lock source on later restarts when it shouldn’t.
  • Tests

    • Added regression coverage for detecting whether settings were previously saved.
    • Added coverage for configurations that intentionally keep the global default unset.

Walkthrough

This PR fixes a bug where setting the global default locked input source to "None" did not persist across app restarts. RuleStore gains a hasPersistedConfiguration property that checks whether any configuration data was ever saved to UserDefaults. AppState.start() now uses this property to compute isFirstRun before loading configuration, and requires isFirstRun (not just a nil defaultSourceID) before seeding the global default from the currently active source. New tests cover both the persisted-state detection and the nil-default persistence case.

Sequence Diagram(s)

sequenceDiagram
  participant AppState
  participant RuleStore
  participant UserDefaults

  AppState->>RuleStore: hasPersistedConfiguration
  RuleStore->>UserDefaults: data(forKey: key)
  UserDefaults-->>RuleStore: data or nil
  RuleStore-->>AppState: isFirstRun (Bool)
  AppState->>RuleStore: load()
  RuleStore-->>AppState: config
  alt isFirstRun && defaultSourceID == nil
    AppState->>AppState: seed global default from active source
  else returning user with None default
    AppState->>AppState: keep defaultSourceID as nil
  end
Loading

Related Issues: #46

Suggested Labels: bug, fix

Suggested Reviewers: (none identified)

Poem

A rabbit checked its saved-state key,
"Was this my first hop, or hop three?"
No more forced switch to U.S. sound,
"None" now stays exactly where it's found.
Tests confirm the fix runs true —
Restart in peace, little burrow crew. 🐇

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title follows the required fix(scope): subject format and clearly describes the main change.
Description check ✅ Passed The description is directly related to the persistence fix and regression test for a nil global default.
Linked Issues check ✅ Passed The changes address #46 by distinguishing first run from saved None and preserving the user's selection across relaunches.
Out of Scope Changes check ✅ Passed The PR stays focused on persistence detection and the related regression test, with no clear unrelated changes.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fix/global-default-none-persist

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
Sources/LockIMEKit/Rules/RuleStore.swift (1)

26-36: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider gating on decodability, not just raw data presence.

hasPersistedConfiguration only checks that some Data exists under the key — it doesn't verify that load() can actually decode it. If a future schema change (or corrupted defaults) makes decoding fail, load()'s try? falls back to .default (nil defaultSourceID), yet hasPersistedConfiguration still reports true. Downstream in AppState.start(), that means isFirstRun is false even though no usable config was ever recovered, so the app won't reseed the default from the live input source — the very case this PR is fixing to avoid, but now triggered by data corruption rather than user intent, and with no way to self-heal.

Since load() already contains the decode logic, this property could reuse it rather than duplicating the "does data exist" check with different semantics:

♻️ Suggested approach: derive from decode success
     public var hasPersistedConfiguration: Bool {
-        defaults.data(forKey: key) != nil
+        guard let data = defaults.data(forKey: key) else { return false }
+        return (try? JSONDecoder().decode(LockConfiguration.self, from: data)) != nil
     }

This is a narrow edge case (requires a future breaking schema change or corrupted defaults), but the current implementation's failure mode is a silent, permanent "stuck on None" regression that's hard to distinguish from a deliberate user choice.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/LockIMEKit/Rules/RuleStore.swift` around lines 26 - 36,
`hasPersistedConfiguration` in `RuleStore` should reflect whether a saved config
is actually usable, not just whether raw `Data` exists. Update it to reuse
`load()`’s decode path (or equivalent decodability check) so corrupted or
schema-mismatched defaults are treated like no persisted configuration. This
keeps `AppState.start()`’s first-run detection aligned with `load()` and avoids
suppressing reseed when recovery fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@Sources/LockIMEKit/Rules/RuleStore.swift`:
- Around line 26-36: `hasPersistedConfiguration` in `RuleStore` should reflect
whether a saved config is actually usable, not just whether raw `Data` exists.
Update it to reuse `load()`’s decode path (or equivalent decodability check) so
corrupted or schema-mismatched defaults are treated like no persisted
configuration. This keeps `AppState.start()`’s first-run detection aligned with
`load()` and avoids suppressing reseed when recovery fails.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 42c834d7-185c-4408-af5d-54c926a449dc

📥 Commits

Reviewing files that changed from the base of the PR and between 48cbd91 and 91b4cba.

📒 Files selected for processing (3)
  • Sources/LockIME/AppState.swift
  • Sources/LockIMEKit/Rules/RuleStore.swift
  • Tests/LockIMEKitTests/RuleStoreTests.swift

@BlackHole1 BlackHole1 merged commit a28c33e into main Jul 1, 2026
3 checks passed
@BlackHole1 BlackHole1 deleted the fix/global-default-none-persist branch July 1, 2026 08:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug? "Locked input source" setting to None does not persist across application restarts

1 participant