9remote 0.1.52 → 0.1.54

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/cli/index.js CHANGED
@@ -8,48 +8,14 @@ import path from "path";
8
8
  import { fileURLToPath } from "url";
9
9
  import fs from "fs";
10
10
  import os from "os";
11
- import dns from "dns";
12
- import https from "https";
13
- import { promisify } from "util";
14
11
  import { getConsistentMachineId } from "./utils/machineId.js";
15
12
  import { generateApiKeyWithMachine } from "./utils/apiKey.js";
16
- import { loadKey, saveKey, loadState, saveState, clearState } from "./utils/state.js";
13
+ import { loadKey, saveKey, loadState, saveState, clearState, readAndClearCmd } from "./utils/state.js";
17
14
  import { createTempKey } from "./utils/token.js";
18
- import { checkAndUpdate } from "./utils/updateChecker.js";
19
- import { ensureCloudflared, spawnCloudflared, killCloudflared, resetRestartCounter } from "./utils/cloudflared.js";
20
-
21
- // DNS resolver using Cloudflare — ensures tunnel domains resolve immediately
22
- const cfResolver = new dns.Resolver();
23
- cfResolver.setServers(["1.1.1.1", "1.0.0.1"]);
24
- const cfResolve4 = promisify(cfResolver.resolve4.bind(cfResolver));
25
-
26
- /** Fetch via IP with correct TLS SNI — bypasses system DNS */
27
- function fetchWithCfDns(url, timeoutMs = 5000) {
28
- return new Promise(async (resolve, reject) => {
29
- try {
30
- const parsed = new URL(url);
31
- const [ip] = await cfResolve4(parsed.hostname);
32
- const req = https.request({
33
- hostname: ip,
34
- port: 443,
35
- path: parsed.pathname + parsed.search,
36
- method: "GET",
37
- headers: { host: parsed.hostname },
38
- servername: parsed.hostname,
39
- rejectUnauthorized: true
40
- }, (res) => {
41
- let body = "";
42
- res.on("data", d => { body += d; });
43
- res.on("end", () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body }));
44
- });
45
- req.setTimeout(timeoutMs, () => { req.destroy(); reject(new Error("timeout")); });
46
- req.on("error", reject);
47
- req.end();
48
- } catch (err) {
49
- reject(err);
50
- }
51
- });
52
- }
15
+ import { checkAndUpdate, checkLatestVersion } from "./utils/updateChecker.js";
16
+ import { spawnQuickTunnel, killCloudflared, resetRestartCounter, ensureCloudflared } from "./utils/cloudflared.js";
17
+ import { showBanner, renderProgress, resetProgress, selectMenu, confirm as tuiConfirm, subscribeSSE, openPermissionPane } from "./utils/tui.js";
18
+ import { checkPermissions } from "./utils/permissions.js";
53
19
 
54
20
  // Parse --skip-update flag
55
21
  const skipUpdate = process.argv.includes("--skip-update");
@@ -62,7 +28,6 @@ const STANDALONE_SERVER = path.resolve(__dirname, "../dist/server.cjs");
62
28
  const DEV_SERVER = path.resolve(__dirname, "../index.js");
63
29
  const WORKER_URL = "https://9remote.cc";
64
30
  const SERVER_PORT = 2208;
65
- const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789";
66
31
  const MAX_RESTART_ATTEMPTS = 10;
67
32
  const RESTART_WINDOW_MS = 60000; // 1 minute
68
33
 
@@ -89,65 +54,23 @@ function getVersion() {
89
54
  }
90
55
  }
91
56
 
92
- /**
93
- * Show banner
94
- */
95
- function showBanner() {
96
- const version = getVersion();
97
- const width = Math.min(44, process.stdout.columns || 44);
98
-
99
- console.log("");
100
- console.log(ORANGE("╔" + "═".repeat(width - 2) + "╗"));
101
- console.log(ORANGE("║") + " ".repeat(width - 2) + ORANGE("║"));
102
-
103
- const title = `🚀 9Remote v${version}`;
104
- const titlePadding = Math.floor((width - 2 - title.length) / 2);
105
- console.log(
106
- ORANGE("║") +
107
- " ".repeat(titlePadding) +
108
- ORANGE.bold(title) +
109
- " ".repeat(width - 2 - titlePadding - title.length) +
110
- ORANGE("║")
111
- );
112
-
113
- const subtitle = "Remote terminal access from anywhere";
114
- const subtitlePadding = Math.floor((width - 2 - subtitle.length) / 2);
115
- console.log(
116
- ORANGE("║") +
117
- " ".repeat(subtitlePadding) +
118
- chalk.gray(subtitle) +
119
- " ".repeat(width - 2 - subtitlePadding - subtitle.length) +
120
- ORANGE("║")
121
- );
122
-
123
- console.log(ORANGE("║") + " ".repeat(width - 2) + ORANGE("║"));
124
- console.log(ORANGE("╚" + "═".repeat(width - 2) + "╝"));
125
- console.log("");
126
- }
127
-
128
- /**
129
- * Generate short random ID for tunnel subdomain
130
- */
131
- function generateShortId() {
132
- let result = "";
133
- for (let i = 0; i < 6; i++) {
134
- result += SHORT_ID_CHARS.charAt(Math.floor(Math.random() * SHORT_ID_CHARS.length));
135
- }
136
- return result;
137
- }
138
57
 
139
58
  /**
140
59
  * Helper: Show QR code for connect URL
141
60
  */
