rms-devremote 3.0.0

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 (68) hide show
  1. package/README.md +154 -0
  2. package/dist/commands/attach.d.ts +2 -0
  3. package/dist/commands/attach.js +10 -0
  4. package/dist/commands/check.d.ts +2 -0
  5. package/dist/commands/check.js +210 -0
  6. package/dist/commands/clean.d.ts +2 -0
  7. package/dist/commands/clean.js +177 -0
  8. package/dist/commands/dashboard.d.ts +2 -0
  9. package/dist/commands/dashboard.js +57 -0
  10. package/dist/commands/link.d.ts +2 -0
  11. package/dist/commands/link.js +112 -0
  12. package/dist/commands/ping.d.ts +2 -0
  13. package/dist/commands/ping.js +21 -0
  14. package/dist/commands/setup.d.ts +2 -0
  15. package/dist/commands/setup.js +54 -0
  16. package/dist/commands/status.d.ts +2 -0
  17. package/dist/commands/status.js +65 -0
  18. package/dist/commands/unlink.d.ts +2 -0
  19. package/dist/commands/unlink.js +53 -0
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.js +55 -0
  22. package/dist/server/auth.d.ts +6 -0
  23. package/dist/server/auth.js +32 -0
  24. package/dist/server/frontend.d.ts +4 -0
  25. package/dist/server/frontend.js +886 -0
  26. package/dist/server/index.d.ts +1 -0
  27. package/dist/server/index.js +283 -0
  28. package/dist/server/terminal.d.ts +14 -0
  29. package/dist/server/terminal.js +43 -0
  30. package/dist/services/battery-worker.d.ts +1 -0
  31. package/dist/services/battery-worker.js +2 -0
  32. package/dist/services/battery.d.ts +27 -0
  33. package/dist/services/battery.js +152 -0
  34. package/dist/services/config.d.ts +63 -0
  35. package/dist/services/config.js +84 -0
  36. package/dist/services/docker.d.ts +25 -0
  37. package/dist/services/docker.js +75 -0
  38. package/dist/services/hooks.d.ts +15 -0
  39. package/dist/services/hooks.js +111 -0
  40. package/dist/services/ntfy.d.ts +19 -0
  41. package/dist/services/ntfy.js +63 -0
  42. package/dist/services/process.d.ts +30 -0
  43. package/dist/services/process.js +90 -0
  44. package/dist/services/proxy-worker.d.ts +1 -0
  45. package/dist/services/proxy-worker.js +12 -0
  46. package/dist/services/proxy.d.ts +4 -0
  47. package/dist/services/proxy.js +195 -0
  48. package/dist/services/shell.d.ts +22 -0
  49. package/dist/services/shell.js +47 -0
  50. package/dist/services/tmux.d.ts +30 -0
  51. package/dist/services/tmux.js +74 -0
  52. package/dist/services/ttyd.d.ts +28 -0
  53. package/dist/services/ttyd.js +71 -0
  54. package/dist/setup-server/routes.d.ts +4 -0
  55. package/dist/setup-server/routes.js +177 -0
  56. package/dist/setup-server/server.d.ts +4 -0
  57. package/dist/setup-server/server.js +32 -0
  58. package/docker/docker-compose.yml +24 -0
  59. package/docker/ntfy/server.yml +6 -0
  60. package/package.json +61 -0
  61. package/scripts/claude-remote.sh +583 -0
  62. package/scripts/hooks/notify.sh +68 -0
  63. package/scripts/notify.sh +54 -0
  64. package/scripts/startup.sh +29 -0
  65. package/scripts/update-check.sh +25 -0
  66. package/src/setup-server/public/index.html +21 -0
  67. package/src/setup-server/public/setup.css +475 -0
  68. package/src/setup-server/public/setup.js +687 -0
