feat: force web usb

This commit is contained in:
2026-02-11 18:32:06 +01:00
parent 5e4283a462
commit dee754c015
5 changed files with 119 additions and 86 deletions

View File

@@ -1,4 +1,4 @@
import { get, writable } from "svelte/store"; import { derived, get, writable } from "svelte/store";
import { CharaDevice, type SerialPortLike } from "$lib/serial/device"; import { CharaDevice, type SerialPortLike } from "$lib/serial/device";
import type { Chord } from "$lib/serial/chord"; import type { Chord } from "$lib/serial/chord";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
@@ -7,12 +7,27 @@ import { persistentWritable } from "$lib/storage";
import { userPreferences } from "$lib/preferences"; import { userPreferences } from "$lib/preferences";
import { getMeta } from "$lib/meta/meta-storage"; import { getMeta } from "$lib/meta/meta-storage";
import type { VersionMeta } from "$lib/meta/types/meta"; import type { VersionMeta } from "$lib/meta/types/meta";
import { serial as serialPolyfill } from "web-serial-polyfill";
export const serialPort = writable<CharaDevice | undefined>(); export const serialPort = writable<CharaDevice | undefined>();
navigator.serial?.addEventListener("disconnect", async (event) => { export const forceWebUSB = persistentWritable("force-webusb", false);
async function onSerialDisconnect() {
serialPort.set(undefined); serialPort.set(undefined);
}); }
export const serialObject = derived<typeof forceWebUSB, Serial>(
forceWebUSB,
(forceWebUSB) =>
forceWebUSB || !("serial" in navigator)
? (serialPolyfill as any as Serial)
: navigator.serial,
);
if ("serial" in navigator) {
navigator.serial.addEventListener("disconnect", onSerialDisconnect);
}
export interface SerialLogEntry { export interface SerialLogEntry {
type: "input" | "output" | "system"; type: "input" | "output" | "system";

View File

@@ -7,7 +7,6 @@ import {
stringifyChordActions, stringifyChordActions,
stringifyPhrase, stringifyPhrase,
} from "$lib/serial/chord"; } from "$lib/serial/chord";
import { browser } from "$app/environment";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog"; import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
import semverGte from "semver/functions/gte"; import semverGte from "semver/functions/gte";
@@ -71,23 +70,8 @@ const KEY_COUNTS = {
ZERO: 256, ZERO: 256,
} as const; } as const;
if ( export async function getViablePorts(serial: Serial): Promise<SerialPort[]> {
browser && return serial.getPorts().then((ports) =>
navigator.serial === undefined &&
import.meta.env.TAURI_FAMILY !== undefined
) {
await import("./tauri-serial");
}
if (browser && navigator.serial === undefined && navigator.usb !== undefined) {
// @ts-expect-error polyfill
navigator.serial = await import("web-serial-polyfill").then(
({ serial }) => serial,
);
}
export async function getViablePorts(): Promise<SerialPort[]> {
return navigator.serial.getPorts().then((ports) =>
ports.filter((it) => { ports.filter((it) => {
const { usbProductId, usbVendorId } = it.getInfo(); const { usbProductId, usbVendorId } = it.getInfo();
for (const filter of PORT_FILTERS.values()) { for (const filter of PORT_FILTERS.values()) {
@@ -109,8 +93,8 @@ type LengthArray<T, N extends number, R extends T[] = []> = number extends N
? R ? R
: LengthArray<T, N, [T, ...R]>; : LengthArray<T, N, [T, ...R]>;
export async function canAutoConnect() { export async function canAutoConnect(serial: Serial) {
return getViablePorts().then((it) => it.length === 1); return getViablePorts(serial).then((it) => it.length === 1);
} }
async function timeout<T>(promise: Promise<T>, ms: number): Promise<T> { async function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {

View File

@@ -12,7 +12,7 @@
themeFromSourceColor, themeFromSourceColor,
} from "@material/material-color-utilities"; } from "@material/material-color-utilities";
import { canAutoConnect, getViablePorts } from "$lib/serial/device"; import { canAutoConnect, getViablePorts } from "$lib/serial/device";
import { initSerial } from "$lib/serial/connection"; import { initSerial, serialObject } from "$lib/serial/connection";
import type { LayoutData } from "./$types"; import type { LayoutData } from "./$types";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import "tippy.js/animations/shift-away.css"; import "tippy.js/animations/shift-away.css";
@@ -74,8 +74,12 @@
webManifestLink = await initPwa(); webManifestLink = await initPwa();
} }
if (browser && $userPreferences.autoConnect && (await canAutoConnect())) { if (
const [port] = await getViablePorts(); browser &&
$userPreferences.autoConnect &&
(await canAutoConnect($serialObject))
) {
const [port] = await getViablePorts($serialObject);
await initSerial(port!, true); await initSerial(port!, true);
} }

View File

@@ -1,21 +1,27 @@
<script lang="ts"> <script lang="ts">
import LL from "$i18n/i18n-svelte"; import LL from "$i18n/i18n-svelte";
import { preference, userPreferences } from "$lib/preferences"; import { preference, userPreferences } from "$lib/preferences";
import { initSerial } from "$lib/serial/connection"; import {
forceWebUSB,
initSerial,
serialObject,
} from "$lib/serial/connection";
import { import {
getPortName, getPortName,
PORT_FILTERS, PORT_FILTERS,
type SerialPortLike, type SerialPortLike,
} from "$lib/serial/device"; } from "$lib/serial/device";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog"; import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
import { onMount } from "svelte";
import { persistentWritable } from "$lib/storage"; import { persistentWritable } from "$lib/storage";
import { browser } from "$app/environment";
let ports = $state<SerialPort[]>([]); let ports = $state<SerialPort[]>([]);
let element: HTMLDivElement | undefined = $state(); let element: HTMLDivElement | undefined = $state();
let supportsWebSerial = browser && "serial" in navigator;
let supportsWebUSB = browser && "usb" in navigator;
onMount(() => { $effect(() => {
refreshPorts(); refreshPorts($serialObject);
}); });
let hasDiscoveredAutoConnect = persistentWritable( let hasDiscoveredAutoConnect = persistentWritable(
@@ -29,8 +35,8 @@
} }
}); });
async function refreshPorts() { async function refreshPorts(serial: Serial) {
ports = await navigator.serial.getPorts(); ports = await serial.getPorts();
} }
async function connect(port: SerialPortLike, withSync: boolean) { async function connect(port: SerialPortLike, withSync: boolean) {
@@ -46,13 +52,13 @@
element?.closest<HTMLElement>("[popover]")?.hidePopover(); element?.closest<HTMLElement>("[popover]")?.hidePopover();
} }
async function connectDevice(event: MouseEvent) { async function connectDevice(event: MouseEvent, serial: Serial) {
const port = await navigator.serial.requestPort({ const port = await serial.requestPort({
filters: event.shiftKey ? [] : [...PORT_FILTERS.values()], filters: event.shiftKey ? [] : [...PORT_FILTERS.values()],
}); });
if (!port) return; if (!port) return;
closePopover(); closePopover();
refreshPorts(); refreshPorts(serial);
connect(port, true); connect(port, true);
} }
</script> </script>
@@ -60,55 +66,79 @@
<div <div
bind:this={element} bind:this={element}
class="device-list" class="device-list"
onmouseenter={() => refreshPorts()} onmouseenter={() => refreshPorts($serialObject)}
role="region" role="region"
> >
{#if ports.length === 1} {#if supportsWebSerial || supportsWebUSB}
<fieldset class:promote={!$hasDiscoveredAutoConnect}> <fieldset class:promote={!$hasDiscoveredAutoConnect}>
<label {#if ports.length === 1}
><input type="checkbox" use:preference={"autoConnect"} /> <label
<div class="title">{$LL.deviceManager.AUTO_CONNECT()}</div> ><input type="checkbox" use:preference={"autoConnect"} />
</label> <div class="title">{$LL.deviceManager.AUTO_CONNECT()}</div>
</label>
<label <label
><input type="checkbox" use:preference={"backup"} /> ><input type="checkbox" use:preference={"backup"} />
<div class="title">{@html $LL.backup.AUTO_BACKUP()}</div> <div class="title">{@html $LL.backup.AUTO_BACKUP()}</div>
</label>
{/if}
<label title="You can try this if you have trouble with the connection."
><input
type="checkbox"
disabled={!supportsWebSerial}
checked={!supportsWebSerial || $forceWebUSB}
onchange={(event) => {
$forceWebUSB = (event.target as HTMLInputElement).checked;
}}
/>
<div class="title">WebUSB Fallback</div>
</label> </label>
</fieldset> </fieldset>
{/if} {#if ports.length !== 0}
{#if ports.length !== 0} <h4>Recent Devices</h4>
<h4>Recent Devices</h4> <div class="devices">
<div class="devices"> <!--
<!--
<div class="device"> <div class="device">
<button onclick={connectCC0}> CC0</button> <button onclick={connectCC0}> CC0</button>
</div>--> </div>-->
{#each ports as port} {#each ports as port}
<div class="device"> <div class="device">
<button <button
onclick={(event) => { onclick={(event) => {
connect(port, !event.shiftKey); connect(port, !event.shiftKey);
}} }}
> >
{getPortName(port)}</button {getPortName(port)}</button
> >
<button <button
class="error" class="error"
onclick={() => { onclick={() => {
port.forget(); port.forget();
refreshPorts(); refreshPorts($serialObject);
}}><span class="icon">visibility_off</span> Hide</button }}><span class="icon">visibility_off</span> Hide</button
> >
</div> </div>
{/each} {/each}
</div>
{/if}
<div class="pair">
<button
onclick={(event) => connectDevice(event, $serialObject)}
class="primary"><span class="icon">add</span>Connect</button
>
<!--<a href="/ccos/zero_wasm/"><span class="icon">add</span>Virtual Device</a>-->
</div> </div>
{#if !supportsWebSerial}
<p>Browser with limited support detected.</p>
{/if}
{:else}
<p><b>Your browser is missing support for critical features.</b></p>
<p>
Please use a Chromium-based browser such as Chrome, Edge or Chromium
instead.
</p>
{/if} {/if}
<div class="pair">
<button onclick={connectDevice} class="primary"
><span class="icon">add</span>Connect</button
>
<!--<a href="/ccos/zero_wasm/"><span class="icon">add</span>Virtual Device</a>-->
</div>
</div> </div>
<style lang="scss"> <style lang="scss">

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { downloadBackup } from "$lib/backup/backup"; import { downloadBackup } from "$lib/backup/backup";
import { initSerial, serialPort } from "$lib/serial/connection"; import { initSerial, serialObject, serialPort } from "$lib/serial/connection";
import { fade, slide } from "svelte/transition"; import { fade, slide } from "svelte/transition";
import { lt as semverLt } from "semver"; import { lt as semverLt } from "semver";
import type { LoaderOptions, ESPLoader } from "esptool-js"; import type { LoaderOptions, ESPLoader } from "esptool-js";
@@ -95,9 +95,9 @@
} }
} }
async function connect() { async function connect(serial: Serial) {
try { try {
const port = await navigator.serial.requestPort(); const port = await serial.requestPort();
await initSerial(port!, true); await initSerial(port!, true);
step = 1; step = 1;
} catch (e) { } catch (e) {
@@ -138,9 +138,9 @@
step = 4; step = 4;
} }
async function espBootloader() { async function espBootloader(serial: Serial) {
$serialPort?.forget(); $serialPort?.forget();
const port = await navigator.serial.requestPort(); const port = await serial.requestPort();
port.open({ baudRate: 1200 }); port.open({ baudRate: 1200 });
} }
@@ -172,8 +172,8 @@
return espLoader; return espLoader;
} }
async function flashImages() { async function flashImages(serial: Serial) {
const port = await navigator.serial.requestPort(); const port = await serial.requestPort();
try { try {
const esptool = data.meta.update.esptool!; const esptool = data.meta.update.esptool!;
espLoader = await connectEsp(port); espLoader = await connectEsp(port);
@@ -202,8 +202,8 @@
} }
} }
async function eraseSPI() { async function eraseSPI(serial: Serial) {
const port = await navigator.serial.requestPort(); const port = await serial.requestPort();
try { try {
console.log(data.meta); console.log(data.meta);
const spiFlash = data.meta.spiFlash!; const spiFlash = data.meta.spiFlash!;
@@ -314,7 +314,7 @@
<section> <section>
<ol> <ol>
<li> <li>
<button class="inline-button" onclick={connect} <button class="inline-button" onclick={() => connect($serialObject)}
><span class="icon">usb</span>Connect</button ><span class="icon">usb</span>Connect</button
> >
your device your device
@@ -367,17 +367,17 @@
</p> </p>
<div class="esp-buttons"> <div class="esp-buttons">
<button onclick={espBootloader} <button onclick={() => espBootloader($serialObject)}
><span class="icon">memory</span>ESP Bootloader</button ><span class="icon">memory</span>ESP Bootloader</button
> >
<button onclick={flashImages} <button onclick={() => flashImages($serialObject)}
><span class="icon">developer_board</span>Flash Images</button ><span class="icon">developer_board</span>Flash Images</button
> >
<label <label
><input type="checkbox" id="erase" bind:checked={eraseAll} />Erase ><input type="checkbox" id="erase" bind:checked={eraseAll} />Erase
All</label All</label
> >
<button onclick={eraseSPI} <button onclick={() => eraseSPI($serialObject)}
><span class="icon">developer_board</span>Erase SPI Flash</button ><span class="icon">developer_board</span>Erase SPI Flash</button
> >
</div> </div>