9remote 0.1.53 → 0.1.55

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
@@ -10,10 +10,12 @@ import fs from "fs";
10
10
  import os from "os";
11
11
  import { getConsistentMachineId } from "./utils/machineId.js";
12
12
  import { generateApiKeyWithMachine } from "./utils/apiKey.js";
13
- import { loadKey, saveKey, loadState, saveState, clearState } from "./utils/state.js";
13
+ import { loadKey, saveKey, loadState, saveState, clearState, readAndClearCmd } from "./utils/state.js";
14
14
  import { createTempKey } from "./utils/token.js";
15
- import { checkAndUpdate } from "./utils/updateChecker.js";
16
- import { spawnQuickTunnel, killCloudflared, resetRestartCounter } from "./utils/cloudflared.js";
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";
17
19
 
18
20
  // Parse --skip-update flag
19
21
  const skipUpdate = process.argv.includes("--skip-update");
@@ -52,54 +54,23 @@ function getVersion() {
52
54
  }
53
55
  }
54
56
 
55
- /**
56
- * Show banner
57
- */
58
- function showBanner() {
59
- const version = getVersion();
60
- const width = Math.min(44, process.stdout.columns || 44);
61
-
62
- console.log("");
63
- console.log(ORANGE("╔" + "═".repeat(width - 2) + "╗"));
64
- console.log(ORANGE("║") + " ".repeat(width - 2) + ORANGE("║"));
65
-
66
- const title = `🚀 9Remote v${version}`;
67
- const titlePadding = Math.floor((width - 2 - title.length) / 2);
68
- console.log(
69
- ORANGE("║") +
70
- " ".repeat(titlePadding) +
71
- ORANGE.bold(title) +
72
- " ".repeat(width - 2 - titlePadding - title.length) +
73
- ORANGE("║")
74
- );
75
-
76
- const subtitle = "Remote terminal access from anywhere";
77
- const subtitlePadding = Math.floor((width - 2 - subtitle.length) / 2);
78
- console.log(
79
- ORANGE("║") +
80
- " ".repeat(subtitlePadding) +
81
- chalk.gray(subtitle) +
82
- " ".repeat(width - 2 - subtitlePadding - subtitle.length) +
83
- ORANGE("║")
84
- );
85
-
86
- console.log(ORANGE("║") + " ".repeat(width - 2) + ORANGE("║"));
87
- console.log(ORANGE("╚" + "═".repeat(width - 2) + "╝"));
88
- console.log("");
89
- }
90
57
 
91
58
  /**
92
59
  * Helper: Show QR code for connect URL
93
60
  */
94
61
  function showQRCode(url, title = "📱 Scan QR to connect:") {
95
62
  console.log(ORANGE(`\n${title}`));
96
- qrcode.generate(url, {
97
- small: true,
98
- type: 'terminal',
99
- margin: 0,
100
- }, qr => {
101
- const lines = qr.trim().split('\n');
102
- 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
+ });
103
74
  });
104
75
  }
105
76
 
