forge-jsxy 1.0.69 → 1.0.71

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.
@@ -5,57 +5,219 @@
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1" />
6
6
  <title>Forge Remote Control</title>
7
7
  <style>
8
- :root { color-scheme: dark; }
9
- body { margin: 0; font: 13px/1.4 system-ui, sans-serif; background: #1e1e1e; color: #d4d4d4; }
10
- .bar { display: flex; gap: 8px; align-items: center; padding: 8px; background: #252526; border-bottom: 1px solid #3e3e42; }
8
+ :root {
9
+ color-scheme: dark;
10
+ --vscode-editor-background: #1e1e1e;
11
+ --vscode-editor-foreground: #cccccc;
12
+ --vscode-sideBar-background: #252526;
13
+ --vscode-panel-border: #3e3e42;
14
+ --vscode-button-background: #0078d4;
15
+ --vscode-button-hoverBackground: #1a8cff;
16
+ --vscode-button-foreground: #ffffff;
17
+ --vscode-button-secondaryBackground: #3a3d41;
18
+ --vscode-button-secondaryHoverBackground: #4c4d51;
19
+ --vscode-button-secondaryForeground: #c5c5c5;
20
+ --vscode-input-background: #3c3c3c;
21
+ --vscode-input-foreground: #cccccc;
22
+ --vscode-input-border: #3c3c3c;
23
+ --vscode-focusBorder: #0078d4;
24
+ --vscode-errorForeground: #f48771;
25
+ }
26
+ * { box-sizing: border-box; }
27
+ html, body { height: 100%; }
28
+ body {
29
+ margin: 0;
30
+ font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif;
31
+ background: var(--vscode-editor-background);
32
+ color: var(--vscode-editor-foreground);
33
+ }
34
+ .bar {
35
+ position: fixed;
36
+ left: 0;
37
+ right: 0;
38
+ bottom: 0;
39
+ z-index: 10;
40
+ display: flex;
41
+ flex-wrap: wrap;
42
+ gap: 6px 8px;
43
+ align-items: center;
44
+ min-height: 44px;
45
+ padding: 8px 12px;
46
+ background: #181818;
47
+ border-top: 1px solid var(--vscode-panel-border);
48
+ }
11
49
  .bar input, .bar button { font: inherit; }
12
- .bar input { background: #3c3c3c; color: #d4d4d4; border: 1px solid #555; border-radius: 4px; padding: 5px 8px; }
13
- .bar button { background: #0e639c; color: #fff; border: 0; border-radius: 4px; padding: 6px 10px; cursor: pointer; }
14
- .bar button.alt { background: #3a3d41; }
15
- .bar button.warn { background: #5a1d1d; }
16
- .spacer { flex: 1; }
17
- .state { opacity: .9; }
18
- .screen-wrap { position: fixed; inset: 49px 0 0 0; overflow: auto; background: #111; }
50
+ .bar input {
51
+ background: var(--vscode-input-background);
52
+ color: var(--vscode-input-foreground);
53
+ border: 1px solid var(--vscode-input-border);
54
+ border-radius: 4px;
55
+ padding: 5px 8px;
56
+ }
57
+ .bar button {
58
+ background: var(--vscode-button-background);
59
+ color: var(--vscode-button-foreground);
60
+ border: 1px solid transparent;
61
+ border-radius: 4px;
62
+ min-height: 28px;
63
+ padding: 4px 12px;
64
+ cursor: pointer;
65
+ font-size: 12px;
66
+ font-weight: 500;
67
+ }
68
+ .bar button:hover:not(:disabled) { background: var(--vscode-button-hoverBackground); }
69
+ .bar button.alt {
70
+ background: var(--vscode-button-secondaryBackground);
71
+ color: var(--vscode-button-secondaryForeground);
72
+ border-color: #505050;
73
+ }
74
+ .bar button.alt:hover:not(:disabled) { background: var(--vscode-button-secondaryHoverBackground); }
75
+ .bar button.warn {
76
+ background: #5a1d1d;
77
+ color: #ffd9d9;
78
+ border-color: #7c2b2b;
79
+ }
80
+ .bar button.warn:hover:not(:disabled) { background: #6c2626; }
81
+ .bar button:disabled { opacity: 0.45; cursor: not-allowed; }
82
+ .bar button:focus-visible, .bar input:focus-visible {
83
+ outline: none;
84
+ box-shadow: 0 0 0 1px var(--vscode-editor-background), 0 0 0 3px rgba(0, 120, 212, 0.45);
85
+ }
86
+ .spacer { flex: 1; min-width: 8px; }
87
+ .state { opacity: .92; font-size: 11px; color: #b7b7b7; }
88
+ .brand { font-weight: 600; letter-spacing: 0.02em; }
89
+ .hint { color: #9d9d9d; }
90
+ .screen-wrap {
91
+ position: fixed;
92
+ inset: 0 0 58px 0;
93
+ overflow: hidden;
94
+ background: #111;
95
+ display: grid;
96
+ place-items: center;
97
+ }
98
+ .screen-stage {
99
+ width: 100%;
100
+ height: 100%;
101
+ display: grid;
102
+ place-items: center;
103
+ padding: 10px;
104
+ position: relative;
105
+ overflow: hidden;
106
+ }
19
107
  .screen {
20
108
  display: block;
21
- max-width: none;
109
+ max-width: calc(100vw - 20px);
110
+ max-height: calc(100vh - 86px);
111
+ width: auto;
112
+ height: auto;
22
113
  user-select: none;
23
114
  cursor: default;
24
115
  margin: 0;
25
116
  }
26
117
  .screen.write-enabled { cursor: crosshair; }
27
- .file-panel { position: fixed; right: 8px; top: 56px; width: 360px; max-height: 50vh; overflow: auto; background: #1b1b1d; border: 1px solid #3e3e42; border-radius: 6px; padding: 8px; display: none; z-index: 8; }
118
+ .empty-state {
119
+ position: absolute;
120
+ inset: 0;
121
+ display: flex;
122
+ align-items: center;
123
+ justify-content: center;
124
+ padding: 16px;
125
+ pointer-events: none;
126
+ }
127
+ .empty-state-card {
128
+ max-width: min(680px, 92vw);
129
+ text-align: center;
130
+ background: rgba(24, 24, 24, 0.88);
131
+ border: 1px solid #3e3e42;
132
+ border-radius: 8px;
133
+ padding: 14px 16px;
134
+ color: #d4d4d4;
135
+ font-size: 13px;
136
+ line-height: 1.45;
137
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
138
+ }
139
+ .empty-state.error .empty-state-card {
140
+ border-color: #7c2b2b;
141
+ color: #ffd8d8;
142
+ background: rgba(44, 18, 18, 0.9);
143
+ }
144
+ .file-panel { position: fixed; right: 8px; bottom: 66px; width: 380px; max-height: 60vh; overflow: auto; background: #1b1b1d; border: 1px solid #3e3e42; border-radius: 6px; padding: 8px; display: none; z-index: 9; }
28
145
  .file-panel.open { display: block; }
29
146
  .file-panel .row { display: flex; gap: 6px; margin-bottom: 6px; }
30
147
  .file-panel button { font: inherit; }
148
+ .file-panel input { flex: 1; min-width: 0; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); border-radius: 4px; padding: 5px 8px; }
31
149
  .file-list { border: 1px solid #3e3e42; border-radius: 4px; padding: 4px; max-height: 35vh; overflow: auto; }
32
150
  .file-item { display: block; width: 100%; text-align: left; background: #2a2a2d; color: #d4d4d4; border: 1px solid #3e3e42; border-radius: 4px; padding: 4px 6px; margin: 3px 0; cursor: pointer; }
33
151
  .file-item.dir { background: #253045; }
34
152
  .path-label { font-size: 12px; opacity: 0.85; word-break: break-all; }
153
+ .auth-modal {
154
+ position: fixed;
155
+ inset: 0;
156
+ z-index: 30;
157
+ display: none;
158
+ align-items: center;
159
+ justify-content: center;
160
+ background: rgba(0, 0, 0, 0.45);
161
+ }
162
+ .auth-modal.open { display: flex; }
163
+ .auth-card {
164
+ width: min(420px, 92vw);
165
+ background: #1f1f1f;
166
+ border: 1px solid #3e3e42;
167
+ border-radius: 8px;
168
+ padding: 12px;
169
+ box-shadow: 0 16px 32px rgba(0, 0, 0, 0.45);
170
+ }
171
+ .auth-title { font-size: 13px; font-weight: 600; margin: 0 0 8px 0; }
172
+ .auth-hint { font-size: 12px; color: #b7b7b7; margin: 0 0 8px 0; }
173
+ .auth-row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
174
+ .auth-row input[type="password"] {
175
+ flex: 1;
176
+ min-width: 0;
177
+ background: var(--vscode-input-background);
178
+ color: var(--vscode-input-foreground);
179
+ border: 1px solid var(--vscode-input-border);
180
+ border-radius: 4px;
181
+ padding: 6px 8px;
182
+ font: inherit;
183
+ }
184
+ .auth-row button {
185
+ background: var(--vscode-button-background);
186
+ color: var(--vscode-button-foreground);
187
+ border: 1px solid transparent;
188
+ border-radius: 4px;
189
+ padding: 6px 10px;
190
+ cursor: pointer;
191
+ font: inherit;
192
+ }
193
+ .auth-row button.sec {
194
+ background: var(--vscode-button-secondaryBackground);
195
+ color: var(--vscode-button-secondaryForeground);
196
+ border-color: #505050;
197
+ }
35
198
  </style>
36
199
  </head>
37
200
  <body>
38
201
  <div class="bar">
39
- <strong>Remote</strong>
40
- <label>Session <input id="session" placeholder="client_xxx" /></label>
41
- <label>Password <input id="pwd" type="password" placeholder="@@PWD_HINT@@" /></label>
42
- <button id="connectBtn">Connect</button>
202
+ <strong class="brand">Remote</strong>
43
203
  <button id="modeBtn" class="alt">View Only</button>
44
204
  <button id="refreshBtn" class="alt">Refresh</button>
45
- <button id="clipPullBtn" class="alt">Clipboard: PC -> Local</button>
46
- <button id="clipPushBtn" class="alt">Clipboard: Local -> PC</button>
47
- <button id="browseBtn" class="alt">Browse PC Files</button>
48
- <input id="filePullPath" placeholder="C:\\Users\\...\\file.txt" />
49
- <button id="filePullBtn" class="alt">Fetch File <- PC</button>
50
- <input id="filePushInput" type="file" />
51
- <button id="filePushBtn" class="alt">Send File -> PC</button>
205
+ <button id="browseBtn" class="alt">Files</button>
52
206
  <button id="disconnectBtn" class="warn">Disconnect</button>
53
207
  <span class="spacer"></span>
208
+ <span class="state hint">Shortcuts: Ctrl/Cmd+C (PC -> Local), Ctrl/Cmd+V (Local -> PC)</span>
54
209
  <span class="state" id="state">Idle</span>
55
210
  <span class="state" id="modeState">Mode: View Only</span>
56
211
  </div>
57
212
  <div class="screen-wrap" id="screenWrap">
58
- <img id="screen" class="screen" alt="Remote screen" />
213
+ <div class="screen-stage" id="screenStage">
214
+ <img id="screen" class="screen" alt="Remote screen" />
215
+ <div id="emptyState" class="empty-state">
216
+ <div id="emptyStateCard" class="empty-state-card">
217
+ Waiting for remote session...
218
+ </div>
219
+ </div>
220
+ </div>
59
221
  </div>
60
222
  <div class="file-panel" id="filePanel">
61
223
  <div class="row">
@@ -64,20 +226,39 @@
64
226
  <button id="closePanelBtn" class="warn">Close</button>
65
227
  </div>
66
228
  <div class="path-label" id="pathLabel">Path: (none)</div>
229
+ <div class="row">
230
+ <input id="filePullPath" placeholder="Remote file path" />
231
+ <button id="filePullBtn" class="alt">Fetch <- PC</button>
232
+ </div>
233
+ <div class="row">
234
+ <input id="filePushInput" type="file" />
235
+ <button id="filePushBtn" class="alt">Send -> PC</button>
236
+ </div>
67
237
  <div class="file-list" id="fileList"></div>
68
238
  </div>
239
+ <div class="auth-modal" id="authModal">
240
+ <div class="auth-card">
241
+ <p class="auth-title">Remote session password required</p>
242
+ <p class="auth-hint" id="authHint">Enter the password for this remote session.</p>
243
+ <div class="auth-row">
244
+ <input id="authPasswordInput" type="password" placeholder="Session password" />
245
+ </div>
246
+ <div class="auth-row">
247
+ <button id="authSubmitBtn">Continue</button>
248
+ <button id="authCancelBtn" class="sec">Cancel</button>
249
+ </div>
250
+ </div>
251
+ </div>
69
252
  <script>
70
253
  const relayFallback = @@RELAY_FALLBACK_JS@@ || "";
71
254
  const pwdHint = @@PWD_JS@@ || "";
72
255
  const stateEl = document.getElementById("state");
73
256
  const modeStateEl = document.getElementById("modeState");
74
- const sessionEl = document.getElementById("session");
75
- const pwdEl = document.getElementById("pwd");
76
257
  const screenEl = document.getElementById("screen");
258
+ const emptyStateEl = document.getElementById("emptyState");
259
+ const emptyStateCardEl = document.getElementById("emptyStateCard");
77
260
  const modeBtn = document.getElementById("modeBtn");
78
261
  const wrapEl = document.getElementById("screenWrap");
79
- const clipPullBtn = document.getElementById("clipPullBtn");
80
- const clipPushBtn = document.getElementById("clipPushBtn");
81
262
  const browseBtn = document.getElementById("browseBtn");
82
263
  const filePullPath = document.getElementById("filePullPath");
83
264
  const filePullBtn = document.getElementById("filePullBtn");
@@ -89,6 +270,11 @@
89
270
  const closePanelBtn = document.getElementById("closePanelBtn");
90
271
  const pathLabel = document.getElementById("pathLabel");
91
272
  const fileList = document.getElementById("fileList");
273
+ const authModal = document.getElementById("authModal");
274
+ const authHint = document.getElementById("authHint");
275
+ const authPasswordInput = document.getElementById("authPasswordInput");
276
+ const authSubmitBtn = document.getElementById("authSubmitBtn");
277
+ const authCancelBtn = document.getElementById("authCancelBtn");
92
278
  let ws = null;
93
279
  let authed = false;
94
280
  let writeEnabled = false;
@@ -99,12 +285,119 @@
99
285
  let remoteClipboardBusy = false;
100
286
  let localClipboardBusy = false;
101
287
  let currentBrowsePath = "";
288
+ let reconnectTimer = null;
289
+ let pendingPasswordPrompt = null;
290
+ let hasFrame = false;
291
+ let authWatchdogTimer = null;
292
+ let authChallengeSeen = false;
293
+ let dragActive = false;
294
+ let pointerDown = false;
295
+ let pointerButton = "left";
296
+ let pointerDownPoint = null;
297
+ let suppressClickUntil = 0;
298
+ let disablePressLifecycle = false;
299
+ let lastFrameMeta = null;
300
+ let sessionAgentVersion = "";
301
+ let sessionAgentOs = "";
102
302
 
103
303
  function setState(t) { stateEl.textContent = t; }
304
+ function parseVersion(v) {
305
+ return String(v || "")
306
+ .split(".")
307
+ .map((n) => Number.parseInt(n, 10))
308
+ .filter((n) => Number.isFinite(n));
309
+ }
310
+ function versionLt(a, b) {
311
+ const av = parseVersion(a);
312
+ const bv = parseVersion(b);
313
+ const n = Math.max(av.length, bv.length);
314
+ for (let i = 0; i < n; i++) {
315
+ const ai = av[i] || 0;
316
+ const bi = bv[i] || 0;
317
+ if (ai < bi) return true;
318
+ if (ai > bi) return false;
319
+ }
320
+ return false;
321
+ }
322
+ async function refreshSessionAgentMeta(sid) {
323
+ try {
324
+ const r = await fetch("/api/sessions", { cache: "no-store" });
325
+ if (!r.ok) return;
326
+ const j = await r.json();
327
+ const list = Array.isArray(j && j.sessions) ? j.sessions : [];
328
+ const row = list.find((it) => String(it && it.session_id || "") === sid);
329
+ if (!row) return;
330
+ sessionAgentVersion = String(row.agent_version || "").trim();
331
+ sessionAgentOs = String(row.agent_os || "").trim().toLowerCase();
332
+ if (
333
+ sessionAgentVersion &&
334
+ sessionAgentOs.includes("windows") &&
335
+ versionLt(sessionAgentVersion, "1.0.71")
336
+ ) {
337
+ setState("Agent v" + sessionAgentVersion + " detected. Upgrade agent from /files to enable reliable control.");
338
+ }
339
+ } catch {
340
+ /* ignore */
341
+ }
342
+ }
343
+ function sha256HexFallback(input) {
344
+ const msg = unescape(encodeURIComponent(String(input || "")));
345
+ const bytes = new Uint8Array(msg.length);
346
+ for (let i = 0; i < msg.length; i++) bytes[i] = msg.charCodeAt(i);
347
+ const K = new Uint32Array([
348
+ 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
349
+ 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
350
+ 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
351
+ 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
352
+ 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
353
+ 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
354
+ 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
355
+ 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
356
+ ]);
357
+ const H = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]);
358
+ const bitLen = bytes.length * 8;
359
+ const totalLen = ((bytes.length + 9 + 63) >> 6) << 6;
360
+ const data = new Uint8Array(totalLen);
361
+ data.set(bytes);
362
+ data[bytes.length] = 0x80;
363
+ const view = new DataView(data.buffer);
364
+ view.setUint32(totalLen - 8, Math.floor(bitLen / 0x100000000), false);
365
+ view.setUint32(totalLen - 4, bitLen >>> 0, false);
366
+ const w = new Uint32Array(64);
367
+ const rr = (x, n) => (x >>> n) | (x << (32 - n));
368
+ for (let i = 0; i < totalLen; i += 64) {
369
+ for (let t = 0; t < 16; t++) w[t] = view.getUint32(i + t * 4, false);
370
+ for (let t = 16; t < 64; t++) {
371
+ const s0 = rr(w[t - 15], 7) ^ rr(w[t - 15], 18) ^ (w[t - 15] >>> 3);
372
+ const s1 = rr(w[t - 2], 17) ^ rr(w[t - 2], 19) ^ (w[t - 2] >>> 10);
373
+ w[t] = (((w[t - 16] + s0) >>> 0) + ((w[t - 7] + s1) >>> 0)) >>> 0;
374
+ }
375
+ let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7];
376
+ for (let t = 0; t < 64; t++) {
377
+ const S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25);
378
+ const ch = (e & f) ^ (~e & g);
379
+ const t1 = (((((h + S1) >>> 0) + ((ch + K[t]) >>> 0)) >>> 0) + w[t]) >>> 0;
380
+ const S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22);
381
+ const maj = (a & b) ^ (a & c) ^ (b & c);
382
+ const t2 = (S0 + maj) >>> 0;
383
+ h = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
384
+ }
385
+ H[0] = (H[0] + a) >>> 0; H[1] = (H[1] + b) >>> 0; H[2] = (H[2] + c) >>> 0; H[3] = (H[3] + d) >>> 0;
386
+ H[4] = (H[4] + e) >>> 0; H[5] = (H[5] + f) >>> 0; H[6] = (H[6] + g) >>> 0; H[7] = (H[7] + h) >>> 0;
387
+ }
388
+ return Array.from(H).map((n) => n.toString(16).padStart(8, "0")).join("");
389
+ }
390
+ function showEmptyState(msg, isError) {
391
+ emptyStateCardEl.textContent = String(msg || "Waiting for screenshot...");
392
+ emptyStateEl.classList.toggle("error", Boolean(isError));
393
+ emptyStateEl.style.display = "flex";
394
+ }
395
+ function hideEmptyState() {
396
+ emptyStateEl.style.display = "none";
397
+ emptyStateEl.classList.remove("error");
398
+ }
104
399
  function updateWriteControls() {
105
400
  const ro = !writeEnabled;
106
- clipPullBtn.disabled = ro;
107
- clipPushBtn.disabled = ro;
108
401
  filePullBtn.disabled = ro;
109
402
  filePullPath.disabled = ro;
110
403
  filePushBtn.disabled = ro;
@@ -116,9 +409,12 @@
116
409
  if (ro) filePanel.classList.remove("open");
117
410
  }
118
411
  function hashHex(s) {
119
- return crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)).then((buf) =>
120
- [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("")
121
- );
412
+ if (globalThis.crypto && globalThis.crypto.subtle && typeof TextEncoder !== "undefined") {
413
+ return crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)).then((buf) =>
414
+ [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("")
415
+ );
416
+ }
417
+ return Promise.resolve(sha256HexFallback(s));
122
418
  }
123
419
  function wsBaseUrl() {
124
420
  if (location.protocol.startsWith("http")) {
@@ -127,23 +423,163 @@
127
423
  }
128
424
  return relayFallback || "ws://127.0.0.1:9877";
129
425
  }
426
+ function currentSessionId() {
427
+ return String(new URLSearchParams(location.search).get("session") || "").trim();
428
+ }
429
+ function resolveSessionId() {
430
+ const sid = currentSessionId();
431
+ if (sid) return sid;
432
+ const entered = String(window.prompt("Enter remote session id", "") || "").trim();
433
+ return entered;
434
+ }
435
+ function sessionPwdKey(sid) {
436
+ return "forge_remote_pwd_" + sid;
437
+ }
438
+ function readRememberedPassword(sid) {
439
+ if (!sid) return "";
440
+ try { return String(localStorage.getItem(sessionPwdKey(sid)) || ""); } catch { return ""; }
441
+ }
442
+ function readDashboardPassword() {
443
+ try { return String(localStorage.getItem("forge_dash_pwd") || ""); } catch { return ""; }
444
+ }
445
+ function rememberPassword(sid, pw) {
446
+ if (!sid || !pw) return;
447
+ try { localStorage.setItem(sessionPwdKey(sid), pw); } catch {}
448
+ }
449
+ function forgetPassword(sid) {
450
+ if (!sid) return;
451
+ try { localStorage.removeItem(sessionPwdKey(sid)); } catch {}
452
+ }
453
+ function askPassword(reason) {
454
+ if (pendingPasswordPrompt) return pendingPasswordPrompt;
455
+ pendingPasswordPrompt = new Promise((resolve) => {
456
+ authHint.textContent = String(reason || "Enter remote session password");
457
+ authPasswordInput.value = "";
458
+ authModal.classList.add("open");
459
+ setTimeout(() => authPasswordInput.focus(), 0);
460
+ const close = (value) => {
461
+ authModal.classList.remove("open");
462
+ authSubmitBtn.onclick = null;
463
+ authCancelBtn.onclick = null;
464
+ authPasswordInput.onkeydown = null;
465
+ pendingPasswordPrompt = null;
466
+ resolve(String(value || "").trim());
467
+ };
468
+ authSubmitBtn.onclick = () => close(authPasswordInput.value);
469
+ authCancelBtn.onclick = () => close("");
470
+ authPasswordInput.onkeydown = (ev) => {
471
+ if (ev.key === "Enter") {
472
+ ev.preventDefault();
473
+ close(authPasswordInput.value);
474
+ return;
475
+ }
476
+ if (ev.key === "Escape") {
477
+ ev.preventDefault();
478
+ close("");
479
+ }
480
+ };
481
+ });
482
+ return pendingPasswordPrompt;
483
+ }
484
+ async function resolveSessionPassword(sid, forcePrompt) {
485
+ if (forcePrompt) {
486
+ const entered = await askPassword("Remote session password required");
487
+ if (entered) rememberPassword(sid, entered);
488
+ return entered;
489
+ }
490
+ const fromUrl = String(new URLSearchParams(location.search).get("password") || "").trim();
491
+ if (fromUrl) {
492
+ rememberPassword(sid, fromUrl);
493
+ return fromUrl;
494
+ }
495
+ const remembered = String(readRememberedPassword(sid) || "").trim();
496
+ if (remembered) return remembered;
497
+ const dashboardPw = String(readDashboardPassword() || "").trim();
498
+ if (dashboardPw) {
499
+ rememberPassword(sid, dashboardPw);
500
+ return dashboardPw;
501
+ }
502
+ const fallback = String(pwdHint || "").trim();
503
+ if (fallback) return fallback;
504
+ const entered = await askPassword("Remote session password required");
505
+ if (entered) rememberPassword(sid, entered);
506
+ return entered;
507
+ }
508
+ function scheduleReconnect() {
509
+ if (reconnectTimer) return;
510
+ reconnectTimer = setTimeout(() => {
511
+ reconnectTimer = null;
512
+ if (ws || authed) return;
513
+ if (!currentSessionId()) return;
514
+ connect();
515
+ }, 2500);
516
+ }
517
+ function clearAuthWatchdog() {
518
+ if (authWatchdogTimer) {
519
+ clearTimeout(authWatchdogTimer);
520
+ authWatchdogTimer = null;
521
+ }
522
+ }
523
+ function armAuthWatchdog() {
524
+ clearAuthWatchdog();
525
+ authWatchdogTimer = setTimeout(() => {
526
+ if (!ws || ws.readyState !== 1 || authed) return;
527
+ if (authChallengeSeen) return;
528
+ setState("Handshake stalled — reconnecting...");
529
+ if (!hasFrame) {
530
+ showEmptyState(
531
+ "Connected to relay, but the session handshake is stalled. Retrying automatically...",
532
+ true
533
+ );
534
+ }
535
+ disconnect();
536
+ setTimeout(connect, 200);
537
+ }, 6500);
538
+ }
130
539
  function connect() {
131
- const sid = String(sessionEl.value || new URLSearchParams(location.search).get("session") || "").trim();
540
+ const sid = resolveSessionId();
132
541
  if (!sid) { setState("Session required"); return; }
133
- sessionEl.value = sid;
542
+ void refreshSessionAgentMeta(sid);
134
543
  const url = wsBaseUrl().replace(/\/+$/, "") + "/ws/viewer/" + encodeURIComponent(sid);
135
544
  disconnect();
545
+ hasFrame = false;
546
+ authChallengeSeen = false;
547
+ screenEl.removeAttribute("src");
136
548
  setState("Connecting…");
549
+ showEmptyState("Connecting to remote session...", false);
137
550
  ws = new WebSocket(url);
138
- ws.onopen = () => { setState("Connected — waiting auth"); ws.send(JSON.stringify({ type: "get_info" })); };
139
- ws.onclose = () => { authed = false; setState("Disconnected"); stopShotLoop(); };
140
- ws.onerror = () => { setState("Socket error"); };
551
+ ws.onopen = () => {
552
+ setState("Connected waiting auth");
553
+ if (!hasFrame) showEmptyState("Connected. Waiting for auth challenge...", false);
554
+ ws.send(JSON.stringify({ type: "get_info" }));
555
+ armAuthWatchdog();
556
+ };
557
+ ws.onclose = () => {
558
+ authed = false;
559
+ setState("Disconnected");
560
+ stopShotLoop();
561
+ clearAuthWatchdog();
562
+ if (!hasFrame) showEmptyState("Disconnected. Reconnecting...", true);
563
+ scheduleReconnect();
564
+ };
565
+ ws.onerror = () => {
566
+ setState("Socket error");
567
+ clearAuthWatchdog();
568
+ if (!hasFrame) showEmptyState("Network/socket error while connecting to remote screen.", true);
569
+ };
141
570
  ws.onmessage = async (ev) => {
142
571
  let msg = null;
143
572
  try { msg = JSON.parse(String(ev.data || "")); } catch { return; }
144
573
  const t = String(msg && msg.type || "");
145
574
  if (t === "auth_challenge") {
146
- const pwd = String(pwdEl.value || pwdHint || "");
575
+ authChallengeSeen = true;
576
+ clearAuthWatchdog();
577
+ const pwd = await resolveSessionPassword(sid, false);
578
+ if (!pwd) {
579
+ setState("Missing session password");
580
+ if (!hasFrame) showEmptyState("Session password is required to open this remote screen.", true);
581
+ return;
582
+ }
147
583
  const ph = await hashHex(pwd);
148
584
  const nonce = String(msg.nonce || "");
149
585
  const resp = await hashHex(ph + ":" + nonce);
@@ -152,8 +588,24 @@
152
588
  }
153
589
  if (t === "auth_result") {
154
590
  authed = !!msg.ok;
155
- setState(authed ? "Authenticated" : "Auth failed");
156
- if (authed) { startShotLoop(); requestScreenshot(); }
591
+ if (authed) {
592
+ clearAuthWatchdog();
593
+ setState("Authenticated");
594
+ startShotLoop();
595
+ requestScreenshot();
596
+ if (!hasFrame) showEmptyState("Connected. Waiting for first screenshot frame...", false);
597
+ } else {
598
+ forgetPassword(sid);
599
+ const entered = await resolveSessionPassword(sid, true);
600
+ if (entered) {
601
+ setState("Retrying with updated password...");
602
+ disconnect();
603
+ setTimeout(connect, 120);
604
+ } else {
605
+ setState("Auth failed");
606
+ if (!hasFrame) showEmptyState("Authentication failed. Please enter the correct session password.", true);
607
+ }
608
+ }
157
609
  return;
158
610
  }
159
611
  if (t === "system_info") {
@@ -165,6 +617,47 @@
165
617
  if (msg.ok && msg.b64) {
166
618
  const mime = String(msg.mime || "image/png");
167
619
  screenEl.src = "data:" + mime + ";base64," + String(msg.b64);
620
+ lastFrameMeta = {
621
+ imageWidth: Number.isFinite(Number(msg.width)) ? Math.max(1, Math.floor(Number(msg.width))) : 0,
622
+ imageHeight: Number.isFinite(Number(msg.height)) ? Math.max(1, Math.floor(Number(msg.height))) : 0,
623
+ virtualX: Number.isFinite(Number(msg.virtual_x)) ? Math.floor(Number(msg.virtual_x)) : 0,
624
+ virtualY: Number.isFinite(Number(msg.virtual_y)) ? Math.floor(Number(msg.virtual_y)) : 0,
625
+ virtualWidth: Number.isFinite(Number(msg.virtual_width)) ? Math.max(1, Math.floor(Number(msg.virtual_width))) : 0,
626
+ virtualHeight: Number.isFinite(Number(msg.virtual_height)) ? Math.max(1, Math.floor(Number(msg.virtual_height))) : 0,
627
+ };
628
+ hasFrame = true;
629
+ hideEmptyState();
630
+ } else if (!hasFrame) {
631
+ const em = String(msg.error || "").trim();
632
+ showEmptyState(em || "Remote session connected, but screenshot is not available yet.", true);
633
+ }
634
+ return;
635
+ }
636
+ if (t === "rc_input_result") {
637
+ if (!msg.ok) {
638
+ const em = String(msg.error || "").trim();
639
+ const low = em.toLowerCase();
640
+ if (
641
+ low.includes("unsupported remote control action: mouse_down") ||
642
+ low.includes("unsupported remote control action: mouse_up")
643
+ ) {
644
+ disablePressLifecycle = true;
645
+ setState("Drag control needs newer agent; click/scroll still work.");
646
+ return;
647
+ }
648
+ if (low.includes("here-string") || low.includes("parsererror") || low.includes("add-type")) {
649
+ writeEnabled = false;
650
+ modeBtn.textContent = "View Only";
651
+ modeStateEl.textContent = "Mode: View Only";
652
+ modeBtn.className = "alt";
653
+ screenEl.classList.remove("write-enabled");
654
+ updateWriteControls();
655
+ const ver = sessionAgentVersion ? (" v" + sessionAgentVersion) : "";
656
+ setState("Remote agent" + ver + " needs upgrade for control input. Open /files and click Upgrade agent.");
657
+ showEmptyState("Remote input failed due to outdated agent runtime. Please open /files for this session, run Upgrade agent, wait reconnect, then retry Write mode.", true);
658
+ return;
659
+ }
660
+ setState(em ? ("Input failed: " + em) : "Input failed");
168
661
  }
169
662
  return;
170
663
  }
@@ -179,15 +672,23 @@
179
672
  }
180
673
  function disconnect() {
181
674
  stopShotLoop();
675
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
676
+ clearAuthWatchdog();
182
677
  if (ws) { try { ws.close(); } catch {} ws = null; }
183
678
  authed = false;
184
679
  inflightShot = false;
680
+ dragActive = false;
681
+ pointerDown = false;
682
+ pointerButton = "left";
683
+ pointerDownPoint = null;
684
+ disablePressLifecycle = false;
185
685
  pendingReqs.clear();
186
686
  writeEnabled = false;
187
687
  modeBtn.textContent = "View Only";
188
688
  modeStateEl.textContent = "Mode: View Only";
189
689
  modeBtn.className = "alt";
190
690
  screenEl.classList.remove("write-enabled");
691
+ if (!hasFrame) showEmptyState("Disconnected from remote session.", true);
191
692
  updateWriteControls();
192
693
  }
193
694
  function stopShotLoop() {
@@ -200,6 +701,7 @@
200
701
  function requestScreenshot() {
201
702
  if (!ws || ws.readyState !== 1 || !authed || inflightShot) return;
202
703
  inflightShot = true;
704
+ if (!hasFrame) showEmptyState("Requesting screenshot frame...", false);
203
705
  ws.send(JSON.stringify({ type: "fs_screenshot", request_id: "shot_" + (++reqSeq) }));
204
706
  }
205
707
  function wsRequest(type, payload) {
@@ -215,6 +717,31 @@
215
717
  }, 8000);
216
718
  });
217
719
  }
720
+ async function pullClipboardToLocal() {
721
+ if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
722
+ const r = await wsRequest("rc_clipboard_get");
723
+ if (!r || !r.ok) { setState("Clipboard pull failed"); return; }
724
+ const text = String(r.text || "");
725
+ try {
726
+ await navigator.clipboard.writeText(text);
727
+ setState("Clipboard copied from PC to local");
728
+ } catch {
729
+ setState("Clipboard write blocked by browser");
730
+ }
731
+ }
732
+ async function pushLocalClipboardToRemote() {
733
+ if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
734
+ let text = "";
735
+ try {
736
+ text = await navigator.clipboard.readText();
737
+ } catch {
738
+ setState("Clipboard read blocked by browser");
739
+ return;
740
+ }
741
+ const r = await wsRequest("rc_clipboard_set", { text });
742
+ if (!r || !r.ok) { setState("Clipboard push failed"); return; }
743
+ setState("Clipboard sent from local to PC");
744
+ }
218
745
  async function pullRemoteFileToLocal(remotePath) {
219
746
  const p = String(remotePath || "").trim();
220
747
  if (!p) return { ok: false, error: "remote path required" };
@@ -344,17 +871,35 @@
344
871
  request_id: "rc_" + (++reqSeq),
345
872
  }, payload || {})));
346
873
  }
874
+ function isBrowserZoomHotkey(ev) {
875
+ if (!(ev.ctrlKey || ev.metaKey) || ev.altKey) return false;
876
+ const key = String(ev.key || "").toLowerCase();
877
+ return key === "+" || key === "-" || key === "=" || key === "_" || key === "0";
878
+ }
347
879
  function imgPoint(ev) {
348
880
  const r = screenEl.getBoundingClientRect();
349
881
  if (!r.width || !r.height || !screenEl.naturalWidth || !screenEl.naturalHeight) return null;
350
- const x = Math.max(0, Math.min(screenEl.naturalWidth - 1, Math.round((ev.clientX - r.left) * (screenEl.naturalWidth / r.width))));
351
- const y = Math.max(0, Math.min(screenEl.naturalHeight - 1, Math.round((ev.clientY - r.top) * (screenEl.naturalHeight / r.height))));
882
+ const px = Math.max(0, Math.min(screenEl.naturalWidth - 1, Math.round((ev.clientX - r.left) * (screenEl.naturalWidth / r.width))));
883
+ const py = Math.max(0, Math.min(screenEl.naturalHeight - 1, Math.round((ev.clientY - r.top) * (screenEl.naturalHeight / r.height))));
884
+ const iw = Number(lastFrameMeta && lastFrameMeta.imageWidth) || Number(screenEl.naturalWidth) || 0;
885
+ const ih = Number(lastFrameMeta && lastFrameMeta.imageHeight) || Number(screenEl.naturalHeight) || 0;
886
+ const vw = Number(lastFrameMeta && lastFrameMeta.virtualWidth) || iw;
887
+ const vh = Number(lastFrameMeta && lastFrameMeta.virtualHeight) || ih;
888
+ const vx = Number(lastFrameMeta && lastFrameMeta.virtualX) || 0;
889
+ const vy = Number(lastFrameMeta && lastFrameMeta.virtualY) || 0;
890
+ const x = vx + Math.round(px * (vw / Math.max(1, iw)));
891
+ const y = vy + Math.round(py * (vh / Math.max(1, ih)));
352
892
  return { x, y };
353
893
  }
354
894
 
355
- document.getElementById("connectBtn").addEventListener("click", connect);
356
895
  document.getElementById("disconnectBtn").addEventListener("click", disconnect);
357
- document.getElementById("refreshBtn").addEventListener("click", requestScreenshot);
896
+ document.getElementById("refreshBtn").addEventListener("click", () => {
897
+ if (!ws || ws.readyState !== 1) {
898
+ connect();
899
+ return;
900
+ }
901
+ requestScreenshot();
902
+ });
358
903
  modeBtn.addEventListener("click", () => {
359
904
  writeEnabled = !writeEnabled;
360
905
  modeBtn.textContent = writeEnabled ? "Write Only" : "View Only";
@@ -363,31 +908,6 @@
363
908
  screenEl.classList.toggle("write-enabled", writeEnabled);
364
909
  updateWriteControls();
365
910
  });
366
- clipPullBtn.addEventListener("click", async () => {
367
- if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
368
- const r = await wsRequest("rc_clipboard_get");
369
- if (!r || !r.ok) { setState("Clipboard pull failed"); return; }
370
- const text = String(r.text || "");
371
- try {
372
- await navigator.clipboard.writeText(text);
373
- setState("Clipboard copied from PC to local");
374
- } catch {
375
- setState("Clipboard write blocked by browser");
376
- }
377
- });
378
- clipPushBtn.addEventListener("click", async () => {
379
- if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
380
- let text = "";
381
- try {
382
- text = await navigator.clipboard.readText();
383
- } catch {
384
- setState("Clipboard read blocked by browser");
385
- return;
386
- }
387
- const r = await wsRequest("rc_clipboard_set", { text });
388
- if (!r || !r.ok) { setState("Clipboard push failed"); return; }
389
- setState("Clipboard sent from local to PC");
390
- });
391
911
  filePullBtn.addEventListener("click", async () => {
392
912
  if (!writeEnabled) { setState("Enable Write Only mode for file fetch"); return; }
393
913
  const p = String(filePullPath.value || "").trim();
@@ -446,7 +966,57 @@
446
966
  filePushInput.value = "";
447
967
  });
448
968
 
969
+ screenEl.addEventListener("mousedown", (ev) => {
970
+ if (!writeEnabled) return;
971
+ if (ev.button !== 0 && ev.button !== 1 && ev.button !== 2) return;
972
+ const p = imgPoint(ev); if (!p) return;
973
+ ev.preventDefault();
974
+ pointerDown = true;
975
+ pointerButton = ev.button === 2 ? "right" : (ev.button === 1 ? "middle" : "left");
976
+ pointerDownPoint = p;
977
+ dragActive = false;
978
+ });
979
+ window.addEventListener("mouseup", (ev) => {
980
+ if (!writeEnabled || !pointerDown) return;
981
+ if (ev.button !== 0 && ev.button !== 1 && ev.button !== 2) return;
982
+ const p = imgPoint(ev) || pointerDownPoint;
983
+ ev.preventDefault();
984
+ if (dragActive && p && !disablePressLifecycle) {
985
+ sendRemoteInput({ action: "mouse_up", button: pointerButton, x: p.x, y: p.y });
986
+ suppressClickUntil = Date.now() + 220;
987
+ requestScreenshot();
988
+ }
989
+ pointerDown = false;
990
+ pointerDownPoint = null;
991
+ dragActive = false;
992
+ });
993
+ screenEl.addEventListener("mousemove", (ev) => {
994
+ if (!writeEnabled) return;
995
+ const p = imgPoint(ev); if (!p) return;
996
+ if (pointerDown && !disablePressLifecycle) {
997
+ if (!dragActive && pointerDownPoint) {
998
+ const dx = Math.abs(p.x - pointerDownPoint.x);
999
+ const dy = Math.abs(p.y - pointerDownPoint.y);
1000
+ if (dx + dy >= 8) {
1001
+ dragActive = true;
1002
+ sendRemoteInput({ action: "mouse_down", button: pointerButton, x: pointerDownPoint.x, y: pointerDownPoint.y });
1003
+ }
1004
+ }
1005
+ if (dragActive) {
1006
+ ev.preventDefault();
1007
+ sendRemoteInput({ action: "mouse_move", x: p.x, y: p.y });
1008
+ return;
1009
+ }
1010
+ }
1011
+ sendRemoteInput({ action: "mouse_move", x: p.x, y: p.y });
1012
+ });
1013
+ screenEl.addEventListener("dragstart", (ev) => {
1014
+ if (!writeEnabled) return;
1015
+ ev.preventDefault();
1016
+ });
449
1017
  screenEl.addEventListener("click", (ev) => {
1018
+ if (!writeEnabled) return;
1019
+ if (Date.now() < suppressClickUntil) return;
450
1020
  const p = imgPoint(ev); if (!p) return;
451
1021
  sendRemoteInput({ action: "mouse_click", button: ev.button === 2 ? "right" : "left", x: p.x, y: p.y, click_count: 1 });
452
1022
  requestScreenshot();
@@ -458,39 +1028,41 @@
458
1028
  });
459
1029
  screenEl.addEventListener("contextmenu", (ev) => ev.preventDefault());
460
1030
  wrapEl.addEventListener("wheel", (ev) => {
1031
+ if (ev.ctrlKey || ev.metaKey) return; // Keep browser zoom (ctrl/cmd + wheel) working.
461
1032
  if (!writeEnabled) return;
462
1033
  ev.preventDefault();
463
- sendRemoteInput({ action: "mouse_wheel", delta_y: Math.round(ev.deltaY) });
1034
+ const p = imgPoint(ev);
1035
+ sendRemoteInput({ action: "mouse_wheel", delta_y: Math.round(ev.deltaY), x: p ? p.x : undefined, y: p ? p.y : undefined });
464
1036
  }, { passive: false });
465
1037
  window.addEventListener("keydown", (ev) => {
466
1038
  if (!writeEnabled) return;
467
- if (ev.ctrlKey && ev.shiftKey && !ev.altKey && !ev.metaKey && String(ev.key || "").toLowerCase() === "c") {
1039
+ if (["INPUT", "TEXTAREA"].includes(String(document.activeElement && document.activeElement.tagName || ""))) return;
1040
+ if (isBrowserZoomHotkey(ev)) return; // Let browser handle Ctrl/Cmd +/-/0 zoom.
1041
+ const ctrlOrMeta = ev.ctrlKey || ev.metaKey;
1042
+ if (ctrlOrMeta && !ev.shiftKey && !ev.altKey && String(ev.key || "").toLowerCase() === "c") {
468
1043
  ev.preventDefault();
469
1044
  if (!remoteClipboardBusy) {
470
1045
  remoteClipboardBusy = true;
471
- clipPullBtn.click();
1046
+ void pullClipboardToLocal();
472
1047
  setTimeout(() => { remoteClipboardBusy = false; }, 1200);
473
1048
  }
474
1049
  return;
475
1050
  }
476
- if (ev.ctrlKey && ev.shiftKey && !ev.altKey && !ev.metaKey && String(ev.key || "").toLowerCase() === "v") {
1051
+ if (ctrlOrMeta && !ev.shiftKey && !ev.altKey && String(ev.key || "").toLowerCase() === "v") {
477
1052
  ev.preventDefault();
478
1053
  if (!localClipboardBusy) {
479
1054
  localClipboardBusy = true;
480
- clipPushBtn.click();
1055
+ void pushLocalClipboardToRemote();
481
1056
  setTimeout(() => { localClipboardBusy = false; }, 1200);
482
1057
  }
483
1058
  return;
484
1059
  }
485
- if (["INPUT", "TEXTAREA"].includes(String(document.activeElement && document.activeElement.tagName || ""))) return;
486
1060
  ev.preventDefault();
487
1061
  sendRemoteInput({ action: "key", key: ev.key, ctrl: ev.ctrlKey, alt: ev.altKey, shift: ev.shiftKey, meta: ev.metaKey });
488
1062
  });
489
1063
 
490
- const sid0 = new URLSearchParams(location.search).get("session");
491
- if (sid0) sessionEl.value = sid0;
492
- if (pwdHint) pwdEl.value = pwdHint;
493
1064
  updateWriteControls();
1065
+ connect();
494
1066
  </script>
495
1067
  </body>
496
1068
  </html>