@aiti-ai/grdpwasm 0.0.0-stage → 0.2.1

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.
@@ -0,0 +1,894 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <link rel="icon" href="data:,">
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>grdpwasm — Web RDP Client</title>
8
+ <style>
9
+ * { box-sizing: border-box; margin: 0; padding: 0; }
10
+ body { background: #1a1a2e; color: #eee; font-family: sans-serif; display: flex; flex-direction: column; height: 100vh; }
11
+ /* native-keyboard hidden input: inside the toolbar, absolutely positioned offscreen */
12
+ #nativeKbInput {
13
+ position: fixed;
14
+ left: -9999px;
15
+ top: 0;
16
+ width: 1px;
17
+ height: 1px;
18
+ opacity: 0;
19
+ pointer-events: none;
20
+ }
21
+
22
+ #toolbar {
23
+ background: #16213e;
24
+ padding: 8px 12px;
25
+ display: flex;
26
+ flex-wrap: wrap;
27
+ gap: 6px;
28
+ align-items: center;
29
+ border-bottom: 1px solid #0f3460;
30
+ }
31
+ #toolbar label { font-size: 12px; color: #aaa; }
32
+ #toolbar input {
33
+ background: #0f3460;
34
+ border: 1px solid #444;
35
+ color: #eee;
36
+ padding: 4px 6px;
37
+ border-radius: 4px;
38
+ font-size: 13px;
39
+ }
40
+ #toolbar input[type=number] { width: 70px; }
41
+ #toolbar input[type=text], #toolbar input[type=password] { width: 130px; }
42
+ #connectBtn {
43
+ background: #e94560;
44
+ color: white;
45
+ border: none;
46
+ padding: 5px 14px;
47
+ border-radius: 4px;
48
+ cursor: pointer;
49
+ font-size: 13px;
50
+ font-weight: bold;
51
+ }
52
+ #connectBtn:hover { background: #c73652; }
53
+ #connectBtn.connected { background: #2ecc71; }
54
+ #connectBtn.connected:hover { background: #27ae60; }
55
+ #status { font-size: 12px; color: #aaa; margin-left: 8px; }
56
+
57
+ /* fixedTarget mode: hide host/port (target comes from the server / URL param),
58
+ the user only fills in credentials. standalone mode (no fixedTarget) shows all fields. */
59
+ html.fixedTarget #hostField, html.fixedTarget #portField { display: none; }
60
+
61
+ #canvasWrapper {
62
+ flex: 1;
63
+ overflow: auto;
64
+ display: flex;
65
+ justify-content: center;
66
+ align-items: flex-start;
67
+ background: #000;
68
+ }
69
+ #rdpCanvas {
70
+ display: block;
71
+ cursor: none;
72
+ outline: none;
73
+ /* small screens: scale the canvas to the container width (aspect ratio kept) so a
74
+ phone sees the whole desktop; pinch-zoom still zooms in. Mouse coords are
75
+ rescaled accordingly. */
76
+ max-width: 100%;
77
+ height: auto;
78
+ }
79
+
80
+ /* ── virtual keyboard (small screens / touch devices) ────────────────────────── */
81
+ #vkbToggle {
82
+ background: #0f3460;
83
+ color: #eee;
84
+ border: 1px solid #444;
85
+ padding: 5px 10px;
86
+ border-radius: 4px;
87
+ cursor: pointer;
88
+ font-size: 14px;
89
+ }
90
+ #vkbToggle:hover { background: #1b4a7e; }
91
+ #vkbToggle.on { background: #2ecc71; color: #111; }
92
+ /* ── shortcut bar (holding a chip presses the combo in order; release fires in reverse) ── */
93
+ #scbToggle {
94
+ background: #0f3460;
95
+ color: #eee;
96
+ border: 1px solid #444;
97
+ padding: 5px 10px;
98
+ border-radius: 4px;
99
+ cursor: pointer;
100
+ font-size: 14px;
101
+ }
102
+ #scbToggle:hover { background: #1b4a7e; }
103
+ #scbToggle.on { background: #e94560; color: #fff; }
104
+ #shortcuts {
105
+ display: flex;
106
+ flex-wrap: wrap;
107
+ gap: 4px;
108
+ padding: 5px 12px;
109
+ background: #16213e;
110
+ border-bottom: 1px solid #0f3460;
111
+ }
112
+ #shortcuts[hidden] { display: none; }
113
+ .scb-chip {
114
+ background: #0f3460;
115
+ color: #cfd8e3;
116
+ border: 1px solid #2c4a77;
117
+ border-radius: 14px;
118
+ padding: 3px 10px;
119
+ font-size: 11px;
120
+ cursor: pointer;
121
+ touch-action: none;
122
+ -webkit-user-select: none;
123
+ user-select: none;
124
+ }
125
+ .scb-chip.scb-on { background: #e94560; color: #fff; border-color: #ff8fa3; }
126
+ #sysKbBtn { background: #0f3460; color: #eee; border: 1px solid #444; padding: 5px 10px; border-radius: 4px; cursor: pointer; font-size: 14px; }
127
+ #sysKbBtn:hover { background: #1b4a7e; }
128
+ #sysKbBtn.on { background: #3498db; color: #111; }
129
+ #vkbRoot {
130
+ position: fixed;
131
+ left: 0;
132
+ right: 0;
133
+ bottom: 0;
134
+ z-index: 60;
135
+ padding: 4px 6px;
136
+ /* the virtual keyboard's own dark background (rgba); no extra overlay beneath */
137
+ }
138
+ body.vkb-open #canvasWrapper {
139
+ padding-bottom: var(--vkb-hpx, 0px);
140
+ }
141
+ </style>
142
+ <link rel="stylesheet" href="vendor/simple-keyboard.css" />
143
+ <link rel="stylesheet" href="virtual-keyboard.css" />
144
+ </head>
145
+ <body>
146
+ <script>
147
+ // ── fixedTarget mode ──────────────────────────────────────────────
148
+ // The target address is controlled by the server (rdp-bridge injects __rdpTarget);
149
+ // the front end cannot tamper with it. FixedTarget mode (host/port hidden) is
150
+ // triggered when either:
151
+ // 1. __rdpTarget exists (injected by rdp-bridge)
152
+ // 2. URL param ?fixedTarget=true (explicit)
153
+ // Neither present = standalone mode: host/port fields shown for free connection.
154
+ (function () {
155
+ var isFixed = !!(window.__rdpTarget && window.__rdpTarget.host)
156
+ || /[?&]fixedTarget=true/.test(location.search);
157
+ if (isFixed) document.documentElement.classList.add("fixedTarget");
158
+ })();
159
+ </script>
160
+ <div id="toolbar">
161
+ <span id="connFields">
162
+ <label>Domain</label>
163
+ <input type="text" id="domain" value="" placeholder="(optional)" />
164
+ <label>User</label>
165
+ <input type="text" id="user" value="" />
166
+ <label>Password</label>
167
+ <input type="password" id="password" value="" />
168
+ </span>
169
+ <label>Width</label>
170
+ <input type="number" id="width" value="1280" />
171
+ <label>Height</label>
172
+ <input type="number" id="height" value="800" />
173
+ <label style="display:flex;align-items:center;gap:4px;cursor:pointer">
174
+ <input type="checkbox" id="swapAltMeta" />
175
+ Swap Alt/Meta
176
+ </label>
177
+ <!-- xrdp compat is fully automatic now: detected via the server bitmap-codecs capability set -->
178
+ <button id="connectBtn" onclick="toggleConnect()">Connect</button>
179
+ <button id="scbToggle" title="Shortcut bar (F1–F12 / Alt+Tab / Ctrl+Alt+T / ⊞ Win / ⇧+F10 …)">⚡</button>
180
+ <button id="vkbToggle" title="Virtual keyboard (touch / small screens)">⌨️</button>
181
+ <button id="sysKbBtn" title="System keyboard (native)">⌨️Sys</button>
182
+ <button id="unlockBtn" title="Unlock: send Ctrl+Alt+Del → auto-type password → Enter (click this when the lock screen shows)" style="display:none">🔓</button>
183
+ <button id="redrawBtn" title="Force a full server redraw (heals missing patches / blank regions)" style="display:none">🔄</button>
184
+ <span id="status">Loading WASM…</span>
185
+ </div>
186
+ <div id="shortcuts" hidden></div>
187
+ <div id="vkbRoot" class="vkb-hidden-root"></div>
188
+ <input id="nativeKbInput" type="text" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
189
+ <div id="canvasWrapper">
190
+ <canvas id="rdpCanvas" tabindex="0"></canvas>
191
+ </div>
192
+
193
+ <script src="wasm_exec.js"></script>
194
+ <script src="vendor/justget.browser.js"></script>
195
+ <script>
196
+ const go = new Go();
197
+ let connected = false;
198
+
199
+ // ── Cookie helpers ────────────────────────────────────────────────────────
200
+ function setCookie(name, value) {
201
+ document.cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value) +
202
+ '; max-age=31536000; SameSite=Strict';
203
+ }
204
+ function getCookie(name) {
205
+ const key = encodeURIComponent(name) + '=';
206
+ for (const part of document.cookie.split(';')) {
207
+ const s = part.trim();
208
+ if (s.startsWith(key)) return decodeURIComponent(s.slice(key.length));
209
+ }
210
+ return null;
211
+ }
212
+ function loadCookies() {
213
+ const fields = ['host', 'port', 'domain', 'width', 'height'];
214
+ for (const id of fields) {
215
+ const v = getCookie('rdp_' + id);
216
+ if (v !== null) document.getElementById(id).value = v;
217
+ }
218
+ const sam = getCookie('rdp_swapAltMeta');
219
+ if (sam !== null) document.getElementById('swapAltMeta').checked = (sam === '1');
220
+ }
221
+ function saveCookies() {
222
+ for (const id of ['domain', 'width', 'height']) {
223
+ const el = document.getElementById(id);
224
+ if (el) setCookie('rdp_' + id, el.value.trim());
225
+ }
226
+ const sm = document.getElementById('swapAltMeta');
227
+ if (sm) setCookie('rdp_swapAltMeta', sm.checked ? '1' : '0');
228
+ }
229
+ loadCookies();
230
+
231
+ // ── Web Audio PCM scheduler ───────────────────────────────────────────────
232
+ // AudioContext is created in doConnect() while a user gesture is active,
233
+ // so it starts in 'running' state and avoids browser autoplay suspension.
234
+ // Web Audio automatically resamples AudioBuffers to the context's rate,
235
+ // so we never need to recreate the context when the server changes format.
236
+ let audioCtx = null;
237
+ let audioNextAt = 0; // next scheduled start time (AudioContext seconds)
238
+ const AUDIO_LOOKAHEAD = 0.15; // 150 ms look-ahead to absorb WASM jitter
239
+ const AUDIO_MAX_QUEUE = 2.0; // reset schedule if >2 s ahead (prevents runaway)
240
+
241
+ function rdpAudioPlay(sampleRate, channels, bitsPerSample, uint8Data) {
242
+ if (!audioCtx || audioCtx.state === 'closed') return;
243
+
244
+ const bytesPerSample = bitsPerSample >> 3;
245
+ const numSamples = uint8Data.byteLength / (channels * bytesPerSample);
246
+ if (numSamples === 0) return;
247
+
248
+ // Create AudioBuffer at the server's sample rate.
249
+ // Web Audio resamples automatically to the AudioContext rate.
250
+ const audioBuf = audioCtx.createBuffer(channels, numSamples, sampleRate);
251
+
252
+ // Fast native Int16 → Float32 deinterleave using TypedArray view.
253
+ if (bitsPerSample === 16) {
254
+ const int16 = new Int16Array(
255
+ uint8Data.buffer, uint8Data.byteOffset, uint8Data.byteLength >> 1
256
+ );
257
+ for (let ch = 0; ch < channels; ch++) {
258
+ const out = audioBuf.getChannelData(ch);
259
+ for (let i = 0; i < numSamples; i++) {
260
+ out[i] = int16[i * channels + ch] / 32768.0;
261
+ }
262
+ }
263
+ } else if (bitsPerSample === 8) {
264
+ for (let ch = 0; ch < channels; ch++) {
265
+ const out = audioBuf.getChannelData(ch);
266
+ for (let i = 0; i < numSamples; i++) {
267
+ out[i] = (uint8Data[i * channels + ch] - 128) / 128.0;
268
+ }
269
+ }
270
+ }
271
+
272
+ const src = audioCtx.createBufferSource();
273
+ src.buffer = audioBuf;
274
+ src.connect(audioCtx.destination);
275
+
276
+ const now = audioCtx.currentTime;
277
+ if (audioNextAt < now + AUDIO_LOOKAHEAD) audioNextAt = now + AUDIO_LOOKAHEAD;
278
+ if (audioNextAt > now + AUDIO_MAX_QUEUE) audioNextAt = now + AUDIO_LOOKAHEAD;
279
+
280
+ src.start(audioNextAt);
281
+ // Use actual audioBuf duration (accounts for resampling rounding).
282
+ audioNextAt += audioBuf.duration;
283
+ }
284
+
285
+ // ── WebCodecs H.264 decoder ───────────────────────────────────────────────
286
+ // h264Dec is created fresh on each RDP connection and torn down on
287
+ // disconnect. Frames are drawn directly onto rdpCanvas via drawImage().
288
+ let h264Dec = null;
289
+ let h264Timestamp = 0; // monotonically increasing μs counter
290
+
291
+ // Pending frame position keyed by timestamp (μs).
292
+ const h264FramePos = new Map();
293
+
294
+ function h264Supported() {
295
+ return (typeof VideoDecoder !== 'undefined') &&
296
+ (typeof EncodedVideoChunk !== 'undefined');
297
+ }
298
+
299
+ function createH264Decoder(width, height) {
300
+ if (!h264Supported()) return null;
301
+ const decoder = new VideoDecoder({
302
+ output(frame) {
303
+ const pos = h264FramePos.get(frame.timestamp);
304
+ h264FramePos.delete(frame.timestamp);
305
+ const x = pos ? pos.x : 0;
306
+ const y = pos ? pos.y : 0;
307
+ ctx2d.drawImage(frame, x, y);
308
+ frame.close();
309
+ },
310
+ error(e) {
311
+ console.warn('WebCodecs H.264 decoder error:', e);
312
+ }
313
+ });
314
+ decoder.configure({
315
+ codec: 'avc1.42E01E', // H.264 Baseline (covers AVC420/444 from RDP)
316
+ codedWidth: width,
317
+ codedHeight: height,
318
+ optimizeForLatency: true,
319
+ });
320
+ return decoder;
321
+ }
322
+
323
+ // Called from Go/WASM with raw H.264 NAL unit data.
324
+ function rdpOnH264(destX, destY, w, h, isKey, uint8Data) {
325
+ if (!h264Dec || h264Dec.state === 'closed') return;
326
+ h264Timestamp += 1000; // 1 ms apart (decoder uses μs)
327
+ h264FramePos.set(h264Timestamp, { x: destX, y: destY });
328
+ try {
329
+ h264Dec.decode(new EncodedVideoChunk({
330
+ type: isKey ? 'key' : 'delta',
331
+ timestamp: h264Timestamp,
332
+ data: uint8Data,
333
+ }));
334
+ } catch (e) {
335
+ console.warn('rdpOnH264 decode error:', e);
336
+ }
337
+ }
338
+
339
+ // ── Canvas / ctx2d ────────────────────────────────────────────────────────
340
+ const canvas = document.getElementById('rdpCanvas');
341
+ let ctx2d = canvas.getContext('2d');
342
+
343
+ // ── Server-side mouse pointer ─────────────────────────────────────────────
344
+ // The RDP server sends pointer shapes (with hotspots) and cache references.
345
+ // We rasterise each shape to a PNG data URL once and apply it as the
346
+ // canvas's CSS cursor; the browser composites it natively at the local
347
+ // mouse position. Pointer shapes are cached by server-assigned index;
348
+ // pointer_cached PDUs just re-select an already-rendered shape.
349
+ const pointerCache = new Map(); // idx → CSS cursor value
350
+
351
+ function rdpOnPointerHide() {
352
+ canvas.style.cursor = 'none';
353
+ }
354
+
355
+ function rdpOnPointerCached(idx) {
356
+ const css = pointerCache.get(idx);
357
+ if (css) canvas.style.cursor = css;
358
+ }
359
+
360
+ function rdpOnPointerUpdate(idx, xorBpp, hotX, hotY, w, h, andMask, xorData) {
361
+ const css = buildCursorCss(xorBpp, hotX, hotY, w, h, andMask, xorData);
362
+ if (!css) return;
363
+ pointerCache.set(idx, css);
364
+ canvas.style.cursor = css;
365
+ }
366
+
367
+ // buildCursorCss rasterises an MS-RDPBCGR pointer (§2.2.9.1.1.4.4) into a
368
+ // CSS `url(...) hotX hotY, default` cursor value. AND/XOR mask data
369
+ // arrives top-down (grdp flips it server-side).
370
+ function buildCursorCss(xorBpp, hotX, hotY, w, h, andMask, xorData) {
371
+ if (w <= 0 || h <= 0) return null;
372
+ const off = ('OffscreenCanvas' in window)
373
+ ? new OffscreenCanvas(w, h)
374
+ : Object.assign(document.createElement('canvas'), { width: w, height: h });
375
+ const octx = off.getContext('2d');
376
+ const img = octx.createImageData(w, h);
377
+ const px = img.data;
378
+
379
+ // AND mask: 1bpp, padded to 2-byte stride per scanline.
380
+ const andStride = (((w + 15) >> 4) << 1);
381
+ const andBit = (x, y) => {
382
+ const off = y * andStride + (x >> 3);
383
+ if (off >= andMask.length) return 1; // missing → transparent
384
+ return (andMask[off] >> (7 - (x & 7))) & 1;
385
+ };
386
+
387
+ if (xorBpp === 1) {
388
+ // Monochrome: XOR mask is 1bpp, same stride rules as AND.
389
+ const xorStride = andStride;
390
+ for (let y = 0; y < h; y++) {
391
+ for (let x = 0; x < w; x++) {
392
+ const a = andBit(x, y);
393
+ const xo = xorStride * y + (x >> 3);
394
+ const xb = xo < xorData.length
395
+ ? (xorData[xo] >> (7 - (x & 7))) & 1
396
+ : 0;
397
+ const o = (y * w + x) << 2;
398
+ // (AND, XOR): 00 black, 01 white, 10 transparent, 11 invert→black.
399
+ if (a === 0 && xb === 0) { px[o]=0; px[o+1]=0; px[o+2]=0; px[o+3]=255; }
400
+ else if (a === 0 && xb === 1) { px[o]=255; px[o+1]=255; px[o+2]=255; px[o+3]=255; }
401
+ else if (a === 1 && xb === 0) { px[o]=0; px[o+1]=0; px[o+2]=0; px[o+3]=0; }
402
+ else { px[o]=0; px[o+1]=0; px[o+2]=0; px[o+3]=255; }
403
+ }
404
+ }
405
+ } else if (xorBpp === 32) {
406
+ // BGRA32, alpha takes precedence over AND mask (modern Windows path).
407
+ const stride = w * 4;
408
+ for (let y = 0; y < h; y++) {
409
+ for (let x = 0; x < w; x++) {
410
+ const s = y * stride + x * 4;
411
+ const o = (y * w + x) << 2;
412
+ if (s + 3 >= xorData.length) {
413
+ px[o]=0; px[o+1]=0; px[o+2]=0; px[o+3]=0;
414
+ continue;
415
+ }
416
+ const b = xorData[s], g = xorData[s+1], r = xorData[s+2], a = xorData[s+3];
417
+ if (a !== 0) {
418
+ px[o]=r; px[o+1]=g; px[o+2]=b; px[o+3]=a;
419
+ } else if (andBit(x, y) === 0) {
420
+ // AND=0 with alpha=0 means opaque-color-but-no-alpha-channel
421
+ // (some servers leave alpha as 0); fall back to AND mask.
422
+ px[o]=r; px[o+1]=g; px[o+2]=b; px[o+3]=255;
423
+ } else {
424
+ px[o]=0; px[o+1]=0; px[o+2]=0; px[o+3]=0;
425
+ }
426
+ }
427
+ }
428
+ } else if (xorBpp === 24) {
429
+ // BGR24, AND mask provides transparency. Stride padded to 2 bytes
430
+ // per grdp's flip (matches xorStride = ((w*24 + 15)/16)*2 = w*3 padded).
431
+ const stride = ((w * 3 + 1) >> 1) << 1;
432
+ for (let y = 0; y < h; y++) {
433
+ for (let x = 0; x < w; x++) {
434
+ const s = y * stride + x * 3;
435
+ const o = (y * w + x) << 2;
436
+ if (s + 2 >= xorData.length) {
437
+ px[o]=0; px[o+1]=0; px[o+2]=0; px[o+3]=0;
438
+ continue;
439
+ }
440
+ if (andBit(x, y) === 0) {
441
+ px[o]=xorData[s+2]; px[o+1]=xorData[s+1]; px[o+2]=xorData[s]; px[o+3]=255;
442
+ } else {
443
+ px[o]=0; px[o+1]=0; px[o+2]=0; px[o+3]=0;
444
+ }
445
+ }
446
+ }
447
+ } else {
448
+ // Unsupported depth (15/16bpp etc.): leave canvas transparent so the
449
+ // cursor hides rather than rendering garbage.
450
+ console.warn('Unsupported cursor xorBpp:', xorBpp);
451
+ }
452
+
453
+ // Mitigation: xrdp color cursors can come with an all-zero AND mask -> the whole
454
+ // rectangle is fully opaque, the cursor shape's (arrow/spinner/text) transparent
455
+ // outline is lost and it renders as a black block. A real cursor always has
456
+ // transparent areas (at least the corners); detect ">=95% opaque" and drop that
457
+ // cursor, keeping the previous (or default) one.
458
+ {
459
+ let opaque = 0, total = w * h;
460
+ for (let o = 3; o < px.length; o += 4) if (px[o] !== 0) opaque++;
461
+ if (total > 0 && opaque >= total * 0.95) {
462
+ console.warn('cursor fully opaque (xrdp AND=0), ignoring', w, 'x', h);
463
+ return null;
464
+ }
465
+ }
466
+
467
+ octx.putImageData(img, 0, 0);
468
+ // OffscreenCanvas doesn't expose toDataURL; copy through a regular canvas.
469
+ let dataUrl;
470
+ if (off instanceof HTMLCanvasElement) {
471
+ dataUrl = off.toDataURL('image/png');
472
+ } else {
473
+ const tmp = document.createElement('canvas');
474
+ tmp.width = w; tmp.height = h;
475
+ tmp.getContext('2d').drawImage(off, 0, 0);
476
+ dataUrl = tmp.toDataURL('image/png');
477
+ }
478
+ // Clamp hotspot to bitmap bounds — some servers ship out-of-range values.
479
+ const hx = Math.max(0, Math.min(w - 1, hotX|0));
480
+ const hy = Math.max(0, Math.min(h - 1, hotY|0));
481
+ return `url("${dataUrl}") ${hx} ${hy}, default`;
482
+ }
483
+
484
+ // ── WASM bootstrap ────────────────────────────────────────────────────────
485
+ // At runtime main.wasm is race-downloaded from CDNs via justget (the package tarball
486
+ // ships the wasm for jsDelivr distribution, but postinstall removes the local copy —
487
+ // same idea as the service-tunnels face-api model).
488
+ // Override: URL ?wasm=<url> or localStorage['grdpwasm.wasmUrl']; default CDN -> same-origin fallback.
489
+ const PKG_VERSION = "0.2.1";
490
+ function wasmSources() {
491
+ const custom = new URLSearchParams(location.search).get("wasm")
492
+ || localStorage.getItem("grdpwasm.wasmUrl");
493
+ if (custom) return [custom];
494
+ // CDN first (release convention: publish to npmjs after debugging; the CDN then
495
+ // serves the latest). Until published it 404s -> same-origin main.wasm fallback.
496
+ // ⚠️ Before publishing, PKG_VERSION must be bumped together with package.json
497
+ // (prepack syncs it via scripts/sync-version.mjs), otherwise the CDN keeps an
498
+ // old decoder and the page silently runs the stale wasm.
499
+ return [
500
+ `https://cdn.jsdelivr.net/npm/@aiti-ai/grdpwasm@${PKG_VERSION}/static/main.wasm`,
501
+ `https://unpkg.com/@aiti-ai/grdpwasm@${PKG_VERSION}/static/main.wasm`,
502
+ "main.wasm",
503
+ ];
504
+ }
505
+ async function fetchPlain(url) {
506
+ const r = await fetch(url);
507
+ if (!r.ok) throw new Error(`HTTP ${r.status} ${url}`);
508
+ return new Uint8Array(await r.arrayBuffer());
509
+ }
510
+ async function bootWasm() {
511
+ const status = document.getElementById("status");
512
+ const [url, ...mirrors] = wasmSources();
513
+ const cdnMirrors = mirrors.filter(m => /^https?:/.test(m));
514
+ let bytes;
515
+ if (window.justget && window.justget.download && /^https?:/.test(url)) {
516
+ try {
517
+ // justget: multi-mirror racing + localStorage learning cache (fastest source wins next time)
518
+ const result = await window.justget.download({ url, mirrors: cdnMirrors });
519
+ bytes = result.data instanceof Blob ? new Uint8Array(await result.data.arrayBuffer()) : result.data;
520
+ } catch { /* all CDNs failed -> same-origin fallback below */ }
521
+ }
522
+ if (!bytes) {
523
+ // try every source one by one (same-origin main.wasm included; CDN sources get one retry, they fail fast)
524
+ let lastErr;
525
+ for (const u of [url, ...mirrors]) {
526
+ try { bytes = await fetchPlain(u); break; } catch (e) { lastErr = e; }
527
+ }
528
+ if (!bytes) throw lastErr || new Error("no wasm source");
529
+ }
530
+ const result = await WebAssembly.instantiate(bytes, go.importObject);
531
+ go.run(result.instance);
532
+ status.textContent = "Ready";
533
+ }
534
+ bootWasm().catch(e => {
535
+ document.getElementById("status").textContent = "WASM load failed: " + e;
536
+ });
537
+
538
+ const ub = document.getElementById("unlockBtn");
539
+ if (ub) ub.addEventListener("click", () => {
540
+ if (typeof rdpUnlock === "function") { rdpUnlock(); setStatus("Unlock sequence sent"); }
541
+ });
542
+ const rb = document.getElementById("redrawBtn");
543
+ if (rb) rb.addEventListener("click", () => {
544
+ if (typeof rdpRedraw === "function") { rdpRedraw(); setStatus("Full redraw requested…"); }
545
+ });
546
+
547
+ function proxyURL() {
548
+ // Derive the base path the page lives under (e.g. /aiti-ui-env/rdp or /dev/aiti-ui-env/rdp)
549
+ // so the WS connects to /ws under the same base (rewritten to rdp-bridge by the pi-web middleware).
550
+ const base = location.pathname.endsWith("/index.html")
551
+ ? location.pathname.slice(0, -"/index.html".length)
552
+ : location.pathname;
553
+ return (location.protocol === "https:" ? "wss://" : "ws://") + location.host + base;
554
+ }
555
+
556
+ function toggleConnect() {
557
+ if (connected) {
558
+ rdpDisconnect();
559
+ setDisconnected("Disconnected");
560
+ } else {
561
+ doConnect();
562
+ }
563
+ }
564
+
565
+ function doConnect() {
566
+ // host/port: injected by the server via __rdpTarget (fixedTarget mode);
567
+ // standalone mode reads hidden DOM fields (invisible and non-editable for the user)
568
+ const host = (window.__rdpTarget && window.__rdpTarget.host) || document.getElementById("host").value.trim();
569
+ const port = String((window.__rdpTarget && window.__rdpTarget.port) || document.getElementById("port").value.trim() || 3389);
570
+ const domain = document.getElementById("domain").value.trim();
571
+ const user = document.getElementById("user").value.trim();
572
+ const password = document.getElementById("password").value;
573
+ const width = parseInt(document.getElementById("width").value) || 1280;
574
+ const height = parseInt(document.getElementById("height").value) || 800;
575
+
576
+ saveCookies();
577
+ try { sessionStorage.setItem("grdp.creds", JSON.stringify({ domain, user, password, width, height })); } catch (_) {}
578
+
579
+ // (Re)create the H.264 decoder sized for this session.
580
+ if (h264Dec) { try { h264Dec.close(); } catch (_) {} }
581
+ h264Dec = createH264Decoder(width, height);
582
+ h264Timestamp = 0;
583
+ h264FramePos.clear();
584
+
585
+ // Reset cursor state for the new session.
586
+ pointerCache.clear();
587
+ canvas.style.cursor = 'default';
588
+
589
+ // Create AudioContext here, while we're in a user-gesture handler,
590
+ // so the browser starts it in 'running' state (no autoplay suspension).
591
+ if (audioCtx) { try { audioCtx.close(); } catch (_) {} }
592
+ audioCtx = new AudioContext({ latencyHint: 'playback' });
593
+ audioNextAt = 0;
594
+ if (h264Dec) {
595
+ document.getElementById("status").textContent = "Connecting… (WebCodecs H.264 enabled)";
596
+ } else {
597
+ document.getElementById("status").textContent = "Connecting… (WebCodecs unavailable)";
598
+ }
599
+ rdpConnect(proxyURL(), host, port, domain, user, password, width, height,
600
+ document.getElementById("swapAltMeta").checked);
601
+ }
602
+
603
+ // Callbacks from WASM
604
+ function rdpOnReady() {
605
+ try { sessionStorage.setItem("grdp.reconnects", "0"); } catch (_) {}
606
+ connected = true;
607
+ const btn = document.getElementById("connectBtn");
608
+ btn.textContent = "Disconnect";
609
+ const ub = document.getElementById("unlockBtn");
610
+ if (ub) ub.style.display = "";
611
+ const rb = document.getElementById("redrawBtn");
612
+ if (rb) rb.style.display = "";
613
+ btn.classList.add("connected");
614
+ document.getElementById("status").textContent =
615
+ h264Dec ? "Connected (WebCodecs H.264)" : "Connected";
616
+ document.getElementById("rdpCanvas").focus();
617
+ }
618
+
619
+ function setStatus(msg) {
620
+ document.getElementById("status").textContent = msg;
621
+ }
622
+ function rdpOnError(msg) {
623
+ setDisconnected("Error: " + msg);
624
+ scheduleAutoReconnect();
625
+ }
626
+
627
+ function rdpOnClose() {
628
+ setDisconnected("Closed");
629
+ scheduleAutoReconnect();
630
+ }
631
+
632
+ // Auto-recovery on disconnect: in autoconnect mode (bridge/server dropped, lock-screen
633
+ // kick, ...) reload the whole page after 4s — credentials live in sessionStorage and
634
+ // are re-filled + reconnected after reload. A backstop beyond the Go-side watchdog
635
+ // covering every disconnect shape.
636
+ var reconnectTimer = null;
637
+ function scheduleAutoReconnect() {
638
+ let creds = null;
639
+ try { creds = JSON.parse(sessionStorage.getItem("grdp.creds") || "null"); } catch (_) {}
640
+ if (!creds || !creds.user) return;
641
+ if (reconnectTimer) return;
642
+ // Retry cap: at most 3 auto-reconnects per page lifetime (a successful connect does
643
+ // NOT reset the counter — otherwise a "connects then instantly drops" loop never
644
+ // ends). Past the cap a persistent error message is shown.
645
+ let n = 0;
646
+ try { n = parseInt(sessionStorage.getItem("grdp.reconnects") || "0"); } catch (_) {}
647
+ if (n >= 3) {
648
+ setStatus("Disconnected (3 retries exhausted). Likely causes: wrong password, session taken over elsewhere (single-session limit), or the target refused the connection. Check credentials and press Connect manually.");
649
+ return;
650
+ }
651
+ try { sessionStorage.setItem("grdp.reconnects", String(n + 1)); } catch (_) {}
652
+ setStatus("Reconnecting… (attempt " + (n + 1) + "/3)");
653
+ }
654
+ // load-time restore: prefill from sessionStorage when the form is empty
655
+ // and a fixed target is present (aiti-ui-env deployments).
656
+ (function restoreCreds() {
657
+ let creds = null;
658
+ try { creds = JSON.parse(sessionStorage.getItem("grdp.creds") || "null"); } catch (_) { return; }
659
+ if (!creds || !creds.user) return;
660
+ const tryFill = () => {
661
+ const u = document.getElementById("user"), p = document.getElementById("password");
662
+ if (!u || !p) { setTimeout(tryFill, 300); return; }
663
+ if (!u.value) u.value = creds.user;
664
+ if (!p.value) p.value = creds.password;
665
+ const d = document.getElementById("domain");
666
+ if (d && !d.value && creds.domain) d.value = creds.domain;
667
+ };
668
+ tryFill();
669
+ })();
670
+
671
+ // Server → client: write text to browser clipboard (text only).
672
+ function rdpOnClipboard(text) {
673
+ if (navigator.clipboard && navigator.clipboard.writeText) {
674
+ navigator.clipboard.writeText(text).catch(err => {
675
+ console.warn("clipboard write failed:", err);
676
+ });
677
+ }
678
+ }
679
+
680
+ function setDisconnected(msg) {
681
+ connected = false;
682
+ if (typeof vkb !== "undefined") vkb.reset(); // release latched modifiers
683
+ if (typeof scb !== "undefined") scb.release(); // release any held shortcut-bar combo
684
+ if (audioCtx) { try { audioCtx.close(); } catch (_) {} audioCtx = null; audioNextAt = 0; }
685
+ if (h264Dec) { try { h264Dec.close(); } catch (_) {} h264Dec = null; }
686
+ h264FramePos.clear();
687
+ const btn = document.getElementById("connectBtn");
688
+ btn.textContent = "Connect";
689
+ btn.classList.remove("connected");
690
+ const ub = document.getElementById("unlockBtn");
691
+ if (ub) ub.style.display = "none";
692
+ const rb = document.getElementById("redrawBtn");
693
+ if (rb) rb.style.display = "none";
694
+ document.getElementById("status").textContent = msg;
695
+ }
696
+
697
+ // Mouse / keyboard events
698
+ // When the canvas is CSS-scaled, convert viewport coords back to remote pixel coords
699
+ function canvasRemoteXY(e) {
700
+ const r = canvas.getBoundingClientRect();
701
+ const sx = canvas.width / r.width;
702
+ const sy = canvas.height / r.height;
703
+ return [Math.floor((e.clientX - r.left) * sx), Math.floor((e.clientY - r.top) * sy)];
704
+ }
705
+ canvas.addEventListener("mousemove", e => {
706
+ if (!connected) return;
707
+ const [x, y] = canvasRemoteXY(e);
708
+ rdpMouseMove(x, y);
709
+ });
710
+
711
+ canvas.addEventListener("mousedown", e => {
712
+ if (!connected) return;
713
+ e.preventDefault();
714
+ canvas.focus();
715
+ const r = canvas.getBoundingClientRect();
716
+ // grdp button: 0=left, 1=middle, 2=right (JS: 0=left, 1=middle, 2=right)
717
+ const [mx, my] = canvasRemoteXY(e);
718
+ rdpMouseDown(e.button, mx, my);
719
+ });
720
+
721
+ canvas.addEventListener("mouseup", e => {
722
+ if (!connected) return;
723
+ e.preventDefault();
724
+ const r = canvas.getBoundingClientRect();
725
+ const [ux, uy] = canvasRemoteXY(e);
726
+ rdpMouseUp(e.button, ux, uy);
727
+ });
728
+
729
+ canvas.addEventListener("wheel", e => {
730
+ if (!connected) return;
731
+ e.preventDefault();
732
+ // Normalize browser deltaY to notch count (1.0 = one physical notch).
733
+ // grdp's MouseWheel(delta) expects notch units and handles WHEEL_DELTA scaling.
734
+ // Browser deltaY is positive when scrolling down; RDP wheel is positive for up.
735
+ let delta;
736
+ if (e.deltaMode === 1) { // DOM_DELTA_LINE: ~3 lines per notch
737
+ delta = -e.deltaY / 3;
738
+ } else if (e.deltaMode === 2) { // DOM_DELTA_PAGE
739
+ delta = -e.deltaY;
740
+ } else { // DOM_DELTA_PIXEL: ~100 px per notch
741
+ delta = -e.deltaY / 100;
742
+ }
743
+ rdpMouseWheel(delta);
744
+ }, { passive: false });
745
+
746
+ canvas.addEventListener("contextmenu", e => e.preventDefault());
747
+
748
+ // Track the in-flight clipboard read so keydown can wait for it to finish.
749
+ let clipboardSyncPromise = Promise.resolve();
750
+
751
+ function syncClipboard() {
752
+ if (!connected || !navigator.clipboard || !navigator.clipboard.readText) return;
753
+ // pointerdown is a user gesture; Chrome will prompt for clipboard-read
754
+ // permission on first use and read silently thereafter.
755
+ clipboardSyncPromise = navigator.clipboard.readText()
756
+ .then(text => { if (text) rdpClipboardChanged(text); })
757
+ .catch(() => {});
758
+ }
759
+
760
+ // ── native (system) keyboard: captured via a hidden input, forwarded to RDP ──
761
+ const sysKbBtn = document.getElementById("sysKbBtn");
762
+ const nativeInput = document.getElementById("nativeKbInput");
763
+ let sysKbActive = false;
764
+
765
+ sysKbBtn.addEventListener("click", () => {
766
+ sysKbActive = !sysKbActive;
767
+ sysKbBtn.classList.toggle("on", sysKbActive);
768
+ if (sysKbActive) {
769
+ // open the system keyboard: focus the hidden input; mobile pops the native keyboard
770
+ nativeInput.value = "";
771
+ nativeInput.focus();
772
+ } else {
773
+ nativeInput.blur();
774
+ }
775
+ });
776
+
777
+ // Forward native-keyboard input to RDP
778
+ nativeInput.addEventListener("input", () => {
779
+ if (!connected || !sysKbActive) return;
780
+ const text = nativeInput.value;
781
+ if (!text) return;
782
+ // send to RDP char by char
783
+ for (let i = 0; i < text.length; i++) {
784
+ const ch = text[i];
785
+ const code = charToKeyCode(ch);
786
+ if (code) {
787
+ rdpKeyDown(code);
788
+ rdpKeyUp(code);
789
+ }
790
+ }
791
+ nativeInput.value = ""; // clear, ready for the next char
792
+ });
793
+
794
+ // char -> DOM code map (common chars, falls through when missing)
795
+ function charToKeyCode(ch) {
796
+ const map = {
797
+ 'a':'KeyA','b':'KeyB','c':'KeyC','d':'KeyD','e':'KeyE','f':'KeyF','g':'KeyG',
798
+ 'h':'KeyH','i':'KeyI','j':'KeyJ','k':'KeyK','l':'KeyL','m':'KeyM',
799
+ 'n':'KeyN','o':'KeyO','p':'KeyP','q':'KeyQ','r':'KeyR','s':'KeyS','t':'KeyT',
800
+ 'u':'KeyU','v':'KeyV','w':'KeyW','x':'KeyX','y':'KeyY','z':'KeyZ',
801
+ '0':'Digit0','1':'Digit1','2':'Digit2','3':'Digit3','4':'Digit4',
802
+ '5':'Digit5','6':'Digit6','7':'Digit7','8':'Digit8','9':'Digit9',
803
+ ' ':'Space','-':'Minus','=':'Equal','[':'BracketLeft',']':'BracketRight',
804
+ '\\':'Backslash',';':'Semicolon',"'":'Quote',',':'Comma','.':'Period','/':'Slash',
805
+ '`':'Backquote','\n':'Enter','\t':'Tab','\b':'Backspace',
806
+ };
807
+ const lower = ch.toLowerCase();
808
+ if (map[lower]) return map[lower];
809
+ // uppercase letters
810
+ if (/^[A-Z]$/.test(ch)) return 'Key' + ch;
811
+ return null;
812
+ }
813
+
814
+ // Sync on click: covers the common case of copying in another app then
815
+ // clicking back into the canvas before pressing the paste key.
816
+ canvas.addEventListener("pointerdown", syncClipboard);
817
+
818
+ // Sync via paste event (Cmd+V on macOS, right-click paste, etc.)
819
+ document.addEventListener("paste", e => {
820
+ if (!connected) return;
821
+ const text = e.clipboardData ? e.clipboardData.getData("text/plain") : "";
822
+ if (text) rdpClipboardChanged(text);
823
+ });
824
+
825
+ // Await any pending clipboard sync before forwarding each key, so that a
826
+ // pointerdown-triggered readText() always completes before reaching the server.
827
+ canvas.addEventListener("keydown", async e => {
828
+ if (!connected) return;
829
+ e.preventDefault();
830
+ await clipboardSyncPromise;
831
+ rdpKeyDown(e.code);
832
+ });
833
+
834
+ canvas.addEventListener("keyup", e => {
835
+ if (!connected) return;
836
+ e.preventDefault();
837
+ rdpKeyUp(e.code);
838
+ });
839
+ </script>
840
+
841
+ <!-- virtual keyboard (simple-keyboard engine + programmer layout + unified dispatch layer) -->
842
+ <script src="vendor/simple-keyboard.js"></script>
843
+ <script src="virtual-keyboard.js"></script>
844
+ <script src="shortcut-bar.js"></script>
845
+ <script>
846
+ (function () {
847
+ if (typeof SimpleKeyboard === "undefined" || typeof VirtualKeyboard === "undefined") return;
848
+ // mount: send to RDP (Go globals rdpKeyDown/rdpKeyUp map DOM code -> scancode)
849
+ const vkb = VirtualKeyboard.mount({
850
+ root: "#vkbRoot",
851
+ onKey(code, down) {
852
+ if (!connected) return;
853
+ if (down) rdpKeyDown(code); else rdpKeyUp(code);
854
+ },
855
+ onLatch(code, on) {
856
+ if (!connected) return;
857
+ if (on) rdpKeyDown(code); else rdpKeyUp(code);
858
+ }
859
+ });
860
+ window.vkb = vkb;
861
+ const btn = document.getElementById("vkbToggle");
862
+ btn.addEventListener("click", () => {
863
+ vkb.toggle();
864
+ btn.classList.toggle("on", vkb.isOpen());
865
+ document.body.classList.toggle("vkb-open", vkb.isOpen());
866
+ if (vkb.isOpen()) {
867
+ const el = document.getElementById("vkbRoot");
868
+ document.body.style.setProperty("--vkb-hpx", el.offsetHeight + "px");
869
+ }
870
+ });
871
+ })();
872
+ </script>
873
+
874
+ <!-- shortcut bar (F1–F12 / Alt+Tab / Ctrl+Alt+T / ⊞ Win / ⇧+F10 …; sent to RDP via the same dispatch layer as the virtual keyboard) -->
875
+ <script>
876
+ (function () {
877
+ if (typeof ShortcutBar === "undefined") return;
878
+ const scb = ShortcutBar.mount({
879
+ root: "#shortcuts",
880
+ onPress(code) { if (!connected) return; rdpKeyDown(code); },
881
+ onRelease(code) { if (!connected) return; rdpKeyUp(code); }
882
+ });
883
+ window.scb = scb;
884
+ const btn = document.getElementById("scbToggle");
885
+ const el = document.getElementById("shortcuts");
886
+ btn.addEventListener("click", () => {
887
+ el.hidden = !el.hidden;
888
+ btn.classList.toggle("on", !el.hidden);
889
+ if (el.hidden) scb.release(); // release held combos when collapsed
890
+ });
891
+ })();
892
+ </script>
893
+ </body>
894
+ </html>