Keep the key backup in the health check list, raised through forceHealthChecks, and let a plan with no changes apply its own fix

This commit is contained in:
Jon Staab 2026-09-28 10:27:52 -07:00
parent c463e0e2db
commit cb425734a3
7 changed files with 69 additions and 62 deletions

View file

@ -47,8 +47,9 @@ only gate.
component that needs app behavior takes it as a prop, or moves to `src/app/components`, as component that needs app behavior takes it as a prop, or moves to `src/app/components`, as
`IconInput` and its picker buttons did. `IconInput` and its picker buttons did.
- **app → components.** `routes.ts` (`goToChat` opens `ChatEnable`, `goToEvent` opens - **app → components.** `routes.ts` (`goToChat` opens `ChatEnable`, `goToEvent` opens
`NoteDetail`), `share.ts` (`Share`, `ShareEvent`), `deepLinks.ts` (`Search`) and `speech.ts` `NoteDetail`), `share.ts` (`Share`, `ShareEvent`), `deepLinks.ts` (`Search`), `speech.ts`
(`OpenRouterEnable`) import a component so they can open a modal mid-flow. Push adapters and (`OpenRouterEnable`) and `healthChecks.ts` (`KeyDownload`, the fix for the key backup check)
import a component so they can open a modal mid-flow. Push adapters and
deep links reach those flows from outside any component, and the rest have several component deep links reach those flows from outside any component, and the rest have several component
callers, so each stays in one module. `editor/` holds `.svelte` files of its own (suggestion callers, so each stays in one module. `editor/` holds `.svelte` files of its own (suggestion
popovers), which `makeEditor` mounts. popovers), which `makeEditor` mounts.
@ -162,7 +163,7 @@ size from `localStorage` before first paint. Then, in order:
1. `restoreSession()` restores the saved session, if there is one, which builds a user-scoped 1. `restoreSession()` restores the saved session, if there is one, which builds a user-scoped
`App` through `login`. `App` through `login`.
2. It awaits `.ready` on the `synced` stores boot reads (device, wallet, push and notification 2. It awaits `.ready` on the `synced` stores boot reads (device, wallet, push and notification
settings, `shouldUnwrap`, `needsKeyBackup`). settings, `shouldUnwrap`, `forceHealthChecks`).
3. It waits for storage, then `setupDeepLinks()` listens for warm-start links and handles a 3. It waits for storage, then `setupDeepLinks()` listens for warm-start links and handles a
cold-start one. cold-start one.
4. Each long-running subscription goes onto one `unsubscribers` list: `setupHistory`, 4. Each long-running subscription goes onto one `unsubscribers` list: `setupHistory`,

View file

@ -247,7 +247,7 @@ A derivation that every row subscribes to, or that joins large sets, is built by
| repository, tracker, relays, relay stats, handles, zappers, plaintext, wraps | IndexedDB | yes | deleted | | repository, tracker, relays, relay stats, handles, zappers, plaintext, wraps | IndexedDB | yes | deleted |
| settings (`SettingsValues`) | an encrypted app-data event, cached in IndexedDB | yes | local copy deleted | | settings (`SettingsValues`) | an encrypted app-data event, cached in IndexedDB | yes | local copy deleted |
| `session`, `wallet` | `ss` | no | cleared | | `session`, `wallet` | `ss` | no | cleared |
| `theme`, `flTheme`, `checked`, `shouldUnwrap`, `device`, `notificationSettings`, push state, `needsKeyBackup` | `kv` | no | cleared | | `theme`, `flTheme`, `checked`, `shouldUnwrap`, `device`, `notificationSettings`, push state, `forceHealthChecks` | `kv` | no | cleared |
| drafts, dictations | a module `Map`, lost on reload | no | page reloads | | drafts, dictations | a module `Map`, lost on reload | no | page reloads |
### IndexedDB (`src/app/storage.ts`, `src/lib/indexeddb.ts`) ### IndexedDB (`src/app/storage.ts`, `src/lib/indexeddb.ts`)
@ -288,13 +288,13 @@ or `withGetter(synced(...))` when callers need `.get()`, which keeps `.ready`. T
`.ready` resolves once it has arrived: `.ready` resolves once it has arrived:
- `theme`, `flTheme` (`theme.ts`), `checked` (`notifications.ts`), `shouldUnwrap` (`sync.ts`), - `theme`, `flTheme` (`theme.ts`), `checked` (`notifications.ts`), `shouldUnwrap` (`sync.ts`),
`device` (`device.ts`), `needsKeyBackup` (`healthChecks.ts`) in `kv` `device` (`device.ts`), `forceHealthChecks` (`healthChecks.ts`) in `kv`
- `pushState` (key `notificationState`) and `notificationSettings` in `kv`, both in - `pushState` (key `notificationState`) and `notificationSettings` in `kv`, both in
`push/adapters/common.ts` and re-exported from `@app/push` `push/adapters/common.ts` and re-exported from `@app/push`
- `wallet` (`lightning.ts`) in `ss` - `wallet` (`lightning.ts`) in `ss`
The root layout awaits `.ready` for the stores boot code reads (`device`, `shouldUnwrap`, The root layout awaits `.ready` for the stores boot code reads (`device`, `shouldUnwrap`,
`wallet`, `notificationSettings`, `pushState`, `needsKeyBackup`) before `Push.sync()` and `wallet`, `notificationSettings`, `pushState`, `forceHealthChecks`) before `Push.sync()` and
background sync start. A new store that boot reads joins that list. Never change a key, since background sync start. A new store that boot reads joins that list. Never change a key, since
it is where existing installs keep their value. it is where existing installs keep their value.

