local-control 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- local_control/__init__.py +11 -0
- local_control/app.py +291 -0
- local_control/auth.py +240 -0
- local_control/cli.py +143 -0
- local_control/clipboard.py +342 -0
- local_control/config.py +47 -0
- local_control/control.py +1043 -0
- local_control/startup.py +140 -0
- local_control/static/css/styles.css +393 -0
- local_control/static/index.html +140 -0
- local_control/static/js/app.js +1658 -0
- local_control/utils/__init__.py +9 -0
- local_control/utils/qrcodegen.py +907 -0
- local_control/utils/terminal_qr.py +34 -0
- local_control-0.1.2.dist-info/METADATA +49 -0
- local_control-0.1.2.dist-info/RECORD +20 -0
- local_control-0.1.2.dist-info/WHEEL +5 -0
- local_control-0.1.2.dist-info/entry_points.txt +2 -0
- local_control-0.1.2.dist-info/licenses/LICENSE +21 -0
- local_control-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1658 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const loginView = document.getElementById("login-view");
|
|
3
|
+
const controlView = document.getElementById("control-view");
|
|
4
|
+
const loginForm = document.getElementById("login-form");
|
|
5
|
+
const loginError = document.getElementById("login-error");
|
|
6
|
+
const statusUser = document.getElementById("status-user");
|
|
7
|
+
const logoutButton = document.getElementById("logout-button");
|
|
8
|
+
const lockButton = document.getElementById("lock-button");
|
|
9
|
+
const unlockButton = document.getElementById("unlock-button");
|
|
10
|
+
const shutdownButton = document.getElementById("shutdown-button");
|
|
11
|
+
const clickButtons = document.querySelectorAll("[data-click]");
|
|
12
|
+
const trackpad = document.getElementById("trackpad");
|
|
13
|
+
const typeInput = document.getElementById("type-input");
|
|
14
|
+
const typeForm = document.getElementById("type-form");
|
|
15
|
+
const realtimeInput = document.getElementById("realtime-input");
|
|
16
|
+
const clipboardPullButton = document.getElementById("clipboard-pull");
|
|
17
|
+
const clipboardPushButton = document.getElementById("clipboard-push");
|
|
18
|
+
const clipboardText = document.getElementById("clipboard-text");
|
|
19
|
+
const clipboardImage = document.getElementById("clipboard-image");
|
|
20
|
+
const clipboardStatus = document.getElementById("clipboard-status");
|
|
21
|
+
const helpButton = document.getElementById("help-button");
|
|
22
|
+
const helpOverlay = document.getElementById("help-overlay");
|
|
23
|
+
const helpClose = document.getElementById("help-close");
|
|
24
|
+
|
|
25
|
+
let authenticated = false;
|
|
26
|
+
const EDGE_RELEASE_RATIO = 0.05;
|
|
27
|
+
const EDGE_RELEASE_DELAY_MS = 100;
|
|
28
|
+
const EDGE_BUFFER_PX = 2;
|
|
29
|
+
const POINTER_WARP_RATIO = 0.3;
|
|
30
|
+
const POINTER_WARP_MIN_THRESHOLD = 45;
|
|
31
|
+
const POINTER_JUMP_RATIO = 3.2;
|
|
32
|
+
const POINTER_JUMP_MIN_MAG = 26;
|
|
33
|
+
const POINTER_JUMP_TIME_WINDOW_MS = 180;
|
|
34
|
+
const POINTER_DIRECTION_DECAY_MS = 160;
|
|
35
|
+
const POINTER_DIRECTION_MIN_MAG = 10;
|
|
36
|
+
const POINTER_DIRECTION_MAX_NOISE = 38;
|
|
37
|
+
const POINTER_DIRECTION_ALIGN_RATIO = 0.35;
|
|
38
|
+
const POINTER_DIRECTION_TURN_RATIO = 0.7;
|
|
39
|
+
const POINTER_BUTTON_MAP = new Map([
|
|
40
|
+
[0, "left"],
|
|
41
|
+
[1, "middle"],
|
|
42
|
+
[2, "right"],
|
|
43
|
+
]);
|
|
44
|
+
let lastRemoteState = null;
|
|
45
|
+
let edgeAccumulators = { left: 0, right: 0, top: 0, bottom: 0 };
|
|
46
|
+
let edgeReleaseTimer = null;
|
|
47
|
+
const activeKeys = new Set();
|
|
48
|
+
const touches = new Map();
|
|
49
|
+
let touchSession = null;
|
|
50
|
+
let pendingScroll = { horizontal: 0, vertical: 0 };
|
|
51
|
+
let scrollFrameQueued = false;
|
|
52
|
+
let lastSanitizedVector = null;
|
|
53
|
+
let lastSanitizedAt = 0;
|
|
54
|
+
let lastDirectionSample = null;
|
|
55
|
+
const heldPointerButtons = new Set();
|
|
56
|
+
const specialKeys = new Map([
|
|
57
|
+
["Enter", "enter"],
|
|
58
|
+
["Backspace", "backspace"],
|
|
59
|
+
["Tab", "tab"],
|
|
60
|
+
["Escape", "esc"],
|
|
61
|
+
["ArrowUp", "up"],
|
|
62
|
+
["ArrowDown", "down"],
|
|
63
|
+
["ArrowLeft", "left"],
|
|
64
|
+
["ArrowRight", "right"],
|
|
65
|
+
["Delete", "delete"],
|
|
66
|
+
["Home", "home"],
|
|
67
|
+
["End", "end"],
|
|
68
|
+
["PageUp", "pageup"],
|
|
69
|
+
["PageDown", "pagedown"],
|
|
70
|
+
]);
|
|
71
|
+
const modifierKeys = new Map([
|
|
72
|
+
["Shift", "shift"],
|
|
73
|
+
["Control", "ctrl"],
|
|
74
|
+
["Alt", "alt"],
|
|
75
|
+
["Meta", "command"],
|
|
76
|
+
]);
|
|
77
|
+
const heldModifiers = new Set();
|
|
78
|
+
const modifierTimers = new Map();
|
|
79
|
+
const TOUCH_MOVE_BASE = 0.9;
|
|
80
|
+
const TOUCH_MOVE_MIN = 1.6;
|
|
81
|
+
const TOUCH_MOVE_MAX = 10;
|
|
82
|
+
const TOUCH_SCROLL_MIN = 1.5;
|
|
83
|
+
const TOUCH_SCROLL_MAX = 6;
|
|
84
|
+
const MULTI_TAP_TIME_MS = 260;
|
|
85
|
+
const MULTI_TAP_TRAVEL_THRESHOLD = 95;
|
|
86
|
+
let helpVisible = false;
|
|
87
|
+
let lastClipboardContent = null;
|
|
88
|
+
const LOG_TEXT_LIMIT = 120;
|
|
89
|
+
|
|
90
|
+
function logEvent(topic, message, detail) {
|
|
91
|
+
const timestamp = new Date().toISOString();
|
|
92
|
+
if (detail !== undefined) {
|
|
93
|
+
console.log(`[${timestamp}] [${topic}] ${message}`, detail);
|
|
94
|
+
} else {
|
|
95
|
+
console.log(`[${timestamp}] [${topic}] ${message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function summarizeTextSample(text, limit = LOG_TEXT_LIMIT) {
|
|
100
|
+
if (typeof text !== "string") {
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
if (text.length <= limit) {
|
|
104
|
+
return text;
|
|
105
|
+
}
|
|
106
|
+
return `${text.slice(0, limit)}… (len=${text.length})`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function describeClipboardContent(content) {
|
|
110
|
+
if (!content) {
|
|
111
|
+
return { type: "none" };
|
|
112
|
+
}
|
|
113
|
+
if (content.type === "text") {
|
|
114
|
+
return {
|
|
115
|
+
type: "text",
|
|
116
|
+
length: content.data ? content.data.length : 0,
|
|
117
|
+
preview: summarizeTextSample(content.data || ""),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (content.type === "image") {
|
|
121
|
+
return {
|
|
122
|
+
type: "image",
|
|
123
|
+
bytes: content.data ? content.data.length : 0,
|
|
124
|
+
mime: content.mime || "image/png",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return { type: content.type || "unknown" };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function api(path, payload) {
|
|
131
|
+
const response = await fetch(path, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: {
|
|
134
|
+
"Content-Type": "application/json",
|
|
135
|
+
},
|
|
136
|
+
credentials: "same-origin",
|
|
137
|
+
body: JSON.stringify(payload ?? {}),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
let data = {};
|
|
141
|
+
try {
|
|
142
|
+
data = await response.json();
|
|
143
|
+
} catch (_) {
|
|
144
|
+
// no-op, keep default empty object
|
|
145
|
+
}
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
const message = data.error || response.statusText;
|
|
148
|
+
throw new Error(message);
|
|
149
|
+
}
|
|
150
|
+
return data;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isPointerLocked() {
|
|
154
|
+
return document.pointerLockElement === trackpad;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function setPointerSyncState(active) {
|
|
158
|
+
if (!trackpad) return;
|
|
159
|
+
trackpad.classList.toggle("pointer-sync", Boolean(active));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function pointerButtonFromEvent(event) {
|
|
163
|
+
if (!event || typeof event.button !== "number") {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
return POINTER_BUTTON_MAP.get(event.button) || null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sendMouseButton(button, action) {
|
|
170
|
+
if (!authenticated || !button) return Promise.resolve();
|
|
171
|
+
logEvent("Mouse", "Pointer button action", { button, action });
|
|
172
|
+
return api("/api/mouse/button", { button, action }).catch((err) => {
|
|
173
|
+
console.error("Mouse button action failed", err);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function releaseAllPointerButtons() {
|
|
178
|
+
if (!heldPointerButtons.size) return;
|
|
179
|
+
const buttons = Array.from(heldPointerButtons);
|
|
180
|
+
heldPointerButtons.clear();
|
|
181
|
+
buttons.forEach((button) => {
|
|
182
|
+
sendMouseButton(button, "up");
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function setClipboardStatus(message, tone = "info") {
|
|
187
|
+
if (!clipboardStatus) return;
|
|
188
|
+
clipboardStatus.textContent = message || "";
|
|
189
|
+
clipboardStatus.dataset.tone = tone;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function setClipboardPreview(content, origin = "host") {
|
|
193
|
+
if (!content) {
|
|
194
|
+
logEvent("Clipboard", "Cleared clipboard preview");
|
|
195
|
+
if (clipboardText) {
|
|
196
|
+
clipboardText.hidden = false;
|
|
197
|
+
clipboardText.value = "";
|
|
198
|
+
}
|
|
199
|
+
if (clipboardImage) {
|
|
200
|
+
clipboardImage.src = "";
|
|
201
|
+
clipboardImage.hidden = true;
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
lastClipboardContent = content;
|
|
206
|
+
logEvent("Clipboard", "Updated clipboard preview", {
|
|
207
|
+
origin,
|
|
208
|
+
...describeClipboardContent(content),
|
|
209
|
+
});
|
|
210
|
+
if (content.type === "text") {
|
|
211
|
+
if (clipboardText) {
|
|
212
|
+
clipboardText.hidden = false;
|
|
213
|
+
clipboardText.value = content.data;
|
|
214
|
+
}
|
|
215
|
+
if (clipboardImage) {
|
|
216
|
+
clipboardImage.src = "";
|
|
217
|
+
clipboardImage.hidden = true;
|
|
218
|
+
}
|
|
219
|
+
setClipboardStatus(
|
|
220
|
+
origin === "device" ? "Uploaded device text to host clipboard." : "Fetched host clipboard text.",
|
|
221
|
+
);
|
|
222
|
+
} else if (content.type === "image") {
|
|
223
|
+
if (clipboardImage) {
|
|
224
|
+
const mime = content.mime || "image/png";
|
|
225
|
+
clipboardImage.src = `data:${mime};base64,${content.data}`;
|
|
226
|
+
clipboardImage.hidden = false;
|
|
227
|
+
}
|
|
228
|
+
if (clipboardText) {
|
|
229
|
+
clipboardText.hidden = true;
|
|
230
|
+
clipboardText.value = "";
|
|
231
|
+
}
|
|
232
|
+
setClipboardStatus(
|
|
233
|
+
origin === "device" ? "Uploaded device image to host clipboard." : "Fetched host clipboard image.",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function base64ToBlob(base64, mime = "application/octet-stream") {
|
|
239
|
+
const binary = atob(base64);
|
|
240
|
+
const array = new Uint8Array(binary.length);
|
|
241
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
242
|
+
array[i] = binary.charCodeAt(i);
|
|
243
|
+
}
|
|
244
|
+
return new Blob([array], { type: mime });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function blobToBase64(blob) {
|
|
248
|
+
return new Promise((resolve, reject) => {
|
|
249
|
+
const reader = new FileReader();
|
|
250
|
+
reader.onload = () => {
|
|
251
|
+
const result = reader.result;
|
|
252
|
+
if (typeof result === "string") {
|
|
253
|
+
const base64 = result.split(",").pop();
|
|
254
|
+
resolve(base64 || "");
|
|
255
|
+
} else {
|
|
256
|
+
reject(new Error("Unsupported clipboard blob result."));
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
reader.onerror = () => reject(reader.error || new Error("Failed to convert blob to base64."));
|
|
260
|
+
reader.readAsDataURL(blob);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function syncClipboardToDevice(content) {
|
|
265
|
+
if (!navigator.clipboard) {
|
|
266
|
+
setClipboardStatus("Browser clipboard API unavailable; copied content shown in preview only.", "warn");
|
|
267
|
+
logEvent("Clipboard", "Device clipboard API unavailable");
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
logEvent("Clipboard", "Syncing host clipboard to device", describeClipboardContent(content));
|
|
271
|
+
try {
|
|
272
|
+
if (content.type === "text") {
|
|
273
|
+
if (navigator.clipboard.writeText) {
|
|
274
|
+
await navigator.clipboard.writeText(content.data);
|
|
275
|
+
setClipboardStatus("Copied host text to this device clipboard.");
|
|
276
|
+
logEvent("Clipboard", "Device clipboard text updated");
|
|
277
|
+
}
|
|
278
|
+
} else if (content.type === "image") {
|
|
279
|
+
const mime = content.mime || "image/png";
|
|
280
|
+
const blob = base64ToBlob(content.data, mime);
|
|
281
|
+
if (navigator.clipboard.write && window.ClipboardItem) {
|
|
282
|
+
const item = new ClipboardItem({ [blob.type]: blob });
|
|
283
|
+
await navigator.clipboard.write([item]);
|
|
284
|
+
setClipboardStatus("Copied host image to this device clipboard.");
|
|
285
|
+
logEvent("Clipboard", "Device clipboard image updated", { mime: blob.type, bytes: blob.size });
|
|
286
|
+
} else {
|
|
287
|
+
setClipboardStatus("Clipboard image synced to preview. Browser API does not support programmatic image copy.", "warn");
|
|
288
|
+
logEvent("Clipboard", "Clipboard image sync limited by browser API");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} catch (err) {
|
|
292
|
+
console.warn("Failed to write to device clipboard", err);
|
|
293
|
+
setClipboardStatus("Clipboard permission denied. Content available in preview.", "warn");
|
|
294
|
+
logEvent("Clipboard", "Failed to sync host clipboard to device", { error: err.message });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function readDeviceClipboard() {
|
|
299
|
+
if (!navigator.clipboard) {
|
|
300
|
+
setClipboardStatus("Browser clipboard API unavailable.", "warn");
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
if (navigator.clipboard.read) {
|
|
305
|
+
const items = await navigator.clipboard.read();
|
|
306
|
+
for (const item of items) {
|
|
307
|
+
if (item.types.includes("image/png")) {
|
|
308
|
+
const blob = await item.getType("image/png");
|
|
309
|
+
const base64 = await blobToBase64(blob);
|
|
310
|
+
return { type: "image", data: base64, mime: blob.type || "image/png" };
|
|
311
|
+
}
|
|
312
|
+
if (item.types.includes("text/plain")) {
|
|
313
|
+
const blob = await item.getType("text/plain");
|
|
314
|
+
const text = await blob.text();
|
|
315
|
+
return { type: "text", data: text };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (navigator.clipboard.readText) {
|
|
320
|
+
const text = await navigator.clipboard.readText();
|
|
321
|
+
if (text !== undefined && text !== null) {
|
|
322
|
+
return { type: "text", data: text };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
} catch (err) {
|
|
326
|
+
console.warn("Failed to read device clipboard", err);
|
|
327
|
+
setClipboardStatus("Clipboard permission denied. Paste may not transfer content.", "warn");
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function pushDeviceClipboardToHost() {
|
|
334
|
+
const local = await readDeviceClipboard();
|
|
335
|
+
if (!local) {
|
|
336
|
+
logEvent("Clipboard", "No device clipboard content to push");
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
logEvent("Clipboard", "Pushing device clipboard to host", describeClipboardContent(local));
|
|
340
|
+
try {
|
|
341
|
+
await api("/api/clipboard", local);
|
|
342
|
+
setClipboardPreview(local, "device");
|
|
343
|
+
logEvent("Clipboard", "Device clipboard pushed to host successfully");
|
|
344
|
+
return true;
|
|
345
|
+
} catch (err) {
|
|
346
|
+
console.error("Failed to push clipboard to host", err);
|
|
347
|
+
setClipboardStatus(err.message || "Clipboard upload failed.", "warn");
|
|
348
|
+
logEvent("Clipboard", "Failed to push clipboard to host", { error: err.message });
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function pullClipboardFromHost(syncDevice = true) {
|
|
354
|
+
try {
|
|
355
|
+
logEvent("Clipboard", "Requesting host clipboard", { syncDevice });
|
|
356
|
+
const response = await fetch("/api/clipboard", { credentials: "same-origin" });
|
|
357
|
+
if (!response.ok) {
|
|
358
|
+
throw new Error(response.statusText);
|
|
359
|
+
}
|
|
360
|
+
const data = await response.json();
|
|
361
|
+
if (!data || !data.content) {
|
|
362
|
+
setClipboardStatus("Host clipboard empty.", "info");
|
|
363
|
+
setClipboardPreview(null);
|
|
364
|
+
logEvent("Clipboard", "Host clipboard empty");
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
const content = data.content;
|
|
368
|
+
setClipboardPreview(content, "host");
|
|
369
|
+
if (syncDevice) {
|
|
370
|
+
await syncClipboardToDevice(content);
|
|
371
|
+
}
|
|
372
|
+
logEvent("Clipboard", "Fetched host clipboard", describeClipboardContent(content));
|
|
373
|
+
return content;
|
|
374
|
+
} catch (err) {
|
|
375
|
+
console.error("Failed to fetch clipboard", err);
|
|
376
|
+
setClipboardStatus(err.message || "Unable to read host clipboard.", "warn");
|
|
377
|
+
logEvent("Clipboard", "Failed to fetch host clipboard", { error: err.message });
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function scheduleClipboardPull(delay = 250) {
|
|
383
|
+
window.setTimeout(() => {
|
|
384
|
+
pullClipboardFromHost(true).catch((err) =>
|
|
385
|
+
console.warn("Clipboard pull failed", err),
|
|
386
|
+
);
|
|
387
|
+
}, delay);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function sendComboPress(key) {
|
|
391
|
+
try {
|
|
392
|
+
logEvent("Keyboard", "Sending combo press", { key });
|
|
393
|
+
await api("/api/keyboard/key", { key, action: "press" });
|
|
394
|
+
} catch (err) {
|
|
395
|
+
console.error("Combo press failed", err);
|
|
396
|
+
logEvent("Keyboard", "Combo press failed", { key, error: err.message });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function handleClipboardCombo(normalized, event) {
|
|
401
|
+
if (normalized === "v") {
|
|
402
|
+
event.preventDefault();
|
|
403
|
+
if (!event.repeat) {
|
|
404
|
+
logEvent("Keyboard", "Clipboard paste combo triggered");
|
|
405
|
+
(async () => {
|
|
406
|
+
const success = await pushDeviceClipboardToHost();
|
|
407
|
+
await sendComboPress(normalized);
|
|
408
|
+
if (!success) {
|
|
409
|
+
setClipboardStatus("Paste sent to host without clipboard payload.", "warn");
|
|
410
|
+
}
|
|
411
|
+
})();
|
|
412
|
+
}
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
if (normalized === "c" || normalized === "x") {
|
|
416
|
+
event.preventDefault();
|
|
417
|
+
if (!event.repeat) {
|
|
418
|
+
logEvent("Keyboard", `Clipboard ${normalized === "c" ? "copy" : "cut"} combo triggered`);
|
|
419
|
+
sendComboPress(normalized);
|
|
420
|
+
scheduleClipboardPull();
|
|
421
|
+
}
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function openHelp() {
|
|
428
|
+
if (!helpOverlay) return;
|
|
429
|
+
if (isPointerLocked()) {
|
|
430
|
+
try {
|
|
431
|
+
document.exitPointerLock();
|
|
432
|
+
} catch (err) {
|
|
433
|
+
console.warn("Failed to exit pointer lock when opening help", err);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
releaseAllActiveKeys();
|
|
437
|
+
releaseAllModifiers();
|
|
438
|
+
helpOverlay.hidden = false;
|
|
439
|
+
helpVisible = true;
|
|
440
|
+
if (helpClose) {
|
|
441
|
+
helpClose.focus();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function closeHelp() {
|
|
446
|
+
if (!helpOverlay) return;
|
|
447
|
+
helpOverlay.hidden = true;
|
|
448
|
+
helpVisible = false;
|
|
449
|
+
if (helpButton) {
|
|
450
|
+
helpButton.focus();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function clamp(value, min, max) {
|
|
455
|
+
return Math.min(max, Math.max(min, value));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function computeTouchMoveScale() {
|
|
459
|
+
if (!trackpad) return TOUCH_MOVE_MIN;
|
|
460
|
+
const rect = trackpad.getBoundingClientRect();
|
|
461
|
+
if (!rect.width || !rect.height) {
|
|
462
|
+
return TOUCH_MOVE_MIN;
|
|
463
|
+
}
|
|
464
|
+
const remoteWidth = lastRemoteState ? Number(lastRemoteState.width) : null;
|
|
465
|
+
const remoteHeight = lastRemoteState ? Number(lastRemoteState.height) : null;
|
|
466
|
+
if (!remoteWidth || !remoteHeight) {
|
|
467
|
+
const viewport = Math.max(window.innerWidth, window.innerHeight) || rect.width;
|
|
468
|
+
const base = viewport / Math.max(rect.width, rect.height);
|
|
469
|
+
return clamp(base * TOUCH_MOVE_BASE, TOUCH_MOVE_MIN, TOUCH_MOVE_MAX);
|
|
470
|
+
}
|
|
471
|
+
const scaleX = remoteWidth / rect.width;
|
|
472
|
+
const scaleY = remoteHeight / rect.height;
|
|
473
|
+
const weighted = Math.max(scaleX, scaleY) * TOUCH_MOVE_BASE;
|
|
474
|
+
return clamp(weighted, TOUCH_MOVE_MIN, TOUCH_MOVE_MAX);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function computeTouchScrollScale() {
|
|
478
|
+
const moveScale = computeTouchMoveScale();
|
|
479
|
+
return clamp(moveScale * 0.45, TOUCH_SCROLL_MIN, TOUCH_SCROLL_MAX);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function rememberDirectionSample(vec, timestamp) {
|
|
483
|
+
const mag = Math.hypot(vec.dx, vec.dy);
|
|
484
|
+
if (mag < POINTER_DIRECTION_MIN_MAG) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const inv = 1 / mag;
|
|
488
|
+
lastDirectionSample = {
|
|
489
|
+
x: vec.dx * inv,
|
|
490
|
+
y: vec.dy * inv,
|
|
491
|
+
mag,
|
|
492
|
+
time: timestamp,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function suppressPerpendicularJitter(vec, timestamp) {
|
|
497
|
+
if (!lastDirectionSample) {
|
|
498
|
+
return vec;
|
|
499
|
+
}
|
|
500
|
+
if (timestamp - lastDirectionSample.time > POINTER_DIRECTION_DECAY_MS) {
|
|
501
|
+
lastDirectionSample = null;
|
|
502
|
+
return vec;
|
|
503
|
+
}
|
|
504
|
+
const mag = Math.hypot(vec.dx, vec.dy);
|
|
505
|
+
if (mag < POINTER_DIRECTION_MIN_MAG || mag > POINTER_DIRECTION_MAX_NOISE) {
|
|
506
|
+
return vec;
|
|
507
|
+
}
|
|
508
|
+
const dot = vec.dx * lastDirectionSample.x + vec.dy * lastDirectionSample.y;
|
|
509
|
+
const alignment = Math.abs(dot) / mag;
|
|
510
|
+
if (alignment >= POINTER_DIRECTION_ALIGN_RATIO) {
|
|
511
|
+
return vec;
|
|
512
|
+
}
|
|
513
|
+
if (mag > lastDirectionSample.mag * POINTER_DIRECTION_TURN_RATIO) {
|
|
514
|
+
lastDirectionSample = null;
|
|
515
|
+
return vec;
|
|
516
|
+
}
|
|
517
|
+
if (Math.abs(lastDirectionSample.x) >= Math.abs(lastDirectionSample.y)) {
|
|
518
|
+
vec.dy = 0;
|
|
519
|
+
} else {
|
|
520
|
+
vec.dx = 0;
|
|
521
|
+
}
|
|
522
|
+
if (Math.abs(vec.dx) < 0.5 && Math.abs(vec.dy) < 0.5) {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
return vec;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function sanitizePointerLockDelta(dx, dy) {
|
|
529
|
+
const docElement = document.documentElement;
|
|
530
|
+
const padWidth = trackpad ? trackpad.clientWidth : 0;
|
|
531
|
+
const padHeight = trackpad ? trackpad.clientHeight : 0;
|
|
532
|
+
const viewportWidth = Math.max(window.innerWidth || 0, (docElement && docElement.clientWidth) || 0);
|
|
533
|
+
const viewportHeight = Math.max(window.innerHeight || 0, (docElement && docElement.clientHeight) || 0);
|
|
534
|
+
const refWidth = Math.min(
|
|
535
|
+
padWidth || Number.POSITIVE_INFINITY,
|
|
536
|
+
viewportWidth || Number.POSITIVE_INFINITY,
|
|
537
|
+
);
|
|
538
|
+
const refHeight = Math.min(
|
|
539
|
+
padHeight || Number.POSITIVE_INFINITY,
|
|
540
|
+
viewportHeight || Number.POSITIVE_INFINITY,
|
|
541
|
+
);
|
|
542
|
+
const thresholdX = Math.max(
|
|
543
|
+
POINTER_WARP_MIN_THRESHOLD,
|
|
544
|
+
(Number.isFinite(refWidth) ? refWidth : viewportWidth) * POINTER_WARP_RATIO,
|
|
545
|
+
);
|
|
546
|
+
const thresholdY = Math.max(
|
|
547
|
+
POINTER_WARP_MIN_THRESHOLD,
|
|
548
|
+
(Number.isFinite(refHeight) ? refHeight : viewportHeight) * POINTER_WARP_RATIO,
|
|
549
|
+
);
|
|
550
|
+
let filteredDx = dx;
|
|
551
|
+
let filteredDy = dy;
|
|
552
|
+
if (Math.abs(filteredDx) >= thresholdX) {
|
|
553
|
+
filteredDx = 0;
|
|
554
|
+
}
|
|
555
|
+
if (Math.abs(filteredDy) >= thresholdY) {
|
|
556
|
+
filteredDy = 0;
|
|
557
|
+
}
|
|
558
|
+
const now = performance.now();
|
|
559
|
+
if (
|
|
560
|
+
lastSanitizedVector &&
|
|
561
|
+
now - lastSanitizedAt < POINTER_JUMP_TIME_WINDOW_MS
|
|
562
|
+
) {
|
|
563
|
+
const prev = lastSanitizedVector;
|
|
564
|
+
const prevMag = Math.hypot(prev.dx, prev.dy);
|
|
565
|
+
const currMag = Math.hypot(filteredDx, filteredDy);
|
|
566
|
+
const prevAxis = Math.abs(prev.dx) >= Math.abs(prev.dy) ? "x" : "y";
|
|
567
|
+
const currAxis = Math.abs(filteredDx) >= Math.abs(filteredDy) ? "x" : "y";
|
|
568
|
+
const prevDominant =
|
|
569
|
+
prevAxis === "x"
|
|
570
|
+
? Math.abs(prev.dx) >= Math.abs(prev.dy) * POINTER_JUMP_RATIO
|
|
571
|
+
: Math.abs(prev.dy) >= Math.abs(prev.dx) * POINTER_JUMP_RATIO;
|
|
572
|
+
const currDominant =
|
|
573
|
+
currAxis === "x"
|
|
574
|
+
? Math.abs(filteredDx) >= Math.abs(filteredDy || 0) * POINTER_JUMP_RATIO
|
|
575
|
+
: Math.abs(filteredDy) >= Math.abs(filteredDx || 0) * POINTER_JUMP_RATIO;
|
|
576
|
+
if (
|
|
577
|
+
prevMag >= POINTER_JUMP_MIN_MAG &&
|
|
578
|
+
currMag >= POINTER_JUMP_MIN_MAG &&
|
|
579
|
+
prevAxis !== currAxis &&
|
|
580
|
+
prevDominant &&
|
|
581
|
+
currDominant
|
|
582
|
+
) {
|
|
583
|
+
if (currAxis === "x") {
|
|
584
|
+
filteredDx = 0;
|
|
585
|
+
} else {
|
|
586
|
+
filteredDy = 0;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const jitterChecked = suppressPerpendicularJitter(
|
|
592
|
+
{ dx: filteredDx, dy: filteredDy },
|
|
593
|
+
now,
|
|
594
|
+
);
|
|
595
|
+
if (!jitterChecked) {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
filteredDx = jitterChecked.dx;
|
|
599
|
+
filteredDy = jitterChecked.dy;
|
|
600
|
+
if (filteredDx === 0 && filteredDy === 0) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
const result = { dx: filteredDx, dy: filteredDy };
|
|
604
|
+
lastSanitizedVector = { dx: filteredDx, dy: filteredDy };
|
|
605
|
+
lastSanitizedAt = now;
|
|
606
|
+
rememberDirectionSample(result, now);
|
|
607
|
+
return result;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function queueScrollFlush() {
|
|
611
|
+
if (scrollFrameQueued) return;
|
|
612
|
+
scrollFrameQueued = true;
|
|
613
|
+
requestAnimationFrame(flushScroll);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function flushScroll() {
|
|
617
|
+
scrollFrameQueued = false;
|
|
618
|
+
if (!authenticated) return;
|
|
619
|
+
if (
|
|
620
|
+
Math.abs(pendingScroll.horizontal) < 0.01 &&
|
|
621
|
+
Math.abs(pendingScroll.vertical) < 0.01
|
|
622
|
+
) {
|
|
623
|
+
pendingScroll = { horizontal: 0, vertical: 0 };
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const payload = {
|
|
627
|
+
horizontal: pendingScroll.horizontal,
|
|
628
|
+
vertical: pendingScroll.vertical,
|
|
629
|
+
};
|
|
630
|
+
pendingScroll = { horizontal: 0, vertical: 0 };
|
|
631
|
+
api("/api/mouse/scroll", payload).catch((err) =>
|
|
632
|
+
console.error("Touch scroll failed", err)
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function cancelEdgeRelease() {
|
|
637
|
+
if (edgeReleaseTimer) {
|
|
638
|
+
clearTimeout(edgeReleaseTimer);
|
|
639
|
+
edgeReleaseTimer = null;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function resetEdgeTracking() {
|
|
644
|
+
edgeAccumulators = { left: 0, right: 0, top: 0, bottom: 0 };
|
|
645
|
+
cancelEdgeRelease();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function scheduleEdgeRelease() {
|
|
649
|
+
if (edgeReleaseTimer) return;
|
|
650
|
+
edgeReleaseTimer = setTimeout(() => {
|
|
651
|
+
edgeReleaseTimer = null;
|
|
652
|
+
if (isPointerLocked() && typeof document.exitPointerLock === "function") {
|
|
653
|
+
try {
|
|
654
|
+
document.exitPointerLock();
|
|
655
|
+
} catch (err) {
|
|
656
|
+
console.warn("Failed to exit pointer lock", err);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
resetEdgeTracking();
|
|
660
|
+
releaseAllActiveKeys();
|
|
661
|
+
releaseAllModifiers();
|
|
662
|
+
}, EDGE_RELEASE_DELAY_MS);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function normalizeKeyForAction(key) {
|
|
666
|
+
if (!key) return null;
|
|
667
|
+
if (key.length === 1) {
|
|
668
|
+
const digitShiftMap = {
|
|
669
|
+
"!": "1",
|
|
670
|
+
"@": "2",
|
|
671
|
+
"#": "3",
|
|
672
|
+
"$": "4",
|
|
673
|
+
"%": "5",
|
|
674
|
+
"^": "6",
|
|
675
|
+
"&": "7",
|
|
676
|
+
"*": "8",
|
|
677
|
+
"(": "9",
|
|
678
|
+
")": "0",
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
if (digitShiftMap[key]) {
|
|
682
|
+
return digitShiftMap[key];
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (key === " ") {
|
|
686
|
+
return "space";
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const punctuationMap = {
|
|
690
|
+
"-": "minus",
|
|
691
|
+
"_": "minus",
|
|
692
|
+
"=": "equals",
|
|
693
|
+
"+": "equals",
|
|
694
|
+
"[": "leftbracket",
|
|
695
|
+
"{": "leftbracket",
|
|
696
|
+
"]": "rightbracket",
|
|
697
|
+
"}": "rightbracket",
|
|
698
|
+
"\\": "backslash",
|
|
699
|
+
"|": "backslash",
|
|
700
|
+
";": "semicolon",
|
|
701
|
+
":": "semicolon",
|
|
702
|
+
"'": "quote",
|
|
703
|
+
'"': "quote",
|
|
704
|
+
",": "comma",
|
|
705
|
+
"<": "comma",
|
|
706
|
+
".": "period",
|
|
707
|
+
">": "period",
|
|
708
|
+
"/": "slash",
|
|
709
|
+
"?": "slash",
|
|
710
|
+
"`": "grave",
|
|
711
|
+
"~": "grave",
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
if (punctuationMap[key]) {
|
|
715
|
+
return punctuationMap[key];
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const lower = key.toLowerCase();
|
|
719
|
+
if ((lower >= "a" && lower <= "z") || (lower >= "0" && lower <= "9")) {
|
|
720
|
+
return lower;
|
|
721
|
+
}
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (key === "Spacebar") {
|
|
726
|
+
return "space";
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function releaseAllActiveKeys() {
|
|
733
|
+
if (!activeKeys.size) return;
|
|
734
|
+
for (const key of activeKeys) {
|
|
735
|
+
api("/api/keyboard/key", { key, action: "up" }).catch((err) =>
|
|
736
|
+
console.error("Release key failed", err)
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
activeKeys.clear();
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function clearModifierTimer(key) {
|
|
743
|
+
const timer = modifierTimers.get(key);
|
|
744
|
+
if (timer) {
|
|
745
|
+
clearTimeout(timer);
|
|
746
|
+
modifierTimers.delete(key);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function scheduleModifierTimer(key) {
|
|
751
|
+
clearModifierTimer(key);
|
|
752
|
+
const timer = setTimeout(() => {
|
|
753
|
+
modifierTimers.delete(key);
|
|
754
|
+
if (!heldModifiers.has(key)) return;
|
|
755
|
+
heldModifiers.delete(key);
|
|
756
|
+
api("/api/keyboard/key", { key, action: "up" }).catch((err) =>
|
|
757
|
+
console.error("Auto-release modifier failed", err)
|
|
758
|
+
);
|
|
759
|
+
}, 8000);
|
|
760
|
+
modifierTimers.set(key, timer);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function modifierDown(key) {
|
|
764
|
+
if (!heldModifiers.has(key)) {
|
|
765
|
+
heldModifiers.add(key);
|
|
766
|
+
api("/api/keyboard/key", { key, action: "down" }).catch((err) =>
|
|
767
|
+
console.error("Modifier down failed", err)
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
scheduleModifierTimer(key);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function modifierUp(key) {
|
|
774
|
+
if (!heldModifiers.has(key)) return;
|
|
775
|
+
heldModifiers.delete(key);
|
|
776
|
+
clearModifierTimer(key);
|
|
777
|
+
api("/api/keyboard/key", { key, action: "up" }).catch((err) =>
|
|
778
|
+
console.error("Modifier up failed", err)
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function releaseAllModifiers() {
|
|
783
|
+
if (!heldModifiers.size) return;
|
|
784
|
+
const keys = Array.from(heldModifiers);
|
|
785
|
+
keys.forEach((key) => modifierUp(key));
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function handleRemoteState(state, movement) {
|
|
789
|
+
if (!state || typeof state !== "object") {
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
lastRemoteState = state;
|
|
793
|
+
lastMovementVector = movement || null;
|
|
794
|
+
const width = Number(state.width) || 0;
|
|
795
|
+
const height = Number(state.height) || 0;
|
|
796
|
+
if (width <= 0 || height <= 0) {
|
|
797
|
+
resetEdgeTracking();
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const x = Number(state.x) || 0;
|
|
801
|
+
const y = Number(state.y) || 0;
|
|
802
|
+
if (!isPointerLocked()) {
|
|
803
|
+
resetEdgeTracking();
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const atLeft = x <= EDGE_BUFFER_PX;
|
|
808
|
+
const atRight = x >= width - EDGE_BUFFER_PX;
|
|
809
|
+
const atTop = y <= EDGE_BUFFER_PX;
|
|
810
|
+
const atBottom = y >= height - EDGE_BUFFER_PX;
|
|
811
|
+
const thresholdX = Math.max(width * EDGE_RELEASE_RATIO, EDGE_BUFFER_PX);
|
|
812
|
+
const thresholdY = Math.max(height * EDGE_RELEASE_RATIO, EDGE_BUFFER_PX);
|
|
813
|
+
|
|
814
|
+
const moveX = movement ? Number(movement.dx || 0) : 0;
|
|
815
|
+
const moveY = movement ? Number(movement.dy || 0) : 0;
|
|
816
|
+
let releaseReady = false;
|
|
817
|
+
|
|
818
|
+
if (atLeft) {
|
|
819
|
+
if (moveX < 0) {
|
|
820
|
+
edgeAccumulators.left = Math.min(edgeAccumulators.left + Math.abs(moveX), thresholdX);
|
|
821
|
+
if (edgeAccumulators.left >= thresholdX) {
|
|
822
|
+
releaseReady = true;
|
|
823
|
+
}
|
|
824
|
+
} else if (moveX > 0) {
|
|
825
|
+
edgeAccumulators.left = 0;
|
|
826
|
+
}
|
|
827
|
+
} else {
|
|
828
|
+
edgeAccumulators.left = 0;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (atRight) {
|
|
832
|
+
if (moveX > 0) {
|
|
833
|
+
edgeAccumulators.right = Math.min(edgeAccumulators.right + Math.abs(moveX), thresholdX);
|
|
834
|
+
if (edgeAccumulators.right >= thresholdX) {
|
|
835
|
+
releaseReady = true;
|
|
836
|
+
}
|
|
837
|
+
} else if (moveX < 0) {
|
|
838
|
+
edgeAccumulators.right = 0;
|
|
839
|
+
}
|
|
840
|
+
} else {
|
|
841
|
+
edgeAccumulators.right = 0;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (atTop) {
|
|
845
|
+
if (moveY < 0) {
|
|
846
|
+
edgeAccumulators.top = Math.min(edgeAccumulators.top + Math.abs(moveY), thresholdY);
|
|
847
|
+
if (edgeAccumulators.top >= thresholdY) {
|
|
848
|
+
releaseReady = true;
|
|
849
|
+
}
|
|
850
|
+
} else if (moveY > 0) {
|
|
851
|
+
edgeAccumulators.top = 0;
|
|
852
|
+
}
|
|
853
|
+
} else {
|
|
854
|
+
edgeAccumulators.top = 0;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (atBottom) {
|
|
858
|
+
if (moveY > 0) {
|
|
859
|
+
edgeAccumulators.bottom = Math.min(edgeAccumulators.bottom + Math.abs(moveY), thresholdY);
|
|
860
|
+
if (edgeAccumulators.bottom >= thresholdY) {
|
|
861
|
+
releaseReady = true;
|
|
862
|
+
}
|
|
863
|
+
} else if (moveY < 0) {
|
|
864
|
+
edgeAccumulators.bottom = 0;
|
|
865
|
+
}
|
|
866
|
+
} else {
|
|
867
|
+
edgeAccumulators.bottom = 0;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (!releaseReady) {
|
|
871
|
+
releaseReady =
|
|
872
|
+
(atLeft && edgeAccumulators.left >= thresholdX) ||
|
|
873
|
+
(atRight && edgeAccumulators.right >= thresholdX) ||
|
|
874
|
+
(atTop && edgeAccumulators.top >= thresholdY) ||
|
|
875
|
+
(atBottom && edgeAccumulators.bottom >= thresholdY);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (releaseReady) {
|
|
879
|
+
scheduleEdgeRelease();
|
|
880
|
+
} else {
|
|
881
|
+
cancelEdgeRelease();
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async function refreshState() {
|
|
886
|
+
try {
|
|
887
|
+
const res = await fetch("/api/mouse/state", { credentials: "same-origin" });
|
|
888
|
+
if (!res.ok) return;
|
|
889
|
+
const data = await res.json();
|
|
890
|
+
handleRemoteState(data.state, null);
|
|
891
|
+
} catch (err) {
|
|
892
|
+
console.warn("Failed to refresh pointer state", err);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function showControl(username) {
|
|
897
|
+
authenticated = true;
|
|
898
|
+
statusUser.textContent = username;
|
|
899
|
+
controlView.hidden = false;
|
|
900
|
+
loginView.hidden = true;
|
|
901
|
+
loginError.textContent = "";
|
|
902
|
+
refreshState();
|
|
903
|
+
logEvent("Auth", "Control view shown", { username });
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function showLogin() {
|
|
907
|
+
authenticated = false;
|
|
908
|
+
controlView.hidden = true;
|
|
909
|
+
loginView.hidden = false;
|
|
910
|
+
loginForm.reset();
|
|
911
|
+
if (realtimeInput) {
|
|
912
|
+
realtimeInput.value = "";
|
|
913
|
+
}
|
|
914
|
+
if (typeInput) {
|
|
915
|
+
typeInput.value = "";
|
|
916
|
+
}
|
|
917
|
+
loginError.textContent = "";
|
|
918
|
+
closeHelp();
|
|
919
|
+
if (isPointerLocked()) {
|
|
920
|
+
try {
|
|
921
|
+
document.exitPointerLock();
|
|
922
|
+
} catch (err) {
|
|
923
|
+
console.warn("Failed to exit pointer lock", err);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
releaseAllActiveKeys();
|
|
927
|
+
releaseAllModifiers();
|
|
928
|
+
logEvent("Auth", "Login view shown");
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function checkSession() {
|
|
932
|
+
try {
|
|
933
|
+
const res = await fetch("/api/session", { credentials: "same-origin" });
|
|
934
|
+
const data = await res.json();
|
|
935
|
+
logEvent("Auth", "Session check result", data);
|
|
936
|
+
if (data.authenticated) {
|
|
937
|
+
showControl(data.username);
|
|
938
|
+
} else {
|
|
939
|
+
showLogin();
|
|
940
|
+
}
|
|
941
|
+
} catch (err) {
|
|
942
|
+
console.error("Failed to check session", err);
|
|
943
|
+
showLogin();
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
loginForm.addEventListener("submit", async (event) => {
|
|
948
|
+
event.preventDefault();
|
|
949
|
+
const username = document.getElementById("username").value.trim();
|
|
950
|
+
const password = document.getElementById("password").value;
|
|
951
|
+
const remember = document.getElementById("remember").checked;
|
|
952
|
+
|
|
953
|
+
try {
|
|
954
|
+
logEvent("Auth", "Login attempt", { username, remember });
|
|
955
|
+
const data = await api("/api/login", { username, password, remember });
|
|
956
|
+
logEvent("Auth", "Login success", { username: data.username });
|
|
957
|
+
showControl(data.username);
|
|
958
|
+
} catch (err) {
|
|
959
|
+
loginError.textContent = err.message;
|
|
960
|
+
logEvent("Auth", "Login failed", { username, error: err.message });
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
logoutButton.addEventListener("click", async () => {
|
|
965
|
+
logEvent("Auth", "Logout requested");
|
|
966
|
+
try {
|
|
967
|
+
await api("/api/logout", {});
|
|
968
|
+
} catch (err) {
|
|
969
|
+
console.warn("Logout failed", err);
|
|
970
|
+
logEvent("Auth", "Logout request failed", { error: err.message });
|
|
971
|
+
} finally {
|
|
972
|
+
logEvent("Auth", "Logout complete");
|
|
973
|
+
showLogin();
|
|
974
|
+
}
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
clickButtons.forEach((button) => {
|
|
978
|
+
button.addEventListener("click", () => {
|
|
979
|
+
if (!authenticated) return;
|
|
980
|
+
const type = button.dataset.click;
|
|
981
|
+
const payload =
|
|
982
|
+
type === "double"
|
|
983
|
+
? { button: "left", double: true }
|
|
984
|
+
: { button: type };
|
|
985
|
+
logEvent("Mouse", "Manual click command issued", payload);
|
|
986
|
+
api("/api/mouse/click", payload).catch((err) =>
|
|
987
|
+
console.error("Click failed", err)
|
|
988
|
+
);
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
lockButton.addEventListener("click", () => {
|
|
993
|
+
if (!authenticated) return;
|
|
994
|
+
logEvent("System", "Lock command requested");
|
|
995
|
+
api("/api/system/lock").catch((err) => alert(err.message));
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
if (unlockButton) {
|
|
999
|
+
unlockButton.addEventListener("click", () => {
|
|
1000
|
+
if (!authenticated) return;
|
|
1001
|
+
logEvent("System", "Unlock/Wake command requested");
|
|
1002
|
+
api("/api/system/unlock").catch((err) => alert(err.message));
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
shutdownButton.addEventListener("click", () => {
|
|
1007
|
+
if (!authenticated) return;
|
|
1008
|
+
const confirmShutdown = confirm(
|
|
1009
|
+
"Shutdown the host computer immediately? Unsaved work will be lost."
|
|
1010
|
+
);
|
|
1011
|
+
if (!confirmShutdown) return;
|
|
1012
|
+
logEvent("System", "Shutdown command confirmed");
|
|
1013
|
+
api("/api/system/shutdown").catch((err) => alert(err.message));
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
// Trackpad handling -------------------------------------------------------
|
|
1017
|
+
let pointerActive = false;
|
|
1018
|
+
let lastPoint = { x: 0, y: 0 };
|
|
1019
|
+
let pendingDelta = { x: 0, y: 0 };
|
|
1020
|
+
let frameQueued = false;
|
|
1021
|
+
let tapCandidate = null;
|
|
1022
|
+
|
|
1023
|
+
function queueFlush() {
|
|
1024
|
+
if (frameQueued) return;
|
|
1025
|
+
frameQueued = true;
|
|
1026
|
+
requestAnimationFrame(flushMovement);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function flushMovement() {
|
|
1030
|
+
frameQueued = false;
|
|
1031
|
+
if (!authenticated) return;
|
|
1032
|
+
if (pendingDelta.x === 0 && pendingDelta.y === 0) return;
|
|
1033
|
+
const payload = { dx: pendingDelta.x, dy: pendingDelta.y };
|
|
1034
|
+
pendingDelta = { x: 0, y: 0 };
|
|
1035
|
+
api("/api/mouse/move", payload)
|
|
1036
|
+
.then((data) => {
|
|
1037
|
+
handleRemoteState(data.state, payload);
|
|
1038
|
+
})
|
|
1039
|
+
.catch((err) => console.error("Move failed", err));
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function pointerDown(event) {
|
|
1043
|
+
if (!authenticated) return;
|
|
1044
|
+
pointerActive = true;
|
|
1045
|
+
lastPoint = { x: event.clientX, y: event.clientY };
|
|
1046
|
+
if (event.pointerType === "touch") {
|
|
1047
|
+
trackpad.setPointerCapture(event.pointerId);
|
|
1048
|
+
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
1049
|
+
if (!touchSession) {
|
|
1050
|
+
touchSession = {
|
|
1051
|
+
startTime: performance.now(),
|
|
1052
|
+
maxPointers: touches.size,
|
|
1053
|
+
totalTravel: 0,
|
|
1054
|
+
lastPositions: new Map([[event.pointerId, { x: event.clientX, y: event.clientY }]]),
|
|
1055
|
+
};
|
|
1056
|
+
} else {
|
|
1057
|
+
touchSession.maxPointers = Math.max(touchSession.maxPointers, touches.size);
|
|
1058
|
+
touchSession.lastPositions.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
1059
|
+
}
|
|
1060
|
+
if (touches.size > 1) {
|
|
1061
|
+
tapCandidate = null;
|
|
1062
|
+
} else {
|
|
1063
|
+
tapCandidate = {
|
|
1064
|
+
startX: event.clientX,
|
|
1065
|
+
startY: event.clientY,
|
|
1066
|
+
time: performance.now(),
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
event.preventDefault();
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
if (event.pointerType === "mouse") {
|
|
1073
|
+
tapCandidate = null;
|
|
1074
|
+
const button = pointerButtonFromEvent(event);
|
|
1075
|
+
if (button) {
|
|
1076
|
+
heldPointerButtons.add(button);
|
|
1077
|
+
sendMouseButton(button, "down");
|
|
1078
|
+
}
|
|
1079
|
+
if (typeof trackpad.requestPointerLock === "function") {
|
|
1080
|
+
try {
|
|
1081
|
+
trackpad.requestPointerLock();
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
console.warn("Pointer lock request failed", err);
|
|
1084
|
+
}
|
|
1085
|
+
} else {
|
|
1086
|
+
trackpad.setPointerCapture(event.pointerId);
|
|
1087
|
+
}
|
|
1088
|
+
} else {
|
|
1089
|
+
tapCandidate = {
|
|
1090
|
+
startX: event.clientX,
|
|
1091
|
+
startY: event.clientY,
|
|
1092
|
+
time: performance.now(),
|
|
1093
|
+
};
|
|
1094
|
+
trackpad.setPointerCapture(event.pointerId);
|
|
1095
|
+
}
|
|
1096
|
+
event.preventDefault();
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function pointerMove(event) {
|
|
1100
|
+
if (!pointerActive) return;
|
|
1101
|
+
if (event.shiftKey && heldModifiers.has("shift")) {
|
|
1102
|
+
scheduleModifierTimer("shift");
|
|
1103
|
+
}
|
|
1104
|
+
if (event.ctrlKey && heldModifiers.has("ctrl")) {
|
|
1105
|
+
scheduleModifierTimer("ctrl");
|
|
1106
|
+
}
|
|
1107
|
+
if (event.altKey && heldModifiers.has("alt")) {
|
|
1108
|
+
scheduleModifierTimer("alt");
|
|
1109
|
+
}
|
|
1110
|
+
if (event.metaKey && heldModifiers.has("command")) {
|
|
1111
|
+
scheduleModifierTimer("command");
|
|
1112
|
+
}
|
|
1113
|
+
if (event.pointerType === "touch") {
|
|
1114
|
+
const current = touches.get(event.pointerId);
|
|
1115
|
+
if (!current) {
|
|
1116
|
+
event.preventDefault();
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
const dxRaw = event.clientX - current.x;
|
|
1120
|
+
const dyRaw = event.clientY - current.y;
|
|
1121
|
+
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
1122
|
+
if (touchSession) {
|
|
1123
|
+
touchSession.totalTravel += Math.abs(dxRaw) + Math.abs(dyRaw);
|
|
1124
|
+
touchSession.lastPositions.set(event.pointerId, {
|
|
1125
|
+
x: event.clientX,
|
|
1126
|
+
y: event.clientY,
|
|
1127
|
+
});
|
|
1128
|
+
touchSession.maxPointers = Math.max(touchSession.maxPointers, touches.size);
|
|
1129
|
+
}
|
|
1130
|
+
if (Math.abs(dxRaw) < 0.01 && Math.abs(dyRaw) < 0.01) {
|
|
1131
|
+
event.preventDefault();
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
if (touches.size >= 2) {
|
|
1135
|
+
const scrollScale = computeTouchScrollScale();
|
|
1136
|
+
pendingScroll.horizontal += dxRaw * scrollScale;
|
|
1137
|
+
pendingScroll.vertical += -dyRaw * scrollScale;
|
|
1138
|
+
queueScrollFlush();
|
|
1139
|
+
} else {
|
|
1140
|
+
const moveScale = computeTouchMoveScale();
|
|
1141
|
+
pendingDelta.x += dxRaw * moveScale;
|
|
1142
|
+
pendingDelta.y += dyRaw * moveScale;
|
|
1143
|
+
queueFlush();
|
|
1144
|
+
}
|
|
1145
|
+
event.preventDefault();
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
let dx = 0;
|
|
1149
|
+
let dy = 0;
|
|
1150
|
+
if (isPointerLocked()) {
|
|
1151
|
+
const sanitized = sanitizePointerLockDelta(
|
|
1152
|
+
event.movementX,
|
|
1153
|
+
event.movementY,
|
|
1154
|
+
);
|
|
1155
|
+
if (!sanitized) {
|
|
1156
|
+
event.preventDefault();
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
dx = sanitized.dx;
|
|
1160
|
+
dy = sanitized.dy;
|
|
1161
|
+
} else {
|
|
1162
|
+
dx = event.clientX - lastPoint.x;
|
|
1163
|
+
dy = event.clientY - lastPoint.y;
|
|
1164
|
+
lastPoint = { x: event.clientX, y: event.clientY };
|
|
1165
|
+
}
|
|
1166
|
+
if (dx === 0 && dy === 0) return;
|
|
1167
|
+
pendingDelta.x += dx;
|
|
1168
|
+
pendingDelta.y += dy;
|
|
1169
|
+
queueFlush();
|
|
1170
|
+
event.preventDefault();
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function pointerUp(event) {
|
|
1174
|
+
if (!pointerActive && event.pointerType !== "touch") return;
|
|
1175
|
+
let handledGesture = false;
|
|
1176
|
+
if (event.pointerType === "touch") {
|
|
1177
|
+
if (trackpad.hasPointerCapture && trackpad.hasPointerCapture(event.pointerId)) {
|
|
1178
|
+
trackpad.releasePointerCapture(event.pointerId);
|
|
1179
|
+
}
|
|
1180
|
+
touches.delete(event.pointerId);
|
|
1181
|
+
if (touchSession) {
|
|
1182
|
+
touchSession.lastPositions.delete(event.pointerId);
|
|
1183
|
+
}
|
|
1184
|
+
const remaining = touches.size;
|
|
1185
|
+
if (touchSession && remaining === 0) {
|
|
1186
|
+
const duration = performance.now() - touchSession.startTime;
|
|
1187
|
+
const travel = touchSession.totalTravel;
|
|
1188
|
+
const maxPointers = touchSession.maxPointers;
|
|
1189
|
+
touchSession = null;
|
|
1190
|
+
if (maxPointers === 2 && duration < MULTI_TAP_TIME_MS && travel < MULTI_TAP_TRAVEL_THRESHOLD) {
|
|
1191
|
+
api("/api/mouse/click", { button: "right" }).catch((err) =>
|
|
1192
|
+
console.error("Two-finger tap failed", err)
|
|
1193
|
+
);
|
|
1194
|
+
handledGesture = true;
|
|
1195
|
+
} else if (
|
|
1196
|
+
maxPointers === 3 &&
|
|
1197
|
+
duration < MULTI_TAP_TIME_MS &&
|
|
1198
|
+
travel < MULTI_TAP_TRAVEL_THRESHOLD
|
|
1199
|
+
) {
|
|
1200
|
+
api("/api/mouse/click", { button: "middle" }).catch((err) =>
|
|
1201
|
+
console.error("Three-finger tap failed", err)
|
|
1202
|
+
);
|
|
1203
|
+
handledGesture = true;
|
|
1204
|
+
}
|
|
1205
|
+
} else if (touchSession) {
|
|
1206
|
+
touchSession.maxPointers = Math.max(touchSession.maxPointers, remaining);
|
|
1207
|
+
}
|
|
1208
|
+
if (remaining > 0) {
|
|
1209
|
+
pointerActive = true;
|
|
1210
|
+
tapCandidate = null;
|
|
1211
|
+
event.preventDefault();
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
if (!isPointerLocked()) {
|
|
1216
|
+
pointerActive = event.pointerType === "touch" ? touches.size > 0 : false;
|
|
1217
|
+
if (trackpad.hasPointerCapture && trackpad.hasPointerCapture(event.pointerId)) {
|
|
1218
|
+
trackpad.releasePointerCapture(event.pointerId);
|
|
1219
|
+
}
|
|
1220
|
+
if (tapCandidate && !handledGesture) {
|
|
1221
|
+
const dt = performance.now() - tapCandidate.time;
|
|
1222
|
+
const dist =
|
|
1223
|
+
Math.abs(event.clientX - tapCandidate.startX) +
|
|
1224
|
+
Math.abs(event.clientY - tapCandidate.startY);
|
|
1225
|
+
if (dt < 220 && dist < 20) {
|
|
1226
|
+
api("/api/mouse/click", { button: "left" }).catch((err) =>
|
|
1227
|
+
console.error("Tap failed", err)
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
if (event.pointerType !== "touch" || touches.size === 0) {
|
|
1232
|
+
releaseAllActiveKeys();
|
|
1233
|
+
releaseAllModifiers();
|
|
1234
|
+
releaseAllPointerButtons();
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
if (event.pointerType === "mouse") {
|
|
1238
|
+
const button = pointerButtonFromEvent(event);
|
|
1239
|
+
if (button) {
|
|
1240
|
+
if (heldPointerButtons.has(button)) {
|
|
1241
|
+
heldPointerButtons.delete(button);
|
|
1242
|
+
}
|
|
1243
|
+
sendMouseButton(button, "up");
|
|
1244
|
+
}
|
|
1245
|
+
tapCandidate = null;
|
|
1246
|
+
event.preventDefault();
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
tapCandidate = null;
|
|
1250
|
+
event.preventDefault();
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
trackpad.addEventListener(
|
|
1254
|
+
"wheel",
|
|
1255
|
+
(event) => {
|
|
1256
|
+
if (!authenticated) return;
|
|
1257
|
+
event.preventDefault();
|
|
1258
|
+
const horizontal = event.deltaX;
|
|
1259
|
+
const vertical = -event.deltaY;
|
|
1260
|
+
if (!horizontal && !vertical) return;
|
|
1261
|
+
api("/api/mouse/scroll", { horizontal, vertical }).catch((err) =>
|
|
1262
|
+
console.error("Scroll failed", err),
|
|
1263
|
+
);
|
|
1264
|
+
},
|
|
1265
|
+
{ passive: false },
|
|
1266
|
+
);
|
|
1267
|
+
|
|
1268
|
+
trackpad.addEventListener("pointerdown", pointerDown);
|
|
1269
|
+
trackpad.addEventListener("pointermove", pointerMove);
|
|
1270
|
+
trackpad.addEventListener("pointerup", pointerUp);
|
|
1271
|
+
trackpad.addEventListener("pointercancel", pointerUp);
|
|
1272
|
+
trackpad.addEventListener("contextmenu", (event) => {
|
|
1273
|
+
event.preventDefault();
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
document.addEventListener("pointerlockchange", () => {
|
|
1277
|
+
if (isPointerLocked()) {
|
|
1278
|
+
pointerActive = true;
|
|
1279
|
+
resetEdgeTracking();
|
|
1280
|
+
lastDirectionSample = null;
|
|
1281
|
+
lastSanitizedVector = null;
|
|
1282
|
+
lastSanitizedAt = 0;
|
|
1283
|
+
const activeElement = document.activeElement;
|
|
1284
|
+
if (
|
|
1285
|
+
activeElement &&
|
|
1286
|
+
typeof activeElement.blur === "function" &&
|
|
1287
|
+
activeElement !== document.body &&
|
|
1288
|
+
activeElement !== trackpad
|
|
1289
|
+
) {
|
|
1290
|
+
activeElement.blur();
|
|
1291
|
+
logEvent("Sync", "Cleared active element focus for pointer lock", {
|
|
1292
|
+
blurred: activeElement.id ? `#${activeElement.id}` : activeElement.tagName,
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
refreshState();
|
|
1296
|
+
activeKeys.clear();
|
|
1297
|
+
releaseAllModifiers();
|
|
1298
|
+
setPointerSyncState(true);
|
|
1299
|
+
logEvent("Sync", "Pointer lock acquired");
|
|
1300
|
+
pushDeviceClipboardToHost().catch((err) =>
|
|
1301
|
+
console.warn("Clipboard push on sync entry failed", err)
|
|
1302
|
+
);
|
|
1303
|
+
} else {
|
|
1304
|
+
pointerActive = false;
|
|
1305
|
+
pendingDelta = { x: 0, y: 0 };
|
|
1306
|
+
lastPoint = { x: 0, y: 0 };
|
|
1307
|
+
resetEdgeTracking();
|
|
1308
|
+
lastDirectionSample = null;
|
|
1309
|
+
lastSanitizedVector = null;
|
|
1310
|
+
lastSanitizedAt = 0;
|
|
1311
|
+
releaseAllPointerButtons();
|
|
1312
|
+
releaseAllActiveKeys();
|
|
1313
|
+
releaseAllModifiers();
|
|
1314
|
+
setPointerSyncState(false);
|
|
1315
|
+
logEvent("Sync", "Pointer lock released");
|
|
1316
|
+
pullClipboardFromHost(true).catch((err) =>
|
|
1317
|
+
console.warn("Clipboard pull on sync exit failed", err)
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
document.addEventListener("pointerlockerror", (event) => {
|
|
1323
|
+
console.warn("Pointer lock error", event);
|
|
1324
|
+
pointerActive = false;
|
|
1325
|
+
lastDirectionSample = null;
|
|
1326
|
+
lastSanitizedVector = null;
|
|
1327
|
+
lastSanitizedAt = 0;
|
|
1328
|
+
releaseAllPointerButtons();
|
|
1329
|
+
releaseAllActiveKeys();
|
|
1330
|
+
releaseAllModifiers();
|
|
1331
|
+
setPointerSyncState(false);
|
|
1332
|
+
logEvent("Sync", "Pointer lock error", { error: event?.name || "unknown" });
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
window.addEventListener("blur", () => {
|
|
1336
|
+
if (!authenticated) return;
|
|
1337
|
+
releaseAllActiveKeys();
|
|
1338
|
+
releaseAllModifiers();
|
|
1339
|
+
releaseAllPointerButtons();
|
|
1340
|
+
logEvent("Sync", "Window blurred; releasing pointer lock if active");
|
|
1341
|
+
if (isPointerLocked()) {
|
|
1342
|
+
try {
|
|
1343
|
+
document.exitPointerLock();
|
|
1344
|
+
} catch (err) {
|
|
1345
|
+
console.warn("Failed to exit pointer lock on blur", err);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
document.addEventListener("visibilitychange", () => {
|
|
1351
|
+
if (!authenticated || !document.hidden) return;
|
|
1352
|
+
releaseAllActiveKeys();
|
|
1353
|
+
releaseAllModifiers();
|
|
1354
|
+
releaseAllPointerButtons();
|
|
1355
|
+
logEvent("Sync", "Document hidden; exiting pointer lock if active");
|
|
1356
|
+
if (isPointerLocked()) {
|
|
1357
|
+
try {
|
|
1358
|
+
document.exitPointerLock();
|
|
1359
|
+
} catch (err) {
|
|
1360
|
+
console.warn("Failed to exit pointer lock on hide", err);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
setPointerSyncState(false);
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
// Keyboard handling -------------------------------------------------------
|
|
1367
|
+
if (realtimeInput) {
|
|
1368
|
+
realtimeInput.addEventListener("input", (event) => {
|
|
1369
|
+
if (!authenticated) {
|
|
1370
|
+
event.target.value = "";
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
const inputType = event.inputType;
|
|
1374
|
+
const text = event.target.value;
|
|
1375
|
+
logEvent("Keyboard", "Realtime input event", {
|
|
1376
|
+
inputType,
|
|
1377
|
+
text: summarizeTextSample(text),
|
|
1378
|
+
});
|
|
1379
|
+
if (inputType === "deleteContentBackward") {
|
|
1380
|
+
api("/api/keyboard/key", { key: "backspace", action: "press" }).catch(
|
|
1381
|
+
(err) => console.error("Realtime backspace failed", err),
|
|
1382
|
+
);
|
|
1383
|
+
} else if (inputType === "deleteContentForward") {
|
|
1384
|
+
api("/api/keyboard/key", { key: "delete", action: "press" }).catch(
|
|
1385
|
+
(err) => console.error("Realtime delete failed", err),
|
|
1386
|
+
);
|
|
1387
|
+
} else if (inputType === "insertLineBreak") {
|
|
1388
|
+
api("/api/keyboard/key", { key: "enter", action: "press" }).catch(
|
|
1389
|
+
(err) => console.error("Realtime enter failed", err),
|
|
1390
|
+
);
|
|
1391
|
+
} else if (text) {
|
|
1392
|
+
api("/api/keyboard/type", { text }).catch((err) =>
|
|
1393
|
+
console.error("Realtime type failed", err),
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
event.target.value = "";
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
realtimeInput.addEventListener("compositionend", (event) => {
|
|
1400
|
+
if (!authenticated) {
|
|
1401
|
+
realtimeInput.value = "";
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
const data = event.data || realtimeInput.value;
|
|
1405
|
+
logEvent("Keyboard", "Composition end", {
|
|
1406
|
+
data: summarizeTextSample(data || ""),
|
|
1407
|
+
});
|
|
1408
|
+
if (data) {
|
|
1409
|
+
api("/api/keyboard/type", { text: data }).catch((err) =>
|
|
1410
|
+
console.error("Realtime composition failed", err),
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
realtimeInput.value = "";
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
realtimeInput.addEventListener("keydown", (event) => {
|
|
1417
|
+
if (!authenticated) return;
|
|
1418
|
+
logEvent("Keyboard", "Realtime keydown", {
|
|
1419
|
+
key: event.key,
|
|
1420
|
+
repeat: event.repeat,
|
|
1421
|
+
ctrl: event.ctrlKey,
|
|
1422
|
+
meta: event.metaKey,
|
|
1423
|
+
alt: event.altKey,
|
|
1424
|
+
});
|
|
1425
|
+
if (modifierKeys.has(event.key)) {
|
|
1426
|
+
const mapped = modifierKeys.get(event.key);
|
|
1427
|
+
modifierDown(mapped);
|
|
1428
|
+
event.preventDefault();
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
if (specialKeys.has(event.key)) {
|
|
1433
|
+
const key = specialKeys.get(event.key);
|
|
1434
|
+
api("/api/keyboard/key", { key, action: "press" }).catch((err) =>
|
|
1435
|
+
console.error("Realtime special key failed", err),
|
|
1436
|
+
);
|
|
1437
|
+
event.preventDefault();
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
const normalized = normalizeKeyForAction(event.key);
|
|
1442
|
+
const usingCombo =
|
|
1443
|
+
normalized && (event.ctrlKey || event.metaKey || event.altKey);
|
|
1444
|
+
if (usingCombo) {
|
|
1445
|
+
if (!handleClipboardCombo(normalized, event)) {
|
|
1446
|
+
if (!event.repeat) {
|
|
1447
|
+
sendComboPress(normalized);
|
|
1448
|
+
}
|
|
1449
|
+
event.preventDefault();
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
realtimeInput.addEventListener("keyup", (event) => {
|
|
1455
|
+
if (!authenticated) return;
|
|
1456
|
+
logEvent("Keyboard", "Realtime keyup", { key: event.key });
|
|
1457
|
+
if (modifierKeys.has(event.key)) {
|
|
1458
|
+
const mapped = modifierKeys.get(event.key);
|
|
1459
|
+
modifierUp(mapped);
|
|
1460
|
+
event.preventDefault();
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
realtimeInput.addEventListener("blur", () => {
|
|
1467
|
+
if (!authenticated) return;
|
|
1468
|
+
releaseAllActiveKeys();
|
|
1469
|
+
releaseAllModifiers();
|
|
1470
|
+
logEvent("Keyboard", "Realtime input blurred; modifiers released");
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
if (typeForm && typeInput) {
|
|
1475
|
+
typeForm.addEventListener("submit", (event) => {
|
|
1476
|
+
event.preventDefault();
|
|
1477
|
+
if (!authenticated) return;
|
|
1478
|
+
const text = typeInput.value;
|
|
1479
|
+
if (!text) return;
|
|
1480
|
+
logEvent("Keyboard", "Bulk text send requested", {
|
|
1481
|
+
length: text.length,
|
|
1482
|
+
preview: summarizeTextSample(text),
|
|
1483
|
+
});
|
|
1484
|
+
api("/api/keyboard/type", { text })
|
|
1485
|
+
.then(() => {
|
|
1486
|
+
typeInput.value = "";
|
|
1487
|
+
typeInput.focus();
|
|
1488
|
+
})
|
|
1489
|
+
.catch((err) => console.error("Type failed", err));
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
typeInput.addEventListener("keydown", (event) => {
|
|
1493
|
+
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
1494
|
+
event.preventDefault();
|
|
1495
|
+
logEvent("Keyboard", "Bulk text submit shortcut pressed");
|
|
1496
|
+
typeForm.requestSubmit();
|
|
1497
|
+
}
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
if (clipboardPullButton) {
|
|
1502
|
+
clipboardPullButton.addEventListener("click", () => {
|
|
1503
|
+
if (!authenticated) return;
|
|
1504
|
+
logEvent("Clipboard", "Manual host clipboard pull requested");
|
|
1505
|
+
pullClipboardFromHost(true);
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
if (clipboardPushButton) {
|
|
1510
|
+
clipboardPushButton.addEventListener("click", () => {
|
|
1511
|
+
if (!authenticated) return;
|
|
1512
|
+
logEvent("Clipboard", "Manual device clipboard push requested");
|
|
1513
|
+
pushDeviceClipboardToHost();
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
if (helpButton && helpOverlay) {
|
|
1518
|
+
helpButton.addEventListener("click", () => {
|
|
1519
|
+
if (helpVisible) {
|
|
1520
|
+
closeHelp();
|
|
1521
|
+
} else {
|
|
1522
|
+
openHelp();
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
if (helpClose) {
|
|
1528
|
+
helpClose.addEventListener("click", closeHelp);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
if (helpOverlay) {
|
|
1532
|
+
helpOverlay.addEventListener("click", (event) => {
|
|
1533
|
+
if (event.target === helpOverlay) {
|
|
1534
|
+
closeHelp();
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
document.addEventListener("keydown", (event) => {
|
|
1540
|
+
if (!authenticated) return;
|
|
1541
|
+
logEvent("Keyboard", "Document keydown", {
|
|
1542
|
+
key: event.key,
|
|
1543
|
+
repeat: event.repeat,
|
|
1544
|
+
ctrl: event.ctrlKey,
|
|
1545
|
+
meta: event.metaKey,
|
|
1546
|
+
alt: event.altKey,
|
|
1547
|
+
target: event.target && event.target.id ? `#${event.target.id}` : event.target?.tagName,
|
|
1548
|
+
});
|
|
1549
|
+
if (helpVisible) {
|
|
1550
|
+
if (event.key === "Escape") {
|
|
1551
|
+
event.preventDefault();
|
|
1552
|
+
closeHelp();
|
|
1553
|
+
}
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
if (typeInput && event.target === typeInput) {
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
if (realtimeInput && event.target === realtimeInput) {
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
if (modifierKeys.has(event.key)) {
|
|
1564
|
+
const mapped = modifierKeys.get(event.key);
|
|
1565
|
+
modifierDown(mapped);
|
|
1566
|
+
event.preventDefault();
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
if (specialKeys.has(event.key)) {
|
|
1571
|
+
const key = specialKeys.get(event.key);
|
|
1572
|
+
api("/api/keyboard/key", { key, action: "press" }).catch((err) =>
|
|
1573
|
+
console.error("Special key failed", err)
|
|
1574
|
+
);
|
|
1575
|
+
event.preventDefault();
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
const normalized = normalizeKeyForAction(event.key);
|
|
1580
|
+
const usingCombo =
|
|
1581
|
+
normalized && (event.ctrlKey || event.metaKey || event.altKey);
|
|
1582
|
+
if (usingCombo) {
|
|
1583
|
+
if (!handleClipboardCombo(normalized, event)) {
|
|
1584
|
+
if (!event.repeat) {
|
|
1585
|
+
sendComboPress(normalized);
|
|
1586
|
+
}
|
|
1587
|
+
event.preventDefault();
|
|
1588
|
+
}
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
if (isPointerLocked()) {
|
|
1593
|
+
if (normalized) {
|
|
1594
|
+
if (!event.repeat && !activeKeys.has(normalized)) {
|
|
1595
|
+
activeKeys.add(normalized);
|
|
1596
|
+
api("/api/keyboard/key", { key: normalized, action: "down" }).catch((err) =>
|
|
1597
|
+
console.error("Key down failed", err)
|
|
1598
|
+
);
|
|
1599
|
+
}
|
|
1600
|
+
event.preventDefault();
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
if (
|
|
1605
|
+
event.key &&
|
|
1606
|
+
event.key.length === 1 &&
|
|
1607
|
+
!event.ctrlKey &&
|
|
1608
|
+
!event.metaKey &&
|
|
1609
|
+
!event.altKey &&
|
|
1610
|
+
!event.isComposing
|
|
1611
|
+
) {
|
|
1612
|
+
api("/api/keyboard/type", { text: event.key }).catch((err) =>
|
|
1613
|
+
console.error("Pointer lock typing failed", err)
|
|
1614
|
+
);
|
|
1615
|
+
event.preventDefault();
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
document.addEventListener("keyup", (event) => {
|
|
1621
|
+
if (!authenticated) return;
|
|
1622
|
+
logEvent("Keyboard", "Document keyup", {
|
|
1623
|
+
key: event.key,
|
|
1624
|
+
target: event.target && event.target.id ? `#${event.target.id}` : event.target?.tagName,
|
|
1625
|
+
});
|
|
1626
|
+
if (helpVisible) {
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
if (typeInput && event.target === typeInput) {
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
if (realtimeInput && event.target === realtimeInput) {
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
if (modifierKeys.has(event.key)) {
|
|
1637
|
+
const mapped = modifierKeys.get(event.key);
|
|
1638
|
+
modifierUp(mapped);
|
|
1639
|
+
event.preventDefault();
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
const normalized = normalizeKeyForAction(event.key);
|
|
1644
|
+
if (normalized && activeKeys.has(normalized)) {
|
|
1645
|
+
activeKeys.delete(normalized);
|
|
1646
|
+
api("/api/keyboard/key", { key: normalized, action: "up" }).catch((err) =>
|
|
1647
|
+
console.error("Key up failed", err)
|
|
1648
|
+
);
|
|
1649
|
+
event.preventDefault();
|
|
1650
|
+
}
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
if (clipboardStatus) {
|
|
1654
|
+
setClipboardStatus("");
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
checkSession();
|
|
1658
|
+
})();
|