Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/push-and-restart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw-webapp": minor
---

Notify when a device goes quiet or the house draws more than the fuse, and restart the box from this phone when something is stuck.
10 changes: 10 additions & 0 deletions contract/push-catalogue.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ events:
- kind: update.installed
title: Your box updated itself
body: "Now running {version}. Everything came back on its own."
- kind: driver.offline
# After the box's own threshold: ten minutes of silence from a driver
# that had been reporting. A blip must not become a lock-screen.
title: A device went quiet
body: "{name} stopped answering."
- kind: fuse.over_limit
# After the box's own threshold: thirty seconds over the rating. A
# kettle must not page anyone.
title: The house is drawing too much
body: "{phase} is over the fuse rating."
- kind: box.unreachable
# The one sentence the box cannot send about itself. The relay holds
# this pre-encrypted and posts it only when the box has missed its
Expand Down
4 changes: 4 additions & 0 deletions src/lib/notify/kinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export const KINDS = [
'charging.session_complete',
'charging.interrupted',
'update.installed',
'driver.offline',
'fuse.over_limit',
Comment on lines +15 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Hide notification kinds absent from the box document

On an existing subscription connected to an older box whose rules document lacks these new kinds, Notifications.svelte still iterates every entry in RULE_KINDS, so both unsupported toggles are displayed. Although NotifyStore.#writeRules correctly filters them out before sending, it then applies the returned document and silently resets the toggles to off after Save, leaving users with controls that can never take effect. Derive the displayed kinds from the types present in the loaded rules document.

Useful? React with 👍 / 👎.

'box.unreachable',
] as const

Expand All @@ -33,5 +35,7 @@ export const KIND_LABELS: Record<PushKind, string> = {
'charging.session_complete': 'When the car finishes charging',
'charging.interrupted': 'If charging stops before it is done',
'update.installed': 'When your box updates itself',
'driver.offline': 'If a device goes quiet',
'fuse.over_limit': 'If the house draws more than the fuse allows',
'box.unreachable': 'If your box goes out of reach',
}
16 changes: 16 additions & 0 deletions src/lib/sim/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ const RULE_TYPES = [
'charging.session_complete',
'charging.interrupted',
'update.installed',
'driver.offline',
'fuse.over_limit',
]

/**
Expand Down Expand Up @@ -147,6 +149,9 @@ const ROUTES: Record<string, RouteFacts> = {
'PUT /api/notifications/rules': { tier: 'configure' },
'POST /api/notifications/test': { tier: 'configure' },

// Owner recovery: a late restart is the same instruction, only later.
'POST /api/restart': { tier: 'configure' },

// At the box, in the house. A credential, a whole file, or a person needed
// in the room.
'GET /api/config': { tier: 'local' },
Expand Down Expand Up @@ -306,6 +311,8 @@ export class SimApi {
#pushRules: { enabled: boolean; events: Record<string, unknown>[] } | null = null
/** How many test pushes were asked for, for a test to look at. */
#testPushes = 0
/** How many times this box was asked to restart, for a test to look at. */
#restarts = 0

