Skip to content

Commit ffeb7d9

Browse files
committed
Onboarding: Postpone update toast until onboarding finishes
- Add `isOnboarded` boolean to `Settings` (default `false`), persisted via the existing settings store. - Gate the "update ready" toast in `updater.svelte.ts` on a pure `shouldShowUpdateToast({ onboarded, fdaPromptShowing, status })` predicate. Helper `showUpdateToast()` replaces the two direct `addToast` calls. - Export `notifyOnboardingComplete()` and `setFdaPromptShowing(value)` for the route to drive. Re-attempts the toast when either gate opens. - Wire `+page.svelte`: `setFdaPromptShowing(true|false)` around `FullDiskAccessPrompt` (first-run AND `wasRevoked`), `notifyOnboardingComplete()` in `handleFdaComplete()` and the legacy `hasFda === true` branch (covers users who granted FDA before this flag existed). - Seed `onboarded` from `loadSettings().isOnboarded` on `startUpdateChecker()` start so returning users aren't gated. - Drop stale "skips in dev mode" comment in `+layout.svelte` (the updater has no dev guard; the CI skip is in Rust). - Add `updater.test.ts` covering the truth table and both trigger paths (15 tests). - Update `updates/CLAUDE.md` and `onboarding/CLAUDE.md`.
1 parent d523e23 commit ffeb7d9

7 files changed

Lines changed: 317 additions & 7 deletions

File tree

apps/desktop/src/lib/onboarding/CLAUDE.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,23 @@ The `wasRevoked` prop switches the copy from "first ask" to "revoked" framing.
3030
- `'allow'` (but FDA revoked) → show prompt with "revoked" framing.
3131
- `'deny'` → skip (user previously declined).
3232

33+
## Onboarding flag and deferred update toast
34+
35+
A separate `isOnboarded` boolean lives in `$lib/settings-store.ts` (default `false`). It exists so the auto-update
36+
"restart to apply" toast doesn't fire during first-launch onboarding (the user just downloaded the app — they'd be
37+
confused) nor stack on top of the FDA-revoked re-prompt.
38+
39+
`+page.svelte` calls `notifyOnboardingComplete()` from `$lib/updates/updater.svelte` in two places:
40+
41+
- `handleFdaComplete()` — fires whichever way the FDA prompt closes (Allow → restart hint, Deny → setting saved). The
42+
helper persists `isOnboarded: true` itself, so the page doesn't double-save.
43+
- The `hasFda === true` branch — covers users who granted FDA before the flag existed. If `!settings.isOnboarded`, call
44+
the helper so they get unblocked too.
45+
46+
Around the same place where `showFdaPrompt = true` is set (both first-run and `wasRevoked`), `+page.svelte` also calls
47+
`setFdaPromptShowing(true)` so the updater suppresses the toast while the modal is up. `handleFdaComplete()` flips it
48+
back with `setFdaPromptShowing(false)`. See `$lib/updates/CLAUDE.md` § "Onboarding gating" for the updater side.
49+
3350
## Key decisions
3451

3552
**Decision**: Three-state setting (`notAskedYet` / `allow` / `deny`) instead of a boolean. **Why**: The app needs to

apps/desktop/src/lib/settings-store.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ export type FullDiskAccessChoice = 'allow' | 'deny' | 'notAskedYet'
1111
export interface Settings {
1212
showHiddenFiles: boolean
1313
fullDiskAccessChoice: FullDiskAccessChoice
14+
isOnboarded: boolean
1415
}
1516

1617
const DEFAULT_SETTINGS: Settings = {
1718
showHiddenFiles: true,
1819
fullDiskAccessChoice: 'notAskedYet',
20+
isOnboarded: false,
1921
}
2022

