From f3b1d7666665d36468e4377ee93fb1999f0f1640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thea=20Sch=C3=B6bl?= Date: Tue, 2 Sep 2025 18:41:46 +0200 Subject: [PATCH] feat: t4g support --- icons.config.js | 3 + src/lib/assets/layouts/t4g.yml | 10 + src/lib/ccos/attachment.ts | 26 +++ src/lib/ccos/ccos-events.ts | 37 ++++ src/lib/ccos/ccos-interop.ts | 111 +++++++++++ src/lib/ccos/ccos.ts | 210 ++++++++++++++++++++ src/lib/components/layout/Layout.svelte | 4 + src/lib/serial/device.ts | 1 + src/routes/(app)/e2e/+page.svelte.wip | 232 ++++++++++++++++++++++ src/routes/(app)/e2e/keycodes.json | 251 ++++++++++++++++++++++++ static/ccos-worker.js | 164 ++++++++++++++++ 11 files changed, 1049 insertions(+) create mode 100644 src/lib/assets/layouts/t4g.yml create mode 100644 src/lib/ccos/attachment.ts create mode 100644 src/lib/ccos/ccos-events.ts create mode 100644 src/lib/ccos/ccos-interop.ts create mode 100644 src/lib/ccos/ccos.ts create mode 100644 src/routes/(app)/e2e/+page.svelte.wip create mode 100644 src/routes/(app)/e2e/keycodes.json create mode 100644 static/ccos-worker.js diff --git a/icons.config.js b/icons.config.js index c5382db0..134098f6 100644 --- a/icons.config.js +++ b/icons.config.js @@ -43,6 +43,9 @@ const config = { "arrow_back", "arrow_back_ios_new", "save", + "step_over", + "step_into", + "step_out", "settings_backup_restore", "sound_detection_loud_sound", "ring_volume", diff --git a/src/lib/assets/layouts/t4g.yml b/src/lib/assets/layouts/t4g.yml new file mode 100644 index 00000000..44978048 --- /dev/null +++ b/src/lib/assets/layouts/t4g.yml @@ -0,0 +1,10 @@ +name: T4G +col: + - row: + - switch: { e: 3, n: 5, w: 4, s: 6 } + - offset: [0.5, 0] + row: + - key: 2 + - row: + - key: 0 + - key: 1 diff --git a/src/lib/ccos/attachment.ts b/src/lib/ccos/attachment.ts new file mode 100644 index 00000000..74bf87c4 --- /dev/null +++ b/src/lib/ccos/attachment.ts @@ -0,0 +1,26 @@ +import type { Attachment } from "svelte/attachments"; +import { browser } from "$app/environment"; +import { persistentWritable } from "$lib/storage"; + +export const emulatedCCOS = persistentWritable("emulatedCCOS", false); + +export function ccosKeyInterceptor() { + return ((element: Window) => { + const ccos = browser + ? import("./ccos").then((module) => module.fetchCCOS(".test")) + : Promise.resolve(undefined); + + function onEvent(event: KeyboardEvent) { + ccos.then((it) => it?.handleKeyEvent(event)); + } + + element.addEventListener("keydown", onEvent, true); + element.addEventListener("keyup", onEvent, true); + + return () => { + ccos.then((it) => it?.destroy()); + element.removeEventListener("keydown", onEvent, true); + element.removeEventListener("keyup", onEvent, true); + }; + }) satisfies Attachment; +} diff --git a/src/lib/ccos/ccos-events.ts b/src/lib/ccos/ccos-events.ts new file mode 100644 index 00000000..7d2b56b3 --- /dev/null +++ b/src/lib/ccos/ccos-events.ts @@ -0,0 +1,37 @@ +export interface CCOSInitEvent { + type: "init"; + url: string; +} + +export interface CCOSKeyPressEvent { + type: "press"; + code: number; +} + +export interface CCOSKeyReleaseEvent { + type: "release"; + code: number; +} + +export interface CCOSSerialEvent { + type: "serial"; + data: number; +} + +export type CCOSInEvent = + | CCOSInitEvent + | CCOSKeyPressEvent + | CCOSKeyReleaseEvent + | CCOSSerialEvent; + +export interface CCOSReportEvent { + type: "report"; + modifiers: number; + keys: number[]; +} + +export interface CCOSReadyEvent { + type: "ready"; +} + +export type CCOSOutEvent = CCOSReportEvent | CCOSReadyEvent | CCOSSerialEvent; diff --git a/src/lib/ccos/ccos-interop.ts b/src/lib/ccos/ccos-interop.ts new file mode 100644 index 00000000..a2dc501f --- /dev/null +++ b/src/lib/ccos/ccos-interop.ts @@ -0,0 +1,111 @@ +export const KEYCODE_TO_SCANCODE = new Map( + Object.entries({ + KeyA: 0x04, + KeyB: 0x05, + KeyC: 0x06, + KeyD: 0x07, + KeyE: 0x08, + KeyF: 0x09, + KeyG: 0x0a, + KeyH: 0x0b, + KeyI: 0x0c, + KeyJ: 0x0d, + KeyK: 0x0e, + KeyL: 0x0f, + KeyM: 0x10, + KeyN: 0x11, + KeyO: 0x12, + KeyP: 0x13, + KeyQ: 0x14, + KeyR: 0x15, + KeyS: 0x16, + KeyT: 0x17, + KeyU: 0x18, + KeyV: 0x19, + KeyW: 0x1a, + KeyX: 0x1b, + KeyY: 0x1c, + KeyZ: 0x1d, + Digit1: 0x1e, + Digit2: 0x1f, + Digit3: 0x20, + Digit4: 0x21, + Digit5: 0x22, + Digit6: 0x23, + Digit7: 0x24, + Digit8: 0x25, + Digit9: 0x26, + Digit0: 0x27, + Enter: 0x28, + Escape: 0x29, + Backspace: 0x2a, + Tab: 0x2b, + Space: 0x2c, + Minus: 0x2d, + Equal: 0x2e, + BracketLeft: 0x2f, + BracketRight: 0x30, + Backslash: 0x31, + Semicolon: 0x33, + Quote: 0x34, + Backquote: 0x35, + Comma: 0x36, + Period: 0x37, + Slash: 0x38, + CapsLock: 0x39, + F1: 0x3a, + F2: 0x3b, + F3: 0x3c, + F4: 0x3d, + F5: 0x3e, + F6: 0x3f, + F7: 0x40, + F8: 0x41, + F9: 0x42, + F10: 0x43, + F11: 0x44, + F12: 0x45, + PrintScreen: 0x46, + ScrollLock: 0x47, + Pause: 0x48, + Insert: 0x49, + Home: 0x4a, + PageUp: 0x4b, + Delete: 0x4c, + End: 0x4d, + PageDown: 0x4e, + ArrowRight: 0x4f, + ArrowLeft: 0x50, + ArrowDown: 0x51, + ArrowUp: 0x52, + NumLock: 0x53, + NumpadDivide: 0x54, + NumpadMultiply: 0x55, + NumpadSubtract: 0x56, + NumpadAdd: 0x57, + NumpadEnter: 0x58, + Numpad1: 0x59, + Numpad2: 0x5a, + Numpad3: 0x5b, + Numpad4: 0x5c, + Numpad5: 0x5d, + Numpad6: 0x5e, + Numpad7: 0x5f, + Numpad8: 0x60, + Numpad9: 0x61, + Numpad0: 0x62, + NumpadDecimal: 0x63, + ControlLeft: 0xe0, + ShiftLeft: 0xe1, + AltLeft: 0xe2, + MetaLeft: 0xe3, + ControlRight: 0xe4, + ShiftRight: 0xe5, + AltRight: 0xe6, + MetaRight: 0xe7, + }), +); + +export const SCANCODE_TO_KEYCODE = new Map( + KEYCODE_TO_SCANCODE.entries().map(([key, value]) => [value!, key]), +); diff --git a/src/lib/ccos/ccos.ts b/src/lib/ccos/ccos.ts new file mode 100644 index 00000000..a9d4aa5c --- /dev/null +++ b/src/lib/ccos/ccos.ts @@ -0,0 +1,210 @@ +import { getMeta } from "$lib/meta/meta-storage"; +import { connectable, from, multicast, Subject } from "rxjs"; +import type { + CCOSInitEvent, + CCOSKeyPressEvent, + CCOSKeyReleaseEvent, + CCOSOutEvent, +} from "./ccos-events"; +import { KEYCODE_TO_SCANCODE, SCANCODE_TO_KEYCODE } from "./ccos-interop"; + +const device = ".zero_wasm"; + +class CCOSKeyboardEvent extends KeyboardEvent { + constructor(...params: ConstructorParameters) { + super(...params); + } +} + +const MASK_CTRL = 0b0001_0001; +const MASK_SHIFT = 0b0010_0010; +const MASK_ALT = 0b0100_0100; +const MASK_ALT_GRAPH = 0b0000_0100; +const MASK_GUI = 0b1000_1000; + +export class CCOS { + private readonly currKeys = new Set(); + + private readonly layout = new Map(); + + private readonly worker = new Worker("/ccos-worker.js", { type: "module" }); + + private ready = false; + + private lastEvent?: KeyboardEvent; + + private onKey( + type: ConstructorParameters[0], + modifiers: number, + scanCode: number, + ) { + if (!this.lastEvent) { + return; + } + + const code = SCANCODE_TO_KEYCODE.get(scanCode); + if (code === undefined) { + return; + } + + const layoutKey = [code]; + if (modifiers & MASK_SHIFT) { + layoutKey.push("Shift"); + } + if (modifiers & MASK_ALT_GRAPH) { + layoutKey.push("AltGraph"); + } + const key = this.layout.get(JSON.stringify(layoutKey)) ?? code; + + const params: Required = { + bubbles: true, + cancelable: true, + location: this.lastEvent.location, + repeat: this.lastEvent.repeat, + detail: this.lastEvent.detail, + view: this.lastEvent.view, + isComposing: this.lastEvent.isComposing, + which: this.lastEvent.which, + composed: this.lastEvent.composed, + key, + code, + charCode: key.charCodeAt(0), + keyCode: this.lastEvent.keyCode, + shiftKey: (modifiers & MASK_SHIFT) !== 0, + ctrlKey: (modifiers & MASK_CTRL) !== 0, + metaKey: (modifiers & MASK_GUI) !== 0, + altKey: (modifiers & MASK_ALT) !== 0, + modifierAltGraph: (modifiers & MASK_ALT_GRAPH) !== 0, + modifierCapsLock: this.lastEvent.getModifierState("CapsLock"), + modifierFn: this.lastEvent.getModifierState("Fn"), + modifierFnLock: this.lastEvent.getModifierState("FnLock"), + modifierHyper: this.lastEvent.getModifierState("Hyper"), + modifierNumLock: this.lastEvent.getModifierState("NumLock"), + modifierSuper: (modifiers & MASK_GUI) !== 0, + modifierSymbol: this.lastEvent.getModifierState("Symbol"), + modifierSymbolLock: this.lastEvent.getModifierState("SymbolLock"), + modifierScrollLock: this.lastEvent.getModifierState("ScrollLock"), + }; + + this.lastEvent.target?.dispatchEvent(new CCOSKeyboardEvent(type, params)); + } + + private onReport(modifiers: number, keys: number[]) { + const nextKeys = new Set(keys); + nextKeys.delete(0); + for (const key of this.currKeys) { + if (!nextKeys.has(key)) { + this.onKey("keyup", modifiers, key); + } + } + for (const key of nextKeys) { + if (!this.currKeys.has(key)) { + this.onKey("keydown", modifiers, key); + } + } + this.currKeys.clear(); + for (const key of keys) { + this.currKeys.add(key); + } + this.currKeys.delete(0); + } + + private outStream = new Subject(); + + private readonly buffer: number[] = []; + private readonly outStream = new WritableStream({ + start(controller) {}, + }); + + readonly readable = connectable() + readonly writable = new WritableStream(); + + constructor(url: string) { + this.worker.addEventListener( + "message", + (event: MessageEvent) => { + switch (event.data.type) { + case "ready": { + this.ready = true; + break; + } + case "report": { + this.onReport(event.data.modifiers, event.data.keys); + break; + } + case "serial": { + this.outStream.next(event.data.data); + break; + } + } + }, + ); + (navigator as any).keyboard + ?.getLayoutMap() + ?.then((it: Map) => + it.entries().forEach(([key, value]) => { + this.layout.set(JSON.stringify([key]), value); + }), + ); + this.worker.postMessage({ + type: "init", + url, + } satisfies CCOSInitEvent); + } + + async destroy() { + this.worker.terminate(); + } + + async handleKeyEvent(event: KeyboardEvent) { + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement + ) { + console.error("CCOS does not support input elements"); + return; + } + + if (!this.ready || event instanceof CCOSKeyboardEvent) { + return; + } + event.stopImmediatePropagation(); + event.preventDefault(); + this.lastEvent = event; + + const layoutKey = [event.code]; + if (event.getModifierState("Shift")) { + layoutKey.push("Shift"); + } + if (event.getModifierState("AltGraph")) { + layoutKey.push("AltGraph"); + } + this.layout.set(JSON.stringify(layoutKey), event.key); + + const scanCode = KEYCODE_TO_SCANCODE.get(event.code); + if (scanCode === undefined) return; + if (event.type === "keydown") { + this.worker.postMessage({ + type: "press", + code: scanCode, + } satisfies CCOSKeyPressEvent); + } else { + this.worker.postMessage({ + type: "release", + code: scanCode, + } satisfies CCOSKeyReleaseEvent); + } + } +} + +export async function fetchCCOS( + version = ".test", + fetch: typeof window.fetch = window.fetch, +): Promise { + const meta = await getMeta(device, version, fetch); + if (!meta?.update.js || !meta?.update.wasm) { + return undefined; + } + + return new CCOS(`${meta.path}/${meta.update.js}`); +} diff --git a/src/lib/components/layout/Layout.svelte b/src/lib/components/layout/Layout.svelte index 8bbccf95..4632f232 100644 --- a/src/lib/components/layout/Layout.svelte +++ b/src/lib/components/layout/Layout.svelte @@ -32,6 +32,10 @@ import("$lib/assets/layouts/m4gr.yml").then( (it) => it.default as VisualLayout, ), + T4G: () => + import("$lib/assets/layouts/t4g.yml").then( + (it) => it.default as VisualLayout, + ), }; diff --git a/src/lib/serial/device.ts b/src/lib/serial/device.ts index 4100ad57..61f88f01 100644 --- a/src/lib/serial/device.ts +++ b/src/lib/serial/device.ts @@ -29,6 +29,7 @@ const KEY_COUNTS = { X: 256, M4G: 90, M4GR: 90, + T4G: 7, } as const; if ( diff --git a/src/routes/(app)/e2e/+page.svelte.wip b/src/routes/(app)/e2e/+page.svelte.wip new file mode 100644 index 00000000..1ec1591d --- /dev/null +++ b/src/routes/(app)/e2e/+page.svelte.wip @@ -0,0 +1,232 @@ + + +

E2E Testing

+ +{#snippet Layout(keys: Set)} + + {#each layout.keys as key} + {#if key.shape === "square"} + + {/if} + {/each} + +{/snippet} + + + +
+ {#each test as { ms, reports, keys }} +
+ {ms}ms +
+ {#each keys ?? [] as key} + {keycodes[key] ?? key} + {/each} + +
+ {@render Layout(new Set(keys))} + {#each reports ?? [] as report} +
+
{report.modifiers}
+
+ {#each report.keys ?? [] as key} + {keycodes[key] ?? key} + {/each} +
+
+ {/each} +
+ {/each} +
+ +
+ {#each test as action, i} + {@const isActionTick = Array.isArray(action)} + {@const isActionPress = typeof action === "number" && action >= 0} + {@const isActionRelease = typeof action === "number" && action < 0} + {#if isActionTick} +
+ step_over + {action[0]}ms +
+ {#if action[1]} +
+ {#each Array.from({ length: 8 }) as _, j} +
{j}
+ {/each} + {#each action[1][1] as key} +
+ {key} +
+ {/each} +
+ {/if} + {:else if typeof action === "string"} +
Command: {action}
+ {:else if isActionPress} + + {:else if isActionRelease} + + {:else} +
Unsupported {action}
+ {/if} + {/each} +
+ + diff --git a/src/routes/(app)/e2e/keycodes.json b/src/routes/(app)/e2e/keycodes.json new file mode 100644 index 00000000..20407730 --- /dev/null +++ b/src/routes/(app)/e2e/keycodes.json @@ -0,0 +1,251 @@ +[ + "reserved", + "esc", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "0", + "-", + "=", + "bksp", + "tab", + "q", + "w", + "e", + "r", + "t", + "y", + "u", + "i", + "o", + "p", + "[", + "]", + "enter", + "lctrl", + "a", + "s", + "d", + "f", + "g", + "h", + "j", + "k", + "l", + ";", + "'", + "`", + "lshift", + "\\", + "z", + "x", + "c", + "v", + "b", + "n", + "m", + ",", + ".", + "/", + "rshift", + "kp*", + "lalt", + "_", + "capslock", + "f1", + "f2", + "f3", + "f4", + "f5", + "f6", + "f7", + "f8", + "f9", + "f10", + "numlock", + "scrolllock", + "kp7", + "kp8", + "kp9", + "kp-", + "kp4", + "kp5", + "kp6", + "kp+", + "kp1", + "kp2", + "kp3", + "kp0", + "kp.", + "ksc_84", + "zenkaku_hankaku", + "102nd", + "f11", + "f12", + "ro", + "katakana", + "hiragana", + "henkan", + "katakana_hiragana", + "muhenkan", + "kp,", + "kp_enter", + "rctrl", + "kp/", + "sysrq", + "ralt", + "linefeed", + "home", + "up", + "pageup", + "left", + "right", + "end", + "down", + "pagedown", + "insert", + "delete", + "macro", + "mute", + "volume_down", + "volume_up", + "power", + "kp=", + "kp+-", + "pause", + "scale", + "kp,", + "hangeul", + "hanja", + "yen", + "lmeta", + "rmeta", + "compose", + "stop", + "again", + "props", + "undo", + "front", + "copy", + "open", + "paste", + "find", + "cut", + "help", + "menu", + "calc", + "setup", + "sleep", + "wakeup", + "file", + "sendfile", + "deletefile", + "xfer", + "prog1", + "prog2", + "www", + "msdos", + "coffee", + "rotate_display", + "cyclewindows", + "mail", + "bookmarks", + "computer", + "back", + "forward", + "close_cd", + "eject_cd", + "eject_close_cd", + "next_song", + "play_pause", + "prev_song", + "stop_cd", + "record", + "rewind", + "phone", + "iso", + "config", + "homepage", + "refresh", + "exit", + "move", + "edit", + "scroll_up", + "scroll_down", + "kp_left_paren", + "kp_right_paren", + "new", + "redo", + "f13", + "f14", + "f15", + "f16", + "f17", + "f18", + "f19", + "f20", + "f21", + "f22", + "f23", + "f24", + "sc_195", + "sc_196", + "sc_197", + "sc_198", + "sc_199", + "play_cd", + "pause_cd", + "prog3", + "prog4", + "all_applications", + "suspend", + "close", + "play", + "fastforward", + "bass_boost", + "print", + "hp", + "camera", + "sound", + "question", + "email", + "chat", + "search", + "connect", + "finance", + "sport", + "shop", + "alterase", + "cancel", + "brightness_down", + "brightness_up", + "media", + "switch_video_mode", + "kbd_illum_toggle", + "kbd_illum_down", + "kbd_illum_up", + "send", + "reply", + "forward_mail", + "save", + "documents", + "battery", + "bluetooth", + "wlan", + "uwb", + "unknown", + "video_next", + "video_prev", + "brightness_cycle", + "brightness_auto", + "display_off", + "wwan", + "rfkill", + "mic_mute" +] diff --git a/static/ccos-worker.js b/static/ccos-worker.js new file mode 100644 index 00000000..c52096ad --- /dev/null +++ b/static/ccos-worker.js @@ -0,0 +1,164 @@ +// @ts-check + +/** + * @typedef {import("../src/lib/ccos/ccos-events").CCOSInEvent} CCOSInEvent + * @typedef {import("../src/lib/ccos/ccos-events").CCOSReadyEvent} CCOSReadyEvent + * @typedef {import("../src/lib/ccos/ccos-events").CCOSReportEvent} CCOSReportEvent + */ + +export class AsyncSemaphore { + /** @type {Promise} */ + last = Promise.resolve(); + + /** + * @template T + * @param {() => T | Promise} callback + * @return {Promise} + */ + run(callback) { + return new Promise((resolve, reject) => { + this.last = this.last.finally(async () => { + try { + const result = await callback(); + resolve(result); + } catch (error) { + reject(error); + } + }); + }); + } +} + +const ccosFsPath = "/CCOS"; + +/** @type {any} */ +let ccos; + +const semaphore = new AsyncSemaphore(); + +/** + * @param {MessageEvent} event + */ +self.addEventListener("message", async (event) => { + switch (event.data.type) { + case "init": { + const url = event.data.url; + await semaphore.run(() => init(url)); + break; + } + case "press": { + const code = event.data.code; + await semaphore.run(() => keyPress(code)); + break; + } + case "release": { + const code = event.data.code; + await semaphore.run(() => keyRelease(code)); + break; + } + case "serial": { + await semaphore.run(() => serialWrite(event.data.data)); + } + } +}); + +/** + * @param {string} url + */ +async function init(url) { + if (ccos) { + console.warn("CCOS is already initialized."); + return; + } + + ccos = await import(/* @vite-ignore */ url).then((it) => it.default()); + + await ccos.FS.mkdir(ccosFsPath); + await ccos.FS.mount( + ccos.FS.filesystems.IDBFS, + { autoPersist: true }, + ccosFsPath, + ); + + await /** @type {Promise} */ ( + new Promise(async (resolve) => { + await ccos.FS.syncfs(true, (/** @type {any} */ err) => { + if (err) { + console.error(err); + } + }); + resolve(); + }) + ); + + const onReport = ccos.addFunction( + /** + * @param {number} modifiers + * @param {...number} keys + */ + (modifiers, ...keys) => { + /** @type {CCOSReportEvent} */ + const msg = { type: "report", modifiers, keys }; + self.postMessage(msg); + }, + "viiiiiiiiiiiii", + ); + const onSerial = ccos.addFunction( + /** + * @param {number} data + */ + (data) => { + /** @type {CCOSInEvent}) */ + const msg = { type: "serial", data }; + self.postMessage(msg); + }, + "vi", + ); + + ccos._init(onReport, onSerial); + + async function update() { + if (ccos) { + await semaphore.run(() => ccos.update()); + } + requestAnimationFrame(update); + } + update(); + + /** @type {CCOSReadyEvent} */ + const readyMsg = { type: "ready" }; + self.postMessage(readyMsg); +} + +/** + * @param {number} data + */ +async function serialWrite(data) { + if (!ccos) { + console.warn("Serial write ignored, CCOS is not initialized."); + return; + } + await ccos.serialWrite(data); +} + +/** + * @param {number} code + */ +async function keyPress(code) { + if (!ccos) { + console.warn("Key press ignored, CCOS is not initialized."); + return; + } + await ccos.addPressedKey(code); +} + +/** + * @param {number} code + */ +async function keyRelease(code) { + if (!ccos) { + console.warn("Key release ignored, CCOS is not initialized."); + return; + } + await ccos.removePressedKey(code); +}