mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2025-12-10 21:06:17 +00:00
feat: t4g support
This commit is contained in:
@@ -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",
|
||||
|
||||
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(
|
||||
(it) => it.default as VisualLayout,
|
||||
),
|
||||
T4G: () =>
|
||||
import("$lib/assets/layouts/t4g.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
),
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const KEY_COUNTS = {
|
||||
X: 256,
|
||||
M4G: 90,
|
||||
M4GR: 90,
|
||||
T4G: 7,
|
||||
} as const;
|
||||
|
||||
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