cli-tunnel 1.3.0 → 1.3.1-beta.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.
package/README.md CHANGED
@@ -115,9 +115,20 @@ All modes share the same WebSocket connections — switching is instant, no reco
115
115
 
116
116
  - **Full terminal** rendered by xterm.js — exact same output as your local terminal
117
117
  - **Key bar** with ↑ ↓ → ← Tab Enter Esc Ctrl+C for mobile navigation
118
+ - **⏺ Record button** — record the terminal as a .webm video, tap again to stop and download
118
119
  - **Sessions button** — switch between terminal and sessions dashboard
119
120
  - **QR code** — scan from your phone to connect instantly
120
121
 
122
+ ## Terminal Recording
123
+
124
+ Tap the **⏺** button in the key bar to start recording your terminal session as a video. The button turns red and shows elapsed time (⏹ 2:35). Tap again to stop — a `.webm` video file downloads automatically.
125
+
126
+ - Records at 30fps using the browser's MediaRecorder API
127
+ - Auto-stops after 10 minutes to prevent memory issues on mobile
128
+ - Auto-stops when you switch views or disconnect
129
+ - Works on both phone and desktop browsers
130
+ - No server-side tools needed — recording happens entirely in the browser
131
+
121
132
  ## Prerequisites
122
133
 
123
134
  - [Node.js](https://nodejs.org/) 22+ (Node 20 works too; Node 23 may need the latest beta)
@@ -182,6 +193,9 @@ Yes. Use `--local` to skip tunnel creation. The terminal is available at `http:/
182
193
  **What's hub mode?**
183
194
  Run `cli-tunnel` with no command to start hub mode — a sessions dashboard that shows all active cli-tunnel sessions. Tap any session to connect, or use Grid view to monitor all sessions simultaneously.
184
195
 
196
+ **How does recording work?**
197
+ The record button captures the xterm.js canvas at 30fps using the browser's MediaRecorder API. The recording is a `.webm` video file that downloads to your device when you stop. It records exactly what you see on screen — perfect for demos and presentations. Max duration is 10 minutes.
198
+
185
199
  **How does the Grid view connect to sessions?**
186
200
  The hub reads session tokens from `~/.cli-tunnel/sessions/` (files with owner-only permissions). It proxies ticket requests to each session's local port — no tokens are exposed to the browser client.
187
201
 
package/dist/index.js CHANGED
@@ -24,6 +24,15 @@ import readline from 'node:readline';
24
24
  import { WebSocketServer, WebSocket } from 'ws';
25
25
  import os from 'node:os';
26
26
  import { redactSecrets } from './redact.js';
27
+ // F-15: Global error handlers to prevent unclean crashes
28
+ process.on('uncaughtException', (err) => {
29
+ console.error('[fatal] Uncaught exception:', err.message);
30
+ process.exit(1);
31
+ });
32
+ process.on('unhandledRejection', (reason) => {
33
+ console.error('[fatal] Unhandled rejection:', reason);
34
+ process.exit(1);
35
+ });
27
36
  function askUser(question) {
28
37
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
29
38
  return new Promise((resolve) => {
@@ -49,7 +58,7 @@ ${BOLD}Options:${RESET}
49
58
  --local Disable devtunnel (localhost only)
50
59
  --port <n> Bridge port (default: random)
51
60
  --name <name> Session name (shown in dashboard)
52
- --replay Enable replay buffer (off by default)
61
+ --no-replay Disable replay buffer (on by default)
53
62
  --help, -h Show this help
54
63
 
55
64
  ${BOLD}Examples:${RESET}
@@ -69,13 +78,13 @@ pass through to the underlying app. cli-tunnel's own flags
69
78
  }
70
79
  const hasLocal = args.includes('--local');
71
80
  const hasTunnel = !hasLocal;
72
- const hasReplay = args.includes('--replay');
81
+ const hasReplay = !args.includes('--no-replay');
73
82
  const portIdx = args.indexOf('--port');
74
83
  const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1], 10) : 0;
75
84
  const nameIdx = args.indexOf('--name');
76
85
  const sessionName = (nameIdx !== -1 && args[nameIdx + 1]) ? args[nameIdx + 1] : '';
77
86
  // Everything that's not our flags is the command
78
- const ourFlags = new Set(['--local', '--tunnel', '--port', '--name', '--replay']);
87
+ const ourFlags = new Set(['--local', '--tunnel', '--port', '--name', '--no-replay']);
79
88
  const cmdArgs = [];
80
89
  let skip = false;
81
90
  for (let i = 0; i < args.length; i++) {
@@ -87,7 +96,7 @@ for (let i = 0; i < args.length; i++) {
87
96
  skip = true;
88
97
  continue;
89
98
  }
90
- if (args[i] === '--local' || args[i] === '--tunnel' || args[i] === '--replay')
99
+ if (args[i] === '--local' || args[i] === '--tunnel' || args[i] === '--no-replay')
91
100
  continue;
92
101
  cmdArgs.push(args[i]);
93
102
  }
@@ -101,6 +110,17 @@ function sanitizeLabel(l) {
101
110
  const clean = l.replace(/[^a-zA-Z0-9_\-=]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').substring(0, 50);
102
111
  return clean || 'unknown';
103
112
  }
113
+ // F-07: Minimal env for subprocess calls (git, devtunnel) — only PATH and essentials
114
+ function getSubprocessEnv() {
115
+ const env = {};
116
+ const SUBPROCESS_ALLOWLIST = new Set(['PATH', 'PATHEXT', 'SYSTEMROOT', 'TEMP', 'TMP', 'HOME', 'USERPROFILE', 'LANG', 'LC_ALL', 'TERM', 'COMSPEC', 'SHELL']);
117
+ for (const [k, v] of Object.entries(process.env)) {
118
+ if (v !== undefined && SUBPROCESS_ALLOWLIST.has(k)) {
119
+ env[k] = v;
120
+ }
121
+ }
122
+ return env;
123
+ }
104
124
  function getGitInfo() {
105
125
  try {
106
126
  const remote = execSync('git remote get-url origin', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
@@ -255,6 +275,7 @@ const server = http.createServer(async (req, res) => {
255
275
  }
256
276
  }
257
277
  // Hub ticket proxy — fetch ticket from local session on behalf of grid client
278
+ // F-03: Only hub mode sessions can use this endpoint (hub token already validated above)
258
279
  if (hubMode && req.url?.startsWith('/api/proxy/ticket/') && req.method === 'POST') {
259
280
  const ticketPathMatch = req.url?.match(/^\/api\/proxy\/ticket\/(\d+)$/);
260
281
  if (!ticketPathMatch) {
@@ -320,7 +341,7 @@ const server = http.createServer(async (req, res) => {
320
341
  url: `https://${id}-${p}.${cluster}.devtunnels.ms`,
321
342
  isLocal: machine === localMachine,
322
343
  };