142
61
  function showQRCode(url, title = "📱 Scan QR to connect:") {
143
62
  console.log(ORANGE(`\n${title}`));
144
- qrcode.generate(url, {
145
- small: true,
146
- type: 'terminal',
147
- margin: 0,
148
- }, qr => {
149
- const lines = qr.trim().split('\n');
150
- console.log(lines.join('\n'));
63
+ qrcode.generate(url, { small: true, type: "terminal", margin: 0 }, (qr) => {
64
+ console.log(qr.trim());
65
+ });
66
+ }
67
+
68
+ /** Build QR block as string (for use in headerContent) */
69
+ function buildQRString(url) {
70
+ return new Promise((resolve) => {
71
+ qrcode.generate(url, { small: true, type: "terminal", margin: 0 }, (qr) => {
72
+ resolve(ORANGE_DIM("📱 Scan QR to connect:") + "\n" + qr.trim());
73
+ });
151
74
  });
152
75
  }
153
76
 
@@ -165,12 +88,15 @@ async function showConnectionInfo(selectedKey, tunnelUrl) {
165
88
  const connectUrl = `${WORKER_URL}/login?k=${tempKeyData.tempKey}`;
166
89
  const width = Math.min(44, process.stdout.columns || 55);
167
90
 
168
- // Push ready state to UI
91
+ // Push ready state to UI (include permanentKey, expiresAt, workerUrl for server-side key generation)
169
92
  pushUiState({
170
- step: 3,
93
+ step: 4,
171
94
  tunnelUrl,
172
95
  oneTimeKey: tempKeyData.tempKey,
96
+ oneTimeKeyExpiresAt: tempKeyData.expiresAt,
97
+ permanentKey: selectedKey,
173
98
  qrUrl: connectUrl,
99
+ workerUrl: WORKER_URL,
174
100
  });
175
101
 
176
102
  showQRCode(connectUrl);
@@ -197,6 +123,31 @@ async function showConnectionInfo(selectedKey, tunnelUrl) {
197
123
  console.log(ORANGE("═".repeat(width)));
198
124
  }
199
125
 
126
+ /**
127
+ * Build full header string: QR + keys info (for selectMenu headerContent)
128
+ */
129
+ async function buildMenuHeader(oneTimeKey, permanentKey, connectUrl) {
130
+ const w = Math.min(44, process.stdout.columns || 44);
131
+ const lines = [];
132
+
133
+ if (oneTimeKey && connectUrl) {
134
+ const qrBlock = await buildQRString(connectUrl);
135
+ lines.push(qrBlock);
136
+ lines.push(chalk.gray("\nQR expires in 30 minutes (one-time use)\n"));
137
+ } else {
138
+ lines.push(chalk.gray("\n(One-time key used — generate a new one from menu)\n"));
139
+ }
140
+
141
+ lines.push(
142
+ ORANGE("═".repeat(w)),
143
+ chalk.white("App URL".padEnd(14)) + chalk.gray(`${WORKER_URL}/login`),
144
+ chalk.white("One-Time Key".padEnd(14)) + (oneTimeKey ? ORANGE.bold(oneTimeKey) + chalk.dim(" (expires in 30m)") : chalk.gray("—")),
145
+ chalk.white("Key".padEnd(14)) + chalk.dim(permanentKey),
146
+ ORANGE("═".repeat(w)),
147
+ );
148
+ return lines.join("\n");
149
+ }
150
+
200
151
  /**
201
152
  * Kill process on specific port
202
153
  */
@@ -237,11 +188,15 @@ function startServerWithRestart(onReady, onServerCrash) {
237
188
  process.exit(1);
238
189
  }
239
190
 
191
+ // Strip NODE_ENV=development when running standalone server (production build)
192
+ const spawnEnv = { ...process.env, PORT: String(SERVER_PORT) };
193
+ if (!useDevServer) delete spawnEnv.NODE_ENV;
194
+
240
195
  currentProcess = spawn("node", [serverPath], {
241
196
  cwd: path.dirname(serverPath),
242
197
  stdio: ["ignore", "inherit", "inherit"],
243
198
  detached: false,
244
- env: { ...process.env, PORT: String(SERVER_PORT) }
199
+ env: spawnEnv,
245
200
  });
246
201
 
247
202
 
@@ -320,7 +275,7 @@ function setupExitHandler(serverManager, tunnelProcess, apiKey) {
320
275
  console.log(chalk.yellow("\n\n🛑 Stopping server..."));
321
276
 
322
277
  serverManager.shutdown();
323
- tunnelProcess.kill();
278
+ if (tunnelProcess) tunnelProcess.kill();
324
279
  resetRestartCounter();
325
280
  clearState();
326
281
 
@@ -342,25 +297,6 @@ function getLanIp() {
342
297
  return null;
343
298
  }
344
299
 
345
- /**
346
- * Helper: Create Named Tunnel via Worker API
347
- */
348
- async function createNamedTunnel(apiKey) {
349
- const response = await fetch(`${WORKER_URL}/api/tunnel/create`, {
350
- method: "POST",
351
- headers: { "Content-Type": "application/json" },
352
- body: JSON.stringify({ apiKey })
353
- });
354
-
355
- if (!response.ok) {
356
- const error = await response.json();
357
- throw new Error(error.error || "Failed to create tunnel");
358
- }
359
-
360
- const data = await response.json();
361
- return data;
362
- }
363
-
364
300
  /** Push UI state to server via HTTP */
365
301
  async function pushUiState(data) {
366
302
  try {
@@ -373,7 +309,25 @@ async function pushUiState(data) {
373
309
  }
374
310
 
375
311
  /**
376
- * Helper: Start server and tunnel
312
+ * Update session tunnelUrl on worker + push to UI
313
+ */
314
+ async function updateTunnelUrl(selectedKey, tunnelUrl) {
315
+ const lanIp = getLanIp();
316
+ try {
317
+ await fetch(`${WORKER_URL}/api/session/update`, {
318
+ method: "POST",
319
+ headers: { "Content-Type": "application/json" },
320
+ body: JSON.stringify({
321
+ apiKey: selectedKey,
322
+ tunnelUrl,
323
+ localIp: lanIp ? `${lanIp}:${SERVER_PORT}` : null
324
+ })
325
+ });
326
+ } catch { }
327
+ }
328
+
329
+ /**
330
+ * Helper: Start server and quick tunnel
377
331
  */
378
332
  async function startServerAndTunnel(selectedKey) {
379
333
  console.log(ORANGE("\n🚀 Starting server..."));
@@ -382,193 +336,61 @@ async function startServerAndTunnel(selectedKey) {
382
336
  // Kill existing cloudflared process
383
337
  try {
384
338
  killCloudflared();
385
- await new Promise(resolve => setTimeout(resolve, 1000));
339
+ await new Promise(resolve => setTimeout(resolve, 500));
386
340
  } catch { }
387
341
 
388
- // Reuse existing shortId or generate new one
389
- const existingState = loadState();
390
- const shortId = existingState?.shortId || generateShortId();
391
-
392
- // Create session first
342
+ // Create session on worker
393
343
  try {
394
344
  const sessionResponse = await fetch(`${WORKER_URL}/api/session/create`, {
395
345
  method: "POST",
396
346
  headers: { "Content-Type": "application/json" },
397
- body: JSON.stringify({ apiKey: selectedKey, shortId })
347
+ body: JSON.stringify({ apiKey: selectedKey })
398
348
  });
399
-
400
349
  if (!sessionResponse.ok) {
401
350
  const text = await sessionResponse.text();
402
- console.log(chalk.red(`❌ Failed to create session: ${sessionResponse.status} ${sessionResponse.statusText}`));
403
- console.log(chalk.yellow(`Response: ${text.substring(0, 200)}`));
351
+ console.log(chalk.red(`❌ Failed to create session: ${sessionResponse.status} ${text.substring(0, 200)}`));
404
352
  return null;
405
353
  }
406
-
407
- const sessionData = await sessionResponse.json();
408
354
  } catch (error) {
409
355
  console.log(chalk.red(`❌ Failed to create session: ${error.message}`));
410
356
  return null;
411
357
  }
412
358
 
413
- // tunnelProcess declared here so the serverManager crash callback can reference it
414
- let tunnelProcess = null;
415
-
416
359
  // Skip spawning server if already running (e.g. nodemon in dev mode)
417
360
  const alreadyRunning = await isServerRunning();
418
-
419
- // Start server with auto-restart (skip if already running)
420
361
  const serverManager = alreadyRunning
421
362
  ? { getProcess: () => null, shutdown: () => {} }
422
- : startServerWithRestart(null, async () => {
423
- // Callback khi server crash - đợi server ready rồi gửi SIGHUP
424
-
425
- if (!tunnelProcess) {
426
- return;
427
- }
428
-
429
- // Wait for server to be ready
430
- const maxWait = 60000; // 60s
431
- const checkInterval = 1000; // 1s
432
- const maxRetries = Math.floor(maxWait / checkInterval);
433
- let serverReady = false;
434
-
435
- for (let i = 0; i < maxRetries; i++) {
436
- try {
437
- const response = await fetch(`http://localhost:${SERVER_PORT}/api/health`, {
438
- method: "GET",
439
- timeout: 2000
440
- });
441
-
442
- if (response.ok) {
443
- const data = await response.json();
444
- if (data.status === "ok") {
445
- serverReady = true;
446
- console.log(chalk.green(`✅ Server ready after ${i + 1}s`));
447
- break;
448
- }
449
- }
450
- } catch (err) {
451
- // Server not ready yet
452
- }
453
-
454
- if (i % 5 === 0) {
455
- }
456
-
457
- await new Promise(resolve => setTimeout(resolve, checkInterval));
458
- }
459
-
460
- if (!serverReady) {
461
- console.log(chalk.red("❌ Server not ready after 60s - skipping tunnel reconnect"));
462
- return;
463
- }
464
-
465
- // Server ready - send SIGHUP to cloudflared to reconnect
466
- try {
467
- process.kill(tunnelProcess.pid, "SIGHUP");
468
- console.log(chalk.green("✅ SIGHUP sent - cloudflared should reconnect"));
469
- } catch (err) {
470
- // Fallback: kill and restart
471
- try {
472
- tunnelProcess.kill();
473
- await new Promise(resolve => setTimeout(resolve, 1000));
474
- tunnelProcess = await startTunnel(token);
475
- console.log(chalk.green("✅ Tunnel restarted"));
476
- } catch (restartErr) {
477
- console.log(chalk.red(`❌ Failed to restart tunnel: ${restartErr.message}`));
478
- }
479
- }
480
- });
363
+ : startServerWithRestart(null, null);
481
364
 
482
- // Wait for server to start (skip if already running)
483
- if (!alreadyRunning) await new Promise((resolve) => setTimeout(resolve, 2000));
365
+ if (!alreadyRunning) await new Promise(resolve => setTimeout(resolve, 2000));
484
366
 
485
- console.log(ORANGE("✅ Creating tunnel..."));
367
+ console.log(ORANGE("✅ Starting tunnel..."));
486
368
  pushUiState({ step: 2 });
487
369
 
488
- // Ensure cloudflared binary
370
+ // Spawn quick tunnel — URL comes directly from cloudflared stdout
371
+ let tunnelProcess, tunnelUrl;
489
372
  try {
490
- await ensureCloudflared();
373
+ const result = await spawnQuickTunnel(SERVER_PORT, async (newUrl) => {
374
+ // URL rotated — update worker + UI
375
+ console.log(ORANGE(`🔄 Tunnel URL rotated: ${newUrl}`));
376
+ await updateTunnelUrl(selectedKey, newUrl);
377
+ pushUiState({ tunnelUrl: newUrl });
378
+ });
379
+ tunnelProcess = result.child;
380
+ tunnelUrl = result.tunnelUrl;
491
381
  } catch (error) {
492
- console.log(chalk.red(`❌ Failed to install cloudflared: ${error.message}`));
382
+ console.log(chalk.red(`❌ Failed to start tunnel: ${error.message}`));
493
383
  serverManager.shutdown();
494
384
  return null;
495
385
  }
496
386
 
497
- // Create Named Tunnel via Worker API
498
- let tunnelData;
499
- try {
500
- tunnelData = await createNamedTunnel(selectedKey);
501
- console.log(ORANGE(`✅ Tunnel ID: ${tunnelData.tunnelId}`));
502
- } catch (error) {
503
- console.log(chalk.red(`❌ Failed to create tunnel: ${error.message}`));
504
- serverManager.shutdown();
505
- return null;
506
- }
507
387
 
508
- const { token, hostname: tunnelUrl } = tunnelData;
388
+ // Save tunnelUrl to worker DB
389
+ await updateTunnelUrl(selectedKey, tunnelUrl);
509
390
 
510
- // Spawn cloudflared with token and auto-restart callback
511
- console.log(ORANGE("✅ Starting tunnel..."));
512
-
513
- const startTunnel = async (tunnelToken) => {
514
- try {
515
- tunnelProcess = await spawnCloudflared(tunnelToken, startTunnel);
516
- return tunnelProcess;
517
- } catch (error) {
518
- console.log(chalk.red(`❌ Failed to start cloudflared: ${error.message}`));
519
- return null;
520
- }
521
- };
522
-
523
- tunnelProcess = await startTunnel(token);
524
- if (!tunnelProcess) {
525
- serverManager.shutdown();
526
- return null;
527
- }
528
-
529
- // Verify tunnel reachable via Cloudflare DNS + https.request (bypasses system DNS)
530
- const spinners = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
531
- let tunnelReady = false;
532
- for (let i = 0; i < 30; i++) {
533
- try {
534
- const res = await fetchWithCfDns(`${tunnelUrl}/api/health`);
535
- if (res.ok) { tunnelReady = true; break; }
536
- } catch { }
537
- process.stdout.write(`\r Verifying tunnel ${spinners[i % spinners.length]} (${i * 2}s)`);
538
- await new Promise(r => setTimeout(r, 2000));
539
- }
540
- process.stdout.write("\r" + " ".repeat(40) + "\r");
541
-
542
- if (!tunnelReady) {
543
- console.log(chalk.red("❌ Tunnel not reachable from outside"));
544
- serverManager.shutdown();
545
- tunnelProcess.kill();
546
- return null;
547
- }
548
-
549
- console.log(ORANGE(`✅ Tunnel URL: ${tunnelUrl}`));
550
- const lanIpLog = getLanIp();
551
- if (lanIpLog) console.log(ORANGE(`✅ Local IP: ${lanIpLog}:${SERVER_PORT} (LAN direct available)`));
552
- console.log(ORANGE(`✅ Connection established`));
553
-
554
- // Update session with tunnelUrl + localIp (worker detects publicIp via CF-Connecting-IP)
555
- const lanIp = lanIpLog;
556
- try {
557
- await fetch(`${WORKER_URL}/api/session/update`, {
558
- method: "POST",
559
- headers: { "Content-Type": "application/json" },
560
- body: JSON.stringify({
561
- apiKey: selectedKey,
562
- tunnelUrl,
563
- localIp: lanIp ? `${lanIp}:${SERVER_PORT}` : null
564
- })
565
- });
566
- } catch { }
567
-
568
- // Save state (persist shortId for reuse on restart)
391
+ // Save local state
569
392
  saveState({
570
393
  apiKey: selectedKey,
571
- shortId,
572
394
  tunnelUrl,
573
395
  serverPid: serverManager.getProcess()?.pid,
574
396
  tunnelPid: tunnelProcess.pid
@@ -579,160 +401,284 @@ async function startServerAndTunnel(selectedKey) {
579
401
 
580
402
 
581
403
  /**
582
- * Show main menu
404
+ * TUI mode — main flow for `9remote` (no subcommand)
583
405
  */
584
- async function mainMenu() {
406
+ async function tuiMode() {
585
407
  console.clear();
586
- showBanner();
587
-
588
- const { action } = await inquirer.prompt([
589
- {
590
- type: "list",
591
- name: "action",
592
- message: "Select action:",
593
- choices: [
594
- { name: "🚀 Start Server", value: "start" },
595
- { name: "🔑 Manage Key", value: "key" },
596
- { name: "❌ Exit", value: "exit" }
597
- ]
598
- }
599
- ]);
600
408
 
601
- switch (action) {
602
- case "start":
603
- await startServer();
604
- break;
605
- case "key":
606
- await manageKey();
607
- break;
608
- case "exit":
609
- console.log(chalk.gray("Goodbye!"));
610
- process.exit(0);
611
- }
612
- }
613
-
614
- /**
615
- * Start server with single key
616
- */
617
- async function startServer() {
409
+ // ── 1. Parallel: check latest version + start server ──────────────────────
618
410
  const machineId = await getConsistentMachineId();
619
411
  let keyData = loadKey();
620
-
621
- // Auto create key if none exists
622
412
  if (!keyData.key) {
623
- console.log(chalk.yellow("\n⚠️ No key found. Creating default key..."));
624
413
  const { key } = generateApiKeyWithMachine(machineId);
625
414
  keyData = saveKey(machineId, key, "Default");
626
- console.log(chalk.green("✅ Default key created!"));
627
415
  }
628
416
 
629
- const result = await startServerAndTunnel(keyData.key);
630
- if (!result) {
631
- await inquirer.prompt([{ type: "input", name: "c", message: "Press Enter to go back..." }]);
632
- await mainMenu();
633
- return;
417
+ // Run update check & server start in parallel
418
+ const [updateInfo] = await Promise.all([
419
+ checkLatestVersion(),
420
+ (async () => {
421
+ const alreadyRunning = await isServerRunning();
422
+ if (!alreadyRunning) {
423
+ startServerWithRestart(null, null);
424
+ await new Promise((r) => setTimeout(r, 2000));
425
+ }
426
+ })(),
427
+ ]);
428
+
429
+ // ── 2. Show banner (with update notice if available) ──────────────────────
430
+ const version = getVersion();
431
+ showBanner(version, updateInfo?.latest ?? null);
432
+
433
+ // ── 3. Progress: Preparing → Connecting → Tunneling → Ready ──────────────
434
+ resetProgress();
435
+ renderProgress(0); // Preparing
436
+
437
+ // Kill stale cloudflared
438
+ try { killCloudflared(); await new Promise((r) => setTimeout(r, 300)); } catch {}
439
+
440
+ // Step 1 — Preparing (ensureCloudflared)
441
+ await ensureCloudflared();
442
+
443
+ renderProgress(1, true); // Connecting
444
+
445
+ // Step 2 — Connecting (create session)
446
+ try {
447
+ const res = await fetch(`${WORKER_URL}/api/session/create`, {
448
+ method: "POST",
449
+ headers: { "Content-Type": "application/json" },
450
+ body: JSON.stringify({ apiKey: keyData.key }),
451
+ });
452
+ if (!res.ok) throw new Error(`Session create failed: ${res.status}`);
453
+ } catch (err) {
454
+ console.log(chalk.red(`\n❌ Failed to connect: ${err.message}`));
455
+ process.exit(1);
634
456
  }
635
457
 
636
- const { serverManager, tunnelProcess, tunnelUrl } = result;
458
+ renderProgress(2, true); // Starting tunnel
637
459
 
638
- await showConnectionInfo(keyData.key, tunnelUrl);
639
- setupExitHandler(serverManager, tunnelProcess, keyData.key);
460
+ // Step 3 — Tunnel
461
+ let tunnelProcess, tunnelUrl;
462
+ try {
463
+ const result = await spawnQuickTunnel(SERVER_PORT, async (newUrl) => {
464
+ await updateTunnelUrl(keyData.key, newUrl);
465
+ await pushUiState({ tunnelUrl: newUrl });
466
+ });
467
+ tunnelProcess = result.child;
468
+ tunnelUrl = result.tunnelUrl;
469
+ } catch (err) {
470
+ console.log(chalk.red(`\n❌ Tunnel failed: ${err.message}`));
471
+ process.exit(1);
472
+ }
640
473
 
641
- // Keep process alive
642
- await new Promise(() => { });
474
+ await updateTunnelUrl(keyData.key, tunnelUrl);
475
+ saveState({ apiKey: keyData.key, tunnelUrl, tunnelPid: tunnelProcess.pid });
476
+
477
+ // ── 4. Create temp key + push ready state ─────────────────────────────────
478
+ const tempKeyData = await createTempKey(keyData.key, WORKER_URL);
479
+ const connectUrl = tempKeyData
480
+ ? `${WORKER_URL}/login?k=${tempKeyData.tempKey}`
481
+ : `${WORKER_URL}/login`;
482
+
483
+ await pushUiState({
484
+ step: 4,
485
+ tunnelUrl,
486
+ oneTimeKey: tempKeyData?.tempKey || "",
487
+ oneTimeKeyExpiresAt: tempKeyData?.expiresAt || null,
488
+ permanentKey: keyData.key,
489
+ qrUrl: connectUrl,
490
+ workerUrl: WORKER_URL,
491
+ });
492
+
493
+ // ── 5. Load initial state from server ────────────────────────────────────
494
+ let currentOneTimeKey = tempKeyData?.tempKey || "";
495
+ let currentConnectUrl = connectUrl;
496
+
497
+ // ── 6. Build initial header ───────────────────────────────────────────────
498
+ let menuHeader = await buildMenuHeader(currentOneTimeKey, keyData.key, currentConnectUrl);
499
+
500
+ // Mutable ref for menu re-render callback (set by selectMenu)
501
+ let triggerMenuRedraw = null;
502
+
503
+ // ── 7. Subscribe SSE — auto-rebuild header on state changes ──────────────
504
+ const stopSSE = subscribeSSE(SERVER_PORT, async (type, data) => {
505
+ if (type === "state") {
506
+ const newKey = data.permanentKey || keyData.key;
507
+ // Explicit check: "" means cleared (one-time key consumed), preserve existing if undefined
508
+ const newOtk = data.oneTimeKey !== undefined ? data.oneTimeKey : currentOneTimeKey;
509
+ const newUrl = data.qrUrl !== undefined ? data.qrUrl : currentConnectUrl;
510
+ if (newOtk !== currentOneTimeKey || newKey !== keyData.key) {
511
+ currentOneTimeKey = newOtk;
512
+ currentConnectUrl = newUrl;
513
+ if (data.permanentKey) keyData = { ...keyData, key: data.permanentKey };
514
+ menuHeader = await buildMenuHeader(currentOneTimeKey, keyData.key, currentConnectUrl);
515
+ triggerMenuRedraw?.();
516
+ }
517
+ } else if (type === "permissions") {
518
+ // desktopEnabled changed — trigger redraw so menu label refreshes
519
+ triggerMenuRedraw?.();
520
+ }
521
+ });
522
+
523
+ setupExitHandler({ getProcess: () => null, shutdown: () => { stopSSE(); } }, tunnelProcess, keyData.key);
524
+
525
+ // ── 8. Interactive menu loop ───────────────────────────────────────────────
526
+ await tuiMenuLoop(
527
+ keyData, tunnelUrl,
528
+ () => menuHeader,
529
+ (h) => { menuHeader = h; },
530
+ (cb) => { triggerMenuRedraw = cb; }
531
+ );
532
+ }
533
+
534
+ /** Fetch desktopEnabled from server (source of truth) */
535
+ async function fetchDesktopEnabled() {
536
+ try {
537
+ const res = await fetch(`http://localhost:${SERVER_PORT}/api/ui/state`);
538
+ if (res.ok) { const d = await res.json(); return !!d.desktopEnabled; }
539
+ } catch {}
540
+ return false;
643
541
  }
644
542
 
645
543
  /**
646
- * Manage single key menu
544
+ * Main menu loop after Ready.
545
+ * @param {object} keyData
546
+ * @param {string} tunnelUrl
547
+ * @param {() => string} getHeader - live header getter (SSE may update it)
548
+ * @param {(newHeader: string) => void} setHeader - update header from inside loop
549
+ * @param {(cb: () => void) => void} onRedrawRegister - register redraw callback
647
550
  */
648
- async function manageKey() {
649
- const machineId = await getConsistentMachineId();
650
- let keyData = loadKey();
551
+ async function tuiMenuLoop(keyData, tunnelUrl, getHeader = () => "", setHeader = () => {}, onRedrawRegister = () => {}) {
552
+ while (true) {
553
+ const desktopOn = await fetchDesktopEnabled();
554
+ const desktopLabel = `Remote Desktop: ${desktopOn ? chalk.green("ON") : chalk.gray("OFF")} ▶`;
555
+ const items = [
556
+ { label: "Open Web UI" },
557
+ { label: "New One-Time Key" },
558
+ { label: "Regenerate Permanent Key" },
559
+ { label: desktopLabel },
560
+ { label: chalk.gray("Exit") },
561
+ ];
562
+
563
+ // Register SSE-triggered redraw with selectMenu
564
+ let redrawMenu = null;
565
+ onRedrawRegister(() => redrawMenu?.());
566
+
567
+ const idx = await selectMenu("Select action", items, 0, getHeader, (setRedraw) => {
568
+ redrawMenu = setRedraw;
569
+ });
651
570
 
652
- // Auto create key if none exists
653
- if (!keyData.key) {
654
- console.log(chalk.yellow("\n⚠️ No key found. Creating default key..."));
655
- const { key } = generateApiKeyWithMachine(machineId);
656
- keyData = saveKey(machineId, key, "Default");
657
- console.log(chalk.green("✅ Default key created!"));
658
- }
571
+ if (idx === 0) {
572
+ // Open UI
573
+ const url = `http://localhost:${SERVER_PORT}`;
574
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
575
+ spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
576
+ console.log(chalk.green(`\n🌐 Opening ${url}\n`));
577
+
578
+ } else if (idx === 1) {
579
+ // New One-Time Key — rebuild header with fresh QR
580
+ const newTempKey = await createTempKey(keyData.key, WORKER_URL);
581
+ if (newTempKey) {
582
+ const newConnectUrl = `${WORKER_URL}/login?k=${newTempKey.tempKey}`;
583
+ setHeader(await buildMenuHeader(newTempKey.tempKey, keyData.key, newConnectUrl));
584
+ await pushUiState({ oneTimeKey: newTempKey.tempKey, oneTimeKeyExpiresAt: newTempKey.expiresAt, qrUrl: newConnectUrl });
585
+ }
659
586
 
660
- console.log(ORANGE("\n🔑 Manage Key"));
661
- console.log(chalk.gray("━".repeat(30)));
662
- console.log(chalk.white(`Key: ${keyData.key}`));
663
- console.log(chalk.gray(`Created: ${keyData.createdAt}\n`));
664
-
665
- const { action } = await inquirer.prompt([
666
- {
667
- type: "list",
668
- name: "action",
669
- message: "Action:",
670
- choices: [
671
- { name: "🔐 Create One-Time Key", value: "oneTime" },
672
- { name: "🔄 Regenerate Key", value: "regenerate" },
673
- { name: chalk.gray("← Back"), value: "back" }
674
- ]
675
- }
676
- ]);
587
+ } else if (idx === 2) {
588
+ // Regenerate Key
589
+ const confirmed = await tuiConfirm(chalk.yellow("⚠️ Replace current key and disconnect all sessions? Continue?"));
590
+ if (confirmed) {
591
+ const machineId = await getConsistentMachineId();
592
+ const { key } = generateApiKeyWithMachine(machineId);
593
+ keyData = saveKey(machineId, key, keyData.name || "Default");
594
+ await pushUiState({ permanentKey: keyData.key });
595
+ // Rebuild header with new key
596
+ const newTmp = await createTempKey(keyData.key, WORKER_URL);
597
+ if (newTmp) {
598
+ const newUrl = `${WORKER_URL}/login?k=${newTmp.tempKey}`;
599
+ setHeader(await buildMenuHeader(newTmp.tempKey, keyData.key, newUrl));
600
+ await pushUiState({ oneTimeKey: newTmp.tempKey, oneTimeKeyExpiresAt: newTmp.expiresAt, qrUrl: newUrl });
601
+ }
602
+ }
603
+
604
+ } else if (idx === 3) {
605
+ // Remote Desktop submenu
606
+ await tuiDesktopMenu();
677
607
 
678
- if (action === "oneTime") {
679
- console.log(chalk.gray("\nCreating one-time key..."));
680
- const tempKeyData = await createTempKey(keyData.key, WORKER_URL);
681
-
682
- if (tempKeyData) {
683
- const connectUrl = `${WORKER_URL}/login?k=${tempKeyData.tempKey}`;
684
- const width = Math.min(50, process.stdout.columns || 50);
685
-
686
- showQRCode(connectUrl);
687
-
688
- console.log(chalk.gray(`\nQR will expire in 30 minutes (one-time use)\n`));
689
-
690
- console.log(ORANGE("═".repeat(width)));
691
-
692
- // App URL
693
- const appLabel = "App URL";
694
- const appValue = `${WORKER_URL}/login`;
695
- console.log(chalk.white(appLabel.padEnd(16)) + chalk.gray(appValue));
696
-
697
- // One-Time Key
698
- const keyLabel = "One-Time Key";
699
- const keyValue = tempKeyData.tempKey;
700
- console.log(chalk.white(keyLabel.padEnd(16)) + ORANGE.bold(keyValue));
701
-
702
- console.log(ORANGE("═".repeat(width)));
703
608
  } else {
704
- console.log(chalk.red("❌ Failed to create one-time key"));
609
+ // Exit
610
+ console.log(chalk.gray("\nGoodbye!\n"));
611
+ process.exit(0);
705
612
  }
706
- await inquirer.prompt([{ type: "input", name: "continue", message: "Press Enter to continue..." }]);
707
613
  }
614
+ }
708
615
 
709
- if (action === "regenerate") {
710
- const { confirm } = await inquirer.prompt([
711
- {
712
- type: "confirm",
713
- name: "confirm",
714
- message: chalk.yellow("⚠️ This will replace your current key. Continue?"),
715
- default: false
616
+ /**
617
+ * Remote Desktop submenu.
618
+ * All state (desktopEnabled + permissions) fetched from server — no local tracking.
619
+ */
620
+ async function tuiDesktopMenu() {
621
+ while (true) {
622
+ // Always read from server
623
+ let desktopOn = false, perms = { screenRecording: false, accessibility: false };
624
+ try {
625
+ const res = await fetch(`http://localhost:${SERVER_PORT}/api/ui/state`);
626
+ if (res.ok) {
627
+ const d = await res.json();
628
+ desktopOn = !!d.desktopEnabled;
629
+ perms = { screenRecording: !!d.screenRecording, accessibility: !!d.accessibility };
716
630
  }
717
- ]);
631
+ } catch {}
718
632
 
719
- if (confirm) {
720
- const { key } = generateApiKeyWithMachine(machineId);
721
- keyData = saveKey(machineId, key, keyData.name);
722
-
723
- console.log(chalk.green(`\n✅ Key regenerated: ${keyData.key}`));
724
- await inquirer.prompt([{ type: "input", name: "continue", message: "Press Enter to continue..." }]);
633
+ const toggleLabel = `Toggle: ${desktopOn ? chalk.green("ON → turn OFF") : chalk.gray("OFF → turn ON")}`;
634
+ const srLabel = `Screen Recording ${perms.screenRecording ? chalk.green("✓") : chalk.red("✗ (click to grant)")}`;
635
+ const axLabel = `Mouse & Keyboard control ${perms.accessibility ? chalk.green("✓") : chalk.red("✗ (click to grant)")}`;
636
+
637
+ const idx = await selectMenu("Remote Desktop", [
638
+ { label: toggleLabel },
639
+ { label: srLabel },
640
+ { label: axLabel },
641
+ { label: chalk.gray("← Back") },
642
+ ], 0);
643
+
644
+ if (idx === 0) {
645
+ // Toggle — flip current value via server
646
+ try {
647
+ await fetch(`http://localhost:${SERVER_PORT}/api/desktop/toggle`, {
648
+ method: "POST",
649
+ headers: { "Content-Type": "application/json" },
650
+ body: JSON.stringify({ enabled: !desktopOn }),
651
+ });
652
+ } catch {}
653
+
654
+ } else if (idx === 1 && !perms.screenRecording) {
655
+ try {
656
+ await fetch(`http://localhost:${SERVER_PORT}/api/permissions/request`, {
657
+ method: "POST", headers: { "Content-Type": "application/json" },
658
+ body: JSON.stringify({ type: "screenRecording" }),
659
+ });
660
+ } catch { openPermissionPane("screenRecording"); }
661
+
662
+ } else if (idx === 2 && !perms.accessibility) {
663
+ try {
664
+ await fetch(`http://localhost:${SERVER_PORT}/api/permissions/request`, {
665
+ method: "POST", headers: { "Content-Type": "application/json" },
666
+ body: JSON.stringify({ type: "accessibility" }),
667
+ });
668
+ } catch { openPermissionPane("accessibility"); }
669
+
670
+ } else if (idx === 3 || idx === -1) {
671
+ return; // Back
725
672
  }
726
673
  }
727
-
728
- await mainMenu();
729
674
  }
730
675
 
676
+
731
677
  /**
732
678
  * Auto start dev server (--auto flag)
733
679
  */
734
680
  async function autoStartDev() {
735
- showBanner();
681
+ showBanner(getVersion());
736
682
 
737
683
  const machineId = await getConsistentMachineId();
738
684
  let keyData = loadKey();
@@ -756,6 +702,7 @@ async function autoStartDev() {
756
702
 
757
703
  await showConnectionInfo(keyData.key, tunnelUrl);
758
704
  setupExitHandler(serverManager, tunnelProcess, keyData.key);
705
+ setupKeyRegenListener();
759
706
 
760
707
  // Push stats to UI every 5s
761
708
  const startTime = Date.now();
@@ -770,6 +717,77 @@ async function autoStartDev() {
770
717
  }
771
718
 
772
719
 
720
+ /**
721
+ * Listen for key regen, stop-tunnel, start-tunnel from server UI
722
+ */
723
+ function setupCmdPoller(getActiveTunnel, setActiveTunnel, apiKey) {
724
+ let busy = false;
725
+ setInterval(async () => {
726
+ if (busy) return;
727
+ const cmd = readAndClearCmd();
728
+ if (!cmd) return;
729
+ busy = true;
730
+ try {
731
+
732
+ if (cmd === "stop-tunnel") {
733
+ const tunnel = getActiveTunnel();
734
+ if (tunnel) {
735
+ tunnel.kill();
736
+ setActiveTunnel(null);
737
+ console.log(chalk.yellow("🛑 Tunnel stopped"));
738
+ }
739
+ await pushUiState({ step: 0, tunnelUrl: "", oneTimeKey: "", oneTimeKeyExpiresAt: null });
740
+ }
741
+
742
+ if (cmd === "start-tunnel") {
743
+ if (getActiveTunnel()) { busy = false; return; } // already running
744
+ console.log(ORANGE("🚀 Starting tunnel..."));
745
+ try {
746
+ // Step 1: Preparing — check/download cloudflared binary
747
+ await pushUiState({ step: 1 });
748
+ await ensureCloudflared();
749
+
750
+ // Step 2: Connecting — create session on worker
751
+ await pushUiState({ step: 2 });
752
+ const sessionResponse = await fetch(`${WORKER_URL}/api/session/create`, {
753
+ method: "POST",
754
+ headers: { "Content-Type": "application/json" },
755
+ body: JSON.stringify({ apiKey }),
756
+ });
757
+ if (!sessionResponse.ok) throw new Error(`Session create failed: ${sessionResponse.status}`);
758
+
759
+ // Step 3: Tunneling — spawn cloudflared
760
+ await pushUiState({ step: 3 });
761
+ const result = await spawnQuickTunnel(SERVER_PORT, async (newUrl) => {
762
+ await updateTunnelUrl(apiKey, newUrl);
763
+ await pushUiState({ tunnelUrl: newUrl });
764
+ });
765
+ setActiveTunnel(result.child);
766
+ await updateTunnelUrl(apiKey, result.tunnelUrl);
767
+
768
+ // Step 4: Ready
769
+ await showConnectionInfo(apiKey, result.tunnelUrl);
770
+ } catch (err) {
771
+ console.log(chalk.red(`❌ Failed to start tunnel: ${err.message}`));
772
+ await pushUiState({ step: 0 });
773
+ }
774
+ }
775
+
776
+ if (cmd === "regenerate-key") {
777
+ const machineId = await getConsistentMachineId();
778
+ const { key } = generateApiKeyWithMachine(machineId);
779
+ const existing = loadKey();
780
+ saveKey(machineId, key, existing?.name || "Default");
781
+ await pushUiState({ permanentKey: key });
782
+ console.log(chalk.green(`✅ Key regenerated: ${key}`));
783
+ }
784
+
785
+ } finally {
786
+ busy = false;
787
+ }
788
+ }, 1000);
789
+ }
790
+
773
791
  /**
774
792
  * Check if server is already running on SERVER_PORT
775
793
  */
@@ -787,7 +805,7 @@ async function isServerRunning() {
787
805
  * Same as "Start Server" in TUI but auto-opens browser
788
806
  */
789
807
  async function startUiMode() {
790
- showBanner();
808
+ showBanner(getVersion());
791
809
 
792
810
  const machineId = await getConsistentMachineId();
793
811
  let keyData = loadKey();
@@ -797,12 +815,13 @@ async function startUiMode() {
797
815
  keyData = saveKey(machineId, key, "Default");
798
816
  }
799
817
 
800
- const result = await startServerAndTunnel(keyData.key);
801
- if (!result) process.exit(1);
802
-
803
- const { serverManager, tunnelProcess, tunnelUrl } = result;
818
+ // Start server only (no tunnel yet — wait for UI Connect button)
819
+ const alreadyRunning = await isServerRunning();
820
+ const serverManager = alreadyRunning
821
+ ? { getProcess: () => null, shutdown: () => {} }
822
+ : startServerWithRestart(null, null);
804
823
 
805
- await showConnectionInfo(keyData.key, tunnelUrl);
824
+ if (!alreadyRunning) await new Promise(resolve => setTimeout(resolve, 2000));
806
825
 
807
826
  // Open browser pointing to UI
808
827
  const url = `http://localhost:${SERVER_PORT}`;
@@ -812,15 +831,16 @@ async function startUiMode() {
812
831
  spawn(openCmd, [url], { detached: true, stdio: "ignore" }).unref();
813
832
  console.log(chalk.green(`\n🌐 UI ready at ${url}`));
814
833
 
815
- setupExitHandler(serverManager, tunnelProcess, keyData.key);
834
+ // Mutable ref for active tunnel
835
+ let activeTunnel = null;
836
+ const getActiveTunnel = () => activeTunnel;
837
+ const setActiveTunnel = (t) => { activeTunnel = t; };
816
838
 
817
- // Push stats to UI every 5s
818
- const startTime = Date.now();
819
- setInterval(() => {
820
- const uptime = Math.floor((Date.now() - startTime) / 1000);
821
- const h = Math.floor(uptime / 3600), m = Math.floor((uptime % 3600) / 60), s = uptime % 60;
822
- pushUiState({ uptime: `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` });
823
- }, 5000);
839
+ // Push permanentKey to UI so Welcome screen can display it
840
+ await pushUiState({ permanentKey: keyData.key, step: 0 });
841
+
842
+ setupExitHandler(serverManager, null, keyData.key);
843
+ setupCmdPoller(getActiveTunnel, setActiveTunnel, keyData.key);
824
844
 
825
845
  await new Promise(() => { });
826
846
  }
@@ -840,8 +860,8 @@ async function start() {
840
860
  // Direct start: 9remote start
841
861
  await autoStartDev();
842
862
  } else {
843
- // Menu mode: 9remote
844
- await mainMenu();
863
+ // TUI mode: 9remote
864
+ await tuiMode();
845
865
  }
846
866
  }
847
867