View file

@ -32,7 +32,8 @@
const back = () => history.back() const back = () => history.back()
const save = async (plan: HealthCheckPlan) => { const save = async (plan: HealthCheckPlan) => {
const error = await publish(await plan.apply()).waitForError() const command = await plan.apply()
const error = command && (await publish(command).waitForError())
if (error) { if (error) {
pushToast({theme: "error", message: `Your relays couldn't be saved: ${errorMessage(error)}`}) pushToast({theme: "error", message: `Your relays couldn't be saved: ${errorMessage(error)}`})

View file

@ -7,52 +7,38 @@
import HomeSection from "@app/components/HomeSection.svelte" import HomeSection from "@app/components/HomeSection.svelte"
import HealthCheckItem from "@app/components/HealthCheckItem.svelte" import HealthCheckItem from "@app/components/HealthCheckItem.svelte"
import HealthCheckReview from "@app/components/HealthCheckReview.svelte" import HealthCheckReview from "@app/components/HealthCheckReview.svelte"
import KeyDownload from "@app/components/KeyDownload.svelte"
import type {HealthCheck} from "@app/healthChecks" import type {HealthCheck} from "@app/healthChecks"
import {healthChecks, needsKeyBackup} from "@app/healthChecks" import {healthChecks} from "@app/healthChecks"
import {session} from "@app/core"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const pending = $healthChecks.pending.$ const pending = $healthChecks.pending.$
const count = $derived($pending.length + ($needsKeyBackup ? 1 : 0)) // Changes are confirmed first, and fixes that change nothing to review run once they're saved.
const review = (checks: HealthCheck[]) => {
const plans = checks.map(check => $healthChecks.plan(check))
const reviewed = plans.filter(plan => plan.changes.length > 0)
const applyRest = () =>
plans.filter(plan => plan.changes.length === 0).forEach(plan => plan.apply())
const finishKeyBackup = () => { if (reviewed.length > 0) {
needsKeyBackup.set(false) pushModal(HealthCheckReview, {plans: reviewed, next: applyRest})
history.back()
}
const backUpKey = () =>
pushModal(KeyDownload, {
secret: ($session!.data as {secret: string}).secret,
next: finishKeyBackup,
submitText: "Done",
})
const review = (checks: HealthCheck[], next?: () => void) => {
const plans = checks
.map(check => $healthChecks.plan(check))
.filter(plan => plan.changes.length > 0)
if (plans.length > 0) {
pushModal(HealthCheckReview, {plans, next})
} else { } else {
next?.() applyRest()
} }
} }
const fix = (healthCheck: HealthCheck) => () => review([healthCheck]) const fix = (healthCheck: HealthCheck) => () => review([healthCheck])
const reviewAll = () => review($pending, $needsKeyBackup ? backUpKey : undefined) const reviewAll = () => review($pending)
</script> </script>
<HomeSection title="Health checks" icon={Stethoscope}> <HomeSection title="Health checks" icon={Stethoscope}>
{#snippet action()} {#snippet action()}
{#if count > 0} {#if $pending.length > 0}
<span class="badge badge-warning">{count} open</span> <span class="badge badge-warning">{$pending.length} open</span>
{/if} {/if}
{/snippet} {/snippet}
{#if count === 0} {#if $pending.length === 0}
<p class="flex items-center gap-2 px-4 pb-4 text-sm opacity-75"> <p class="flex items-center gap-2 px-4 pb-4 text-sm opacity-75">
<Icon icon={CheckCircle} size={4} /> <Icon icon={CheckCircle} size={4} />
Your connection to the network looks healthy. Your connection to the network looks healthy.
@ -65,13 +51,7 @@
description={healthCheck.description} description={healthCheck.description}
onfix={fix(healthCheck)} /> onfix={fix(healthCheck)} />
{/each} {/each}
{#if $needsKeyBackup} {#if $pending.length > 1}
<HealthCheckItem
title="Back Up Your Key"
description="Save a backup of your private key in a secure place."
onfix={backUpKey} />
{/if}
{#if count > 1}
<div class="flex justify-center p-3"> <div class="flex justify-center p-3">
<Button class="button button-primary button-sm" onclick={reviewAll}> <Button class="button button-primary button-sm" onclick={reviewAll}>
<Icon icon={Stars} size={4} /> <Icon icon={Stars} size={4} />

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import {assoc} from "@welshman/lib"
import type {ClientOptions} from "@pomade/core" import type {ClientOptions} from "@pomade/core"
import {makeSecret, RELAYS, MESSAGING_RELAYS, makeEvent} from "@welshman/util" import {makeSecret, RELAYS, MESSAGING_RELAYS, makeEvent} from "@welshman/util"
import {Profile} from "@welshman/domain" import {Profile} from "@welshman/domain"
@ -25,7 +26,7 @@
DEFAULT_MESSAGING_RELAYS, DEFAULT_MESSAGING_RELAYS,
DEFAULT_SPACES, DEFAULT_SPACES,
} from "@app/env" } from "@app/env"
import {needsKeyBackup} from "@app/healthChecks" import {forceHealthChecks} from "@app/healthChecks"
import {loginWithPomade} from "@app/pomade" import {loginWithPomade} from "@app/pomade"
import {pushModal, clearModals} from "@app/modal" import {pushModal, clearModals} from "@app/modal"
import {app, domain, login, roomLists} from "@app/core" import {app, domain, login, roomLists} from "@app/core"
@ -92,7 +93,7 @@
const secret = getKey<string>("signup.secret")! const secret = getKey<string>("signup.secret")!
await login(toSession(nip01, {secret})) await login(toSession(nip01, {secret}))
needsKeyBackup.set(true) forceHealthChecks.update(assoc("backup-key", true))
await completeSignup() await completeSignup()
}, },
}, },

View file

@ -1,12 +1,14 @@
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {sample} from "@welshman/lib" import {assoc, sample} from "@welshman/lib"
import {normalizeRelayUrl} from "@welshman/util" import {normalizeRelayUrl} from "@welshman/util"
import {synced} from "@welshman/store" import {synced} from "@welshman/store"
import {MessagingRelayLists, RelayLists, Relays, SearchRelayLists, projection} from "@welshman/app" import {MessagingRelayLists, RelayLists, Relays, SearchRelayLists, projection} from "@welshman/app"
import type {Command, IApp, Projection} from "@welshman/app" import type {Command, IApp, Projection} from "@welshman/app"
import {usePlugin} from "@app/core" import {session, usePlugin} from "@app/core"
import {DEFAULT_RELAYS} from "@app/env" import {DEFAULT_RELAYS} from "@app/env"
import {kv} from "@app/storage" import {kv} from "@app/storage"
import {pushModal} from "@app/modal"
import KeyDownload from "@app/components/KeyDownload.svelte"
export type HealthCheckContext = { export type HealthCheckContext = {
readRelays: string[] readRelays: string[]
@ -21,10 +23,11 @@ export type HealthCheckChange = {
removed: string[] removed: string[]
} }
// A plan with changes is reviewed and returns the Command to publish; one without does the fix itself.
export type HealthCheckPlan = { export type HealthCheckPlan = {
summary: string summary: string
changes: HealthCheckChange[] changes: HealthCheckChange[]
apply: () => Promise<Command> apply: () => Promise<Command | void>
} }
export type HealthCheck = { export type HealthCheck = {
@ -94,8 +97,12 @@ const tooManyRelaysCheck = (spec: RelayListSpec): HealthCheck => ({
), ),
}) })
// Set when a key is generated in-app, and cleared once the user has saved a backup of it // Raises or clears a check by id regardless of what its own isPending says.
export const needsKeyBackup = synced({key: "needsKeyBackup", storage: kv, defaultValue: false}) export const forceHealthChecks = synced<Record<string, boolean>>({
key: "forceHealthChecks",
storage: kv,
defaultValue: {},
})
export class HealthChecks { export class HealthChecks {
context: Projection<HealthCheckContext> context: Projection<HealthCheckContext>
@ -125,8 +132,10 @@ export class HealthChecks {
) )
this.pending = projection( this.pending = projection(
derived([this.context.$, app.use(Relays).index.$], ([$context]) => derived(
this.checks.filter(check => check.isPending($context)), [this.context.$, forceHealthChecks, app.use(Relays).index.$],
([$context, $forceHealthChecks]) =>
this.checks.filter(check => $forceHealthChecks[check.id] ?? check.isPending($context)),
), ),
) )
} }
@ -184,8 +193,31 @@ export class HealthChecks {
urls => this.app.use(SearchRelayLists).setUrls(urls), urls => this.app.use(SearchRelayLists).setUrls(urls),
), ),
}, },
{
id: "backup-key",
title: "Back Up Your Key",
description: "Save a backup of your private key in a secure place.",
// Only a key generated in-app is ours to back up, so signup raises this through forceHealthChecks.
isPending: () => false,
plan: () => ({
summary: "Opens the key backup dialog, where you choose how to save your key.",
changes: [],
apply: async () => {
pushModal(KeyDownload, {
secret: (session.get()!.data as {secret: string}).secret,
next: this.finishKeyBackup,
submitText: "Done",
})
},
}),
},
] ]
private finishKeyBackup = () => {
forceHealthChecks.update(assoc("backup-key", false))
history.back()
}
plan = (healthCheck: HealthCheck) => healthCheck.plan(this.context.get()) plan = (healthCheck: HealthCheck) => healthCheck.plan(this.context.get())
} }