@@ -0,0 +1,886 @@
1
+ /**
2
+ * Generates the complete frontend HTML for the devremote terminal PWA.
3
+ */
4
+ export function buildFrontendHTML() {
5
+ return `<!DOCTYPE html>
6
+ <html lang="en">
7
+ <head>
8
+ <meta charset="UTF-8">
9
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
10
+ <meta name="theme-color" content="#000000">
11
+ <meta name="apple-mobile-web-app-capable" content="yes">
12
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
13
+ <link rel="manifest" href="/manifest.json">
14
+ <link rel="icon" href="/icon.svg" type="image/svg+xml">
15
+ <link rel="apple-touch-icon" href="/icon.svg">
16
+ <title>devremote</title>
17
+ <link rel="stylesheet" href="/xterm.css">
18
+ <style>${CSS}</style>
19
+ </head>
20
+ <body>
21
+ <!-- Loading screen -->
22
+ <div id="loading">
23
+ <div class="ld-logo">&gt;_</div>
24
+ <div class="ld-text">devremote</div>
25
+ </div>
26
+
27
+ <!-- Header -->
28
+ <header id="header" class="hidden">
29
+ <div class="h-left">
30
+ <span class="h-dot" id="status-dot"></span>
31
+ <span class="h-title">devremote</span>
32
+ <span class="h-status" id="status-text">connecting</span>
33
+ </div>
34
+ <div class="h-right">
35
+ <button class="h-btn" id="btn-kb" title="Keyboard">&#x2328;</button>
36
+ <button class="h-btn" id="btn-toggle" title="Toolbar">&#x2699;</button>
37
+ </div>
38
+ </header>
39
+
40
+ <!-- Info panel (slides down from header) -->
41
+ <div id="info-panel" class="hidden">
42
+ <div class="info-row"><span class="info-label">Status</span><span class="info-value" id="info-status">--</span></div>
43
+ <div class="info-row"><span class="info-label">Path</span><span class="info-value info-path" id="info-path">--</span></div>
44
+ <div class="info-row"><span class="info-label">tmux</span><span class="info-value" id="info-tmux">--</span></div>
45
+ <div class="info-row"><span class="info-label">Domain</span><span class="info-value" id="info-domain">--</span></div>
46
+ <div class="info-row"><span class="info-label">Uptime</span><span class="info-value" id="info-uptime">--</span></div>
47
+ </div>
48
+
49
+ <!-- Terminal -->
50
+ <div id="terminal-container" class="hidden"></div>
51
+
52
+ <!-- Disconnect screen -->
53
+ <div id="disconnect" class="hidden">
54
+ <div class="dc-card">
55
+ <div class="dc-icon">&gt;_</div>
56
+ <div class="dc-title" id="dc-title">No Terminal Connected</div>
57
+ <div class="dc-reason" id="dc-reason">Waiting for connection...</div>
58
+ <button class="dc-retry" id="dc-retry">Retry</button>
59
+ <div class="dc-hint">Run <code>rms-devremote link</code> on the host machine.</div>
60
+ </div>
61
+ </div>
62
+
63
+ <!-- Gesture hint (one-time) -->
64
+ <div id="gesture-hint" class="hidden">
65
+ <span>Swipe: &#x2191;&#x2193; arrows &bull; &#x2190; Esc &bull; &#x2192; Tab &bull; Double-tap: Enter</span>
66
+ </div>
67
+
68
+ <!-- Input bar -->
69
+ <div id="input-bar" class="hidden">
70
+ <input type="text" id="input-field" placeholder="command..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
71
+ <button id="input-send" title="Send">&#x27A4;</button>
72
+ </div>
73
+
74
+ <!-- Normal toolbar -->
75
+ <div id="toolbar" class="hidden">
76
+ <div class="tb-row">
77
+ <button class="tb" data-key="ArrowLeft" data-repeat="true">&#x25C0;</button>
78
+ <button class="tb" data-key="ArrowUp" data-repeat="true">&#x25B2;</button>
79
+ <button class="tb" data-key="ArrowDown" data-repeat="true">&#x25BC;</button>
80
+ <button class="tb" data-key="ArrowRight" data-repeat="true">&#x25B6;</button>
81
+ <button class="tb accent" data-key="Enter">Enter</button>
82
+ <button class="tb" id="btn-bksp" data-repeat="true">&#x232B;</button>
83
+ </div>
84
+ <div class="tb-row">
85
+ <button class="tb" data-key="Escape">Esc</button>
86
+ <button class="tb" data-key="Tab">Tab</button>
87
+ <button class="tb" id="btn-y">Y</button>
88
+ <button class="tb" id="btn-n">N</button>
89
+ <button class="tb" data-ctrl="c">^C</button>
90
+ <button class="tb" id="btn-scroll">Scroll</button>
91
+ </div>
92
+ </div>
93
+
94
+ <!-- Scroll toolbar (shown in tmux copy-mode) -->
95
+ <div id="toolbar-scroll" class="hidden">
96
+ <div class="tb-row">
97
+ <button class="tb" id="scroll-up">&#x25B2; Up</button>
98
+ <button class="tb" id="scroll-down">&#x25BC; Down</button>
99
+ <button class="tb scroll-active" id="btn-scroll-exit">Exit</button>
100
+ </div>
101
+ </div>
102
+
103
+ <script src="/xterm.js"></script>
104
+ <script src="/xterm-addon-fit.js"></script>
105
+ <script src="/xterm-addon-web-links.js"></script>
106
+ <script>${JS}</script>
107
+ </body>
108
+ </html>`;
109
+ }
110
+ // ---------------------------------------------------------------------------
111
+ // CSS
112
+ // ---------------------------------------------------------------------------
113
+ const CSS = `
114
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
115
+
116
+ :root {
117
+ --bg: #000000;
118
+ --surface: #0a0a0a;
119
+ --surface2: #151515;
120
+ --accent: #00ffaa;
121
+ --accent-dim: #00cc88;
122
+ --danger: #ff3355;
123
+ --text: #e8e8e8;
124
+ --text-bright: #ffffff;
125
+ --muted: #888888;
126
+ --border: #333333;
127
+ --header-h: 44px;
128
+ --input-h: 48px;
129
+ --toolbar-h: 104px;
130
+ --safe-t: env(safe-area-inset-top, 0px);
131
+ --safe-b: env(safe-area-inset-bottom, 0px);
132
+ --safe-l: env(safe-area-inset-left, 0px);
133
+ --safe-r: env(safe-area-inset-right, 0px);
134
+ }
135
+
136
+ html, body {
137
+ height: 100%; width: 100%;
138
+ background: var(--bg); color: var(--text);
139
+ font-family: -apple-system, system-ui, sans-serif;
140
+ overflow: hidden;
141
+ -webkit-text-size-adjust: 100%;
142
+ }
143
+ body { touch-action: none; }
144
+ .hidden { display: none !important; }
145
+
146
+ /* ── Loading ────────────────────────────── */
147
+ #loading {
148
+ position: fixed; inset: 0;
149
+ display: flex; flex-direction: column;
150
+ align-items: center; justify-content: center;
151
+ background: var(--bg); z-index: 999;
152
+ animation: fadeOut 0.4s 1.2s forwards;
153
+ }
154
+ .ld-logo {
155
+ font-family: monospace; font-size: 56px; font-weight: 700;
156
+ color: var(--accent); text-shadow: 0 0 20px rgba(0,255,170,0.3);
157
+ }
158
+ .ld-text {
159
+ font-size: 15px; font-weight: 600; letter-spacing: 0.12em;
160
+ color: var(--muted); margin-top: 14px; text-transform: uppercase;
161
+ }
162
+ @keyframes fadeOut { to { opacity: 0; pointer-events: none; } }
163
+
164
+ /* ── Header ─────────────────────────────── */
165
+ #header {
166
+ position: fixed; top: 0; left: 0; right: 0;
167
+ height: calc(var(--header-h) + var(--safe-t));
168
+ padding-top: var(--safe-t);
169
+ padding-left: calc(14px + var(--safe-l));
170
+ padding-right: calc(14px + var(--safe-r));
171
+ background: var(--surface);
172
+ border-bottom: 2px solid var(--border);
173
+ display: flex; align-items: center; justify-content: space-between;
174
+ z-index: 100;
175
+ }
176
+ .h-left { display: flex; align-items: center; gap: 10px; }
177
+ .h-right { display: flex; align-items: center; gap: 8px; }
178
+ .h-dot {
179
+ width: 10px; height: 10px; border-radius: 50%;
180
+ background: var(--danger); transition: background 0.3s, box-shadow 0.3s;
181
+ border: 1px solid rgba(255,255,255,0.1);
182
+ }
183
+ .h-dot.ok { background: var(--accent); box-shadow: 0 0 10px rgba(0,255,170,0.6); animation: pulse 2s infinite; }
184
+ @keyframes pulse {
185
+ 0%, 100% { box-shadow: 0 0 10px rgba(0,255,170,0.5); }
186
+ 50% { box-shadow: 0 0 18px rgba(0,255,170,0.9); }
187
+ }
188
+ .h-title { font-size: 15px; font-weight: 800; letter-spacing: 0.08em; color: var(--text-bright); }
189
+ .h-status { font-size: 12px; color: var(--muted); font-weight: 500; }
190
+ .h-btn {
191
+ background: var(--surface2); border: 2px solid var(--border); border-radius: 8px;
192
+ width: 36px; height: 36px;
193
+ display: flex; align-items: center; justify-content: center;
194
+ color: var(--muted); font-size: 18px; cursor: pointer;
195
+ -webkit-tap-highlight-color: transparent;
196
+ transition: border-color 0.15s, color 0.15s, background 0.15s;
197
+ }
198
+ .h-btn:active, .h-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(0,255,170,0.08); }
199
+
200
+ /* ── Info panel ──────────────────────────── */
201
+ #info-panel {
202
+ position: fixed;
203
+ top: calc(var(--header-h) + var(--safe-t));
204
+ left: 0; right: 0;
205
+ background: var(--surface);
206
+ border-bottom: 2px solid var(--border);
207
+ padding: 12px calc(14px + var(--safe-l)) 14px calc(14px + var(--safe-r));
208
+ z-index: 90;
209
+ display: flex; flex-direction: column; gap: 8px;
210
+ animation: slideDown 0.2s ease-out;
211
+ }
212
+ @keyframes slideDown { from { transform: translateY(-100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
213
+ #info-panel.hidden { display: none !important; }
214
+ .info-row {
215
+ display: flex; justify-content: space-between; align-items: center;
216
+ padding: 4px 0;
217
+ }
218
+ .info-label { font-size: 13px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
219
+ .info-value { font-size: 14px; color: var(--text-bright); font-weight: 500; font-family: 'JetBrains Mono', monospace; }
220
+ .info-value.ok { color: var(--accent); }
221
+ .info-value.err { color: var(--danger); }
222
+ .info-value.info-path { font-size: 12px; max-width: 60%; text-align: right; word-break: break-all; }
223
+
224
+ /* ── Terminal ───────────────────────────── */
225
+ #terminal-container {
226
+ position: fixed;
227
+ top: calc(var(--header-h) + var(--safe-t)); left: 0; right: 0;
228
+ bottom: calc(var(--input-h) + var(--toolbar-h) + var(--safe-b));
229
+ background: var(--bg);
230
+ opacity: 0; animation: fadeIn 0.3s 0.2s forwards;
231
+ overflow: hidden;
232
+ }
233
+ @keyframes fadeIn { to { opacity: 1; } }
234
+ #terminal-container.no-input { bottom: calc(var(--toolbar-h) + var(--safe-b)); }
235
+ #terminal-container.no-toolbar { bottom: calc(var(--input-h) + var(--safe-b)); }
236
+ #terminal-container.no-input.no-toolbar { bottom: var(--safe-b); }
237
+ .xterm { height: 100%; }
238
+ .xterm-helper-textarea {
239
+ opacity: 0 !important;
240
+ position: absolute !important; left: -9999px !important; top: -9999px !important;
241
+ }
242
+
243
+ /* Auto-hide toolbar when keyboard is open */
244
+ body.keyboard-open #toolbar { display: none !important; }
245
+ body.keyboard-open #terminal-container { bottom: calc(var(--input-h) + var(--safe-b)) !important; }
246
+
247
+ /* ── Disconnect screen ──────────────────── */
248
+ #disconnect {
249
+ position: fixed; inset: 0;
250
+ background: var(--bg);
251
+ display: flex; align-items: center; justify-content: center;
252
+ z-index: 150; animation: fadeIn 0.3s;
253
+ }
254
+ .dc-card {
255
+ display: flex; flex-direction: column; align-items: center;
256
+ gap: 20px; padding: 40px 32px; max-width: 340px; text-align: center;
257
+ }
258
+ .dc-icon {
259
+ font-family: monospace; font-size: 64px; font-weight: 700;
260
+ color: var(--muted); opacity: 0.4;
261
+ }
262
+ .dc-icon.error { color: var(--danger); opacity: 0.7; }
263
+ .dc-icon.loading { animation: pulse-icon 1.5s infinite; }
264
+ @keyframes pulse-icon {
265
+ 0%, 100% { opacity: 0.3; }
266
+ 50% { opacity: 0.6; }
267
+ }
268
+ .dc-title { font-size: 20px; font-weight: 700; color: var(--text-bright); }
269
+ .dc-reason { font-size: 14px; color: var(--muted); line-height: 1.5; }
270
+ .dc-retry {
271
+ padding: 14px 36px;
272
+ background: var(--accent); border: none; border-radius: 10px;
273
+ color: var(--bg); font-size: 16px; font-weight: 700;
274
+ cursor: pointer; -webkit-tap-highlight-color: transparent;
275
+ transition: background 0.15s; text-transform: uppercase; letter-spacing: 0.05em;
276
+ }
277
+ .dc-retry:active { background: var(--accent-dim); }
278
+ .dc-hint { font-size: 13px; color: var(--muted); line-height: 1.6; }
279
+ .dc-hint code {
280
+ background: var(--surface2); padding: 3px 8px; border-radius: 5px;
281
+ font-size: 12px; color: var(--accent); border: 1px solid var(--border);
282
+ }
283
+
284
+ /* ── Gesture hint ───────────────────────── */
285
+ #gesture-hint {
286
+ position: fixed; bottom: calc(var(--input-h) + var(--toolbar-h) + var(--safe-b) + 10px);
287
+ left: 50%; transform: translateX(-50%);
288
+ background: var(--surface2); border: 1px solid var(--border);
289
+ border-radius: 10px; padding: 10px 18px;
290
+ font-size: 12px; color: var(--muted); white-space: nowrap;
291
+ z-index: 110; animation: fadeIn 0.3s, fadeOut 0.5s 4s forwards;
292
+ }
293
+
294
+ /* ── Input bar ──────────────────────────── */
295
+ #input-bar {
296
+ position: fixed; left: 0; right: 0;
297
+ bottom: calc(var(--toolbar-h) + var(--safe-b));
298
+ height: var(--input-h);
299
+ background: var(--surface);
300
+ border-top: 2px solid var(--accent);
301
+ display: flex; align-items: center;
302
+ padding: 0 calc(10px + var(--safe-l)) 0 calc(10px + var(--safe-r));
303
+ gap: 8px; z-index: 100;
304
+ }
305
+ #input-field {
306
+ flex: 1; height: 38px;
307
+ background: #111111; border: 2px solid #444444;
308
+ border-radius: 8px; padding: 0 14px;
309
+ color: #ffffff; font-size: 17px;
310
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
311
+ outline: none; caret-color: var(--accent);
312
+ }
313
+ #input-field:focus { border-color: var(--accent); background: #1a1a1a; box-shadow: 0 0 8px rgba(0,255,170,0.15); }
314
+ #input-field::placeholder { color: #999999; font-family: -apple-system, system-ui, sans-serif; font-size: 15px; }
315
+ #input-send {
316
+ width: 48px; height: 38px;
317
+ background: var(--accent); border: none; border-radius: 8px;
318
+ color: var(--bg); font-size: 20px; font-weight: 700; cursor: pointer;
319
+ display: flex; align-items: center; justify-content: center;
320
+ -webkit-tap-highlight-color: transparent;
321
+ }
322
+ #input-send:active { background: var(--accent-dim); }
323
+
324
+ /* ── Toolbar ────────────────────────────── */
325
+ #toolbar {
326
+ position: fixed; bottom: 0; left: 0; right: 0;
327
+ height: calc(var(--toolbar-h) + var(--safe-b));
328
+ padding: 6px calc(6px + var(--safe-l)) calc(6px + var(--safe-b)) calc(6px + var(--safe-r));
329
+ background: var(--surface);
330
+ border-top: 2px solid var(--border);
331
+ display: flex; flex-direction: column;
332
+ justify-content: center; gap: 6px; z-index: 100;
333
+ }
334
+ .tb-row { display: flex; gap: 5px; justify-content: center; }
335
+ .tb {
336
+ display: flex; align-items: center; justify-content: center;
337
+ flex: 1; max-width: 72px; height: 44px;
338
+ background: var(--surface2); border: 2px solid var(--border);
339
+ border-radius: 10px; font-size: 15px; font-weight: 600;
340
+ color: var(--text-bright); cursor: pointer; user-select: none;
341
+ -webkit-tap-highlight-color: transparent;
342
+ transition: background 0.08s, transform 0.06s;
343
+ }
344
+ .tb:active { background: #222222; transform: scale(0.92); border-color: var(--accent); }
345
+ .tb.accent {
346
+ background: var(--accent); color: var(--bg);
347
+ font-weight: 800; border-color: var(--accent);
348
+ }
349
+ .tb.accent:active { background: var(--accent-dim); }
350
+ .tb.scroll-active { background: var(--danger); color: #fff; border-color: var(--danger); font-weight: 800; }
351
+
352
+ /* ── Scroll toolbar ─────────────────────── */
353
+ #toolbar-scroll {
354
+ position: fixed; bottom: 0; left: 0; right: 0;
355
+ height: calc(56px + var(--safe-b));
356
+ padding: 6px calc(8px + var(--safe-l)) calc(6px + var(--safe-b)) calc(8px + var(--safe-r));
357
+ background: var(--surface);
358
+ border-top: 3px solid var(--danger);
359
+ display: flex; flex-direction: column;
360
+ justify-content: center; z-index: 100;
361
+ }
362
+ #toolbar-scroll .tb { flex: 1; max-width: 140px; height: 44px; font-size: 16px; font-weight: 700; }
363
+ #toolbar-scroll.hidden { display: none !important; }
364
+
365
+ /* ── Landscape mode ─────────────────────── */
366
+ @media (orientation: landscape) {
367
+ :root {
368
+ --header-h: 36px;
369
+ --input-h: 40px;
370
+ --toolbar-h: 52px;
371
+ }
372
+ .tb { height: 38px; font-size: 14px; max-width: 80px; }
373
+ .tb-row { gap: 4px; }
374
+ #toolbar { gap: 0; }
375
+ #toolbar .tb-row:last-child { display: none; }
376
+ .h-title { font-size: 13px; }
377
+ .h-btn { width: 32px; height: 32px; }
378
+ #input-field { height: 32px; font-size: 14px; }
379
+ #input-send { width: 38px; height: 32px; }
380
+ #toolbar-scroll { height: calc(46px + var(--safe-b)); }
381
+ #toolbar-scroll .tb { height: 38px; }
382
+ }
383
+ `;
384
+ // ---------------------------------------------------------------------------
385
+ // JS
386
+ // ---------------------------------------------------------------------------
387
+ const JS = `
388
+ (function() {
389
+ 'use strict';
390
+
391
+ var RECONNECT_DELAY = 2000;
392
+ var MAX_RECONNECT = 10;
393
+ var DOUBLE_TAP_MS = 300;
394
+ var VIBRATE = 10;
395
+
396
+ // ── Elements ─────────────────────────────────────────────
397
+ var loading = document.getElementById('loading');
398
+ var header = document.getElementById('header');
399
+ var container = document.getElementById('terminal-container');
400
+ var statusDot = document.getElementById('status-dot');
401
+ var statusText = document.getElementById('status-text');
402
+ var toolbar = document.getElementById('toolbar');
403
+ var inputBar = document.getElementById('input-bar');
404
+ var inputField = document.getElementById('input-field');
405
+ var inputSend = document.getElementById('input-send');
406
+ var btnToggle = document.getElementById('btn-toggle');
407
+ var btnKb = document.getElementById('btn-kb');
408
+ var disconnect = document.getElementById('disconnect');
409
+ var dcTitle = document.getElementById('dc-title');
410
+ var dcReason = document.getElementById('dc-reason');
411
+ var dcRetry = document.getElementById('dc-retry');
412
+ var dcIcon = document.querySelector('.dc-icon');
413
+ var gestureHint = document.getElementById('gesture-hint');
414
+
415
+ // ── Block zoom (but allow 2-finger scroll on terminal) ──
416
+ document.addEventListener('gesturestart', function(e) { e.preventDefault(); }, { passive: false });
417
+ document.addEventListener('gesturechange', function(e) { e.preventDefault(); }, { passive: false });
418
+ document.addEventListener('touchmove', function(e) {
419
+ if (e.touches.length > 1 && !container.contains(e.target)) e.preventDefault();
420
+ }, { passive: false });
421
+
422
+ // ── Terminal setup ───────────────────────────────────────
423
+ var term = new Terminal({
424
+ cursorBlink: true,
425
+ fontSize: 16,
426
+ fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace",
427
+ theme: {
428
+ background: '#000000',
429
+ foreground: '#e8e8e8',
430
+ cursor: '#00ffaa',
431
+ cursorAccent: '#000000',
432
+ selectionBackground: 'rgba(0, 255, 170, 0.2)',
433
+ black: '#151515', red: '#ff3355', green: '#00ffaa', yellow: '#ffcc00',
434
+ blue: '#4499ff', magenta: '#cc66ff', cyan: '#00ddff', white: '#e8e8e8',
435
+ brightBlack: '#666666', brightRed: '#ff6680', brightGreen: '#66ffcc',
436
+ brightYellow: '#ffdd55', brightBlue: '#77bbff', brightMagenta: '#dd88ff',
437
+ brightCyan: '#66eeff', brightWhite: '#ffffff'
438
+ },
439
+ allowProposedApi: true,
440
+ scrollback: 5000,
441
+ convertEol: true,
442
+ disableStdin: false
443
+ });
444
+
445
+ var fitAddon = new FitAddon.FitAddon();
446
+ var webLinksAddon = new WebLinksAddon.WebLinksAddon();
447
+ term.loadAddon(fitAddon);
448
+ term.loadAddon(webLinksAddon);
449
+ term.open(container);
450
+ setTimeout(function() { fitAddon.fit(); }, 100);
451
+
452
+ // ── Show app after loading ───────────────────────────────
453
+ setTimeout(function() {
454
+ loading.classList.add('hidden');
455
+ header.classList.remove('hidden');
456
+ }, 1600);
457
+
458
+ // ── WebSocket ────────────────────────────────────────────
459
+ var ws = null;
460
+ var reconnectAttempts = 0;
461
+ var reconnectTimer = null;
462
+ var connected = false;
463
+
464
+ function getWsUrl() {
465
+ var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
466
+ return proto + '//' + location.host + '/ws';
467
+ }
468
+
469
+ function showTerminal() {
470
+ disconnect.classList.add('hidden');
471
+ container.classList.remove('hidden');
472
+ inputBar.classList.remove('hidden');
473
+ toolbar.classList.remove('hidden');
474
+ connected = true;
475
+ setTimeout(doResize, 50);
476
+ showGestureHint();
477
+ }
478
+
479
+ function showDisconnect(title, reason, iconClass) {
480
+ container.classList.add('hidden');
481
+ inputBar.classList.add('hidden');
482
+ toolbar.classList.add('hidden');
483
+ disconnect.classList.remove('hidden');
484
+ dcTitle.textContent = title;
485
+ dcReason.textContent = reason;
486
+ dcIcon.className = 'dc-icon' + (iconClass ? ' ' + iconClass : '');
487
+ connected = false;
488
+ }
489
+
490
+ function connect() {
491
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
492
+
493
+ ws = new WebSocket(getWsUrl());
494
+
495
+ ws.onopen = function() {
496
+ reconnectAttempts = 0;
497
+ setStatus('connected', true);
498
+ showTerminal();
499
+
500
+ var dims = fitAddon.proposeDimensions();
501
+ ws.send(JSON.stringify({
502
+ type: 'init',
503
+ cols: dims ? dims.cols : 80,
504
+ rows: dims ? dims.rows : 24
505
+ }));
506
+ };
507
+
508
+ ws.onmessage = function(evt) {
509
+ try {
510
+ var msg = JSON.parse(evt.data);
511
+ if (msg.type === 'output') term.write(msg.data);
512
+ else if (msg.type === 'exit') {
513
+ showDisconnect('Session Ended', 'The tmux session has ended.', 'error');
514
+ setStatus('session ended', false);
515
+ }
516
+ } catch(e) {}
517
+ };
518
+
519
+ ws.onclose = function(evt) {
520
+ setStatus('disconnected', false);
521
+ if (evt.code === 1008 || evt.code === 4409) {
522
+ showDisconnect('Already Connected', 'Another client is already connected to this terminal.', 'error');
523
+ } else {
524
+ scheduleReconnect();
525
+ }
526
+ };
527
+
528
+ ws.onerror = function() {};
529
+ }
530
+
531
+ function scheduleReconnect() {
532
+ if (reconnectAttempts >= MAX_RECONNECT) {
533
+ showDisconnect('Connection Failed', 'Could not reach the server after ' + MAX_RECONNECT + ' attempts.', 'error');
534
+ setStatus('connection failed', false);
535
+ return;
536
+ }
537
+ reconnectAttempts++;
538
+ showDisconnect('Reconnecting...', 'Attempt ' + reconnectAttempts + ' of ' + MAX_RECONNECT, 'loading');
539
+ setStatus('reconnecting', false);
540
+ clearTimeout(reconnectTimer);
541
+ reconnectTimer = setTimeout(connect, RECONNECT_DELAY);
542
+ }
543
+
544
+ function setStatus(text, ok) {
545
+ statusText.textContent = text;
546
+ statusDot.classList.toggle('ok', ok);
547
+ }
548
+
549
+ function sendInput(data) {
550
+ if (ws && ws.readyState === WebSocket.OPEN) {
551
+ ws.send(JSON.stringify({ type: 'input', data: data }));
552
+ }
553
+ }
554
+
555
+ // Retry button
556
+ dcRetry.addEventListener('click', function() {
557
+ reconnectAttempts = 0;
558
+ connect();
559
+ });
560
+
561
+ // Terminal keyboard input (desktop)
562
+ term.onData(function(data) { sendInput(data); });
563
+
564
+ // ── Input bar ────────────────────────────────────────────
565
+ // Input: use InputEvent.data to capture each keystroke.
566
+ // Don't clear the field (breaks mobile keyboards).
567
+ // The field shows what you typed — it's visual feedback.
568
+ inputField.addEventListener('input', function(e) {
569
+ if (e.inputType === 'insertText' && e.data) {
570
+ sendInput(e.data);
571
+ } else if (e.inputType === 'deleteContentBackward') {
572
+ sendInput('\\x7f');
573
+ } else if (e.inputType === 'insertLineBreak') {
574
+ sendInput('\\r');
575
+ inputField.value = '';
576
+ inputField.blur();
577
+ }
578
+ });
579
+
580
+ inputField.addEventListener('keydown', function(e) {
581
+ if (e.key === 'Enter') {
582
+ e.preventDefault();
583
+ sendInput('\\r');
584
+ inputField.value = '';
585
+ inputField.blur();
586
+ }
587
+ });
588
+
589
+ inputSend.addEventListener('click', function() {
590
+ sendInput('\\r');
591
+ inputField.value = '';
592
+ inputField.blur();
593
+ });
594
+
595
+ // ── Keyboard toggle ──────────────────────────────────────
596
+ var kbVisible = true;
597
+
598
+ btnKb.addEventListener('click', function() {
599
+ kbVisible = !kbVisible;
600
+ inputBar.classList.toggle('hidden', !kbVisible);
601
+ container.classList.toggle('no-input', !kbVisible);
602
+ btnKb.classList.toggle('active', kbVisible);
603
+ if (kbVisible) inputField.focus();
604
+ setTimeout(doResize, 100);
605
+ });
606
+
607
+ // ── Virtual keyboard detection ───────────────────────────
608
+ if (window.visualViewport) {
609
+ window.visualViewport.addEventListener('resize', function() {
610
+ var ratio = window.visualViewport.height / window.innerHeight;
611
+ document.body.classList.toggle('keyboard-open', ratio < 0.75);
612
+ setTimeout(doResize, 50);
613
+ });
614
+ }
615
+
616
+ // ── Resize ───────────────────────────────────────────────
617
+ function doResize() {
618
+ if (!connected) return;
619
+ fitAddon.fit();
620
+ if (ws && ws.readyState === WebSocket.OPEN) {
621
+ var dims = fitAddon.proposeDimensions();
622
+ if (dims) ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
623
+ }
624
+ }
625
+
626
+ window.addEventListener('resize', function() { setTimeout(doResize, 150); });
627
+ window.addEventListener('orientationchange', function() { setTimeout(doResize, 300); });
628
+
629
+ // ── Toolbar buttons ──────────────────────────────────────
630
+ var KEY_MAP = {
631
+ ArrowUp: '\\x1b[A', ArrowDown: '\\x1b[B',
632
+ ArrowLeft: '\\x1b[D', ArrowRight: '\\x1b[C',
633
+ Enter: '\\r', Escape: '\\x1b', Tab: '\\t'
634
+ };
635
+
636
+ var holdTimer = null, holdInterval = null;
637
+
638
+ function handleButton(btn) {
639
+ if (navigator.vibrate) navigator.vibrate(VIBRATE);
640
+ btn.blur();
641
+ if (btn.id === 'btn-bksp') { sendInput('\\x7f'); return; }
642
+ var ctrlKey = btn.getAttribute('data-ctrl');
643
+ if (ctrlKey) { sendInput(String.fromCharCode(ctrlKey.toUpperCase().charCodeAt(0) - 64)); return; }
644
+ var key = btn.getAttribute('data-key');
645
+ if (key && KEY_MAP[key]) sendInput(KEY_MAP[key]);
646
+ }
647
+
648
+ // Prevent all toolbar/header buttons from taking focus (opens keyboard on mobile)
649
+ document.querySelectorAll('button').forEach(function(btn) {
650
+ btn.setAttribute('tabindex', '-1');
651
+ });
652
+
653
+ var buttons = document.querySelectorAll('#toolbar .tb');
654
+ for (var i = 0; i < buttons.length; i++) {
655
+ (function(btn) {
656
+ var canRepeat = btn.getAttribute('data-repeat') === 'true';
657
+ function start(e) {
658
+ e.preventDefault();
659
+ handleButton(btn);
660
+ if (canRepeat) {
661
+ holdTimer = setTimeout(function() {
662
+ holdInterval = setInterval(function() { handleButton(btn); }, 80);
663
+ }, 400);
664
+ }
665
+ }
666
+ function stop(e) {
667
+ e.preventDefault();
668
+ clearTimeout(holdTimer); clearInterval(holdInterval);
669
+ }
670
+ btn.addEventListener('touchstart', start, { passive: false });
671
+ btn.addEventListener('touchend', stop, { passive: false });
672
+ btn.addEventListener('touchcancel', stop, { passive: false });
673
+ btn.addEventListener('mousedown', start);
674
+ btn.addEventListener('mouseup', stop);
675
+ btn.addEventListener('mouseleave', stop);
676
+ btn.addEventListener('contextmenu', function(e) { e.preventDefault(); });
677
+ })(buttons[i]);
678
+ }
679
+
680
+ // ── Y / N buttons ────────────────────────────────────────
681
+ document.getElementById('btn-y').addEventListener('click', function(e) {
682
+ e.preventDefault();
683
+ if (navigator.vibrate) navigator.vibrate(VIBRATE);
684
+ sendInput('y');
685
+ sendInput('\\r');
686
+ });
687
+ document.getElementById('btn-n').addEventListener('click', function(e) {
688
+ e.preventDefault();
689
+ if (navigator.vibrate) navigator.vibrate(VIBRATE);
690
+ sendInput('n');
691
+ sendInput('\\r');
692
+ });
693
+
694
+ // ── Scroll via tmux copy-mode ─────────────────────────────
695
+ var scrollActive = false;
696
+ var scrollBtn = document.getElementById('btn-scroll');
697
+ var scrollExitBtn = document.getElementById('btn-scroll-exit');
698
+ var toolbarNormal = document.getElementById('toolbar');
699
+ var toolbarScroll = document.getElementById('toolbar-scroll');
700
+ var scrollUpBtn = document.getElementById('scroll-up');
701
+ var scrollDownBtn = document.getElementById('scroll-down');
702
+
703
+ function enterScrollMode() {
704
+ if (navigator.vibrate) navigator.vibrate(VIBRATE);
705
+ sendInput('\\x02['); // Ctrl-B [ = tmux copy-mode
706
+ scrollActive = true;
707
+ toolbarNormal.classList.add('hidden');
708
+ inputBar.classList.add('hidden');
709
+ toolbarScroll.classList.remove('hidden');
710
+ container.style.bottom = 'calc(50px + var(--safe-b))';
711
+ setTimeout(doResize, 100);
712
+ }
713
+
714
+ function exitScrollMode() {
715
+ if (navigator.vibrate) navigator.vibrate(VIBRATE);
716
+ sendInput('q'); // exit copy-mode
717
+ scrollActive = false;
718
+ toolbarScroll.classList.add('hidden');
719
+ toolbarNormal.classList.remove('hidden');
720
+ if (kbVisible) inputBar.classList.remove('hidden');
721
+ container.style.bottom = '';
722
+ setTimeout(doResize, 100);
723
+ }
724
+
725
+ scrollBtn.addEventListener('touchstart', function(e) { e.preventDefault(); enterScrollMode(); }, { passive: false });
726
+ scrollBtn.addEventListener('mousedown', function(e) { e.preventDefault(); enterScrollMode(); });
727
+ scrollExitBtn.addEventListener('touchstart', function(e) { e.preventDefault(); exitScrollMode(); }, { passive: false });
728
+ scrollExitBtn.addEventListener('mousedown', function(e) { e.preventDefault(); exitScrollMode(); });
729
+
730
+ // Scroll up/down with progressive acceleration
731
+ function bindScrollBtn(btn, direction) {
732
+ var ht = null, hi = null;
733
+ var speed = 1; // lines per tick, increases over time
734
+
735
+ function sendScrollKey() {
736
+ // Send multiple arrow keys based on current speed
737
+ var key = direction === 'up' ? '\\x1b[A' : '\\x1b[B';
738
+ for (var s = 0; s < Math.floor(speed); s++) sendInput(key);
739
+ speed = Math.min(speed * 1.15, 20); // accelerate up to 20 lines/tick
740
+ }
741
+
742
+ function start(e) {
743
+ e.preventDefault();
744
+ if (navigator.vibrate) navigator.vibrate(VIBRATE);
745
+ speed = 1;
746
+ sendScrollKey();
747
+ ht = setTimeout(function() {
748
+ hi = setInterval(sendScrollKey, 60);
749
+ }, 300);
750
+ }
751
+
752
+ function stop(e) {
753
+ e.preventDefault();
754
+ clearTimeout(ht);
755
+ clearInterval(hi);
756
+ speed = 1;
757
+ }
758
+
759
+ btn.addEventListener('touchstart', start, { passive: false });
760
+ btn.addEventListener('touchend', stop, { passive: false });
761
+ btn.addEventListener('touchcancel', stop, { passive: false });
762
+ btn.addEventListener('mousedown', start);
763
+ btn.addEventListener('mouseup', stop);
764
+ btn.addEventListener('mouseleave', stop);
765
+ }
766
+
767
+ bindScrollBtn(scrollUpBtn, 'up');
768
+ bindScrollBtn(scrollDownBtn, 'down');
769
+
770
+ // Double tap on terminal = Enter
771
+ var lastTapTime = 0;
772
+ var touchStartX = 0, touchStartY = 0;
773
+
774
+ container.addEventListener('touchstart', function(e) {
775
+ if (e.touches.length === 1) {
776
+ touchStartX = e.touches[0].clientX;
777
+ touchStartY = e.touches[0].clientY;
778
+ }
779
+ }, { passive: true });
780
+
781
+ container.addEventListener('touchend', function(e) {
782
+ if (e.changedTouches.length !== 1) return;
783
+ var dx = Math.abs(e.changedTouches[0].clientX - touchStartX);
784
+ var dy = Math.abs(e.changedTouches[0].clientY - touchStartY);
785
+
786
+ // Only count as tap if finger didn't move
787
+ if (dx > 15 || dy > 15) return;
788
+
789
+ var now = Date.now();
790
+ if (now - lastTapTime < DOUBLE_TAP_MS) {
791
+ sendInput('\\r');
792
+ if (navigator.vibrate) navigator.vibrate(VIBRATE);
793
+ lastTapTime = 0;
794
+ e.preventDefault();
795
+ } else {
796
+ lastTapTime = now;
797
+ }
798
+ }, { passive: false });
799
+
800
+ // ── Toolbar toggle ───────────────────────────────────────
801
+ btnToggle.addEventListener('click', function() {
802
+ toolbar.classList.toggle('hidden');
803
+ container.classList.toggle('no-toolbar');
804
+ setTimeout(doResize, 100);
805
+ });
806
+
807
+ // ── Prevent xterm textarea focus ─────────────────────────
808
+ function blockXtermFocus() {
809
+ var ta = document.querySelector('.xterm-helper-textarea');
810
+ if (ta && !ta.hasAttribute('readonly')) {
811
+ ta.setAttribute('readonly', 'true');
812
+ ta.setAttribute('tabindex', '-1');
813
+ ta.addEventListener('focus', function() { ta.blur(); });
814
+ }
815
+ }
816
+ blockXtermFocus();
817
+ new MutationObserver(blockXtermFocus).observe(container, { childList: true, subtree: true });
818
+
819
+ // ── Gesture hint (one-time) ──────────────────────────────
820
+ function showGestureHint() {
821
+ if (localStorage.getItem('dr-hint')) return;
822
+ gestureHint.classList.remove('hidden');
823
+ localStorage.setItem('dr-hint', '1');
824
+ setTimeout(function() { gestureHint.classList.add('hidden'); }, 5000);
825
+ }
826
+
827
+ // ── Service Worker ───────────────────────────────────────
828
+ if ('serviceWorker' in navigator) {
829
+ navigator.serviceWorker.register('/sw.js').catch(function() {});
830
+ }
831
+
832
+ // ── Info panel ────────────────────────────────────────────
833
+ var infoPanel = document.getElementById('info-panel');
834
+ var infoOpen = false;
835
+ var hTitle = document.querySelector('.h-title');
836
+
837
+ hTitle.style.cursor = 'pointer';
838
+ hTitle.addEventListener('touchstart', function(e) { e.preventDefault(); toggleInfo(); }, { passive: false });
839
+ hTitle.addEventListener('click', toggleInfo);
840
+
841
+ function toggleInfo() {
842
+ infoOpen = !infoOpen;
843
+ infoPanel.classList.toggle('hidden', !infoOpen);
844
+ if (infoOpen) fetchInfo();
845
+ }
846
+
847
+ function fetchInfo() {
848
+ document.getElementById('info-domain').textContent = location.host;
849
+
850
+ fetch('/status', { credentials: 'same-origin' })
851
+ .then(function(r) { return r.json(); })
852
+ .then(function(d) {
853
+ var tmuxEl = document.getElementById('info-tmux');
854
+ tmuxEl.textContent = d.tmux ? 'active' : 'not found';
855
+ tmuxEl.className = 'info-value ' + (d.tmux ? 'ok' : 'err');
856
+
857
+ document.getElementById('info-path').textContent = d.cwd || 'unknown';
858
+
859
+ var st = d.tmux ? 'online' : 'offline';
860
+ document.getElementById('info-status').textContent = st;
861
+ document.getElementById('info-status').className = 'info-value ' + (d.tmux ? 'ok' : 'err');
862
+ })
863
+ .catch(function() {
864
+ document.getElementById('info-status').textContent = 'unreachable';
865
+ document.getElementById('info-status').className = 'info-value err';
866
+ });
867
+
868
+ fetch('/health', { credentials: 'same-origin' })
869
+ .then(function(r) { return r.json(); })
870
+ .then(function(d) {
871
+ var mins = Math.floor(d.uptime / 60);
872
+ var hrs = Math.floor(mins / 60);
873
+ document.getElementById('info-uptime').textContent = hrs > 0 ? hrs + 'h ' + (mins % 60) + 'm' : mins + 'm';
874
+ })
875
+ .catch(function() {});
876
+ }
877
+
878
+ // ── Start ────────────────────────────────────────────────
879
+ // Wait for loading screen to finish
880
+ setTimeout(function() {
881
+ showDisconnect('Connecting...', 'Establishing connection to terminal...', 'loading');
882
+ connect();
883
+ }, 1700);
884
+
885
+ })();
886
+ `;