mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2025-12-12 13:56:16 +00:00
feat: t4g support
This commit is contained in:
@@ -43,6 +43,9 @@ const config = {
|
|||||||
"arrow_back",
|
"arrow_back",
|
||||||
"arrow_back_ios_new",
|
"arrow_back_ios_new",
|
||||||
"save",
|
"save",
|
||||||
|
"step_over",
|
||||||
|
"step_into",
|
||||||
|
"step_out",
|
||||||
"settings_backup_restore",
|
"settings_backup_restore",
|
||||||
"sound_detection_loud_sound",
|
"sound_detection_loud_sound",
|
||||||
"ring_volume",
|
"ring_volume",
|
||||||
|
|||||||
10
src/lib/assets/layouts/t4g.yml
Normal file
10
src/lib/assets/layouts/t4g.yml
Normal 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
|
||||||
26
src/lib/ccos/attachment.ts
Normal file
26
src/lib/ccos/attachment.ts
Normal 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>;
|
||||||
|
}
|
||||||
37
src/lib/ccos/ccos-events.ts
Normal file
37
src/lib/ccos/ccos-events.ts
Normal 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;
|
||||||
111
src/lib/ccos/ccos-interop.ts
Normal file
111
src/lib/ccos/ccos-interop.ts
Normal 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
210
src/lib/ccos/ccos.ts
Normal 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}`);
|
||||||
|
}
|
||||||
@@ -32,6 +32,10 @@
|
|||||||
import("$lib/assets/layouts/m4gr.yml").then(
|
import("$lib/assets/layouts/m4gr.yml").then(
|
||||||
(it) => it.default as VisualLayout,
|
(it) => it.default as VisualLayout,
|
||||||
),
|
),
|
||||||
|
T4G: () =>
|
||||||
|
import("$lib/assets/layouts/t4g.yml").then(
|
||||||
|
(it) => it.default as VisualLayout,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const KEY_COUNTS = {
|
|||||||
X: 256,
|
X: 256,
|
||||||
M4G: 90,
|
M4G: 90,
|
||||||
M4GR: 90,
|
M4GR: 90,
|
||||||
|
T4G: 7,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
232
src/routes/(app)/e2e/+page.svelte.wip
Normal file
232
src/routes/(app)/e2e/+page.svelte.wip
Normal 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>
|
||||||
251
src/routes/(app)/e2e/keycodes.json
Normal file
251
src/routes/(app)/e2e/keycodes.json
Normal 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
164
static/ccos-worker.js
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user