constructor(opts: SimApiOptions) {
this.#opts = opts
Expand Down Expand Up @@ -374,6 +381,11 @@ export class SimApi {
return this.#testPushes
}

/** Restarts this box was asked for, for a test to look at. */
get restarts(): number {
return this.#restarts
}

/**
* Serve one request, or refuse it.
*
Expand Down Expand Up @@ -499,6 +511,10 @@ export class SimApi {
return json(200, { status: 'sent', to: this.#pushSubscriptions.size })
}
if (route === 'GET /api/notifications/history') return this.#pushHistory()
if (route === 'POST /api/restart') {
this.#restarts += 1
return json(202, { status: 'restarting' })
}

// A real read whose answer this session cannot carry. Refused by class at
// the status line, never by a list of paths, so a route added next year
Expand Down
7 changes: 7 additions & 0 deletions src/views/Box.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import { deviceIdOnBox, openVaultStore } from '$lib/identity/vault'
import Access from '$views/Access.svelte'
import Notifications from '$views/Notifications.svelte'
import Restart from '$views/Restart.svelte'

interface Props {
site: SiteStore
Expand Down Expand Up @@ -267,6 +268,12 @@
about the pairing, like the roster above it. -->
<Notifications {site} />

<!-- Recovery, not a preference: the same box this phone is paired to,
asked to come back up when a device is stuck and the owner is not
at home. Owner only, confirmed, the way signing out is. Draws its
own rule when visible so a viewer does not get an empty gap. -->
<Restart {site} />

<hr />

<!-- The spare key. Written as what it costs, not as a feature: someone who
Expand Down
3 changes: 2 additions & 1 deletion src/views/DemoBox.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
<h2>Notifications</h2>
<p>
An installed app can show useful events on the lock screen, such as a
finished EV charge, an installed box update or a box that went quiet.
finished EV charge, a device that stops answering, or a box that went
quiet.
</p>

<hr />
Expand Down
2 changes: 1 addition & 1 deletion src/views/Notifications.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@
{:else}
<p>
A few words on the lock screen when something at home matters: the car is
charged, your box updated itself, or it went quiet. Nothing is sent until
charged, a device goes quiet, or the box itself. Nothing is sent until

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Broken notifications intro copy

Medium Severity

The offline notifications teaser ends with or the box itself, which is an incomplete phrase. Readers never learn what happens with the box, unlike the parallel DemoBox sentence that finishes the idea.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2554794. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extra toggles on older boxes

Medium Severity

The kinds list always renders every RULE_KINDS entry, including driver.offline and fuse.over_limit, even when those types are absent from the box’s rules document. A save correctly omits missing kinds, so those toggles can appear, be flipped, and then snap back off after save.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2554794. Configure here.

you turn this on.
</p>
<button
Expand Down
8 changes: 6 additions & 2 deletions src/views/Notifications.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,15 @@ describe('the notifications section', () => {
expect(box.api.pushEnabled, 'subscribed but the box stayed switched off').toBe(true)
expect(box.api.pushRules['charging.session_complete']).toBe(true)
expect(box.api.pushRules['charging.interrupted']).toBe(true)
expect(box.api.pushRules['driver.offline']).toBe(true)
expect(box.api.pushRules['fuse.over_limit']).toBe(true)

// And the section now offers what an enabled phone can do.
expect(buttonSaying(/Send a test/)).toBeDefined()
expect(buttonSaying(/Turn off notifications/)).toBeDefined()
expect(text()).toContain('When the car finishes charging')
expect(text()).toContain('If a device goes quiet')
expect(text()).toContain('If the house draws more than the fuse allows')
})

it('shows the box’s own record of what it has sent', async () => {
Expand All @@ -186,10 +190,10 @@ describe('the notifications section', () => {
render(Notifications, { props: { site } })
await vi.advanceTimersByTimeAsync(500)

// Three switches: box.unreachable has none — the box cannot gate a
// Five switches: box.unreachable has none — the box cannot gate a
// message about its own absence, so it follows the subscription itself.
const boxes = [...document.querySelectorAll('input[type="checkbox"]')] as HTMLInputElement[]
expect(boxes.length).toBe(3)
expect(boxes.length).toBe(5)

// Everything starts off, because the box seeds every rule disabled —
// sparse by design. Two edits, and no save yet: a toggle is an edit,
Expand Down
120 changes: 120 additions & 0 deletions src/views/Restart.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<!--
Restart — ask this box to come back up, from the phone.

Not a setting. The Box screen is about the pairing, and this is a
recovery act on that same box: a device stuck after a blip, and the
owner is not at home. It lives here because this is the screen that
already names the box, and because a restart is not a preference to
hunt for.

Owner only, with a step-up, because the box prices POST /api/restart
as configure. A viewer is shown nothing. An old box without the
passthrough is shown nothing — the session cannot carry the route.

Confirm before sending. A stray tap that bounced the process would
drop every phone for a minute, and the confirm is the same shape as
signing out on this screen.
-->
<script lang="ts">
import { callBox, BoxApiError } from '$lib/state/box-api'
import type { SiteStore } from '$lib/state/site.svelte'

interface Props {
site: SiteStore
}

let { site }: Props = $props()

/** From contract/registry.yaml. Absent means this box has no passthrough. */
const CAP_PASSTHROUGH = 'api.passthrough'

const canAsk = $derived(site.canConfigure && site.session.caps.has(CAP_PASSTHROUGH))

type Stage = 'idle' | 'confirming' | 'restarting'
let stage = $state<Stage>('idle')
let error = $state<string | null>(null)

async function restart(): Promise<void> {
if (stage === 'restarting') return
error = null
stage = 'restarting'
try {
await callBox<{ status?: unknown }>(site, { method: 'POST', path: '/api/restart' })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the restarting state once reconnection completes

After the restart request returns 202, no success path ever changes stage from restarting. App.svelte keeps previously opened views mounted even while navigating between tabs, so the automatic disconnect/reconnect does not recreate this component; the Box screen consequently says “Restarting” for the rest of the app session and permanently suppresses another restart attempt. Observe the session returning to streaming, or otherwise reset the stage after reconnection.

Useful? React with 👍 / 👎.

} catch (err) {
stage = 'idle'
error = err instanceof BoxApiError ? err.help : "That didn't work. Nothing has changed."
}
}
</script>

{#if site.heardFromBox && canAsk}
<hr />
{#if stage === 'confirming'}
<h2>Restart this box?</h2>
<p>
The software restarts. Devices keep running on their own until it comes
back, usually within a minute. This phone reconnects by itself.
</p>
<button class="danger" onclick={() => void restart()}>Restart now</button>
<button class="quiet" onclick={() => (stage = 'idle')}>Cancel</button>
{:else if stage === 'restarting'}
<h2>Restarting</h2>
<p>Your box is coming back on its own. This usually takes a minute.</p>
{:else}
<h2>Restart</h2>
<p>
Restarts the software on this box. Use it when a device is stuck and will
not come back on its own.
</p>
<button class="quiet outline" onclick={() => (stage = 'confirming')}>Restart this box</button>
{/if}
{#if error}
<p class="problem">{error}</p>
{/if}
{/if}

<style>
h2 {
font-size: 17px;
font-weight: 500;
letter-spacing: -0.01em;
margin-top: var(--space-2);
}

p {
color: var(--fg-dim);
max-width: 30rem;
}

.problem {
color: var(--fresh-stale);
}

.quiet {
color: var(--fg-dim);
font-size: 14px;
}

.outline {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0 var(--space-4);
min-height: 34px;
}

.danger {
border: 1px solid var(--fresh-stale);
color: var(--fresh-stale);
border-radius: var(--radius-sm);
padding: 0 var(--space-5);
font-weight: 500;
}

hr {
width: 100%;
height: 1px;
border: 0;
background: var(--line-soft);
margin: var(--space-3) 0 0;
}
</style>
117 changes: 117 additions & 0 deletions src/views/Restart.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/* Restart, from the tap to the box's own process.

* Real Session, real SimBox, real loopback carrier. The passkey ceremony is
* stubbed at the module the app calls, so the round trip that discovers the
* step-up still happens over the wire. A restart is configure: owner, with
* a ceremony, and a confirm before the process is asked to come back.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render } from '@testing-library/svelte'
import Restart from './Restart.svelte'
import { SiteStore } from '$lib/state/site.svelte'
import { LoopbackCarrier } from '$lib/carrier/loopback'
import { SimBox } from '$lib/sim/box'
import { ROLE_VIEWER } from '$lib/protocol/messages'

vi.mock('$lib/identity/stepup', () => ({
stepUp: vi.fn(async () => 'done'),
stepUpHelp: () => 'needs a ceremony',
}))

const NOON = new Date(2026, 6, 15, 12, 0, 0).getTime()

function open(role?: string) {
const box = new SimBox({ now: () => Date.now(), ...(role ? { role } : {}) })
const site = new SiteStore('test')
site.connect(new LoopbackCarrier(box, { latencyMs: 0 }))
return { box, site }
}

function text(): string {
return (document.body.textContent ?? '').replace(/\s+/g, ' ')
}

function buttonSaying(pattern: RegExp): HTMLButtonElement | undefined {
return [...document.querySelectorAll('button')].find((b) =>
pattern.test(b.textContent ?? '')
) as HTMLButtonElement | undefined
}

describe('restart, from the phone', () => {
beforeEach(async () => {
const { stepUp } = await import('$lib/identity/stepup')
vi.mocked(stepUp).mockResolvedValue('done')
})

afterEach(() => {
document.body.replaceChildren()
vi.useRealTimers()
vi.restoreAllMocks()
})

it('asks first, then the box is told once, with one ceremony', async () => {
vi.useFakeTimers()
vi.setSystemTime(NOON)
const { box, site } = open()
const stepup = await import('$lib/identity/stepup')

render(Restart, { props: { site } })
await vi.advanceTimersByTimeAsync(500)

expect(box.api.restarts, 'drew a control that fired on sight').toBe(0)
buttonSaying(/Restart this box/)!.click()
await vi.advanceTimersByTimeAsync(10)

expect(box.api.restarts, 'the confirm itself restarted the box').toBe(0)
expect(text()).toMatch(/Devices keep running on their own/)

vi.mocked(stepup.stepUp).mockClear()
buttonSaying(/Restart now/)!.click()
await vi.advanceTimersByTimeAsync(500)

expect(box.api.restarts).toBe(1)
expect(vi.mocked(stepup.stepUp), 'a restart cost more than one ceremony').toHaveBeenCalledOnce()
expect(text()).toMatch(/coming back on its own/)
})

it('cancels without asking the box', async () => {
vi.useFakeTimers()
vi.setSystemTime(NOON)
const { box, site } = open()

render(Restart, { props: { site } })
await vi.advanceTimersByTimeAsync(500)
buttonSaying(/Restart this box/)!.click()
await vi.advanceTimersByTimeAsync(10)
buttonSaying(/Cancel/)!.click()
await vi.advanceTimersByTimeAsync(10)

expect(box.api.restarts).toBe(0)
expect(buttonSaying(/Restart this box/)).toBeDefined()
})

it('shows a viewer nothing, not a button their box would refuse', async () => {
vi.useFakeTimers()
vi.setSystemTime(NOON)
const { site } = open(ROLE_VIEWER)

render(Restart, { props: { site } })
await vi.advanceTimersByTimeAsync(500)

expect(text()).not.toMatch(/Restart/i)
expect(document.querySelectorAll('button')).toHaveLength(0)
})

it('draws nothing until the box has said something', async () => {
vi.useFakeTimers()
vi.setSystemTime(NOON)
const site = new SiteStore('test')

render(Restart, { props: { site } })
await vi.advanceTimersByTimeAsync(200)

expect(document.querySelectorAll('button')).toHaveLength(0)
expect(text()).not.toMatch(/Restart/i)
})
})
Loading