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:
parent
c463e0e2db
commit
cb425734a3
7 changed files with 69 additions and 62 deletions
|
|
@ -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`,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)}`})
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue