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 +379 -198
- package/cli/utils/apiKey.js +13 -8
- package/cli/utils/permissions.js +45 -0
- package/cli/utils/state.js +23 -0
- package/cli/utils/tui.js +251 -0
- package/cli/utils/updateChecker.js +22 -0
- package/dist/cli.cjs +44 -95
- package/dist/server.cjs +48 -45
- package/dist/ui/assets/index-CMD-4YxV.js +8 -0
- package/dist/ui/assets/index-D4GJ1wNn.css +1 -0
- package/dist/ui/index.html +2 -2
- package/index.js +200 -8
- package/package.json +1 -1
- package/dist/ui/assets/index-BIuRs677.js +0 -8
- package/dist/ui/assets/index-DhKgENYK.css +0 -1
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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:
|
|
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
|
-
*
|
|
405
|
+
* TUI mode — main flow for `9remote` (no subcommand)
|
|
413
406
|
*/
|
|
414
|
-
async function
|
|
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
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
460
|
+
renderProgress(2, true); // Starting tunnel
|
|
467
461
|
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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
|
-
*
|
|
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
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
|
728
|
+
* Listen for key regen, stop-tunnel, start-tunnel from server UI
|
|
606
729
|
*/
|
|
607
|
-
function
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
|
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
|
-
|
|
661
|
-
|
|
841
|
+
// Mutable ref for active tunnel
|
|
842
|
+
let activeTunnel = null;
|
|
843
|
+
const getActiveTunnel = () => activeTunnel;
|
|
844
|
+
const setActiveTunnel = (t) => { activeTunnel = t; };
|
|
662
845
|
|
|
663
|
-
// Push
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
//
|
|
690
|
-
await
|
|
870
|
+
// TUI mode: 9remote
|
|
871
|
+
await tuiMode();
|
|
691
872
|
}
|
|
692
873
|
}
|
|
693
874
|
|