diff --git a/README.md b/README.md index 5c22591..ef14ba8 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Visit [openathan.com](https://openathan.com) for supported hardware, installatio Connect once through USB setup: the website checks the speaker and shows existing-device controls or installation confirmation. The browser installer supports fresh installation on **AtomS3R C126 + Pyramid A167** and Wi-Fi/password recovery for existing devices. **Fresh installation erases saved data.** Existing owners should [update from the device page or follow the preserving USB transition](https://openathan.com/docs/getting-started/#firmware-updates). +Existing speakers show one main task, a firmware status row and expandable Wi-Fi/password recovery. A separate signed application-only USB updater is implemented for new firmware that explicitly supports it. Its public activation flag remains **off** pending physical qualification and publication of a capable release. Existing releases continue to use the device page or maintainer transition. + The installer uses the latest approved stable OpenAthan release. The website is scheduled to check for releases twice hourly and deploys only after artifact verification and website tests pass. See the [deployment guide](deployment/README.md) for timing limitations, release selection, and rollback. Prayer settings and activation happen on the device's own local page. The speaker operates independently of this website and Home Assistant. The optional [location helper](https://openathan.com/location/) uses browser location with a GeoJS IP-location fallback; users review the suggestion before saving it on the device. diff --git a/installer/README.md b/installer/README.md index d770623..4d54097 100644 --- a/installer/README.md +++ b/installer/README.md @@ -8,7 +8,7 @@ Review `/preview/installer/` on the development server to exercise the complete ## Protocol and session ownership -The implementation follows the firmware [provisioning handoff](https://github.com/OpenAthan-Project/openathan/blob/d376d4e3e59b59e3b7b26910768d630048c9839e/firmware/esphome/provisioning/README.md) and [Python protocol tests](https://github.com/OpenAthan-Project/openathan/blob/d376d4e3e59b59e3b7b26910768d630048c9839e/tests/test_provision_device.py). No firmware API change is required. +Credential recovery follows the firmware [provisioning handoff](https://github.com/OpenAthan-Project/openathan/blob/d376d4e3e59b59e3b7b26910768d630048c9839e/firmware/esphome/provisioning/README.md) and [Python protocol tests](https://github.com/OpenAthan-Project/openathan/blob/d376d4e3e59b59e3b7b26910768d630048c9839e/tests/test_provision_device.py). The separate application updater requires the new [USB upgrade protocol](https://github.com/OpenAthan-Project/openathan/blob/main/docs/development/usb-firmware-updates.md). - Improv Serial v1: Wi-Fi credentials (1), device information (3), network scan (4). - OpenAthan v1 extension: status (1), device password (2). Maintenance/test commands are not exposed. @@ -16,12 +16,36 @@ The implementation follows the firmware [provisioning handoff](https://github.co - Frames validate version, checksums, lengths and UTF-8. Partial frames expire after one second. Initial firmware discovery has a combined 10-second deadline after the port opens: identification gets up to 5 seconds, then status uses the remaining time. The normal 40-second command deadline covers firmware's 30-second Wi-Fi join timeout. - Wi-Fi and password writes are sent once. Missing/malformed acknowledgements or disconnects yield an uncertain outcome and block further writes in that session. Read-only status is permitted. A connected status does not identify the attempted SSID and cannot prove that an uncertain Wi-Fi write succeeded. Reconnection and a deliberate user decision are required before any later attempt. - A Wi-Fi save acknowledgement must contain exactly the firmware v1 URL pair: the HTTP OpenAthan `.local` hostname followed by its HTTP dotted-decimal IPv4 address, both with root paths. Empty, incomplete or unexpected results are uncertain; they cannot become success through a connected status read. These response fields are validated as protocol data, not opened as links; handoff links still come from separately validated status. -- Error 255 during either credential save triggers a read-only status check. A reported storage/password/setup fault or unreadable status blocks further changes; a healthy status allows a deliberate retry without claiming that the rejected save succeeded. Recovery also offers **Refresh status** to detect Wi-Fi reconnection and updated device links using the existing USB session, without writing credentials. **Open device settings** uses the same check before displaying device links; neither action can leave a previous healthy status authorizing changes after a failed read. +- Error 255 during either credential save triggers a read-only status check. A reported storage/password/setup fault or unreadable status blocks further changes; a healthy status allows a deliberate retry without claiming that the rejected save succeeded. Recovery also offers **Refresh status** to detect Wi-Fi reconnection and updated device links using the existing USB session, without writing credentials. **Continue to device settings** uses the same check before displaying device links; neither action can leave a previous healthy status authorizing changes after a failed read. - SSIDs are 1–32 UTF-8 bytes; Wi-Fi passwords are 8–63 UTF-8 bytes or exactly 64 hexadecimal ASCII characters (0–9, A–F, a–f). Embedded nulls are rejected, and credentials are never trimmed or truncated. Device passwords are 12–128 printable ASCII characters, matching the firmware. Credentials are held only in temporary browser memory, removed from forms after submission and never logged, stored, added to URLs or sent to analytics. JavaScript strings cannot be guaranteed to be physically zeroed; transmitted byte buffers are cleared after writing. Avoid browser tracing with real credentials. The site opens only validated device-reported local links chosen by the user; it does not call the device's HTTP API across origins. -Recovery never imports or invokes the flashing adapter, erase operations or settings/history commands. Unrecognized firmware receives troubleshooting guidance. Recognized OpenAthan firmware opens the connected-speaker menu. If identity succeeds but status cannot be read, installation is blocked and the user receives reconnect/troubleshooting guidance. An unrecognized result goes directly to installation confirmation, with retry, cancel and troubleshooting available. It does not identify a blank device or authorize erasure: new installation requires a separate explicit hardware/full-erasure confirmation. Discovery disconnects cannot become installation candidates. Existing-device upgrades happen on the authenticated device page or through a preserving USB transition, not this browser installer. See the [owner guidance](https://openathan.com/docs/getting-started/#firmware-updates). +Recovery never imports or invokes the flashing adapter, erase operations or settings/history commands. Recognized firmware opens a single connected task heading with identity and status. Healthy speakers promote **Continue to device settings**, followed by a quieter firmware row and collapsed native **Wi-Fi and password recovery** disclosure. Missing credentials promote the required repair and expand recovery unless an explicit firmware check has identified a pending USB update. Task transitions focus their heading and announce status. Fresh installation retains explicit hardware/full-erasure confirmation and one **Back to connection** action; after verified installation, Wi-Fi Back returns to the installed-speaker summary and cannot return to erase confirmation. Recognized firmware with unreadable status and discovery disconnects cannot become installation candidates. See the [owner guidance](https://openathan.com/docs/getting-started/#firmware-updates). + +## Preserving USB updates + +`catalog.json` has an optional `usbUpdateEnabled` boolean, omitted or `false` by default. It is currently **false**. Release discovery carries this reviewed policy through the frozen selection; automatic adoption of a newer firmware release cannot enable USB writes. Enable it only in a separately reviewed activation after the new physical qualification and a capable release are available. It does not change the schema-1 fresh-install manifest or `/release.json`. + +**Check for updates** explicitly probes read-only firmware INFO command `0x10`; connection alone does not probe firmware or download update artifacts. Error 2 identifies legacy firmware: owners need an initial Wi-Fi or maintainer update, with credential recovery still available. New firmware reports its actual version/commit, boot health and update state. Unsupported bootloaders, pending startup checks and conflicting updates prevent offers. Checking never mutates the speaker. + +INFO states `usb_descriptor`, `usb_receiving`, `usb_interrupted`, `usb_selection_uncertain` and `awaiting_power` establish pending USB ownership. Its resolution takes priority over settings and credential recovery: **Finish power handoff** for a verified selection, **Discard incomplete transfer** only after confirmed startup health, or **Check update status** for other owned states. The firmware row contains details without a duplicate resolution button. Recovery stays visible in an initially collapsed native disclosure with an explanation, disabled Wi-Fi/password controls and read-only **Refresh status**. Recovery entry, scanning and credential submission also enforce the restriction independently of rendered controls. + +Within the current connection, a provisioning-status refresh or failed INFO read cannot clear known USB ownership. Failed INFO promotes **Reconnect and check** without retaining a stale handoff or discard action. Only a successful firmware check showing ownership has cleared restores ordinary recovery, including the fresh check after a successful discard. Legacy unsupported firmware and ordinary startup/network-update states do not establish USB ownership. These checks and resolution controls remain available while `usbUpdateEnabled` is false; that policy continues to prevent new USB transfers. + +When activated, the browser verifies the selected manifest, P-256 signature on the exact descriptor payload, compatibility and application SHA-256/size/header/version before offering that exact release. It sends only the descriptor and OTA application through a dedicated serialized session: BEGIN, bounded descriptor chunks, VERIFY, bounded application chunks and FINISH. The firmware independently verifies trust and controls the inactive slot. This path never imports the esptool adapter, writes the bootloader/audio/NVS partitions, or accepts user-supplied binaries or URLs. + +Each update asset download has a 60-second deadline covering response headers and the entire body; arriving chunks do not extend it. Timeout aborts the request and returns to the connected-speaker controls with internet/check-again guidance. Installed firmware is unchanged, existing USB ownership restrictions remain in force, and downloads are never retried automatically. + +Chunks contain a device-issued 64-bit token, descriptor/application kind and exact offset. Each acknowledgement must echo the token/kind and next offset. Writes are sent once. Lost or malformed replies block later mutation; FINISH rejection or failed readback is also uncertain because boot selection may have succeeded. Reconnect and read authoritative firmware state before another owner decision. No automatic transfer retry or conversion to a device-side network download occurs. A rebooted incomplete request can be discarded explicitly through USB; a verified selected image cannot be discarded. + +Completion means **Update written and verified**, followed by Atom USB unplug and Pyramid bottom-only power. The browser displays this handoff as soon as writing and boot selection are verified, before closing USB. Cleanup errors show a nonfatal connection notice; disconnects, including earlier queued notices, cannot replace the power instructions or conditional settings guidance. **Finish power handoff** after reconnecting to an `awaiting_power` device has the same protection, even when Wi-Fi or password status is missing. Returning to the connection screen resets that protection. Installation and credential recovery retain their existing completion sequence. + +When Wi-Fi is connected and the device password is ready, the handoff retains settings links and sign-in instructions. An absent password instead requires creation through USB setup after startup; settings links and sign-in instructions are withheld until recovery is complete. Disconnected Wi-Fi may reconnect using saved network settings and does not prove that credentials are missing. Let the speaker start on bottom power and reconnect first; use USB recovery if it remains offline. + +For USB recovery after startup, disconnect Pyramid bottom power before reconnecting Atom USB. Select **Back to connection**, reconnect, and choose **Check for updates**. Resolve any remaining update prompts before repairing Wi-Fi or creating the device password. After recovery, unplug Atom USB and return to Pyramid bottom-only power before opening the device's settings. + +Verified writing and selection do not mean startup succeeded. Owners confirm the installed version and health result on the device page after startup; the firmware retains its existing confirmation/rollback checks. A failed or uncertain transfer still requires reconnecting and reading firmware status, without an automatic resend or success claim. Settings, prayer-consumption history and shared audio are outside the application write range. ## Approved release discovery and import @@ -31,7 +55,7 @@ The website consumes stable published releases only from `OpenAthan-Project/open The committed release remains a verified manual pin. Set `automatic` to `false` to hold or roll back to it through a reviewed PR. Set `release` to `null` to disable fresh installation; null takes precedence over automatic release discovery. The USB picker remains available for existing-device controls and recovery, while unrecognized firmware receives an unavailable-installation screen without erase controls. Automatic mode must remain off for a persistent manual rollback. -`npm run release:import` discovers a selection once, verifies the published identity, exact asset names/URLs, source commit, bounded sizes, hardware/layout and hashes, then imports only its manifest and two binaries under ignored `public/releases//`. Old generated imports are removed only after the candidate verifies; null removes all generated firmware imports without querying the firmware service. CI first runs `release:select`, then `release:import -- --snapshot` to retain its frozen selection. +`npm run release:import` discovers a selection once, verifies the published identity, exact asset names/URLs, source commit, bounded sizes, hardware/layout and hashes, then imports its manifest and two fresh-install binaries under ignored `public/releases//`. If the release also publishes `upgrade.json` and `firmware.ota.bin`, both are required together and verified against the committed trust key, manifest identity, application digest and factory application bytes. Old generated imports are removed only after the candidate verifies; null removes all generated firmware imports without querying the firmware service. CI first runs `release:select`, then `release:import -- --snapshot` to retain its frozen selection. The ignored `build/release-selection.json` supplies the importer, public labels, release links, installer and static integrity checker. A production build requires a snapshot matching the current policy. Local development without a snapshot uses the committed pin; it does not discover latest in the browser. `/release.json` is a read-only static interface with schema 1, the website commit, and either `release: null` or `release: { tag, manifestSha256 }`. It contains no credentials or device information. @@ -104,5 +128,6 @@ These simulations establish software behavior only. They do not establish USB re 3. Network scan/manual/hidden-network entry, incorrect credentials, lost acknowledgements, device-password creation/reset and storage-fault handling. 4. Recovery preservation checks for current settings, prayer-consumption history, shared audio and recovery capability, including interruptions and power cycles. 5. Recording licensing/content/quality approval, firmware release qualification and approved stable publication. Website adoption follows the reviewed automatic-release policy. +6. Before enabling USB updates: capable confirmed firmware and approved rollback bootloader; desktop Chrome/Edge streaming and exact version confirmation; Atom-only transfer then bottom-power startup; preservation readback; disconnect/power cuts before and after selection; failed startup rollback; invalid/truncated/out-of-order traffic; competing Wi-Fi/USB/settings actions; runtime heap, fragmentation and prayer/audio responsiveness. Prior fresh-install or Wi-Fi update evidence does not establish this new transport. Use the firmware repository's current hardware runbooks and fresh private backups for coordinated acceptance. Historical full-flash images can roll back prayer history and are not routine credential recovery. Keep private diagnostics, credentials and recovery images outside this repository. Published recordings are imported only through the reviewed release manifest; never commit generated binaries. diff --git a/installer/catalog.json b/installer/catalog.json index 6d66040..31dbf42 100644 --- a/installer/catalog.json +++ b/installer/catalog.json @@ -1,6 +1,7 @@ { "schema": 1, "automatic": true, + "usbUpdateEnabled": false, "release": { "tag": "v0.2.1", "manifestSha256": "b6ef1cd38e8feb75ed90d0aeb454ea7ae033d49ded439ab293b29ec510eeb437", diff --git a/installer/entry.ts b/installer/entry.ts index e352e3a..c78b92a 100644 --- a/installer/entry.ts +++ b/installer/entry.ts @@ -8,7 +8,10 @@ if (root) { try { mountInstaller( root, - new RealService(parsePin(JSON.parse(root.dataset.releaseSelection ?? 'null'))), + new RealService( + parsePin(JSON.parse(root.dataset.releaseSelection ?? 'null')), + JSON.parse(root.dataset.releaseSelection ?? 'null')?.usbUpdateEnabled === true, + ), usbSupport(), ); } catch { diff --git a/installer/real-service.ts b/installer/real-service.ts index 8fded41..995a424 100644 --- a/installer/real-service.ts +++ b/installer/real-service.ts @@ -1,5 +1,15 @@ import { BrowserTransport, requestDevicePort } from './browser-transport'; -import { ProvisioningSession } from './session'; +import { ProvisioningSession, DeviceError, UncertainOutcome } from './session'; +import { + loadUpgrade, + UpgradeDownloadTimeout, + newerVersion, + UPGRADE, + CHUNK_BYTES, + type UpgradeBundle, + type UpgradeOffer, + type UpgradeCheck, +} from './upgrade'; import { loadRelease, type ReleasePin } from './release'; import type { InstallerService, Connected } from './service'; @@ -11,8 +21,12 @@ export class RealService implements InstallerService { private installationCandidate = false; private reloadRequired = false; private connectionLost = false; + private updateBundle?: UpgradeBundle; onDisconnect?: () => void; - constructor(private pin: ReleasePin | null) { + constructor( + private pin: ReleasePin | null, + private usbUpdateEnabled = false, + ) { this.installAvailable = pin !== null; } private async open(): Promise { @@ -95,6 +109,145 @@ export class RealService implements InstallerService { 'Installation was verified, but setup could not reconnect. Reconnect through USB setup to check the speaker. Do not reinstall.', ); } + async discardUpdate() { + const info = await this.firmware(); + if (info.state !== 'usb_interrupted' || info.boot !== 'confirmed') + throw new Error('Read update status before discarding an incomplete transfer.'); + const [reply] = await this.current().command(true, UPGRADE.abort, ['interrupted'], { + mutation: true, + }); + if (reply?.length !== 1 || reply[0] !== 'aborted') { + this.current().uncertain = true; + throw new UncertainOutcome(); + } + } + firmware() { + return this.current().firmware(); + } + async checkUpdate(): Promise { + this.updateBundle = undefined; + let info; + try { + info = await this.firmware(); + } catch (error) { + if (error instanceof DeviceError && error.code === 2) + return { + state: 'unsupported', + recoveryBlocked: false, + detail: + 'This firmware needs an initial Wi-Fi or maintainer update before USB updates are supported.', + }; + return { + state: 'failed', + detail: 'Firmware status could not be read. Reconnect and check again.', + }; + } + const recoveryBlocked = [ + 'usb_descriptor', + 'usb_receiving', + 'usb_interrupted', + 'usb_selection_uncertain', + 'awaiting_power', + ].includes(info.state); + if (!info.supported && !recoveryBlocked) + return { + state: 'unsupported', + recoveryBlocked, + detail: 'This speaker needs a maintainer bootloader transition before preserving updates.', + }; + if ( + info.boot !== 'confirmed' || + !['idle', 'current', 'available', 'success', 'rolled_back', 'failed'].includes(info.state) + ) + return { + state: 'busy', + recoveryBlocked, + action: + info.state === 'awaiting_power' + ? 'power' + : info.state === 'usb_interrupted' && info.boot === 'confirmed' + ? 'discard' + : undefined, + detail: + info.state === 'awaiting_power' + ? 'An update is verified. Switch to Pyramid bottom power to finish.' + : 'The device has an update or startup check in progress. Check its status before another update.', + }; + if (!this.usbUpdateEnabled || !this.pin) + return { + state: 'disabled', + recoveryBlocked, + detail: + 'USB updating is awaiting physical qualification. Use firmware updates on the device’s settings page.', + }; + try { + const bundle = await loadUpgrade(this.pin); + if (!newerVersion(bundle.offer.version, info.version)) + return { + state: 'current', + recoveryBlocked, + detail: `Firmware ${info.version} is current for this website’s selected release.`, + }; + this.updateBundle = bundle; + return { state: 'available', offer: bundle.offer, recoveryBlocked }; + } catch (error) { + return { + state: 'failed', + recoveryBlocked, + detail: + error instanceof UpgradeDownloadTimeout + ? error.message + : 'The selected update could not be verified. Installed firmware has not changed.', + }; + } + } + async update(offer: UpgradeOffer, progress: (percent: number) => void): Promise { + const bundle = this.updateBundle; + this.updateBundle = undefined; + if (!this.usbUpdateEnabled || !bundle || JSON.stringify(offer) !== JSON.stringify(bundle.offer)) + throw new Error('Check for updates and review the offered version first.'); + const session = this.current(), + info = await session.firmware(); + if ( + !info.supported || + info.boot !== 'confirmed' || + !['idle', 'current', 'available', 'success', 'rolled_back', 'failed'].includes(info.state) || + !newerVersion(offer.version, info.version) + ) + throw new Error('The speaker is not ready for this update. Check its status.'); + const [begin] = await session.command(true, UPGRADE.begin, [String(bundle.descriptor.length)], { + mutation: true, + }); + const token = begin?.[0]; + const uncertain = () => { + session.uncertain = true; + return new UncertainOutcome(); + }; + if (begin?.length !== 1 || !/^[a-f0-9]{16}$/.test(token ?? '') || token === '0000000000000000') + throw uncertain(); + const send = async (kind: number, bytes: Uint8Array) => { + for (let offset = 0; offset < bytes.length; offset += CHUNK_BYTES) { + await session.writeChunk(token!, kind, offset, bytes.slice(offset, offset + CHUNK_BYTES)); + if (kind === 1) + progress(Math.floor((Math.min(offset + CHUNK_BYTES, bytes.length) * 100) / bytes.length)); + } + }; + await send(0, bundle.descriptor); + const [accepted] = await session.command(true, UPGRADE.verify, [token!], { mutation: true }); + if (accepted?.length !== 2 || accepted[0] !== 'accepted' || accepted[1] !== offer.version) + throw uncertain(); + await send(1, bundle.application); + try { + const [finished] = await session.command(true, UPGRADE.finish, [token!], { mutation: true }); + if (finished?.length !== 2 || finished[0] !== 'verified' || finished[1] !== offer.version) + throw uncertain(); + const after = await session.firmware(); + if (after.state !== 'awaiting_power' || after.offered !== offer.version) throw uncertain(); + } catch { + // Selection may have succeeded even when the reply or status read failed. + throw uncertain(); + } + } private current() { if (!this.session) throw new Error('Connect the device first.'); return this.session; @@ -117,6 +270,7 @@ export class RealService implements InstallerService { return this.current().password(password); } async close() { + this.updateBundle = undefined; this.port = undefined; this.installationCandidate = false; await this.releaseSession(); diff --git a/installer/service.ts b/installer/service.ts index d21573a..c8584ae 100644 --- a/installer/service.ts +++ b/installer/service.ts @@ -1,4 +1,5 @@ import type { DeviceStatus } from './protocol'; +import type { FirmwareInfo, UpgradeCheck, UpgradeOffer } from './upgrade'; export type Connected = { kind: 'unrecognized' } | { kind: 'existing'; status: DeviceStatus }; export interface InstallerService { readonly simulated: boolean; @@ -6,6 +7,10 @@ export interface InstallerService { onDisconnect?: () => void; connect(): Promise; install(confirmed: boolean, progress: (percent: number) => void): Promise; + discardUpdate(): Promise; + firmware(): Promise; + checkUpdate(): Promise; + update(offer: UpgradeOffer, progress: (percent: number) => void): Promise; status(): Promise; scan(): Promise<{ ssid: string; signal: number; secured: boolean }[]>; wifi(ssid: string, password: string): Promise; diff --git a/installer/session.ts b/installer/session.ts index fa659a0..723f0cb 100644 --- a/installer/session.ts +++ b/installer/session.ts @@ -8,6 +8,8 @@ import { type DeviceStatus, type Frame, } from './protocol'; +import { encodeFrame } from './protocol'; +import { parseFirmware, UPGRADE, chunkPayload } from './upgrade'; /** A byte stream; implementations must never log payloads, which can contain passwords. */ export interface ByteTransport { @@ -51,6 +53,12 @@ interface Pending { export class ProvisioningSession { private decoder = new FrameDecoder(); private pending?: Pending; + private chunkPending?: { + expected: Uint8Array; + resolve: () => void; + reject: (error: Error) => void; + timer: ReturnType; + }; private stopped = false; private readonly unlisten: () => void; /** Set after any uncertain write; no further mutation until the session is reopened. */ @@ -67,7 +75,7 @@ export class ProvisioningSession { () => { this.stopped = true; this.fail( - this.pending?.mutation + this.pending?.mutation || this.chunkPending ? new UncertainOutcome() : new Error('Device disconnected. Reconnect the USB data cable.'), ); @@ -76,14 +84,41 @@ export class ProvisioningSession { ); } private fail(error: Error): void { + const chunk = this.chunkPending; + if (chunk) { + clearTimeout(chunk.timer); + this.chunkPending = undefined; + chunk.reject(error); + } const pending = this.pending; - if (!pending) return; if (error instanceof UncertainOutcome) this.uncertain = true; + if (!pending) return; clearTimeout(pending.timer); this.pending = undefined; pending.reject(error); } private receive(frame: Frame): void { + const chunk = this.chunkPending; + if (chunk && frame.extension) { + if (frame.type === 2) { + if (frame.payload.length !== 1) this.fail(new UncertainOutcome()); + else if (frame.payload[0]) this.fail(new DeviceError(frame.payload[0])); + return; + } + if (frame.type === 6) { + if ( + frame.payload.length !== 13 || + !frame.payload.every((b, i) => b === chunk.expected[i]) + ) { + this.fail(new UncertainOutcome()); + return; + } + clearTimeout(chunk.timer); + this.chunkPending = undefined; + chunk.resolve(); + return; + } + } const pending = this.pending; if (!pending || frame.extension !== pending.extension) return; if (frame.type === 2 && frame.payload.length === 1 && frame.payload[0]) { @@ -118,7 +153,8 @@ export class ProvisioningSession { options: { scan?: boolean; mutation?: boolean; timeoutMs?: number } = {}, ): Promise { if (this.stopped) throw new Error('Connect the device first.'); - if (this.pending) throw new Error('Another device action is still running.'); + if (this.pending || this.chunkPending) + throw new Error('Another device action is still running.'); if (options.mutation && this.uncertain) throw new Error('Reconnect and check status before another change.'); const packet = encodeRequest(extension, command, fields); @@ -156,6 +192,29 @@ export class ProvisioningSession { .finally(() => packet.fill(0)); }); } + async firmware() { + const [fields] = await this.command(true, UPGRADE.info, [], { timeoutMs: 5000 }); + return parseFirmware(fields!); + } + async writeChunk(token: string, kind: number, offset: number, bytes: Uint8Array): Promise { + if (this.stopped || this.uncertain || this.pending || this.chunkPending) + throw new Error('Reconnect and check before another update write.'); + const payload = chunkPayload(token, kind, offset, bytes), + expected = payload.slice(0, 13); + new DataView(expected.buffer).setUint32(9, offset + bytes.length, true); + const packet = encodeFrame(true, 5, payload); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => this.fail(new UncertainOutcome()), this.timeout); + const pending = { expected, resolve, reject, timer }; + this.chunkPending = pending; + void this.transport + .write(packet) + .catch(() => { + if (this.chunkPending === pending) this.fail(new UncertainOutcome()); + }) + .finally(() => packet.fill(0)); + }); + } async identify(timeoutMs?: number): Promise { const [fields] = await this.command(false, 3, [], { timeoutMs }); if (fields?.length !== 4 || fields[0] !== 'OpenAthan' || fields[2] !== 'ESP32-S3') { @@ -211,7 +270,11 @@ export class ProvisioningSession { } async close(): Promise { this.stopped = true; - this.fail(this.pending?.mutation ? new UncertainOutcome() : new Error('Connection closed.')); + this.fail( + this.pending?.mutation || this.chunkPending + ? new UncertainOutcome() + : new Error('Connection closed.'), + ); this.unlisten(); this.decoder.reset(); await this.transport.close(); diff --git a/installer/simulator.ts b/installer/simulator.ts index ad1ae78..d7e33d5 100644 --- a/installer/simulator.ts +++ b/installer/simulator.ts @@ -1,6 +1,7 @@ /** Deliberately has no dependency on Web Serial, release files or the flasher. */ import { decodeFields, encodeFrame, FrameDecoder, rpcPayload } from './protocol'; import { ProvisioningSession, type ByteTransport } from './session'; +import type { FirmwareInfo, UpgradeCheck, UpgradeOffer } from './upgrade'; import type { Connected, InstallerService } from './service'; export const scenarios = [ 'success', @@ -15,6 +16,11 @@ export const scenarios = [ 'lost-wifi-ack', 'lost-password-ack', 'flash-failed', + 'update-available', + 'update-current', + 'update-unsupported', + 'update-failed', + 'update-uncertain', ] as const; export type Scenario = (typeof scenarios)[number]; @@ -163,6 +169,55 @@ export class SimulatorService implements InstallerService { } return this.open(false).status(); } + async discardUpdate() {} + async firmware(): Promise { + return { + version: 'v0.4.0', + commit: 'a'.repeat(40), + supported: true, + state: 'idle', + boot: 'confirmed', + result: '', + offered: '', + received: 0, + }; + } + async checkUpdate(): Promise { + if (this.scenario === 'update-current') + return { + state: 'current', + detail: 'The simulated firmware is current.', + recoveryBlocked: false, + }; + if (['update-available', 'update-failed', 'update-uncertain'].includes(this.scenario)) + return { + state: 'available', + recoveryBlocked: false, + offer: { + version: 'v0.5.0', + commit: 'b'.repeat(40), + bytes: 256, + sha256: '0'.repeat(64), + notes: 'https://github.com/OpenAthan-Project/openathan/releases', + }, + }; + return { + state: 'unsupported', + recoveryBlocked: false, + detail: + 'This simulated firmware needs an initial Wi-Fi or maintainer update before USB updates are supported.', + }; + } + async update(_offer: UpgradeOffer, progress: (percent: number) => void) { + for (const n of [15, 40, 75, 100]) { + await new Promise((resolve) => setTimeout(resolve, 100)); + progress(n); + if (n === 40 && this.scenario === 'update-failed') + throw new Error('Simulated update rejected; reconnect and check.'); + if (n === 75 && this.scenario === 'update-uncertain') + throw new (await import('./session')).UncertainOutcome(); + } + } private current() { if (!this.session) throw new Error('Connect the simulated device first.'); return this.session; diff --git a/installer/ui.ts b/installer/ui.ts index 878c3d0..740e331 100644 --- a/installer/ui.ts +++ b/installer/ui.ts @@ -1,6 +1,7 @@ import { validatePassword, validateWifi, type DeviceStatus } from './protocol'; import { DeviceError, UncertainOutcome } from './session'; import type { InstallerService } from './service'; +import type { UpgradeCheck, UpgradeOffer } from './upgrade'; const escape = (value: string) => value.replace( @@ -8,8 +9,8 @@ const escape = (value: string) => (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!, ); -const button = (label: string, action: string, secondary = false) => - ``; +const button = (label: string, action: string, secondary = false, disabled = false) => + ``; /** Shared presentation only. The service is injected by separate real/demo entrypoints. */ export function mountInstaller( @@ -24,8 +25,14 @@ export function mountInstaller( status: DeviceStatus | undefined, busy = false, current = 0; + let upgradeCheck: UpgradeCheck | undefined, + offered: UpgradeOffer | undefined, + installed = false, + updateWritten = false; + let operation: 'wifi' | 'password' | 'update' | 'none' = 'none'; let notice = '', mutationBlocked = false, + recoveryBlocked = false, initialized = false; const notify = (text: string, error = false) => { message.textContent = text; @@ -33,19 +40,24 @@ export function mountInstaller( message.classList.toggle('error', error); message.classList.remove('uncertain'); }; - function render(title: string, body: string, step: number, entry = false) { + function render(title: string, body: string, step: number, _entry = false) { current = step; root.dataset.view = 'flow'; - context.hidden = entry; + context.hidden = true; context.innerHTML = `

${path === 'recovery' ? 'Connected speaker' : 'New installation'}${service.simulated ? ' · simulated device' : ''}

${path === 'recovery' ? 'Prayer settings and playback history are preserved.' : 'Set up Wi-Fi and a password before opening your device’s settings.'}

`; - const heading = entry ? 'h2' : 'h3'; + const heading = 'h2'; panel.innerHTML = `<${heading} tabindex="-1" class="step-title">${title}${body}`; if (initialized) panel.querySelector('.step-title')?.focus(); } function connectionScreen() { status = undefined; + upgradeCheck = undefined; + offered = undefined; + installed = false; + updateWritten = false; path = 'recovery'; mutationBlocked = false; + recoveryBlocked = false; notify(''); const unavailable = '
New installation is unavailable.

You can still connect an existing OpenAthan to open its settings or fix Wi-Fi and its device password.

'; @@ -71,7 +83,7 @@ export function mountInstaller( const available = service.installAvailable; render( available ? 'Confirm a new installation' : 'New installation is unavailable', - `

OpenAthan was not recognized. This does not prove the device is new or empty. If you expected an existing OpenAthan, check the cable and retry before considering installation.

${available ? '
This erases the device.

Existing firmware, saved Wi-Fi, prayer settings and prayer history will be replaced. This is not an update or a password recovery.

' : '

No installation release is available. Check the connection or follow the troubleshooting instructions.

'}
${available ? '' : ''}${button('Retry connection', 'start', true)}${button('Cancel', 'start', true)}Troubleshooting
`, + `

OpenAthan was not recognized. This does not prove the device is new or empty. If you expected an existing OpenAthan, check the cable and retry before considering installation.

${available ? '
This erases the device.

Existing firmware, saved Wi-Fi, prayer settings and prayer history will be replaced. This is not an update or a password recovery.

' : '

No installation release is available. Check the connection or follow the troubleshooting instructions.

'}
${available ? '' : ''}${button('Back to connection', 'start', true)}Troubleshooting
`, 0, ); } @@ -116,46 +128,135 @@ export function mountInstaller( } function recoveryMenu() { if (storageFault()) return; + const wifiMissing = status?.wifi !== '4', + passwordMissing = status?.password === 'absent'; + const row = (title: string, detail: string, label = '', action = '', disabled = false) => + `

${title}

${detail}

${action ? button(label, action, true, disabled) : ''}
`; + const firmwareText = + upgradeCheck?.state === 'available' + ? `${escape(upgradeCheck.offer.version)} is available.` + : ((upgradeCheck && 'detail' in upgradeCheck ? upgradeCheck.detail : undefined) ?? + 'Check for a compatible firmware update.'); + const resolution = upgradeCheck?.state === 'busy' ? upgradeCheck.action : undefined; + const primary = recoveryBlocked + ? upgradeCheck?.state === 'failed' + ? button('Reconnect and check', 'start') + : resolution === 'power' + ? button('Finish power handoff', 'update-handoff') + : resolution === 'discard' + ? button('Discard incomplete transfer', 'discard-update') + : button('Check update status', 'check-update') + : wifiMissing + ? button('Connect Wi-Fi', 'wifi-screen') + : passwordMissing + ? button('Create device password', 'password-screen') + : button('Continue to device settings', 'finish'); render( 'Your OpenAthan is connected', - `

${escape(status?.hostname ?? '')}

Open your speaker’s settings or choose a change. Your prayer settings and playback history will stay on the device.

${status?.wifi !== '4' ? '

Wi-Fi is not connected. If the speaker is reconnecting, wait a moment and select Refresh status.

' : ''}
${status?.wifi === '4' && status.password === 'ready' ? button('Open device settings', 'finish') : ''}${button('Change Wi-Fi', 'wifi-screen', status?.wifi === '4' && status.password === 'ready')}${button(status?.password === 'absent' ? 'Create device password' : 'Reset device password', 'password-screen', true)}
${button('Refresh status', 'refresh-status', true)}${button('Disconnect', 'start', true)}
`, + `

${escape(status?.hostname ?? '')}

${wifiMissing ? 'Wi-Fi is not connected.' : 'Wi-Fi is connected.'} ${passwordMissing ? 'A device password is needed.' : 'Your device password is set.'}

+

Compatible firmware updates preserve settings, prayer history and recordings. Recovery changes only the Wi-Fi or password you choose.

+
${primary}${!recoveryBlocked && wifiMissing ? button('Refresh status', 'refresh-status', true) : ''}
+ ${recoveryBlocked ? row('Firmware', firmwareText) : row('Firmware', firmwareText, upgradeCheck?.state === 'available' ? 'Review update' : 'Check for updates', upgradeCheck?.state === 'available' ? 'review-update' : 'check-update')} + ${recoveryBlocked ? '

Wi-Fi and password recovery must wait until the pending USB update is resolved. Check update status again after the device starts.

' : ''} +
Wi-Fi and password recovery
+ ${recoveryBlocked || !wifiMissing ? row('Wi-Fi', 'Connect to a different home network.', wifiMissing ? 'Connect Wi-Fi' : 'Change Wi-Fi', 'wifi-screen', recoveryBlocked) : ''} + ${recoveryBlocked || !passwordMissing || wifiMissing ? row('Device password', 'Recover access to your speaker’s settings.', passwordMissing ? 'Create device password' : 'Reset device password', 'password-screen', recoveryBlocked) : ''} + ${recoveryBlocked || !wifiMissing ? row('Connection status', 'Read the speaker’s latest connection status.', 'Refresh status', 'refresh-status') : ''}
+
${button('Disconnect', 'start', true)}Connection help
`, + 1, + ); + } + async function checkUpdate() { + render( + 'Checking firmware', + '

Reading the speaker’s update support and the selected release. Installed firmware is unchanged.

', + 1, + ); + upgradeCheck = await service.checkUpdate(); + // Provisioning status and unreadable INFO cannot release known USB ownership. + if (upgradeCheck.recoveryBlocked !== undefined) recoveryBlocked = upgradeCheck.recoveryBlocked; + offered = upgradeCheck.state === 'available' ? upgradeCheck.offer : undefined; + recoveryMenu(); + } + function reviewUpdate() { + if (!offered || mutationBlocked || recoveryBlocked) return; + render( + 'Review firmware update', + `

Install ${escape(offered.version)} over USB.

Your Wi-Fi, device password, prayer settings, playback history and recordings stay on the speaker. The current application is retained for startup rollback.

Read the release notes

Keep Atom USB connected during transfer. After verification, you’ll switch to Pyramid bottom power.

${button('Install update', 'update')}${button('Back', 'recovery', true)}
`, 1, ); } function wifiScreen() { if (storageFault()) return; + if (blockRecovery()) return; render( 'Connect to your Wi-Fi', - `

Choose your home’s 2.4 GHz network, or enter its name. Your computer and phone will need to be on the same home network.

${button('Find networks', 'scan', true)}

Use your router’s Wi-Fi password. A 64-character key must contain only 0–9 and A–F (upper or lower case).

WPA2/WPA3 personal networks. Guest portals and enterprise sign-ins are not supported.

${button('Back', path === 'recovery' ? 'recovery' : 'start', true)}Troubleshooting
`, + `

Choose your home’s 2.4 GHz network, or enter its name. Your computer and phone will need to be on the same home network.

${button('Find networks', 'scan', true)}

Use your router’s Wi-Fi password. A 64-character key must contain only 0–9 and A–F (upper or lower case).

WPA2/WPA3 personal networks. Guest portals and enterprise sign-ins are not supported.

${button('Back', installed || path === 'recovery' ? 'recovery' : 'start', true)}Troubleshooting
`, 1, ); } function passwordScreen() { if (storageFault()) return; + if (blockRecovery()) return; render( status?.password === 'ready' ? 'Choose a new device password' : 'Create a device password', `

This protects the settings page on your home network. You’ll sign in as admin.

12–128 characters. Use English letters, numbers, spaces or punctuation. Choose a long, unique passphrase.

${button('Back', path === 'recovery' ? 'recovery' : 'wifi-screen', true)}

${status?.password === 'ready' ? 'Resetting signs out existing sessions. Your prayer settings are preserved.' : 'Remember this password. You’ll enter it on the device’s settings page.'}

`, 2, ); } + function blockRecovery(): boolean { + if (!recoveryBlocked) return false; + recoveryMenu(); + notify('Resolve the pending USB update before changing Wi-Fi or the device password.'); + return true; + } + function renderHandoff(device: DeviceStatus) { + const passwordAbsent = updateWritten && device.password === 'absent'; + const wifiDisconnected = updateWritten && device.wifi !== '4'; + const needsRecovery = passwordAbsent || wifiDisconnected; + const urls = needsRecovery ? [] : device.urls; + const recoveryGuidance = needsRecovery + ? `
${passwordAbsent ? '

Your device password still needs to be created through USB setup.

' : ''}${wifiDisconnected ? '

The speaker may reconnect using its saved Wi-Fi settings. If it stays offline, check or change Wi-Fi through USB setup.

' : ''}

For USB recovery after startup:

  1. After the speaker starts, unplug the Pyramid’s bottom power cable before connecting the Atom’s USB-C port to your computer.
  2. Select Back to connection, reconnect, then choose Check for updates.
  3. Follow any remaining update instructions before changing Wi-Fi or creating a device password.
  4. After recovery, unplug Atom USB and return to Pyramid bottom-only power, then open the device’s settings.
` + : ''; + render( + updateWritten + ? 'Update written and verified' + : path === 'install' + ? 'Ready for device setup' + : 'Continue on speaker power', + `

${updateWritten ? 'The signed application was verified and selected. Switch to normal speaker power so it can start and complete its health checks.' : path === 'install' ? 'Wi-Fi and your device password are saved. Finish your prayer settings on the device.' : 'Your device is ready to open. Its prayer settings and history were not reset.'}

  1. Unplug the Atom’s USB cable.
  2. Connect only the Pyramid’s bottom USB-C power.
  3. ${needsRecovery ? 'Let the speaker start and complete its startup checks.' : 'Wait for it to reconnect, then open its settings on the same home network.'}
${recoveryGuidance}${urls.length && !service.simulated ? '

Device links open in a new tab.

' : ''}

${needsRecovery ? '' : 'Sign in as admin with the password you chose. '}${path === 'install' && !updateWritten ? 'Review your location, timezone and timetable, then select Finish setup.' : updateWritten ? 'Check the firmware version and update result on the device’s settings page. Startup success is confirmed there; an interrupted connection alone cannot establish success or rollback.' : 'Continue with your saved prayer settings.'} Athan playback waits until the device has synchronized its clock.

${!urls.length && !needsRecovery ? '

A device address was not returned. Reconnect through USB setup to read it again.

' : ''}
${button('Back to connection', 'start', true)}Setup instructions
`, + 3, + ); + } async function finish() { - if (!(await readCurrentStatus()) || !status) return; - if (status.wifi !== '4') { + if (!updateWritten && blockRecovery()) return; + // A verified boot selection already requires power handoff. Credential + // readback must not hide that instruction or imply the transfer failed. + if ((!updateWritten && !(await readCurrentStatus())) || !status) return; + if (!updateWritten && status.wifi !== '4') { wifiScreen(); notify('Connect Wi-Fi to open the device’s settings.'); return; } - if (status.password !== 'ready') { + if (!updateWritten && status.password !== 'ready') { passwordScreen(); return; } - await service.close(); - const urls = status.urls; - render( - 'Ready for device setup', - `

${path === 'install' ? 'Wi-Fi and your device password are saved. Finish your prayer settings on the device.' : 'Your device is ready to open. Its prayer settings and history were not reset.'}

  1. Unplug the Atom’s USB cable.
  2. Connect only the Pyramid’s bottom USB-C power.
  3. Wait for it to reconnect, then open its settings on the same home network.
${urls.length && !service.simulated ? '

Device links open in a new tab.

' : ''}

Sign in as admin with the password you chose. Review your location, timezone and timetable, then select Finish setup. Athan playback waits until the device has synchronized its clock.

${!urls.length ? '

A device address was not returned. Reconnect through USB setup to read it again.

' : ''}
${button('Done', 'start', true)}Setup instructions
`, - 3, - ); + if (updateWritten) { + // Verification has completed; cleanup and queued disconnects cannot undo it. + notice = ''; + renderHandoff(status); + try { + await service.close(); + } catch { + notify( + 'The browser could not close the USB connection. Unplug the Atom’s USB cable and follow the power steps below.', + ); + } + } else { + await service.close(); + renderHandoff(status); + } } async function attempt(action: () => Promise) { if (busy) return; @@ -180,14 +281,19 @@ export function mountInstaller( mutationBlocked = true; let detail = ''; try { - status = await service.status(); - detail = ` Current device status: Wi-Fi ${status.wifi === '4' ? 'connected' : 'not connected'}, password revision ${status.passwordRevision}. This does not confirm the requested change.`; + if (operation === 'update') { + const info = await service.firmware(); + detail = ` Current firmware: ${info.version}; update state: ${info.state}. Startup success still requires the device’s health checks.`; + } else { + status = await service.status(); + detail = ` Current device status: Wi-Fi ${status.wifi === '4' ? 'connected' : 'not connected'}, password revision ${status.passwordRevision}. This does not confirm the requested change.`; + } } catch { detail = ' Status is unavailable.'; } render( 'Check before trying again', - `

The device may have saved the change before the connection was interrupted. It was not sent again.

${escape(detail)}

Reconnect and check your device. For an uncertain Wi-Fi change, a connected status may refer to the previous network.

${button('Disconnect and start again', 'start')}Troubleshooting
`, + `

The device may have completed the requested operation before the connection was interrupted. It was not sent again.

${escape(detail)}

${operation === 'update' ? 'Reconnect and read firmware status before another transfer. Switch to speaker power only when the device reports a verified handoff.' : operation === 'wifi' ? 'Reconnect and check your device. A connected status may refer to the previous network.' : 'Reconnect and read the password revision before another change.'}

${button('Disconnect and start again', 'start')}Troubleshooting
`, current, ); notify('The result is uncertain. No automatic retry was made.', true); @@ -211,7 +317,7 @@ export function mountInstaller( busy = false; root.removeAttribute('aria-busy'); if (notice) { - if (!handledUncertain) { + if (!handledUncertain && !updateWritten) { render( 'Connection lost', '

Your device is no longer connected.

' + button('Start again', 'start'), @@ -224,6 +330,10 @@ export function mountInstaller( } } service.onDisconnect = () => { + if (updateWritten) { + notice = ''; + return; + } mutationBlocked = true; notice = 'The device disconnected. Reconnect the USB data cable and start again.'; if (!busy) { @@ -282,6 +392,7 @@ export function mountInstaller( panel.querySelector('[data-progress]')!.textContent = `${percent}% · ${percent === 100 ? 'Verifying…' : 'Writing firmware…'}`; }); + installed = true; wifiScreen(); } catch (error) { render( @@ -299,14 +410,69 @@ export function mountInstaller( notify( status?.wifi === '4' ? 'Wi-Fi is connected.' - : 'Wi-Fi is still not connected. Wait a moment and refresh again, or choose Change Wi-Fi.', + : recoveryBlocked + ? 'Wi-Fi is still not connected. Resolve the pending USB update before changing Wi-Fi.' + : 'Wi-Fi is still not connected. Wait a moment and refresh again, or choose Change Wi-Fi.', ); } } else if (action === 'recovery') recoveryMenu(); else if (action === 'wifi-screen') wifiScreen(); else if (action === 'password-screen') passwordScreen(); else if (action === 'finish') await finish(); - else if (action === 'scan') { + else if (action === 'check-update') await checkUpdate(); + else if (action === 'review-update') reviewUpdate(); + else if (action === 'update-handoff') { + if (mutationBlocked || upgradeCheck?.state !== 'busy' || upgradeCheck.action !== 'power') + return; + updateWritten = true; + await finish(); + } else if (action === 'discard-update') { + if (mutationBlocked || upgradeCheck?.state !== 'busy' || upgradeCheck.action !== 'discard') + return; + operation = 'update'; + try { + await service.discardUpdate(); + } catch (error) { + if (!(error instanceof UncertainOutcome)) + render( + 'Update needs attention', + '

The incomplete transfer could not be discarded. Reconnect and read firmware status before another attempt.

' + + button('Reconnect and check', 'start') + + '

Update help

', + 1, + ); + throw error; + } + await checkUpdate(); + } else if (action === 'update' && offered && !mutationBlocked && !recoveryBlocked) { + operation = 'update'; + const selected = offered; + render( + 'Updating firmware', + '

Keep Atom USB connected while the application is written and verified. You will switch to speaker power afterwards.

Preparing signed update…

', + 1, + ); + try { + await service.update(selected, (percent) => { + panel.querySelector('progress')!.value = percent; + panel.querySelector('[data-progress]')!.textContent = + `${percent}% · ${percent === 100 ? 'Verifying…' : 'Writing application…'}`; + }); + } catch (error) { + if (!(error instanceof UncertainOutcome)) + render( + 'Update needs attention', + '

The update did not complete. Reconnect and read firmware status before another attempt.

' + + button('Reconnect and check', 'start') + + '

Update help

', + 1, + ); + throw error; + } + updateWritten = true; + await finish(); + } else if (action === 'scan') { + if (mutationBlocked || blockRecovery()) return; const networks = await service.scan(); const container = panel.querySelector('[data-networks]')!; container.innerHTML = networks.length @@ -319,6 +485,7 @@ export function mountInstaller( root.addEventListener('submit', (event) => { event.preventDefault(); if (busy || mutationBlocked) return; + if (blockRecovery()) return; const form = event.target as HTMLFormElement; const kind = form.dataset.form; const ssid = form.querySelector('[name="ssid"]')?.value ?? ''; @@ -339,6 +506,7 @@ export function mountInstaller( form.querySelectorAll('input[type="password"]').forEach((input) => { input.value = ''; }); + operation = kind === 'wifi' ? 'wifi' : 'password'; void attempt(async () => { let acknowledged = false; try { diff --git a/installer/upgrade.ts b/installer/upgrade.ts new file mode 100644 index 0000000..dca4772 --- /dev/null +++ b/installer/upgrade.ts @@ -0,0 +1,301 @@ +/** Signed application-only updates. Does not import the destructive flasher. */ +import { + HARDWARE, + LAYOUT, + checkedManifest, + sha256, + type ReleasePin, + type ReleaseManifest, +} from './release.ts'; +export interface FirmwareInfo { + version: string; + commit: string; + supported: boolean; + state: string; + boot: 'pending' | 'confirmed'; + result: string; + offered: string; + received: number; +} +export interface UpgradeOffer { + version: string; + commit: string; + bytes: number; + sha256: string; + notes: string; +} +export interface UpgradeBundle { + offer: UpgradeOffer; + descriptor: Uint8Array; + application: Uint8Array; +} +export type UpgradeCheck = ( + | { state: 'available'; offer: UpgradeOffer } + | { + state: 'current' | 'unsupported' | 'disabled' | 'busy' | 'failed'; + detail: string; + action?: 'power' | 'discard'; + } +) & { + /** USB ownership from firmware INFO; omitted when that read failed. */ + recoveryBlocked?: boolean; +}; +export const UPGRADE = { + info: 0x10, + begin: 0x11, + verify: 0x12, + finish: 0x13, + abort: 0x14, +} as const; +export const CHUNK_BYTES = 242; +export class UpgradeDownloadTimeout extends Error { + constructor() { + super( + 'The firmware download timed out. Check your internet connection and choose Check for updates again. Installed firmware has not changed.', + ); + this.name = 'UpgradeDownloadTimeout'; + } +} +// Matches openathan release/upgrade-public-key.pem. Rotation requires coordinated review. +const PUBLIC_KEY = + 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEVwaZCDVSDHpSxg6B7l0MolCO54GMIWd8IwsVIXiHphmRuegj7AM2UHZyfwDFYwh3KChTzIOh1QvpjPZtfb8siw=='; +const stable = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +export function newerVersion(offered: string, installed: string): boolean { + if (!stable.test(offered) || !stable.test(installed)) + throw new Error('Unsupported firmware version.'); + const a = offered.slice(1).split('.').map(BigInt), + b = installed.slice(1).split('.').map(BigInt); + if ([...a, ...b].some((n) => n > 0xffffffffn)) throw new Error('Unsupported firmware version.'); + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) return a[i]! > b[i]!; + } + return false; +} +export function parseFirmware(fields: string[]): FirmwareInfo { + const [protocol, version, commit, support, state, boot, result, offered, received] = fields; + if ( + fields.length !== 9 || + protocol !== '1' || + !stable.test(version ?? '') || + !/^(?:[a-f0-9]{40}|development)$/.test(commit ?? '') || + !['supported', 'unsupported'].includes(support ?? '') || + ![ + 'idle', + 'checking', + 'available', + 'current', + 'queued', + 'downloading', + 'verifying', + 'restarting', + 'failed', + 'storage_fault', + 'success', + 'rolled_back', + 'usb_descriptor', + 'usb_receiving', + 'usb_interrupted', + 'usb_selection_uncertain', + 'awaiting_power', + ].includes(state ?? '') || + !['pending', 'confirmed'].includes(boot ?? '') || + !['', 'success', 'superseded', 'rolled_back'].includes(result ?? '') || + (offered !== '' && !stable.test(offered ?? '')) || + !/^\d+$/.test(received ?? '') || + Number(received) > 1572864 + ) + throw new Error('Unsupported USB firmware information.'); + return { + version: version!, + commit: commit!, + supported: support === 'supported', + state: state!, + boot: boot as FirmwareInfo['boot'], + result: result!, + offered: offered!, + received: Number(received), + }; +} +/** Strict DER ECDSA to WebCrypto's fixed-width P-256 r || s representation. */ +export function signatureBytes(hex: string): Uint8Array { + if (!/^[a-f0-9]{128,144}$/.test(hex) || hex.length % 2) + throw new Error('Invalid update signature.'); + const der = Uint8Array.from(hex.match(/../g)!, (b) => parseInt(b, 16)); + if (der[0] !== 0x30 || der[1] !== der.length - 2) throw new Error('Invalid update signature.'); + const out = new Uint8Array(64); + let at = 2; + for (let i = 0; i < 2; i++) { + if (der[at++] !== 2) throw new Error('Invalid update signature.'); + const length = der[at++]!; + let part = der.slice(at, at + length); + at += length; + if ( + !length || + length > 33 || + part.length !== length || + part[0]! & 128 || + (length > 1 && part[0] === 0 && !(part[1]! & 128)) + ) + throw new Error('Invalid update signature.'); + if (part[0] === 0) part = part.slice(1); + if (part.length > 32) throw new Error('Invalid update signature.'); + out.set(part, (i + 1) * 32 - part.length); + } + if (at !== der.length) throw new Error('Invalid update signature.'); + return out; +} +export async function verifyDescriptor( + bytes: Uint8Array, + manifest: ReleaseManifest, + keyBase64 = PUBLIC_KEY, +): Promise { + if (!bytes.length || bytes.length > 8192) throw new Error('Invalid update descriptor.'); + const wrapper = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + if ( + !wrapper || + Object.keys(wrapper).sort().join() !== 'payload,signature' || + typeof wrapper.payload !== 'string' || + typeof wrapper.signature !== 'string' + ) + throw new Error('Invalid update descriptor.'); + const payload = new TextEncoder().encode(wrapper.payload); + if (!payload.length || payload.length > 4096) throw new Error('Invalid update payload.'); + const key = await crypto.subtle.importKey( + 'spki', + Uint8Array.from(atob(keyBase64), (c) => c.charCodeAt(0)), + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['verify'], + ); + if ( + !(await crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + new Uint8Array(signatureBytes(wrapper.signature)), + payload, + )) + ) + throw new Error('Update signature verification failed.'); + const p = JSON.parse(wrapper.payload); + if ( + !p || + Object.keys(p).length !== 10 || + p.schema !== 1 || + p.version !== manifest.tag || + p.commit !== manifest.commit || + !stable.test(p.version) || + p.hardware !== HARDWARE || + p.layout !== LAYOUT || + p.storageFormat !== 1 || + p.audioFormat !== 1 || + p.rollback !== true || + !Number.isInteger(p.bytes) || + p.bytes < 256 || + p.bytes > 1572864 || + !/^[a-f0-9]{64}$/.test(p.sha256) + ) + throw new Error('Incompatible firmware update.'); + newerVersion(p.version, p.version); + return { + version: p.version, + commit: p.commit, + bytes: p.bytes, + sha256: p.sha256, + notes: `https://github.com/OpenAthan-Project/openathan/releases/tag/${p.version}`, + }; +} +export async function verifyApplication( + offer: UpgradeOffer, + application: Uint8Array, +): Promise { + if ( + application.length !== offer.bytes || + (await sha256(application)) !== offer.sha256 || + application[0] !== 0xe9 || + new DataView(application.buffer, application.byteOffset, application.byteLength).getUint16( + 12, + true, + ) !== 9 || + new TextDecoder().decode(application.slice(48, 80)).replace(/\0.*$/, '') !== offer.version + ) + throw new Error('Firmware image verification failed.'); +} +async function bounded(url: string, maximum: number): Promise { + const controller = new AbortController(); + // One deadline includes response headers and the entire body, regardless of progress. + const timer = setTimeout(() => controller.abort(new UpgradeDownloadTimeout()), 60_000); + let reader: ReadableStreamDefaultReader | undefined; + try { + const response = await fetch(url, { + credentials: 'omit', + cache: 'no-store', + redirect: 'error', + signal: controller.signal, + }); + if (!response.ok || !response.body) { + void response.body?.cancel().catch(() => undefined); + throw new Error('Could not load the approved update.'); + } + reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const { done, value } = await reader.read(); + controller.signal.throwIfAborted(); + if (done) break; + size += value.length; + if (size > maximum) throw new Error('Oversized update download.'); + chunks.push(value); + } + const out = new Uint8Array(size); + let at = 0; + for (const chunk of chunks) { + out.set(chunk, at); + at += chunk.length; + } + return out; + } catch (error) { + // Cancellation closes the reader immediately; pending network cleanup must not + // prevent the installer from restoring recovery controls after a timeout. + void reader?.cancel().catch(() => undefined); + if (controller.signal.aborted) throw new UpgradeDownloadTimeout(); + throw error; + } finally { + clearTimeout(timer); + reader?.releaseLock(); + } +} +export async function loadUpgrade(pin: ReleasePin): Promise { + const base = `/releases/${pin.tag}/`; + const manifest = await checkedManifest(await bounded(base + 'manifest.json', 16384), pin); + const descriptor = await bounded(base + 'upgrade.json', 8192), + offer = await verifyDescriptor(descriptor, manifest); + const application = await bounded(base + 'firmware.ota.bin', offer.bytes); + await verifyApplication(offer, application); + return { offer, descriptor, application }; +} +export function chunkPayload( + token: string, + kind: number, + offset: number, + bytes: Uint8Array, +): Uint8Array { + if ( + !/^[a-f0-9]{16}$/.test(token) || + BigInt('0x' + token) === 0n || + ![0, 1].includes(kind) || + !Number.isInteger(offset) || + offset < 0 || + offset > 1572864 || + !bytes.length || + bytes.length > CHUNK_BYTES + ) + throw new Error('Invalid update chunk.'); + const out = new Uint8Array(13 + bytes.length), + view = new DataView(out.buffer); + view.setBigUint64(0, BigInt('0x' + token), true); + out[8] = kind; + view.setUint32(9, offset, true); + out.set(bytes, 13); + return out; +} diff --git a/playwright.config.ts b/playwright.config.ts index 59efc19..aef8e30 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,7 +3,7 @@ const port = Number(process.env.OPENATHAN_BROWSER_TEST_PORT ?? 4321); const built = process.env.OPENATHAN_BROWSER_TEST_BUILT === '1'; export default defineConfig({ testDir: './tests/browser', - testIgnore: built ? ['**/installer.spec.ts'] : [], + testIgnore: built ? ['**/installer.spec.ts', '**/usb-update.spec.ts'] : [], fullyParallel: true, forbidOnly: !!process.env.CI, retries: 0, diff --git a/site/pages/preview/[page].astro b/site/pages/preview/[page].astro index 74f547d..df5bc95 100644 --- a/site/pages/preview/[page].astro +++ b/site/pages/preview/[page].astro @@ -44,6 +44,11 @@ const isInstaller = Astro.params.page === 'installer'; + + + + + diff --git a/site/release-selection.ts b/site/release-selection.ts index b6c32f0..baf8ed0 100644 --- a/site/release-selection.ts +++ b/site/release-selection.ts @@ -12,6 +12,9 @@ export async function siteSelection(): Promise { return { schema: 1, automatic: policy.automatic, + ...(policy.usbUpdateEnabled === undefined + ? {} + : { usbUpdateEnabled: policy.usbUpdateEnabled }), policySha256, websiteCommit: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), release: policy.pin, diff --git a/site/styles.css b/site/styles.css index cea83b3..57ee325 100644 --- a/site/styles.css +++ b/site/styles.css @@ -927,3 +927,60 @@ progress { background: var(--panel); border-color: var(--field-border); } + +/* USB task hierarchy follows the approved summary and native recovery disclosure. */ +.task-row { + display: flex; + gap: 24px; + align-items: center; + justify-content: space-between; + padding: 20px 0; + border-top: 1px solid var(--line); +} +.task-row h3 { + margin: 0 0 4px; + font-size: 1.35rem; +} +.task-row p { + margin: 0; +} +.task-row .button, +.field-heading .button { + font-size: 1rem; + padding: 8px 16px; + flex-shrink: 0; +} +.recovery-disclosure { + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + margin: 12px 0 20px; +} +.recovery-disclosure summary { + padding: 18px 0; + font-weight: 700; + cursor: pointer; +} +.recovery-tasks { + padding-bottom: 12px; +} +.field-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 16px; +} +.quiet-actions .button { + font-size: 1rem; + padding: 8px 16px; +} +@media (max-width: 600px) { + .task-row { + align-items: flex-start; + flex-direction: column; + gap: 12px; + } + .field-heading { + flex-wrap: wrap; + } +} diff --git a/tests/browser/fixtures/serial-device.ts b/tests/browser/fixtures/serial-device.ts index f09a3b3..15d7b52 100644 --- a/tests/browser/fixtures/serial-device.ts +++ b/tests/browser/fixtures/serial-device.ts @@ -93,6 +93,8 @@ export async function fakeSerialDevice( 'ESP32-S3', 'openathan-test.local', ]); + } else if (extension && command === 0x10) { + frame(true, 2, new Uint8Array([2])); } else if (extension && command === 1) { reply( true, diff --git a/tests/browser/installer.spec.ts b/tests/browser/installer.spec.ts index b268cfb..5a3d373 100644 --- a/tests/browser/installer.spec.ts +++ b/tests/browser/installer.spec.ts @@ -83,16 +83,20 @@ test('recovery changes Wi-Fi and password without showing install or erase actio await connect(page); await expect(page.getByRole('heading', { name: 'Your OpenAthan is connected' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Install OpenAthan', exact: true })).toHaveCount(0); + if (!(await page.locator('.recovery-disclosure').evaluate((el) => el.hasAttribute('open')))) + await page.locator('.recovery-disclosure summary').click(); await page.getByRole('button', { name: 'Change Wi-Fi' }).click(); await saveWifi(page); await expect(page.getByRole('status')).toHaveText('Wi-Fi saved.'); + if (!(await page.locator('.recovery-disclosure').evaluate((el) => el.hasAttribute('open')))) + await page.locator('.recovery-disclosure summary').click(); await page.getByRole('button', { name: 'Reset device password' }).click(); await page.getByLabel('Device password', { exact: true }).fill('another made-up password'); await page.getByLabel('Repeat password').fill('another made-up password'); await page.getByRole('button', { name: 'Save device password' }).click(); await expect(page.getByRole('status')).toContainText('Device password saved.'); - await page.getByRole('button', { name: 'Open device settings', exact: true }).click(); - await expect(page.getByRole('heading', { name: 'Ready for device setup' })).toBeVisible(); + await page.getByRole('button', { name: 'Continue to device settings', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Continue on speaker power' })).toBeVisible(); }); for (const [scenario, message] of [ @@ -117,6 +121,8 @@ test('wrong Wi-Fi password shows retained credentials and clears entered secret' }) => { await demo(page, 'wifi-failed'); await connect(page); + if (!(await page.locator('.recovery-disclosure').evaluate((el) => el.hasAttribute('open')))) + await page.locator('.recovery-disclosure summary').click(); await page.getByRole('button', { name: 'Change Wi-Fi' }).click(); await saveWifi(page); await expect(page.getByRole('status')).toContainText('Previous saved credentials were retained'); @@ -125,6 +131,8 @@ test('wrong Wi-Fi password shows retained credentials and clears entered secret' test('disconnect leaves actionable guidance and no further mutation', async ({ page }) => { await demo(page, 'disconnect'); await connect(page); + if (!(await page.locator('.recovery-disclosure').evaluate((el) => el.hasAttribute('open')))) + await page.locator('.recovery-disclosure summary').click(); await page.getByRole('button', { name: 'Change Wi-Fi' }).click(); await page.getByRole('button', { name: 'Find networks' }).click(); await expect(page.getByRole('status')).toContainText('device disconnected'); @@ -141,6 +149,8 @@ test('lost Wi-Fi acknowledgement remains unconfirmed despite connected status', }) => { await demo(page, 'lost-wifi-ack'); await connect(page); + if (!(await page.locator('.recovery-disclosure').evaluate((el) => el.hasAttribute('open')))) + await page.locator('.recovery-disclosure summary').click(); await page.getByRole('button', { name: 'Change Wi-Fi' }).click(); await saveWifi(page); await expect(page.getByRole('heading', { name: 'Check before trying again' })).toBeVisible(); @@ -152,6 +162,8 @@ test('password validation and lost acknowledgement do not silently repeat writes }) => { await demo(page, 'lost-password-ack'); await connect(page); + if (!(await page.locator('.recovery-disclosure').evaluate((el) => el.hasAttribute('open')))) + await page.locator('.recovery-disclosure summary').click(); await page.getByRole('button', { name: 'Reset device password' }).click(); await page.getByLabel('Device password', { exact: true }).fill('long demo password'); await page.getByLabel('Repeat password').fill('mismatched password'); @@ -201,7 +213,7 @@ test('installer layout fits the viewport and moves focus with the step', async ( expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); }); -for (const action of ['Cancel', 'Retry connection']) { +for (const action of ['Back to connection']) { test(`unrecognized firmware requires explicit erasure and supports ${action.toLowerCase()}`, async ({ page, }) => { diff --git a/tests/browser/recovery-status.spec.ts b/tests/browser/recovery-status.spec.ts index 8fc32eb..b1a78aa 100644 --- a/tests/browser/recovery-status.spec.ts +++ b/tests/browser/recovery-status.spec.ts @@ -8,11 +8,15 @@ async function connect(page: Page) { } async function save(page: Page, kind: 'wifi' | 'password') { if (kind === 'wifi') { + if (!(await page.locator('.recovery-disclosure').evaluate((el) => el.hasAttribute('open')))) + await page.locator('.recovery-disclosure summary').click(); await page.getByRole('button', { name: 'Change Wi-Fi' }).click(); await page.getByLabel('Network name', { exact: true }).fill('Test network'); await page.getByLabel('Wi-Fi password', { exact: true }).fill('made-up wifi password'); await page.getByRole('button', { name: 'Save Wi-Fi', exact: true }).click(); } else { + if (!(await page.locator('.recovery-disclosure').evaluate((el) => el.hasAttribute('open')))) + await page.locator('.recovery-disclosure summary').click(); await page.getByRole('button', { name: 'Reset device password' }).click(); await page.getByLabel('Device password', { exact: true }).fill('made-up device password'); await page.getByLabel('Repeat password').fill('made-up device password'); @@ -53,6 +57,7 @@ for (const kind of ['wifi', 'password'] as const) { await expect(page.getByRole('status')).toContainText('storage is unavailable'); await expect(page.locator('input[type="password"]').first()).toHaveValue(''); await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.locator('.recovery-disclosure summary').click(); await expect(page.getByRole('button', { name: 'Change Wi-Fi' })).toBeEnabled(); await expect(page.getByRole('button', { name: 'Reset device password' })).toBeEnabled(); } @@ -86,18 +91,28 @@ test('recovery refreshes Wi-Fi and device links without reopening USB or writing }) => { await fakeSerialDevice(page, { offline: true }); await connect(page); - await expect(page.getByRole('button', { name: 'Open device settings', exact: true })).toHaveCount( - 0, - ); + await expect( + page.getByRole('button', { name: 'Continue to device settings', exact: true }), + ).toHaveCount(0); + if ( + !(await page.locator('.recovery-disclosure').evaluate((el) => el.hasAttribute('open'))) && + !(await page.getByRole('button', { name: 'Refresh status', exact: true }).isVisible()) + ) + await page.locator('.recovery-disclosure summary').click(); await page.getByRole('button', { name: 'Refresh status', exact: true }).press('Enter'); await expect(page.getByRole('status')).toContainText('Wi-Fi is still not connected'); await page.evaluate(() => { window.recoveryDevice.wifi = '4'; }); + if ( + !(await page.locator('.recovery-disclosure').evaluate((el) => el.hasAttribute('open'))) && + !(await page.getByRole('button', { name: 'Refresh status', exact: true }).isVisible()) + ) + await page.locator('.recovery-disclosure summary').click(); await page.getByRole('button', { name: 'Refresh status', exact: true }).press('Enter'); await expect(page.getByRole('heading', { name: 'Your OpenAthan is connected' })).toBeFocused(); - await page.getByRole('button', { name: 'Open device settings', exact: true }).click(); - await expect(page.getByRole('heading', { name: 'Ready for device setup' })).toBeVisible(); + await page.getByRole('button', { name: 'Continue to device settings', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Continue on speaker power' })).toBeVisible(); await expect(page.getByRole('link', { name: /Open device settings/ })).toHaveAttribute( 'href', 'http://openathan-test.local/', @@ -115,7 +130,7 @@ test('recovery refreshes Wi-Fi and device links without reopening USB or writing expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); }); -for (const action of ['Refresh status', 'Open device settings']) { +for (const action of ['Refresh status', 'Continue to device settings']) { for (const fault of ['storage', 'password', 'setup', 'unreadable'] as const) { test(`${action} blocks changes when current ${fault} status is unsafe`, async ({ page }) => { await fakeSerialDevice(page); @@ -124,6 +139,7 @@ for (const action of ['Refresh status', 'Open device settings']) { if (fault === 'unreadable') window.recoveryDevice.unreadable = true; else window.recoveryDevice[fault] = fault === 'setup' ? 'storage_fault' : 'fault'; }, fault); + if (action === 'Refresh status') await page.locator('.recovery-disclosure summary').click(); await page.getByRole('button', { name: action, exact: true }).press('Enter'); await expect( page.getByRole('heading', { @@ -150,6 +166,8 @@ test('invalid Wi-Fi keys stay in the browser and corrected input can be submitte }) => { await fakeSerialDevice(page, { save: { reply: 'error255', status: 'healthy' } }); await connect(page); + if (!(await page.locator('.recovery-disclosure').evaluate((el) => el.hasAttribute('open')))) + await page.locator('.recovery-disclosure summary').click(); await page.getByRole('button', { name: 'Change Wi-Fi' }).click(); await page.getByLabel('Network name', { exact: true }).fill('Test network'); for (const password of ['x'.repeat(64), 'é'.repeat(32)]) { @@ -274,7 +292,9 @@ for (const state of ['offline', 'password-absent'] as const) { if (state === 'offline') window.recoveryDevice.wifi = '2'; else window.recoveryDevice.password = 'absent'; }, state); - await page.getByRole('button', { name: 'Open device settings', exact: true }).press('Enter'); + await page + .getByRole('button', { name: 'Continue to device settings', exact: true }) + .press('Enter'); await expect( page.getByRole('heading', { name: state === 'offline' ? 'Connect to your Wi-Fi' : 'Create a device password', @@ -319,14 +339,14 @@ for (const discovery of ['existing', 'unrecognized', 'unreadable', 'status-only' page.getByRole('heading', { name: 'Your OpenAthan is connected' }), ).toBeVisible(); await expect( - page.getByRole('button', { name: 'Open device settings', exact: true }), + page.getByRole('button', { name: 'Continue to device settings', exact: true }), ).toBeVisible(); } else if (discovery === 'unrecognized') { await expect( page.getByRole('heading', { name: 'New installation is unavailable' }), ).toBeVisible(); await expect( - page.getByRole('button', { name: 'Retry connection', exact: true }), + page.getByRole('button', { name: 'Back to connection', exact: true }), ).toBeVisible(); } else { await expect(page.getByRole('status')).toContainText( diff --git a/tests/browser/usb-update.spec.ts b/tests/browser/usb-update.spec.ts new file mode 100644 index 0000000..e56d740 --- /dev/null +++ b/tests/browser/usb-update.spec.ts @@ -0,0 +1,917 @@ +import { test, expect, type Page } from '@playwright/test'; +import { mkdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import type { DeviceStatus } from '../../installer/protocol'; +import type { InstallerService } from '../../installer/service'; +import type { UpgradeCheck, UpgradeOffer } from '../../installer/upgrade'; + +interface HandoffOptions { + cleanup?: 'reject' | 'pending'; + transfer?: 'failed' | 'uncertain'; + queuedDisconnect?: boolean; + awaitingPower?: boolean; + missingCredentials?: boolean; + missingWifi?: boolean; + missingPassword?: boolean; + checks?: UpgradeCheck[]; + discard?: 'success' | 'failed' | 'uncertain'; + deferCheck?: boolean; + downloadTimeout?: 'headers' | 'body'; +} +declare global { + interface Window { + usbHandoffFixture: { + counts: { + connect: number; + checkUpdate: number; + update: number; + close: number; + status: number; + firmware: number; + discardUpdate: number; + }; + unexpectedOperations: string[]; + downloads: { requests: number; aborts: number }; + downloadReaderLocked(): boolean; + disconnect(): void; + resolveCleanup(): void; + rejectCleanup(): void; + }; + } +} + +const cleanupNotice = + 'The browser could not close the USB connection. Unplug the Atom’s USB cable and follow the power steps below.'; + +/** Development-only injection: no serial transport, flasher or production scenarios. */ +async function injectHandoffService(page: Page, options: HandoffOptions = {}) { + await page.goto('/preview/installer/'); + await expect(page.getByRole('button', { name: 'Connect simulated device' })).toBeVisible(); + await page.evaluate(async (options) => { + const uiPath = '/installer/ui.ts'; + const sessionPath = '/installer/session.ts'; + const { mountInstaller } = (await import(uiPath)) as typeof import('../../installer/ui'); + const { UncertainOutcome } = (await import( + sessionPath + )) as typeof import('../../installer/session'); + // Release the demo controller's listeners before mounting the injected service. + const previous = document.querySelector('[data-demo-installer]')!; + const root = previous.cloneNode(true) as HTMLElement; + previous.replaceWith(root); + const counts = { + connect: 0, + checkUpdate: 0, + update: 0, + close: 0, + status: 0, + firmware: 0, + discardUpdate: 0, + }; + const unexpectedOperations: string[] = []; + const downloads = { requests: 0, aborts: 0 }; + let downloadBody: ReadableStream | undefined; + let checkDownload: (() => Promise) | undefined; + const unexpected = async (operation: string): Promise => { + unexpectedOperations.push(operation); + throw new Error(`Unexpected ${operation} operation`); + }; + const missingWifi = options.missingWifi || options.missingCredentials; + const status: DeviceStatus = { + wifi: missingWifi ? '2' : '4', + password: options.missingPassword || options.missingCredentials ? 'absent' : 'ready', + setup: 'active', + passwordRevision: 1, + hostname: 'openathan-test.local', + storage: 'ready', + urls: missingWifi ? [] : ['http://openathan-test.local/', 'http://192.168.1.50/'], + }; + const offer: UpgradeOffer = { + version: 'v0.5.0', + commit: 'b'.repeat(40), + bytes: 1024, + sha256: 'c'.repeat(64), + notes: 'https://github.com/OpenAthan-Project/openathan/releases', + }; + let resolveCleanup = () => {}; + let rejectCleanup = () => {}; + const cleanupError = () => new DOMException('USB disconnected during cleanup', 'NetworkError'); + const service: InstallerService = { + simulated: false, + installAvailable: false, + async connect() { + counts.connect++; + return { kind: 'existing', status }; + }, + async checkUpdate() { + counts.checkUpdate++; + if (checkDownload) return checkDownload(); + if (options.checks?.length) + return options.checks[Math.min(counts.checkUpdate - 1, options.checks.length - 1)]!; + return options.awaitingPower + ? { + state: 'busy', + action: 'power', + recoveryBlocked: true, + detail: 'A verified update awaits speaker power.', + } + : { state: 'available', offer, recoveryBlocked: false }; + }, + async update(_offer, progress) { + counts.update++; + progress(100); + if (options.queuedDisconnect) service.onDisconnect?.(); + if (options.transfer === 'failed') throw new Error('The application was rejected.'); + if (options.transfer === 'uncertain') throw new UncertainOutcome(); + }, + async close() { + counts.close++; + // Later explicit disconnects remain available after a failed cleanup. + if (counts.close !== 1) return; + if (options.cleanup === 'reject') throw cleanupError(); + if (options.cleanup === 'pending') + await new Promise((resolve, reject) => { + resolveCleanup = resolve; + rejectCleanup = () => reject(cleanupError()); + }); + }, + async status() { + counts.status++; + return status; + }, + async firmware() { + counts.firmware++; + return { + version: 'v0.4.0', + commit: 'a'.repeat(40), + supported: true, + state: 'awaiting_power', + boot: 'confirmed', + result: '', + offered: offer.version, + received: offer.bytes, + }; + }, + install: () => unexpected('install'), + async discardUpdate() { + if (!options.discard) return unexpected('discardUpdate'); + counts.discardUpdate++; + if (options.discard === 'failed') throw new Error('The discard was rejected.'); + if (options.discard === 'uncertain') throw new UncertainOutcome(); + }, + scan: () => unexpected('scan'), + wifi: () => unexpected('wifi'), + password: () => unexpected('password'), + }; + if (options.downloadTimeout) { + const servicePath = '/installer/real-service.ts'; + const { RealService } = (await import( + servicePath + )) as typeof import('../../installer/real-service'); + const checker = new RealService( + { + tag: 'v0.5.0', + manifestSha256: 'a'.repeat(64), + mediaReviewed: true, + hardwareQualified: true, + }, + true, + ); + // Exercise real download/error handling with read-only fake firmware INFO. + checker.firmware = async () => ({ ...(await service.firmware()), state: 'idle' }); + checkDownload = () => checker.checkUpdate(); + const originalFetch = window.fetch; + window.fetch = async (input, init) => { + if (String(input) !== '/releases/v0.5.0/manifest.json') return originalFetch(input, init); + downloads.requests++; + const signal = init?.signal; + if (options.downloadTimeout === 'headers') + return new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => { + downloads.aborts++; + reject(signal.reason); + }, + { once: true }, + ); + }); + let controller!: ReadableStreamDefaultController; + downloadBody = new ReadableStream({ + start(value) { + controller = value; + controller.enqueue(new Uint8Array([123])); + }, + }); + signal?.addEventListener( + 'abort', + () => { + downloads.aborts++; + controller.error(signal.reason); + }, + { once: true }, + ); + return new Response(downloadBody); + }; + } + window.usbHandoffFixture = { + counts, + unexpectedOperations, + downloads, + downloadReaderLocked: () => downloadBody?.locked ?? false, + disconnect: () => service.onDisconnect?.(), + resolveCleanup: () => resolveCleanup(), + rejectCleanup: () => rejectCleanup(), + }; + mountInstaller(root, service); + }, options); + await page.getByRole('button', { name: 'Choose USB device' }).click(); + if (!options.deferCheck) await page.getByRole('button', { name: 'Check for updates' }).click(); +} + +async function transferUpdate(page: Page) { + await page.getByRole('button', { name: 'Review update' }).click(); + await page.getByRole('button', { name: 'Install update', exact: true }).click(); +} + +for (const downloadTimeout of ['headers', 'body'] as const) { + test(`a stalled download ${downloadTimeout} times out and restores recovery, disconnect and deliberate recheck`, async ({ + page, + }) => { + await page.clock.install(); + await injectHandoffService(page, { + downloadTimeout, + deferCheck: true, + missingCredentials: true, + }); + await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)); + await page.getByRole('button', { name: 'Check for updates' }).press('Enter'); + await expect(page.getByRole('heading', { name: 'Checking firmware' })).toBeVisible(); + await expect(page.locator('[data-demo-installer]')).toHaveAttribute('aria-busy', 'true'); + if (downloadTimeout === 'body') + await expect + .poll(() => page.evaluate(() => window.usbHandoffFixture.downloadReaderLocked())) + .toBe(true); + await page.clock.fastForward(59_999); + await expect(page.locator('[data-demo-installer]')).toHaveAttribute('aria-busy', 'true'); + expect(await page.evaluate(() => window.usbHandoffFixture.downloads)).toEqual({ + requests: 1, + aborts: 0, + }); + await page.clock.fastForward(1); + await expect(page.getByRole('heading', { name: 'Your OpenAthan is connected' })).toBeVisible(); + await expect(page.locator('[data-demo-installer]')).not.toHaveAttribute('aria-busy', 'true'); + await expect( + page.getByText( + 'The firmware download timed out. Check your internet connection and choose Check for updates again. Installed firmware has not changed.', + { exact: true }, + ), + ).toBeVisible(); + await expect(page.getByRole('button', { name: 'Disconnect', exact: true })).toBeEnabled(); + await expect(page.getByRole('button', { name: 'Create device password' })).toBeEnabled(); + expect(await page.evaluate(() => window.usbHandoffFixture.downloadReaderLocked())).toBe(false); + await page.getByRole('button', { name: 'Connect Wi-Fi', exact: true }).press('Enter'); + await expect(page.getByRole('button', { name: 'Save Wi-Fi' })).toBeEnabled(); + await page.getByRole('button', { name: 'Back', exact: true }).press('Enter'); + await page.clock.fastForward(60_000); + expect(await page.evaluate(() => window.usbHandoffFixture.downloads)).toEqual({ + requests: 1, + aborts: 1, + }); + await page.getByRole('button', { name: 'Check for updates' }).press('Enter'); + await expect(page.getByRole('heading', { name: 'Checking firmware' })).toBeVisible(); + await page.clock.fastForward(60_000); + await expect(page.locator('[data-demo-installer]')).not.toHaveAttribute('aria-busy', 'true'); + expect(await page.evaluate(() => window.usbHandoffFixture.downloads)).toEqual({ + requests: 2, + aborts: 2, + }); + expect(await page.evaluate(() => window.usbHandoffFixture.counts)).toMatchObject({ + connect: 1, + checkUpdate: 2, + update: 0, + close: 0, + firmware: 2, + }); + expect(await page.evaluate(() => window.usbHandoffFixture.unexpectedOperations)).toEqual([]); + await page.getByRole('button', { name: 'Disconnect', exact: true }).press('Enter'); + await expect(page.getByRole('heading', { name: 'Connect speaker', exact: true })).toBeVisible(); + expect(await page.evaluate(() => window.usbHandoffFixture.counts.close)).toBe(1); + }); +} + +async function expectHandoff(page: Page, links = true) { + await expect(page.getByRole('heading', { name: 'Update written and verified' })).toBeVisible(); + await expect(page.getByText('Unplug the Atom’s USB cable.', { exact: true })).toBeVisible(); + await expect(page.getByText('only the Pyramid’s bottom USB-C power')).toBeVisible(); + await expect(page.getByText(/Startup success is confirmed there/)).toBeVisible(); + await expect( + page.getByRole('heading', { name: /Connection lost|Update needs attention/ }), + ).toHaveCount(0); + if (links) { + await expect(page.getByRole('link', { name: /Open device settings/ })).toHaveAttribute( + 'href', + 'http://openathan-test.local/', + ); + await expect(page.getByRole('link', { name: /Try the IP address/ })).toHaveAttribute( + 'href', + 'http://192.168.1.50/', + ); + } else await expect(page.locator('.device-links a')).toHaveCount(0); +} +async function expectCredentialHandoff( + page: Page, + credentials: { missingWifi: boolean; missingPassword: boolean }, +) { + const needsRecovery = credentials.missingWifi || credentials.missingPassword; + await expectHandoff(page, !needsRecovery); + const panel = page.locator('[data-panel]'); + if (needsRecovery) { + await expect(panel.getByText(/Sign in as admin with the password you chose/)).toHaveCount(0); + await expect( + panel.getByText('Let the speaker start and complete its startup checks.', { exact: true }), + ).toBeVisible(); + await expect( + panel.getByText(/unplug the Pyramid’s bottom power cable before connecting/), + ).toBeVisible(); + await expect( + panel.getByText(/Select Back to connection, reconnect, then choose Check for updates/), + ).toBeVisible(); + await expect( + panel.getByText( + /Follow any remaining update instructions before changing Wi-Fi or creating a device password/, + ), + ).toBeVisible(); + await expect( + panel.getByText(/After recovery, unplug Atom USB and return to Pyramid bottom-only power/), + ).toBeVisible(); + await expect(panel.getByText(/A device address was not returned/)).toHaveCount(0); + } else { + await expect(panel.getByText(/Sign in as admin with the password you chose/)).toBeVisible(); + await expect(panel.getByText(/Wait for it to reconnect, then open its settings/)).toBeVisible(); + await expect(panel.getByText(/Follow any remaining update instructions/)).toHaveCount(0); + } + if (credentials.missingPassword) + await expect( + panel.getByText('Your device password still needs to be created through USB setup.', { + exact: true, + }), + ).toBeVisible(); + else + await expect(panel.getByText(/Your device password still needs to be created/)).toHaveCount(0); + if (credentials.missingWifi) + await expect( + panel.getByText( + /The speaker may reconnect using its saved Wi-Fi settings. If it stays offline/, + ), + ).toBeVisible(); + else + await expect( + panel.getByText(/The speaker may reconnect using its saved Wi-Fi settings/), + ).toHaveCount(0); + await expect(panel.locator('form')).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'Update written and verified' })).toBeFocused(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); +} +async function expectOperations(page: Page, update: number, close: number, firmware = 0) { + expect(await page.evaluate(() => window.usbHandoffFixture.counts)).toEqual({ + connect: 1, + checkUpdate: 1, + update, + close, + status: 0, + firmware, + discardUpdate: 0, + }); + expect(await page.evaluate(() => window.usbHandoffFixture.unexpectedOperations)).toEqual([]); +} + +test('verified update retains the power handoff when USB cleanup rejects', async ({ + page, +}, testInfo) => { + await injectHandoffService(page, { cleanup: 'reject' }); + await transferUpdate(page); + await expectHandoff(page); + await expect(page.getByRole('status')).toHaveText(cleanupNotice); + await expect(page.getByRole('status')).not.toHaveClass(/error|uncertain/); + await expect(page.getByRole('heading', { name: 'Update written and verified' })).toBeFocused(); + await expectOperations(page, 1, 1); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + const capture = process.env.OPENATHAN_REVIEW_DIR; + if (capture && testInfo.project.name !== 'webkit') { + await mkdir(capture, { recursive: true }); + await page.screenshot({ + path: resolve(capture, `cleanup-notice-${testInfo.project.name}.png`), + fullPage: true, + }); + } +}); + +for (const outcome of ['resolve', 'reject'] as const) { + test(`verified handoff survives disconnects during pending cleanup and after ${outcome}`, async ({ + page, + }) => { + await injectHandoffService(page, { cleanup: 'pending' }); + await transferUpdate(page); + await expectHandoff(page); + await expect(page.locator('[data-demo-installer]')).toHaveAttribute('aria-busy', 'true'); + await expect(page.getByRole('heading', { name: 'Update written and verified' })).toBeFocused(); + await page.evaluate(() => window.usbHandoffFixture.disconnect()); + await expectHandoff(page); + await page.evaluate((outcome) => { + if (outcome === 'resolve') window.usbHandoffFixture.resolveCleanup(); + else window.usbHandoffFixture.rejectCleanup(); + }, outcome); + await expect(page.locator('[data-demo-installer]')).not.toHaveAttribute('aria-busy', 'true'); + await page.evaluate(() => window.usbHandoffFixture.disconnect()); + await expectHandoff(page); + if (outcome === 'reject') await expect(page.getByRole('status')).toHaveText(cleanupNotice); + else await expect(page.getByRole('status', { includeHidden: true })).toBeHidden(); + await expectOperations(page, 1, 1); + }); +} + +test('a disconnect queued before verification cannot overwrite the verified handoff', async ({ + page, +}) => { + await injectHandoffService(page, { queuedDisconnect: true }); + await transferUpdate(page); + await expect(page.locator('[data-demo-installer]')).not.toHaveAttribute('aria-busy', 'true'); + await expectHandoff(page); + await expect(page.getByRole('status', { includeHidden: true })).toBeHidden(); + await expectOperations(page, 1, 1); +}); + +for (const credentials of [ + { name: 'ready', missingWifi: false, missingPassword: false }, + { name: 'missing Wi-Fi', missingWifi: true, missingPassword: false }, + { name: 'missing password', missingWifi: false, missingPassword: true }, + { name: 'both missing', missingWifi: true, missingPassword: true }, +]) { + for (const awaitingPower of [false, true]) { + for (const cleanup of [undefined, 'reject'] as const) { + test(`${awaitingPower ? 'reconciled awaiting_power' : 'completed transfer'} prioritizes handoff with credentials ${credentials.name} and cleanup ${cleanup ?? 'resolved'}`, async ({ + page, + }, testInfo) => { + await injectHandoffService(page, { awaitingPower, ...credentials, cleanup }); + if (awaitingPower) { + await expectBlockedRecovery(page, 'Finish power handoff'); + await page.getByRole('button', { name: 'Finish power handoff' }).click(); + } else await transferUpdate(page); + await expectCredentialHandoff(page, credentials); + if (cleanup === 'reject') await expect(page.getByRole('status')).toHaveText(cleanupNotice); + await page.evaluate(() => window.usbHandoffFixture.disconnect()); + await expectCredentialHandoff(page, credentials); + await expectOperations(page, awaitingPower ? 0 : 1, 1); + const capture = process.env.OPENATHAN_REVIEW_DIR; + if ( + capture && + awaitingPower && + credentials.missingPassword && + cleanup === 'reject' && + testInfo.project.name !== 'webkit' + ) { + await mkdir(capture, { recursive: true }); + await page.screenshot({ + path: resolve( + capture, + `handoff-${credentials.name.replaceAll(' ', '-')}-${testInfo.project.name}.png`, + ), + fullPage: true, + }); + } + }); + } + } +} + +async function expectBlockedRecovery(page: Page, primary: string) { + await expect(page.locator('[data-panel] .button:not(.secondary)')).toHaveCount(1); + await expect(page.locator('[data-panel] .button:not(.secondary)')).toHaveText(primary); + await expect(page.getByRole('button', { name: primary, exact: true })).toHaveCount(1); + await expect( + page + .locator('.task-row') + .filter({ has: page.getByRole('heading', { name: 'Firmware', exact: true }) }) + .getByRole('button'), + ).toHaveCount(0); + await expect(page.getByText(/Wi-Fi and password recovery must wait/)).toBeVisible(); + const recovery = page.locator('.recovery-disclosure'); + await expect(recovery).not.toHaveAttribute('open'); + await recovery.locator('summary').press('Enter'); + await expect(recovery.getByRole('button', { name: /^(Connect|Change) Wi-Fi$/ })).toBeDisabled(); + await expect( + recovery.getByRole('button', { name: /^(Create|Reset) device password$/ }), + ).toBeDisabled(); + await expect(recovery.getByRole('button', { name: 'Refresh status', exact: true })).toBeEnabled(); +} + +async function dispatchAction(page: Page, action: string) { + await page.evaluate((action) => { + const control = document.createElement('button'); + control.dataset.action = action; + document.querySelector('[data-demo-installer]')!.append(control); + control.click(); + control.remove(); + }, action); + await expect(page.locator('[data-demo-installer]')).not.toHaveAttribute('aria-busy', 'true'); +} + +for (const state of [ + 'usb_descriptor', + 'usb_receiving', + 'usb_selection_uncertain', + 'usb_interrupted before startup confirmation', +]) { + test(`${state} prioritizes read-only update status over recovery`, async ({ page }) => { + await injectHandoffService(page, { + missingCredentials: true, + checks: [{ state: 'busy', recoveryBlocked: true, detail: `Pending ${state}.` }], + }); + await expectBlockedRecovery(page, 'Check update status'); + await expect(page.getByRole('button', { name: 'Discard incomplete transfer' })).toHaveCount(0); + await expectOperations(page, 0, 0); + }); +} + +test('connection alone keeps recovery available without a firmware check', async ({ page }) => { + await injectHandoffService(page, { + awaitingPower: true, + missingCredentials: true, + deferCheck: true, + }); + await expect(page.locator('[data-panel] .button:not(.secondary)')).toHaveText('Connect Wi-Fi'); + await expect(page.locator('.recovery-disclosure')).toHaveAttribute('open', ''); + expect(await page.evaluate(() => window.usbHandoffFixture.counts)).toMatchObject({ + connect: 1, + checkUpdate: 0, + firmware: 0, + status: 0, + }); + await page.getByRole('button', { name: 'Check for updates' }).click(); + await expectBlockedRecovery(page, 'Finish power handoff'); +}); + +test('returning to connection resets USB ownership for the next connection', async ({ page }) => { + await injectHandoffService(page, { + missingCredentials: true, + checks: [ + { state: 'busy', recoveryBlocked: true, detail: 'USB transfer in progress.' }, + { state: 'failed', detail: 'Firmware INFO is unavailable on this connection.' }, + ], + }); + await expectBlockedRecovery(page, 'Check update status'); + await page.getByRole('button', { name: 'Disconnect', exact: true }).click(); + await page.getByRole('button', { name: 'Choose USB device' }).click(); + await page.getByRole('button', { name: 'Check for updates' }).click(); + await expect(page.locator('[data-panel] .button:not(.secondary)')).toHaveText('Connect Wi-Fi'); + await expect(page.getByRole('button', { name: 'Create device password' })).toBeEnabled(); + expect(await page.evaluate(() => window.usbHandoffFixture.counts)).toMatchObject({ + connect: 2, + checkUpdate: 2, + close: 1, + }); +}); + +test('healthy provisioning refresh cannot release known USB ownership or allow forged recovery handlers', async ({ + page, +}) => { + await injectHandoffService(page, { awaitingPower: true }); + await expectBlockedRecovery(page, 'Finish power handoff'); + await page.getByRole('button', { name: 'Refresh status', exact: true }).click(); + await expectBlockedRecovery(page, 'Finish power handoff'); + // Dispatch stale/forged events to exercise handlers independently of disabled buttons. + for (const action of ['wifi-screen', 'password-screen', 'scan', 'finish']) { + await dispatchAction(page, action); + await expect(page.getByRole('heading', { name: 'Your OpenAthan is connected' })).toBeVisible(); + await expect(page.locator('[data-panel] form')).toHaveCount(0); + } + for (const kind of ['wifi', 'password']) { + await page.evaluate((kind) => { + const root = document.querySelector('[data-demo-installer]')!; + const form = document.createElement('form'); + form.dataset.form = kind; + form.innerHTML = + ''; + root.append(form); + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + form.remove(); + }, kind); + await expect(page.locator('[data-demo-installer]')).not.toHaveAttribute('aria-busy', 'true'); + } + expect(await page.evaluate(() => window.usbHandoffFixture.unexpectedOperations)).toEqual([]); + expect(await page.evaluate(() => window.usbHandoffFixture.counts)).toMatchObject({ + status: 1, + checkUpdate: 1, + close: 0, + update: 0, + }); +}); + +test('failed INFO retains known ownership, replaces resolution with reconnect, and fresh INFO clears it', async ({ + page, +}) => { + await injectHandoffService(page, { + missingCredentials: true, + checks: [ + { state: 'busy', recoveryBlocked: true, detail: 'USB transfer in progress.' }, + { state: 'failed', detail: 'Firmware status could not be read. Reconnect and check again.' }, + { + state: 'disabled', + recoveryBlocked: false, + detail: 'USB updates are awaiting physical qualification.', + }, + ], + }); + await page.getByRole('button', { name: 'Check update status', exact: true }).click(); + await expectBlockedRecovery(page, 'Reconnect and check'); + await page.getByRole('button', { name: 'Refresh status', exact: true }).click(); + await expectBlockedRecovery(page, 'Reconnect and check'); + // A later successful INFO result is the only authority that can clear the lock. + await dispatchAction(page, 'check-update'); + await expect(page.locator('[data-panel] .button:not(.secondary)')).toHaveText('Connect Wi-Fi'); + await expect(page.getByRole('button', { name: 'Create device password' })).toBeEnabled(); + expect(await page.evaluate(() => window.usbHandoffFixture.counts)).toMatchObject({ + checkUpdate: 3, + status: 1, + close: 0, + }); +}); + +for (const action of ['power', 'discard'] as const) { + test(`failed INFO removes stale ${action} resolution without unlocking recovery`, async ({ + page, + }) => { + await injectHandoffService(page, { + checks: [ + { state: 'busy', action, recoveryBlocked: true, detail: 'A USB update needs resolution.' }, + { + state: 'failed', + detail: 'Firmware status could not be read. Reconnect and check again.', + }, + ], + }); + await dispatchAction(page, 'check-update'); + await expectBlockedRecovery(page, 'Reconnect and check'); + await dispatchAction(page, action === 'power' ? 'update-handoff' : 'discard-update'); + await expect(page.getByRole('heading', { name: 'Your OpenAthan is connected' })).toBeVisible(); + expect(await page.evaluate(() => window.usbHandoffFixture.counts)).toMatchObject({ + close: 0, + discardUpdate: 0, + update: 0, + checkUpdate: 2, + }); + expect(await page.evaluate(() => window.usbHandoffFixture.unexpectedOperations)).toEqual([]); + }); +} + +test('successful discard cannot unlock recovery when its fresh INFO read fails', async ({ + page, +}) => { + await injectHandoffService(page, { + missingCredentials: true, + discard: 'success', + checks: [ + { + state: 'busy', + action: 'discard', + recoveryBlocked: true, + detail: 'An incomplete transfer can be discarded.', + }, + { state: 'failed', detail: 'Firmware status could not be read. Reconnect and check again.' }, + ], + }); + await page.getByRole('button', { name: 'Discard incomplete transfer' }).click(); + await expectBlockedRecovery(page, 'Reconnect and check'); + expect(await page.evaluate(() => window.usbHandoffFixture.counts)).toMatchObject({ + checkUpdate: 2, + discardUpdate: 1, + }); +}); + +test('successful discard reads fresh INFO and restores missing-credential recovery', async ({ + page, +}) => { + await injectHandoffService(page, { + missingCredentials: true, + discard: 'success', + checks: [ + { + state: 'busy', + action: 'discard', + recoveryBlocked: true, + detail: 'An incomplete transfer can be discarded.', + }, + { + state: 'disabled', + recoveryBlocked: false, + detail: 'USB updates are awaiting physical qualification.', + }, + ], + }); + await expectBlockedRecovery(page, 'Discard incomplete transfer'); + await page.getByRole('button', { name: 'Discard incomplete transfer' }).click(); + await expect(page.locator('[data-panel] .button:not(.secondary)')).toHaveText('Connect Wi-Fi'); + await expect(page.locator('.recovery-disclosure')).toHaveAttribute('open', ''); + await expect(page.getByRole('button', { name: 'Create device password' })).toBeEnabled(); + await page.getByRole('button', { name: 'Connect Wi-Fi', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Find networks' })).toBeEnabled(); + await expect(page.getByRole('button', { name: 'Save Wi-Fi' })).toBeEnabled(); + expect(await page.evaluate(() => window.usbHandoffFixture.counts)).toMatchObject({ + checkUpdate: 2, + discardUpdate: 1, + close: 0, + }); + expect(await page.evaluate(() => window.usbHandoffFixture.unexpectedOperations)).toEqual([]); +}); + +for (const discard of ['failed', 'uncertain'] as const) { + test(`${discard} discard retains reconnect guidance without retry or unlocked recovery`, async ({ + page, + }) => { + await injectHandoffService(page, { + discard, + checks: [ + { + state: 'busy', + action: 'discard', + recoveryBlocked: true, + detail: 'An incomplete transfer can be discarded.', + }, + ], + }); + await page.getByRole('button', { name: 'Discard incomplete transfer' }).click(); + await expect( + page.getByRole('heading', { + name: discard === 'failed' ? 'Update needs attention' : 'Check before trying again', + }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: /Reconnect and check|Disconnect and start again/ }), + ).toHaveCount(1); + await expect(page.getByRole('button', { name: /^(Change|Connect) Wi-Fi$/ })).toHaveCount(0); + expect(await page.evaluate(() => window.usbHandoffFixture.counts)).toMatchObject({ + discardUpdate: 1, + checkUpdate: 1, + close: 0, + update: 0, + }); + }); +} + +for (const check of [ + { + state: 'unsupported', + recoveryBlocked: false, + detail: 'Legacy firmware needs an initial Wi-Fi update.', + }, + { state: 'busy', recoveryBlocked: false, detail: 'Ordinary startup checks are pending.' }, + { state: 'busy', recoveryBlocked: false, detail: 'A network update is downloading.' }, + { state: 'failed', detail: 'Firmware INFO is unavailable.' }, +] satisfies UpgradeCheck[]) { + test(`${check.detail} does not establish USB ownership`, async ({ page }) => { + await injectHandoffService(page, { missingCredentials: true, checks: [check] }); + await expect(page.locator('[data-panel] .button:not(.secondary)')).toHaveText('Connect Wi-Fi'); + await expect(page.getByRole('button', { name: 'Create device password' })).toBeEnabled(); + await page.getByRole('button', { name: 'Connect Wi-Fi', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Save Wi-Fi' })).toBeEnabled(); + await expectOperations(page, 0, 0); + }); +} + +for (const width of [1440, 390]) { + test(`blocked recovery keyboard behavior and layout at ${width}px`, async ({ + page, + }, testInfo) => { + await page.setViewportSize({ width, height: 844 }); + await injectHandoffService(page, { awaitingPower: true, missingCredentials: true }); + await expect(page.getByRole('heading', { name: 'Your OpenAthan is connected' })).toBeFocused(); + await expectBlockedRecovery(page, 'Finish power handoff'); + await expect(page.locator('.recovery-disclosure summary')).toBeFocused(); + // WebKit's default tab mode skips buttons; Option-Tab visits all native controls. + await page.keyboard.press(testInfo.project.name === 'chromium' ? 'Tab' : 'Alt+Tab'); + await expect(page.getByRole('button', { name: 'Refresh status', exact: true })).toBeFocused(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe( + true, + ); + const capture = process.env.OPENATHAN_REVIEW_DIR; + if (capture && testInfo.project.name === 'chromium') { + await mkdir(capture, { recursive: true }); + await page.screenshot({ + path: resolve(capture, `blocked-recovery-${width}.png`), + fullPage: true, + }); + } + }); +} + +for (const transfer of ['failed', 'uncertain'] as const) { + test(`${transfer} transfer retains its guidance without cleanup or automatic resend`, async ({ + page, + }) => { + await injectHandoffService(page, { transfer, queuedDisconnect: transfer === 'uncertain' }); + await transferUpdate(page); + await expect( + page.getByRole('heading', { + name: transfer === 'failed' ? 'Update needs attention' : 'Check before trying again', + }), + ).toBeVisible(); + await expect( + page.getByText( + transfer === 'failed' + ? /Reconnect and read firmware status before another attempt/ + : /read firmware status before another transfer/, + ), + ).toBeVisible(); + if (transfer === 'uncertain') { + await expect(page.getByRole('status')).toHaveText( + 'The result is uncertain. No automatic retry was made.', + ); + await expect(page.getByText(/update state: awaiting_power/)).toBeVisible(); + } else await expect(page.getByRole('status')).toHaveText('The application was rejected.'); + await expect(page.getByRole('heading', { name: 'Update written and verified' })).toHaveCount(0); + await expect(page.locator('.finish-list, .device-links a')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Install update', exact: true })).toHaveCount(0); + await expectOperations(page, 1, 0, transfer === 'uncertain' ? 1 : 0); + }); +} + +test('returning to connection resets verified handoff disconnect protection', async ({ page }) => { + await injectHandoffService(page); + await transferUpdate(page); + await expectHandoff(page); + await page.getByRole('button', { name: 'Back to connection' }).click(); + await expect(page.getByRole('heading', { name: 'Connect speaker' })).toBeVisible(); + await page.evaluate(() => window.usbHandoffFixture.disconnect()); + await expect(page.getByRole('heading', { name: 'Connection lost' })).toBeVisible(); + await expect(page.getByRole('status')).toContainText('Reconnect the USB data cable'); + await expectOperations(page, 1, 2); +}); + +for (const width of [1440, 390]) { + test(`approved USB summary and update handoff at ${width}px`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 844 }); + await page.goto('/preview/installer/'); + await page.getByLabel('Preview scenario').selectOption('update-available'); + await page.getByRole('button', { name: 'Connect simulated device' }).click(); + const recovery = page.locator('.recovery-disclosure'); + await expect(recovery).not.toHaveAttribute('open'); + await expect(page.getByRole('button', { name: 'Continue to device settings' })).toBeVisible(); + await expect(page.locator('[data-panel] .button:not(.secondary)')).toHaveCount(1); + await expect(page.locator('[data-context]')).toBeHidden(); + await page.locator('.recovery-disclosure summary').focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('button', { name: 'Change Wi-Fi' })).toBeVisible(); + await page.locator('.recovery-disclosure summary').click(); + const capture = process.env.OPENATHAN_REVIEW_DIR; + if (capture && testInfo.project.name === 'chromium') { + await mkdir(capture, { recursive: true }); + await page.screenshot({ + path: resolve(capture, width === 1440 ? 'desktop.png' : 'mobile.png'), + fullPage: true, + }); + } + await page.getByRole('button', { name: 'Check for updates' }).click(); + await page.getByRole('button', { name: 'Review update' }).click(); + await expect(page.getByRole('heading', { name: 'Review firmware update' })).toBeFocused(); + await expect(page.getByRole('link', { name: 'Read the release notes' })).toHaveAttribute( + 'href', + 'https://github.com/OpenAthan-Project/openathan/releases', + ); + await page.getByRole('button', { name: 'Install update', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Update written and verified' })).toBeVisible(); + await expect(page.getByText(/Startup success is confirmed there/)).toBeVisible(); + await expect(page.getByText('Finish setup', { exact: true })).toHaveCount(0); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe( + true, + ); + if (capture && testInfo.project.name === 'chromium') + await page.screenshot({ path: resolve(capture, `handoff-${width}.png`), fullPage: true }); + }); +} +test('post-install Back preserves installation and cannot return to erase confirmation', async ({ + page, +}) => { + await page.goto('/preview/installer/'); + await page.getByLabel('Preview scenario').selectOption('new-device'); + await page.getByRole('button', { name: 'Connect simulated device' }).click(); + await page.getByLabel(/I have an AtomS3R/).check(); + await page.getByRole('button', { name: 'Install OpenAthan', exact: true }).click(); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Connect Wi-Fi', exact: true })).toBeVisible(); + await expect(page.getByLabel(/I have an AtomS3R/)).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Install OpenAthan', exact: true })).toHaveCount(0); +}); +test('uncertain firmware transfer stays uncertain and offers one reconnect action', async ({ + page, +}) => { + await page.goto('/preview/installer/'); + await page.getByLabel('Preview scenario').selectOption('update-uncertain'); + await page.getByRole('button', { name: 'Connect simulated device' }).click(); + await page.getByRole('button', { name: 'Check for updates' }).click(); + await page.getByRole('button', { name: 'Review update' }).click(); + await page.getByRole('button', { name: 'Install update', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Check before trying again' })).toBeVisible(); + await expect(page.getByText(/read firmware status before another transfer/)).toBeVisible(); + await expect(page.getByRole('status')).toContainText('uncertain'); + await expect(page.getByRole('button', { name: 'Install update', exact: true })).toHaveCount(0); +}); diff --git a/tests/unit/upgrade.test.ts b/tests/unit/upgrade.test.ts new file mode 100644 index 0000000..3afb746 --- /dev/null +++ b/tests/unit/upgrade.test.ts @@ -0,0 +1,426 @@ +import { afterEach, describe, it, expect, vi } from 'vitest'; +import { generateKeyPairSync, sign } from 'node:crypto'; +import { fixture } from './fixtures/release'; +import { + verifyDescriptor, + signatureBytes, + newerVersion, + parseFirmware, + chunkPayload, + loadUpgrade, +} from '../../installer/upgrade'; +import { + ProvisioningSession, + DeviceError, + UncertainOutcome, + type ByteTransport, +} from '../../installer/session'; +import { encodeFrame, rpcPayload } from '../../installer/protocol'; + +describe('USB update download deadlines', () => { + const pin = { + tag: 'v0.5.0', + manifestSha256: 'a'.repeat(64), + mediaReviewed: true, + hardwareQualified: true, + } as const; + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + it('aborts a stalled response header once at 60 seconds without retrying', async () => { + vi.useFakeTimers(); + let signal: AbortSignal | undefined; + const abort = vi.fn(); + const fetcher = vi.fn((_url: string, options: RequestInit) => { + signal = options.signal ?? undefined; + return new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => { + abort(); + reject(signal!.reason); + }, + { once: true }, + ); + }); + }); + vi.stubGlobal('fetch', fetcher); + const result = loadUpgrade(pin).catch((error: Error) => error); + await vi.advanceTimersByTimeAsync(59_999); + expect(signal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(signal?.aborted).toBe(true); + expect(await result).toMatchObject({ name: 'UpgradeDownloadTimeout' }); + expect(abort).toHaveBeenCalledTimes(1); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + for (const slow of [false, true]) { + it(`aborts a ${slow ? 'slow' : 'stalled'} body at the original deadline and releases its reader`, async () => { + vi.useFakeTimers(); + let signal: AbortSignal | undefined; + let controller!: ReadableStreamDefaultController; + const body = new ReadableStream({ + start(value) { + controller = value; + controller.enqueue(new Uint8Array([123])); + }, + }); + const abort = vi.fn(); + const fetcher = vi.fn(async (_url: string, options: RequestInit) => { + signal = options.signal ?? undefined; + signal?.addEventListener( + 'abort', + () => { + abort(); + controller.error(signal!.reason); + }, + { once: true }, + ); + return new Response(body); + }); + vi.stubGlobal('fetch', fetcher); + const result = loadUpgrade(pin).catch((error: Error) => error); + await vi.advanceTimersByTimeAsync(20_000); + if (slow) controller.enqueue(new Uint8Array([32])); + await vi.advanceTimersByTimeAsync(20_000); + if (slow) controller.enqueue(new Uint8Array([32])); + await vi.advanceTimersByTimeAsync(19_999); + expect(signal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(signal?.aborted).toBe(true); + expect(await result).toMatchObject({ name: 'UpgradeDownloadTimeout' }); + expect(body.locked).toBe(false); + expect(abort).toHaveBeenCalledTimes(1); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + } + it('starts a fresh deadline for the next asset after a slow successful download', async () => { + const f = await fixture(); + vi.useFakeTimers(); + const signals: AbortSignal[] = []; + let requested!: () => void; + const nextAsset = new Promise((resolve) => { + requested = resolve; + }); + const fetcher = vi.fn((_url: string, options: RequestInit) => { + const signal = options.signal!; + signals.push(signal); + if (signals.length === 1) + return new Promise((resolve) => { + setTimeout(() => resolve(new Response(f.bytes)), 59_000); + }); + requested(); + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }); + vi.stubGlobal('fetch', fetcher); + const result = loadUpgrade(f.pin).catch((error: Error) => error); + await vi.advanceTimersByTimeAsync(59_000); + await nextAsset; + expect(fetcher).toHaveBeenLastCalledWith('/releases/v0.1.0/upgrade.json', expect.anything()); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(59_999); + expect(signals.every((signal) => !signal.aborted)).toBe(true); + await vi.advanceTimersByTimeAsync(1); + expect(await result).toMatchObject({ name: 'UpgradeDownloadTimeout' }); + expect(signals[0]!.aborted).toBe(false); + expect(signals[1]!.aborted).toBe(true); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + }); + it('clears deadlines and releases readers after completed downloads, retaining descriptor validation', async () => { + const f = await fixture(); + vi.useFakeTimers(); + const signals: AbortSignal[] = []; + const bodies = [f.bytes, new TextEncoder().encode('{}')].map( + (bytes) => + new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }), + ); + const fetcher = vi.fn(async (_url: string, options: RequestInit) => { + signals.push(options.signal!); + return new Response(bodies[signals.length - 1]); + }); + vi.stubGlobal('fetch', fetcher); + await expect(loadUpgrade(f.pin)).rejects.toThrow('Invalid update descriptor'); + expect(bodies.every((body) => !body.locked)).toBe(true); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(60_000); + expect(signals.every((signal) => !signal.aborted)).toBe(true); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + it('cancels an oversized body without waiting for stalled cleanup and releases its reader', async () => { + vi.useFakeTimers(); + const cancel = vi.fn(() => new Promise(() => {})); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(16_385)); + }, + cancel, + }); + const fetcher = vi.fn(async () => new Response(body)); + vi.stubGlobal('fetch', fetcher); + await expect(loadUpgrade(pin)).rejects.toThrow('Oversized update download'); + expect(cancel).toHaveBeenCalledTimes(1); + expect(body.locked).toBe(false); + expect(vi.getTimerCount()).toBe(0); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + it.each(['fetch failure', 'HTTP failure', 'missing body', 'body failure'] as const)( + 'clears the deadline after %s without retrying', + async (failure) => { + vi.useFakeTimers(); + let signal: AbortSignal | undefined; + let body: ReadableStream | undefined; + const cancel = vi.fn(); + const fetcher = vi.fn(async (_url: string, options: RequestInit) => { + signal = options.signal ?? undefined; + if (failure === 'fetch failure') throw new Error('Network unavailable.'); + if (failure === 'missing body') return new Response(null); + body = new ReadableStream({ + start(controller) { + if (failure === 'body failure') controller.error(new Error('Stream failed.')); + }, + cancel, + }); + return new Response(body, { status: failure === 'HTTP failure' ? 503 : 200 }); + }); + vi.stubGlobal('fetch', fetcher); + await expect(loadUpgrade(pin)).rejects.toThrow( + failure === 'fetch failure' + ? 'Network unavailable' + : failure === 'body failure' + ? 'Stream failed' + : 'Could not load', + ); + expect(body?.locked ?? false).toBe(false); + if (failure === 'HTTP failure') expect(cancel).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(60_000); + expect(signal?.aborted).toBe(false); + expect(fetcher).toHaveBeenCalledTimes(1); + }, + ); +}); + +async function signed() { + const { manifest } = await fixture(); + const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + const payload = JSON.stringify({ + schema: 1, + version: manifest.tag, + commit: manifest.commit, + hardware: manifest.hardware, + layout: manifest.layout, + storageFormat: 1, + audioFormat: 1, + rollback: true, + bytes: 256, + sha256: 'a'.repeat(64), + }); + const signature = sign('sha256', Buffer.from(payload), privateKey).toString('hex'); + const bytes = new TextEncoder().encode(JSON.stringify({ payload, signature })); + return { + manifest, + bytes, + key: publicKey.export({ type: 'spki', format: 'der' }).toString('base64'), + }; +} +describe('signed USB upgrade contract', () => { + it('verifies the exact signed payload and binds version and source to the selected manifest', async () => { + const f = await signed(); + expect((await verifyDescriptor(f.bytes, f.manifest, f.key)).version).toBe(f.manifest.tag); + await expect( + verifyDescriptor(f.bytes, { ...f.manifest, commit: 'b'.repeat(40) }, f.key), + ).rejects.toThrow('Incompatible'); + const altered = JSON.parse(new TextDecoder().decode(f.bytes)); + altered.payload = altered.payload.replace('256', '257'); + await expect( + verifyDescriptor(new TextEncoder().encode(JSON.stringify(altered)), f.manifest, f.key), + ).rejects.toThrow('signature'); + }); + it('rejects malformed DER and unsupported firmware information', () => { + expect(() => signatureBytes('00'.repeat(64))).toThrow(); + expect(() => + parseFirmware(['1', '1-dev', 'development', 'supported', 'idle', 'confirmed', '', '', '0']), + ).toThrow(); + expect(() => + parseFirmware([ + '1', + 'v0.5.0', + 'a'.repeat(40), + 'supported', + 'unexpected', + 'confirmed', + '', + '', + '0', + ]), + ).toThrow(); + }); + it('compares numeric versions and rejects overflow and downgrades', () => { + expect(newerVersion('v0.10.0', 'v0.9.0')).toBe(true); + expect(newerVersion('v0.4.0', 'v0.5.0')).toBe(false); + expect(() => newerVersion('v4294967296.0.0', 'v0.1.0')).toThrow(); + }); + it('sends one bounded chunk and accepts only its exact next-offset acknowledgement', async () => { + let receive!: (bytes: Uint8Array) => void; + let writes = 0; + const transport: ByteTransport = { + listen(fn) { + receive = fn; + return () => {}; + }, + async close() {}, + async write(packet) { + writes++; + const ack = packet.slice(9, 22); + new DataView(ack.buffer, ack.byteOffset).setUint32(9, 242, true); + receive(encodeFrame(true, 6, ack)); + }, + }; + const session = new ProvisioningSession(transport, 10); + await session.writeChunk('0123456789abcdef', 1, 0, new Uint8Array(242)); + expect(writes).toBe(1); + await session.close(); + }); + it('never repeats a lost chunk and blocks later writes while allowing a status read', async () => { + let receive!: (bytes: Uint8Array) => void; + let writes = 0; + const transport: ByteTransport = { + listen(fn) { + receive = fn; + return () => {}; + }, + async close() {}, + async write() { + writes++; + }, + }; + const session = new ProvisioningSession(transport, 10); + await expect( + session.writeChunk('0123456789abcdef', 1, 0, new Uint8Array(1)), + ).rejects.toBeInstanceOf(UncertainOutcome); + await expect(session.writeChunk('0123456789abcdef', 1, 0, new Uint8Array(1))).rejects.toThrow( + 'Reconnect', + ); + expect(writes).toBe(1); + const read = session.command(true, 0x10, [], { timeoutMs: 10 }); + receive(encodeFrame(true, 2, new Uint8Array([2]))); + await expect(read).rejects.toThrow('support'); + await session.close(); + }); + for (const kind of [0, 1]) { + it.each([ + ['extra error byte', [255, 99]], + ['empty error', []], + ['extra error-clear byte', [0, 99]], + ] as const)( + `locks writes after a malformed %s response to chunk kind ${kind}`, + async (_name, payload) => { + let receive!: (bytes: Uint8Array) => void; + let writes = 0; + const transport: ByteTransport = { + listen(fn) { + receive = fn; + return () => {}; + }, + async close() {}, + async write() { + writes++; + }, + }; + const session = new ProvisioningSession(transport, 1000); + const transfer = session.writeChunk('0123456789abcdef', kind, 0, new Uint8Array([42])); + const rejected = expect(transfer).rejects.toBeInstanceOf(UncertainOutcome); + receive(encodeFrame(true, 2, new Uint8Array(payload))); + expect(session.uncertain).toBe(true); + await rejected; + // A delayed acknowledgement cannot clear the uncertainty lock. + const ack = chunkPayload('0123456789abcdef', kind, 1, new Uint8Array([42])).slice(0, 13); + receive(encodeFrame(true, 6, ack)); + await expect( + session.writeChunk('0123456789abcdef', kind, 1, new Uint8Array([43])), + ).rejects.toThrow('Reconnect'); + await expect(session.wifi('Test network', 'test wifi password')).rejects.toThrow( + 'Reconnect', + ); + await expect(session.password('test device password')).rejects.toThrow('Reconnect'); + expect(writes).toBe(1); + const status = session.status(); + receive( + encodeFrame( + true, + 4, + rpcPayload(1, ['1', '4', 'ready', 'active', '2', 'openathan-test.local', 'ready']), + ), + ); + expect((await status).wifi).toBe('4'); + expect(session.uncertain).toBe(true); + expect(writes).toBe(2); + await session.close(); + }, + ); + } + it.each([1, 2, 255])( + 'retains valid one-byte chunk error %i as a definite rejection', + async (code) => { + let receive!: (bytes: Uint8Array) => void; + const transport: ByteTransport = { + listen(fn) { + receive = fn; + return () => {}; + }, + async close() {}, + async write() {}, + }; + const session = new ProvisioningSession(transport, 1000); + const transfer = session.writeChunk('0123456789abcdef', 1, 0, new Uint8Array([42])); + const rejected = expect(transfer).rejects.toBeInstanceOf(DeviceError); + receive(encodeFrame(true, 2, new Uint8Array([code]))); + await rejected; + expect(session.uncertain).toBe(false); + await session.close(); + }, + ); + it('waits through a one-byte error clear for the exact chunk acknowledgement', async () => { + let receive!: (bytes: Uint8Array) => void; + let writes = 0; + const transport: ByteTransport = { + listen(fn) { + receive = fn; + return () => {}; + }, + async close() {}, + async write() { + writes++; + }, + }; + const session = new ProvisioningSession(transport, 1000); + let settled = false; + const transfer = session.writeChunk('0123456789abcdef', 1, 0, new Uint8Array([42])).then(() => { + settled = true; + }); + receive(encodeFrame(true, 2, new Uint8Array([0]))); + await Promise.resolve(); + expect(settled).toBe(false); + const ack = chunkPayload('0123456789abcdef', 1, 1, new Uint8Array([42])).slice(0, 13); + receive(encodeFrame(true, 6, ack)); + await transfer; + expect(session.uncertain).toBe(false); + expect(writes).toBe(1); + await session.close(); + }); + it('rejects oversized chunks and zero transaction tokens before transport', () => { + expect(() => chunkPayload('0000000000000000', 1, 0, new Uint8Array(1))).toThrow(); + expect(() => chunkPayload('0123456789abcdef', 1, 0, new Uint8Array(243))).toThrow(); + }); +}); diff --git a/tests/unit/usb-service.test.ts b/tests/unit/usb-service.test.ts new file mode 100644 index 0000000..b991d86 --- /dev/null +++ b/tests/unit/usb-service.test.ts @@ -0,0 +1,385 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +import { decodeFields, encodeFrame, FrameDecoder, rpcPayload } from '../../installer/protocol'; +import type { ByteTransport } from '../../installer/session'; +import { UncertainOutcome } from '../../installer/session'; +import { UPGRADE, UpgradeDownloadTimeout } from '../../installer/upgrade'; +const hooks = vi.hoisted(() => ({ open: vi.fn(), load: vi.fn(), flash: vi.fn() })); +vi.mock('../../installer/browser-transport', () => ({ + requestDevicePort: async () => ({}), + BrowserTransport: { open: hooks.open }, +})); +vi.mock('../../installer/upgrade', async (original) => ({ + ...(await original()), + loadUpgrade: hooks.load, +})); +vi.mock('../../installer/flasher', () => ({ flashBundle: hooks.flash })); +import { RealService } from '../../installer/real-service'; + +const offer = { + version: 'v0.5.0', + commit: 'a'.repeat(40), + bytes: 300, + sha256: 'b'.repeat(64), + notes: 'https://github.com/OpenAthan-Project/openathan/releases/tag/v0.5.0', +}; +const pin = { + tag: 'v0.5.0', + manifestSha256: 'c'.repeat(64), + mediaReviewed: true, + hardwareQualified: true, +} as const; +class Port implements ByteTransport { + private bytes?: (bytes: Uint8Array) => void; + commands: number[] = []; + chunks = 0; + selected = false; + firmwareState = 'idle'; + boot: 'pending' | 'confirmed' = 'confirmed'; + supported = true; + infoFailure?: 'malformed' | 'rejected'; + discardReply: 'success' | 'rejected' | 'malformed' = 'success'; + malformedChunkKind?: 0 | 1; + constructor( + public mode: + | 'success' + | 'legacy' + | 'selection-error' + | 'bad-readback' + | 'bad-begin' = 'success', + ) {} + listen(bytes: (bytes: Uint8Array) => void) { + this.bytes = bytes; + return () => { + this.bytes = undefined; + }; + } + async close() {} + async write(bytes: Uint8Array) { + const frame = new FrameDecoder().feed(bytes)[0]!; + const respond = (command: number, fields: string[]) => + this.bytes?.(encodeFrame(frame.extension, 4, rpcPayload(command, fields))); + if (frame.type === 5) { + this.chunks++; + if (frame.payload[8] === this.malformedChunkKind) { + this.bytes?.(encodeFrame(true, 2, new Uint8Array([255, 99]))); + return; + } + const ack = frame.payload.slice(0, 13), + view = new DataView(ack.buffer); + view.setUint32(9, view.getUint32(9, true) + frame.payload.length - 13, true); + this.bytes?.(encodeFrame(true, 6, ack)); + return; + } + const { command } = decodeFields(frame.payload); + this.commands.push(command); + if (!frame.extension) respond(command, ['OpenAthan', 'v0.4.0', 'ESP32-S3', 'OpenAthan']); + else if (command === 1) + respond(command, ['1', '4', 'ready', 'active', '2', 'openathan-test.local', 'ready']); + else if (command === UPGRADE.info) { + if (this.mode === 'legacy') this.bytes?.(encodeFrame(true, 2, new Uint8Array([2]))); + else if (this.infoFailure === 'rejected') + this.bytes?.(encodeFrame(true, 2, new Uint8Array([255]))); + else if (this.infoFailure === 'malformed') respond(command, ['malformed']); + else if (this.selected && this.mode === 'bad-readback') respond(command, ['malformed']); + else + respond(command, [ + '1', + 'v0.4.0', + 'd'.repeat(40), + this.supported ? 'supported' : 'unsupported', + this.selected ? 'awaiting_power' : this.firmwareState, + this.boot, + '', + this.selected ? offer.version : '', + '0', + ]); + } else if (command === UPGRADE.begin) + respond(command, [this.mode === 'bad-begin' ? '0000000000000000' : '0123456789abcdef']); + else if (command === UPGRADE.verify) respond(command, ['accepted', offer.version]); + else if (command === UPGRADE.finish) { + this.selected = true; + if (this.mode === 'selection-error') + this.bytes?.(encodeFrame(true, 2, new Uint8Array([255]))); + else respond(command, ['verified', offer.version]); + } else if (command === UPGRADE.abort) { + if (this.discardReply === 'rejected') + this.bytes?.(encodeFrame(true, 2, new Uint8Array([255]))); + else if (this.discardReply === 'malformed') respond(command, ['malformed']); + else { + this.firmwareState = 'idle'; + respond(command, ['aborted']); + } + } + } +} +beforeEach(() => { + vi.clearAllMocks(); + hooks.load.mockResolvedValue({ + offer, + descriptor: new Uint8Array([1, 2, 3]), + application: new Uint8Array(300), + }); +}); +it.each(['legacy', 'success'] as const)( + 'keeps public USB writes disabled for %s firmware', + async (mode) => { + const port = new Port(mode); + hooks.open.mockResolvedValue(port); + const service = new RealService(pin); + await service.connect(); + expect((await service.checkUpdate()).state).toBe( + mode === 'legacy' ? 'unsupported' : 'disabled', + ); + await expect(service.update(offer, () => {})).rejects.toThrow('review'); + expect(hooks.load).not.toHaveBeenCalled(); + expect(hooks.flash).not.toHaveBeenCalled(); + expect(port.chunks).toBe(0); + await service.close(); + }, +); +it('streams only the reviewed signed application and confirms handoff without flashing or retry', async () => { + const port = new Port(); + hooks.open.mockResolvedValue(port); + const service = new RealService(pin, true); + await service.connect(); + expect(await service.checkUpdate()).toEqual({ + state: 'available', + offer, + recoveryBlocked: false, + }); + const progress = vi.fn(); + await service.update(offer, progress); + expect(progress).toHaveBeenLastCalledWith(100); + expect(port.chunks).toBe(3); + expect(port.commands.filter((c) => c === UPGRADE.finish)).toHaveLength(1); + expect((await service.firmware()).state).toBe('awaiting_power'); + await expect(service.update(offer, progress)).rejects.toThrow('review'); + expect(hooks.flash).not.toHaveBeenCalled(); + await service.close(); +}); +it.each([0, 1] as const)( + 'stops a malformed chunk error in transfer kind %i before further update writes', + async (kind) => { + const port = new Port(); + port.malformedChunkKind = kind; + hooks.open.mockResolvedValue(port); + const service = new RealService(pin, true); + await service.connect(); + await service.checkUpdate(); + await expect(service.update(offer, () => {})).rejects.toBeInstanceOf(UncertainOutcome); + expect(port.chunks).toBe(kind === 0 ? 1 : 2); + expect(port.commands.filter((command) => command === UPGRADE.verify)).toHaveLength(kind); + expect(port.commands).not.toContain(UPGRADE.finish); + expect(port.selected).toBe(false); + const before = port.commands.length; + await expect(service.password('test device password')).rejects.toThrow('Reconnect'); + await expect(service.wifi('Test network', 'test wifi password')).rejects.toThrow('Reconnect'); + expect(port.commands).toHaveLength(before); + expect((await service.status()).wifi).toBe('4'); + expect((await service.firmware()).state).toBe('idle'); + expect(port.chunks).toBe(kind === 0 ? 1 : 2); + expect(hooks.flash).not.toHaveBeenCalled(); + await service.close(); + }, +); +it.each(['selection-error', 'bad-readback', 'bad-begin'] as const)( + 'blocks writes after %s and permits status reconciliation', + async (mode) => { + const port = new Port(mode); + hooks.open.mockResolvedValue(port); + const service = new RealService(pin, true); + await service.connect(); + await service.checkUpdate(); + await expect(service.update(offer, () => {})).rejects.toBeInstanceOf(UncertainOutcome); + const before = port.commands.length; + await expect(service.password('test device password')).rejects.toThrow('Reconnect'); + expect(port.commands).toHaveLength(before); + expect((await service.status()).wifi).toBe('4'); + expect(port.commands.filter((c) => c === UPGRADE.finish)).toHaveLength( + mode === 'bad-begin' ? 0 : 1, + ); + expect(hooks.flash).not.toHaveBeenCalled(); + await service.close(); + }, +); + +for (const state of [ + 'usb_descriptor', + 'usb_receiving', + 'usb_interrupted', + 'usb_selection_uncertain', + 'awaiting_power', +]) { + it.each(['pending', 'confirmed'] as const)( + `blocks recovery for ${state} with %s startup`, + async (boot) => { + const port = new Port(); + port.firmwareState = state; + port.boot = boot; + hooks.open.mockResolvedValue(port); + const service = new RealService(pin); + await service.connect(); + expect(port.commands).toEqual([3, 1]); + expect(await service.checkUpdate()).toMatchObject({ + state: 'busy', + recoveryBlocked: true, + action: + state === 'awaiting_power' + ? 'power' + : state === 'usb_interrupted' && boot === 'confirmed' + ? 'discard' + : undefined, + }); + expect(port.commands).toEqual([3, 1, UPGRADE.info]); + expect(hooks.load).not.toHaveBeenCalled(); + await service.close(); + }, + ); +} + +it.each([ + 'idle', + 'current', + 'available', + 'success', + 'rolled_back', + 'failed', + 'checking', + 'queued', + 'downloading', + 'verifying', + 'restarting', + 'storage_fault', +])('does not infer USB ownership from ordinary %s activity', async (state) => { + const port = new Port(); + port.firmwareState = state; + hooks.open.mockResolvedValue(port); + const service = new RealService(pin); + await service.connect(); + expect((await service.checkUpdate()).recoveryBlocked).toBe(false); + expect(hooks.load).not.toHaveBeenCalled(); + await service.close(); +}); + +it('does not infer USB ownership from ordinary pending startup or legacy firmware', async () => { + const port = new Port(); + port.boot = 'pending'; + hooks.open.mockResolvedValue(port); + const service = new RealService(pin); + await service.connect(); + expect(await service.checkUpdate()).toMatchObject({ state: 'busy', recoveryBlocked: false }); + port.mode = 'legacy'; + expect(await service.checkUpdate()).toMatchObject({ + state: 'unsupported', + recoveryBlocked: false, + }); + await service.close(); +}); + +it.each(['malformed', 'rejected'] as const)( + 'leaves ownership unknown after %s INFO', + async (failure) => { + const port = new Port(); + port.infoFailure = failure; + hooks.open.mockResolvedValue(port); + const service = new RealService(pin); + await service.connect(); + const result = await service.checkUpdate(); + expect(result.state).toBe('failed'); + expect(result).not.toHaveProperty('recoveryBlocked'); + await service.close(); + }, +); + +it('retains successful INFO ownership metadata when release verification fails', async () => { + const port = new Port(); + hooks.open.mockResolvedValue(port); + hooks.load.mockRejectedValue(new Error('Invalid signature')); + const service = new RealService(pin, true); + await service.connect(); + expect(await service.checkUpdate()).toMatchObject({ state: 'failed', recoveryBlocked: false }); + await service.close(); +}); + +it('reports a download timeout without mutation or retry and permits a deliberate fresh check', async () => { + const port = new Port(); + hooks.open.mockResolvedValue(port); + hooks.load.mockRejectedValueOnce(new UpgradeDownloadTimeout()); + const service = new RealService(pin, true); + await service.connect(); + expect(await service.checkUpdate()).toEqual({ + state: 'failed', + recoveryBlocked: false, + detail: + 'The firmware download timed out. Check your internet connection and choose Check for updates again. Installed firmware has not changed.', + }); + await expect(service.update(offer, () => {})).rejects.toThrow('review'); + expect(hooks.load).toHaveBeenCalledTimes(1); + expect(port.commands).toEqual([3, 1, UPGRADE.info]); + expect(port.chunks).toBe(0); + expect(hooks.flash).not.toHaveBeenCalled(); + expect(await service.checkUpdate()).toMatchObject({ state: 'available', recoveryBlocked: false }); + expect(hooks.load).toHaveBeenCalledTimes(2); + expect(port.commands).toEqual([3, 1, UPGRADE.info, UPGRADE.info]); + await service.close(); +}); + +it('keeps USB ownership ahead of bootloader capability reporting', async () => { + const port = new Port(); + port.supported = false; + hooks.open.mockResolvedValue(port); + const service = new RealService(pin); + await service.connect(); + expect(await service.checkUpdate()).toMatchObject({ + state: 'unsupported', + recoveryBlocked: false, + }); + port.firmwareState = 'awaiting_power'; + expect(await service.checkUpdate()).toMatchObject({ + state: 'busy', + action: 'power', + recoveryBlocked: true, + }); + await service.close(); +}); + +it('requires fresh confirmed startup health before discarding and reads cleared ownership afterward', async () => { + const port = new Port(); + port.firmwareState = 'usb_interrupted'; + port.boot = 'pending'; + hooks.open.mockResolvedValue(port); + const service = new RealService(pin); + await service.connect(); + await expect(service.discardUpdate()).rejects.toThrow('Read update status'); + expect(port.commands).not.toContain(UPGRADE.abort); + port.boot = 'confirmed'; + await service.discardUpdate(); + expect(port.commands.filter((c) => c === UPGRADE.abort)).toHaveLength(1); + expect(await service.checkUpdate()).toMatchObject({ state: 'disabled', recoveryBlocked: false }); + expect(hooks.flash).not.toHaveBeenCalled(); + await service.close(); +}); + +it.each(['rejected', 'malformed'] as const)( + 'does not retry a %s discard or claim cleared ownership', + async (reply) => { + const port = new Port(); + port.firmwareState = 'usb_interrupted'; + port.discardReply = reply; + hooks.open.mockResolvedValue(port); + const service = new RealService(pin); + await service.connect(); + await expect(service.discardUpdate()).rejects.toThrow(); + expect(port.commands.filter((c) => c === UPGRADE.abort)).toHaveLength(1); + expect(await service.checkUpdate()).toMatchObject({ + state: 'busy', + action: 'discard', + recoveryBlocked: true, + }); + if (reply === 'malformed') { + await expect(service.discardUpdate()).rejects.toThrow('Reconnect'); + expect(port.commands.filter((c) => c === UPGRADE.abort)).toHaveLength(1); + } + await service.close(); + }, +); diff --git a/tools/check-static.mjs b/tools/check-static.mjs index 4687789..be0ace1 100644 --- a/tools/check-static.mjs +++ b/tools/check-static.mjs @@ -1,6 +1,7 @@ import { readFile, readdir, stat } from 'node:fs/promises'; import { resolve, dirname, join } from 'node:path'; import { checkedManifest, verifyBundle } from '../installer/release.ts'; +import { verifyDescriptor, verifyApplication } from '../installer/upgrade.ts'; import { readSelection, metadata, parseMetadata } from './release-selection.ts'; const root = resolve(import.meta.dirname, '..'); @@ -64,7 +65,24 @@ if (pin) { for (const part of manifest.parts) inputs.set(part.file, new Uint8Array(await readFile(join(directory, part.file)))); await verifyBundle(manifest, inputs); - if (binaries.length !== manifest.parts.length) + const otaPath = join(directory, 'firmware.ota.bin'); + const descriptorPath = join(directory, 'upgrade.json'); + const hasOta = output.includes(otaPath), + hasDescriptor = output.includes(descriptorPath); + if (hasOta !== hasDescriptor || (selection.usbUpdateEnabled && !hasOta)) + throw new Error('Selected USB update artifacts are incomplete.'); + if (hasOta) { + const offer = await verifyDescriptor(new Uint8Array(await readFile(descriptorPath)), manifest); + const application = new Uint8Array(await readFile(otaPath)); + await verifyApplication(offer, application); + const factory = inputs.get(manifest.parts.find((part) => part.role === 'factory').file); + if ( + factory.length !== 0x10000 + application.length || + !application.every((byte, i) => byte === factory[0x10000 + i]) + ) + throw new Error('USB application differs from the verified factory image.'); + } + if (binaries.length !== manifest.parts.length + Number(hasOta)) throw new Error('Unexpected release binaries in static output.'); } console.log( diff --git a/tools/import-release.ts b/tools/import-release.ts index e3f9e25..7be9fac 100644 --- a/tools/import-release.ts +++ b/tools/import-release.ts @@ -3,6 +3,7 @@ import { mkdir, writeFile, rename, rm } from 'node:fs/promises'; import { execFileSync } from 'node:child_process'; import { resolve } from 'node:path'; import { checkedManifest, verifyBundle } from '../installer/release.ts'; +import { verifyDescriptor, verifyApplication } from '../installer/upgrade.ts'; import { publishedRelease, readSelection, releaseAsset } from './release-selection.ts'; const root = resolve(import.meta.dirname, '..'); @@ -26,6 +27,28 @@ if (!pin) { for (const part of manifest.parts) inputs.set(part.file, await releaseAsset(release, part.file, part.bytes)); await verifyBundle(manifest, inputs); + const upgradeAssets = release.assets.filter( + (asset) => + asset && + typeof asset === 'object' && + ['upgrade.json', 'firmware.ota.bin'].includes((asset as { name: string }).name), + ); + if (upgradeAssets.length) { + if (upgradeAssets.length !== 2) throw new Error('Incomplete upgrade asset pair.'); + const descriptor = await releaseAsset(release, 'upgrade.json', 8192), + offer = await verifyDescriptor(descriptor, manifest); + const application = await releaseAsset(release, 'firmware.ota.bin', offer.bytes); + await verifyApplication(offer, application); + const factory = inputs.get('firmware.factory.bin'); + if ( + !factory || + factory.length !== 0x10000 + application.length || + !application.every((byte, i) => byte === factory[0x10000 + i]) + ) + throw new Error('Upgrade differs from the selected factory application.'); + inputs.set('upgrade.json', descriptor); + inputs.set('firmware.ota.bin', application); + } const temporary = resolve(root, 'build/release-import'); await rm(temporary, { force: true, recursive: true }); try { diff --git a/tools/release-selection.ts b/tools/release-selection.ts index 91a5040..0559bab 100644 --- a/tools/release-selection.ts +++ b/tools/release-selection.ts @@ -7,10 +7,12 @@ export const FIRMWARE_API = 'https://api.github.com/repos/OpenAthan-Project/open export const WEBSITE_API = 'https://api.github.com/repos/OpenAthan-Project/website'; export const DEPLOYED_METADATA = 'https://openathan.com/release.json'; export interface Policy { + usbUpdateEnabled?: boolean; automatic: boolean; pin: ReleasePin | null; } export interface Selection { + usbUpdateEnabled?: boolean; schema: 1; automatic: boolean; policySha256: string; @@ -27,12 +29,20 @@ export function parsePolicy(value: unknown): Policy { const automatic = (value as { automatic?: unknown }).automatic; if (automatic !== undefined && typeof automatic !== 'boolean') throw new Error('Invalid automatic release policy.'); - return { automatic: automatic === true, pin }; + const usbUpdateEnabled = (value as { usbUpdateEnabled?: unknown }).usbUpdateEnabled; + if (usbUpdateEnabled !== undefined && typeof usbUpdateEnabled !== 'boolean') + throw new Error('Invalid USB update policy.'); + return { + automatic: automatic === true, + pin, + ...(usbUpdateEnabled === undefined ? {} : { usbUpdateEnabled }), + }; } export function parseSelection(value: unknown): Selection { const pin = parsePin(value); const s = value as Selection; if ( + (s.usbUpdateEnabled !== undefined && typeof s.usbUpdateEnabled !== 'boolean') || typeof s.automatic !== 'boolean' || typeof s.policySha256 !== 'string' || !/^[a-f0-9]{64}$/.test(s.policySha256) || @@ -237,6 +247,7 @@ export async function readSelection(root = process.cwd()): Promise { const { policy, policySha256 } = await readPolicy(root); if ( selection.policySha256 !== policySha256 || + selection.usbUpdateEnabled !== policy.usbUpdateEnabled || selection.automatic !== policy.automatic || (!policy.automatic && !sameRelease(selection.release, policy.pin)) || (!policy.pin && selection.release) diff --git a/tools/select-release.ts b/tools/select-release.ts index 1bd4a0a..fc10c6b 100644 --- a/tools/select-release.ts +++ b/tools/select-release.ts @@ -29,6 +29,7 @@ if (process.argv.includes('--freshness')) { const selection = parseSelection({ schema: 1, automatic: policy.automatic, + ...(policy.usbUpdateEnabled === undefined ? {} : { usbUpdateEnabled: policy.usbUpdateEnabled }), policySha256, websiteCommit, release: await resolveRelease(policy),