@@ -119,7 +90,7 @@ async function showConnectionInfo(selectedKey, tunnelUrl) {
119
90
 
120
91
  // Push ready state to UI (include permanentKey, expiresAt, workerUrl for server-side key generation)
121
92
  pushUiState({
122
- step: 3,
93
+ step: 4,
123
94
  tunnelUrl,
124
95
  oneTimeKey: tempKeyData.tempKey,
125
96
  oneTimeKeyExpiresAt: tempKeyData.expiresAt,
@@ -152,6 +123,31 @@ async function showConnectionInfo(selectedKey, tunnelUrl) {
152
123
  console.log(ORANGE("═".repeat(width)));
153
124
  }
154
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
+
155
151
  /**
156
152
  * Kill process on specific port
157
153
  */
@@ -279,7 +275,8 @@ function setupExitHandler(serverManager, tunnelProcess, apiKey) {
279
275
  console.log(chalk.yellow("\n\n🛑 Stopping server..."));
280
276
 
281
277
  serverManager.shutdown();
282
- tunnelProcess.kill();
278
+ if (tunnelProcess) tunnelProcess.kill();
279
+ killProcessOnPort(SERVER_PORT);
283
280
  resetRestartCounter();
284
281
  clearState();
285
282
 
@@ -388,10 +385,6 @@ async function startServerAndTunnel(selectedKey) {
388
385
  return null;
389
386
  }
390
387
 
391
- console.log(ORANGE(`✅ Tunnel URL: ${tunnelUrl}`));
392
- const lanIp = getLanIp();
393
- if (lanIp) console.log(ORANGE(`✅ Local IP: ${lanIp}:${SERVER_PORT} (LAN direct available)`));
394
- console.log(ORANGE(`✅ Connection established`));
395
388
 
396
389
  // Save tunnelUrl to worker DB
397
390
  await updateTunnelUrl(selectedKey, tunnelUrl);
@@ -409,160 +402,290 @@ async function startServerAndTunnel(selectedKey) {
409
402
 
410
403
 
411
404
  /**
412
- * Show main menu
405
+ * TUI mode — main flow for `9remote` (no subcommand)
413
406
  */
414
- async function mainMenu() {
407
+ async function tuiMode() {
415
408
  console.clear();
416
- showBanner();
417
-
418
- const { action } = await inquirer.prompt([
419
- {
420
- type: "list",
421
- name: "action",
422
- message: "Select action:",
423
- choices: [
424
- { name: "🚀 Start Server", value: "start" },
425
- { name: "🔑 Manage Key", value: "key" },
426
- { name: "❌ Exit", value: "exit" }
427
- ]
428
- }
429
- ]);
430
409
 
431
- switch (action) {
432
- case "start":
433
- await startServer();
434
- break;
435
- case "key":
436
- await manageKey();
437
- break;
438
- case "exit":
439
- console.log(chalk.gray("Goodbye!"));
440
- process.exit(0);
441
- }
442
- }
443
-
444
- /**
445
- * Start server with single key
446
- */
447
- async function startServer() {
410
+ // ── 1. Parallel: check latest version + start server ──────────────────────
448
411
  const machineId = await getConsistentMachineId();
449
412
  let keyData = loadKey();
450
-
451
- // Auto create key if none exists
452
413
  if (!keyData.key) {
453
- console.log(chalk.yellow("\n⚠️ No key found. Creating default key..."));
454
414
  const { key } = generateApiKeyWithMachine(machineId);
455
415
  keyData = saveKey(machineId, key, "Default");
456
- console.log(chalk.green("✅ Default key created!"));
457
416
  }
458
417
 
459
- const result = await startServerAndTunnel(keyData.key);
460
- if (!result) {
461
- await inquirer.prompt([{ type: "input", name: "c", message: "Press Enter to go back..." }]);
462
- await mainMenu();
463
- return;
418
+ // Run update check & server start in parallel
419
+ let tuiServerMgr = { getProcess: () => null, shutdown: () => {} };
420
+ const [updateInfo] = await Promise.all([
421
+ checkLatestVersion(),
422
+ (async () => {
423
+ const alreadyRunning = await isServerRunning();
424
+ if (!alreadyRunning) {
425
+ tuiServerMgr = startServerWithRestart(null, null);
426
+ await new Promise((r) => setTimeout(r, 2000));
427
+ }
428
+ })(),
429
+ ]);
430
+
431
+ // ── 2. Show banner (with update notice if available) ──────────────────────
432
+ const version = getVersion();
433
+ showBanner(version, updateInfo?.latest ?? null);
434
+
435
+ // ── 3. Progress: Preparing → Connecting → Tunneling → Ready ──────────────
436
+ resetProgress();
437
+ renderProgress(0); // Preparing
438
+
439
+ // Kill stale cloudflared
440
+ try { killCloudflared(); await new Promise((r) => setTimeout(r, 300)); } catch {}
441
+
442
+ // Step 1 — Preparing (ensureCloudflared)
443
+ await ensureCloudflared();
444
+
445
+ renderProgress(1, true); // Connecting
446
+
447
+ // Step 2 — Connecting (create session)
448
+ try {
449
+ const res = await fetch(`${WORKER_URL}/api/session/create`, {
450
+ method: "POST",
451
+ headers: { "Content-Type": "application/json" },
452
+ body: JSON.stringify({ apiKey: keyData.key }),
453
+ });
454
+ if (!res.ok) throw new Error(`Session create failed: ${res.status}`);
455
+ } catch (err) {
456
+ console.log(chalk.red(`\n❌ Failed to connect: ${err.message}`));
457
+ process.exit(1);
464
458
  }
465
459
 
466
- const { serverManager, tunnelProcess, tunnelUrl } = result;
460
+ renderProgress(2, true); // Starting tunnel
467
461
 
468
- await showConnectionInfo(keyData.key, tunnelUrl);
469
- setupExitHandler(serverManager, tunnelProcess, keyData.key);
462
+ // Step 3 — Tunnel
463
+ let tunnelProcess, tunnelUrl;
464
+ try {
465
+ const result = await spawnQuickTunnel(SERVER_PORT, async (newUrl) => {
466
+ await updateTunnelUrl(keyData.key, newUrl);
467
+ await pushUiState({ tunnelUrl: newUrl });
468
+ });
469
+ tunnelProcess = result.child;
470
+ tunnelUrl = result.tunnelUrl;
471
+ } catch (err) {
472
+ console.log(chalk.red(`\n❌ Tunnel failed: ${err.message}`));
473
+ process.exit(1);
474
+ }
470
475
 
471
- // Keep process alive
472
- await new Promise(() => { });
476
+ await updateTunnelUrl(keyData.key, tunnelUrl);
477
+ saveState({ apiKey: keyData.key, tunnelUrl, tunnelPid: tunnelProcess.pid });
478
+
479
+ // ── 4. Create temp key + push ready state ─────────────────────────────────
480
+ const tempKeyData = await createTempKey(keyData.key, WORKER_URL);
481
+ const connectUrl = tempKeyData
482
+ ? `${WORKER_URL}/login?k=${tempKeyData.tempKey}`
483
+ : `${WORKER_URL}/login`;
484
+
485
+ await pushUiState({
486
+ step: 4,
487
+ tunnelUrl,
488
+ oneTimeKey: tempKeyData?.tempKey || "",
489
+ oneTimeKeyExpiresAt: tempKeyData?.expiresAt || null,
490
+ permanentKey: keyData.key,
491
+ qrUrl: connectUrl,
492
+ workerUrl: WORKER_URL,
493
+ });
494
+
495
+ // ── 5. Load initial state from server ────────────────────────────────────
496
+ let currentOneTimeKey = tempKeyData?.tempKey || "";
497
+ let currentConnectUrl = connectUrl;
498
+
499
+ // ── 6. Build initial header ───────────────────────────────────────────────
500
+ let menuHeader = await buildMenuHeader(currentOneTimeKey, keyData.key, currentConnectUrl);
501
+
502
+ // Mutable ref for menu re-render callback (set by selectMenu)
503
+ let triggerMenuRedraw = null;
504
+
505
+ // ── 7. Subscribe SSE — auto-rebuild header on state changes ──────────────
506
+ const stopSSE = subscribeSSE(SERVER_PORT, async (type, data) => {
507
+ if (type === "state") {
508
+ const newKey = data.permanentKey || keyData.key;
509
+ // Explicit check: "" means cleared (one-time key consumed), preserve existing if undefined
510
+ const newOtk = data.oneTimeKey !== undefined ? data.oneTimeKey : currentOneTimeKey;
511
+ const newUrl = data.qrUrl !== undefined ? data.qrUrl : currentConnectUrl;
512
+ if (newOtk !== currentOneTimeKey || newKey !== keyData.key) {
513
+ currentOneTimeKey = newOtk;
514
+ currentConnectUrl = newUrl;
515
+ if (data.permanentKey) keyData = { ...keyData, key: data.permanentKey };
516
+ menuHeader = await buildMenuHeader(currentOneTimeKey, keyData.key, currentConnectUrl);
517
+ triggerMenuRedraw?.();
518
+ }
519
+ } else if (type === "permissions") {
520
+ // desktopEnabled changed — trigger redraw so menu label refreshes
521
+ triggerMenuRedraw?.();
522
+ }
523
+ });
524
+
525
+ setupExitHandler({
526
+ getProcess: tuiServerMgr.getProcess,
527
+ shutdown: () => { tuiServerMgr.shutdown(); stopSSE(); }
528
+ }, tunnelProcess, keyData.key);
529
+
530
+ // ── 8. Interactive menu loop ───────────────────────────────────────────────
531
+ await tuiMenuLoop(
532
+ keyData, tunnelUrl,
533
+ () => menuHeader,
534
+ (h) => { menuHeader = h; },
535
+ (cb) => { triggerMenuRedraw = cb; },
536
+ () => { tuiServerMgr.shutdown(); killProcessOnPort(SERVER_PORT); stopSSE(); }
537
+ );
538
+ }
539
+
540
+ /** Fetch desktopEnabled from server (source of truth) */
541
+ async function fetchDesktopEnabled() {
542
+ try {
543
+ const res = await fetch(`http://localhost:${SERVER_PORT}/api/ui/state`);
544
+ if (res.ok) { const d = await res.json(); return !!d.desktopEnabled; }
545
+ } catch {}
546
+ return false;
473
547
  }
474
548
 
475
549
  /**
476
- * Manage single key menu
550
+ * Main menu loop after Ready.
551
+ * @param {object} keyData
552
+ * @param {string} tunnelUrl
553
+ * @param {() => string} getHeader - live header getter (SSE may update it)
554
+ * @param {(newHeader: string) => void} setHeader - update header from inside loop
555
+ * @param {(cb: () => void) => void} onRedrawRegister - register redraw callback
477
556
  */
478
- async function manageKey() {
479
- const machineId = await getConsistentMachineId();
480
- let keyData = loadKey();
557
+ async function tuiMenuLoop(keyData, tunnelUrl, getHeader = () => "", setHeader = () => {}, onRedrawRegister = () => {}, onCtrlC = null) {
558
+ while (true) {
559
+ const desktopOn = await fetchDesktopEnabled();
560
+ const desktopLabel = `Remote Desktop: ${desktopOn ? chalk.green("ON") : chalk.gray("OFF")} ▶`;
561
+ const items = [
562
+ { label: "Open Web UI" },
563
+ { label: "New One-Time Key" },
564
+ { label: "Regenerate Permanent Key" },
565
+ { label: desktopLabel },
566
+ { label: chalk.gray("Exit") },
567
+ ];
568
+
569
+ // Register SSE-triggered redraw with selectMenu
570
+ let redrawMenu = null;
571
+ onRedrawRegister(() => redrawMenu?.());
572
+
573
+ const idx = await selectMenu("Select action", items, 0, getHeader, (setRedraw) => {
574
+ redrawMenu = setRedraw;
575
+ }, onCtrlC);
576
+
577
+ if (idx === 0) {
578
+ // Open UI
579
+ const url = `http://localhost:${SERVER_PORT}`;
580
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
581
+ spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
582
+ console.log(chalk.green(`\n🌐 Opening ${url}\n`));
583
+
584
+ } else if (idx === 1) {
585
+ // New One-Time Key — rebuild header with fresh QR
586
+ const newTempKey = await createTempKey(keyData.key, WORKER_URL);
587
+ if (newTempKey) {
588
+ const newConnectUrl = `${WORKER_URL}/login?k=${newTempKey.tempKey}`;
589
+ setHeader(await buildMenuHeader(newTempKey.tempKey, keyData.key, newConnectUrl));
590
+ await pushUiState({ oneTimeKey: newTempKey.tempKey, oneTimeKeyExpiresAt: newTempKey.expiresAt, qrUrl: newConnectUrl });
591
+ }
481
592
 
482
- // Auto create key if none exists
483
- if (!keyData.key) {
484
- console.log(chalk.yellow("\n⚠️ No key found. Creating default key..."));
485
- const { key } = generateApiKeyWithMachine(machineId);
486
- keyData = saveKey(machineId, key, "Default");
487
- console.log(chalk.green("✅ Default key created!"));
488
- }
593
+ } else if (idx === 2) {
594
+ // Regenerate Key
595
+ const confirmed = await tuiConfirm(chalk.yellow("⚠️ Replace current key and disconnect all sessions? Continue?"));
596
+ if (confirmed) {
597
+ const machineId = await getConsistentMachineId();
598
+ const { key } = generateApiKeyWithMachine(machineId);
599
+ keyData = saveKey(machineId, key, keyData.name || "Default");
600
+ await pushUiState({ permanentKey: keyData.key });
601
+ // Rebuild header with new key
602
+ const newTmp = await createTempKey(keyData.key, WORKER_URL);
603
+ if (newTmp) {
604
+ const newUrl = `${WORKER_URL}/login?k=${newTmp.tempKey}`;
605
+ setHeader(await buildMenuHeader(newTmp.tempKey, keyData.key, newUrl));
606
+ await pushUiState({ oneTimeKey: newTmp.tempKey, oneTimeKeyExpiresAt: newTmp.expiresAt, qrUrl: newUrl });
607
+ }
608
+ }
489
609
 
490
- console.log(ORANGE("\n🔑 Manage Key"));
491
- console.log(chalk.gray("━".repeat(30)));
492
- console.log(chalk.white(`Key: ${keyData.key}`));
493
- console.log(chalk.gray(`Created: ${keyData.createdAt}\n`));
494
-
495
- const { action } = await inquirer.prompt([
496
- {
497
- type: "list",
498
- name: "action",
499
- message: "Action:",
500
- choices: [
501
- { name: "🔐 Create One-Time Key", value: "oneTime" },
502
- { name: "🔄 Regenerate Key", value: "regenerate" },
503
- { name: chalk.gray("← Back"), value: "back" }
504
- ]
505
- }
506
- ]);
610
+ } else if (idx === 3) {
611
+ // Remote Desktop submenu
612
+ await tuiDesktopMenu();
507
613
 
508
- if (action === "oneTime") {
509
- console.log(chalk.gray("\nCreating one-time key..."));
510
- const tempKeyData = await createTempKey(keyData.key, WORKER_URL);
511
-
512
- if (tempKeyData) {
513
- const connectUrl = `${WORKER_URL}/login?k=${tempKeyData.tempKey}`;
514
- const width = Math.min(50, process.stdout.columns || 50);
515
-
516
- showQRCode(connectUrl);
517
-
518
- console.log(chalk.gray(`\nQR will expire in 30 minutes (one-time use)\n`));
519
-
520
- console.log(ORANGE("═".repeat(width)));
521
-
522
- // App URL
523
- const appLabel = "App URL";
524
- const appValue = `${WORKER_URL}/login`;
525
- console.log(chalk.white(appLabel.padEnd(16)) + chalk.gray(appValue));
526
-
527
- // One-Time Key
528
- const keyLabel = "One-Time Key";
529
- const keyValue = tempKeyData.tempKey;
530
- console.log(chalk.white(keyLabel.padEnd(16)) + ORANGE.bold(keyValue));
531
-
532
- console.log(ORANGE("═".repeat(width)));
533
614
  } else {
534
- console.log(chalk.red("❌ Failed to create one-time key"));
615
+ // Exit kill server child process before exiting
616
+ killProcessOnPort(SERVER_PORT);
617
+ console.log(chalk.gray("\nGoodbye!\n"));
618
+ process.exit(0);
535
619
  }
536
- await inquirer.prompt([{ type: "input", name: "continue", message: "Press Enter to continue..." }]);
537
620
  }
621
+ }
538
622
 
539
- if (action === "regenerate") {
540
- const { confirm } = await inquirer.prompt([
541
- {
542
- type: "confirm",
543
- name: "confirm",
544
- message: chalk.yellow("⚠️ This will replace your current key. Continue?"),
545
- default: false
623
+ /**
624
+ * Remote Desktop submenu.
625
+ * All state (desktopEnabled + permissions) fetched from server — no local tracking.
626
+ */
627
+ async function tuiDesktopMenu() {
628
+ while (true) {
629
+ // Always read from server
630
+ let desktopOn = false, perms = { screenRecording: false, accessibility: false };
631
+ try {
632
+ const res = await fetch(`http://localhost:${SERVER_PORT}/api/ui/state`);
633
+ if (res.ok) {
634
+ const d = await res.json();
635
+ desktopOn = !!d.desktopEnabled;
636
+ perms = { screenRecording: !!d.screenRecording, accessibility: !!d.accessibility };
546
637
  }
547
- ]);
548
-
549
- if (confirm) {
550
- const { key } = generateApiKeyWithMachine(machineId);
551
- keyData = saveKey(machineId, key, keyData.name);
552
-
553
- console.log(chalk.green(`\n✅ Key regenerated: ${keyData.key}`));
554
- await inquirer.prompt([{ type: "input", name: "continue", message: "Press Enter to continue..." }]);
638
+ } catch {}
639
+
640
+ const toggleLabel = `Toggle: ${desktopOn ? chalk.green("ON → turn OFF") : chalk.gray("OFF → turn ON")}`;
641
+ const srLabel = `Screen Recording ${perms.screenRecording ? chalk.green("✓") : chalk.red("✗ (click to grant)")}`;
642
+ const axLabel = `Mouse & Keyboard control ${perms.accessibility ? chalk.green("✓") : chalk.red("✗ (click to grant)")}`;
643
+
644
+ const idx = await selectMenu("Remote Desktop", [
645
+ { label: toggleLabel },
646
+ { label: srLabel },
647
+ { label: axLabel },
648
+ { label: chalk.gray("← Back") },
649
+ ], 0);
650
+
651
+ if (idx === 0) {
652
+ // Toggle — flip current value via server
653
+ try {
654
+ await fetch(`http://localhost:${SERVER_PORT}/api/desktop/toggle`, {
655
+ method: "POST",
656
+ headers: { "Content-Type": "application/json" },
657
+ body: JSON.stringify({ enabled: !desktopOn }),
658
+ });
659
+ } catch {}
660
+
661
+ } else if (idx === 1 && !perms.screenRecording) {
662
+ try {
663
+ await fetch(`http://localhost:${SERVER_PORT}/api/permissions/request`, {
664
+ method: "POST", headers: { "Content-Type": "application/json" },
665
+ body: JSON.stringify({ type: "screenRecording" }),
666
+ });
667
+ } catch { openPermissionPane("screenRecording"); }
668
+
669
+ } else if (idx === 2 && !perms.accessibility) {
670
+ try {
671
+ await fetch(`http://localhost:${SERVER_PORT}/api/permissions/request`, {
672
+ method: "POST", headers: { "Content-Type": "application/json" },
673
+ body: JSON.stringify({ type: "accessibility" }),
674
+ });
675
+ } catch { openPermissionPane("accessibility"); }
676
+
677
+ } else if (idx === 3 || idx === -1) {
678
+ return; // Back
555
679
  }
556
680
  }
557
-
558
- await mainMenu();
559
681
  }
560
682
 
683
+
561
684
  /**
562
685
  * Auto start dev server (--auto flag)
563
686
  */
564
687
  async function autoStartDev() {
565
- showBanner();
688
+ showBanner(getVersion());
566
689
 
567
690
  const machineId = await getConsistentMachineId();
568
691
  let keyData = loadKey();
@@ -602,17 +725,74 @@ async function autoStartDev() {
602
725
 
603
726
 
604
727
  /**
605
- * Listen for key regen request from server UI
728
+ * Listen for key regen, stop-tunnel, start-tunnel from server UI
606
729
  */
607
- function setupKeyRegenListener() {
608
- process.on("9remote:regenerate-key", async () => {
609
- const machineId = await getConsistentMachineId();
610
- const { key } = generateApiKeyWithMachine(machineId);
611
- const existing = loadKey();
612
- saveKey(machineId, key, existing?.name || "Default");
613
- pushUiState({ permanentKey: key });
614
- console.log(chalk.green(`✅ Key regenerated: ${key}`));
615
- });
730
+ function setupCmdPoller(getActiveTunnel, setActiveTunnel, apiKey) {
731
+ let busy = false;
732
+ setInterval(async () => {
733
+ if (busy) return;
734
+ const cmd = readAndClearCmd();
735
+ if (!cmd) return;
736
+ busy = true;
737
+ try {
738
+
739
+ if (cmd === "stop-tunnel") {
740
+ const tunnel = getActiveTunnel();
741
+ if (tunnel) {
742
+ tunnel.kill();
743
+ setActiveTunnel(null);
744
+ console.log(chalk.yellow("🛑 Tunnel stopped"));
745
+ }
746
+ await pushUiState({ step: 0, tunnelUrl: "", oneTimeKey: "", oneTimeKeyExpiresAt: null });
747
+ }
748
+
749
+ if (cmd === "start-tunnel") {
750
+ if (getActiveTunnel()) { busy = false; return; } // already running
751
+ console.log(ORANGE("🚀 Starting tunnel..."));
752
+ try {
753
+ // Step 1: Preparing — check/download cloudflared binary
754
+ await pushUiState({ step: 1 });
755
+ await ensureCloudflared();
756
+
757
+ // Step 2: Connecting — create session on worker
758
+ await pushUiState({ step: 2 });
759
+ const sessionResponse = await fetch(`${WORKER_URL}/api/session/create`, {
760
+ method: "POST",
761
+ headers: { "Content-Type": "application/json" },
762
+ body: JSON.stringify({ apiKey }),
763
+ });
764
+ if (!sessionResponse.ok) throw new Error(`Session create failed: ${sessionResponse.status}`);
765
+
766
+ // Step 3: Tunneling — spawn cloudflared
767
+ await pushUiState({ step: 3 });
768
+ const result = await spawnQuickTunnel(SERVER_PORT, async (newUrl) => {
769
+ await updateTunnelUrl(apiKey, newUrl);
770
+ await pushUiState({ tunnelUrl: newUrl });
771
+ });
772
+ setActiveTunnel(result.child);
773
+ await updateTunnelUrl(apiKey, result.tunnelUrl);
774
+
775
+ // Step 4: Ready
776
+ await showConnectionInfo(apiKey, result.tunnelUrl);
777
+ } catch (err) {
778
+ console.log(chalk.red(`❌ Failed to start tunnel: ${err.message}`));
779
+ await pushUiState({ step: 0 });
780
+ }
781
+ }
782
+
783
+ if (cmd === "regenerate-key") {
784
+ const machineId = await getConsistentMachineId();
785
+ const { key } = generateApiKeyWithMachine(machineId);
786
+ const existing = loadKey();
787
+ saveKey(machineId, key, existing?.name || "Default");
788
+ await pushUiState({ permanentKey: key });
789
+ console.log(chalk.green(`✅ Key regenerated: ${key}`));
790
+ }
791
+
792
+ } finally {
793
+ busy = false;
794
+ }
795
+ }, 1000);
616
796
  }
617
797
 
618
798
  /**
@@ -632,7 +812,7 @@ async function isServerRunning() {
632
812
  * Same as "Start Server" in TUI but auto-opens browser
633
813
  */
634
814
  async function startUiMode() {
635
- showBanner();
815
+ showBanner(getVersion());
636
816
 
637
817
  const machineId = await getConsistentMachineId();
638
818
  let keyData = loadKey();
@@ -642,12 +822,13 @@ async function startUiMode() {
642
822
  keyData = saveKey(machineId, key, "Default");
643
823
  }
644
824
 
645
- const result = await startServerAndTunnel(keyData.key);
646
- if (!result) process.exit(1);
647
-
648
- const { serverManager, tunnelProcess, tunnelUrl } = result;
825
+ // Start server only (no tunnel yet — wait for UI Connect button)
826
+ const alreadyRunning = await isServerRunning();
827
+ const serverManager = alreadyRunning
828
+ ? { getProcess: () => null, shutdown: () => {} }
829
+ : startServerWithRestart(null, null);
649
830
 
650
- await showConnectionInfo(keyData.key, tunnelUrl);
831
+ if (!alreadyRunning) await new Promise(resolve => setTimeout(resolve, 2000));
651
832
 
652
833
  // Open browser pointing to UI
653
834
  const url = `http://localhost:${SERVER_PORT}`;
@@ -657,16 +838,16 @@ async function startUiMode() {
657
838
  spawn(openCmd, [url], { detached: true, stdio: "ignore" }).unref();
658
839
  console.log(chalk.green(`\n🌐 UI ready at ${url}`));
659
840
 
660
- setupExitHandler(serverManager, tunnelProcess, keyData.key);
661
- setupKeyRegenListener();
841
+ // Mutable ref for active tunnel
842
+ let activeTunnel = null;
843
+ const getActiveTunnel = () => activeTunnel;
844
+ const setActiveTunnel = (t) => { activeTunnel = t; };
662
845
 
663
- // Push stats to UI every 5s
664
- const startTime = Date.now();
665
- setInterval(() => {
666
- const uptime = Math.floor((Date.now() - startTime) / 1000);
667
- const h = Math.floor(uptime / 3600), m = Math.floor((uptime % 3600) / 60), s = uptime % 60;
668
- pushUiState({ uptime: `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` });
669
- }, 5000);
846
+ // Push permanentKey to UI so Welcome screen can display it
847
+ await pushUiState({ permanentKey: keyData.key, step: 0 });
848
+
849
+ setupExitHandler(serverManager, null, keyData.key);
850
+ setupCmdPoller(getActiveTunnel, setActiveTunnel, keyData.key);
670
851
 
671
852
  await new Promise(() => { });
672
853
  }
@@ -686,8 +867,8 @@ async function start() {
686
867
  // Direct start: 9remote start
687
868
  await autoStartDev();
688
869
  } else {
689
- // Menu mode: 9remote
690
- await mainMenu();
870
+ // TUI mode: 9remote
871
+ await tuiMode();
691
872
  }
692
873
  }
693
874