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 type { Chord } from "$lib/serial/chord";
import type { Writable } from "svelte/store";
@@ -7,12 +7,27 @@ import { persistentWritable } from "$lib/storage";
import { userPreferences } from "$lib/preferences";
import { getMeta } from "$lib/meta/meta-storage";
import type { VersionMeta } from "$lib/meta/types/meta";
import { serial as serialPolyfill } from "web-serial-polyfill";
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);
});
}
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 {
type: "input" | "output" | "system";

View File

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

View File

@@ -12,7 +12,7 @@
themeFromSourceColor,
} from "@material/material-color-utilities";
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 { browser } from "$app/environment";
import "tippy.js/animations/shift-away.css";
@@ -74,8 +74,12 @@
webManifestLink = await initPwa();
}
if (browser && $userPreferences.autoConnect && (await canAutoConnect())) {
const [port] = await getViablePorts();
if (
browser &&
$userPreferences.autoConnect &&
(await canAutoConnect($serialObject))
) {
const [port] = await getViablePorts($serialObject);
await initSerial(port!, true);
}

View File

@@ -1,21 +1,27 @@
<script lang="ts">
import LL from "$i18n/i18n-svelte";
import { preference, userPreferences } from "$lib/preferences";
import { initSerial } from "$lib/serial/connection";
import {
forceWebUSB,
initSerial,
serialObject,
} from "$lib/serial/connection";
import {
getPortName,
PORT_FILTERS,
type SerialPortLike,
} from "$lib/serial/device";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
import { onMount } from "svelte";
import { persistentWritable } from "$lib/storage";
import { browser } from "$app/environment";
let ports = $state<SerialPort[]>([]);
let element: HTMLDivElement | undefined = $state();
let supportsWebSerial = browser && "serial" in navigator;
let supportsWebUSB = browser && "usb" in navigator;
onMount(() => {
refreshPorts();
$effect(() => {
refreshPorts($serialObject);
});
let hasDiscoveredAutoConnect = persistentWritable(
@@ -29,8 +35,8 @@
}
});
async function refreshPorts() {
ports = await navigator.serial.getPorts();
async function refreshPorts(serial: Serial) {
ports = await serial.getPorts();
}
async function connect(port: SerialPortLike, withSync: boolean) {
@@ -46,13 +52,13 @@
element?.closest<HTMLElement>("[popover]")?.hidePopover();
}
async function connectDevice(event: MouseEvent) {
const port = await navigator.serial.requestPort({
async function connectDevice(event: MouseEvent, serial: Serial) {
const port = await serial.requestPort({
filters: event.shiftKey ? [] : [...PORT_FILTERS.values()],
});
if (!port) return;
closePopover();
refreshPorts();
refreshPorts(serial);
connect(port, true);
}
</script>
@@ -60,55 +66,79 @@
<div
bind:this={element}
class="device-list"
onmouseenter={() => refreshPorts()}
onmouseenter={() => refreshPorts($serialObject)}
role="region"
>
{#if ports.length === 1}
{#if supportsWebSerial || supportsWebUSB}
<fieldset class:promote={!$hasDiscoveredAutoConnect}>
<label
><input type="checkbox" use:preference={"autoConnect"} />
<div class="title">{$LL.deviceManager.AUTO_CONNECT()}</div>
</label>
{#if ports.length === 1}
<label
><input type="checkbox" use:preference={"autoConnect"} />
<div class="title">{$LL.deviceManager.AUTO_CONNECT()}</div>
</label>
<label
><input type="checkbox" use:preference={"backup"} />
<div class="title">{@html $LL.backup.AUTO_BACKUP()}</div>
<label
><input type="checkbox" use:preference={"backup"} />
<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>
</fieldset>
{/if}
{#if ports.length !== 0}
<h4>Recent Devices</h4>
<div class="devices">
<!--
{#if ports.length !== 0}
<h4>Recent Devices</h4>
<div class="devices">
<!--
<div class="device">
<button onclick={connectCC0}> CC0</button>
</div>-->
{#each ports as port}
<div class="device">
<button
onclick={(event) => {
connect(port, !event.shiftKey);
}}
>
{getPortName(port)}</button
>
<button
class="error"
onclick={() => {
port.forget();
refreshPorts();
}}><span class="icon">visibility_off</span> Hide</button
>
</div>
{/each}
{#each ports as port}
<div class="device">
<button
onclick={(event) => {
connect(port, !event.shiftKey);
}}
>
{getPortName(port)}</button
>
<button
class="error"
onclick={() => {
port.forget();
refreshPorts($serialObject);
}}><span class="icon">visibility_off</span> Hide</button
>
</div>
{/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>
{#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}
<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>
<style lang="scss">

View File

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