From 759784763c1f84986749224bd17f54c10646bfe2 Mon Sep 17 00:00:00 2001 From: Michael Katz Date: Fri, 12 Dec 2025 19:58:38 +0200 Subject: [PATCH 01/13] initial implementation of screen sharing via cf workers --- AGENTS.md | 29 + cypress/e2e/01-midi-connection-display.cy.ts | 1 - docs/screen-streaming.md | 316 +++++ src/components/Header.tsx | 2 + src/components/screenStreaming/QrCodeSvg.tsx | 40 + .../screenStreaming/ScreenStreamingButton.tsx | 29 + .../screenStreaming/ScreenStreamingModal.tsx | 282 +++++ src/components/screenStreaming/ViewerApp.tsx | 495 ++++++++ src/lib/display.ts | 4 + src/lib/qr/qrcodegen.ts | 1024 +++++++++++++++++ src/lib/screenStreaming/bip39English2048.ts | 136 +++ src/lib/screenStreaming/codec.ts | 87 ++ src/lib/screenStreaming/control.ts | 68 ++ src/lib/screenStreaming/ids.ts | 15 + src/lib/screenStreaming/index.ts | 7 + src/lib/screenStreaming/passwordToken.ts | 19 + src/lib/screenStreaming/roomCode.ts | 27 + src/lib/screenStreaming/wsUrl.ts | 56 + src/main.tsx | 44 +- src/services/screenStreamingStreamer.ts | 298 +++++ src/test/lib/screenStreaming.test.ts | 109 ++ src/vite-env.d.ts | 8 + worker/src/index.ts | 500 ++++++++ worker/wrangler.toml | 11 + 24 files changed, 3597 insertions(+), 10 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/screen-streaming.md create mode 100644 src/components/screenStreaming/QrCodeSvg.tsx create mode 100644 src/components/screenStreaming/ScreenStreamingButton.tsx create mode 100644 src/components/screenStreaming/ScreenStreamingModal.tsx create mode 100644 src/components/screenStreaming/ViewerApp.tsx create mode 100644 src/lib/qr/qrcodegen.ts create mode 100644 src/lib/screenStreaming/bip39English2048.ts create mode 100644 src/lib/screenStreaming/codec.ts create mode 100644 src/lib/screenStreaming/control.ts create mode 100644 src/lib/screenStreaming/ids.ts create mode 100644 src/lib/screenStreaming/index.ts create mode 100644 src/lib/screenStreaming/passwordToken.ts create mode 100644 src/lib/screenStreaming/roomCode.ts create mode 100644 src/lib/screenStreaming/wsUrl.ts create mode 100644 src/services/screenStreamingStreamer.ts create mode 100644 src/test/lib/screenStreaming.test.ts create mode 100644 worker/src/index.ts create mode 100644 worker/wrangler.toml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9e3507b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- App source lives in `src/` with `components/` (UI), `hooks/` (logic hooks), `commands/` (SysEx/USB ops), `services/` (I/O + side effects), and `lib/` utilities. Entry is `src/main.tsx`, styles in `src/styles/` and `src/index.css`, PWA service worker in `src/sw.ts`. +- Tests sit in `src/test/` using Vitest + Testing Library; end-to-end specs live in `cypress/e2e/`. Static assets are in `public/` and `src/assets/`. Vite/Tailwind config is in `vite.config.ts` and `tailwind.config.js`. + +## Build, Test, and Development Commands +- `yarn install` — install deps (repo targets Node >= 21.1 + Yarn 4; use `corepack enable` if needed). +- `yarn dev` — run Vite dev server (HMR on port 5173 by default). +- `yarn build` — production bundle to `dist/`. +- `yarn preview` — serve the built bundle locally. +- `yarn typecheck` — TypeScript no-emit check. +- `yarn test` / `yarn test:watch` — Vitest suite under `src/test/`. +- `yarn lint` — ESLint (Preact config). +- `yarn pwa-check` — ensures `dist/` contains `sw.js` and `manifest.webmanifest`. + +## Coding Style & Naming Conventions +- TypeScript + Preact with TSX; prefer functional components. Component files and exports use `PascalCase`, hooks `useCamelCase`, utilities `camelCase`. +- Prettier formatting (2-space indent, semicolons on, single quotes via ESLint). Avoid orphaned default exports; favor named exports for reuse. +- Tailwind is used for layout/styling; keep variants/classes close to the elements they affect and co-locate component-specific styles with the component. + +## Testing Guidelines +- Place unit/integration specs beside code in `src/test/` with `*.test.ts` or `*.test.tsx`. Prefer Testing Library queries over DOM selectors; stub network/USB surfaces in `services/`. +- E2E specs live in `cypress/e2e/`; use fixtures from `cypress/fixtures/` and add custom commands in `cypress/support/commands.ts`. +- Cover new user-visible behaviors and edge cases (file operations, PWA offline paths). Keep tests deterministic—mock time and random sources when relevant. + +## Commit & Pull Request Guidelines +- Commit history mixes imperative statements and Conventional Commits (`feat: ...`, `Fix ...`). Prefer imperative, present-tense subjects; include a type prefix when it clarifies scope. +- Pull requests: describe the change and rationale, note affected areas (UI, file operations, PWA), link issues, and include before/after screenshots or recordings for UI tweaks. Call out test coverage added or why it is not needed. diff --git a/cypress/e2e/01-midi-connection-display.cy.ts b/cypress/e2e/01-midi-connection-display.cy.ts index a295114..64f2980 100644 --- a/cypress/e2e/01-midi-connection-display.cy.ts +++ b/cypress/e2e/01-midi-connection-display.cy.ts @@ -1,5 +1,4 @@ /// -/// describe("MIDI Connection and Basic Display", () => { const DELUGE_MIDI_PORT_NAME = "Deluge Port 1"; // As per user update diff --git a/docs/screen-streaming.md b/docs/screen-streaming.md new file mode 100644 index 0000000..ea81b7c --- /dev/null +++ b/docs/screen-streaming.md @@ -0,0 +1,316 @@ +## Goal + +Add “Screen Streaming” to DEx in a way that matches how DEx actually works today: + +- **Streamer** (Chrome/Edge on desktop or Android): runs normal DEx, connects to the Deluge via WebMIDI, and relays *only* the Deluge display state over the network. +- **Viewer** (iOS Safari / any browser): runs a lightweight DEx “viewer mode” that does **not** use WebMIDI; it just joins a room and renders the incoming display state to a canvas. +- Transport: **WebSocket signaling + WebSocket data relay** via **Cloudflare Worker + Durable Object (DO)**. +- Optional later: upgrade to WebRTC DataChannel; keep room/auth concepts and the same on-wire frame format where possible. + +--- + +## Reality check: what DEx already does (and what we should reuse) + +DEx already has a complete “Deluge display pipeline”: + +- WebMIDI input subscription and fanout: `src/lib/webMidi.ts` +- Display message parsing/decoding and rendering: + - OLED is currently **128×48** in DEx. + - OLED device payload is a packed **7-to-8 RLE** format; `src/lib/display.ts` already unpacks it. + - OLED updates can be “full” or “delta”; DEx already applies deltas and maintains a framebuffer. + - 7-seg rendering is already implemented in `src/lib/display.ts` via `draw7Seg(...)` / `render7Seg(...)`. +- Display refresh cadence is currently driven by polling (default `pollingMs = 1000` in `src/lib/display.ts`), so “fps” is not 60 by default. Streaming should mirror the *actual* update cadence unless we intentionally increase polling. + +Streaming should **not** invent a new display decoder, and it must **not** relay unrelated SysEx traffic (file browser / smSysex JSON / debug). + +--- + +## High-level architecture + +- DEx frontend remains a static SPA (Cloudflare Pages). +- A Worker + DO provides: + - Exactly one active streamer per room (no takeovers) + - Up to 5 viewers per room + - Last-known “screen snapshot” for instant join (in-memory only) + - Heartbeats + cleanup + +Key integration point on the frontend: + +- The streamer side subscribes to **incoming display updates** (the same place that currently drives `DisplayViewer`) and publishes them to the room WS. +- The viewer side renders incoming frames using existing display helpers. + +--- + +## What exactly are we streaming? + +We only need to mirror the Deluge’s display state, but we should **not** bake display assumptions (frame size, encoding, future firmware changes) into the streaming protocol. + +Recommended approach: + +- Stream the **raw Deluge display SysEx payloads** that DEx already receives: + - OLED “full frame” SysEx message + - OLED “delta” SysEx message + - 7-seg SysEx message +- On **viewer join**, send a **full frame first** (keyframe), then continue with deltas. + - This keeps the viewer’s OLED state correct without re-encoding a framebuffer. + +We do **not** stream: + +- MIDI device selection / WebMIDI details +- Debug log, file browser, or any other SysEx traffic + +--- + +## Message protocol (WS JSON control + WS binary frames) + +### Control messages (JSON) + +All JSON messages include: + +```ts +type Base = { t: string; roomId: string; clientId: string; ts?: number }; +``` + +Streamer → DO: + +```ts +type StreamerHello = Base & { + t: "streamer:hello"; + // Room password is opt-in: if provided, viewers must also provide it to join. + // The streamer should send a derived token, not the plaintext password. + passwordToken?: string; + // Stream ownership token (not shared) to enforce "creator-only streaming". + // Generated once client-side and persisted (e.g. localStorage) for reconnects. + ownerKey: string; + meta?: { device?: string; appVersion?: string; pollingMs?: number }; +}; +type Ping = Base & { t: "ping" }; +type Bye = Base & { t: "bye" }; +type DisplayActive = Base & { t: "display:active"; active: "oled" | "seg7" }; +``` + +Viewer → DO: + +```ts +type ViewerHello = Base & { t: "viewer:hello"; passwordToken?: string }; +``` + +DO → clients: + +```ts +type Ok = Base & { t: "ok"; role: "streamer" | "viewer"; roomState: RoomState }; +type Err = Base & { t: "err"; code: string; msg: string }; +type RoomState = { + hasStreamer: boolean; + viewers: number; + viewerCap: number; + lastSeq?: number; + active?: "oled" | "seg7"; + requiresPassword: boolean; +}; +type ViewerCount = Base & { t: "room:viewers"; viewers: number }; +type StreamerStatus = Base & { t: "room:streamer"; status: "online" | "offline" }; +type RequestFull = Base & { + t: "streamer:request_full"; + screen: "oled" | "seg7" | "both"; +}; +``` + +### Frame messages (binary) + +We keep a small binary envelope and treat the payload as **opaque bytes**. The protocol is **pass-through display SysEx** (full on join, then deltas), not a re-encoded framebuffer. + +#### Binary envelope + +``` +bytes: +0..1 magic "DX" (0x44, 0x58) +2 version (0x01) +3 msgType (0x01 = DISPLAY_SYSEX) +4..7 seq (uint32 BE) +8 kind (0=OLED_FULL, 1=OLED_DELTA, 2=SEG7) +9..10 payloadLen (uint16 BE) // optional; WS frame length can be used instead +11.. payload // raw SysEx bytes (starts with 0xF0, ends with 0xF7) +``` + +#### Payload + +- Payload is the **exact SysEx message bytes** DEx received from the Deluge for that display update. +- Viewer rendering should reuse the same logic as `DisplayViewer`: + - For `OLED_FULL`: call `drawOled(canvas, sysexBytes)` + - For `OLED_DELTA`: call `drawOledDelta(canvas, sysexBytes)` + - For `SEG7`: extract digits/dots exactly as `DisplayViewer` already does (or implement a `draw7SegFromSysex` helper) + +Notes: + +- WS is reliable and ordered; OLED deltas will apply correctly as long as the viewer receives a full frame first. +- DO and streamer should ensure “keyframe first” semantics for new viewers. + +--- + +## Durable Object behavior + +### Room state + +- `streamer: WebSocket | null` +- `viewers: Map` +- `lastOledFull: ArrayBuffer | null` (last `DISPLAY_SYSEX` frame with `kind=OLED_FULL`) +- `lastSeg7: ArrayBuffer | null` (last `DISPLAY_SYSEX` frame with `kind=SEG7`) +- `lastSeq: number` +- `activeDisplay: "oled" | "seg7"` (defaults to what the streamer last reported / what last arrived) +- `viewerCap: 5` +- `passwordToken: string | null` (room password is opt-in; store token only, never plaintext) +- `ownerKey: string | null` (creator-only streaming) +- `createdAt`, `updatedAt` + +### Relay rules + +- Only accept binary `DISPLAY_SYSEX` frames from the streamer connection. +- Drop frames that exceed a **very generous hard maximum** (e.g. 256KB) as an abuse guardrail (and treat it as a protocol violation). +- Enforce a sustained frame-rate limit: + - If the streamer exceeds **60 frames within any rolling 1 second window**, start dropping frames until the rate returns below the threshold. +- Enforce monotonic `seq` (drop old/out-of-order). +- Store: + - `OLED_FULL` → `lastOledFull` + - `SEG7` → `lastSeg7` +- **Join ordering matters:** a viewer must receive `lastOledFull` before any `OLED_DELTA` frames. + - Easiest: on `viewer:hello`, send snapshots first, then add the viewer to the broadcast set. +- If `lastOledFull` is missing when a viewer joins: + - DO should send `streamer:request_full` to the streamer and keep the viewer pending until a keyframe arrives (or timeout with an error). + +### Connection / role rules (no takeovers) + +- Streamer: + - If a streamer is already connected, reject new `role=streamer` connections (`err code="room_has_streamer"`). + - If `ownerKey` is already set for the room and the incoming streamer’s `ownerKey` does not match, reject (`err code="room_owned_by_other_streamer"`). + - If this is the first streamer ever for the room, set `ownerKey` from `streamer:hello`. +- Viewer: + - Enforce viewer cap = 5. If full, respond with `err code="room_full"` and close the WS. + - If the room has a password, require `passwordToken` to match; otherwise respond with: + - `err code="password_required"` when missing + - `err code="bad_password"` when provided but incorrect + - Viewers are never allowed to upgrade to streamer; the only way to stream is `role=streamer` (and will be rejected by the above rules). + +### Heartbeats + +- Clients send JSON `ping` every 10–15s. +- DO closes idle connections after 45s and cleans up empty rooms. + +--- + +## Frontend integration (DEx) + +### Routing / “Viewer mode” + +DEx currently has no router. We can avoid adding any routing dependencies by using a **query parameter** to enter viewer mode. + +- Keep the existing “full app” at `/`. +- Add a lightweight viewer mode via URL like: `/?roomId=` +- Implement mode selection in `src/main.tsx`: + - If `roomId` exists in `window.location.search`, render `` + - Else render existing `` + +Viewer mode should: + +- Not call `initMidi(...)` or auto-connect +- Not render SysEx console, file browser, or any hardware actions +- Render: canvas + fullscreen + optional pixel scale controls + +### Streamer flow + +- UI action: “Start streaming” + - Visible only when WebMIDI is available and a MIDI output is selected. + - If the user is not already polling the display, show a prompt (“Enable display polling to stream”) or offer a one-click enable. + - Optional: “Require password” toggle that forces viewers to enter a password before they can view. +- Connect: + - `ws = new WebSocket(${STREAM_HOST}/api/rooms/${roomId}/ws?role=streamer)` + - Send `streamer:hello` (include DEx version + current pollingMs) +- Emit frames: + - Subscribe to display updates at the same layer that already receives them. + - Preferred implementation: stream **pass-through display SysEx**: + - When an OLED full frame SysEx is received → send `kind=OLED_FULL` with raw SysEx bytes + - When an OLED delta SysEx is received → send `kind=OLED_DELTA` with raw SysEx bytes + - When a 7SEG SysEx is received → send `kind=SEG7` with raw SysEx bytes + - Throttle/suppress duplicates so you don’t spam identical frames. + - Ensure a keyframe exists: + - Immediately after `streamer:hello` succeeds, request a forced full OLED frame so the first frame a viewer sees after joining is a full frame. +- UI: + - Show join URL for viewers (copy button) + - Show QR code for the join URL (no router needed) + - Example viewer URL: `https://dex.yourdomain/?roomId=` + - Include a visible “Refresh display” button that forces a full OLED frame (sends a keyframe). + +### Viewer flow + +- Open the join URL (e.g. via QR): `/?roomId=` +- Connect: + - `ws = new WebSocket(${STREAM_HOST}/api/rooms/${roomId}/ws?role=viewer)` + - Send `viewer:hello` (include `passwordToken` if prompted) +- On `DISPLAY_SYSEX` frames: + - Decode header, extract the raw SysEx bytes, and render using the same code paths as `DisplayViewer`. +- Hidden debug option: + - Provide a non-obvious UI affordance to request a keyframe (for debugging/resync), e.g.: + - Tap the room code 7 times to reveal “Debug” controls. + - Debug control: “Request keyframe” (OLED full) which triggers `streamer:request_full` to the streamer. + +--- + +## Security / Abuse Controls + +- Room IDs: **Diceware-style room codes** (2048-word list, normalized to `lowercase-hyphens`) + - Recommend 4 words (≈ 44 bits of entropy): easy to speak + hard to guess online. + - Example: `cactus-echo-lantern-saffron` + - Always normalize input (case-insensitive; collapse whitespace; convert to hyphens). +- Viewer cap: 5 viewers per room (show a friendly “Room is full” message) +- Payload caps: avoid small “expected size” limits; only enforce a **very generous hard maximum** (e.g. 256KB) as an abuse guardrail, not as a protocol assumption +- Frame rate cap: drop if streamer exceeds **60fps sustained for 1s** (rolling window) +- Optional password (opt-in): + - Streamer can require a password; viewers must supply it to join. + - Implement via `passwordToken = SHA-256("DEx-screen-streaming:" + roomId + ":" + password)` (or equivalent) so the password itself is never sent/stored. + +--- + +## Local relay (alternative deployment) + +Provide a Node-based relay server that speaks the same protocol: + +- `/api/rooms/:roomId/ws?role=...` +- Same hello/control + same binary `DISPLAY_SYSEX` envelope + +Frontend config: + +- `STREAM_HOST=wss://...` (env var at build time) or query param override. + - Frontend (Vite): use `VITE_STREAM_HOST=wss://...` + - Runtime override: `?streamHost=wss://...` + +--- + +## Acceptance criteria (aligned with DEx behavior) + +- Viewer on iOS Safari can see updates with low latency relative to streamer (network overhead should be negligible compared to polling cadence). +- Joining mid-stream shows the current screen within 1s (keyframe-first, then deltas). +- Viewer mode does not require WebMIDI and does not show hardware-only UI. +- Viewers cannot become streamers (single-streamer room, no takeovers). +- Room enforces viewer cap (5) with a friendly “Room is full” message. +- Streaming never relays non-display SysEx traffic. +- Rooms clean up when streamer disconnects and no viewers remain. + +--- + +## Implementation plan + +1. Add Diceware room-code generator + UI (generate/regenerate/copy), and build join URLs using `?roomId=...`. +2. Add optional room password UI and implement `passwordToken` derivation (streamer sets it; viewer prompts for it). +3. Implement viewer mode entry in `src/main.tsx` based on `roomId` query param and build the viewer-only UI (active display only). +4. Implement Worker + DO room relay with: + - single-streamer enforcement (`room_has_streamer`, `room_owned_by_other_streamer`) + - viewer cap = 5 (`room_full`) + - password enforcement (`password_required`, `bad_password`) + - keyframe-first join semantics (`lastOledFull`, `streamer:request_full`) + - sustained >60fps drop policy (rolling 1s window) +5. Integrate streamer-side frame emission: + - pass-through OLED full + delta + 7SEG SysEx frames only + - immediately force a full OLED frame after streamer connects + - add a visible “Refresh display” button to send a keyframe +6. Add viewer-side hidden debug “Request keyframe” control. +7. Add tests (codec encode/decode, DO room rules, viewer cap/password errors, keyframe ordering). diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 12a5eb4..dd862ac 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -5,6 +5,7 @@ import { FullscreenToggleButton } from "./FullscreenToggleButton"; import FileBrowserToggleButton from "./FileBrowserToggleButton"; import { HelpIconButton } from "./HelpIconButton"; import { fullscreenActive } from "../state"; +import { ScreenStreamingButton } from "./screenStreaming/ScreenStreamingButton"; export function Header() { return ( @@ -31,6 +32,7 @@ export function Header() { {/* Right: controls */} {!fullscreenActive.value && (
+ diff --git a/src/components/screenStreaming/QrCodeSvg.tsx b/src/components/screenStreaming/QrCodeSvg.tsx new file mode 100644 index 0000000..9459887 --- /dev/null +++ b/src/components/screenStreaming/QrCodeSvg.tsx @@ -0,0 +1,40 @@ +import { useMemo } from "preact/hooks"; +import { qrcodegen } from "@/lib/qr/qrcodegen"; + +export function QrCodeSvg(props: { + text: string; + className?: string; + title?: string; +}) { + const qr = useMemo( + () => + qrcodegen.QrCode.encodeText(props.text, qrcodegen.QrCode.Ecc.MEDIUM), + [props.text], + ); + + const border = 4; + const size = qr.size + border * 2; + const modules: JSX.Element[] = []; + + for (let y = 0; y < qr.size; y++) { + for (let x = 0; x < qr.size; x++) { + if (!qr.getModule(x, y)) continue; + modules.push( + , + ); + } + } + + return ( + + + {modules} + + ); +} diff --git a/src/components/screenStreaming/ScreenStreamingButton.tsx b/src/components/screenStreaming/ScreenStreamingButton.tsx new file mode 100644 index 0000000..05995f5 --- /dev/null +++ b/src/components/screenStreaming/ScreenStreamingButton.tsx @@ -0,0 +1,29 @@ +import { useState } from "preact/hooks"; +import { QrCodeIcon } from "@heroicons/react/24/outline"; +import { ScreenStreamingModal } from "./ScreenStreamingModal"; +import { screenStreamerStatus } from "@/services/screenStreamingStreamer"; + +export function ScreenStreamingButton() { + const [open, setOpen] = useState(false); + const active = screenStreamerStatus.value !== "idle"; + + return ( + <> + + {open && setOpen(false)} />} + + ); +} + diff --git a/src/components/screenStreaming/ScreenStreamingModal.tsx b/src/components/screenStreaming/ScreenStreamingModal.tsx new file mode 100644 index 0000000..39a9e8d --- /dev/null +++ b/src/components/screenStreaming/ScreenStreamingModal.tsx @@ -0,0 +1,282 @@ +import { useEffect, useMemo, useState } from "preact/hooks"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { midiIn, midiOut } from "@/state"; +import { createRoomId, normalizeRoomId } from "@/lib/screenStreaming/roomCode"; +import { derivePasswordToken } from "@/lib/screenStreaming/passwordToken"; +import type { ErrMsg } from "@/lib/screenStreaming/control"; +import { + refreshStreamedDisplay, + screenStreamerError, + screenStreamerRoomId, + screenStreamerRoomState, + screenStreamerStatus, + startScreenStreaming, + stopScreenStreaming, +} from "@/services/screenStreamingStreamer"; +import { QrCodeSvg } from "./QrCodeSvg"; + +function buildViewerUrl(roomId: string): string { + const url = new URL(window.location.origin + window.location.pathname); + url.searchParams.set("roomId", roomId); + return url.toString(); +} + +function friendlyStreamerError(err: ErrMsg): string { + if (err.code === "room_has_streamer") return "Room already has a streamer."; + if (err.code === "room_owned_by_other_streamer") + return "Room is owned by another streamer."; + return err.msg || "Unable to start streaming."; +} + +async function copyText(text: string) { + await navigator.clipboard.writeText(text); +} + +export function ScreenStreamingModal(props: { onClose: () => void }) { + const status = screenStreamerStatus.value; + const activeRoomId = screenStreamerRoomId.value; + const roomState = screenStreamerRoomState.value; + const err = screenStreamerError.value; + + const [draftRoomId, setDraftRoomId] = useState(() => createRoomId()); + const [requirePassword, setRequirePassword] = useState(false); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [copied, setCopied] = useState(null); + + const effectiveRoomId = status === "idle" ? draftRoomId : activeRoomId; + const joinUrl = useMemo( + () => (effectiveRoomId ? buildViewerUrl(effectiveRoomId) : null), + [effectiveRoomId], + ); + + useEffect(() => { + if (copied == null) return; + const id = window.setTimeout(() => setCopied(null), 1500); + return () => clearTimeout(id); + }, [copied]); + + const canStart = status === "idle" && !!midiOut.value && !!midiIn.value; + const canStop = status !== "idle"; + + const regenerateRoom = () => setDraftRoomId(createRoomId()); + + const start = async () => { + if (!draftRoomId) return; + if (!midiOut.value || !midiIn.value) return; + + const normalizedRoomId = normalizeRoomId(draftRoomId); + if (!normalizedRoomId) return; + setDraftRoomId(normalizedRoomId); + + setBusy(true); + try { + const passwordToken = requirePassword + ? await derivePasswordToken(normalizedRoomId, password) + : undefined; + startScreenStreaming({ roomId: normalizedRoomId, passwordToken }); + setPassword(""); + } catch (e) { + console.error(e); + } finally { + setBusy(false); + } + }; + + const stop = () => { + stopScreenStreaming(); + }; + + const copyJoinUrl = async () => { + if (!joinUrl) return; + await copyText(joinUrl); + setCopied("link"); + }; + + const copyRoomCode = async () => { + if (!effectiveRoomId) return; + await copyText(effectiveRoomId); + setCopied("room"); + }; + + return ( +
{ + if (e.target === e.currentTarget) props.onClose(); + }} + > +
+
+
+
Screen streaming
+
+ One-way room (max 5 viewers) +
+
+ +
+ +
+ {(!midiOut.value || !midiIn.value) && ( +
+ Select a Deluge MIDI device to start streaming. +
+ )} + +
+
Room
+
+ setDraftRoomId((e.target as HTMLInputElement).value)} + disabled={status !== "idle"} + className="flex-1 min-w-[16rem] px-3 py-2 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] font-mono text-sm" + /> + + {status === "idle" && ( + + )} +
+
+ + {status === "idle" && ( +
+ + {requirePassword && ( + setPassword((e.target as HTMLInputElement).value)} + className="w-full px-3 py-2 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)]" + placeholder="Room password" + autoComplete="new-password" + /> + )} +
+ Password is hashed (token) before sending. +
+
+ )} + + {joinUrl && ( +
+
Join URL
+
+ + +
+
+ +
+
+ )} + +
+
+
+ Status:{" "} + {status} +
+
+ Viewers:{" "} + + {roomState ? `${roomState.viewers}/${roomState.viewerCap}` : "—"} + +
+
+ Requires password:{" "} + + {roomState?.requiresPassword ? "yes" : "no"} + +
+
+ + {err && ( +
+ {friendlyStreamerError(err)} +
+ )} + + {status === "streaming" && ( +
+ +
+ )} +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/screenStreaming/ViewerApp.tsx b/src/components/screenStreaming/ViewerApp.tsx new file mode 100644 index 0000000..368b6a8 --- /dev/null +++ b/src/components/screenStreaming/ViewerApp.tsx @@ -0,0 +1,495 @@ +import { useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { + drawOled, + drawOledDelta, + draw7Seg, + enterFullscreenScale, + exitFullscreenScale, + registerCanvas, + resizeCanvas, +} from "@/lib/display"; +import { displaySettings, fullscreenActive } from "@/state"; +import type { + AnyControlMsg, + ErrMsg, + OkMsg, + RoomActiveDisplay, + RoomState, +} from "@/lib/screenStreaming/control"; +import { nowMs } from "@/lib/screenStreaming/control"; +import { decodeDisplaySysexFrame, DisplaySysexKind } from "@/lib/screenStreaming/codec"; +import { createClientId } from "@/lib/screenStreaming/ids"; +import { derivePasswordToken } from "@/lib/screenStreaming/passwordToken"; +import { buildRoomWsUrl } from "@/lib/screenStreaming/wsUrl"; +import { FullscreenToggleButton } from "@/components/FullscreenToggleButton"; +import { ThemeSwitcher } from "@/components/ThemeSwitcher"; +import { PixelSizeControls } from "@/components/PixelSizeControls"; + +type ConnectionStatus = + | "connecting" + | "awaiting_ok" + | "connected" + | "needs_password" + | "error" + | "closed"; + +function errToFriendlyMessage(err: ErrMsg): string { + switch (err.code) { + case "room_full": + return "Room is full (max 5 viewers)."; + case "password_required": + return "This room requires a password."; + case "bad_password": + return "Incorrect password."; + case "room_has_streamer": + return "This room already has an active streamer."; + case "room_owned_by_other_streamer": + return "This room is owned by another streamer."; + default: + return err.msg || "Unable to join room."; + } +} + +function leaveViewerMode() { + const next = new URL(window.location.origin + window.location.pathname); + window.location.assign(next.toString()); +} + +export function ViewerApp(props: { roomId: string }) { + const clientId = useMemo(() => createClientId(), []); + + const canvasRef = useRef(null); + const containerRef = useRef(null); + + const socketRef = useRef(null); + const pingIdRef = useRef(null); + + const hasOledFullRef = useRef(false); + const lastSeqRef = useRef(null); + const activeDisplayRef = useRef(null); + const endStateRef = useRef(null); + + const [status, setStatus] = useState("connecting"); + const [roomState, setRoomState] = useState(null); + const [activeDisplay, setActiveDisplay] = useState( + null, + ); + const [lastErr, setLastErr] = useState(null); + + const [password, setPassword] = useState(""); + const [passwordToken, setPasswordToken] = useState(null); + const [passwordBusy, setPasswordBusy] = useState(false); + + const [debugUnlocked, setDebugUnlocked] = useState(false); + const tapCountRef = useRef(0); + const tapResetIdRef = useRef(null); + + // Register the canvas with display helpers on mount. + useEffect(() => { + if (canvasRef.current) registerCanvas(canvasRef.current); + }, []); + + // Resize canvas when display settings change. + useEffect(() => { + if (canvasRef.current) resizeCanvas(canvasRef.current); + }, [displaySettings.value]); + + // Handle fullscreen changes (mirror DisplayViewer behavior). + useEffect(() => { + if (!canvasRef.current) return; + + if (fullscreenActive.value) { + enterFullscreenScale(canvasRef.current); + document.body.classList.add("fullscreen-mode"); + if (containerRef.current) { + containerRef.current.style.display = "block"; + containerRef.current.style.visibility = "visible"; + containerRef.current.style.opacity = "1"; + } + } else { + exitFullscreenScale(canvasRef.current); + document.body.classList.remove("fullscreen-mode"); + } + }, [fullscreenActive.value]); + + // Listen for display:resized events to sync wrapper dimensions. + useEffect(() => { + const handleDisplayResized = (e: CustomEvent) => { + if (containerRef.current) { + containerRef.current.style.width = `${e.detail.width}px`; + containerRef.current.style.height = `${e.detail.height}px`; + } + }; + window.addEventListener( + "display:resized", + handleDisplayResized as EventListener, + true, + ); + return () => { + window.removeEventListener( + "display:resized", + handleDisplayResized as EventListener, + true, + ); + }; + }, []); + + // WebSocket connect/reconnect (roomId, passwordToken). + useEffect(() => { + hasOledFullRef.current = false; + lastSeqRef.current = null; + endStateRef.current = null; + activeDisplayRef.current = null; + setActiveDisplay(null); + setRoomState(null); + + const wsUrl = buildRoomWsUrl({ roomId: props.roomId, role: "viewer" }); + const ws = new WebSocket(wsUrl); + ws.binaryType = "arraybuffer"; + socketRef.current = ws; + setStatus("connecting"); + setLastErr(null); + + const stopPing = () => { + if (pingIdRef.current != null) { + clearInterval(pingIdRef.current); + pingIdRef.current = null; + } + }; + + const closeSocket = () => { + stopPing(); + try { + ws.close(); + } catch { + // ignore + } + }; + + const sendJson = (msg: AnyControlMsg) => { + if (ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify({ ...msg, ts: nowMs() })); + }; + + ws.onopen = () => { + endStateRef.current = null; + setStatus("awaiting_ok"); + sendJson({ + t: "viewer:hello", + roomId: props.roomId, + clientId, + passwordToken: passwordToken ?? undefined, + }); + + pingIdRef.current = window.setInterval(() => { + sendJson({ t: "ping", roomId: props.roomId, clientId }); + }, 15_000); + }; + + const handleControl = (msg: AnyControlMsg) => { + if (msg.t === "ok") { + const ok = msg as OkMsg; + setRoomState(ok.roomState); + activeDisplayRef.current = ok.roomState.active ?? null; + setActiveDisplay(ok.roomState.active ?? null); + setStatus("connected"); + return; + } + + if (msg.t === "err") { + const err = msg as ErrMsg; + setLastErr(err); + + if (err.code === "password_required" || err.code === "bad_password") { + endStateRef.current = "needs_password"; + setStatus("needs_password"); + } else { + endStateRef.current = "error"; + setStatus("error"); + } + + closeSocket(); + return; + } + + if (msg.t === "display:active") { + activeDisplayRef.current = msg.active; + setActiveDisplay(msg.active); + return; + } + }; + + const handleBinary = (buf: ArrayBuffer) => { + const canvas = canvasRef.current; + if (!canvas) return; + + let frame; + try { + frame = decodeDisplaySysexFrame(buf); + } catch { + return; + } + + if (lastSeqRef.current != null && frame.seq <= lastSeqRef.current) { + return; + } + lastSeqRef.current = frame.seq; + + const activeNow = activeDisplayRef.current; + if ( + frame.kind === DisplaySysexKind.OledFull || + frame.kind === DisplaySysexKind.OledDelta + ) { + if (activeNow && activeNow !== "oled") return; + } else if (frame.kind === DisplaySysexKind.Seg7) { + if (activeNow && activeNow !== "seg7") return; + } + + if (frame.kind === DisplaySysexKind.OledFull) { + hasOledFullRef.current = true; + drawOled(canvas, frame.payload); + activeDisplayRef.current = "oled"; + setActiveDisplay("oled"); + } else if (frame.kind === DisplaySysexKind.OledDelta) { + if (!hasOledFullRef.current) return; + drawOledDelta(canvas, frame.payload); + activeDisplayRef.current = "oled"; + setActiveDisplay("oled"); + } else if (frame.kind === DisplaySysexKind.Seg7) { + const data = frame.payload; + if (data.length < 11) return; + const dots = data[6]; + const digitsRaw = Array.from(data.subarray(7, 11)); + draw7Seg(canvas, digitsRaw, dots); + activeDisplayRef.current = "seg7"; + setActiveDisplay("seg7"); + } + }; + + ws.onmessage = (ev) => { + if (typeof ev.data === "string") { + let parsed: unknown; + try { + parsed = JSON.parse(ev.data); + } catch { + return; + } + if (!parsed || typeof parsed !== "object") return; + const msg = parsed as AnyControlMsg; + if (typeof msg.t !== "string") return; + handleControl(msg); + return; + } + + if (ev.data instanceof ArrayBuffer) { + handleBinary(ev.data); + return; + } + + if (ev.data instanceof Blob) { + void ev.data.arrayBuffer().then(handleBinary); + } + }; + + ws.onclose = () => { + stopPing(); + if (endStateRef.current) setStatus(endStateRef.current); + else setStatus("closed"); + }; + + ws.onerror = () => { + stopPing(); + if (endStateRef.current) setStatus(endStateRef.current); + else setStatus("error"); + }; + + return () => { + stopPing(); + socketRef.current = null; + try { + ws.close(); + } catch { + // ignore + } + }; + }, [props.roomId, passwordToken]); + + const requestKeyframe = () => { + const ws = socketRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + ws.send( + JSON.stringify({ + t: "viewer:request_full", + roomId: props.roomId, + clientId, + ts: nowMs(), + }), + ); + }; + + const unlockDebugIfNeeded = () => { + tapCountRef.current += 1; + if (tapResetIdRef.current != null) { + clearTimeout(tapResetIdRef.current); + tapResetIdRef.current = null; + } + tapResetIdRef.current = window.setTimeout(() => { + tapCountRef.current = 0; + tapResetIdRef.current = null; + }, 1500); + if (tapCountRef.current >= 7) { + tapCountRef.current = 0; + setDebugUnlocked(true); + } + }; + + const handleSubmitPassword = async () => { + setPasswordBusy(true); + try { + const token = await derivePasswordToken(props.roomId, password); + setPasswordToken(token); + setLastErr(null); + setStatus("connecting"); + } finally { + setPasswordBusy(false); + } + }; + + const showError = status === "error" || status === "closed"; + const showPasswordPrompt = status === "needs_password"; + + return ( +
+
+
+ DEx Logo +
+ Viewer mode + +
+
+ +
+ + {!fullscreenActive.value && ( +
+ + + +
+ )} +
+ + {!fullscreenActive.value && ( +
+ +
+ )} + +
+
+ +
+
+ +
+
+
+
+ Status:{" "} + {status} +
+
+ Active:{" "} + {activeDisplay ?? "unknown"} +
+
+ Viewers:{" "} + + {roomState ? `${roomState.viewers}/${roomState.viewerCap}` : "—"} + +
+
+ + {showPasswordPrompt && ( +
+
+ {lastErr ? errToFriendlyMessage(lastErr) : "Password required."} +
+
+ setPassword((e.target as HTMLInputElement).value)} + className="px-3 py-2 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)]" + placeholder="Room password" + autoComplete="current-password" + /> + +
+
+ )} + + {showError && lastErr && ( +
+ {errToFriendlyMessage(lastErr)} +
+ )} + + {debugUnlocked && !fullscreenActive.value && ( +
+
+ Debug +
+
+ +
+ lastSeq={lastSeqRef.current ?? "—"} +
+
+
+ )} +
+
+
+ ); +} diff --git a/src/lib/display.ts b/src/lib/display.ts index ef4ce40..487ffa5 100644 --- a/src/lib/display.ts +++ b/src/lib/display.ts @@ -467,6 +467,10 @@ let canvasRef: HTMLCanvasElement | null = null; export const pollingMs = 1000; let pollingId: number | null = null; +export function isPollingActive(): boolean { + return pollingId != null; +} + export function startPolling() { if (pollingId == null) { pollingId = window.setInterval(() => midi.getDisplay(false), pollingMs); diff --git a/src/lib/qr/qrcodegen.ts b/src/lib/qr/qrcodegen.ts new file mode 100644 index 0000000..986758c --- /dev/null +++ b/src/lib/qr/qrcodegen.ts @@ -0,0 +1,1024 @@ +/* + * QR Code generator library (TypeScript) + * + * Copyright (c) Project Nayuki. (MIT License) + * https://www.nayuki.io/page/qr-code-generator-library + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * - The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * - The Software is provided "as is", without warranty of any kind, express or + * implied, including but not limited to the warranties of merchantability, + * fitness for a particular purpose and noninfringement. In no event shall the + * authors or copyright holders be liable for any claim, damages or other + * liability, whether in an action of contract, tort or otherwise, arising from, + * out of or in connection with the Software or the use or other dealings in the + * Software. + */ + +"use strict"; +type bit = number; +type byte = number; +type int = number; + + /*---- QR Code symbol class ----*/ + + /* + * A QR Code symbol, which is a type of two-dimension barcode. + * Invented by Denso Wave and described in the ISO/IEC 18004 standard. + * Instances of this class represent an immutable square grid of dark and light cells. + * The class provides static factory functions to create a QR Code from text or binary data. + * The class covers the QR Code Model 2 specification, supporting all versions (sizes) + * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. + * + * Ways to create a QR Code object: + * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary(). + * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments(). + * - Low level: Custom-make the array of data codeword bytes (including + * segment headers and final padding, excluding error correction codewords), + * supply the appropriate version number, and call the QrCode() constructor. + * (Note that all ways require supplying the desired error correction level.) + */ + export class QrCode { + public static Ecc: typeof Ecc; + /*-- Static factory functions (high level) --*/ + + // Returns a QR Code representing the given Unicode text string at the given error correction level. + // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer + // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible + // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the + // ecl argument if it can be done without increasing the version. + public static encodeText(text: string, ecl: Ecc): QrCode { + const segs: Array = QrSegment.makeSegments(text); + return QrCode.encodeSegments(segs, ecl); + } + + // Returns a QR Code representing the given binary data at the given error correction level. + // This function always encodes using the binary segment mode, not any text mode. The maximum number of + // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. + // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. + public static encodeBinary( + data: Readonly>, + ecl: Ecc, + ): QrCode { + const seg: QrSegment = QrSegment.makeBytes(data); + return QrCode.encodeSegments([seg], ecl); + } + + /*-- Static factory functions (mid level) --*/ + + // Returns a QR Code representing the given segments with the given encoding parameters. + // The smallest possible QR Code version within the given range is automatically + // chosen for the output. Iff boostEcl is true, then the ECC level of the result + // may be higher than the ecl argument if it can be done without increasing the + // version. The mask number is either between 0 to 7 (inclusive) to force that + // mask, or -1 to automatically choose an appropriate mask (which may be slow). + // This function allows the user to create a custom sequence of segments that switches + // between modes (such as alphanumeric and byte) to encode text in less space. + // This is a mid-level API; the high-level API is encodeText() and encodeBinary(). + public static encodeSegments( + segs: Readonly>, + ecl: Ecc, + minVersion: int = 1, + maxVersion: int = 40, + mask: int = -1, + boostEcl: boolean = true, + ): QrCode { + if ( + !( + QrCode.MIN_VERSION <= minVersion && + minVersion <= maxVersion && + maxVersion <= QrCode.MAX_VERSION + ) || + mask < -1 || + mask > 7 + ) + throw new RangeError("Invalid value"); + + // Find the minimal version number to use + let version: int; + let dataUsedBits: int; + for (version = minVersion; ; version++) { + const dataCapacityBits: int = + QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available + const usedBits: number = QrSegment.getTotalBits(segs, version); + if (usedBits <= dataCapacityBits) { + dataUsedBits = usedBits; + break; // This version number is found to be suitable + } + if (version >= maxVersion) + // All versions in the range could not fit the given data + throw new RangeError("Data too long"); + } + + // Increase the error correction level while the data still fits in the current version number + for (const newEcl of [Ecc.MEDIUM, Ecc.QUARTILE, Ecc.HIGH]) { + // From low to high + if ( + boostEcl && + dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8 + ) + ecl = newEcl; + } + + // Concatenate all segments to create the data bit string + const bb: Array = []; + for (const seg of segs) { + appendBits(seg.mode.modeBits, 4, bb); + appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb); + for (const b of seg.getData()) bb.push(b); + } + assert(bb.length == dataUsedBits); + + // Add terminator and pad up to a byte if applicable + const dataCapacityBits: int = + QrCode.getNumDataCodewords(version, ecl) * 8; + assert(bb.length <= dataCapacityBits); + appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb); + appendBits(0, (8 - (bb.length % 8)) % 8, bb); + assert(bb.length % 8 == 0); + + // Pad with alternating bytes until data capacity is reached + for ( + let padByte = 0xec; + bb.length < dataCapacityBits; + padByte ^= 0xec ^ 0x11 + ) + appendBits(padByte, 8, bb); + + // Pack bits into bytes in big endian + const dataCodewords: Array = []; + while (dataCodewords.length * 8 < bb.length) dataCodewords.push(0); + bb.forEach( + (b: bit, i: int) => (dataCodewords[i >>> 3] |= b << (7 - (i & 7))), + ); + + // Create the QR Code object + return new QrCode(version, ecl, dataCodewords, mask); + } + + /*-- Fields --*/ + + // The width and height of this QR Code, measured in modules, between + // 21 and 177 (inclusive). This is equal to version * 4 + 17. + public readonly size: int; + + // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). + // Even if a QR Code is created with automatic masking requested (mask = -1), + // the resulting object still has a mask value between 0 and 7. + public readonly mask: int; + + // The modules of this QR Code (false = light, true = dark). + // Immutable after constructor finishes. Accessed through getModule(). + private readonly modules: Array> = []; + + // Indicates function modules that are not subjected to masking. Discarded when constructor finishes. + private readonly isFunction: Array> = []; + + /*-- Constructor (low level) and fields --*/ + + // Creates a new QR Code with the given version number, + // error correction level, data codeword bytes, and mask number. + // This is a low-level API that most users should not use directly. + // A mid-level API is the encodeSegments() function. + public constructor( + // The version number of this QR Code, which is between 1 and 40 (inclusive). + // This determines the size of this barcode. + public readonly version: int, + + // The error correction level used in this QR Code. + public readonly errorCorrectionLevel: Ecc, + + dataCodewords: Readonly>, + + msk: int, + ) { + // Check scalar arguments + if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION) + throw new RangeError("Version value out of range"); + if (msk < -1 || msk > 7) throw new RangeError("Mask value out of range"); + this.size = version * 4 + 17; + + // Initialize both grids to be size*size arrays of Boolean false + const row: Array = []; + for (let i = 0; i < this.size; i++) row.push(false); + for (let i = 0; i < this.size; i++) { + this.modules.push(row.slice()); // Initially all light + this.isFunction.push(row.slice()); + } + + // Compute ECC, draw modules + this.drawFunctionPatterns(); + const allCodewords: Array = this.addEccAndInterleave(dataCodewords); + this.drawCodewords(allCodewords); + + // Do masking + if (msk == -1) { + // Automatically choose best mask + let minPenalty: int = 1000000000; + for (let i = 0; i < 8; i++) { + this.applyMask(i); + this.drawFormatBits(i); + const penalty: int = this.getPenaltyScore(); + if (penalty < minPenalty) { + msk = i; + minPenalty = penalty; + } + this.applyMask(i); // Undoes the mask due to XOR + } + } + assert(0 <= msk && msk <= 7); + this.mask = msk; + this.applyMask(msk); // Apply the final choice of mask + this.drawFormatBits(msk); // Overwrite old format bits + + this.isFunction = []; + } + + /*-- Accessor methods --*/ + + // Returns the color of the module (pixel) at the given coordinates, which is false + // for light or true for dark. The top left corner has the coordinates (x=0, y=0). + // If the given coordinates are out of bounds, then false (light) is returned. + public getModule(x: int, y: int): boolean { + return ( + 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x] + ); + } + + /*-- Private helper methods for constructor: Drawing function modules --*/ + + // Reads this object's version field, and draws and marks all function modules. + private drawFunctionPatterns(): void { + // Draw horizontal and vertical timing patterns + for (let i = 0; i < this.size; i++) { + this.setFunctionModule(6, i, i % 2 == 0); + this.setFunctionModule(i, 6, i % 2 == 0); + } + + // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules) + this.drawFinderPattern(3, 3); + this.drawFinderPattern(this.size - 4, 3); + this.drawFinderPattern(3, this.size - 4); + + // Draw numerous alignment patterns + const alignPatPos: Array = this.getAlignmentPatternPositions(); + const numAlign: int = alignPatPos.length; + for (let i = 0; i < numAlign; i++) { + for (let j = 0; j < numAlign; j++) { + // Don't draw on the three finder corners + if ( + !( + (i == 0 && j == 0) || + (i == 0 && j == numAlign - 1) || + (i == numAlign - 1 && j == 0) + ) + ) + this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]); + } + } + + // Draw configuration data + this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor + this.drawVersion(); + } + + // Draws two copies of the format bits (with its own error correction code) + // based on the given mask and this object's error correction level field. + private drawFormatBits(mask: int): void { + // Calculate error correction code and pack bits + const data: int = (this.errorCorrectionLevel.formatBits << 3) | mask; // errCorrLvl is uint2, mask is uint3 + let rem: int = data; + for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); + const bits = ((data << 10) | rem) ^ 0x5412; // uint15 + assert(bits >>> 15 == 0); + + // Draw first copy + for (let i = 0; i <= 5; i++) + this.setFunctionModule(8, i, getBit(bits, i)); + this.setFunctionModule(8, 7, getBit(bits, 6)); + this.setFunctionModule(8, 8, getBit(bits, 7)); + this.setFunctionModule(7, 8, getBit(bits, 8)); + for (let i = 9; i < 15; i++) + this.setFunctionModule(14 - i, 8, getBit(bits, i)); + + // Draw second copy + for (let i = 0; i < 8; i++) + this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i)); + for (let i = 8; i < 15; i++) + this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i)); + this.setFunctionModule(8, this.size - 8, true); // Always dark + } + + // Draws two copies of the version bits (with its own error correction code), + // based on this object's version field, iff 7 <= version <= 40. + private drawVersion(): void { + if (this.version < 7) return; + + // Calculate error correction code and pack bits + let rem: int = this.version; // version is uint6, in the range [7, 40] + for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); + const bits: int = (this.version << 12) | rem; // uint18 + assert(bits >>> 18 == 0); + + // Draw two copies + for (let i = 0; i < 18; i++) { + const color: boolean = getBit(bits, i); + const a: int = this.size - 11 + (i % 3); + const b: int = Math.floor(i / 3); + this.setFunctionModule(a, b, color); + this.setFunctionModule(b, a, color); + } + } + + // Draws a 9*9 finder pattern including the border separator, + // with the center module at (x, y). Modules can be out of bounds. + private drawFinderPattern(x: int, y: int): void { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm + const xx: int = x + dx; + const yy: int = y + dy; + if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size) + this.setFunctionModule(xx, yy, dist != 2 && dist != 4); + } + } + } + + // Draws a 5*5 alignment pattern, with the center module + // at (x, y). All modules must be in bounds. + private drawAlignmentPattern(x: int, y: int): void { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) + this.setFunctionModule( + x + dx, + y + dy, + Math.max(Math.abs(dx), Math.abs(dy)) != 1, + ); + } + } + + // Sets the color of a module and marks it as a function module. + // Only used by the constructor. Coordinates must be in bounds. + private setFunctionModule(x: int, y: int, isDark: boolean): void { + this.modules[y][x] = isDark; + this.isFunction[y][x] = true; + } + + /*-- Private helper methods for constructor: Codewords and masking --*/ + + // Returns a new byte string representing the given data with the appropriate error correction + // codewords appended to it, based on this object's version and error correction level. + private addEccAndInterleave(data: Readonly>): Array { + const ver: int = this.version; + const ecl: Ecc = this.errorCorrectionLevel; + if (data.length != QrCode.getNumDataCodewords(ver, ecl)) + throw new RangeError("Invalid argument"); + + // Calculate parameter numbers + const numBlocks: int = + QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; + const blockEccLen: int = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver]; + const rawCodewords: int = Math.floor( + QrCode.getNumRawDataModules(ver) / 8, + ); + const numShortBlocks: int = numBlocks - (rawCodewords % numBlocks); + const shortBlockLen: int = Math.floor(rawCodewords / numBlocks); + + // Split data into blocks and append ECC to each block + const blocks: Array> = []; + const rsDiv: Array = QrCode.reedSolomonComputeDivisor(blockEccLen); + for (let i = 0, k = 0; i < numBlocks; i++) { + const dat: Array = data.slice( + k, + k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1), + ); + k += dat.length; + const ecc: Array = QrCode.reedSolomonComputeRemainder(dat, rsDiv); + if (i < numShortBlocks) dat.push(0); + blocks.push(dat.concat(ecc)); + } + + // Interleave (not concatenate) the bytes from every block into a single sequence + const result: Array = []; + for (let i = 0; i < blocks[0].length; i++) { + blocks.forEach((block, j) => { + // Skip the padding byte in short blocks + if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) + result.push(block[i]); + }); + } + assert(result.length == rawCodewords); + return result; + } + + // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire + // data area of this QR Code. Function modules need to be marked off before this is called. + private drawCodewords(data: Readonly>): void { + if ( + data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8) + ) + throw new RangeError("Invalid argument"); + let i: int = 0; // Bit index into the data + // Do the funny zigzag scan + for (let right = this.size - 1; right >= 1; right -= 2) { + // Index of right column in each column pair + if (right == 6) right = 5; + for (let vert = 0; vert < this.size; vert++) { + // Vertical counter + for (let j = 0; j < 2; j++) { + const x: int = right - j; // Actual x coordinate + const upward: boolean = ((right + 1) & 2) == 0; + const y: int = upward ? this.size - 1 - vert : vert; // Actual y coordinate + if (!this.isFunction[y][x] && i < data.length * 8) { + this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7)); + i++; + } + // If this QR Code has any remainder bits (0 to 7), they were assigned as + // 0/false/light by the constructor and are left unchanged by this method + } + } + } + assert(i == data.length * 8); + } + + // XORs the codeword modules in this QR Code with the given mask pattern. + // The function modules must be marked and the codeword bits must be drawn + // before masking. Due to the arithmetic of XOR, calling applyMask() with + // the same mask value a second time will undo the mask. A final well-formed + // QR Code needs exactly one (not zero, two, etc.) mask applied. + private applyMask(mask: int): void { + if (mask < 0 || mask > 7) throw new RangeError("Mask value out of range"); + for (let y = 0; y < this.size; y++) { + for (let x = 0; x < this.size; x++) { + let invert: boolean; + switch (mask) { + case 0: + invert = (x + y) % 2 == 0; + break; + case 1: + invert = y % 2 == 0; + break; + case 2: + invert = x % 3 == 0; + break; + case 3: + invert = (x + y) % 3 == 0; + break; + case 4: + invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; + break; + case 5: + invert = ((x * y) % 2) + ((x * y) % 3) == 0; + break; + case 6: + invert = (((x * y) % 2) + ((x * y) % 3)) % 2 == 0; + break; + case 7: + invert = (((x + y) % 2) + ((x * y) % 3)) % 2 == 0; + break; + default: + throw new Error("Unreachable"); + } + if (!this.isFunction[y][x] && invert) + this.modules[y][x] = !this.modules[y][x]; + } + } + } + + // Calculates and returns the penalty score based on state of this QR Code's current modules. + // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. + private getPenaltyScore(): int { + let result: int = 0; + + // Adjacent modules in row having same color, and finder-like patterns + for (let y = 0; y < this.size; y++) { + let runColor = false; + let runX = 0; + const runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let x = 0; x < this.size; x++) { + if (this.modules[y][x] == runColor) { + runX++; + if (runX == 5) result += QrCode.PENALTY_N1; + else if (runX > 5) result++; + } else { + this.finderPenaltyAddHistory(runX, runHistory); + if (!runColor) + result += + this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y][x]; + runX = 1; + } + } + result += + this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * + QrCode.PENALTY_N3; + } + // Adjacent modules in column having same color, and finder-like patterns + for (let x = 0; x < this.size; x++) { + let runColor = false; + let runY = 0; + const runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let y = 0; y < this.size; y++) { + if (this.modules[y][x] == runColor) { + runY++; + if (runY == 5) result += QrCode.PENALTY_N1; + else if (runY > 5) result++; + } else { + this.finderPenaltyAddHistory(runY, runHistory); + if (!runColor) + result += + this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y][x]; + runY = 1; + } + } + result += + this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * + QrCode.PENALTY_N3; + } + + // 2*2 blocks of modules having same color + for (let y = 0; y < this.size - 1; y++) { + for (let x = 0; x < this.size - 1; x++) { + const color: boolean = this.modules[y][x]; + if ( + color == this.modules[y][x + 1] && + color == this.modules[y + 1][x] && + color == this.modules[y + 1][x + 1] + ) + result += QrCode.PENALTY_N2; + } + } + + // Balance of dark and light modules + let dark: int = 0; + for (const row of this.modules) + dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark); + const total: int = this.size * this.size; // Note that size is odd, so dark/total != 1/2 + // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)% + const k: int = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; + assert(0 <= k && k <= 9); + result += k * QrCode.PENALTY_N4; + assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4 + return result; + } + + /*-- Private helper functions --*/ + + // Returns an ascending list of positions of alignment patterns for this version number. + // Each position is in the range [0,177), and are used on both the x and y axes. + // This could be implemented as lookup table of 40 variable-length lists of integers. + private getAlignmentPatternPositions(): Array { + if (this.version == 1) return []; + else { + const numAlign: int = Math.floor(this.version / 7) + 2; + const step: int = + Math.floor( + (this.version * 8 + numAlign * 3 + 5) / (numAlign * 4 - 4), + ) * 2; + const result: Array = [6]; + for (let pos = this.size - 7; result.length < numAlign; pos -= step) + result.splice(1, 0, pos); + return result; + } + } + + // Returns the number of data bits that can be stored in a QR Code of the given version number, after + // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. + // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. + private static getNumRawDataModules(ver: int): int { + if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION) + throw new RangeError("Version number out of range"); + let result: int = (16 * ver + 128) * ver + 64; + if (ver >= 2) { + const numAlign: int = Math.floor(ver / 7) + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if (ver >= 7) result -= 36; + } + assert(208 <= result && result <= 29648); + return result; + } + + // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any + // QR Code of the given version number and error correction level, with remainder bits discarded. + // This stateless pure function could be implemented as a (40*4)-cell lookup table. + private static getNumDataCodewords(ver: int, ecl: Ecc): int { + return ( + Math.floor(QrCode.getNumRawDataModules(ver) / 8) - + QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] * + QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver] + ); + } + + // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be + // implemented as a lookup table over all possible parameter values, instead of as an algorithm. + private static reedSolomonComputeDivisor(degree: int): Array { + if (degree < 1 || degree > 255) + throw new RangeError("Degree out of range"); + // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1. + // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93]. + const result: Array = []; + for (let i = 0; i < degree - 1; i++) result.push(0); + result.push(1); // Start off with the monomial x^0 + + // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}), + // and drop the highest monomial term which is always 1x^degree. + // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D). + let root = 1; + for (let i = 0; i < degree; i++) { + // Multiply the current product by (x - r^i) + for (let j = 0; j < result.length; j++) { + result[j] = QrCode.reedSolomonMultiply(result[j], root); + if (j + 1 < result.length) result[j] ^= result[j + 1]; + } + root = QrCode.reedSolomonMultiply(root, 0x02); + } + return result; + } + + // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. + private static reedSolomonComputeRemainder( + data: Readonly>, + divisor: Readonly>, + ): Array { + const result: Array = divisor.map(() => 0); + for (const b of data) { + // Polynomial division + const factor: byte = b ^ (result.shift() as byte); + result.push(0); + divisor.forEach( + (coef, i) => (result[i] ^= QrCode.reedSolomonMultiply(coef, factor)), + ); + } + return result; + } + + // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result + // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8. + private static reedSolomonMultiply(x: byte, y: byte): byte { + if (x >>> 8 != 0 || y >>> 8 != 0) + throw new RangeError("Byte out of range"); + // Russian peasant multiplication + let z: int = 0; + for (let i = 7; i >= 0; i--) { + z = (z << 1) ^ ((z >>> 7) * 0x11d); + z ^= ((y >>> i) & 1) * x; + } + assert(z >>> 8 == 0); + return z as byte; + } + + // Can only be called immediately after a light run is added, and + // returns either 0, 1, or 2. A helper function for getPenaltyScore(). + private finderPenaltyCountPatterns(runHistory: Readonly>): int { + const n: int = runHistory[1]; + assert(n <= this.size * 3); + const core: boolean = + n > 0 && + runHistory[2] == n && + runHistory[3] == n * 3 && + runHistory[4] == n && + runHistory[5] == n; + return ( + (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) + + (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0) + ); + } + + // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). + private finderPenaltyTerminateAndCount( + currentRunColor: boolean, + currentRunLength: int, + runHistory: Array, + ): int { + if (currentRunColor) { + // Terminate dark run + this.finderPenaltyAddHistory(currentRunLength, runHistory); + currentRunLength = 0; + } + currentRunLength += this.size; // Add light border to final run + this.finderPenaltyAddHistory(currentRunLength, runHistory); + return this.finderPenaltyCountPatterns(runHistory); + } + + // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). + private finderPenaltyAddHistory( + currentRunLength: int, + runHistory: Array, + ): void { + if (runHistory[0] == 0) currentRunLength += this.size; // Add light border to initial run + runHistory.pop(); + runHistory.unshift(currentRunLength); + } + + /*-- Constants and tables --*/ + + // The minimum version number supported in the QR Code Model 2 standard. + public static readonly MIN_VERSION: int = 1; + // The maximum version number supported in the QR Code Model 2 standard. + public static readonly MAX_VERSION: int = 40; + + // For use in getPenaltyScore(), when evaluating which mask is best. + private static readonly PENALTY_N1: int = 3; + private static readonly PENALTY_N2: int = 3; + private static readonly PENALTY_N3: int = 40; + private static readonly PENALTY_N4: int = 10; + + private static readonly ECC_CODEWORDS_PER_BLOCK: Array> = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [ + -1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, + 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, + 30, 30, 30, 30, 30, + ], // Low + [ + -1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, + 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, + 28, 28, 28, 28, 28, + ], // Medium + [ + -1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, + 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, + 30, 30, 30, 30, 30, + ], // Quartile + [ + -1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, + 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + 30, 30, 30, 30, 30, + ], // High + ]; + + private static readonly NUM_ERROR_CORRECTION_BLOCKS: Array> = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [ + -1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, + 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25, + ], // Low + [ + -1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, + 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, + 47, 49, + ], // Medium + [ + -1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, + 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, + 65, 68, + ], // Quartile + [ + -1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, + 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, + 74, 77, 81, + ], // High + ]; + } + + // Appends the given number of low-order bits of the given value + // to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len. + function appendBits(val: int, len: int, bb: Array): void { + if (len < 0 || len > 31 || val >>> len != 0) + throw new RangeError("Value out of range"); + for ( + let i = len - 1; + i >= 0; + i-- // Append bit by bit + ) + bb.push((val >>> i) & 1); + } + + // Returns true iff the i'th bit of x is set to 1. + function getBit(x: int, i: int): boolean { + return ((x >>> i) & 1) != 0; + } + + // Throws an exception if the given condition is false. + function assert(cond: boolean): void { + if (!cond) throw new Error("Assertion error"); + } + + /*---- Data segment class ----*/ + + /* + * A segment of character/binary/control data in a QR Code symbol. + * Instances of this class are immutable. + * The mid-level way to create a segment is to take the payload data + * and call a static factory function such as QrSegment.makeNumeric(). + * The low-level way to create a segment is to custom-make the bit buffer + * and call the QrSegment() constructor with appropriate values. + * This segment class imposes no length restrictions, but QR Codes have restrictions. + * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. + * Any segment longer than this is meaningless for the purpose of generating QR Codes. + */ + export class QrSegment { + public static Mode: typeof Mode; + /*-- Static factory functions (mid level) --*/ + + // Returns a segment representing the given binary data encoded in + // byte mode. All input byte arrays are acceptable. Any text string + // can be converted to UTF-8 bytes and encoded as a byte mode segment. + public static makeBytes(data: Readonly>): QrSegment { + const bb: Array = []; + for (const b of data) appendBits(b, 8, bb); + return new QrSegment(QrSegment.Mode.BYTE, data.length, bb); + } + + // Returns a segment representing the given string of decimal digits encoded in numeric mode. + public static makeNumeric(digits: string): QrSegment { + if (!QrSegment.isNumeric(digits)) + throw new RangeError("String contains non-numeric characters"); + const bb: Array = []; + for (let i = 0; i < digits.length; ) { + // Consume up to 3 digits per iteration + const n: int = Math.min(digits.length - i, 3); + appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb); + i += n; + } + return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb); + } + + // Returns a segment representing the given text string encoded in alphanumeric mode. + // The characters allowed are: 0 to 9, A to Z (uppercase only), space, + // dollar, percent, asterisk, plus, hyphen, period, slash, colon. + public static makeAlphanumeric(text: string): QrSegment { + if (!QrSegment.isAlphanumeric(text)) + throw new RangeError( + "String contains unencodable characters in alphanumeric mode", + ); + const bb: Array = []; + let i: int; + for (i = 0; i + 2 <= text.length; i += 2) { + // Process groups of 2 + let temp: int = + QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45; + temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); + appendBits(temp, 11, bb); + } + if (i < text.length) + // 1 character remaining + appendBits( + QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), + 6, + bb, + ); + return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb); + } + + // Returns a new mutable list of zero or more segments to represent the given Unicode text string. + // The result may use various segment modes and switch modes to optimize the length of the bit stream. + public static makeSegments(text: string): Array { + // Select the most efficient segment encoding automatically + if (text == "") return []; + else if (QrSegment.isNumeric(text)) return [QrSegment.makeNumeric(text)]; + else if (QrSegment.isAlphanumeric(text)) + return [QrSegment.makeAlphanumeric(text)]; + else return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))]; + } + + // Returns a segment representing an Extended Channel Interpretation + // (ECI) designator with the given assignment value. + public static makeEci(assignVal: int): QrSegment { + const bb: Array = []; + if (assignVal < 0) + throw new RangeError("ECI assignment value out of range"); + else if (assignVal < 1 << 7) appendBits(assignVal, 8, bb); + else if (assignVal < 1 << 14) { + appendBits(0b10, 2, bb); + appendBits(assignVal, 14, bb); + } else if (assignVal < 1000000) { + appendBits(0b110, 3, bb); + appendBits(assignVal, 21, bb); + } else throw new RangeError("ECI assignment value out of range"); + return new QrSegment(QrSegment.Mode.ECI, 0, bb); + } + + // Tests whether the given string can be encoded as a segment in numeric mode. + // A string is encodable iff each character is in the range 0 to 9. + public static isNumeric(text: string): boolean { + return QrSegment.NUMERIC_REGEX.test(text); + } + + // Tests whether the given string can be encoded as a segment in alphanumeric mode. + // A string is encodable iff each character is in the following set: 0 to 9, A to Z + // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. + public static isAlphanumeric(text: string): boolean { + return QrSegment.ALPHANUMERIC_REGEX.test(text); + } + + /*-- Constructor (low level) and fields --*/ + + // Creates a new QR Code segment with the given attributes and data. + // The character count (numChars) must agree with the mode and the bit buffer length, + // but the constraint isn't checked. The given bit buffer is cloned and stored. + public constructor( + // The mode indicator of this segment. + public readonly mode: Mode, + + // The length of this segment's unencoded data. Measured in characters for + // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. + // Always zero or positive. Not the same as the data's bit length. + public readonly numChars: int, + + // The data bits of this segment. Accessed through getData(). + private readonly bitData: Array, + ) { + if (numChars < 0) throw new RangeError("Invalid argument"); + this.bitData = bitData.slice(); // Make defensive copy + } + + /*-- Methods --*/ + + // Returns a new copy of the data bits of this segment. + public getData(): Array { + return this.bitData.slice(); // Make defensive copy + } + + // (Package-private) Calculates and returns the number of bits needed to encode the given segments at + // the given version. The result is infinity if a segment has too many characters to fit its length field. + public static getTotalBits( + segs: Readonly>, + version: int, + ): number { + let result: number = 0; + for (const seg of segs) { + const ccbits: int = seg.mode.numCharCountBits(version); + if (seg.numChars >= 1 << ccbits) return Infinity; // The segment's length doesn't fit the field's bit width + result += 4 + ccbits + seg.bitData.length; + } + return result; + } + + // Returns a new array of bytes representing the given string encoded in UTF-8. + private static toUtf8ByteArray(str: string): Array { + str = encodeURI(str); + const result: Array = []; + for (let i = 0; i < str.length; i++) { + if (str.charAt(i) != "%") result.push(str.charCodeAt(i)); + else { + result.push(parseInt(str.substring(i + 1, i + 3), 16)); + i += 2; + } + } + return result; + } + + /*-- Constants --*/ + + // Describes precisely all strings that are encodable in numeric mode. + private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/; + + // Describes precisely all strings that are encodable in alphanumeric mode. + private static readonly ALPHANUMERIC_REGEX: RegExp = + /^[A-Z0-9 $%*+./:-]*$/; + + // The set of all legal characters in alphanumeric mode, + // where each character value maps to the index in the string. + private static readonly ALPHANUMERIC_CHARSET: string = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; + } + + /* + * The error correction level in a QR Code symbol. Immutable. + */ + export class Ecc { + public static readonly LOW = new Ecc(0, 1); // The QR Code can tolerate about 7% erroneous codewords + public static readonly MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords + public static readonly QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords + public static readonly HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords + + private constructor( + public readonly ordinal: int, + public readonly formatBits: int, + ) {} + } + + /* + * Describes how a segment's data bits are interpreted. Immutable. + */ + export class Mode { + public static readonly NUMERIC = new Mode(0x1, [10, 12, 14]); + public static readonly ALPHANUMERIC = new Mode(0x2, [9, 11, 13]); + public static readonly BYTE = new Mode(0x4, [8, 16, 16]); + public static readonly KANJI = new Mode(0x8, [8, 10, 12]); + public static readonly ECI = new Mode(0x7, [0, 0, 0]); + + private constructor( + public readonly modeBits: int, + private readonly numBitsCharCount: [int, int, int], + ) {} + + public numCharCountBits(ver: int): int { + return this.numBitsCharCount[Math.floor((ver + 7) / 17)]; + } + } + + QrCode.Ecc = Ecc; + QrSegment.Mode = Mode; + + export const qrcodegen = { QrCode, QrSegment }; diff --git a/src/lib/screenStreaming/bip39English2048.ts b/src/lib/screenStreaming/bip39English2048.ts new file mode 100644 index 0000000..7f355c0 --- /dev/null +++ b/src/lib/screenStreaming/bip39English2048.ts @@ -0,0 +1,136 @@ +// BIP-0039 English word list (2048 words). +// Source: https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt +// License: MIT (as noted in BIP-0039). + +export const BIP39_ENGLISH_WORDS_2048 = [ + 'abandon', 'ability', 'able', 'about', 'above', 'absent', 'absorb', 'abstract', 'absurd', 'abuse', 'access', 'accident', 'account', 'accuse', 'achieve', 'acid', + 'acoustic', 'acquire', 'across', 'act', 'action', 'actor', 'actress', 'actual', 'adapt', 'add', 'addict', 'address', 'adjust', 'admit', 'adult', 'advance', + 'advice', 'aerobic', 'affair', 'afford', 'afraid', 'again', 'age', 'agent', 'agree', 'ahead', 'aim', 'air', 'airport', 'aisle', 'alarm', 'album', + 'alcohol', 'alert', 'alien', 'all', 'alley', 'allow', 'almost', 'alone', 'alpha', 'already', 'also', 'alter', 'always', 'amateur', 'amazing', 'among', + 'amount', 'amused', 'analyst', 'anchor', 'ancient', 'anger', 'angle', 'angry', 'animal', 'ankle', 'announce', 'annual', 'another', 'answer', 'antenna', 'antique', + 'anxiety', 'any', 'apart', 'apology', 'appear', 'apple', 'approve', 'april', 'arch', 'arctic', 'area', 'arena', 'argue', 'arm', 'armed', 'armor', + 'army', 'around', 'arrange', 'arrest', 'arrive', 'arrow', 'art', 'artefact', 'artist', 'artwork', 'ask', 'aspect', 'assault', 'asset', 'assist', 'assume', + 'asthma', 'athlete', 'atom', 'attack', 'attend', 'attitude', 'attract', 'auction', 'audit', 'august', 'aunt', 'author', 'auto', 'autumn', 'average', 'avocado', + 'avoid', 'awake', 'aware', 'away', 'awesome', 'awful', 'awkward', 'axis', 'baby', 'bachelor', 'bacon', 'badge', 'bag', 'balance', 'balcony', 'ball', + 'bamboo', 'banana', 'banner', 'bar', 'barely', 'bargain', 'barrel', 'base', 'basic', 'basket', 'battle', 'beach', 'bean', 'beauty', 'because', 'become', + 'beef', 'before', 'begin', 'behave', 'behind', 'believe', 'below', 'belt', 'bench', 'benefit', 'best', 'betray', 'better', 'between', 'beyond', 'bicycle', + 'bid', 'bike', 'bind', 'biology', 'bird', 'birth', 'bitter', 'black', 'blade', 'blame', 'blanket', 'blast', 'bleak', 'bless', 'blind', 'blood', + 'blossom', 'blouse', 'blue', 'blur', 'blush', 'board', 'boat', 'body', 'boil', 'bomb', 'bone', 'bonus', 'book', 'boost', 'border', 'boring', + 'borrow', 'boss', 'bottom', 'bounce', 'box', 'boy', 'bracket', 'brain', 'brand', 'brass', 'brave', 'bread', 'breeze', 'brick', 'bridge', 'brief', + 'bright', 'bring', 'brisk', 'broccoli', 'broken', 'bronze', 'broom', 'brother', 'brown', 'brush', 'bubble', 'buddy', 'budget', 'buffalo', 'build', 'bulb', + 'bulk', 'bullet', 'bundle', 'bunker', 'burden', 'burger', 'burst', 'bus', 'business', 'busy', 'butter', 'buyer', 'buzz', 'cabbage', 'cabin', 'cable', + 'cactus', 'cage', 'cake', 'call', 'calm', 'camera', 'camp', 'can', 'canal', 'cancel', 'candy', 'cannon', 'canoe', 'canvas', 'canyon', 'capable', + 'capital', 'captain', 'car', 'carbon', 'card', 'cargo', 'carpet', 'carry', 'cart', 'case', 'cash', 'casino', 'castle', 'casual', 'cat', 'catalog', + 'catch', 'category', 'cattle', 'caught', 'cause', 'caution', 'cave', 'ceiling', 'celery', 'cement', 'census', 'century', 'cereal', 'certain', 'chair', 'chalk', + 'champion', 'change', 'chaos', 'chapter', 'charge', 'chase', 'chat', 'cheap', 'check', 'cheese', 'chef', 'cherry', 'chest', 'chicken', 'chief', 'child', + 'chimney', 'choice', 'choose', 'chronic', 'chuckle', 'chunk', 'churn', 'cigar', 'cinnamon', 'circle', 'citizen', 'city', 'civil', 'claim', 'clap', 'clarify', + 'claw', 'clay', 'clean', 'clerk', 'clever', 'click', 'client', 'cliff', 'climb', 'clinic', 'clip', 'clock', 'clog', 'close', 'cloth', 'cloud', + 'clown', 'club', 'clump', 'cluster', 'clutch', 'coach', 'coast', 'coconut', 'code', 'coffee', 'coil', 'coin', 'collect', 'color', 'column', 'combine', + 'come', 'comfort', 'comic', 'common', 'company', 'concert', 'conduct', 'confirm', 'congress', 'connect', 'consider', 'control', 'convince', 'cook', 'cool', 'copper', + 'copy', 'coral', 'core', 'corn', 'correct', 'cost', 'cotton', 'couch', 'country', 'couple', 'course', 'cousin', 'cover', 'coyote', 'crack', 'cradle', + 'craft', 'cram', 'crane', 'crash', 'crater', 'crawl', 'crazy', 'cream', 'credit', 'creek', 'crew', 'cricket', 'crime', 'crisp', 'critic', 'crop', + 'cross', 'crouch', 'crowd', 'crucial', 'cruel', 'cruise', 'crumble', 'crunch', 'crush', 'cry', 'crystal', 'cube', 'culture', 'cup', 'cupboard', 'curious', + 'current', 'curtain', 'curve', 'cushion', 'custom', 'cute', 'cycle', 'dad', 'damage', 'damp', 'dance', 'danger', 'daring', 'dash', 'daughter', 'dawn', + 'day', 'deal', 'debate', 'debris', 'decade', 'december', 'decide', 'decline', 'decorate', 'decrease', 'deer', 'defense', 'define', 'defy', 'degree', 'delay', + 'deliver', 'demand', 'demise', 'denial', 'dentist', 'deny', 'depart', 'depend', 'deposit', 'depth', 'deputy', 'derive', 'describe', 'desert', 'design', 'desk', + 'despair', 'destroy', 'detail', 'detect', 'develop', 'device', 'devote', 'diagram', 'dial', 'diamond', 'diary', 'dice', 'diesel', 'diet', 'differ', 'digital', + 'dignity', 'dilemma', 'dinner', 'dinosaur', 'direct', 'dirt', 'disagree', 'discover', 'disease', 'dish', 'dismiss', 'disorder', 'display', 'distance', 'divert', 'divide', + 'divorce', 'dizzy', 'doctor', 'document', 'dog', 'doll', 'dolphin', 'domain', 'donate', 'donkey', 'donor', 'door', 'dose', 'double', 'dove', 'draft', + 'dragon', 'drama', 'drastic', 'draw', 'dream', 'dress', 'drift', 'drill', 'drink', 'drip', 'drive', 'drop', 'drum', 'dry', 'duck', 'dumb', + 'dune', 'during', 'dust', 'dutch', 'duty', 'dwarf', 'dynamic', 'eager', 'eagle', 'early', 'earn', 'earth', 'easily', 'east', 'easy', 'echo', + 'ecology', 'economy', 'edge', 'edit', 'educate', 'effort', 'egg', 'eight', 'either', 'elbow', 'elder', 'electric', 'elegant', 'element', 'elephant', 'elevator', + 'elite', 'else', 'embark', 'embody', 'embrace', 'emerge', 'emotion', 'employ', 'empower', 'empty', 'enable', 'enact', 'end', 'endless', 'endorse', 'enemy', + 'energy', 'enforce', 'engage', 'engine', 'enhance', 'enjoy', 'enlist', 'enough', 'enrich', 'enroll', 'ensure', 'enter', 'entire', 'entry', 'envelope', 'episode', + 'equal', 'equip', 'era', 'erase', 'erode', 'erosion', 'error', 'erupt', 'escape', 'essay', 'essence', 'estate', 'eternal', 'ethics', 'evidence', 'evil', + 'evoke', 'evolve', 'exact', 'example', 'excess', 'exchange', 'excite', 'exclude', 'excuse', 'execute', 'exercise', 'exhaust', 'exhibit', 'exile', 'exist', 'exit', + 'exotic', 'expand', 'expect', 'expire', 'explain', 'expose', 'express', 'extend', 'extra', 'eye', 'eyebrow', 'fabric', 'face', 'faculty', 'fade', 'faint', + 'faith', 'fall', 'false', 'fame', 'family', 'famous', 'fan', 'fancy', 'fantasy', 'farm', 'fashion', 'fat', 'fatal', 'father', 'fatigue', 'fault', + 'favorite', 'feature', 'february', 'federal', 'fee', 'feed', 'feel', 'female', 'fence', 'festival', 'fetch', 'fever', 'few', 'fiber', 'fiction', 'field', + 'figure', 'file', 'film', 'filter', 'final', 'find', 'fine', 'finger', 'finish', 'fire', 'firm', 'first', 'fiscal', 'fish', 'fit', 'fitness', + 'fix', 'flag', 'flame', 'flash', 'flat', 'flavor', 'flee', 'flight', 'flip', 'float', 'flock', 'floor', 'flower', 'fluid', 'flush', 'fly', + 'foam', 'focus', 'fog', 'foil', 'fold', 'follow', 'food', 'foot', 'force', 'forest', 'forget', 'fork', 'fortune', 'forum', 'forward', 'fossil', + 'foster', 'found', 'fox', 'fragile', 'frame', 'frequent', 'fresh', 'friend', 'fringe', 'frog', 'front', 'frost', 'frown', 'frozen', 'fruit', 'fuel', + 'fun', 'funny', 'furnace', 'fury', 'future', 'gadget', 'gain', 'galaxy', 'gallery', 'game', 'gap', 'garage', 'garbage', 'garden', 'garlic', 'garment', + 'gas', 'gasp', 'gate', 'gather', 'gauge', 'gaze', 'general', 'genius', 'genre', 'gentle', 'genuine', 'gesture', 'ghost', 'giant', 'gift', 'giggle', + 'ginger', 'giraffe', 'girl', 'give', 'glad', 'glance', 'glare', 'glass', 'glide', 'glimpse', 'globe', 'gloom', 'glory', 'glove', 'glow', 'glue', + 'goat', 'goddess', 'gold', 'good', 'goose', 'gorilla', 'gospel', 'gossip', 'govern', 'gown', 'grab', 'grace', 'grain', 'grant', 'grape', 'grass', + 'gravity', 'great', 'green', 'grid', 'grief', 'grit', 'grocery', 'group', 'grow', 'grunt', 'guard', 'guess', 'guide', 'guilt', 'guitar', 'gun', + 'gym', 'habit', 'hair', 'half', 'hammer', 'hamster', 'hand', 'happy', 'harbor', 'hard', 'harsh', 'harvest', 'hat', 'have', 'hawk', 'hazard', + 'head', 'health', 'heart', 'heavy', 'hedgehog', 'height', 'hello', 'helmet', 'help', 'hen', 'hero', 'hidden', 'high', 'hill', 'hint', 'hip', + 'hire', 'history', 'hobby', 'hockey', 'hold', 'hole', 'holiday', 'hollow', 'home', 'honey', 'hood', 'hope', 'horn', 'horror', 'horse', 'hospital', + 'host', 'hotel', 'hour', 'hover', 'hub', 'huge', 'human', 'humble', 'humor', 'hundred', 'hungry', 'hunt', 'hurdle', 'hurry', 'hurt', 'husband', + 'hybrid', 'ice', 'icon', 'idea', 'identify', 'idle', 'ignore', 'ill', 'illegal', 'illness', 'image', 'imitate', 'immense', 'immune', 'impact', 'impose', + 'improve', 'impulse', 'inch', 'include', 'income', 'increase', 'index', 'indicate', 'indoor', 'industry', 'infant', 'inflict', 'inform', 'inhale', 'inherit', 'initial', + 'inject', 'injury', 'inmate', 'inner', 'innocent', 'input', 'inquiry', 'insane', 'insect', 'inside', 'inspire', 'install', 'intact', 'interest', 'into', 'invest', + 'invite', 'involve', 'iron', 'island', 'isolate', 'issue', 'item', 'ivory', 'jacket', 'jaguar', 'jar', 'jazz', 'jealous', 'jeans', 'jelly', 'jewel', + 'job', 'join', 'joke', 'journey', 'joy', 'judge', 'juice', 'jump', 'jungle', 'junior', 'junk', 'just', 'kangaroo', 'keen', 'keep', 'ketchup', + 'key', 'kick', 'kid', 'kidney', 'kind', 'kingdom', 'kiss', 'kit', 'kitchen', 'kite', 'kitten', 'kiwi', 'knee', 'knife', 'knock', 'know', + 'lab', 'label', 'labor', 'ladder', 'lady', 'lake', 'lamp', 'language', 'laptop', 'large', 'later', 'latin', 'laugh', 'laundry', 'lava', 'law', + 'lawn', 'lawsuit', 'layer', 'lazy', 'leader', 'leaf', 'learn', 'leave', 'lecture', 'left', 'leg', 'legal', 'legend', 'leisure', 'lemon', 'lend', + 'length', 'lens', 'leopard', 'lesson', 'letter', 'level', 'liar', 'liberty', 'library', 'license', 'life', 'lift', 'light', 'like', 'limb', 'limit', + 'link', 'lion', 'liquid', 'list', 'little', 'live', 'lizard', 'load', 'loan', 'lobster', 'local', 'lock', 'logic', 'lonely', 'long', 'loop', + 'lottery', 'loud', 'lounge', 'love', 'loyal', 'lucky', 'luggage', 'lumber', 'lunar', 'lunch', 'luxury', 'lyrics', 'machine', 'mad', 'magic', 'magnet', + 'maid', 'mail', 'main', 'major', 'make', 'mammal', 'man', 'manage', 'mandate', 'mango', 'mansion', 'manual', 'maple', 'marble', 'march', 'margin', + 'marine', 'market', 'marriage', 'mask', 'mass', 'master', 'match', 'material', 'math', 'matrix', 'matter', 'maximum', 'maze', 'meadow', 'mean', 'measure', + 'meat', 'mechanic', 'medal', 'media', 'melody', 'melt', 'member', 'memory', 'mention', 'menu', 'mercy', 'merge', 'merit', 'merry', 'mesh', 'message', + 'metal', 'method', 'middle', 'midnight', 'milk', 'million', 'mimic', 'mind', 'minimum', 'minor', 'minute', 'miracle', 'mirror', 'misery', 'miss', 'mistake', + 'mix', 'mixed', 'mixture', 'mobile', 'model', 'modify', 'mom', 'moment', 'monitor', 'monkey', 'monster', 'month', 'moon', 'moral', 'more', 'morning', + 'mosquito', 'mother', 'motion', 'motor', 'mountain', 'mouse', 'move', 'movie', 'much', 'muffin', 'mule', 'multiply', 'muscle', 'museum', 'mushroom', 'music', + 'must', 'mutual', 'myself', 'mystery', 'myth', 'naive', 'name', 'napkin', 'narrow', 'nasty', 'nation', 'nature', 'near', 'neck', 'need', 'negative', + 'neglect', 'neither', 'nephew', 'nerve', 'nest', 'net', 'network', 'neutral', 'never', 'news', 'next', 'nice', 'night', 'noble', 'noise', 'nominee', + 'noodle', 'normal', 'north', 'nose', 'notable', 'note', 'nothing', 'notice', 'novel', 'now', 'nuclear', 'number', 'nurse', 'nut', 'oak', 'obey', + 'object', 'oblige', 'obscure', 'observe', 'obtain', 'obvious', 'occur', 'ocean', 'october', 'odor', 'off', 'offer', 'office', 'often', 'oil', 'okay', + 'old', 'olive', 'olympic', 'omit', 'once', 'one', 'onion', 'online', 'only', 'open', 'opera', 'opinion', 'oppose', 'option', 'orange', 'orbit', + 'orchard', 'order', 'ordinary', 'organ', 'orient', 'original', 'orphan', 'ostrich', 'other', 'outdoor', 'outer', 'output', 'outside', 'oval', 'oven', 'over', + 'own', 'owner', 'oxygen', 'oyster', 'ozone', 'pact', 'paddle', 'page', 'pair', 'palace', 'palm', 'panda', 'panel', 'panic', 'panther', 'paper', + 'parade', 'parent', 'park', 'parrot', 'party', 'pass', 'patch', 'path', 'patient', 'patrol', 'pattern', 'pause', 'pave', 'payment', 'peace', 'peanut', + 'pear', 'peasant', 'pelican', 'pen', 'penalty', 'pencil', 'people', 'pepper', 'perfect', 'permit', 'person', 'pet', 'phone', 'photo', 'phrase', 'physical', + 'piano', 'picnic', 'picture', 'piece', 'pig', 'pigeon', 'pill', 'pilot', 'pink', 'pioneer', 'pipe', 'pistol', 'pitch', 'pizza', 'place', 'planet', + 'plastic', 'plate', 'play', 'please', 'pledge', 'pluck', 'plug', 'plunge', 'poem', 'poet', 'point', 'polar', 'pole', 'police', 'pond', 'pony', + 'pool', 'popular', 'portion', 'position', 'possible', 'post', 'potato', 'pottery', 'poverty', 'powder', 'power', 'practice', 'praise', 'predict', 'prefer', 'prepare', + 'present', 'pretty', 'prevent', 'price', 'pride', 'primary', 'print', 'priority', 'prison', 'private', 'prize', 'problem', 'process', 'produce', 'profit', 'program', + 'project', 'promote', 'proof', 'property', 'prosper', 'protect', 'proud', 'provide', 'public', 'pudding', 'pull', 'pulp', 'pulse', 'pumpkin', 'punch', 'pupil', + 'puppy', 'purchase', 'purity', 'purpose', 'purse', 'push', 'put', 'puzzle', 'pyramid', 'quality', 'quantum', 'quarter', 'question', 'quick', 'quit', 'quiz', + 'quote', 'rabbit', 'raccoon', 'race', 'rack', 'radar', 'radio', 'rail', 'rain', 'raise', 'rally', 'ramp', 'ranch', 'random', 'range', 'rapid', + 'rare', 'rate', 'rather', 'raven', 'raw', 'razor', 'ready', 'real', 'reason', 'rebel', 'rebuild', 'recall', 'receive', 'recipe', 'record', 'recycle', + 'reduce', 'reflect', 'reform', 'refuse', 'region', 'regret', 'regular', 'reject', 'relax', 'release', 'relief', 'rely', 'remain', 'remember', 'remind', 'remove', + 'render', 'renew', 'rent', 'reopen', 'repair', 'repeat', 'replace', 'report', 'require', 'rescue', 'resemble', 'resist', 'resource', 'response', 'result', 'retire', + 'retreat', 'return', 'reunion', 'reveal', 'review', 'reward', 'rhythm', 'rib', 'ribbon', 'rice', 'rich', 'ride', 'ridge', 'rifle', 'right', 'rigid', + 'ring', 'riot', 'ripple', 'risk', 'ritual', 'rival', 'river', 'road', 'roast', 'robot', 'robust', 'rocket', 'romance', 'roof', 'rookie', 'room', + 'rose', 'rotate', 'rough', 'round', 'route', 'royal', 'rubber', 'rude', 'rug', 'rule', 'run', 'runway', 'rural', 'sad', 'saddle', 'sadness', + 'safe', 'sail', 'salad', 'salmon', 'salon', 'salt', 'salute', 'same', 'sample', 'sand', 'satisfy', 'satoshi', 'sauce', 'sausage', 'save', 'say', + 'scale', 'scan', 'scare', 'scatter', 'scene', 'scheme', 'school', 'science', 'scissors', 'scorpion', 'scout', 'scrap', 'screen', 'script', 'scrub', 'sea', + 'search', 'season', 'seat', 'second', 'secret', 'section', 'security', 'seed', 'seek', 'segment', 'select', 'sell', 'seminar', 'senior', 'sense', 'sentence', + 'series', 'service', 'session', 'settle', 'setup', 'seven', 'shadow', 'shaft', 'shallow', 'share', 'shed', 'shell', 'sheriff', 'shield', 'shift', 'shine', + 'ship', 'shiver', 'shock', 'shoe', 'shoot', 'shop', 'short', 'shoulder', 'shove', 'shrimp', 'shrug', 'shuffle', 'shy', 'sibling', 'sick', 'side', + 'siege', 'sight', 'sign', 'silent', 'silk', 'silly', 'silver', 'similar', 'simple', 'since', 'sing', 'siren', 'sister', 'situate', 'six', 'size', + 'skate', 'sketch', 'ski', 'skill', 'skin', 'skirt', 'skull', 'slab', 'slam', 'sleep', 'slender', 'slice', 'slide', 'slight', 'slim', 'slogan', + 'slot', 'slow', 'slush', 'small', 'smart', 'smile', 'smoke', 'smooth', 'snack', 'snake', 'snap', 'sniff', 'snow', 'soap', 'soccer', 'social', + 'sock', 'soda', 'soft', 'solar', 'soldier', 'solid', 'solution', 'solve', 'someone', 'song', 'soon', 'sorry', 'sort', 'soul', 'sound', 'soup', + 'source', 'south', 'space', 'spare', 'spatial', 'spawn', 'speak', 'special', 'speed', 'spell', 'spend', 'sphere', 'spice', 'spider', 'spike', 'spin', + 'spirit', 'split', 'spoil', 'sponsor', 'spoon', 'sport', 'spot', 'spray', 'spread', 'spring', 'spy', 'square', 'squeeze', 'squirrel', 'stable', 'stadium', + 'staff', 'stage', 'stairs', 'stamp', 'stand', 'start', 'state', 'stay', 'steak', 'steel', 'stem', 'step', 'stereo', 'stick', 'still', 'sting', + 'stock', 'stomach', 'stone', 'stool', 'story', 'stove', 'strategy', 'street', 'strike', 'strong', 'struggle', 'student', 'stuff', 'stumble', 'style', 'subject', + 'submit', 'subway', 'success', 'such', 'sudden', 'suffer', 'sugar', 'suggest', 'suit', 'summer', 'sun', 'sunny', 'sunset', 'super', 'supply', 'supreme', + 'sure', 'surface', 'surge', 'surprise', 'surround', 'survey', 'suspect', 'sustain', 'swallow', 'swamp', 'swap', 'swarm', 'swear', 'sweet', 'swift', 'swim', + 'swing', 'switch', 'sword', 'symbol', 'symptom', 'syrup', 'system', 'table', 'tackle', 'tag', 'tail', 'talent', 'talk', 'tank', 'tape', 'target', + 'task', 'taste', 'tattoo', 'taxi', 'teach', 'team', 'tell', 'ten', 'tenant', 'tennis', 'tent', 'term', 'test', 'text', 'thank', 'that', + 'theme', 'then', 'theory', 'there', 'they', 'thing', 'this', 'thought', 'three', 'thrive', 'throw', 'thumb', 'thunder', 'ticket', 'tide', 'tiger', + 'tilt', 'timber', 'time', 'tiny', 'tip', 'tired', 'tissue', 'title', 'toast', 'tobacco', 'today', 'toddler', 'toe', 'together', 'toilet', 'token', + 'tomato', 'tomorrow', 'tone', 'tongue', 'tonight', 'tool', 'tooth', 'top', 'topic', 'topple', 'torch', 'tornado', 'tortoise', 'toss', 'total', 'tourist', + 'toward', 'tower', 'town', 'toy', 'track', 'trade', 'traffic', 'tragic', 'train', 'transfer', 'trap', 'trash', 'travel', 'tray', 'treat', 'tree', + 'trend', 'trial', 'tribe', 'trick', 'trigger', 'trim', 'trip', 'trophy', 'trouble', 'truck', 'true', 'truly', 'trumpet', 'trust', 'truth', 'try', + 'tube', 'tuition', 'tumble', 'tuna', 'tunnel', 'turkey', 'turn', 'turtle', 'twelve', 'twenty', 'twice', 'twin', 'twist', 'two', 'type', 'typical', + 'ugly', 'umbrella', 'unable', 'unaware', 'uncle', 'uncover', 'under', 'undo', 'unfair', 'unfold', 'unhappy', 'uniform', 'unique', 'unit', 'universe', 'unknown', + 'unlock', 'until', 'unusual', 'unveil', 'update', 'upgrade', 'uphold', 'upon', 'upper', 'upset', 'urban', 'urge', 'usage', 'use', 'used', 'useful', + 'useless', 'usual', 'utility', 'vacant', 'vacuum', 'vague', 'valid', 'valley', 'valve', 'van', 'vanish', 'vapor', 'various', 'vast', 'vault', 'vehicle', + 'velvet', 'vendor', 'venture', 'venue', 'verb', 'verify', 'version', 'very', 'vessel', 'veteran', 'viable', 'vibrant', 'vicious', 'victory', 'video', 'view', + 'village', 'vintage', 'violin', 'virtual', 'virus', 'visa', 'visit', 'visual', 'vital', 'vivid', 'vocal', 'voice', 'void', 'volcano', 'volume', 'vote', + 'voyage', 'wage', 'wagon', 'wait', 'walk', 'wall', 'walnut', 'want', 'warfare', 'warm', 'warrior', 'wash', 'wasp', 'waste', 'water', 'wave', + 'way', 'wealth', 'weapon', 'wear', 'weasel', 'weather', 'web', 'wedding', 'weekend', 'weird', 'welcome', 'west', 'wet', 'whale', 'what', 'wheat', + 'wheel', 'when', 'where', 'whip', 'whisper', 'wide', 'width', 'wife', 'wild', 'will', 'win', 'window', 'wine', 'wing', 'wink', 'winner', + 'winter', 'wire', 'wisdom', 'wise', 'wish', 'witness', 'wolf', 'woman', 'wonder', 'wood', 'wool', 'word', 'work', 'world', 'worry', 'worth', + 'wrap', 'wreck', 'wrestle', 'wrist', 'write', 'wrong', 'yard', 'year', 'yellow', 'you', 'young', 'youth', 'zebra', 'zero', 'zone', 'zoo', +] as const; + +export type Bip39EnglishWord = (typeof BIP39_ENGLISH_WORDS_2048)[number]; diff --git a/src/lib/screenStreaming/codec.ts b/src/lib/screenStreaming/codec.ts new file mode 100644 index 0000000..7afbbb0 --- /dev/null +++ b/src/lib/screenStreaming/codec.ts @@ -0,0 +1,87 @@ +export const STREAM_MAGIC_0 = 0x44; // 'D' +export const STREAM_MAGIC_1 = 0x58; // 'X' +export const STREAM_VERSION = 0x01; + +export const STREAM_MSG_TYPE_DISPLAY_SYSEX = 0x01; + +export const STREAM_HEADER_BYTES = 11; + +export enum DisplaySysexKind { + OledFull = 0, + OledDelta = 1, + Seg7 = 2, +} + +export type DisplaySysexFrame = { + msgType: typeof STREAM_MSG_TYPE_DISPLAY_SYSEX; + seq: number; + kind: DisplaySysexKind; + payload: Uint8Array; +}; + +export function encodeDisplaySysexFrame( + frame: Omit, +): Uint8Array { + const payloadLen = frame.payload.length; + const header = new Uint8Array(STREAM_HEADER_BYTES); + header[0] = STREAM_MAGIC_0; + header[1] = STREAM_MAGIC_1; + header[2] = STREAM_VERSION; + header[3] = STREAM_MSG_TYPE_DISPLAY_SYSEX; + + const view = new DataView(header.buffer); + view.setUint32(4, frame.seq >>> 0, false); + header[8] = frame.kind; + + // payloadLen is optional; if payload is larger than uint16, set 0 and rely + // on the WS frame length. + view.setUint16(9, payloadLen < 0x10000 ? payloadLen : 0, false); + + const out = new Uint8Array(STREAM_HEADER_BYTES + payloadLen); + out.set(header, 0); + out.set(frame.payload, STREAM_HEADER_BYTES); + return out; +} + +export function decodeDisplaySysexFrame( + data: ArrayBuffer | Uint8Array, +): DisplaySysexFrame { + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + if (bytes.length < STREAM_HEADER_BYTES) { + throw new Error("frame too short"); + } + if (bytes[0] !== STREAM_MAGIC_0 || bytes[1] !== STREAM_MAGIC_1) { + throw new Error("bad magic"); + } + if (bytes[2] !== STREAM_VERSION) { + throw new Error("unsupported version"); + } + if (bytes[3] !== STREAM_MSG_TYPE_DISPLAY_SYSEX) { + throw new Error("unsupported msgType"); + } + + const view = new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ); + const seq = view.getUint32(4, false); + const kind = bytes[8] as DisplaySysexKind; + const payloadLen = view.getUint16(9, false); + const payload = bytes.subarray(STREAM_HEADER_BYTES); + if (payloadLen !== 0 && payloadLen !== payload.length) { + throw new Error("payload length mismatch"); + } + + return { msgType: STREAM_MSG_TYPE_DISPLAY_SYSEX, seq, kind, payload }; +} + +export function tryDecodeDisplaySysexFrame( + data: ArrayBuffer | Uint8Array, +): DisplaySysexFrame | null { + try { + return decodeDisplaySysexFrame(data); + } catch { + return null; + } +} diff --git a/src/lib/screenStreaming/control.ts b/src/lib/screenStreaming/control.ts new file mode 100644 index 0000000..29dcc56 --- /dev/null +++ b/src/lib/screenStreaming/control.ts @@ -0,0 +1,68 @@ +export type StreamRole = "streamer" | "viewer"; +export type RoomActiveDisplay = "oled" | "seg7"; + +export type RoomState = { + hasStreamer: boolean; + viewers: number; + viewerCap: number; + lastSeq?: number; + active?: RoomActiveDisplay; + requiresPassword: boolean; + createdAt?: number; + updatedAt?: number; +}; + +export type BaseControlMsg = { + t: string; + roomId: string; + clientId: string; + ts?: number; +}; + +export type StreamerHelloMsg = BaseControlMsg & { + t: "streamer:hello"; + passwordToken?: string; + ownerKey: string; + meta?: { device?: string; appVersion?: string; pollingMs?: number }; +}; + +export type ViewerHelloMsg = BaseControlMsg & { + t: "viewer:hello"; + passwordToken?: string; +}; + +export type DisplayActiveMsg = BaseControlMsg & { + t: "display:active"; + active: RoomActiveDisplay; +}; + +export type PingMsg = BaseControlMsg & { t: "ping" }; +export type ByeMsg = BaseControlMsg & { t: "bye" }; + +export type ViewerRequestFullMsg = BaseControlMsg & { t: "viewer:request_full" }; +export type StreamerRequestFullMsg = BaseControlMsg & { + t: "streamer:request_full"; +}; + +export type OkMsg = BaseControlMsg & { + t: "ok"; + role: StreamRole; + roomState: RoomState; +}; + +export type ErrMsg = BaseControlMsg & { t: "err"; code: string; msg: string }; + +export type AnyControlMsg = + | StreamerHelloMsg + | ViewerHelloMsg + | DisplayActiveMsg + | PingMsg + | ByeMsg + | ViewerRequestFullMsg + | StreamerRequestFullMsg + | OkMsg + | ErrMsg; + +export function nowMs(): number { + return Date.now(); +} diff --git a/src/lib/screenStreaming/ids.ts b/src/lib/screenStreaming/ids.ts new file mode 100644 index 0000000..d13671d --- /dev/null +++ b/src/lib/screenStreaming/ids.ts @@ -0,0 +1,15 @@ +const OWNER_KEY_STORAGE = "dex.screenStreaming.ownerKey"; + +export function createClientId(): string { + if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); + const bytes = crypto.getRandomValues(new Uint8Array(16)); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +export function getOrCreateOwnerKey(): string { + const existing = localStorage.getItem(OWNER_KEY_STORAGE); + if (existing) return existing; + const created = createClientId(); + localStorage.setItem(OWNER_KEY_STORAGE, created); + return created; +} diff --git a/src/lib/screenStreaming/index.ts b/src/lib/screenStreaming/index.ts new file mode 100644 index 0000000..71bd34b --- /dev/null +++ b/src/lib/screenStreaming/index.ts @@ -0,0 +1,7 @@ +export * from "./bip39English2048"; +export * from "./codec"; +export * from "./control"; +export * from "./ids"; +export * from "./passwordToken"; +export * from "./roomCode"; +export * from "./wsUrl"; diff --git a/src/lib/screenStreaming/passwordToken.ts b/src/lib/screenStreaming/passwordToken.ts new file mode 100644 index 0000000..8e8a56a --- /dev/null +++ b/src/lib/screenStreaming/passwordToken.ts @@ -0,0 +1,19 @@ +import { normalizeRoomId } from "./roomCode"; + +function toHex(bytes: ArrayBuffer): string { + const view = new Uint8Array(bytes); + return Array.from(view, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +export async function derivePasswordToken( + roomId: string, + password: string, +): Promise { + const normalizedRoomId = normalizeRoomId(roomId); + const input = `DEx-screen-streaming:${normalizedRoomId}:${password}`; + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(input), + ); + return toHex(digest); +} diff --git a/src/lib/screenStreaming/roomCode.ts b/src/lib/screenStreaming/roomCode.ts new file mode 100644 index 0000000..6a43640 --- /dev/null +++ b/src/lib/screenStreaming/roomCode.ts @@ -0,0 +1,27 @@ +import { BIP39_ENGLISH_WORDS_2048 } from "./bip39English2048"; + +export const DEFAULT_ROOM_WORD_COUNT = 4; + +export function normalizeRoomId(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[\s_]+/g, "-") + .replace(/-+/g, "-") + .replace(/[^a-z0-9-]/g, "") + .replace(/^-+/, "") + .replace(/-+$/, ""); +} + +export function createRoomId( + wordCount: number = DEFAULT_ROOM_WORD_COUNT, +): string { + if (!Number.isInteger(wordCount) || wordCount < 1 || wordCount > 8) { + throw new Error("wordCount must be an integer between 1 and 8"); + } + + const rands = new Uint32Array(wordCount); + crypto.getRandomValues(rands); + const words = Array.from(rands, (n) => BIP39_ENGLISH_WORDS_2048[n & 2047]); + return words.join("-"); +} diff --git a/src/lib/screenStreaming/wsUrl.ts b/src/lib/screenStreaming/wsUrl.ts new file mode 100644 index 0000000..11cfaf6 --- /dev/null +++ b/src/lib/screenStreaming/wsUrl.ts @@ -0,0 +1,56 @@ +import type { StreamRole } from "./control"; + +function stripTrailingSlash(s: string): string { + return s.endsWith("/") ? s.slice(0, -1) : s; +} + +function defaultStreamHostFromLocation(location: Location): string { + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + return `${proto}//${location.host}`; +} + +function normalizeStreamHost( + streamHost: string, + location: Location, +): string { + const host = stripTrailingSlash(streamHost.trim()); + if (!host) return defaultStreamHostFromLocation(location); + if (host.startsWith("wss://") || host.startsWith("ws://")) return host; + if (host.startsWith("https://")) + return `wss://${host.slice("https://".length)}`; + if (host.startsWith("http://")) + return `ws://${host.slice("http://".length)}`; + const proto = location.protocol === "https:" ? "wss://" : "ws://"; + return `${proto}${host}`; +} + +export function getStreamHostOverrideFromQuery( + location: Location, +): string | null { + const params = new URLSearchParams(location.search); + return params.get("streamHost"); +} + +export function getDefaultStreamHost(location: Location): string { + const fromQuery = getStreamHostOverrideFromQuery(location); + if (fromQuery) return normalizeStreamHost(fromQuery, location); + + const fromEnv = (import.meta.env.VITE_STREAM_HOST ?? "").trim(); + if (fromEnv) return normalizeStreamHost(fromEnv, location); + + return defaultStreamHostFromLocation(location); +} + +export function buildRoomWsUrl(params: { + roomId: string; + role: StreamRole; + streamHost?: string; + location?: Location; +}): string { + const location = params.location ?? window.location; + const base = params.streamHost + ? normalizeStreamHost(params.streamHost, location) + : getDefaultStreamHost(location); + const roomIdEsc = encodeURIComponent(params.roomId); + return `${base}/api/rooms/${roomIdEsc}/ws?role=${params.role}`; +} diff --git a/src/main.tsx b/src/main.tsx index 9db1fd8..8c43f0c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,13 +1,39 @@ import "./index.css"; import "./styles/theme.css"; import { render } from "preact"; -import { App } from "./components/App"; import { ThemeProvider } from "./components/ThemeProvider"; -import "./lib/auto"; // Load auto-behavior effects - -render( - - - , - document.getElementById("app")!, -); +import { normalizeRoomId } from "./lib/screenStreaming/roomCode"; + +const root = document.getElementById("app")!; + +function getRoomIdFromLocation(): string | null { + const params = new URLSearchParams(window.location.search); + const raw = params.get("roomId"); + if (!raw) return null; + const normalized = normalizeRoomId(raw); + return normalized || null; +} + +(async () => { + const roomId = getRoomIdFromLocation(); + + if (roomId) { + const { ViewerApp } = await import("./components/screenStreaming/ViewerApp"); + render( + + + , + root, + ); + return; + } + + await import("./lib/auto"); // Load auto-behavior effects (WebMIDI/polling) + const { App } = await import("./components/App"); + render( + + + , + root, + ); +})(); diff --git a/src/services/screenStreamingStreamer.ts b/src/services/screenStreamingStreamer.ts new file mode 100644 index 0000000..c912a3a --- /dev/null +++ b/src/services/screenStreamingStreamer.ts @@ -0,0 +1,298 @@ +import { signal } from "@preact/signals"; +import { subscribeMidiListener } from "@/commands"; +import { getOLED } from "@/commands/display"; +import { midiIn, midiOut } from "@/state"; +import { isPollingActive, pollingMs, startPolling } from "@/lib/display"; +import { + encodeDisplaySysexFrame, + DisplaySysexKind, +} from "@/lib/screenStreaming/codec"; +import type { + AnyControlMsg, + ErrMsg, + OkMsg, + RoomActiveDisplay, + RoomState, +} from "@/lib/screenStreaming/control"; +import { nowMs } from "@/lib/screenStreaming/control"; +import { createClientId, getOrCreateOwnerKey } from "@/lib/screenStreaming/ids"; +import { buildRoomWsUrl } from "@/lib/screenStreaming/wsUrl"; + +export type ScreenStreamerStatus = + | "idle" + | "connecting" + | "awaiting_ok" + | "streaming" + | "error"; + +export const screenStreamerStatus = signal("idle"); +export const screenStreamerRoomId = signal(null); +export const screenStreamerRoomState = signal(null); +export const screenStreamerError = signal(null); + +let ws: WebSocket | null = null; +let pingId: number | null = null; +let reconnectId: number | null = null; +let reconnectAttempt = 0; +let manualStop = false; + +let currentClientId: string | null = null; +let currentOwnerKey: string | null = null; +let currentRoomId: string | null = null; +let currentPasswordToken: string | undefined; +let currentStreamHost: string | undefined; + +let unsubMidi: (() => void) | null = null; +let seq = 0; +let hasSentOledFull = false; +let lastActive: RoomActiveDisplay | null = null; + +function clearTimers() { + if (pingId != null) { + clearInterval(pingId); + pingId = null; + } + if (reconnectId != null) { + clearTimeout(reconnectId); + reconnectId = null; + } +} + +function sendJson(msg: AnyControlMsg) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify({ ...msg, ts: nowMs() })); +} + +function closeWs() { + try { + ws?.close(); + } catch { + // ignore + } finally { + ws = null; + } +} + +function cleanupConnections() { + clearTimers(); + closeWs(); + if (unsubMidi) { + unsubMidi(); + unsubMidi = null; + } +} + +function classifyDisplaySysex(data: Uint8Array): DisplaySysexKind | null { + if (data.length < 5 || data[0] !== 0xf0 || data[1] !== 0x7d) return null; + + if (data[2] === 0x02 && data[3] === 0x40 && data[4] === 1) { + return DisplaySysexKind.OledFull; + } + if (data[2] === 0x02 && data[3] === 0x40 && data[4] === 2) { + return DisplaySysexKind.OledDelta; + } + if (data[2] === 0x02 && data[3] === 0x41 && data[4] === 0) { + return DisplaySysexKind.Seg7; + } + return null; +} + +function ensureMidiSubscription() { + if (unsubMidi) return; + + unsubMidi = subscribeMidiListener((e) => { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + if (screenStreamerStatus.value !== "streaming") return; + + const data = e.data as Uint8Array; + const kind = classifyDisplaySysex(data); + if (kind == null) return; + + if (kind === DisplaySysexKind.OledDelta && !hasSentOledFull) return; + + seq += 1; + const frame = encodeDisplaySysexFrame({ seq, kind, payload: data }); + ws.send(frame); + + if (kind === DisplaySysexKind.OledFull) { + hasSentOledFull = true; + } + + const active: RoomActiveDisplay = + kind === DisplaySysexKind.Seg7 ? "seg7" : "oled"; + if (active !== lastActive) { + lastActive = active; + sendJson({ + t: "display:active", + roomId: currentRoomId!, + clientId: currentClientId!, + active, + }); + } + }); +} + +async function requestOledFull() { + try { + await getOLED(); + } catch { + // ignore + } +} + +function scheduleReconnect() { + if (manualStop) return; + if (!currentRoomId) return; + + reconnectAttempt += 1; + const delayMs = Math.min(10_000, 500 * 2 ** Math.min(6, reconnectAttempt)); + reconnectId = window.setTimeout(() => { + reconnectId = null; + connect(); + }, delayMs); +} + +function connect() { + if (!currentRoomId || !currentClientId || !currentOwnerKey) return; + + clearTimers(); + closeWs(); + + screenStreamerStatus.value = "connecting"; + const wsUrl = buildRoomWsUrl({ + roomId: currentRoomId, + role: "streamer", + streamHost: currentStreamHost, + }); + ws = new WebSocket(wsUrl); + + ws.onopen = () => { + screenStreamerStatus.value = "awaiting_ok"; + sendJson({ + t: "streamer:hello", + roomId: currentRoomId!, + clientId: currentClientId!, + ownerKey: currentOwnerKey!, + passwordToken: currentPasswordToken, + meta: { + pollingMs, + }, + }); + + pingId = window.setInterval(() => { + sendJson({ t: "ping", roomId: currentRoomId!, clientId: currentClientId! }); + }, 15_000); + }; + + ws.onmessage = (ev) => { + if (typeof ev.data !== "string") return; + + let parsed: unknown; + try { + parsed = JSON.parse(ev.data); + } catch { + return; + } + if (!parsed || typeof parsed !== "object") return; + const msg = parsed as AnyControlMsg; + if (typeof msg.t !== "string") return; + + if (msg.t === "ok") { + const ok = msg as OkMsg; + screenStreamerRoomState.value = ok.roomState; + screenStreamerStatus.value = "streaming"; + screenStreamerError.value = null; + reconnectAttempt = 0; + + seq = ok.roomState.lastSeq ?? 0; + hasSentOledFull = false; + lastActive = ok.roomState.active ?? null; + + ensureMidiSubscription(); + void requestOledFull(); + return; + } + + if (msg.t === "err") { + const err = msg as ErrMsg; + manualStop = true; + screenStreamerError.value = err; + screenStreamerStatus.value = "error"; + clearTimers(); + closeWs(); + return; + } + + if (msg.t === "streamer:request_full") { + void requestOledFull(); + } + }; + + ws.onclose = () => { + clearTimers(); + ws = null; + if (manualStop) return; + if (screenStreamerStatus.value !== "error") { + screenStreamerStatus.value = "connecting"; + } + scheduleReconnect(); + }; + + ws.onerror = () => { + if (manualStop) return; + closeWs(); + }; +} + +export function startScreenStreaming(options: { + roomId: string; + passwordToken?: string; + streamHost?: string; +}) { + if (!midiOut.value || !midiIn.value) { + throw new Error("Select a MIDI input and output to start streaming."); + } + if (!isPollingActive()) { + startPolling(); + } + + manualStop = false; + cleanupConnections(); + + currentRoomId = options.roomId; + currentPasswordToken = options.passwordToken; + currentStreamHost = options.streamHost; + currentClientId = createClientId(); + currentOwnerKey = getOrCreateOwnerKey(); + + screenStreamerRoomId.value = options.roomId; + screenStreamerRoomState.value = null; + screenStreamerError.value = null; + screenStreamerStatus.value = "connecting"; + + connect(); +} + +export function stopScreenStreaming() { + manualStop = true; + cleanupConnections(); + reconnectAttempt = 0; + + currentClientId = null; + currentOwnerKey = null; + currentRoomId = null; + currentPasswordToken = undefined; + currentStreamHost = undefined; + + seq = 0; + hasSentOledFull = false; + lastActive = null; + + screenStreamerStatus.value = "idle"; + screenStreamerRoomId.value = null; + screenStreamerRoomState.value = null; +} + +export function refreshStreamedDisplay() { + void requestOledFull(); +} diff --git a/src/test/lib/screenStreaming.test.ts b/src/test/lib/screenStreaming.test.ts new file mode 100644 index 0000000..51e0502 --- /dev/null +++ b/src/test/lib/screenStreaming.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import { createHash } from "node:crypto"; +import { + BIP39_ENGLISH_WORDS_2048, + createRoomId, + decodeDisplaySysexFrame, + DisplaySysexKind, + encodeDisplaySysexFrame, + normalizeRoomId, + derivePasswordToken, + buildRoomWsUrl, +} from "@/lib/screenStreaming"; + +describe("screen streaming utilities", () => { + describe("room codes", () => { + it("includes a 2048-word list", () => { + expect(BIP39_ENGLISH_WORDS_2048).toHaveLength(2048); + expect(new Set(BIP39_ENGLISH_WORDS_2048).size).toBe(2048); + }); + + it("normalizes room ids", () => { + expect(normalizeRoomId(" CACTUS Echo lantern_saffron ")).toBe( + "cactus-echo-lantern-saffron", + ); + expect(normalizeRoomId("__Hello---World__")).toBe("hello-world"); + expect(normalizeRoomId("")).toBe(""); + expect(normalizeRoomId(" ")).toBe(""); + }); + + it("creates deterministic room id with mocked RNG", () => { + const spy = vi + .spyOn(crypto, "getRandomValues") + .mockImplementation((arr) => { + const view = arr as Uint32Array; + view[0] = 0; + view[1] = 1; + view[2] = 2; + view[3] = 3; + return arr; + }); + expect(createRoomId(4)).toBe("abandon-ability-able-about"); + spy.mockRestore(); + }); + }); + + describe("password token", () => { + it("derives sha256 token from roomId and password", async () => { + const roomId = "Cactus Echo"; + const password = "correct horse battery staple"; + const normalizedRoomId = normalizeRoomId(roomId); + const expected = createHash("sha256") + .update(`DEx-screen-streaming:${normalizedRoomId}:${password}`) + .digest("hex"); + await expect(derivePasswordToken(roomId, password)).resolves.toBe(expected); + }); + }); + + describe("binary frame codec", () => { + it("round-trips a DISPLAY_SYSEX frame", () => { + const payload = Uint8Array.from([0xf0, 0x7d, 0x02, 0x40, 0x01, 0xf7]); + const encoded = encodeDisplaySysexFrame({ + seq: 123, + kind: DisplaySysexKind.OledFull, + payload, + }); + const decoded = decodeDisplaySysexFrame(encoded); + expect(decoded.seq).toBe(123); + expect(decoded.kind).toBe(DisplaySysexKind.OledFull); + expect(Array.from(decoded.payload)).toEqual(Array.from(payload)); + }); + + it("rejects payload length mismatch", () => { + const payload = Uint8Array.from([0xf0, 0x7d, 0x02, 0x41, 0x00, 0xf7]); + const encoded = encodeDisplaySysexFrame({ + seq: 1, + kind: DisplaySysexKind.Seg7, + payload, + }); + const view = new DataView(encoded.buffer, encoded.byteOffset, encoded.byteLength); + view.setUint16(9, payload.length + 1, false); + expect(() => decodeDisplaySysexFrame(encoded)).toThrow(/payload length mismatch/); + }); + }); + + describe("ws url builder", () => { + it("builds wss url from https location", () => { + const url = buildRoomWsUrl({ + roomId: "room", + role: "viewer", + location: { protocol: "https:", host: "dex.test", search: "" } as Location, + }); + expect(url).toBe("wss://dex.test/api/rooms/room/ws?role=viewer"); + }); + + it("uses streamHost query override", () => { + const url = buildRoomWsUrl({ + roomId: "room", + role: "viewer", + location: { + protocol: "https:", + host: "dex.test", + search: "?streamHost=wss://relay.example", + } as Location, + }); + expect(url).toBe("wss://relay.example/api/rooms/room/ws?role=viewer"); + }); + }); +}); + diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..36122c0 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,9 @@ /// + +interface ImportMetaEnv { + readonly VITE_STREAM_HOST?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/worker/src/index.ts b/worker/src/index.ts new file mode 100644 index 0000000..ea14221 --- /dev/null +++ b/worker/src/index.ts @@ -0,0 +1,500 @@ +type Env = { + ROOMS: DurableObjectNamespace; +}; + +function normalizeRoomId(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[\s_]+/g, "-") + .replace(/-+/g, "-") + .replace(/[^a-z0-9-]/g, "") + .replace(/^-+/, "") + .replace(/-+$/, ""); +} + +function isWebSocketUpgrade(request: Request): boolean { + return request.headers.get("Upgrade")?.toLowerCase() === "websocket"; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + const m = url.pathname.match(/^\/api\/rooms\/([^/]+)\/ws$/); + if (!m) return new Response("Not found", { status: 404 }); + + if (!isWebSocketUpgrade(request)) { + return new Response("Expected WebSocket upgrade", { status: 426 }); + } + + const rawRoomId = decodeURIComponent(m[1] ?? ""); + const roomId = normalizeRoomId(rawRoomId); + if (!roomId) return new Response("Invalid roomId", { status: 400 }); + + const id = env.ROOMS.idFromName(roomId); + const stub = env.ROOMS.get(id); + return stub.fetch(request); + }, +}; + +type StreamRole = "streamer" | "viewer"; +type RoomActiveDisplay = "oled" | "seg7"; + +type BaseMsg = { t: string; roomId: string; clientId: string; ts?: number }; +type StreamerHello = BaseMsg & { + t: "streamer:hello"; + passwordToken?: string; + ownerKey: string; + meta?: { device?: string; appVersion?: string; pollingMs?: number }; +}; +type ViewerHello = BaseMsg & { t: "viewer:hello"; passwordToken?: string }; +type Ping = BaseMsg & { t: "ping" }; +type Bye = BaseMsg & { t: "bye" }; +type DisplayActive = BaseMsg & { t: "display:active"; active: RoomActiveDisplay }; +type ViewerRequestFull = BaseMsg & { t: "viewer:request_full" }; +type StreamerRequestFull = BaseMsg & { t: "streamer:request_full" }; +type Ok = BaseMsg & { t: "ok"; role: StreamRole; roomState: RoomState }; +type Err = BaseMsg & { t: "err"; code: string; msg: string }; + +type RoomState = { + hasStreamer: boolean; + viewers: number; + viewerCap: number; + lastSeq?: number; + active?: RoomActiveDisplay; + requiresPassword: boolean; + createdAt?: number; + updatedAt?: number; +}; + +type AnyControl = + | StreamerHello + | ViewerHello + | Ping + | Bye + | DisplayActive + | ViewerRequestFull + | StreamerRequestFull + | Ok + | Err; + +const FRAME_MAGIC_0 = 0x44; // 'D' +const FRAME_MAGIC_1 = 0x58; // 'X' +const FRAME_VERSION = 0x01; +const FRAME_MSG_DISPLAY_SYSEX = 0x01; +const FRAME_HEADER_BYTES = 11; + +const FRAME_KIND_OLED_FULL = 0; +const FRAME_KIND_OLED_DELTA = 1; +const FRAME_KIND_SEG7 = 2; + +const VIEWER_CAP = 5; +const MAX_FRAME_BYTES = 256 * 1024; +const MAX_FPS = 60; +const FPS_WINDOW_MS = 1000; +const IDLE_TIMEOUT_MS = 45_000; +const SWEEP_MS = 15_000; + +type ConnMeta = { + role: StreamRole; + roomId: string; + clientId: string | null; + authed: boolean; + lastSeen: number; +}; + +export class RoomDurableObject { + private readonly createdAt = Date.now(); + private updatedAt = Date.now(); + + private ownerKey: string | null = null; + private passwordToken: string | null = null; + + private streamer: { ws: WebSocket; meta: ConnMeta; ownerKey: string } | null = + null; + private viewers = new Map(); + private pendingViewers = new Map(); + + private lastOledFull: ArrayBuffer | null = null; + private lastSeg7: ArrayBuffer | null = null; + private lastSeq = 0; + private activeDisplay: RoomActiveDisplay | null = null; + + private recentFrameTimes: number[] = []; + private sweepId: number | null = null; + + constructor( + private readonly state: DurableObjectState, + private readonly env: Env, + ) { + void this.state.blockConcurrencyWhile(async () => { + this.startSweep(); + }); + } + + private startSweep() { + if (this.sweepId != null) return; + this.sweepId = setInterval(() => this.sweep(), SWEEP_MS) as unknown as number; + } + + private stopSweepIfIdle() { + const hasAny = + this.streamer != null || + this.viewers.size > 0 || + this.pendingViewers.size > 0; + if (hasAny) return; + if (this.sweepId != null) { + clearInterval(this.sweepId); + this.sweepId = null; + } + } + + private sweep() { + const now = Date.now(); + + if (this.streamer && now - this.streamer.meta.lastSeen > IDLE_TIMEOUT_MS) { + this.streamer.ws.close(4000, "idle"); + this.streamer = null; + } + + for (const [id, v] of this.viewers) { + if (now - v.meta.lastSeen > IDLE_TIMEOUT_MS) { + v.ws.close(4000, "idle"); + this.viewers.delete(id); + } + } + + for (const [id, v] of this.pendingViewers) { + if (now - v.meta.lastSeen > IDLE_TIMEOUT_MS) { + v.ws.close(4000, "idle"); + this.pendingViewers.delete(id); + } + } + + this.stopSweepIfIdle(); + } + + private roomState(): RoomState { + return { + hasStreamer: this.streamer != null, + viewers: this.viewers.size + this.pendingViewers.size, + viewerCap: VIEWER_CAP, + lastSeq: this.lastSeq || undefined, + active: this.activeDisplay ?? undefined, + requiresPassword: this.passwordToken != null, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + }; + } + + private sendJson(ws: WebSocket, msg: AnyControl) { + try { + ws.send(JSON.stringify({ ...msg, ts: Date.now() })); + } catch { + // ignore + } + } + + private err(ws: WebSocket, roomId: string, clientId: string, code: string, msg: string) { + this.sendJson(ws, { t: "err", roomId, clientId, code, msg }); + try { + ws.close(4001, code); + } catch { + // ignore + } + } + + private ok(ws: WebSocket, roomId: string, clientId: string, role: StreamRole) { + this.sendJson(ws, { t: "ok", roomId, clientId, role, roomState: this.roomState() }); + } + + async fetch(request: Request): Promise { + if (!isWebSocketUpgrade(request)) { + return new Response("Expected WebSocket upgrade", { status: 426 }); + } + + const url = new URL(request.url); + const role = url.searchParams.get("role"); + if (role !== "streamer" && role !== "viewer") { + return new Response("Invalid role", { status: 400 }); + } + + const pathMatch = url.pathname.match(/^\/api\/rooms\/([^/]+)\/ws$/); + const roomId = normalizeRoomId( + decodeURIComponent(pathMatch?.[1] ?? ""), + ); + if (!roomId) return new Response("Invalid roomId", { status: 400 }); + + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + server.accept(); + + const meta: ConnMeta = { + role, + roomId, + clientId: null, + authed: false, + lastSeen: Date.now(), + }; + + server.addEventListener("message", (ev) => { + meta.lastSeen = Date.now(); + this.updatedAt = meta.lastSeen; + this.startSweep(); + void this.onMessage(server, meta, ev.data); + }); + server.addEventListener("close", () => { + this.onClose(server, meta); + }); + server.addEventListener("error", () => { + this.onClose(server, meta); + }); + + return new Response(null, { status: 101, webSocket: client }); + } + + private onClose(ws: WebSocket, meta: ConnMeta) { + if (this.streamer?.ws === ws) { + this.streamer = null; + } + + if (meta.clientId) { + this.viewers.delete(meta.clientId); + this.pendingViewers.delete(meta.clientId); + } else { + for (const [id, v] of this.viewers) { + if (v.ws === ws) this.viewers.delete(id); + } + for (const [id, v] of this.pendingViewers) { + if (v.ws === ws) this.pendingViewers.delete(id); + } + } + + this.stopSweepIfIdle(); + } + + private parseJson(data: unknown): AnyControl | null { + if (typeof data !== "string") return null; + try { + return JSON.parse(data) as AnyControl; + } catch { + return null; + } + } + + private parseFrameHeader(buf: ArrayBuffer): { seq: number; kind: number } | null { + if (buf.byteLength < FRAME_HEADER_BYTES) return null; + const bytes = new Uint8Array(buf); + if ( + bytes[0] !== FRAME_MAGIC_0 || + bytes[1] !== FRAME_MAGIC_1 || + bytes[2] !== FRAME_VERSION || + bytes[3] !== FRAME_MSG_DISPLAY_SYSEX + ) { + return null; + } + const view = new DataView(buf); + const seq = view.getUint32(4, false); + const kind = bytes[8]; + const payloadLen = view.getUint16(9, false); + const actualPayloadLen = buf.byteLength - FRAME_HEADER_BYTES; + if (payloadLen !== 0 && payloadLen !== actualPayloadLen) return null; + return { seq, kind }; + } + + private shouldDropForFps(now: number): boolean { + this.recentFrameTimes = this.recentFrameTimes.filter( + (t) => now - t <= FPS_WINDOW_MS, + ); + if (this.recentFrameTimes.length >= MAX_FPS) return true; + this.recentFrameTimes.push(now); + return false; + } + + private broadcastBinary(buf: ArrayBuffer) { + for (const { ws } of this.viewers.values()) { + try { + ws.send(buf); + } catch { + // ignore + } + } + } + + private broadcastJson(msg: AnyControl) { + for (const { ws } of this.viewers.values()) { + this.sendJson(ws, msg); + } + } + + private flushPendingOnOledFull(buf: ArrayBuffer) { + if (this.pendingViewers.size === 0) return; + for (const [id, v] of this.pendingViewers) { + try { + v.ws.send(buf); + this.viewers.set(id, v); + } catch { + // ignore + } + } + this.pendingViewers.clear(); + } + + private maybeUpdateActiveDisplay(next: RoomActiveDisplay, roomId: string, clientId: string) { + if (this.activeDisplay === next) return; + this.activeDisplay = next; + this.broadcastJson({ t: "display:active", roomId, clientId, active: next }); + } + + private async onMessage(ws: WebSocket, meta: ConnMeta, data: unknown) { + if (typeof data === "string") { + const msg = this.parseJson(data); + if (!msg) return; + + if (!msg.roomId || !msg.clientId) return; + meta.clientId = msg.clientId; + + if (msg.t === "ping") return; + if (msg.t === "bye") { + ws.close(1000, "bye"); + return; + } + + if (meta.role === "streamer") { + if (msg.t === "streamer:hello") { + const hello = msg as StreamerHello; + + if (this.streamer && this.streamer.ws !== ws) { + this.err(ws, meta.roomId, hello.clientId, "room_has_streamer", "Room already has a streamer"); + return; + } + + if (this.ownerKey && this.ownerKey !== hello.ownerKey) { + this.err( + ws, + meta.roomId, + hello.clientId, + "room_owned_by_other_streamer", + "Room is owned by another streamer", + ); + return; + } + if (!this.ownerKey) this.ownerKey = hello.ownerKey; + + this.passwordToken = hello.passwordToken ?? null; + this.streamer = { ws, meta, ownerKey: hello.ownerKey }; + + this.ok(ws, meta.roomId, hello.clientId, "streamer"); + return; + } + + if (!this.streamer || this.streamer.ws !== ws) return; + + if (msg.t === "display:active") { + const da = msg as DisplayActive; + this.maybeUpdateActiveDisplay(da.active, da.roomId, da.clientId); + return; + } + + return; + } + + // Viewer role + if (msg.t === "viewer:hello") { + const hello = msg as ViewerHello; + + const viewerCount = this.viewers.size + this.pendingViewers.size; + if (viewerCount >= VIEWER_CAP) { + this.err(ws, meta.roomId, hello.clientId, "room_full", "Room is full"); + return; + } + + if (this.passwordToken) { + if (!hello.passwordToken) { + this.err( + ws, + meta.roomId, + hello.clientId, + "password_required", + "Password required", + ); + return; + } + if (hello.passwordToken !== this.passwordToken) { + this.err(ws, meta.roomId, hello.clientId, "bad_password", "Bad password"); + return; + } + } + + meta.authed = true; + meta.clientId = hello.clientId; + + this.ok(ws, meta.roomId, hello.clientId, "viewer"); + + const needsOledKeyframe = + (this.activeDisplay ?? "oled") === "oled" && this.lastOledFull == null; + if (needsOledKeyframe && this.streamer) { + this.pendingViewers.set(hello.clientId, { ws, meta }); + this.sendJson(this.streamer.ws, { + t: "streamer:request_full", + roomId: meta.roomId, + clientId: hello.clientId, + }); + return; + } + + // Send snapshots first, then add to broadcast set. + if (this.lastOledFull) ws.send(this.lastOledFull); + if (this.lastSeg7) ws.send(this.lastSeg7); + this.viewers.set(hello.clientId, { ws, meta }); + return; + } + + if (!meta.authed || !meta.clientId) return; + + if (msg.t === "viewer:request_full") { + if (!this.streamer) return; + this.sendJson(this.streamer.ws, { + t: "streamer:request_full", + roomId: meta.roomId, + clientId: meta.clientId, + }); + } + + return; + } + + // Binary frames + if (!(data instanceof ArrayBuffer)) return; + if (!this.streamer || this.streamer.ws !== ws) return; + + if (data.byteLength > MAX_FRAME_BYTES) { + ws.close(1009, "frame too large"); + return; + } + + const header = this.parseFrameHeader(data); + if (!header) return; + + const now = Date.now(); + if (this.shouldDropForFps(now)) return; + + if (header.seq <= this.lastSeq) return; + this.lastSeq = header.seq; + + const shouldFlushPending = header.kind === FRAME_KIND_OLED_FULL; + if (header.kind === FRAME_KIND_OLED_FULL) { + this.lastOledFull = data; + this.maybeUpdateActiveDisplay("oled", meta.roomId, meta.clientId ?? "streamer"); + } else if (header.kind === FRAME_KIND_SEG7) { + this.lastSeg7 = data; + this.maybeUpdateActiveDisplay("seg7", meta.roomId, meta.clientId ?? "streamer"); + } else if (header.kind === FRAME_KIND_OLED_DELTA) { + this.maybeUpdateActiveDisplay("oled", meta.roomId, meta.clientId ?? "streamer"); + } + + this.broadcastBinary(data); + if (shouldFlushPending) this.flushPendingOnOledFull(data); + } +} diff --git a/worker/wrangler.toml b/worker/wrangler.toml new file mode 100644 index 0000000..56a7d0d --- /dev/null +++ b/worker/wrangler.toml @@ -0,0 +1,11 @@ +name = "dex-screen-streaming" +main = "src/index.ts" +compatibility_date = "2025-12-12" + +[durable_objects] +bindings = [{ name = "ROOMS", class_name = "RoomDurableObject" }] + +[[migrations]] +tag = "v1" +new_classes = ["RoomDurableObject"] + From 505075acc047b05df93086aa8b2d16ce5b9b42d7 Mon Sep 17 00:00:00 2001 From: Michael Katz Date: Fri, 12 Dec 2025 21:51:22 +0200 Subject: [PATCH 02/13] add missing dependencies --- DEVELOPMENT.md | 32 + README.md | 16 +- docs/screen-streaming.md | 116 ++- functions/_worker.ts | 28 + server.mjs | 2 + server/relay.mjs | 661 ++++++++++++++++++ .../screenStreaming/ScreenStreamingModal.tsx | 85 ++- src/components/screenStreaming/ViewerApp.tsx | 11 +- src/lib/fullscreen.ts | 30 +- src/lib/screenStreaming/wsUrl.ts | 47 ++ src/main.tsx | 28 +- 11 files changed, 1030 insertions(+), 26 deletions(-) create mode 100644 functions/_worker.ts create mode 100644 server.mjs create mode 100644 server/relay.mjs diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7c17467..e82b805 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -53,6 +53,38 @@ If port 5173 is taken Vite will pick the next free port and print it to the cons --- +## Screen streaming (local relay / LAN testing) + +Screen streaming uses a WebSocket relay. For local dev we provide a dependency-free Node relay that speaks the same protocol as the Cloudflare Durable Object. + +### Quickstart + +Terminal A (relay): + +```bash +node server.mjs +``` + +Terminal B (frontend): + +```bash +yarn dev --host +``` + +Then: + +1. On the desktop (streamer), open DEx and click **Screen streaming**. +2. Use the stable dev room `local-local`. +3. Set **Share base URL** to the LAN URL printed by Vite (e.g. `http://192.168.1.10:5173`) so the QR/link works on mobile. +4. On mobile (same network), open the Join URL from the modal (viewer mode). + +Notes: + +- On local/LAN hostnames, DEx connects to the relay at port `8787` by default (no `?streamHost=...` needed). +- If you serve the frontend over HTTPS (secure context), the relay must be reachable via `wss://...` (run `server/relay.mjs` with `--tls-cert/--tls-key`). + +--- + ## Static type-checking ```bash diff --git a/README.md b/README.md index b9b78dd..ecca7e3 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ https://github.com/user-attachments/assets/be507463-47b3-4adc-a98c-2b184429e9fa - **👀 Dual Display Mirroring**: View _both_ the OLED and the classic 7-Segment displays in real-time. Perfect for seeing intricate details or getting a quick overview. - **📱 Fullscreen Mode**: Enter a distraction-free fullscreen view that works beautifully on both desktop and mobile devices! Perfect for performances or when projecting your Deluge's display to an audience. +- **📡 Screen Streaming**: Share the Deluge display to other devices via a room link / QR code (one-way; viewers don’t need WebMIDI). See `docs/screen-streaming.md`. - **🎨 Customizable OLED View**: Tailor the OLED display to your liking! Adjust pixel scaling (size) and choose custom foreground/background colors. Settings are saved automatically! - **↔️ Resizable Display**: Instantly resize the mirrored display canvas with dedicated buttons for the perfect fit on your screen. - **⚙️ Advanced Settings Drawer**: Access technical controls like display customization, manual refresh triggers, ping tests, and decoding tests. @@ -80,6 +81,7 @@ https://github.com/user-attachments/assets/be507463-47b3-4adc-a98c-2b184429e9fa - **Get Debug Messages**: Manually requests the latest debug info from the Deluge. - **Monitor UI Changes**: Toggles the UI monitoring mode on/off. - **Full Screen**: Enters a distraction-free fullscreen mode that optimizes the display for your current device and screen size. Press 'ESC' or tap the button again to exit. +- **Screen Streaming**: Share the Deluge display to other devices via a room link / QR code (one-way; viewers don’t need WebMIDI). See `docs/screen-streaming.md`. - **📸 Screenshot**: Download a snapshot of the current canvas as a PNG by clicking the camera icon or pressing 's'. - **📋 Copy Base64**: Copy the current OLED display as a gzipped, base64-encoded string (in a markdown directive) by clicking the copy icon or pressing 'c'. - **❓ Keyboard Help**: View all available keyboard shortcuts by clicking the question mark icon or pressing '?'. @@ -98,6 +100,15 @@ DEx provides convenient keyboard shortcuts for common actions: - **Escape**: Clear file browser search - **?**: Toggle keyboard shortcuts help overlay +### Screen Streaming (Viewer mode) + +Screen streaming lets you mirror the Deluge display to other devices (e.g. iOS). + +- **Streamer** (Chrome/Edge): connect your Deluge → click **Screen streaming** → **Start streaming** → share the Join URL / QR. +- **Viewer** (any browser / iOS Safari): open the Join URL and enter the password if required. + +See `docs/screen-streaming.md` for local relay (LAN) and Cloudflare deployment details. + ### Advanced Settings Drawer - **OLED Display Settings**: Customize pixel size and colors. Click 'Apply Settings' to see changes and save them. @@ -108,13 +119,12 @@ DEx provides convenient keyboard shortcuts for common actions: ### Mobile Usage Tips -**iOS currently doesn't support WebMIDI in its common browsers (Safari, Chrome, etc.). -Although some third-party browsers claim patched support, I can't recommend any because I don't use iPhone.** +**iOS doesn’t support WebMIDI in common browsers (Safari/Chrome), so you can’t connect a Deluge directly. +However, you can still view the Deluge display on iOS using Screen Streaming (Viewer mode).** - For the best experience on mobile devices, use the **Full Screen** button to maximize the display. - On Android, you may need a USB OTG (On-The-Go) adapter to connect your Deluge. Although for me, it works with a regular USB-C to USB-B cable. -- - Rotate your device to landscape orientation for an optimal viewing experience. - Press 'f' on external keyboards or tap the Full Screen button again to exit fullscreen mode. diff --git a/docs/screen-streaming.md b/docs/screen-streaming.md index ea81b7c..41c58b3 100644 --- a/docs/screen-streaming.md +++ b/docs/screen-streaming.md @@ -1,3 +1,12 @@ +# Screen Streaming + +This document covers: + +- How to use screen streaming (streamer + viewer) +- Local development / LAN testing (local relay) +- Remote deployment (Cloudflare Pages + Durable Objects) +- Protocol + architecture notes + ## Goal Add “Screen Streaming” to DEx in a way that matches how DEx actually works today: @@ -9,6 +18,75 @@ Add “Screen Streaming” to DEx in a way that matches how DEx actually works t --- +## Using screen streaming + +### Streamer (Deluge connected) + +1. Open DEx in a WebMIDI-capable browser (Chrome/Edge) and connect your Deluge. +2. Click **Screen streaming** in the header. +3. Choose a room code (diceware-style words) and optionally enable a password. +4. Click **Start streaming**. +5. Share the **Join URL** or QR code with viewers. +6. Use **Refresh display** if you want to force a keyframe. + +Notes: + +- Rooms are one-way: only the creator streams. +- Viewer cap is 5. + +### Viewer (no Deluge needed) + +1. Open the Join URL (works on iOS Safari and any modern browser). +2. If prompted, enter the room password. +3. Only the *active* Deluge display is rendered (OLED vs 7-seg). + +Debug (hidden): + +- Tap the room code 7× to reveal a **Request keyframe** button (sends `viewer:request_full`). + +Fullscreen / keep-awake: + +- Fullscreen can be toggled via the UI or the `f` shortcut (external keyboard). +- On browsers that support the Screen Wake Lock API, DEx requests a wake lock while fullscreen is active to reduce screen sleep. + +--- + +## Remote deployment (Cloudflare Pages) + +The default deployment expects the relay to be available on the same origin as the SPA: + +``` +wss:///api/rooms//ws?role=... +``` + +This repo implements the relay as a Pages “advanced worker” in `functions/_worker.ts`. + +### Setup + +In your Cloudflare Pages project (Settings → Functions → Durable Objects): + +1. Deploy the site normally (Pages will pick up `functions/_worker.ts` automatically). +2. Add a Durable Object binding: + - binding name: `ROOMS` + - class name: `RoomDurableObject` + - create/select a DO namespace for the room instances + - apply to Preview and Production environments as needed +3. Redeploy. + +### Verify + +- `https:///api/health` returns `ok` +- Starting a stream connects to `wss:///api/rooms/.../ws?role=streamer` + +### Alternative: deploy relay separately + +You can also deploy the relay as a standalone Worker (see `worker/wrangler.toml`) and point the frontend at it: + +- build-time: `VITE_STREAM_HOST=wss://` +- runtime: `?streamHost=wss://` + +--- + ## Reality check: what DEx already does (and what we should reuse) DEx already has a complete “Deluge display pipeline”: @@ -272,16 +350,46 @@ Viewer mode should: ## Local relay (alternative deployment) -Provide a Node-based relay server that speaks the same protocol: +Provide a local Node-based relay server that speaks the same protocol (no persistence, in-memory rooms): - `/api/rooms/:roomId/ws?role=...` - Same hello/control + same binary `DISPLAY_SYSEX` envelope +Run: + +```sh +node server.mjs +``` + +If your frontend is served over HTTPS (hosted DEx / installed PWA), browsers will require `wss://` (TLS). The relay supports TLS via Node built-ins: + +```sh +node server/relay.mjs --host 0.0.0.0 --port 8787 --tls-cert ./cert.pem --tls-key ./key.pem +``` + +Local dev flow (LAN testing): + +1. Start the relay: `node server.mjs` (default port `8787`) +2. Start the client: `yarn dev --host` +3. In the streamer UI, keep the room as `local-local` (stable dev room) and set **Share base URL** to `http://:5173` so the QR/link works on mobile. +4. On mobile (same network), open the Join URL shown in the modal, e.g.: + - `http://:5173/?roomId=local-local` + - or `http://:5173/roomId=local-local` +5. DEx will connect to the relay at `ws(s)://:8787` by default on local hostnames (no extra query params needed). + +If you need a secure context on mobile (e.g. to test APIs that require HTTPS), serve the frontend over HTTPS and run the relay with TLS so it’s reachable as `wss://:8787`. + +Then point the frontend at it: + +- Build-time: `VITE_STREAM_HOST=ws://:8787` +- Runtime: `?streamHost=ws://:8787` + Frontend config: -- `STREAM_HOST=wss://...` (env var at build time) or query param override. - - Frontend (Vite): use `VITE_STREAM_HOST=wss://...` - - Runtime override: `?streamHost=wss://...` +- Build-time: `VITE_STREAM_HOST=wss://...` +- Runtime override: `?streamHost=wss://...` + +Note: if you start DEx with `?streamHost=...`, the streaming UI’s Join URL / QR will include the same `streamHost` (unless you override the Share base URL). --- diff --git a/functions/_worker.ts b/functions/_worker.ts new file mode 100644 index 0000000..c19c8db --- /dev/null +++ b/functions/_worker.ts @@ -0,0 +1,28 @@ +import worker, { RoomDurableObject } from "../worker/src/index.ts"; + +type Env = { + ROOMS: DurableObjectNamespace; + ASSETS: { fetch: (request: Request) => Promise }; +}; + +export { RoomDurableObject }; + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === "/api/health") { + return new Response("ok", { + status: 200, + headers: { "content-type": "text/plain" }, + }); + } + + if (url.pathname.startsWith("/api/")) { + return worker.fetch(request, env); + } + + return env.ASSETS.fetch(request); + }, +}; + diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..c5f58ff --- /dev/null +++ b/server.mjs @@ -0,0 +1,2 @@ +import "./server/relay.mjs"; + diff --git a/server/relay.mjs b/server/relay.mjs new file mode 100644 index 0000000..40e6771 --- /dev/null +++ b/server/relay.mjs @@ -0,0 +1,661 @@ +import http from "node:http"; +import https from "node:https"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import { Buffer } from "node:buffer"; +import process from "node:process"; + +const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +const VIEWER_CAP = 5; +const MAX_FRAME_BYTES = 256 * 1024; +const MAX_FPS = 60; +const FPS_WINDOW_MS = 1000; +const IDLE_TIMEOUT_MS = 45_000; +const SWEEP_MS = 15_000; + +const FRAME_MAGIC_0 = 0x44; // 'D' +const FRAME_MAGIC_1 = 0x58; // 'X' +const FRAME_VERSION = 0x01; +const FRAME_MSG_DISPLAY_SYSEX = 0x01; +const FRAME_HEADER_BYTES = 11; + +const FRAME_KIND_OLED_FULL = 0; +const FRAME_KIND_OLED_DELTA = 1; +const FRAME_KIND_SEG7 = 2; + +function normalizeRoomId(input) { + return String(input ?? "") + .trim() + .toLowerCase() + .replace(/[\s_]+/g, "-") + .replace(/-+/g, "-") + .replace(/[^a-z0-9-]/g, "") + .replace(/^-+/, "") + .replace(/-+$/, ""); +} + +function parseArgs(argv) { + const args = { + host: "0.0.0.0", + port: 8787, + tlsCert: null, + tlsKey: null, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--port") args.port = Number(argv[++i]); + else if (a === "--host") args.host = String(argv[++i]); + else if (a === "--tls-cert") args.tlsCert = String(argv[++i]); + else if (a === "--tls-key") args.tlsKey = String(argv[++i]); + } + if (!Number.isFinite(args.port) || args.port <= 0) { + throw new Error("Invalid --port"); + } + if ((args.tlsCert && !args.tlsKey) || (!args.tlsCert && args.tlsKey)) { + throw new Error("Provide both --tls-cert and --tls-key"); + } + return args; +} + +function nowMs() { + return Date.now(); +} + +function sha1Base64(input) { + return createHash("sha1").update(input).digest("base64"); +} + +function writeHttpError(socket, status, message) { + socket.write( + `HTTP/1.1 ${status} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`, + ); + socket.destroy(); +} + +function encodeServerFrame(opcode, payload) { + const len = payload.length; + let header; + if (len < 126) { + header = Buffer.allocUnsafe(2); + header[1] = len; + } else if (len < 65536) { + header = Buffer.allocUnsafe(4); + header[1] = 126; + header.writeUInt16BE(len, 2); + } else { + header = Buffer.allocUnsafe(10); + header[1] = 127; + header.writeBigUInt64BE(BigInt(len), 2); + } + header[0] = 0x80 | (opcode & 0x0f); // FIN + opcode + return Buffer.concat([header, payload]); +} + +class WsConn { + constructor(socket, head) { + this.socket = socket; + this.closed = false; + this.buffer = head?.length ? Buffer.from(head) : Buffer.alloc(0); + this.fragmentOpcode = null; + this.fragmentParts = []; + this.onText = null; + this.onBinary = null; + this.onClose = null; + + socket.on("data", (chunk) => { + if (this.closed) return; + this.buffer = Buffer.concat([this.buffer, chunk]); + this.process(); + }); + socket.on("close", () => this.handleClose()); + socket.on("end", () => this.handleClose()); + socket.on("error", () => this.handleClose()); + } + + handleClose() { + if (this.closed) return; + this.closed = true; + if (this.onClose) this.onClose(); + } + + close(code = 1000, reason = "") { + if (this.closed) return; + const payload = + code != null + ? Buffer.concat([ + Buffer.from([(code >> 8) & 0xff, code & 0xff]), + Buffer.from(String(reason ?? ""), "utf8"), + ]) + : Buffer.alloc(0); + try { + this.socket.write(encodeServerFrame(0x8, payload)); + } catch { + // ignore + } + try { + this.socket.end(); + } catch { + // ignore + } + this.handleClose(); + } + + sendText(text) { + if (this.closed) return; + const payload = Buffer.from(String(text), "utf8"); + this.socket.write(encodeServerFrame(0x1, payload)); + } + + sendBinary(buf) { + if (this.closed) return; + const payload = Buffer.isBuffer(buf) ? buf : Buffer.from(buf); + this.socket.write(encodeServerFrame(0x2, payload)); + } + + sendPong(payload) { + if (this.closed) return; + const p = Buffer.isBuffer(payload) ? payload : Buffer.from(payload); + this.socket.write(encodeServerFrame(0xa, p)); + } + + process() { + while (this.buffer.length >= 2) { + const b0 = this.buffer[0]; + const b1 = this.buffer[1]; + const fin = (b0 & 0x80) !== 0; + const opcode = b0 & 0x0f; + const masked = (b1 & 0x80) !== 0; + let len = b1 & 0x7f; + let off = 2; + + if (len === 126) { + if (this.buffer.length < off + 2) return; + len = this.buffer.readUInt16BE(off); + off += 2; + } else if (len === 127) { + if (this.buffer.length < off + 8) return; + const bigLen = this.buffer.readBigUInt64BE(off); + if (bigLen > BigInt(Number.MAX_SAFE_INTEGER)) { + this.close(1009, "payload too large"); + return; + } + len = Number(bigLen); + off += 8; + } + + if (!masked) { + this.close(1002, "client frames must be masked"); + return; + } + if (this.buffer.length < off + 4 + len) return; + + const maskKey = this.buffer.subarray(off, off + 4); + off += 4; + const payload = this.buffer.subarray(off, off + len); + off += len; + + // Advance buffer before unmasking payload. + this.buffer = this.buffer.subarray(off); + + for (let i = 0; i < payload.length; i++) { + payload[i] ^= maskKey[i & 3]; + } + + this.handleFrame({ fin, opcode, payload }); + } + } + + handleFrame(frame) { + const { fin, opcode, payload } = frame; + + if (opcode === 0x8) { + this.close(1000, "closed"); + return; + } + if (opcode === 0x9) { + this.sendPong(payload); + return; + } + if (opcode === 0xa) { + return; + } + + if (opcode === 0x0) { + if (this.fragmentOpcode == null) { + this.close(1002, "unexpected continuation"); + return; + } + this.fragmentParts.push(payload); + if (!fin) return; + + const full = Buffer.concat(this.fragmentParts); + const originalOpcode = this.fragmentOpcode; + this.fragmentOpcode = null; + this.fragmentParts = []; + this.dispatchMessage(originalOpcode, full); + return; + } + + if (opcode !== 0x1 && opcode !== 0x2) { + this.close(1003, "unsupported opcode"); + return; + } + + if (!fin) { + this.fragmentOpcode = opcode; + this.fragmentParts = [payload]; + return; + } + + this.dispatchMessage(opcode, payload); + } + + dispatchMessage(opcode, payload) { + if (opcode === 0x1) { + const text = payload.toString("utf8"); + if (this.onText) this.onText(text); + return; + } + if (opcode === 0x2) { + if (this.onBinary) this.onBinary(payload); + } + } +} + +class Room { + constructor(roomId) { + this.roomId = roomId; + this.createdAt = nowMs(); + this.updatedAt = this.createdAt; + + this.ownerKey = null; + this.passwordToken = null; + + this.streamer = null; // { conn, meta, ownerKey } + this.viewers = new Map(); // clientId -> { conn, meta } + this.pendingViewers = new Map(); // clientId -> { conn, meta } + + this.lastOledFull = null; // Buffer + this.lastSeg7 = null; // Buffer + this.lastSeq = 0; + this.activeDisplay = null; // "oled" | "seg7" + this.frameTimes = []; + } + + state() { + return { + hasStreamer: this.streamer != null, + viewers: this.viewers.size + this.pendingViewers.size, + viewerCap: VIEWER_CAP, + lastSeq: this.lastSeq || undefined, + active: this.activeDisplay ?? undefined, + requiresPassword: this.passwordToken != null, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + }; + } + + sendJson(conn, msg) { + conn.sendText(JSON.stringify({ ...msg, ts: nowMs() })); + } + + err(conn, clientId, code, msg) { + this.sendJson(conn, { t: "err", roomId: this.roomId, clientId, code, msg }); + conn.close(4001, code); + } + + ok(conn, clientId, role) { + this.sendJson(conn, { + t: "ok", + roomId: this.roomId, + clientId, + role, + roomState: this.state(), + }); + } + + broadcastJson(msg) { + for (const { conn } of this.viewers.values()) this.sendJson(conn, msg); + } + + broadcastBinary(buf) { + for (const { conn } of this.viewers.values()) conn.sendBinary(buf); + } + + maybeSetActive(nextActive, clientId) { + if (this.activeDisplay === nextActive) return; + this.activeDisplay = nextActive; + this.broadcastJson({ + t: "display:active", + roomId: this.roomId, + clientId, + active: nextActive, + }); + } + + shouldDropForFps(now) { + this.frameTimes = this.frameTimes.filter((t) => now - t <= FPS_WINDOW_MS); + if (this.frameTimes.length >= MAX_FPS) return true; + this.frameTimes.push(now); + return false; + } + + attach(conn, meta) { + conn.onClose = () => this.detach(conn, meta); + conn.onText = (text) => this.handleText(conn, meta, text); + conn.onBinary = (buf) => this.handleBinary(conn, meta, buf); + } + + detach(conn, meta) { + if (this.streamer?.conn === conn) this.streamer = null; + + if (meta.clientId) { + this.viewers.delete(meta.clientId); + this.pendingViewers.delete(meta.clientId); + } else { + for (const [id, v] of this.viewers) if (v.conn === conn) this.viewers.delete(id); + for (const [id, v] of this.pendingViewers) if (v.conn === conn) this.pendingViewers.delete(id); + } + } + + handleText(conn, meta, text) { + meta.lastSeen = nowMs(); + this.updatedAt = meta.lastSeen; + + let msg; + try { + msg = JSON.parse(text); + } catch { + return; + } + if (!msg || typeof msg !== "object") return; + if (typeof msg.t !== "string") return; + if (typeof msg.roomId !== "string" || typeof msg.clientId !== "string") return; + if (msg.roomId !== this.roomId) return; + + meta.clientId = msg.clientId; + + if (msg.t === "ping") return; + if (msg.t === "bye") { + conn.close(1000, "bye"); + return; + } + + if (meta.role === "streamer") { + if (msg.t === "streamer:hello") { + const ownerKey = String(msg.ownerKey ?? ""); + const passwordToken = msg.passwordToken ? String(msg.passwordToken) : null; + + if (this.streamer && this.streamer.conn !== conn) { + this.err(conn, msg.clientId, "room_has_streamer", "Room already has a streamer"); + return; + } + + if (this.ownerKey && this.ownerKey !== ownerKey) { + this.err( + conn, + msg.clientId, + "room_owned_by_other_streamer", + "Room is owned by another streamer", + ); + return; + } + if (!this.ownerKey) this.ownerKey = ownerKey; + + this.passwordToken = passwordToken; + this.streamer = { conn, meta, ownerKey }; + this.ok(conn, msg.clientId, "streamer"); + return; + } + + if (!this.streamer || this.streamer.conn !== conn) return; + + if (msg.t === "display:active") { + const active = msg.active === "seg7" ? "seg7" : "oled"; + this.maybeSetActive(active, msg.clientId); + } + return; + } + + // Viewer + if (msg.t === "viewer:hello") { + const viewerCount = this.viewers.size + this.pendingViewers.size; + if (viewerCount >= VIEWER_CAP) { + this.err(conn, msg.clientId, "room_full", "Room is full"); + return; + } + + const pass = msg.passwordToken ? String(msg.passwordToken) : null; + if (this.passwordToken) { + if (!pass) { + this.err(conn, msg.clientId, "password_required", "Password required"); + return; + } + if (pass !== this.passwordToken) { + this.err(conn, msg.clientId, "bad_password", "Bad password"); + return; + } + } + + meta.authed = true; + meta.clientId = msg.clientId; + + this.ok(conn, msg.clientId, "viewer"); + + const active = this.activeDisplay ?? "oled"; + const needsOledKeyframe = active === "oled" && this.lastOledFull == null; + if (needsOledKeyframe) { + this.pendingViewers.set(msg.clientId, { conn, meta }); + if (this.streamer) { + this.sendJson(this.streamer.conn, { + t: "streamer:request_full", + roomId: this.roomId, + clientId: msg.clientId, + }); + } + return; + } + + if (this.lastOledFull) conn.sendBinary(this.lastOledFull); + if (this.lastSeg7) conn.sendBinary(this.lastSeg7); + this.viewers.set(msg.clientId, { conn, meta }); + return; + } + + if (!meta.authed || !meta.clientId) return; + + if (msg.t === "viewer:request_full") { + if (!this.streamer) return; + this.sendJson(this.streamer.conn, { + t: "streamer:request_full", + roomId: this.roomId, + clientId: meta.clientId, + }); + } + } + + parseFrameHeader(buf) { + if (!Buffer.isBuffer(buf) || buf.length < FRAME_HEADER_BYTES) return null; + if ( + buf[0] !== FRAME_MAGIC_0 || + buf[1] !== FRAME_MAGIC_1 || + buf[2] !== FRAME_VERSION || + buf[3] !== FRAME_MSG_DISPLAY_SYSEX + ) { + return null; + } + const seq = buf.readUInt32BE(4); + const kind = buf[8]; + const payloadLen = buf.readUInt16BE(9); + const actual = buf.length - FRAME_HEADER_BYTES; + if (payloadLen !== 0 && payloadLen !== actual) return null; + return { seq, kind }; + } + + flushPendingOledFull(buf) { + if (this.pendingViewers.size === 0) return; + for (const [id, v] of this.pendingViewers) { + v.conn.sendBinary(buf); + this.viewers.set(id, v); + } + this.pendingViewers.clear(); + } + + handleBinary(conn, meta, buf) { + meta.lastSeen = nowMs(); + this.updatedAt = meta.lastSeen; + + if (!this.streamer || this.streamer.conn !== conn) return; + if (buf.length > MAX_FRAME_BYTES) { + conn.close(1009, "frame too large"); + return; + } + + const header = this.parseFrameHeader(buf); + if (!header) return; + + const now = nowMs(); + if (this.shouldDropForFps(now)) return; + + if (header.seq <= this.lastSeq) return; + this.lastSeq = header.seq; + + const shouldFlushPending = header.kind === FRAME_KIND_OLED_FULL; + if (header.kind === FRAME_KIND_OLED_FULL) { + this.lastOledFull = buf; + this.maybeSetActive("oled", meta.clientId ?? "streamer"); + } else if (header.kind === FRAME_KIND_SEG7) { + this.lastSeg7 = buf; + this.maybeSetActive("seg7", meta.clientId ?? "streamer"); + } else if (header.kind === FRAME_KIND_OLED_DELTA) { + this.maybeSetActive("oled", meta.clientId ?? "streamer"); + } + + this.broadcastBinary(buf); + if (shouldFlushPending) this.flushPendingOledFull(buf); + } +} + +function parseRoomFromRequest(req) { + const base = `http://${req.headers.host ?? "localhost"}`; + const url = new URL(req.url ?? "/", base); + const match = url.pathname.match(/^\/api\/rooms\/([^/]+)\/ws$/); + if (!match) return null; + + const rawRoomId = decodeURIComponent(match[1] ?? ""); + const roomId = normalizeRoomId(rawRoomId); + if (!roomId) return null; + + const role = url.searchParams.get("role"); + if (role !== "streamer" && role !== "viewer") return null; + + return { roomId, role }; +} + +function makeWsAccept(secKey) { + return sha1Base64(`${secKey}${WS_GUID}`); +} + +function isWebSocketUpgrade(req) { + const up = req.headers.upgrade; + return typeof up === "string" && up.toLowerCase() === "websocket"; +} + +function sweepRooms(rooms) { + const now = nowMs(); + for (const [roomId, room] of rooms) { + const maybeClose = (v) => { + if (now - v.meta.lastSeen > IDLE_TIMEOUT_MS) v.conn.close(4000, "idle"); + }; + + if (room.streamer) maybeClose(room.streamer); + for (const v of room.viewers.values()) maybeClose(v); + for (const v of room.pendingViewers.values()) maybeClose(v); + + const empty = + room.streamer == null && + room.viewers.size === 0 && + room.pendingViewers.size === 0; + if (empty) rooms.delete(roomId); + } +} + +const { host, port, tlsCert, tlsKey } = parseArgs(process.argv.slice(2)); +const rooms = new Map(); + +const requestHandler = (req, res) => { + if (req.url === "/health") { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("ok"); + return; + } + res.writeHead(404); + res.end(); +}; + +const server = + tlsCert && tlsKey + ? https.createServer( + { + cert: fs.readFileSync(tlsCert), + key: fs.readFileSync(tlsKey), + }, + requestHandler, + ) + : http.createServer(requestHandler); + +server.on("upgrade", (req, socket, head) => { + if (!isWebSocketUpgrade(req)) { + writeHttpError(socket, 426, "Expected WebSocket upgrade"); + return; + } + + const parsed = parseRoomFromRequest(req); + if (!parsed) { + writeHttpError(socket, 400, "Invalid room request"); + return; + } + + const secKey = req.headers["sec-websocket-key"]; + if (typeof secKey !== "string" || !secKey) { + writeHttpError(socket, 400, "Missing Sec-WebSocket-Key"); + return; + } + + const accept = makeWsAccept(secKey); + socket.write( + [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${accept}`, + "", + "", + ].join("\r\n"), + ); + + const conn = new WsConn(socket, head); + const room = rooms.get(parsed.roomId) ?? new Room(parsed.roomId); + rooms.set(parsed.roomId, room); + + const meta = { + role: parsed.role, + roomId: parsed.roomId, + clientId: null, + authed: false, + lastSeen: nowMs(), + }; + room.attach(conn, meta); +}); + +server.listen(port, host, () => { + const proto = tlsCert && tlsKey ? "https" : "http"; + const wsProto = tlsCert && tlsKey ? "wss" : "ws"; + console.log(`[relay] listening on ${proto}://${host}:${port}`); + console.log( + `[relay] ws endpoint: ${wsProto}://${host}:${port}/api/rooms/:roomId/ws?role=...`, + ); +}); + +setInterval(() => sweepRooms(rooms), SWEEP_MS); diff --git a/src/components/screenStreaming/ScreenStreamingModal.tsx b/src/components/screenStreaming/ScreenStreamingModal.tsx index 39a9e8d..a61bfb3 100644 --- a/src/components/screenStreaming/ScreenStreamingModal.tsx +++ b/src/components/screenStreaming/ScreenStreamingModal.tsx @@ -3,6 +3,7 @@ import { XMarkIcon } from "@heroicons/react/24/outline"; import { midiIn, midiOut } from "@/state"; import { createRoomId, normalizeRoomId } from "@/lib/screenStreaming/roomCode"; import { derivePasswordToken } from "@/lib/screenStreaming/passwordToken"; +import { getStreamHostOverrideFromQuery } from "@/lib/screenStreaming/wsUrl"; import type { ErrMsg } from "@/lib/screenStreaming/control"; import { refreshStreamedDisplay, @@ -15,9 +16,39 @@ import { } from "@/services/screenStreamingStreamer"; import { QrCodeSvg } from "./QrCodeSvg"; -function buildViewerUrl(roomId: string): string { - const url = new URL(window.location.origin + window.location.pathname); +const LOCAL_DEV_ROOM_ID = "local-local"; +const SHARE_BASE_URL_KEY = "dex.screenStreaming.shareBaseUrl"; + +function normalizeShareBaseUrl(input: string): string | null { + const raw = input.trim(); + if (!raw) return null; + const tryParse = (value: string) => { + try { + return new URL(value); + } catch { + return null; + } + }; + + const direct = tryParse(raw); + if (direct) return direct.toString().replace(/\/$/, ""); + + const withHttp = tryParse(`http://${raw}`); + if (withHttp) return withHttp.toString().replace(/\/$/, ""); + + return null; +} + +function buildViewerUrl(roomId: string, shareBaseUrl: string): string { + const normalizedBase = normalizeShareBaseUrl(shareBaseUrl); + const url = normalizedBase + ? new URL(window.location.pathname, normalizedBase) + : new URL(window.location.origin + window.location.pathname); url.searchParams.set("roomId", roomId); + if (!normalizedBase) { + const streamHost = getStreamHostOverrideFromQuery(window.location); + if (streamHost) url.searchParams.set("streamHost", streamHost); + } return url.toString(); } @@ -38,16 +69,21 @@ export function ScreenStreamingModal(props: { onClose: () => void }) { const roomState = screenStreamerRoomState.value; const err = screenStreamerError.value; - const [draftRoomId, setDraftRoomId] = useState(() => createRoomId()); + const [draftRoomId, setDraftRoomId] = useState(() => + import.meta.env.DEV ? LOCAL_DEV_ROOM_ID : createRoomId(), + ); const [requirePassword, setRequirePassword] = useState(false); const [password, setPassword] = useState(""); const [busy, setBusy] = useState(false); const [copied, setCopied] = useState(null); + const [shareBaseUrl, setShareBaseUrl] = useState(() => { + return window.localStorage.getItem(SHARE_BASE_URL_KEY) ?? ""; + }); const effectiveRoomId = status === "idle" ? draftRoomId : activeRoomId; const joinUrl = useMemo( - () => (effectiveRoomId ? buildViewerUrl(effectiveRoomId) : null), - [effectiveRoomId], + () => (effectiveRoomId ? buildViewerUrl(effectiveRoomId, shareBaseUrl) : null), + [effectiveRoomId, shareBaseUrl], ); useEffect(() => { @@ -60,6 +96,11 @@ export function ScreenStreamingModal(props: { onClose: () => void }) { const canStop = status !== "idle"; const regenerateRoom = () => setDraftRoomId(createRoomId()); + const useLocalRoom = () => setDraftRoomId(LOCAL_DEV_ROOM_ID); + + useEffect(() => { + window.localStorage.setItem(SHARE_BASE_URL_KEY, shareBaseUrl); + }, [shareBaseUrl]); const start = async () => { if (!draftRoomId) return; @@ -160,9 +201,43 @@ export function ScreenStreamingModal(props: { onClose: () => void }) { Regenerate )} + {status === "idle" && import.meta.env.DEV && ( + + )}
+ {status === "idle" && import.meta.env.DEV && ( +
+
Local dev
+
+ Tip: run the relay on port 8787 and start Vite with{" "} + yarn dev --host. For mobile, set + the share base URL to the LAN URL printed by Vite (not localhost). +
+
+ + + setShareBaseUrl((e.target as HTMLInputElement).value) + } + className="w-full px-3 py-2 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] font-mono text-xs" + placeholder="http://192.168.1.10:5173" + /> +
+
+ )} + {status === "idle" && (