diff --git a/e2e/admin-home.spec.ts b/e2e/admin-home.spec.ts
index d2e1de5..5842e5d 100644
--- a/e2e/admin-home.spec.ts
+++ b/e2e/admin-home.spec.ts
@@ -243,17 +243,17 @@ test.describe('mobile', () => {
await advancedToggle(page).click()
await page.getByRole('button', { name: 'Device', exact: true }).click()
- // Network and Security are collapsible, closed by default; open both.
+ // Network and After a power cut are collapsible, closed by default; open both.
await page.getByText('Network', { exact: true }).click()
await expect(page.getByLabel('WiFi SSID')).toBeVisible()
await expect(page.getByLabel('WiFi password')).toBeVisible()
await expect(page.getByRole('button', { name: 'Save to device', exact: true })).toBeVisible()
- await page.getByText('Security', { exact: true }).click()
- await expect(page.getByRole('heading', { name: 'Boot PIN', exact: true })).toBeVisible()
- const unattendedWarning = page.getByText(/A boot PIN is typed over the USB cable/)
+ await page.getByText('After a power cut', { exact: true }).click()
+ await page.locator('summary', { hasText: 'Boot PIN' }).click()
+ const unattendedWarning = page.getByText(/a signer with a boot PIN stays locked/)
await expect(unattendedWarning).toBeVisible()
- await expect(unattendedWarning).toContainText('signing and remote management cannot resume')
+ await expect(unattendedWarning).toContainText('Signing and remote management stop')
await expect(unattendedWarning).toContainText('use a vault key with phone unlock instead')
const overflow = await page.evaluate(
diff --git a/src/app.css b/src/app.css
index 818920b..09744ad 100644
--- a/src/app.css
+++ b/src/app.css
@@ -145,6 +145,7 @@ body {
}
.field-input::placeholder { color: #555; }
.field-input:focus { outline: none; border-color: var(--green-dim); }
+.field-input:focus-visible { outline: 2px solid var(--green); outline-offset: 1px; }
.field-input:disabled { opacity: 0.4; }
select.field-input { cursor: pointer; }
.field-hint { font-size: 0.8rem; color: var(--text-dim); line-height: 1.5; }
diff --git a/src/components/DevicePanel.svelte b/src/components/DevicePanel.svelte
index 256ae51..8d12be9 100644
--- a/src/components/DevicePanel.svelte
+++ b/src/components/DevicePanel.svelte
@@ -1,14 +1,17 @@
- {#if pairingBackup.needsBackup}
-
-
Pairing backup required
-
- {#if pairingBackup.lastExportAt}
- App pairings changed after this browser's recorded encrypted backup ({backupTime(pairingBackup.lastExportAt)}).
- {:else}
- App pairings changed and this browser has not recorded a completed encrypted pairing backup.
- {/if}
- A reset or reflash would make the affected apps pair again. {#if overUsb}Export a fresh backup below and keep its passphrase separately.{:else}Connect this signer by USB to export a fresh backup; the signer will ask for a physical button confirmation.{/if}
-
-
- {/if}
+
-
-
-
Your signer
-
- {#each summaryRows as row (row.id)}
-
-
+ {#if attention.length}
+
+ {#each attention as row, i (row.id)}
+
+
- {row.label}
-
- {row.text}
+ {row.label}
+
+
{row.text}
{#if row.actionLabel}
-
+
{/if}
{/each}
-
-
-
-
-
- Firmware{updateInfo?.upgrade ? 'Update available' : 'Up to date'}
-
-
+
+ {/if}
-
- {#if overUsb || device.mode === 'relay'}
-
- After a power cut{powerCutStateWord}
-
How this signer recovers its keys after a restart. Pick the option that suits
- where it lives.
Phone unlock needs encryption on. Turn on Encrypt at rest
- (Security{overUsb ? ', below' : ', over the USB cable'}), then add a phone
- below over the cable. Add two if you can, so one lost abroad does not leave
- the signer locked.
- {:else}
-
Add a phone below{overUsb ? '' : ', with the signer on the USB cable'}. Encryption is already on.
The boot PIN, bridge secret and encryption at rest are changed over USB.
- Plug the signer into this computer and connect by cable.
+
+ {#if overUsb}
+
+
+
Backup
+ {backupStateWord}
+
+
+
{:else}
-
Boot PIN
-
Locks the device at boot. It must be unlocked before it signs anything.
- The device asks for its button to confirm.
-
A boot PIN is typed over the USB cable, so after any reboot or power cut
- a signer in another location stays locked until someone reaches it: signing and remote
- management cannot resume, and the signer refuses remote network activation in this mode.
- For an unattended signer, use a vault key with phone unlock instead (After a power cut,
- above).
Encrypts the signer's stored keys with a vault key held by this browser, never
- by the device: a stolen device yields only ciphertext.
- {#if vaultLocked}
-
This signer is locked. Unlock it from the banner on Home before
- changing encryption.
- {:else if !vaultDeviceKey}
-
Add an identity to the signer first.
- {:else if vaultEscrowKey}
-
-
Your vault key: store it somewhere safe off this browser
- (password manager, printed) before the signer is sealed. Without it,
- a sealed signer cannot be unlocked.
Turn the picture round if the signer sits the other way up, for the
+ other hand or a case. The button labels on its cards move with it. On the signer itself: hold its
+ button on the DEVICE page.
-
Turn the picture round if the signer sits the other way up, for the
- other hand or a case. The button labels on its cards move with it. On the signer itself: hold its
- button on the DEVICE page.
The signer's last restart was not planned.{#if health.crashed_during} It crashed while
+ handling {health.crashed_during}.{/if} If this repeats, note the pattern; the request log
+ below restarts empty each boot.
{/if}
- {#if typeof maxSignBytes === 'number'}
-
Max signed message
- {formatBytes(maxSignBytes)}{#if typeof maxSignBytesObject === 'number' && maxSignBytesObject > maxSignBytes}
- · {formatBytes(maxSignBytesObject)} for apps that ask for the compact reply{/if}
-
+ {#if fragmented}
+
+ Why is the memory fragmented?
+
The signer's largest free block is small relative to total free.
+ This can happen after a burst of decryptions; it clears on the next restart. Newer firmware
+ frees the TLS buffers between messages to avoid it.
+
{/if}
- {#if storage}
-
-
Identity & app storage
-
-
- {storage.label}
-
-
+ {#if trimmed}
+
The signer trimmed this status to its vital fields because its memory
+ was too fragmented to send the full report. The request log paused this poll instead of the signer
+ crashing, and resumes once the memory recovers.
{/if}
-
- {#if storage?.state === 'warn'}
-
The signer's storage is filling up. Identities, personas, app pairings
- and settings share it. Removing an unused persona or app pairing frees space.
- {/if}
- {#if storage?.state === 'full'}
-
The signer's storage is nearly full. It will refuse new personas before
- app pairings stop working, so remove an unused persona or app pairing now.
- {/if}
- {#if lastReset?.crash}
-
The signer's last restart was not planned.{#if health.crashed_during} It crashed while
- handling {health.crashed_during}.{/if} If this repeats, note the pattern; the request log
- below restarts empty each boot.
- {/if}
- {#if fragmented}
-
- Why is the memory fragmented?
-
The signer's largest free block is small relative to total free.
- This can happen after a burst of decryptions; it clears on the next restart. Newer firmware
- frees the TLS buffers between messages to avoid it.
-
- {/if}
- {#if trimmed}
-
The signer trimmed this status to its vital fields because its memory
- was too fragmented to send the full report. The request log paused this poll instead of the signer
- crashing, and resumes once the memory recovers.
- {/if}
- {#if recoveryReason}
-
- Why did the signer restart itself?
-
Its relay service was unusable for several minutes
- ({recoveryReason}), usually memory too fragmented to place TLS or publish
- buffers. It now recovers on its own instead of staying unreachable until a power-cycle; if
- this repeats often, the memory readings above tell the story.
+ {#if recoveryReason}
+
+ Why did the signer restart itself?
+
Its relay service was unusable for several minutes
+ ({recoveryReason}), usually memory too fragmented to place TLS or publish
+ buffers. It now recovers on its own instead of staying unreachable until a power-cycle; if
+ this repeats often, the memory readings above tell the story.
+
+
diff --git a/src/components/UnlockPhones.svelte b/src/components/UnlockPhones.svelte
index 53892d0..f80d876 100644
--- a/src/components/UnlockPhones.svelte
+++ b/src/components/UnlockPhones.svelte
@@ -3,7 +3,7 @@
// signer after a restart with a tap. Add one by scanning the code Cambium
// shows, list them, revoke one. No secret passes through Sapwood: the board
// seals each phone's unlock secret to a key only that phone holds.
- import { onDestroy, untrack } from 'svelte'
+ import { onDestroy, tick, untrack } from 'svelte'
import { SimplePool } from 'nostr-tools/pool'
import { encodeQR } from '@paulmillr/qr'
import {
@@ -22,6 +22,8 @@
} from '../lib/enrol-invite.js'
import ConfirmButton from './ConfirmButton.svelte'
import QrScanner from './QrScanner.svelte'
+ import WordPairs from './WordPairs.svelte'
+ import TogglePair from './TogglePair.svelte'
/** Fallback relays for a fresh invite: the ones Sapwood already uses for
* this signer over the relay, its USB-reported WiFi relays, or failing
@@ -164,6 +166,9 @@
let working = $state(null)
let result = $state(null)
let addError = $state(null)
+ /** Which of the two "invite-aborted" stories to tell: someone else answered,
+ * or nobody did. */
+ let abortReason = $state<'two-phones' | 'no-relays' | null>(null)
/** Set once the board has been asked: its enrolment key is then spent. */
let codeSpent = $state(false)
/** A record the board may have kept with nobody holding its secret: the
@@ -212,7 +217,8 @@
code = inviteState.code
step = 'confirm'
} else if (inviteState.status === 'aborted') {
- addError = 'Two phones answered this code. Someone else may have seen it. Nothing was added; start again.'
+ addError = 'Someone else may have seen the code. Nothing was added. Cancel on your phone, then make a new code.'
+ abortReason = 'two-phones'
stopInvite()
step = 'invite-aborted'
}
@@ -225,6 +231,7 @@
code = null
result = null
addError = null
+ abortReason = null
codeSpent = false
scanSpent = false
orphanId = null
@@ -243,6 +250,7 @@
zeroInviteSecret(made)
stopInvite()
addError = 'None of the relays answered, so the phone could not reply. Check this computer\'s connection and try again.'
+ abortReason = 'no-relays'
step = 'invite-aborted'
return
}
@@ -274,6 +282,7 @@
code = null
result = null
addError = null
+ abortReason = null
codeSpent = false
scanSpent = false
orphanId = null
@@ -353,8 +362,7 @@
enrol: async (enrolPubkey) => {
codeSpent = true
if (code) markCodeSpent(code)
- return enrolUnlockPhone(enrolPubkey, label,
- `Check the signer: it shows ADD PHONE and five words, two at a time over about 12 seconds before it will accept a hold. Compare the five words with your phone, then hold its button.`)
+ return enrolUnlockPhone(enrolPubkey, label, 'Signer: compare the five words, then hold its button.')
},
onBoard: () => {
working = isRelay
@@ -378,13 +386,55 @@
if (codeSpent) void load(true)
}
}
+
+ // --- 7.2 Add a phone: the constant enrolment card ---
+ // The heading and live region exist from the first render of the card (any
+ // step but 'idle'). Every step change sets both and moves focus to the
+ // heading, so the compare-and-confirm bench flow announces itself to
+ // assistive tech without the owner hunting for what changed.
+ const confirmWords = $derived(code ? requestWords(code.enrolPubkey) : [])
+
+ const phase = $derived(
+ step === 'invite' ? (invite ? 'invite-ready' : 'invite-opening') : step,
+ )
+
+ let cardHeadingEl = $state(null)
+ let cardHeadingText = $state('')
+ let liveText = $state('')
+
+ $effect(() => {
+ const p = phase
+ const label = code?.label ?? 'The phone'
+ if (p === 'invite-opening') { cardHeadingText = 'Add a phone'; liveText = 'Opening relays.' }
+ else if (p === 'invite-ready') { cardHeadingText = 'Scan this with Cambium'; liveText = 'Code ready. It expires in 10 minutes.' }
+ else if (p === 'invite-expired') { cardHeadingText = 'Code expired'; liveText = 'Code expired.' }
+ else if (p === 'invite-aborted') {
+ cardHeadingText = abortReason === 'no-relays' ? 'Could not reach the relays' : 'Two phones answered'
+ liveText = abortReason === 'no-relays' ? 'Could not reach the relays.' : 'Stopped: two phones answered.'
+ } else if (p === 'scan' || p === 'paste') { cardHeadingText = 'Add a phone'; liveText = '' }
+ else if (p === 'confirm') { cardHeadingText = `${label} wants to unlock this signer`; liveText = `${label} answered. Compare the five words.` }
+ else if (p === 'working') { cardHeadingText = 'Check the signer'; liveText = 'Sent to the signer. Hold its button when the words match.' }
+ else if (p === 'done') { cardHeadingText = `${label} added`; liveText = result ? `${label} added. Check code ${result.checkCode}.` : '' }
+ else if (p === 'orphan') { cardHeadingText = 'A stray record may be left'; liveText = '' }
+ else if (p === 'failed') { cardHeadingText = 'Not added'; liveText = '' }
+ if (p !== 'idle') void tick().then(() => cardHeadingEl?.focus())
+ })
+
+ // Announce the countdown only at its two milestones and at expiry (the
+ // heading effect above covers expiry): a live region that spoke every
+ // second would be unusable.
+ $effect(() => {
+ const s = inviteSecondsLeft
+ if (phase !== 'invite-ready') return
+ if (s === 300) liveText = 'Five minutes left to scan the code.'
+ else if (s === 60) liveText = 'One minute left to scan the code.'
+ })
Phones that can unlock
-
A phone running Cambium can unlock this signer after a power cut: it shows
- a notification, and one tap plus the phone's screen lock sends the unlock. Nothing on the
- wire names the phone. A lost phone is revoked here without changing anything else.
+
A phone running Cambium can unlock this signer after a power cut. Nothing on the
+ wire names the phone. Revoking a lost phone changes nothing else.
{#if support === 'checking'}
Asking the signer…
@@ -421,16 +471,17 @@
{/each}
-
{list.phones.length} of {list.max}.{#if list.phones.length === 1}
+
{list.phones.length} of {list.max} phones.{#if list.phones.length === 1}
Add a second device (a spare, or a family member's phone) so losing this one abroad does not
- leave the signer locked.{/if}
+ leave the signer locked.{:else if list.phones.length >= list.max}
+ This signer holds {list.max} phones, its limit. Revoke one to add another.{/if}
{/if}
{#if revokeStatus}
{revokeStatus}
{/if}
{#if step === 'idle'}
{#if canAdd}
- = list.max}
+ = list.max}
onclick={startInvite}>Add a phone
On the phone, open Cambium and tap “Set up phone unlock” under this
+ signer. It shows a code; nothing in it is secret. Its “Copy code” button gives the text
+ to paste here.
Check Cambium shows these five words, in this order.
+
+
Next, the signer shows the same words, two at a time. Hold its button
+ only if all three screens match.
+
+ Send to the signer
+ { stopInvite(); step = 'idle' }}>Cancel
+
+
+ Why compare with the phone, not only this page?
+
Whoever relayed the request could have swapped the words shown here.
+ The phone and the signer cannot both be swapped, so their match is the check that
+ counts. The check code afterwards only confirms the phone received the hand-off.
+
+
Phone
{code.label}
+
Relays
+ {#each code.relays as relay}
{relay}
{/each}
+
+
+
+ {:else if step === 'working'}
+
Wait for ADD PHONE. It shows the words two at a time, and its button only
+ works once all five have shown. Hold it if they match these and the phone.
+
+ {#if working}
{working}
{/if}
+ {:else if step === 'done' && result}
+
Check code
+
{result.checkCode}
+
Cambium and the signer show the same code. If all three match, confirm
+ on the phone with its screen lock.
+
+ { step = 'idle' }}>Done
+ {
+ const r = result
+ const label = code?.label ?? 'the phone'
+ if (r) void revoke(r.id, label)
+ step = 'idle'
+ }}
+ />
+
{addError ?? `The signer never answered in time, but it may have kept a record nobody holds (record ${orphanId}): its secret never reached this browser, so it cannot reach the phone either.`}
+
Revoke it, then start again with a new code from the phone.
+ {#if codeSpent}
+ startAdd('scan')}>Scan a new code
+ {:else}
+ Try again
+ {/if}
+ { step = 'idle' }}>Close
+
+ {/if}
+
+ {/if}
+
Sapwood's own unlock message
When it is locked, the signer also posts a message tagged with your operator
@@ -454,121 +633,18 @@
can link the signer's restarts. Phone messages carry no such tag. If your phones are enough,
turn it off; Sapwood can still unlock over USB. It stays on while no phone is set up, or a
locked signer could only be unlocked over the cable.
On the phone, open Cambium and tap “Set up phone unlock” under this
- signer. It shows a code; nothing in it is secret. Its “Copy code” button gives the text
- to paste here.
The signer shows ADD PHONE and five words, two at a time over about
- 12 seconds before it will accept a hold.
-
-
Check your phone shows these same five words
-
{requestWords(code.enrolPubkey).join(' ')}
-
Compare them with your phone's own screen, and with the signer's
- card, before holding the button: that is the check that matters, since whoever
- relayed the request could have swapped the words shown here. A check code appears
- afterwards too, but it only confirms the phone got the hand-off; it does not defend
- against a swapped phone.
The signer added {code?.label} as record {result.id}.
-
This check code confirms the phone got it: the five words you compared
- before holding the button are what defended against a swapped phone. The phone should show
- the same six characters:
-
{result.checkCode}
-
If they match, confirm on the phone with its screen lock. If they do
- not, someone else answered the phone first: revoke record {result.id} now and start
- again.
{addError ?? `The signer never answered in time, but it may have kept a record nobody holds (record ${orphanId}): its secret never reached this browser, so it cannot reach the phone either.`}
-
Revoke it, then start again with a new code from the phone.
- {#if codeSpent}
- startAdd('scan')}>Scan a new code
- {:else}
- Try again
- {/if}
- { step = 'idle' }}>Close
-
- {/if}
-
- {/if}
diff --git a/src/components/UnlockPhones.test.ts b/src/components/UnlockPhones.test.ts
index f86bc3b..6918ad7 100644
--- a/src/components/UnlockPhones.test.ts
+++ b/src/components/UnlockPhones.test.ts
@@ -174,10 +174,10 @@ describe('UnlockPhones', () => {
await fireEvent.click(screen.getByRole('button', { name: 'Paste a code' }))
await fireEvent.input(screen.getByRole('textbox'), { target: { value: CODE } })
await fireEvent.click(screen.getByRole('button', { name: 'Use this code' }))
- await fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
+ await fireEvent.click(screen.getByRole('button', { name: 'Send to the signer' }))
expect(await screen.findByText('9B6 164')).toBeTruthy()
expect(api.enrolUnlockPhone).toHaveBeenCalledOnce()
- expect(api.enrolUnlockPhone).toHaveBeenCalledWith(P, 'Pixel 8', expect.stringMatching(/ADD PHONE/))
+ expect(api.enrolUnlockPhone).toHaveBeenCalledWith(P, 'Pixel 8', expect.stringMatching(/compare the five words/i))
expect(relays.ensureRelay).toHaveBeenCalledWith('wss://relay.example', expect.anything())
const [urls, event] = relays.publish.mock.calls[0] as unknown as [string[], { kind: number; tags: string[][] }]
expect(urls).toEqual(['wss://relay.example'])
@@ -185,7 +185,7 @@ describe('UnlockPhones', () => {
expect(event.tags).toEqual([['h', R]])
// Finished, then the same code again: refused here, not sent twice.
- await fireEvent.click(screen.getByRole('button', { name: 'Finished' }))
+ await fireEvent.click(screen.getByRole('button', { name: 'Done' }))
await fireEvent.click(await screen.findByText('Phone shows a code instead? Paste it'))
await fireEvent.click(screen.getByRole('button', { name: 'Paste a code' }))
await fireEvent.input(screen.getByRole('textbox'), { target: { value: CODE } })
@@ -209,7 +209,7 @@ describe('UnlockPhones', () => {
await fireEvent.click(screen.getByRole('button', { name: 'Paste a code' }))
await fireEvent.input(screen.getByRole('textbox'), { target: { value: CODE.replace(P, 'c3'.repeat(32)) } })
await fireEvent.click(screen.getByRole('button', { name: 'Use this code' }))
- await fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
+ await fireEvent.click(screen.getByRole('button', { name: 'Send to the signer' }))
expect(await screen.findByText(/Each code works once/)).toBeTruthy()
expect(screen.getByRole('button', { name: 'Scan a new code' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Try again' })).toBeNull()
@@ -254,12 +254,13 @@ describe('UnlockPhones', () => {
await fireEvent.input(screen.getByRole('textbox'), { target: { value: codeWith(p) } })
await fireEvent.click(screen.getByRole('button', { name: 'Use this code' }))
// Sapwood's own copy of the request-code words: for reference only.
- expect(screen.getByText('release jar chimney acoustic depart')).toBeTruthy()
- expect(screen.getByText(/Compare them with your phone's own screen/)).toBeTruthy()
- await fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
+ expect(screen.getByText('release')).toBeTruthy()
+ expect(screen.getByText('depart')).toBeTruthy()
+ expect(screen.getByText('Check Cambium shows these five words, in this order.')).toBeTruthy()
+ await fireEvent.click(screen.getByRole('button', { name: 'Send to the signer' }))
expect(await screen.findByText('9B6 164')).toBeTruthy()
expect(api.enrolUnlockPhone).toHaveBeenCalledOnce()
- expect(api.enrolUnlockPhone).toHaveBeenCalledWith(p, 'Pixel 8', expect.stringMatching(/ADD PHONE/))
+ expect(api.enrolUnlockPhone).toHaveBeenCalledWith(p, 'Pixel 8', expect.stringMatching(/compare the five words/i))
// Sent exactly once: the hand-off is only ever published the once.
const [urls, event] = relays.publish.mock.calls[0] as unknown as [string[], { kind: number; tags: string[][] }]
expect(urls).toEqual(['wss://relay.example'])
@@ -282,7 +283,7 @@ describe('UnlockPhones', () => {
await fireEvent.click(screen.getByRole('button', { name: 'Paste a code' }))
await fireEvent.input(screen.getByRole('textbox'), { target: { value: codeWith(p) } })
await fireEvent.click(screen.getByRole('button', { name: 'Use this code' }))
- await fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
+ await fireEvent.click(screen.getByRole('button', { name: 'Send to the signer' }))
expect(await screen.findByRole('button', { name: 'Revoke record 99' })).toBeTruthy()
expect(screen.getByText(/nobody holds/)).toBeTruthy()
await fireEvent.click(screen.getByRole('button', { name: 'Revoke record 99' }))
@@ -300,7 +301,7 @@ describe('UnlockPhones', () => {
await fireEvent.click(screen.getByRole('button', { name: 'Paste a code' }))
await fireEvent.input(screen.getByRole('textbox'), { target: { value: codeWith(p) } })
await fireEvent.click(screen.getByRole('button', { name: 'Use this code' }))
- await fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
+ await fireEvent.click(screen.getByRole('button', { name: 'Send to the signer' }))
expect(await screen.findByText(/never answered in time/)).toBeTruthy()
expect(screen.queryByText(/nobody holds/)).toBeNull()
})
@@ -320,11 +321,11 @@ describe('UnlockPhones', () => {
const p = '11'.repeat(32)
opts.onevent(inviteReply(codeWith(p)))
- expect(await screen.findByText('Check your phone shows these same five words')).toBeTruthy()
- expect(screen.getByText('Pixel 8')).toBeTruthy()
- await fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
+ expect(await screen.findByText('Pixel 8 wants to unlock this signer')).toBeTruthy()
+ expect(screen.getAllByText('Pixel 8').length).toBeGreaterThan(0)
+ await fireEvent.click(screen.getByRole('button', { name: 'Send to the signer' }))
expect(await screen.findByText('9B6 164')).toBeTruthy()
- expect(api.enrolUnlockPhone).toHaveBeenCalledWith(p, 'Pixel 8', expect.stringMatching(/ADD PHONE/))
+ expect(api.enrolUnlockPhone).toHaveBeenCalledWith(p, 'Pixel 8', expect.stringMatching(/compare the five words/i))
})
it('ignores a repeat of the identical reply', async () => {
@@ -335,9 +336,9 @@ describe('UnlockPhones', () => {
const p = '22'.repeat(32)
opts.onevent(inviteReply(codeWith(p)))
- await screen.findByText('Check your phone shows these same five words')
+ await screen.findByText('Pixel 8 wants to unlock this signer')
opts.onevent(inviteReply(codeWith(p)))
- expect(screen.getByText('Check your phone shows these same five words')).toBeTruthy()
+ expect(screen.getByText('Pixel 8 wants to unlock this signer')).toBeTruthy()
expect(screen.queryByText(/Two phones answered/)).toBeNull()
})
@@ -348,10 +349,10 @@ describe('UnlockPhones', () => {
const opts = relays.subscribe.mock.calls[0][2]
opts.onevent(inviteReply(codeWith('33'.repeat(32))))
- await screen.findByText('Check your phone shows these same five words')
+ await screen.findByText('Pixel 8 wants to unlock this signer')
opts.onevent(inviteReply(codeWith('44'.repeat(32))))
- expect(await screen.findByText(/Two phones answered this code/)).toBeTruthy()
+ expect(await screen.findByText('Two phones answered')).toBeTruthy()
expect(screen.getByRole('button', { name: 'New code' })).toBeTruthy()
expect(api.enrolUnlockPhone).not.toHaveBeenCalled()
})
@@ -374,7 +375,7 @@ describe('UnlockPhones', () => {
await screen.findByText('Scan this with Cambium')
const opts = relays.subscribe.mock.calls[0][2]
opts.onevent(inviteReply(codeWith('55'.repeat(32)), 'ff'.repeat(16)))
- expect(screen.queryByText('Check your phone shows these same five words')).toBeNull()
+ expect(screen.queryByText('Pixel 8 wants to unlock this signer')).toBeNull()
})
})
})
diff --git a/src/components/VaultUnlock.svelte b/src/components/VaultUnlock.svelte
index 86ff4f3..218db49 100644
--- a/src/components/VaultUnlock.svelte
+++ b/src/components/VaultUnlock.svelte
@@ -110,7 +110,7 @@
}
const seconds = Math.round(vaultUnlockTimeoutMs(lockedSlots) / 1000)
const count = lockedSlots === null ? 'each identity' : `${lockedSlots} ${lockedSlots === 1 ? 'identity' : 'identities'}`
- device.awaitingButton = `Unlocking — the signer unseals ${count} with a slow key derivation on purpose, about 25 seconds each. This waits up to ${seconds} seconds; leave the cable in.`
+ device.awaitingButton = `Unlocking: the signer unseals ${count} with a slow key derivation on purpose, about 25 seconds each. This waits up to ${seconds} seconds; leave the cable in.`
try {
await serialVaultUnlock(serialTransport, keyHex, lockedSlots)
} finally {
@@ -134,7 +134,7 @@
async function unlockRelay(keyHex: string | undefined, remember: boolean) {
busy = true
status = null
- device.awaitingButton = 'Sending the vault key — the signer unseals each identity with a slow key derivation on purpose, about 25 seconds each, then rejoins its relays.'
+ device.awaitingButton = 'Sending the vault key: the signer unseals each identity with a slow key derivation on purpose, about 25 seconds each, then rejoins its relays.'
try {
await sendVaultKeyOverRelay(keyHex)
if (remember && keyHex && deviceKey) {
@@ -158,7 +158,7 @@
Signer is locked
Its keys are encrypted at rest, so it signs nothing until it is unlocked.
- It shows “Locked — Await unlock”.
+ It shows “Locked: Await unlock”.
{#if storedKey}
@@ -196,8 +196,8 @@
Signer is locked
- Your signer is locked and asking for its vault key — unlock? Only do this if you know
- it just rebooted.
+ Your signer is locked and asking for its vault key. Unlock it only if you know it just
+ restarted.
{#if storedKey}
@@ -237,7 +237,7 @@
{:else if device.vaultReconnect}
- Vault key sent. Waiting for the signer to unseal and rejoin its relays — about 25 seconds an
+ Vault key sent. Waiting for the signer to unseal and rejoin its relays, about 25 seconds an
identity, then ~20 seconds to reconnect. Dialling it again every 10 seconds
(attempt {device.vaultReconnect.attempt}, {reconnectElapsed}s so far).
diff --git a/src/components/VaultUnlock.test.ts b/src/components/VaultUnlock.test.ts
index d0ceecd..9719f00 100644
--- a/src/components/VaultUnlock.test.ts
+++ b/src/components/VaultUnlock.test.ts
@@ -144,7 +144,7 @@ describe('VaultUnlock banner', () => {
state.vaultUnlockRequest = { unlockPub: UNLOCK_PUB, lastSeen: Date.now() }
storeVaultKey(HEX, VAULT_KEY)
render(VaultUnlock)
- expect(screen.getByText(/Only do this if you know it just rebooted/)).toBeTruthy()
+ expect(screen.getByText(/Unlock it only if you know it just restarted/)).toBeTruthy()
await fireEvent.click(screen.getByText('Unlock'))
expect(vi.mocked(sendVaultKeyOverRelay)).toHaveBeenCalledWith(undefined)
})
diff --git a/src/components/WordPairs.svelte b/src/components/WordPairs.svelte
new file mode 100644
index 0000000..39134fc
--- /dev/null
+++ b/src/components/WordPairs.svelte
@@ -0,0 +1,34 @@
+
+
+
+ {#each words as word, i (i)}
+
{i + 1}{word}
+ {/each}
+
+
+
diff --git a/src/lib/device-summary.test.ts b/src/lib/device-summary.test.ts
index 906d629..d1956f3 100644
--- a/src/lib/device-summary.test.ts
+++ b/src/lib/device-summary.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { deviceSummaryRows, type DeviceSummaryInput } from './device-summary.js'
+import { deviceSummaryRows, attentionRows, type DeviceSummaryInput } from './device-summary.js'
function base(overrides: Partial = {}): DeviceSummaryInput {
return {
@@ -10,38 +10,46 @@ function base(overrides: Partial = {}): DeviceSummaryInput {
phoneCount: null,
atRestUnrecognised: false,
overUsb: true,
+ locked: false,
needsBackup: false,
lastExportAt: Date.parse('2026-09-01T00:00:00Z'),
crash: false,
fragmented: false,
recovering: false,
storageState: 'ok',
- modeLabel: 'USB cable',
+ storagePct: null,
+ statusLost: false,
...overrides,
}
}
describe('deviceSummaryRows', () => {
- it('reports firmware up to date and no storage row when everything is ok', () => {
+ it('reports firmware up to date, in fixed section order', () => {
const rows = deviceSummaryRows(base())
+ expect(rows.map((r) => r.id)).toEqual(['power-cut', 'firmware', 'backup'])
expect(rows.find((r) => r.id === 'firmware')).toEqual({
- id: 'firmware', label: 'Firmware', dot: 'ok', text: 'Up to date (v0.18.0)', actionLabel: null,
+ id: 'firmware', label: 'Firmware', dot: 'ok', text: 'Up to date (v0.18.0)',
})
- expect(rows.find((r) => r.id === 'storage')).toBeUndefined()
})
- it('flags an available update with an Update action', () => {
+ it('gives an unknown version a grey dot, never green', () => {
+ const rows = deviceSummaryRows(base({ runningVersion: null }))
+ const row = rows.find((r) => r.id === 'firmware')!
+ expect(row.dot).toBe('unknown')
+ expect(row.text).toBe('Version unknown')
+ })
+
+ it('flags an available update on the firmware row', () => {
const rows = deviceSummaryRows(base({ updateAvailable: true, updateVersion: '0.19.0' }))
expect(rows.find((r) => r.id === 'firmware')).toEqual({
- id: 'firmware', label: 'Firmware', dot: 'attention', text: 'Update available: v0.19.0', actionLabel: 'Update',
+ id: 'firmware', label: 'Firmware', dot: 'attention', text: 'Update available: v0.19.0',
})
})
it('describes phone unlock with a phone count', () => {
const rows = deviceSummaryRows(base({ unlockMode: 'phone', phoneCount: 2 }))
expect(rows.find((r) => r.id === 'power-cut')).toEqual({
- id: 'power-cut', label: 'After a power cut', dot: 'ok',
- text: 'Unlocks from your phone (2 phones)', actionLabel: 'Change',
+ id: 'power-cut', label: 'After a power cut', dot: 'ok', text: 'Unlocks from your phone (2 phones)',
})
})
@@ -50,38 +58,36 @@ describe('deviceSummaryRows', () => {
expect(rows.find((r) => r.id === 'power-cut')?.text).toBe('Unlocks from your phone (1 phone)')
})
- it('marks no encryption as a problem', () => {
+ it('marks no encryption as a problem, row text just "Not encrypted"', () => {
const rows = deviceSummaryRows(base({ unlockMode: 'none' }))
expect(rows.find((r) => r.id === 'power-cut')).toEqual({
- id: 'power-cut', label: 'After a power cut', dot: 'problem',
- text: 'Not encrypted: anyone holding it can read the keys',
- actionLabel: 'Change',
+ id: 'power-cut', label: 'After a power cut', dot: 'problem', text: 'Not encrypted',
})
})
- it('names the transport for an unknown mode over WiFi, with a neutral dot', () => {
- const rows = deviceSummaryRows(base({ unlockMode: null, overUsb: false, modeLabel: 'WiFi' }))
+ it('gives a locked signer a problem row reading "Locked"', () => {
+ const rows = deviceSummaryRows(base({ locked: true, unlockMode: null }))
expect(rows.find((r) => r.id === 'power-cut')).toEqual({
- id: 'power-cut', label: 'After a power cut', dot: 'unknown',
- text: 'Unknown over WiFi. Connect by USB to check', actionLabel: 'Change',
+ id: 'power-cut', label: 'After a power cut', dot: 'problem', text: 'Locked',
})
})
- it('gives a different unknown reason over USB, still neutral', () => {
- const rows = deviceSummaryRows(base({ unlockMode: null, overUsb: true }))
- const row = rows.find((r) => r.id === 'power-cut')!
- expect(row.dot).toBe('unknown')
- expect(row.text).toBe('Unknown: no vault key held here')
+ it('names an unknown mode over WiFi and over USB, both neutral', () => {
+ const wifi = deviceSummaryRows(base({ unlockMode: null, overUsb: false }))
+ expect(wifi.find((r) => r.id === 'power-cut')).toEqual({
+ id: 'power-cut', label: 'After a power cut', dot: 'unknown', text: 'Unknown: connect by USB to check',
+ })
+ const usb = deviceSummaryRows(base({ unlockMode: null, overUsb: true }))
+ expect(usb.find((r) => r.id === 'power-cut')).toEqual({
+ id: 'power-cut', label: 'After a power cut', dot: 'unknown', text: 'Unknown: no vault key held here',
+ })
})
- it('names an unrecognised firmware report distinctly, over USB or WiFi', () => {
- const usb = deviceSummaryRows(base({ unlockMode: null, overUsb: true, atRestUnrecognised: true }))
- const wifi = deviceSummaryRows(base({ unlockMode: null, overUsb: false, modeLabel: 'WiFi', atRestUnrecognised: true }))
- for (const rows of [usb, wifi]) {
- const row = rows.find((r) => r.id === 'power-cut')!
- expect(row.dot).toBe('unknown')
- expect(row.text).toBe("Your signer reported a setting this version of Sapwood doesn't know. Update Sapwood.")
- }
+ it('names an unrecognised firmware report distinctly', () => {
+ const rows = deviceSummaryRows(base({ unlockMode: null, atRestUnrecognised: true }))
+ const row = rows.find((r) => r.id === 'power-cut')!
+ expect(row.dot).toBe('unknown')
+ expect(row.text).toBe('Update Sapwood to read this setting')
})
it('reads a fresh backup with its date, green', () => {
@@ -89,90 +95,109 @@ describe('deviceSummaryRows', () => {
const row = rows.find((r) => r.id === 'backup')!
expect(row.dot).toBe('ok')
expect(row.text).toContain('Backed up')
- expect(row.actionLabel).toBe('Back up')
})
- it('asks for a backup when one is needed', () => {
- const rows = deviceSummaryRows(base({ needsBackup: true }))
- expect(rows.find((r) => r.id === 'backup')).toEqual({
- id: 'backup', label: 'Backup', dot: 'attention', text: 'Back up needed', actionLabel: 'Back up',
+ it('reports backup needed, over USB or WiFi', () => {
+ expect(deviceSummaryRows(base({ needsBackup: true })).find((r) => r.id === 'backup')).toEqual({
+ id: 'backup', label: 'Backup', dot: 'attention', text: 'Backup needed',
+ })
+ expect(deviceSummaryRows(base({ overUsb: false, needsBackup: true })).find((r) => r.id === 'backup')).toEqual({
+ id: 'backup', label: 'Backup', dot: 'attention', text: 'Backup needed',
})
})
- it('asks for a backup when none has ever been recorded', () => {
- const rows = deviceSummaryRows(base({ lastExportAt: null }))
+ it('points a WiFi signer at USB for backup, neutral', () => {
+ const rows = deviceSummaryRows(base({ overUsb: false }))
expect(rows.find((r) => r.id === 'backup')).toEqual({
- id: 'backup', label: 'Backup', dot: 'attention', text: 'Back up needed', actionLabel: 'Back up',
+ id: 'backup', label: 'Backup', dot: 'unknown', text: 'Connect by USB to back up',
})
})
+})
- it('points a WiFi signer at USB for backup, neutral (not green) and with no action button', () => {
- const rows = deviceSummaryRows(base({ overUsb: false, modeLabel: 'WiFi' }))
- expect(rows.find((r) => r.id === 'backup')).toEqual({
- id: 'backup', label: 'Backup', dot: 'unknown', text: 'Connect by USB to back up', actionLabel: null,
- })
+describe('attentionRows', () => {
+ it('is empty when nothing needs attention', () => {
+ expect(attentionRows(base())).toEqual([])
})
- it('flags a needed backup over WiFi as amber, not neutral', () => {
- const rows = deviceSummaryRows(base({ overUsb: false, modeLabel: 'WiFi', needsBackup: true }))
- expect(rows.find((r) => r.id === 'backup')).toEqual({
- id: 'backup', label: 'Backup', dot: 'attention', text: 'Back up needed: connect by USB', actionLabel: null,
- })
+ it('flags no encryption as a problem card, with a "Choose how it unlocks" action', () => {
+ const rows = attentionRows(base({ unlockMode: 'none' }))
+ expect(rows).toEqual([{
+ id: 'not-encrypted', dot: 'problem', label: 'Not encrypted',
+ text: 'Anyone holding it can read the keys.', actionLabel: 'Choose how it unlocks',
+ }])
})
- it('reports a plain connection when nothing is wrong', () => {
- const rows = deviceSummaryRows(base())
- expect(rows.find((r) => r.id === 'connection')).toEqual({
- id: 'connection', label: 'Connection', dot: 'ok', text: 'Connected over USB cable', actionLabel: 'Details',
- })
+ it('does not flag a locked signer as "not encrypted"', () => {
+ expect(attentionRows(base({ locked: true, unlockMode: null }))).toEqual([])
})
- it('names crash, fragmentation and recovery, but never storage', () => {
- const rows = deviceSummaryRows(base({ crash: true, fragmented: true, recovering: true, storageState: 'full' }))
- const row = rows.find((r) => r.id === 'connection')!
- expect(row.dot).toBe('attention')
- expect(row.text).toBe(
- 'Connected over USB cable: last restart was a crash, memory is fragmented, recovering from a restart',
- )
- expect(row.text).not.toContain('storage')
+ it('offers an Update action over USB, and How to update over WiFi', () => {
+ const usb = attentionRows(base({ updateAvailable: true, updateVersion: '0.19.0' }))
+ expect(usb).toEqual([{
+ id: 'firmware', dot: 'attention', label: 'Update available',
+ text: 'v0.19.0 is ready to install.', actionLabel: 'Update',
+ }])
+ const wifi = attentionRows(base({ updateAvailable: true, updateVersion: '0.19.0', overUsb: false }))
+ expect(wifi).toEqual([{
+ id: 'firmware', dot: 'attention', label: 'Update available',
+ text: 'v0.19.0 is ready. Updates need the USB cable.', actionLabel: 'How to update',
+ }])
})
- it('does not cite storage in the connection row even when storage alone is warn', () => {
- const rows = deviceSummaryRows(base({ storageState: 'warn' }))
- const row = rows.find((r) => r.id === 'connection')!
- expect(row.dot).toBe('ok')
- expect(row.text).toBe('Connected over USB cable')
+ it('names the backup date when one is on record, over USB', () => {
+ const rows = attentionRows(base({ needsBackup: true, lastExportAt: Date.parse('2026-09-01T00:00:00Z') }))
+ expect(rows[0].text).toContain('Pairings changed since your last backup on')
+ expect(rows[0].actionLabel).toBe('Back up')
})
- it('reports disconnected as a problem with no action', () => {
- const rows = deviceSummaryRows(base({ modeLabel: 'Disconnected' }))
- expect(rows.find((r) => r.id === 'connection')).toEqual({
- id: 'connection', label: 'Connection', dot: 'problem', text: 'Disconnected', actionLabel: null,
- })
+ it('says no backup is on record when none exists', () => {
+ const rows = attentionRows(base({ needsBackup: true, lastExportAt: null }))
+ expect(rows[0].text).toBe('Pairings changed and this browser has no backup on record.')
})
- it('adds a storage row only when it is warn or full', () => {
- expect(deviceSummaryRows(base({ storageState: 'ok' })).find((r) => r.id === 'storage')).toBeUndefined()
- expect(deviceSummaryRows(base({ storageState: 'warn' })).find((r) => r.id === 'storage')).toEqual({
- id: 'storage', label: 'Storage', dot: 'attention', text: 'Filling up', actionLabel: 'Details',
- })
- expect(deviceSummaryRows(base({ storageState: 'full' })).find((r) => r.id === 'storage')).toEqual({
- id: 'storage', label: 'Storage', dot: 'problem', text: 'Nearly full', actionLabel: 'Details',
+ it('gives a WiFi backup card no button, and the USB-cable copy', () => {
+ const rows = attentionRows(base({ overUsb: false, needsBackup: true }))
+ expect(rows).toEqual([{
+ id: 'backup', dot: 'attention', label: 'Back up your app pairings',
+ text: 'Connect by USB to back up. The signer asks for a button press.', actionLabel: null,
+ }])
+ })
+
+ it('shows the storage percentage, and problem dot only when full', () => {
+ const warn = attentionRows(base({ storageState: 'warn', storagePct: 87 }))
+ expect(warn[0]).toEqual({
+ id: 'storage', dot: 'attention', label: 'Storage 87% full',
+ text: 'Remove an unused persona or app pairing to free space.', actionLabel: 'Details',
})
+ const full = attentionRows(base({ storageState: 'full', storagePct: 98 }))
+ expect(full[0].dot).toBe('problem')
+ expect(full[0].label).toBe('Storage 98% full')
+ })
+
+ it('is null (absent) when nothing needs a health card', () => {
+ expect(attentionRows(base())).toHaveLength(0)
+ })
+
+ it('joins several health reasons with a space, as a Health card, not Connection', () => {
+ const rows = attentionRows(base({ crash: true, fragmented: true, recovering: true }))
+ expect(rows).toEqual([{
+ id: 'health', dot: 'attention', label: 'Health',
+ text: 'The last restart was a crash. Memory is fragmented. It restarted itself to recover.',
+ actionLabel: 'Details',
+ }])
+ })
+
+ it('flags a stopped relay as "Not answering", with no button', () => {
+ const rows = attentionRows(base({ statusLost: true }))
+ expect(rows).toEqual([{
+ id: 'status-lost', dot: 'problem', label: 'Not answering',
+ text: 'The signer stopped answering over WiFi. What you see may be out of date.', actionLabel: null,
+ }])
})
- it('orders problems first, then attention, then unknown, then ok rows', () => {
- const rows = deviceSummaryRows(base({
- unlockMode: 'none', // problem
- storageState: 'warn', // attention, standalone row
- overUsb: false, // backup becomes 'unknown'
- modeLabel: 'WiFi',
- }))
- const rank = (d: string) => (d === 'problem' ? 0 : d === 'attention' ? 1 : d === 'unknown' ? 2 : 3)
- const dots = rows.map((r) => r.dot)
- for (let i = 1; i < dots.length; i++) {
- expect(rank(dots[i])).toBeGreaterThanOrEqual(rank(dots[i - 1]))
- }
- expect(rows[0].id).toBe('power-cut')
+ it('orders problem cards before attention cards', () => {
+ const rows = attentionRows(base({ unlockMode: 'none', storageState: 'warn', storagePct: 50 }))
+ expect(rows.map((r) => r.dot)).toEqual(['problem', 'attention'])
+ expect(rows[0].id).toBe('not-encrypted')
})
})
diff --git a/src/lib/device-summary.ts b/src/lib/device-summary.ts
index b8b87dd..e9b10c7 100644
--- a/src/lib/device-summary.ts
+++ b/src/lib/device-summary.ts
@@ -1,7 +1,7 @@
-// "Your signer" summary rows: the handful of plain-English lines at the top
-// of the Device panel, one per concern, ordered so anything needing
-// attention comes first. Pure so the ordering and wording are unit-tested
-// without a rendered component.
+// Device tab summary logic: the section-row status words (always shown, one
+// per section) and the Needs-attention cards (0 to n, shown only for the
+// handful of concerns that need a decision). Pure so the wording and ordering
+// are unit-tested without a rendered component.
import type { UnlockMode } from './phone-unlock.js'
@@ -10,15 +10,27 @@ import type { UnlockMode } from './phone-unlock.js'
* state). It must never read as green. */
export type SummaryDot = 'ok' | 'unknown' | 'attention' | 'problem'
-export type SummaryRowId = 'firmware' | 'power-cut' | 'backup' | 'connection' | 'storage'
+export type SummaryRowId = 'firmware' | 'power-cut' | 'backup'
+/** A section row's status word, shown beside its title whether the section
+ * is open or not. */
export interface SummaryRow {
id: SummaryRowId
/** The fixed, bold concern label shown before the state text, e.g. "Firmware". */
label: string
dot: SummaryDot
text: string
- /** Label for the row's one primary button, or null when there is nothing to do. */
+}
+
+export type AttentionRowId = 'not-encrypted' | 'firmware' | 'backup' | 'storage' | 'health' | 'status-lost'
+
+/** A Needs-attention card: only ever `problem` or `attention`. */
+export interface AttentionRow {
+ id: AttentionRowId
+ dot: SummaryDot
+ label: string
+ text: string
+ /** Label for the card's one button, or null when there is nothing to press. */
actionLabel: string | null
}
@@ -37,6 +49,9 @@ export interface DeviceSummaryInput {
* at all. Distinguishes "update Sapwood" from "connect by USB to check". */
atRestUnrecognised: boolean
overUsb: boolean
+ /** The signer is locked over USB (VaultUnlock owns the unlock itself; this
+ * only changes the After-a-power-cut row's status). */
+ locked: boolean
needsBackup: boolean
lastExportAt: number | null
/** The signer's last restart was a crash. */
@@ -46,109 +61,152 @@ export interface DeviceSummaryInput {
/** The signer restarted itself to recover from a wedged relay service. */
recovering: boolean
storageState: 'ok' | 'warn' | 'full' | null
- /** "USB cable" / "WiFi" / "Bridge" / "Disconnected". */
- modeLabel: string
+ storagePct: number | null
+ /** A relay signer stopped answering status polls: what is on screen may be
+ * stale. Never true over USB or bridge, where a dropped connection is
+ * obvious immediately. */
+ statusLost: boolean
}
function severityRank(dot: SummaryDot): number {
return dot === 'problem' ? 0 : dot === 'attention' ? 1 : dot === 'unknown' ? 2 : 3
}
+function formatDate(at: number): string {
+ return new Date(at).toLocaleDateString(undefined, { dateStyle: 'medium' })
+}
+
function firmwareRow(input: DeviceSummaryInput): SummaryRow {
if (input.updateAvailable && input.updateVersion) {
- return {
- id: 'firmware', label: 'Firmware', dot: 'attention',
- text: `Update available: v${input.updateVersion}`, actionLabel: 'Update',
- }
+ return { id: 'firmware', label: 'Firmware', dot: 'attention', text: `Update available: v${input.updateVersion}` }
}
if (input.runningVersion) {
- return {
- id: 'firmware', label: 'Firmware', dot: 'ok',
- text: `Up to date (v${input.runningVersion})`, actionLabel: null,
- }
+ return { id: 'firmware', label: 'Firmware', dot: 'ok', text: `Up to date (v${input.runningVersion})` }
}
- return { id: 'firmware', label: 'Firmware', dot: 'ok', text: 'Version unknown', actionLabel: null }
+ // Unknown must never read green (F7): nothing here has been confirmed up to date.
+ return { id: 'firmware', label: 'Firmware', dot: 'unknown', text: 'Version unknown' }
}
function powerCutRow(input: DeviceSummaryInput): SummaryRow {
const label = 'After a power cut'
+ if (input.locked) {
+ return { id: 'power-cut', label, dot: 'problem', text: 'Locked' }
+ }
if (input.unlockMode === 'phone') {
const n = input.phoneCount ?? 0
- return {
- id: 'power-cut', label, dot: 'ok',
- text: `Unlocks from your phone (${n} phone${n === 1 ? '' : 's'})`,
- actionLabel: 'Change',
- }
+ return { id: 'power-cut', label, dot: 'ok', text: `Unlocks from your phone (${n} phone${n === 1 ? '' : 's'})` }
}
if (input.unlockMode === 'sapwood') {
- return { id: 'power-cut', label, dot: 'ok', text: 'Waits for Sapwood', actionLabel: 'Change' }
+ return { id: 'power-cut', label, dot: 'ok', text: 'Waits for Sapwood or its PIN' }
}
if (input.unlockMode === 'none') {
- return {
- id: 'power-cut', label, dot: 'problem',
- text: 'Not encrypted: anyone holding it can read the keys',
- actionLabel: 'Change',
- }
+ return { id: 'power-cut', label, dot: 'problem', text: 'Not encrypted' }
}
- const text = input.atRestUnrecognised
- ? "Your signer reported a setting this version of Sapwood doesn't know. Update Sapwood."
- : input.overUsb
- ? 'Unknown: no vault key held here'
- : `Unknown over ${input.modeLabel}. Connect by USB to check`
- return { id: 'power-cut', label, dot: 'unknown', text, actionLabel: 'Change' }
-}
-
-function backupDate(at: number): string {
- return new Date(at).toLocaleDateString()
+ if (input.atRestUnrecognised) {
+ return { id: 'power-cut', label, dot: 'unknown', text: 'Update Sapwood to read this setting' }
+ }
+ if (input.overUsb) {
+ return { id: 'power-cut', label, dot: 'unknown', text: 'Unknown: no vault key held here' }
+ }
+ return { id: 'power-cut', label, dot: 'unknown', text: 'Unknown: connect by USB to check' }
}
function backupRow(input: DeviceSummaryInput): SummaryRow {
const label = 'Backup'
if (!input.overUsb) {
return input.needsBackup
- ? { id: 'backup', label, dot: 'attention', text: 'Back up needed: connect by USB', actionLabel: null }
- : { id: 'backup', label, dot: 'unknown', text: 'Connect by USB to back up', actionLabel: null }
+ ? { id: 'backup', label, dot: 'attention', text: 'Backup needed' }
+ : { id: 'backup', label, dot: 'unknown', text: 'Connect by USB to back up' }
}
if (!input.needsBackup && input.lastExportAt) {
- return { id: 'backup', label, dot: 'ok', text: `Backed up ${backupDate(input.lastExportAt)}`, actionLabel: 'Back up' }
+ return { id: 'backup', label, dot: 'ok', text: `Backed up ${formatDate(input.lastExportAt)}` }
+ }
+ return { id: 'backup', label, dot: 'attention', text: 'Backup needed' }
+}
+
+/**
+ * The section-row status words, in the tab's fixed display order (After a
+ * power cut, Firmware, Backup). Network, Display and Diagnostics compute
+ * their own row words directly in the component; they carry no attention
+ * cards of their own.
+ */
+export function deviceSummaryRows(input: DeviceSummaryInput): SummaryRow[] {
+ return [powerCutRow(input), firmwareRow(input), backupRow(input)]
+}
+
+function notEncryptedAttention(input: DeviceSummaryInput): AttentionRow | null {
+ // A locked signer's encryption mode cannot be read, so it is reported as
+ // "Locked" (via the row, and via VaultUnlock's own banner), never as "Not
+ // encrypted": the two are different problems needing different actions.
+ if (input.locked || input.unlockMode !== 'none') return null
+ return {
+ id: 'not-encrypted', dot: 'problem', label: 'Not encrypted',
+ text: 'Anyone holding it can read the keys.', actionLabel: 'Choose how it unlocks',
}
- return { id: 'backup', label, dot: 'attention', text: 'Back up needed', actionLabel: 'Back up' }
}
-function connectionRow(input: DeviceSummaryInput): SummaryRow {
- const label = 'Connection'
- if (input.modeLabel === 'Disconnected') {
- return { id: 'connection', label, dot: 'problem', text: 'Disconnected', actionLabel: null }
+function firmwareAttention(input: DeviceSummaryInput): AttentionRow | null {
+ if (!input.updateAvailable || !input.updateVersion) return null
+ return input.overUsb
+ ? { id: 'firmware', dot: 'attention', label: 'Update available', text: `v${input.updateVersion} is ready to install.`, actionLabel: 'Update' }
+ : { id: 'firmware', dot: 'attention', label: 'Update available', text: `v${input.updateVersion} is ready. Updates need the USB cable.`, actionLabel: 'How to update' }
+}
+
+function backupAttention(input: DeviceSummaryInput): AttentionRow | null {
+ if (!input.needsBackup) return null
+ if (!input.overUsb) {
+ return {
+ id: 'backup', dot: 'attention', label: 'Back up your app pairings',
+ text: 'Connect by USB to back up. The signer asks for a button press.', actionLabel: null,
+ }
}
- // Storage has its own row when it needs attention; naming it here too would
- // repeat the same fact twice on the page.
- const reasons: string[] = []
- if (input.crash) reasons.push('last restart was a crash')
- if (input.fragmented) reasons.push('memory is fragmented')
- if (input.recovering) reasons.push('recovering from a restart')
- const text = reasons.length
- ? `Connected over ${input.modeLabel}: ${reasons.join(', ')}`
- : `Connected over ${input.modeLabel}`
- return { id: 'connection', label, dot: reasons.length ? 'attention' : 'ok', text, actionLabel: 'Details' }
+ const text = input.lastExportAt
+ ? `Pairings changed since your last backup on ${formatDate(input.lastExportAt)}.`
+ : 'Pairings changed and this browser has no backup on record.'
+ return { id: 'backup', dot: 'attention', label: 'Back up your app pairings', text, actionLabel: 'Back up' }
}
-function storageRow(input: DeviceSummaryInput): SummaryRow | null {
+function storageAttention(input: DeviceSummaryInput): AttentionRow | null {
if (input.storageState !== 'warn' && input.storageState !== 'full') return null
+ const pct = input.storagePct ?? 0
return {
id: 'storage',
- label: 'Storage',
dot: input.storageState === 'full' ? 'problem' : 'attention',
- text: input.storageState === 'full' ? 'Nearly full' : 'Filling up',
+ label: `Storage ${pct}% full`,
+ text: 'Remove an unused persona or app pairing to free space.',
actionLabel: 'Details',
}
}
+function healthAttention(input: DeviceSummaryInput): AttentionRow | null {
+ const reasons: string[] = []
+ if (input.crash) reasons.push('The last restart was a crash.')
+ if (input.fragmented) reasons.push('Memory is fragmented.')
+ if (input.recovering) reasons.push('It restarted itself to recover.')
+ if (!reasons.length) return null
+ return { id: 'health', dot: 'attention', label: 'Health', text: reasons.join(' '), actionLabel: 'Details' }
+}
+
+function statusLostAttention(input: DeviceSummaryInput): AttentionRow | null {
+ if (!input.statusLost) return null
+ return {
+ id: 'status-lost', dot: 'problem', label: 'Not answering',
+ text: 'The signer stopped answering over WiFi. What you see may be out of date.', actionLabel: null,
+ }
+}
+
/**
- * The "Your signer" rows, in display order: anything needing attention
- * (problem, then attention, then unknown) comes before rows that are simply ok.
+ * The Needs-attention cards, worst first (problem, then attention). Absent
+ * entirely when nothing needs a decision.
*/
-export function deviceSummaryRows(input: DeviceSummaryInput): SummaryRow[] {
- const rows = [firmwareRow(input), powerCutRow(input), backupRow(input), connectionRow(input), storageRow(input)]
- .filter((row): row is SummaryRow => row !== null)
+export function attentionRows(input: DeviceSummaryInput): AttentionRow[] {
+ const rows = [
+ notEncryptedAttention(input),
+ statusLostAttention(input),
+ firmwareAttention(input),
+ backupAttention(input),
+ storageAttention(input),
+ healthAttention(input),
+ ].filter((row): row is AttentionRow => row !== null)
return [...rows].sort((a, b) => severityRank(a.dot) - severityRank(b.dot))
}
diff --git a/src/lib/version.test.ts b/src/lib/version.test.ts
index c59bb46..7fdd8b9 100644
--- a/src/lib/version.test.ts
+++ b/src/lib/version.test.ts
@@ -16,11 +16,24 @@ describe('compareVersions', () => {
expect(compareVersions('v0.14.0', '0.14.0')).toBe(0)
})
- it('ignores a pre-release suffix for ordering', () => {
- expect(compareVersions('0.14.0-rc1', '0.14.0')).toBe(0)
+ it('orders a pre-release below its own release (SemVer 2.0 §11)', () => {
+ expect(compareVersions('0.14.0-rc1', '0.14.0')).toBeLessThan(0)
+ expect(compareVersions('0.14.0', '0.14.0-rc1')).toBeGreaterThan(0)
expect(compareVersions('0.14.0-rc1', '0.13.9')).toBeGreaterThan(0)
})
+ it('orders pre-release identifiers numerically, not lexically', () => {
+ expect(compareVersions('0.18.0-beta.17', '0.18.0-beta.19')).toBeLessThan(0)
+ expect(compareVersions('0.18.0-beta.9', '0.18.0-beta.10')).toBeLessThan(0)
+ expect(compareVersions('0.18.0-beta.19', '0.18.0-beta.19')).toBe(0)
+ })
+
+ it('ignores build metadata, with or without a pre-release', () => {
+ expect(compareVersions('0.14.0+abc123', '0.14.0')).toBe(0)
+ expect(compareVersions('0.18.0-beta.19+abc123', '0.18.0-beta.19')).toBe(0)
+ expect(compareVersions('0.18.0-beta.17+abc123', '0.18.0-beta.19')).toBeLessThan(0)
+ })
+
it('returns null rather than guessing at unparseable input', () => {
expect(compareVersions('', '0.14.0')).toBeNull()
expect(compareVersions('unknown', '0.14.0')).toBeNull()
@@ -48,4 +61,16 @@ describe('isUpgrade', () => {
expect(isUpgrade('0.14.0', null)).toBe(false)
expect(isUpgrade('unknown', '0.14.0')).toBe(false)
})
+
+ it('treats a newer beta as an upgrade over an older beta on the same release', () => {
+ expect(isUpgrade('0.18.0-beta.17', '0.18.0-beta.19')).toBe(true)
+ })
+
+ it('treats the final release as an upgrade over its own beta', () => {
+ expect(isUpgrade('0.18.0-beta.19', '0.18.0')).toBe(true)
+ })
+
+ it('does not offer a beta as an upgrade over its own final release', () => {
+ expect(isUpgrade('0.18.0', '0.18.0-beta.19')).toBe(false)
+ })
})
diff --git a/src/lib/version.ts b/src/lib/version.ts
index 8cbf06d..e2dd95a 100644
--- a/src/lib/version.ts
+++ b/src/lib/version.ts
@@ -7,16 +7,48 @@
// so silently reverts whatever the newer build fixed. Compare properly and only
// offer a genuine upgrade.
-/** Parse "0.14.0" (or "v0.14.0", or "0.14.0-rc1") into comparable parts, or
+interface ParsedVersion {
+ core: number[]
+ /** Dot-separated pre-release identifiers, or null when there is no pre-release. */
+ prerelease: string[] | null
+}
+
+/** Parse "0.14.0" (or "v0.14.0", or "0.14.0-beta.17") into comparable parts, or
* null when it is not a version we can reason about. */
-function parse(version: string): number[] | null {
- const cleaned = version.trim().replace(/^v/i, '')
- // Pre-release suffixes are dropped for ordering: the numeric release is what
- // the manifest and the device agree on, and treating "0.14.0-rc1" as 0.14.0
- // is closer to right than refusing to compare at all.
- const core = cleaned.split(/[-+]/)[0] ?? ''
- if (!/^\d+(\.\d+)*$/.test(core)) return null
- return core.split('.').map(Number)
+function parse(version: string): ParsedVersion | null {
+ // A build-metadata suffix (+...) plays no part in precedence (SemVer 2.0 §10).
+ const cleaned = version.trim().replace(/^v/i, '').split('+')[0] ?? ''
+ const [core, ...rest] = cleaned.split('-')
+ if (!core || !/^\d+(\.\d+)*$/.test(core)) return null
+ const prereleaseRaw = rest.length > 0 ? rest.join('-') : null
+ return {
+ core: core.split('.').map(Number),
+ prerelease: prereleaseRaw ? prereleaseRaw.split('.') : null,
+ }
+}
+
+/** Compare a single pair of pre-release identifiers per SemVer 2.0 §11.4. */
+function compareIdentifier(a: string, b: string): number {
+ const aNumeric = /^\d+$/.test(a)
+ const bNumeric = /^\d+$/.test(b)
+ if (aNumeric && bNumeric) return Number(a) - Number(b)
+ // Numeric identifiers always have lower precedence than alphanumeric ones.
+ if (aNumeric) return -1
+ if (bNumeric) return 1
+ if (a === b) return 0
+ return a < b ? -1 : 1
+}
+
+/** Compare two pre-release identifier lists per SemVer 2.0 §11.4. */
+function comparePrerelease(a: string[], b: string[]): number {
+ const len = Math.max(a.length, b.length)
+ for (let i = 0; i < len; i++) {
+ if (a[i] === undefined) return -1 // fewer fields: lower precedence
+ if (b[i] === undefined) return 1
+ const cmp = compareIdentifier(a[i], b[i])
+ if (cmp !== 0) return cmp
+ }
+ return 0
}
/**
@@ -24,18 +56,24 @@ function parse(version: string): number[] | null {
* than `b`, zero when they are equal, positive when `a` is newer. Returns null
* when either side is unparseable, so callers can decline to act rather than
* guess.
+ *
+ * A pre-release has lower precedence than its release (SemVer 2.0 §11):
+ * `0.18.0-beta.17` < `0.18.0-beta.19` < `0.18.0`.
*/
export function compareVersions(a: string, b: string): number | null {
const pa = parse(a)
const pb = parse(b)
if (!pa || !pb) return null
- const len = Math.max(pa.length, pb.length)
+ const len = Math.max(pa.core.length, pb.core.length)
for (let i = 0; i < len; i++) {
// "0.14" and "0.14.0" are the same version.
- const diff = (pa[i] ?? 0) - (pb[i] ?? 0)
+ const diff = (pa.core[i] ?? 0) - (pb.core[i] ?? 0)
if (diff !== 0) return diff
}
- return 0
+ if (pa.prerelease === null && pb.prerelease === null) return 0
+ if (pa.prerelease === null) return 1 // a is a release, b is a pre-release: a is newer
+ if (pb.prerelease === null) return -1
+ return comparePrerelease(pa.prerelease, pb.prerelease)
}
/**