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
`IconInput` and its picker buttons did.
- **app → components.** `routes.ts` (`goToChat` opens `ChatEnable`, `goToEvent` opens
`NoteDetail`), `share.ts` (`Share`, `ShareEvent`), `deepLinks.ts` (`Search`) and `speech.ts`
(`OpenRouterEnable`) import a component so they can open a modal mid-flow. Push adapters and
`NoteDetail`), `share.ts` (`Share`, `ShareEvent`), `deepLinks.ts` (`Search`), `speech.ts`
(`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
callers, so each stays in one module. `editor/` holds `.svelte` files of its own (suggestion
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
`App` through `login`.
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
cold-start one.
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 |
| settings (`SettingsValues`) | an encrypted app-data event, cached in IndexedDB | yes | local copy deleted |
| `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 |
### 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:
- `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
`push/adapters/common.ts` and re-exported from `@app/push`
- `wallet` (`lightning.ts`) in `ss`
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
it is where existing installs keep their value.

View file

@ -32,7 +32,8 @@
const back = () => history.back()
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) {
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 HealthCheckItem from "@app/components/HealthCheckItem.svelte"
import HealthCheckReview from "@app/components/HealthCheckReview.svelte"
import KeyDownload from "@app/components/KeyDownload.svelte"
import type {HealthCheck} from "@app/healthChecks"
import {healthChecks, needsKeyBackup} from "@app/healthChecks"
import {session} from "@app/core"
import {healthChecks} from "@app/healthChecks"
import {pushModal} from "@app/modal"
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 = () => {
needsKeyBackup.set(false)
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})
if (reviewed.length > 0) {
pushModal(HealthCheckReview, {plans: reviewed, next: applyRest})
} else {
next?.()
applyRest()
}
}
const fix = (healthCheck: HealthCheck) => () => review([healthCheck])
const reviewAll = () => review($pending, $needsKeyBackup ? backUpKey : undefined)
const reviewAll = () => review($pending)
</script>
<HomeSection title="Health checks" icon={Stethoscope}>
{#snippet action()}
{#if count > 0}
<span class="badge badge-warning">{count} open</span>
{#if $pending.length > 0}
<span class="badge badge-warning">{$pending.length} open</span>
{/if}
{/snippet}
{#if count === 0}
{#if $pending.length === 0}
<p class="flex items-center gap-2 px-4 pb-4 text-sm opacity-75">
<Icon icon={CheckCircle} size={4} />
Your connection to the network looks healthy.
@ -65,13 +51,7 @@
description={healthCheck.description}
onfix={fix(healthCheck)} />
{/each}
{#if $needsKeyBackup}
<HealthCheckItem
title="Back Up Your Key"
description="Save a backup of your private key in a secure place."
onfix={backUpKey} />
{/if}
{#if count > 1}
{#if $pending.length > 1}
<div class="flex justify-center p-3">
<Button class="button button-primary button-sm" onclick={reviewAll}>
<Icon icon={Stars} size={4} />

View file

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

View file

@ -1,12 +1,14 @@
import {derived} from "svelte/store"
import {sample} from "@welshman/lib"
import {assoc, sample} from "@welshman/lib"
import {normalizeRelayUrl} from "@welshman/util"
import {synced} from "@welshman/store"
import {MessagingRelayLists, RelayLists, Relays, SearchRelayLists, 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 {kv} from "@app/storage"
import {pushModal} from "@app/modal"
import KeyDownload from "@app/components/KeyDownload.svelte"
export type HealthCheckContext = {
readRelays: string[]
@ -21,10 +23,11 @@ export type HealthCheckChange = {
removed: string[]
}
// A plan with changes is reviewed and returns the Command to publish; one without does the fix itself.
export type HealthCheckPlan = {
summary: string
changes: HealthCheckChange[]
apply: () => Promise<Command>
apply: () => Promise<Command | void>
}
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
export const needsKeyBackup = synced({key: "needsKeyBackup", storage: kv, defaultValue: false})
// Raises or clears a check by id regardless of what its own isPending says.
export const forceHealthChecks = synced<Record<string, boolean>>({
key: "forceHealthChecks",
storage: kv,
defaultValue: {},
})
export class HealthChecks {
context: Projection<HealthCheckContext>
@ -125,8 +132,10 @@ export class HealthChecks {
)
this.pending = projection(
derived([this.context.$, app.use(Relays).index.$], ([$context]) =>
this.checks.filter(check => check.isPending($context)),
derived(
[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),
),
},
{
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())
}

View file

@ -27,9 +27,9 @@
import {restoreSession} from "@app/session"
import {syncSignerAlerts} from "@app/signer"
import {wallet} from "@app/lightning"
import {kv, storage} from "@app/storage"
import {storage} from "@app/storage"
import {device} from "@app/device"
import {needsKeyBackup} from "@app/healthChecks"
import {forceHealthChecks} from "@app/healthChecks"
import {setupShareIntents} from "@app/share"
import {shouldUnwrap, syncApplicationData} from "@app/sync"
import * as env from "@app/env"
@ -89,17 +89,9 @@
wallet.ready,
notificationSettings.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
await storage.get()?.ready