323
- // Attach token from local session files (hub mode only)
344
+ // F-05: Never expose raw tokens in API responses only indicate availability
324
345
  const baseId = t.tunnelId?.split('.')[0] || t.tunnelId;
325
346
  const token = tokenMap.get(baseId) || tokenMap.get(t.tunnelId);
326
347
  if (token)
@@ -337,6 +358,7 @@ const server = http.createServer(async (req, res) => {
337
358
  return;
338
359
  }
339
360
  // Delete session
361
+ // F-05: Only allow deleting tunnels owned by this machine
340
362
  if (req.url?.startsWith('/api/sessions/') && req.method === 'DELETE') {
341
363
  const tunnelId = req.url.replace('/api/sessions/', '').replace(/\.\w+$/, '');
342
364
  if (!/^[a-zA-Z0-9._-]+$/.test(tunnelId)) {
@@ -344,6 +366,24 @@ const server = http.createServer(async (req, res) => {
344
366
  res.end(JSON.stringify({ error: 'Invalid tunnel ID' }));
345
367
  return;
346
368
  }
369
+ // Verify the tunnel belongs to this machine before allowing delete
370
+ try {
371
+ const verifyOut = execFileSync('devtunnel', ['show', tunnelId, '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
372
+ const verifyData = JSON.parse(verifyOut);
373
+ const labels = verifyData.tunnel?.labels || [];
374
+ const tunnelMachine = labels[4] || '';
375
+ if (tunnelMachine !== os.hostname()) {
376
+ res.writeHead(403, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
377
+ res.end(JSON.stringify({ error: 'Cannot delete tunnels from other machines' }));
378
+ return;
379
+ }
380
+ }
381
+ catch {
382
+ // If we can't verify ownership, deny the delete
383
+ res.writeHead(403, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
384
+ res.end(JSON.stringify({ error: 'Cannot verify tunnel ownership' }));
385
+ return;
386
+ }
347
387
  try {
348
388
  execFileSync('devtunnel', ['delete', tunnelId, '--force'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
349
389
  res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
@@ -424,19 +464,21 @@ const wss = new WebSocketServer({
424
464
  if (Date.now() - sessionCreatedAt > SESSION_TTL)
425
465
  return false;
426
466
  // F-3: Validate origin BEFORE ticket acceptance
467
+ // F-06: Require Origin header — reject non-browser clients without Origin
427
468
  const origin = info.req.headers.origin;
428
- if (origin) {
429
- try {
430
- const originUrl = new URL(origin);
431
- const host = originUrl.hostname;
432
- if (host !== 'localhost' && host !== '127.0.0.1' && !host.endsWith('.devtunnels.ms')) {
433
- return false;
434
- }
435
- }
436
- catch {
469
+ if (!origin) {
470
+ return false;
471
+ }
472
+ try {
473
+ const originUrl = new URL(origin);
474
+ const host = originUrl.hostname;
475
+ if (host !== 'localhost' && host !== '127.0.0.1' && !host.endsWith('.devtunnels.ms')) {
437
476
  return false;
438
477
  }
439
478
  }
479
+ catch {
480
+ return false;
481
+ }
440
482
  const url = new URL(info.req.url, `http://${info.req.headers.host}`);
441
483
  // F-02: Accept one-time ticket (only auth method for WS)
442
484
  const ticket = url.searchParams.get('ticket');
@@ -454,6 +496,10 @@ fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 });
454
496
  const auditLogPath = path.join(auditDir, `audit-${new Date().toISOString().slice(0, 10)}.jsonl`);
455
497
  const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
456
498
  auditLog.on('error', (err) => { console.error('Audit log error:', err.message); });
499
+ // R-01: WebSocketServer error handler — prevents process crash on WSS-level errors
500
+ wss.on('error', (err) => {
501
+ console.error('[wss] WebSocketServer error:', err.message);
502
+ });
457
503
  wss.on('connection', (ws, req) => {
458
504
  // F-10: Connection cap (global + per-IP)
459
505
  if (connections.size >= 5) {
@@ -473,6 +519,9 @@ wss.on('connection', (ws, req) => {
473
519
  const id = crypto.randomUUID();
474
520
  ws._remoteAddress = remoteAddress;
475
521
  connections.set(id, ws);
522
+ // F-13: Per-connection WS message rate limiter (100 msg/sec)
523
+ let wsMessageCount = 0;
524
+ let wsMessageResetAt = Date.now() + 1000;
476
525
  // F-10: WS ping/pong heartbeat
477
526
  ws._isAlive = true;
478
527
  ws.on('pong', () => { ws._isAlive = true; });
@@ -484,12 +533,29 @@ wss.on('connection', (ws, req) => {
484
533
  ws.send(JSON.stringify({ type: '_replay_done' }));
485
534
  }
486
535
  ws.on('message', (data) => {
536
+ // F-13: Enforce WS message rate limit (100 msg/sec)
537
+ const now = Date.now();
538
+ if (now > wsMessageResetAt) {
539
+ wsMessageCount = 0;
540
+ wsMessageResetAt = now + 1000;
541
+ }
542
+ wsMessageCount++;
543
+ if (wsMessageCount > 100) {
544
+ auditLog.write(JSON.stringify({ ts: new Date().toISOString(), src: remoteAddress, type: 'rejected', reason: 'ws-rate-limit' }) + '\n');
545
+ return;
546
+ }
487
547
  const raw = data.toString();
488
548
  try {
489
549
  const msg = JSON.parse(raw);
490
550
  if (msg.type === 'pty_input' && ptyProcess) {
491
- auditLog.write(JSON.stringify({ ts: new Date().toISOString(), src: remoteAddress, type: 'pty_input', data: redactSecrets(JSON.stringify(msg.data)) }) + '\n');
492
- ptyProcess.write(msg.data);
551
+ // R-03: Validate msg.data is a string before writing to PTY
552
+ if (typeof msg.data !== 'string') {
553
+ auditLog.write(JSON.stringify({ ts: new Date().toISOString(), src: remoteAddress, type: 'rejected', reason: 'invalid-data-type', dataType: typeof msg.data }) + '\n');
554
+ }
555
+ else {
556
+ auditLog.write(JSON.stringify({ ts: new Date().toISOString(), src: remoteAddress, type: 'pty_input', data: redactSecrets(msg.data) }) + '\n');
557
+ ptyProcess.write(msg.data);
558
+ }
493
559
  }
494
560
  // #7: NaN guard on pty_resize
495
561
  if (msg.type === 'pty_resize') {
@@ -519,10 +585,17 @@ setInterval(() => {
519
585
  ws.ping();
520
586
  }
521
587
  }, 30000);
588
+ // F-12: Cap per-entry replay buffer size (64KB)
589
+ const MAX_REPLAY_ENTRY_SIZE = 65536;
522
590
  function broadcast(data) {
523
- const msg = JSON.stringify({ type: 'pty', data });
591
+ // F-01: Redact secrets from live broadcast (not just replay)
592
+ const redacted = redactSecrets(data);
593
+ const msg = JSON.stringify({ type: 'pty', data: redacted });
524
594
  if (hasReplay) {
525
- acpEventLog.push(msg);
595
+ // F-12: Cap per-entry size to prevent memory exhaustion
596
+ if (msg.length <= MAX_REPLAY_ENTRY_SIZE) {
597
+ acpEventLog.push(msg);
598
+ }
526
599
  if (acpEventLog.length > 2000)
527
600
  acpEventLog.splice(0, acpEventLog.length - 2000);
528
601
  }
@@ -548,7 +621,8 @@ async function main() {
548
621
  if (hubMode) {
549
622
  console.log(` ${BOLD}📋 Hub Mode${RESET} — sessions dashboard`);
550
623
  console.log(` ${DIM}Port:${RESET} ${actualPort}`);
551
- console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}&hub=1\n`);
624
+ console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}&hub=1`);
625
+ console.log(` ${YELLOW}⚠ Token in URL — do not share this URL in screen recordings or public channels${RESET}\n`);
552
626
  }
553
627
  else {
554
628
  console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
@@ -556,6 +630,7 @@ async function main() {
556
630
  console.log(` ${DIM}Port:${RESET} ${actualPort}`);
557
631
  console.log(` ${DIM}Audit log:${RESET} ${auditLogPath}`);
558
632
  console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}`);
633
+ console.log(` ${YELLOW}⚠ Token in URL — do not share this URL in screen recordings or public channels${RESET}`);
559
634
  console.log(` ${DIM}Session expires:${RESET} ${new Date(sessionCreatedAt + SESSION_TTL).toLocaleTimeString()}`);
560
635
  }
561
636
  // Tunnel
@@ -655,7 +730,7 @@ async function main() {
655
730
  const tunnelId = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[0];
656
731
  const cluster = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[1] || 'euw';
657
732
  execFileSync('devtunnel', ['port', 'create', tunnelId, '-p', String(actualPort), '--protocol', 'http'], { stdio: 'pipe' });
658
- const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false });
733
+ const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false, env: getSubprocessEnv() });
659
734
  const url = await new Promise((resolve, reject) => {
660
735
  const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 15000);
661
736
  let out = '';
@@ -670,7 +745,8 @@ async function main() {
670
745
  hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
671
746
  });
672
747
  const tunnelUrlWithToken = `${url}?token=${sessionToken}${hubMode ? '&hub=1' : ''}`;
673
- console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}\n`);
748
+ console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}`);
749
+ console.log(` ${YELLOW}⚠ Token in URL — do not share in screen recordings or public channels${RESET}\n`);
674
750
  // Write session file for hub discovery
675
751
  writeSessionFile(tunnelId, url, actualPort);
676
752
  try {
@@ -766,8 +842,17 @@ async function main() {
766
842
  'NODE_PATH', 'NODE_REDIRECT_WARNINGS', 'NODE_PENDING_DEPRECATION',
767
843
  'UV_THREADPOOL_SIZE', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES',
768
844
  'SSH_AUTH_SOCK', 'GPG_TTY',
769
- 'PYTHONPATH', 'BASH_ENV', 'BASH_FUNC', 'JAVA_TOOL_OPTIONS', 'JAVA_OPTIONS', '_JAVA_OPTIONS',
770
- 'PROMPT_COMMAND', 'ENV', 'ZDOTDIR', 'PERL5OPT', 'RUBYOPT']);
845
+ 'PYTHONPATH', 'PYTHONSTARTUP', 'BASH_ENV', 'BASH_FUNC', 'JAVA_TOOL_OPTIONS', 'JAVA_OPTIONS', '_JAVA_OPTIONS',
846
+ 'PROMPT_COMMAND', 'ENV', 'ZDOTDIR', 'PERL5OPT', 'RUBYOPT',
847
+ // F-04: Additional dangerous vars missed by original blocklist
848
+ 'DATABASE_URL', 'REDIS_URL', 'MONGODB_URI', 'MONGO_URL',
849
+ 'SLACK_WEBHOOK_URL', 'SLACK_TOKEN', 'SLACK_BOT_TOKEN',
850
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
851
+ 'HISTFILE', 'HISTFILESIZE', 'LESSHISTFILE',
852
+ 'GCP_SERVICE_ACCOUNT', 'GOOGLE_APPLICATION_CREDENTIALS',
853
+ 'AZURE_SUBSCRIPTION_ID', 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET',
854
+ 'SENDGRID_API_KEY', 'TWILIO_AUTH_TOKEN', 'STRIPE_SECRET_KEY',
855
+ 'AWS_SESSION_TOKEN', 'AWS_SECURITY_TOKEN']);
771
856
  const sensitivePattern = /token|secret|key|password|credential|api_key|private_key|access_key|connection_string|auth|kubeconfig|docker_host|docker_config/i;
772
857
  const safeEnv = {};
773
858
  for (const [k, v] of Object.entries(process.env)) {
package/dist/redact.js CHANGED
@@ -1,5 +1,10 @@
1
+ // F-08: Strip Unicode zero-width characters that can bypass regex patterns
2
+ function stripZeroWidth(text) {
3
+ return text.replace(/[\u200B\u200C\u200D\uFEFF\u00AD\u2060\u180E]/g, '');
4
+ }
1
5
  export function redactSecrets(text) {
2
- return text
6
+ const cleaned = stripZeroWidth(text);
7
+ return cleaned
3
8
  // Generic patterns: key=value, key: value, key="value"
4
9
  .replace(/(?:token|secret|key|password|credential|authorization|api_key|private_key|access_key|connection_string|db_pass|signing)[\s:="']+\S{8,}/gi, '[REDACTED]')
5
10
  // OpenAI keys
@@ -11,16 +16,31 @@ export function redactSecrets(text) {
11
16
  // Azure connection strings
12
17
  .replace(/DefaultEndpointsProtocol=[^;\s]{20,}/gi, '[REDACTED]')
13
18
  .replace(/AccountKey=[^;\s]{20,}/gi, 'AccountKey=[REDACTED]')
19
+ // F-09: Azure SAS tokens
20
+ .replace(/[?&]sig=[a-zA-Z0-9%/+=]{20,}/gi, '?sig=[REDACTED]')
21
+ .replace(/SharedAccessSignature\s+[^\s"']{20,}/gi, 'SharedAccessSignature [REDACTED]')
14
22
  // Database URLs
15
23
  .replace(/(postgres|mongodb|mysql|redis):\/\/[^\s"']{10,}/gi, '[REDACTED]')
16
- // Bearer tokens in headers
17
- .replace(/Bearer\s+[a-zA-Z0-9._-]{20,}/gi, 'Bearer [REDACTED]')
24
+ // F-17: Bearer tokens (relaxed min length to catch shorter tokens)
25
+ .replace(/Bearer\s+[a-zA-Z0-9._\-/+=]{8,}/gi, 'Bearer [REDACTED]')
26
+ // F-17: Basic auth headers
27
+ .replace(/Basic\s+[a-zA-Z0-9+/=]{8,}/gi, 'Basic [REDACTED]')
18
28
  // JWT tokens
19
29
  .replace(/eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}/g, '[REDACTED]')
20
30
  // Slack tokens
21
31
  .replace(/xox[bpras]-[a-zA-Z0-9-]{10,}/g, '[REDACTED]')
22
32
  // npm tokens
23
33
  .replace(/npm_[a-zA-Z0-9]{20,}/g, '[REDACTED]')
34
+ // F-09: Google API keys
35
+ .replace(/AIzaSy[a-zA-Z0-9_-]{33}/g, '[REDACTED]')
36
+ // F-09: Stripe keys
37
+ .replace(/(?:sk|pk|rk)_(?:live|test)_[a-zA-Z0-9]{10,}/g, '[REDACTED]')
38
+ // F-09: SendGrid keys
39
+ .replace(/SG\.[a-zA-Z0-9_-]{22,}\.[a-zA-Z0-9_-]{22,}/g, '[REDACTED]')
40
+ // F-09: Twilio keys
41
+ .replace(/SK[a-f0-9]{32}/g, '[REDACTED]')
42
+ // F-09: Webhook secrets
43
+ .replace(/whsec_[a-zA-Z0-9+/=]{20,}/g, '[REDACTED]')
24
44
  // PEM private keys
25
45
  .replace(/-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----/g, '[REDACTED]');
26
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-tunnel",
3
- "version": "1.3.0",
3
+ "version": "1.3.1-beta.0",
4
4
  "description": "Tunnel any CLI app to your phone — PTY + devtunnel + xterm.js",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/remote-ui/app.js CHANGED
@@ -135,6 +135,34 @@
135
135
  }
136
136
  }
137
137
 
138
+ function takeScreenshot() {
139
+ var canvas = null;
140
+ if (currentView === 'grid' && gridMode === 'fullscreen' && gridTerminals[focusedIndex]) {
141
+ canvas = gridTerminals[focusedIndex].panel.querySelector('canvas');
142
+ } else {
143
+ canvas = termContainer.querySelector('canvas');
144
+ }
145
+ if (!canvas) {
146
+ if (statusText) { var prev = statusText.textContent; statusText.textContent = 'No terminal to capture'; setTimeout(function() { statusText.textContent = prev; }, 2000); }
147
+ return;
148
+ }
149
+ try {
150
+ var dataUrl = canvas.toDataURL('image/png');
151
+ var a = document.createElement('a');
152
+ var timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
153
+ a.href = dataUrl;
154
+ a.download = 'cli-tunnel-' + timestamp + '.png';
155
+ document.body.appendChild(a);
156
+ a.click();
157
+ document.body.removeChild(a);
158
+ // Flash effect
159
+ canvas.style.opacity = '0.5';
160
+ setTimeout(function() { canvas.style.opacity = '1'; }, 150);
161
+ } catch (e) {
162
+ if (statusText) { var prev2 = statusText.textContent; statusText.textContent = 'Screenshot failed'; setTimeout(function() { statusText.textContent = prev2; }, 2000); }
163
+ }
164
+ }
165
+
138
166
  // ─── xterm.js Terminal ───────────────────────────────────
139
167
  let xterm = null;
140
168
  let fitAddon = null;
@@ -1097,6 +1125,10 @@
1097
1125
  toggleRecording();
1098
1126
  return;
1099
1127
  }
1128
+ if (btn && btn.tagName === 'BUTTON' && btn.dataset.action === 'take-screenshot') {
1129
+ takeScreenshot();
1130
+ return;
1131
+ }
1100
1132
  if (btn && btn.tagName === 'BUTTON' && btn.dataset.key) {
1101
1133
  var key = keyMap[btn.dataset.key] || btn.dataset.key;
1102
1134
  if (currentView === 'grid' && gridMode === 'fullscreen' && gridTerminals[focusedIndex]) {
@@ -43,6 +43,7 @@
43
43
  <button data-key=" ">Space</button>
44
44
  <button data-key="\x7f">⌫</button>
45
45
  <button id="btn-record" data-action="toggle-record" title="Record terminal" aria-label="Record terminal">⏺</button>
46
+ <button id="btn-screenshot" data-action="take-screenshot" title="Screenshot terminal" aria-label="Screenshot terminal">📷</button>
46
47
  </div>
47
48
  <form id="input-form">
48
49
  <span class="prompt">&gt;</span>