2123
let storeInstance: Store | null = null
@@ -36,13 +38,15 @@ export async function loadSettings(): Promise<Settings> {
3638
const store = await getStore()
3739
const showHiddenFiles = await store.get('showHiddenFiles')
3840
const fullDiskAccessChoice = await store.get('fullDiskAccessChoice')
41+
const isOnboarded = await store.get('isOnboarded')
3942

4043
const validChoices: FullDiskAccessChoice[] = ['allow', 'deny', 'notAskedYet']
4144
return {
4245
showHiddenFiles: typeof showHiddenFiles === 'boolean' ? showHiddenFiles : DEFAULT_SETTINGS.showHiddenFiles,
4346
fullDiskAccessChoice: validChoices.includes(fullDiskAccessChoice as FullDiskAccessChoice)
4447
? (fullDiskAccessChoice as FullDiskAccessChoice)
4548
: DEFAULT_SETTINGS.fullDiskAccessChoice,
49+
isOnboarded: typeof isOnboarded === 'boolean' ? isOnboarded : DEFAULT_SETTINGS.isOnboarded,
4650
}
4751
} catch {
4852
// If store fails, return defaults
@@ -62,6 +66,9 @@ export async function saveSettings(settings: Partial<Settings>): Promise<void> {
6266
if (settings.fullDiskAccessChoice !== undefined) {
6367
await store.set('fullDiskAccessChoice', settings.fullDiskAccessChoice)
6468
}
69+
if (settings.isOnboarded !== undefined) {
70+
await store.set('isOnboarded', settings.isOnboarded)
71+
}
6572
await store.save()
6673
} catch {
6774
// Silently fail - persistence is nice-to-have

apps/desktop/src/lib/updates/CLAUDE.md

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,31 @@ The frontend branches on platform at the top of `checkForUpdates()`:
3737
- **Non-macOS**: dynamically imports `@tauri-apps/plugin-updater` and calls `check()` / `downloadAndInstall()`. The
3838
custom updater Rust module is not compiled on these platforms.
3939

40-
When `status` becomes `'ready'`, the updater calls
41-
`addToast(UpdateToastContent, { id: 'update', dismissal: 'persistent' })` to show the restart prompt via the global
42-
toast system. `UpdateToastContent.svelte` renders the toast body, calls `relaunch()` directly from
40+
When `status` becomes `'ready'`, the updater funnels through the `showUpdateToast()` helper instead of calling
41+
`addToast` directly. The helper consults `shouldShowUpdateToast({ onboarded, fdaPromptShowing, status })` — a pure,
42+
unit-tested predicate — and only fires `addToast(UpdateToastContent, { id: 'update', dismissal: 'persistent' })` when
43+
all three conditions hold. `UpdateToastContent.svelte` renders the toast body, calls `relaunch()` directly from
4344
`@tauri-apps/plugin-process` for the restart action, and handles the "Later" button by calling `dismissToast('update')`.
4445
There is no local `$state` dismissed flag — dismissal is managed entirely by the toast infrastructure.
4546

47+
### Onboarding gating
48+
49+
The toast must NOT show during first-launch onboarding (the user just downloaded the app — telling them to "restart to
50+
update" is confusing) nor while the FDA-revoked re-prompt is on screen (it'd stack two prompts). Two module-level
51+
`$state` flags drive this:
52+
53+
- `onboarded` — seeded from `loadSettings().isOnboarded` on `startUpdateChecker()` start, then flipped by
54+
`notifyOnboardingComplete()` (also persists `isOnboarded: true`).
55+
- `fdaPromptShowing` — flipped by `setFdaPromptShowing(value)` from `routes/(main)/+page.svelte` whenever the
56+
`FullDiskAccessPrompt` opens or closes (any reason — first-run OR `wasRevoked`).
57+
58+
When a gate opens (`notifyOnboardingComplete()` runs, or `setFdaPromptShowing(false)` flips), the helper re-attempts the
59+
toast. If the download completed during onboarding, `updateState.status` stays `'ready'` and the toast shows on unblock
60+
— nothing is lost.
61+
62+
Two test-only hooks (`_resetUpdaterStateForTest`, `_setUpdateStatusForTest`) exist for the unit tests in
63+
`updater.test.ts`. Production code must not call them.
64+
4665
## Key decisions
4766

4867
**Decision**: Platform branching in the frontend (`navigator.platform` check). **Why**: The custom updater Rust module
@@ -75,7 +94,9 @@ interval is acceptable.
7594
- The `check_for_update` command returns `None` when `CI` env var is set — no network calls in CI.
7695
- No retry or backoff on error — the next interval fires a fresh attempt.
7796
- Default interval: 60 minutes. Configurable in settings from 5 minutes to 24 hours.
78-
- No tests exist — the module has hard dependencies on Tauri commands and the network.
97+
- Unit tests in `updater.test.ts` cover the gating logic via the pure `shouldShowUpdateToast` predicate plus the
98+
`notifyOnboardingComplete` and `setFdaPromptShowing` triggers. The download-and-install path is still untested — it
99+
has hard Tauri/network dependencies.
79100
- Cleanup is mandatory: the return value of `startUpdateChecker()` must be called in `onDestroy`.
80101

81102
## Dependencies
@@ -85,4 +106,5 @@ interval is acceptable.
85106
- `@tauri-apps/plugin-process``relaunch()`
86107
- `@tauri-apps/api/app``getVersion()`
87108
- `$lib/settings/settings-store``getSetting`, `onSpecificSettingChange`
109+
- `$lib/settings-store``loadSettings`, `saveSettings` (for the `isOnboarded` flag)
88110
- `$lib/logging/logger``getAppLogger` (logs via unified LogTape bridge)

apps/desktop/src/lib/updates/updater.svelte.ts

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getSetting, onSpecificSettingChange } from '$lib/settings/settings-stor
44
import { getAppLogger } from '$lib/logging/logger'
55
import UpdateToastContent from './UpdateToastContent.svelte'
66
import { addToast } from '$lib/ui/toast'
7+
import { loadSettings, saveSettings } from '$lib/settings-store'
78

89
const log = getAppLogger('updater')
910

@@ -33,6 +34,58 @@ const updateState = $state<UpdateState>({
3334
error: null,
3435
})
3536

37+
// Module-level gating flags. The toast for "update ready, restart now" must NOT show during onboarding
38+
// (the user just downloaded the app — they'd be confused) nor while the FDA-revoked re-prompt is on screen.
39+
let onboarded = $state(false)
40+
let fdaPromptShowing = $state(false)
41+
42+
/**
43+
* Pure predicate for whether the "update ready" toast should show right now.
44+
* Exported for unit testing the truth table.
45+
*/
46+
export function shouldShowUpdateToast(args: {
47+
onboarded: boolean
48+
fdaPromptShowing: boolean
49+
status: UpdateState['status']
50+
}): boolean {
51+
return args.onboarded && !args.fdaPromptShowing && args.status === 'ready'
52+
}
53+
54+
/**
55+
* Show the update-ready toast, but only if gating allows. Called from the download-complete branches
56+
* and from the onboarding/FDA hooks below. When suppressed, we leave `updateState.status === 'ready'`
57+
* so the download stays applied — the toast just doesn't render until the gate opens.
58+
*/
59+
function showUpdateToast(): void {
60+
if (!shouldShowUpdateToast({ onboarded, fdaPromptShowing, status: updateState.status })) {
61+
return
62+
}
63+
addToast(UpdateToastContent, { id: 'update', dismissal: 'persistent' })
64+
}
65+
66+
/**
67+
* Mark onboarding as complete. Persists the flag and, if an update is already ready, shows the toast.
68+
* Called by the parent route once FDA onboarding finishes (either Allow or Deny path) or for users
69+
* who already had FDA granted before this flag existed.
70+
*/
71+
export async function notifyOnboardingComplete(): Promise<void> {
72+
onboarded = true
73+
await saveSettings({ isOnboarded: true })
74+
showUpdateToast()
75+
}
76+
77+
/**
78+
* Track whether the FDA prompt is on screen. While it's up, suppress the update toast so we don't
79+
* pile two modals on top of each other. When it closes and an update is ready, re-attempt the toast.
80+
*/
81+
export function setFdaPromptShowing(value: boolean): void {
82+
const wasShowing = fdaPromptShowing
83+
fdaPromptShowing = value
84+
if (wasShowing && !value) {
85+
showUpdateToast()
86+
}
87+
}
88+
3689
export async function checkForUpdates(): Promise<void> {
3790
if (updateState.status === 'downloading' || updateState.status === 'ready') {
3891
return // Don't interrupt ongoing download or ready state
@@ -57,7 +110,7 @@ export async function checkForUpdates(): Promise<void> {
57110
log.info('v{version} installed, restart to apply', { version: update.version })
58111
updateState.status = 'ready'
59112
updateState.update = update
60-
addToast(UpdateToastContent, { id: 'update', dismissal: 'persistent' })
113+
showUpdateToast()
61114
} else {
62115
log.debug('v{version} is up to date', { version: currentVersion })
63116
updateState.status = 'idle'
@@ -74,7 +127,7 @@ export async function checkForUpdates(): Promise<void> {
74127
log.info('v{version} installed, restart to apply', { version: update.version })
75128
updateState.status = 'ready'
76129
updateState.update = { version: update.version, url: '', signature: '' }
77-
addToast(UpdateToastContent, { id: 'update', dismissal: 'persistent' })
130+
showUpdateToast()
78131
} else {
79132
log.debug('v{version} is up to date', { version: currentVersion })
80133
updateState.status = 'idle'
@@ -90,6 +143,13 @@ export async function checkForUpdates(): Promise<void> {
90143
export function startUpdateChecker(): () => void {
91144
log.debug('Started')
92145

146+
// Seed onboarded flag from persisted settings so returning users aren't gated.
147+
void loadSettings().then((settings) => {
148+
onboarded = settings.isOnboarded
149+
// Edge case: an interval tick fired and reached 'ready' before this resolved.
150+
showUpdateToast()
151+
})
152+
93153
// Check immediately on start
94154
void checkForUpdates()
95155

@@ -114,3 +174,21 @@ export function startUpdateChecker(): () => void {
114174
unsubscribe()
115175
}
116176
}
177+
178+
/**
179+
* Test-only hook: reset module-level gating flags. Production code should never call this.
180+
*/
181+
export function _resetUpdaterStateForTest(): void {
182+
onboarded = false
183+
fdaPromptShowing = false
184+
updateState.status = 'idle'
185+
updateState.update = null
186+
updateState.error = null
187+
}
188+
189+
/**
190+
* Test-only hook: directly set the update state's status. Production code should never call this.
191+
*/
192+
export function _setUpdateStatusForTest(status: UpdateState['status']): void {
193+
updateState.status = status
194+
}

0 commit comments

Comments
 (0)