feat: t4g support

This commit is contained in:
2025-09-02 18:41:46 +02:00
parent 0b2695a380
commit f3b1d76666
11 changed files with 1049 additions and 0 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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<Window>;
}

View File

@@ -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;

View File

@@ -0,0 +1,111 @@
export const KEYCODE_TO_SCANCODE = new Map<string, number | undefined>(
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<number, string>(
KEYCODE_TO_SCANCODE.entries().map(([key, value]) => [value!, key]),
);

210
src/lib/ccos/ccos.ts Normal file
View File

@@ -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<typeof KeyboardEvent>) {
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<number>();
private readonly layout = new Map<string, string>();
private readonly worker = new Worker("/ccos-worker.js", { type: "module" });
private ready = false;
private lastEvent?: KeyboardEvent;
private onKey(
type: ConstructorParameters<typeof KeyboardEvent>[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<KeyboardEventInit> = {
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<number>(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<number>();
private readonly buffer: number[] = [];
private readonly outStream = new WritableStream<number>({
start(controller) {},
});
readonly readable = connectable()
readonly writable = new WritableStream<string>();
constructor(url: string) {
this.worker.addEventListener(
"message",
(event: MessageEvent<CCOSOutEvent>) => {
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<string, string>) =>
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<CCOS | undefined> {
const meta = await getMeta(device, version, fetch);
if (!meta?.update.js || !meta?.update.wasm) {
return undefined;
}
return new CCOS(`${meta.path}/${meta.update.js}`);
}

View File

@@ -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,
),
};
</script>

View File

@@ -29,6 +29,7 @@ const KEY_COUNTS = {
X: 256,
M4G: 90,
M4GR: 90,
T4G: 7,
} as const;
if (

View File

@@ -0,0 +1,232 @@
<script lang="ts">
import {
compileLayout,
type VisualLayout,
} from "$lib/serialization/visual-layout";
import ccxLayout from "$lib/assets/layouts/generic/103-key.yml";
import keycodes from "./keycodes.json";
let width = $state(16);
let height = $state(16);
let layout = $state(compileLayout(ccxLayout as VisualLayout));
let layoutMargin = $state(0.2);
let timelineCanvas = $state<HTMLCanvasElement | undefined>(undefined);
interface Report {
modifiers?: number;
keys?: number[];
}
interface Tick {
ms?: number;
reports?: Report[];
keys?: number[];
}
let test: Tick[] = $state([
{ ms: 1, reports: [{ keys: [4] }], keys: [4] },
{ ms: 2, reports: [{ keys: [4, 2] }], keys: [4, 12] },
]);
function timelineData<T extends { ms: number }>(
ticks: T[],
value: (tick: T) => number[],
) {
let totalTicks = 0;
const result = new Map<number, [number, number][]>();
for (const tick of ticks) {
const key = value(tick);
}
}
let timelineData = $derived.by(() => {
const result = new Map<number, [number, number][]>();
for (const tick of test) {
if (!tick.keys) continue;
if (Array.isArray(action)) {
if (typeof action[0] === "number") {
ticks.push([action[0]]);
totalTicks++;
} else if (action.length === 0) {
ticks.push([1]);
totalTicks++;
}
}
if (typeof action !== "number") continue;
if (action >= 0) {
if (!result.has(action)) {
result.set(action, []);
}
result.get(action)!.push([totalTicks, test.length - 1]);
} else {
const value = result.get(~action)?.at(-1);
if (!value || value[1] !== test.length - 1) continue;
value[1] = totalTicks;
}
}
return {
totalTicks,
ticks,
presses: [...result.entries()].sort(([a], [b]) => a - b),
};
});
</script>
<h1>E2E Testing</h1>
{#snippet Layout(keys: Set<number>)}
<svg viewBox="0 0 {layout.size[0]} {layout.size[1]}">
{#each layout.keys as key}
{#if key.shape === "square"}
<rect
x={key.pos[0] + layoutMargin / 2}
y={key.pos[1] + layoutMargin / 2}
rx={0.5 - layoutMargin / 2}
width={key.size[0] - layoutMargin}
height={key.size[1] - layoutMargin}
fill={keys.has(key.id)
? "var(--md-sys-color-primary)"
: "var(--md-sys-color-on-surface)"}
opacity={keys.has(key.id) ? 1 : 0.1}
/>
{/if}
{/each}
</svg>
{/snippet}
<canvas bind:this={timelineCanvas}></canvas>
<div class="t">
{#each test as { ms, reports, keys }}
<div class="tick">
{ms}ms
<div class="keys">
{#each keys ?? [] as key}
<kbd>{keycodes[key] ?? key}</kbd>
{/each}
<button class="icon">+</button>
</div>
{@render Layout(new Set(keys))}
{#each reports ?? [] as report}
<div class="report">
<div class="modifiers">{report.modifiers}</div>
<div class="keys">
{#each report.keys ?? [] as key}
<kbd>{keycodes[key] ?? key}</kbd>
{/each}
</div>
</div>
{/each}
</div>
{/each}
</div>
<div class="actions">
{#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}
<div class="tick">
<span class="icon">step_over</span>
{action[0]}ms
</div>
{#if action[1]}
<div class="report">
{#each Array.from({ length: 8 }) as _, j}
<div class="modifier">{j}</div>
{/each}
{#each action[1][1] as key}
<div class="key">
{key}
</div>
{/each}
</div>
{/if}
{:else if typeof action === "string"}
<div>Command: {action}</div>
{:else if isActionPress}
<button class="release" onclick={() => (test[i] = ~action)}
>{action}</button
>
{:else if isActionRelease}
<button class="press" onclick={() => (test[i] = ~action)}
>{~action}</button
>
{:else}
<div>Unsupported {action}</div>
{/if}
{/each}
</div>
<style lang="scss">
svg {
width: 100px;
}
$shadow-inset: 1px;
.timeline {
display: grid;
grid-template-rows: auto repeat(auto-fit, minmax(var(--height), 1fr));
}
.timeline-press {
margin-inline: calc(var(--width) / 2);
border-radius: calc(var(--height) / 2);
background-color: var(--md-sys-color-surface-variant);
height: var(--height);
}
.actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.tick {
display: flex;
position: relative;
flex-direction: column;
align-items: center;
gap: 0.5rem;
cursor: ew-resize;
padding: 0.5rem;
user-select: none;
span.icon {
position: absolute;
top: 0;
transform: translateY(-50%);
}
}
button {
cursor: pointer;
margin: 0;
border: none;
padding: 8px;
aspect-ratio: 1;
user-select: none;
&.release {
box-shadow:
inset #{$shadow-inset} #{$shadow-inset} #{$shadow-inset * 2}
rgba(0, 0, 0, 0.6),
inset -#{$shadow-inset} -#{$shadow-inset} #{$shadow-inset * 2}
rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
}
&.press {
box-shadow:
#{$shadow-inset} #{$shadow-inset} #{$shadow-inset * 2}
rgba(0, 0, 0, 0.6),
-#{$shadow-inset} -#{$shadow-inset} #{$shadow-inset * 2}
rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
}
}
</style>

View File

@@ -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"
]

164
static/ccos-worker.js Normal file
View File

@@ -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<unknown>} */
last = Promise.resolve();
/**
* @template T
* @param {() => T | Promise<T>} callback
* @return {Promise<T>}
*/
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<CCOSInEvent>} 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<void>} */ (
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);
}