View file

@ -27,9 +27,9 @@
import {restoreSession} from "@app/session" import {restoreSession} from "@app/session"
import {syncSignerAlerts} from "@app/signer" import {syncSignerAlerts} from "@app/signer"
import {wallet} from "@app/lightning" import {wallet} from "@app/lightning"
import {kv, storage} from "@app/storage" import {storage} from "@app/storage"
import {device} from "@app/device" import {device} from "@app/device"
import {needsKeyBackup} from "@app/healthChecks" import {forceHealthChecks} from "@app/healthChecks"
import {setupShareIntents} from "@app/share" import {setupShareIntents} from "@app/share"
import {shouldUnwrap, syncApplicationData} from "@app/sync" import {shouldUnwrap, syncApplicationData} from "@app/sync"
import * as env from "@app/env" import * as env from "@app/env"
@ -89,17 +89,9 @@
wallet.ready, wallet.ready,
notificationSettings.ready, notificationSettings.ready,
pushState.ready, pushState.ready,
needsKeyBackup.ready, forceHealthChecks.ready,
]) ])
// The key backup reminder used to live in a forceHealthChecks record
const legacyHealthChecks = await kv.get<Record<string, boolean>>("forceHealthChecks")
if (legacyHealthChecks) {
needsKeyBackup.set(Boolean(legacyHealthChecks["backup-key"]))
await kv.remove("forceHealthChecks")
}
// Wait for critical storage data only // Wait for critical storage data only
await storage.get()?.ready await storage.get()?.ready