From b96635a862fcc177b30655867a1ad75294822bf7 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Mon, 9 Mar 2026 12:31:45 +0100 Subject: [PATCH 1/3] Scope serial and flash manager to component lifetime Replace global serial port state with a scoped SerialContext utility and add a FlashContext that manages FlashManager lifecycle with onMount cleanup. Both contexts automatically clean up (close ports, disconnect) on unmount. Co-Authored-By: Claude Opus 4.6 --- src/lib/state/serial-ports-state.svelte.ts | 53 ------------- src/lib/utils/serial-context.svelte.ts | 76 +++++++++++++++++++ src/routes/flashtool/+page.svelte | 62 +++++++-------- src/routes/flashtool/FirmwareFlasher.svelte | 30 +++----- .../flashtool/SerialPortSelector.svelte | 9 ++- src/routes/flashtool/flash-context.svelte.ts | 60 +++++++++++++++ 6 files changed, 181 insertions(+), 109 deletions(-) delete mode 100644 src/lib/state/serial-ports-state.svelte.ts create mode 100644 src/lib/utils/serial-context.svelte.ts create mode 100644 src/routes/flashtool/flash-context.svelte.ts diff --git a/src/lib/state/serial-ports-state.svelte.ts b/src/lib/state/serial-ports-state.svelte.ts deleted file mode 100644 index fe13db94..00000000 --- a/src/lib/state/serial-ports-state.svelte.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { isSerialSupported } from '$lib/utils/compatibility'; - -const ports = $state([]); - -let initialized = false; - -function addPort(port: SerialPort) { - if (!ports.includes(port)) { - ports.push(port); - } -} -function removePort(port: SerialPort) { - const idx = ports.indexOf(port); - if (idx !== -1) { - ports.splice(idx, 1); - } -} - -function ensureInitialized() { - if (initialized || !isSerialSupported) return; - initialized = true; - - navigator.serial.addEventListener('connect', (e) => addPort(e.target as SerialPort)); - navigator.serial.addEventListener('disconnect', (e) => removePort(e.target as SerialPort)); - - navigator.serial - .getPorts() - .then((existingPorts) => { - for (const p of existingPorts) { - addPort(p); - } - }) - .catch((error) => { - console.error('Failed to get ports', error); - }); -} - -export const serialPortsState = { - get ports() { - ensureInitialized(); - return ports; - }, - requestPort: async (options: SerialPortRequestOptions) => { - if (!isSerialSupported) return null; - ensureInitialized(); - - const port = await navigator.serial.requestPort(options); - - addPort(port); - - return port; - }, -}; diff --git a/src/lib/utils/serial-context.svelte.ts b/src/lib/utils/serial-context.svelte.ts new file mode 100644 index 00000000..2203b16a --- /dev/null +++ b/src/lib/utils/serial-context.svelte.ts @@ -0,0 +1,76 @@ +import { isSerialSupported } from '$lib/utils/compatibility'; +import { onMount } from 'svelte'; + +export class SerialContext { + #ports = $state([]); + #onConnect: (e: Event) => void; + #onDisconnect: (e: Event) => void; + + constructor() { + this.#onConnect = (e) => this.#addPort(e.target as SerialPort); + this.#onDisconnect = (e) => this.#removePort(e.target as SerialPort); + + if (isSerialSupported) { + navigator.serial.addEventListener('connect', this.#onConnect); + navigator.serial.addEventListener('disconnect', this.#onDisconnect); + + navigator.serial + .getPorts() + .then((existing) => { + for (const p of existing) { + this.#addPort(p); + } + }) + .catch((error) => { + console.error('Failed to get serial ports', error); + }); + } + } + + get ports(): readonly SerialPort[] { + return this.#ports; + } + + async requestPort(options: SerialPortRequestOptions): Promise { + if (!isSerialSupported) return null; + + const port = await navigator.serial.requestPort(options); + this.#addPort(port); + return port; + } + + destroy() { + if (isSerialSupported) { + navigator.serial.removeEventListener('connect', this.#onConnect); + navigator.serial.removeEventListener('disconnect', this.#onDisconnect); + } + for (const port of this.#ports) { + port.close().catch(() => {}); + } + this.#ports.length = 0; + } + + #addPort(port: SerialPort) { + if (!this.#ports.includes(port)) { + this.#ports.push(port); + } + } + + #removePort(port: SerialPort) { + const idx = this.#ports.indexOf(port); + if (idx !== -1) { + this.#ports.splice(idx, 1); + } + } +} + +/** + * Creates a SerialContext scoped to the current component's lifetime. + * Must be called during component initialization (top-level script). + * Automatically cleans up event listeners on unmount. + */ +export function useSerial(): SerialContext { + const ctx = new SerialContext(); + onMount(() => () => ctx.destroy()); + return ctx; +} diff --git a/src/routes/flashtool/+page.svelte b/src/routes/flashtool/+page.svelte index 8e00ba99..16bfa57e 100644 --- a/src/routes/flashtool/+page.svelte +++ b/src/routes/flashtool/+page.svelte @@ -15,17 +15,17 @@ import { Label } from '$lib/components/ui/label'; import { Progress } from '$lib/components/ui/progress'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/ui/sheet'; + import { useSerial } from '$lib/utils/serial-context.svelte'; import { getBrowserName, isSerialSupported } from '$lib/utils/compatibility'; import FirmwareBoardSelector from './FirmwareBoardSelector.svelte'; import FirmwareFlasher from './FirmwareFlasher.svelte'; - import FlashManager from './FlashManager'; + import { useFlashManager } from './flash-context.svelte'; import HelpDialog from './HelpDialog.svelte'; import SerialPortSelector from './SerialPortSelector.svelte'; + const serial = useSerial(); + let port = $state(null); - let manager = $state(null); - let connectFailed = $state(false); - let isFlashing = $state(false); let terminalOpen = $state(false); let terminalText = $state(''); @@ -43,16 +43,13 @@ }, }; + const flash = useFlashManager(terminal); + $effect(() => { - if (port && !manager) { - connectFailed = false; - FlashManager.Connect(port, terminal).then((m) => { - manager = m; - connectFailed = !manager; - }); - } else if (!port && manager) { - manager.disconnect(); - manager = null; + if (port && !flash.manager) { + flash.connect(port); + } else if (!port && flash.manager) { + flash.disconnect(); } }); @@ -64,52 +61,52 @@ let eraseBeforeFlash = $state(false); async function AppModeDevice() { - if (isFlashing) return; + if (flash.isFlashing) return; try { - isFlashing = true; + flash.isFlashing = true; - if (!manager) { + if (!flash.manager) { terminal.writeLine(`Host-side error during reset: no device!`); return; } - await manager.ensureApplication(true); + await flash.manager.ensureApplication(true); } catch (e) { terminal.writeLine(`Host-side error during reset: ${e}`); } finally { - isFlashing = false; + flash.isFlashing = false; } } async function RunCommand() { - if (isFlashing) return; + if (flash.isFlashing) return; try { - isFlashing = true; + flash.isFlashing = true; - if (!manager) { + if (!flash.manager) { terminal.writeLine(`Couldn't send: no device!`); return; } - await manager.ensureApplication(); - await manager.sendApplicationCommand(terminalCommand); + await flash.manager.ensureApplication(); + await flash.manager.sendApplicationCommand(terminalCommand); } catch (e) { terminal.writeLine(`Couldn't send: ${e}`); } finally { - isFlashing = false; + flash.isFlashing = false; } } {#snippet mainContent()} - + - {#if manager} + {#if flash.manager}

Select Channel

- +

Select Board

- +
@@ -131,13 +128,12 @@ {/if} - {:else if port && !connectFailed} + {:else if port && !flash.connectFailed}
Connecting... @@ -145,7 +141,7 @@
{/if} - {#if port && connectFailed} + {#if port && flash.connectFailed}
Device connection failed @@ -227,7 +223,7 @@
- +
('none'); let progressName = $state(null); @@ -30,7 +22,7 @@ let error = $state(null); async function FlashDeviceImpl() { - if (!version || !board || !manager) { + if (!version || !board || !flash.manager) { progressName = null; error = 'No device selected.'; return; @@ -45,7 +37,7 @@ progressName = 'Resetting...'; progressPercent = null; - await manager.ensureBootloader(); + await flash.manager.ensureBootloader(); progressName = 'Downloading firmware...'; progressPercent = null; @@ -58,28 +50,28 @@ progressName = 'Flashing firmware...'; progressPercent = null; - await manager.flash(firmware, eraseBeforeFlash, progressCallback); + await flash.manager.flash(firmware, eraseBeforeFlash, progressCallback); progressName = 'Rebooting device... (Reconnect to power manually if stuck)'; progressPercent = null; - await manager.ensureApplication(); + await flash.manager.ensureApplication(); progressName = 'Rebooted device! Flashing complete.'; progressPercent = 100; } async function FlashDevice() { - if (isFlashing) return; + if (flash.isFlashing) return; if (showNonStableWarning && riskAcknowledgeStatus !== 'accepted') { riskAcknowledgeStatus = 'shown'; return; } try { - isFlashing = true; + flash.isFlashing = true; await FlashDeviceImpl(); } catch (e) { error = (e as Error).message; } finally { - isFlashing = false; + flash.isFlashing = false; } } @@ -92,7 +84,7 @@
- diff --git a/src/routes/flashtool/SerialPortSelector.svelte b/src/routes/flashtool/SerialPortSelector.svelte index fad5c1f5..15a286e3 100644 --- a/src/routes/flashtool/SerialPortSelector.svelte +++ b/src/routes/flashtool/SerialPortSelector.svelte @@ -1,15 +1,16 @@