9remote 0.1.53 → 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 +372 -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 +250 -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,7 @@ 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();
|
|
283
279
|
resetRestartCounter();
|
|
284
280
|
clearState();
|
|
285
281
|
|
|
@@ -388,10 +384,6 @@ async function startServerAndTunnel(selectedKey) {
|
|
|
388
384
|
return null;
|
|
389
385
|
}
|
|
390
386
|
|
|
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
387
|
|
|
396
388
|
// Save tunnelUrl to worker DB
|
|
397
389
|
await updateTunnelUrl(selectedKey, tunnelUrl);
|
|
@@ -409,160 +401,284 @@ async function startServerAndTunnel(selectedKey) {
|
|
|
409
401
|
|
|
410
402
|
|
|
411
403
|
/**
|
|
412
|
-
*
|
|
404
|
+
* TUI mode — main flow for `9remote` (no subcommand)
|
|
413
405
|
*/
|
|
414
|
-
async function
|
|
406
|
+
async function tuiMode() {
|
|
415
407
|
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
408
|
|
|
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() {
|
|
409
|
+
// ── 1. Parallel: check latest version + start server ──────────────────────
|
|
448
410
|
const machineId = await getConsistentMachineId();
|
|
449
411
|
let keyData = loadKey();
|
|
450
|
-
|
|
451
|
-
// Auto create key if none exists
|
|
452
412
|
if (!keyData.key) {
|
|
453
|
-
console.log(chalk.yellow("\n⚠️ No key found. Creating default key..."));
|
|
454
413
|
const { key } = generateApiKeyWithMachine(machineId);
|
|
455
414
|
keyData = saveKey(machineId, key, "Default");
|
|
456
|
-
console.log(chalk.green("✅ Default key created!"));
|
|
457
415
|
}
|
|
458
416
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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);
|
|
464
456
|
}
|
|
465
457
|
|
|
466
|
-
|
|
458
|
+
renderProgress(2, true); // Starting tunnel
|
|
467
459
|
|
|
468
|
-
|
|
469
|
-
|
|
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
|
+
}
|
|
470
473
|
|
|
471
|
-
|
|
472
|
-
|
|
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;
|
|
473
541
|
}
|
|
474
542
|
|
|
475
543
|
/**
|
|
476
|
-
*
|
|
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
|
|
477
550
|
*/
|
|
478
|
-
async function
|
|
479
|
-
|
|
480
|
-
|
|
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
|
+
});
|
|
481
570
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
+
}
|
|
489
586
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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();
|
|
507
607
|
|
|
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
608
|
} else {
|
|
534
|
-
|
|
609
|
+
// Exit
|
|
610
|
+
console.log(chalk.gray("\nGoodbye!\n"));
|
|
611
|
+
process.exit(0);
|
|
535
612
|
}
|
|
536
|
-
await inquirer.prompt([{ type: "input", name: "continue", message: "Press Enter to continue..." }]);
|
|
537
613
|
}
|
|
614
|
+
}
|
|
538
615
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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 };
|
|
546
630
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
631
|
+
} catch {}
|
|
632
|
+
|
|
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
|
|
555
672
|
}
|
|
556
673
|
}
|
|
557
|
-
|
|
558
|
-
await mainMenu();
|
|
559
674
|
}
|
|
560
675
|
|
|
676
|
+
|
|
561
677
|
/**
|
|
562
678
|
* Auto start dev server (--auto flag)
|
|
563
679
|
*/
|
|
564
680
|
async function autoStartDev() {
|
|
565
|
-
showBanner();
|
|
681
|
+
showBanner(getVersion());
|
|
566
682
|
|
|
567
683
|
const machineId = await getConsistentMachineId();
|
|
568
684
|
let keyData = loadKey();
|
|
@@ -602,17 +718,74 @@ async function autoStartDev() {
|
|
|
602
718
|
|
|
603
719
|
|
|
604
720
|
/**
|
|
605
|
-
* Listen for key regen
|
|
721
|
+
* Listen for key regen, stop-tunnel, start-tunnel from server UI
|
|
606
722
|
*/
|
|
607
|
-
function
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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);
|
|
616
789
|
}
|
|
617
790
|
|
|
618
791
|
/**
|
|
@@ -632,7 +805,7 @@ async function isServerRunning() {
|
|
|
632
805
|
* Same as "Start Server" in TUI but auto-opens browser
|
|
633
806
|
*/
|
|
634
807
|
async function startUiMode() {
|
|
635
|
-
showBanner();
|
|
808
|
+
showBanner(getVersion());
|
|
636
809
|
|
|
637
810
|
const machineId = await getConsistentMachineId();
|
|
638
811
|
let keyData = loadKey();
|
|
@@ -642,12 +815,13 @@ async function startUiMode() {
|
|
|
642
815
|
keyData = saveKey(machineId, key, "Default");
|
|
643
816
|
}
|
|
644
817
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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);
|
|
649
823
|
|
|
650
|
-
await
|
|
824
|
+
if (!alreadyRunning) await new Promise(resolve => setTimeout(resolve, 2000));
|
|
651
825
|
|
|
652
826
|
// Open browser pointing to UI
|
|
653
827
|
const url = `http://localhost:${SERVER_PORT}`;
|
|
@@ -657,16 +831,16 @@ async function startUiMode() {
|
|
|
657
831
|
spawn(openCmd, [url], { detached: true, stdio: "ignore" }).unref();
|
|
658
832
|
console.log(chalk.green(`\n🌐 UI ready at ${url}`));
|
|
659
833
|
|
|
660
|
-
|
|
661
|
-
|
|
834
|
+
// Mutable ref for active tunnel
|
|
835
|
+
let activeTunnel = null;
|
|
836
|
+
const getActiveTunnel = () => activeTunnel;
|
|
837
|
+
const setActiveTunnel = (t) => { activeTunnel = t; };
|
|
662
838
|
|
|
663
|
-
// Push
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
pushUiState({ uptime: `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` });
|
|
669
|
-
}, 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);
|
|
670
844
|
|
|
671
845
|
await new Promise(() => { });
|
|
672
846
|
}
|
|
@@ -686,8 +860,8 @@ async function start() {
|
|
|
686
860
|
// Direct start: 9remote start
|
|
687
861
|
await autoStartDev();
|
|
688
862
|
} else {
|
|
689
|
-
//
|
|
690
|
-
await
|
|
863
|
+
// TUI mode: 9remote
|
|
864
|
+
await tuiMode();
|
|
691
865
|
}
|
|
692
866
|
}
|
|
693
867
|
|