9remote 2.0.1 → 2.0.7

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 DELETED
@@ -1,1288 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import inquirer from "inquirer";
4
- import chalk from "chalk";
5
- import qrcode from "qrcode-terminal";
6
- import { spawn, execSync, execFile } from "child_process";
7
- import path from "path";
8
- import { fileURLToPath } from "url";
9
- import fs from "fs";
10
- import os from "os";
11
- import dns from "dns";
12
- import { getConsistentMachineId } from "./utils/machineId.js";
13
- import { generateApiKeyWithMachine } from "./utils/apiKey.js";
14
- import { loadKey, saveKey, loadState, saveState, clearState, readAndClearCmd, writeCmd } from "./utils/state.js";
15
- import { createTempKey } from "./utils/token.js";
16
- import { checkAndUpdate, checkLatestVersion, stopRunningInstances } from "./utils/updateChecker.js";
17
- import { writePid, clearPid } from "./utils/pids.js";
18
- import { spawnQuickTunnel, killCloudflared, resetRestartCounter, ensureCloudflared } from "./utils/cloudflared.js";
19
- import { showBanner, getBannerText, renderProgress, resetProgress, updateProgressDesc, setProgressInfo, selectMenu, confirm as tuiConfirm, subscribeSSE, openPermissionPane, showDeviceApproval } from "./utils/tui.js";
20
- import { checkPermissions } from "./utils/permissions.js";
21
- import { initTray, killTray, openBrowser, updateTrayTooltip, showTrayNotification } from "./utils/tray.js";
22
- import { STEP, DEBUG, browserFetch } from "../lib/constants.js";
23
-
24
- const skipUpdate = process.argv.includes("--skip-update");
25
-
26
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
- const PROJECT_ROOT = path.resolve(__dirname, "../..");
28
- const STANDALONE_SERVER = path.resolve(__dirname, "../dist/server.cjs");
29
- const DEV_SERVER = path.resolve(__dirname, "../index.js");
30
- const WORKER_URL = "https://9remote.cc";
31
- const SERVER_PORT = 2208;
32
- const MAX_RESTART_ATTEMPTS = 10;
33
- const RESTART_WINDOW_MS = 60000; // 1 minute
34
-
35
- const ORANGE = chalk.rgb(230, 138, 110);
36
- const ORANGE_DIM = chalk.rgb(200, 120, 95);
37
-
38
- // Submenus set this to receive SSE-driven refreshes (permissions, state, ...) while open
39
- let activeSubmenuRefresh = null;
40
-
41
- /** Ensure API key exists, create if missing */
42
- async function ensureKeyData() {
43
- const machineId = await getConsistentMachineId();
44
- let keyData = loadKey();
45
- if (!keyData.key) {
46
- const { key } = generateApiKeyWithMachine(machineId);
47
- keyData = saveKey(machineId, key, "Default");
48
- }
49
- return keyData;
50
- }
51
-
52
- function getVersion() {
53
- if (typeof __CLI_VERSION__ !== "undefined") {
54
- return __CLI_VERSION__;
55
- }
56
-
57
- try {
58
- const packagePath = path.join(__dirname, "..", "package.json");
59
- const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf-8"));
60
- return packageJson.version;
61
- } catch {
62
- return "unknown";
63
- }
64
- }
65
-
66
- function showQRCode(url, title = "šŸ“± Scan QR to connect:") {
67
- console.log(ORANGE(`\n${title}`));
68
- qrcode.generate(url, { small: true, type: "terminal", margin: 0 }, (qr) => {
69
- console.log(qr.trim());
70
- });
71
- }
72
-
73
- function buildQRString(url) {
74
- return new Promise((resolve) => {
75
- qrcode.generate(url, { small: true, type: "terminal", margin: 0 }, (qr) => {
76
- resolve(ORANGE_DIM("šŸ“± Scan QR to connect:") + "\n" + qr.trim());
77
- });
78
- });
79
- }
80
-
81
- async function showConnectionInfo(selectedKey, tunnelUrl) {
82
- const tempKeyData = await createTempKey(selectedKey, WORKER_URL);
83
-
84
- if (!tempKeyData) {
85
- console.log(chalk.red("āŒ Failed to create temp key"));
86
- return;
87
- }
88
-
89
- const connectUrl = `${WORKER_URL}/login?k=${tempKeyData.tempKey}`;
90
- const width = Math.min(44, process.stdout.columns || 55);
91
-
92
- await setStep(STEP.READY, {
93
- tunnelUrl,
94
- oneTimeKey: tempKeyData.tempKey,
95
- oneTimeKeyExpiresAt: tempKeyData.expiresAt,
96
- permanentKey: selectedKey,
97
- qrUrl: connectUrl,
98
- workerUrl: WORKER_URL,
99
- });
100
-
101
- showQRCode(connectUrl);
102
-
103
- console.log(chalk.gray(`\nQR will expire in 30 minutes (one-time use)\n`));
104
-
105
- console.log(ORANGE("═".repeat(width)));
106
-
107
- const appLabel = "App URL";
108
- const appValue = `${WORKER_URL}/login`;
109
- console.log(chalk.white(appLabel.padEnd(14)) + chalk.gray(appValue));
110
-
111
- const keyLabel = "One-Time Key";
112
- const keyValue = tempKeyData.tempKey;
113
- console.log(chalk.white(keyLabel.padEnd(14)) + ORANGE.bold(keyValue));
114
-
115
- const permLabel = "Key";
116
- const permValue = selectedKey;
117
- console.log(chalk.white(permLabel.padEnd(14)) + chalk.gray(permValue));
118
-
119
- console.log(ORANGE("═".repeat(width)));
120
- }
121
-
122
- async function buildMenuHeader(oneTimeKey, permanentKey, connectUrl, tunnelUrl = "") {
123
- const w = Math.min(44, process.stdout.columns || 44);
124
- const lines = [];
125
-
126
- if (oneTimeKey && connectUrl) {
127
- const qrBlock = await buildQRString(connectUrl);
128
- lines.push(qrBlock);
129
- lines.push(chalk.gray("\nQR expires in 30 minutes (one-time use)\n"));
130
- } else {
131
- lines.push(chalk.gray("\n(One-time key used — generate a new one from menu)\n"));
132
- }
133
-
134
- lines.push(
135
- ORANGE("═".repeat(w)),
136
- chalk.white("App URL".padEnd(14)) + chalk.gray(`${WORKER_URL}/login`),
137
- );
138
-
139
- if (DEBUG.showTunnelUrlInMenu) {
140
- lines.push(chalk.white("Tunnel".padEnd(14)) + (tunnelUrl ? chalk.cyan(tunnelUrl) : chalk.gray("—")));
141
- }
142
-
143
- lines.push(
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
-
151
- function killProcessOnPort(port) {
152
- try {
153
- if (process.platform === "win32") {
154
- execSync(`for /f "tokens=5" %a in ('netstat -aon ^| findstr :${port}') do taskkill /F /PID %a`, { stdio: "ignore", windowsHide: true });
155
- } else {
156
- const nullDevice = "/dev/null";
157
- execSync(`lsof -ti:${port} | xargs kill -9 2>${nullDevice} || true`, { stdio: "ignore" });
158
- }
159
- } catch { }
160
- }
161
-
162
- function startServerWithRestart(onReady, onServerCrash) {
163
- const restartTimes = [];
164
- let currentProcess = null;
165
- let isShuttingDown = false;
166
- let isFirstStart = true;
167
-
168
- const spawnServer = () => {
169
- if (isFirstStart) {
170
- killProcessOnPort(SERVER_PORT);
171
- isFirstStart = false;
172
- }
173
-
174
- const useDevServer = process.env.NODE_ENV === "development" && fs.existsSync(DEV_SERVER);
175
- const serverPath = useDevServer ? DEV_SERVER : STANDALONE_SERVER;
176
-
177
- if (!fs.existsSync(serverPath)) {
178
- console.error(`āŒ Server not found: ${serverPath}`);
179
- process.exit(1);
180
- }
181
-
182
- const spawnEnv = { ...process.env, PORT: String(SERVER_PORT) };
183
- if (!useDevServer) delete spawnEnv.NODE_ENV;
184
-
185
- currentProcess = spawn("node", [serverPath], {
186
- cwd: path.dirname(serverPath),
187
- stdio: "inherit",
188
- detached: false,
189
- windowsHide: true,
190
- env: spawnEnv,
191
- });
192
- // No separate PID file: server is a direct child of agent. When the
193
- // updater kills agent with `taskkill /F /T`, this child dies too.
194
-
195
- currentProcess.on("exit", (code, signal) => {
196
- if (isShuttingDown) {
197
- return;
198
- }
199
-
200
- // Check if it's a crash (non-zero exit code or unexpected signal)
201
- if (code !== 0 || signal) {
202
- console.log(chalk.red(`\nšŸ’„ Server crashed (code: ${code}, signal: ${signal})`));
203
-
204
- // Check restart limit
205
- const now = Date.now();
206
- restartTimes.push(now);
207
-
208
- while (restartTimes.length > 0 && restartTimes[0] < now - RESTART_WINDOW_MS) {
209
- restartTimes.shift();
210
- }
211
-
212
- if (restartTimes.length > MAX_RESTART_ATTEMPTS) {
213
- console.log(chalk.red(`āŒ Too many restarts (${MAX_RESTART_ATTEMPTS} in ${RESTART_WINDOW_MS / 1000}s). Giving up.`));
214
- // Don't bare-exit: cloudflared + tray would orphan and PID files
215
- // would go stale. shutdownAll handles all of that.
216
- isShuttingDown = true;
217
- shutdownAll({ code: 1 });
218
- return;
219
- }
220
-
221
- console.log(chalk.yellow(`šŸ”„ Restarting server... (attempt ${restartTimes.length}/${MAX_RESTART_ATTEMPTS})`));
222
-
223
- // āœ… Callback Ä‘į»ƒ restart cloudflared
224
- if (onServerCrash) {
225
- console.log(chalk.yellow("āœ… Restarting tunnel connection..."));
226
- onServerCrash();
227
- }
228
-
229
- setTimeout(() => {
230
- spawnServer();
231
- }, 1000);
232
- }
233
- });
234
-
235
- currentProcess.on("error", (err) => {
236
- console.log(chalk.red(`āŒ Server error: ${err.message}`));
237
- });
238
-
239
- if (onReady) {
240
- onReady(currentProcess);
241
- }
242
- };
243
-
244
- spawnServer();
245
-
246
- return {
247
- getProcess: () => currentProcess,
248
- shutdown: () => {
249
- isShuttingDown = true;
250
- if (currentProcess) {
251
- // SIGKILL so Windows TerminateProcess fires immediately — SIGTERM
252
- // on Windows is best-effort and leaves server node.exe orphaned
253
- // when agent exits right after.
254
- try { currentProcess.kill("SIGKILL"); } catch {}
255
- }
256
- }
257
- };
258
- }
259
-
260
- /**
261
- * Tear down every 9remote resource and exit. Single source of truth for
262
- * shutdown so Ctrl+C, menu Exit, tray "Shutdown" and IPC all behave the same:
263
- * - stop server (in-memory manager + port safety net)
264
- * - kill cloudflared (PID file + in-memory tunnel reference)
265
- * - kill tray helper
266
- * - clear PID files so next update doesn't hit stale entries
267
- * - clear local state/IPC files
268
- */
269
- function shutdownAll({ serverManager, tunnelProcess, exit = true, code = 0 } = {}) {
270
- try { serverManager?.shutdown?.(); } catch {}
271
- try { tunnelProcess?.kill?.(); } catch {}
272
- try { killCloudflared(); } catch {}
273
- try { killTray(); } catch {}
274
- try { killProcessOnPort(SERVER_PORT); } catch {}
275
- try { resetRestartCounter(); } catch {}
276
- try { clearState(); } catch {}
277
- try { clearPid("agent"); } catch {}
278
- try { clearPid("cloudflared"); } catch {}
279
- if (exit) {
280
- // Delay so tray / HTTP replies flush AND Windows `taskkill /F` on port
281
- // finishes killing orphan server node.exe before we exit. 200ms was
282
- // too short on Windows and left 1 node.exe alive.
283
- setTimeout(() => process.exit(code), 500);
284
- }
285
- }
286
-
287
- let exitHandlerRegistered = false;
288
-
289
- /**
290
- * Register cleanup for every "shutdown path" we can observe.
291
- *
292
- * Why so many signals:
293
- * - SIGINT : Ctrl+C in terminal
294
- * - SIGTERM : `kill <pid>`, service manager stop, Docker stop
295
- * - SIGHUP : terminal closed on POSIX (parent shell died)
296
- * - SIGBREAK : Ctrl+Break on Windows (Node's Windows-only signal)
297
- * - Windows X button : Node emits SIGHUP on CTRL_CLOSE_EVENT when stdin is
298
- * in raw mode OR when a readline interface is open.
299
- * We force-enable it by creating a readline iface on
300
- * Windows so closing the console window triggers
301
- * cleanup instead of orphaning tray + cloudflared.
302
- * - beforeExit : natural event-loop drain (last-resort cleanup)
303
- * - uncaughtException/unhandledRejection: don't leak processes on crash
304
- *
305
- * Without this, closing the terminal with the X button on Windows (or
306
- * kill -TERM) leaves tray_windows_release.exe + cloudflared.exe alive,
307
- * which hold file handles inside node_modules\9remote\dist and cause
308
- * `npm i -g 9remote@latest` to fail with EBUSY rename errors.
309
- */
310
- function setupExitHandler(serverManager, tunnelProcess, apiKey) {
311
- if (exitHandlerRegistered) return;
312
- exitHandlerRegistered = true;
313
-
314
- let shuttingDown = false;
315
- const onSignal = (sig) => {
316
- if (shuttingDown) return;
317
- shuttingDown = true;
318
- // Only log when attached to a TTY — if the console was closed (SIGHUP
319
- // on window-close), stdout may already be gone and writes throw.
320
- try {
321
- if (process.stdout.isTTY) {
322
- console.log(chalk.yellow(`\n\nšŸ›‘ Stopping 9Remote (${sig})...`));
323
- }
324
- } catch {}
325
- shutdownAll({ serverManager, tunnelProcess });
326
- try {
327
- if (process.stdout.isTTY) {
328
- console.log(chalk.green("āœ… Server stopped"));
329
- }
330
- } catch {}
331
- };
332
-
333
- // POSIX + Windows common signals
334
- process.on("SIGINT", () => onSignal("SIGINT"));
335
- process.on("SIGTERM", () => onSignal("SIGTERM"));
336
- process.on("SIGHUP", () => onSignal("SIGHUP"));
337
- // SIGBREAK only exists on Windows; registering it elsewhere is harmless
338
- // but Node warns — guard it.
339
- if (process.platform === "win32") {
340
- process.on("SIGBREAK", () => onSignal("SIGBREAK"));
341
-
342
- // Windows "X button" on the console window fires CTRL_CLOSE_EVENT. Node
343
- // only translates this into SIGHUP if it has a readline interface open
344
- // (see Node docs: "Signal Events" → Windows). Open a minimal one so the
345
- // signal actually fires and our cleanup runs.
346
- try {
347
- // Lazy import so non-Windows platforms don't pay for it
348
- import("readline").then(({ createInterface }) => {
349
- const rl = createInterface({ input: process.stdin, output: process.stdout });
350
- rl.on("SIGINT", () => onSignal("SIGINT"));
351
- // Detach from event loop — don't keep the process alive just for this
352
- if (process.stdin.isTTY) process.stdin.unref?.();
353
- }).catch(() => {});
354
- } catch {}
355
- }
356
-
357
- // Last-resort cleanup on crash — don't leak tray/cloudflared if we throw.
358
- process.on("uncaughtException", (err) => {
359
- try { console.error(chalk.red("Uncaught exception:"), err?.message || err); } catch {}
360
- onSignal("uncaughtException");
361
- setTimeout(() => process.exit(1), 300);
362
- });
363
- process.on("unhandledRejection", (err) => {
364
- try { console.error(chalk.red("Unhandled rejection:"), err?.message || err); } catch {}
365
- onSignal("unhandledRejection");
366
- setTimeout(() => process.exit(1), 300);
367
- });
368
- }
369
-
370
- function getLanIp() {
371
- const interfaces = os.networkInterfaces();
372
- for (const iface of Object.values(interfaces)) {
373
- for (const addr of iface) {
374
- if (addr.family === "IPv4" && !addr.internal) return addr.address;
375
- }
376
- }
377
- return null;
378
- }
379
-
380
- /** Local API helpers */
381
- async function apiPost(path, data) {
382
- try {
383
- return await fetch(`http://localhost:${SERVER_PORT}${path}`, {
384
- method: "POST",
385
- headers: { "Content-Type": "application/json" },
386
- body: JSON.stringify(data),
387
- });
388
- } catch { return null; }
389
- }
390
-
391
- async function apiGet(path) {
392
- try {
393
- const res = await fetch(`http://localhost:${SERVER_PORT}${path}`);
394
- return res.ok ? await res.json() : null;
395
- } catch { return null; }
396
- }
397
-
398
- async function pushUiState(data) {
399
- await apiPost("/api/ui/state", data);
400
- }
401
-
402
- // DRY: single source for step progression — updates both terminal progress + web UI state.
403
- // Terminal rendering only active in TUI mode (guarded by isTuiActive flag).
404
- let isTuiActive = false;
405
- async function setStep(step, extra = {}) {
406
- if (isTuiActive) renderProgress(step - 1, step > STEP.PREPARING);
407
- await pushUiState({ step, stepDesc: "", ...extra });
408
- }
409
-
410
- // DRY: single handler for cloudflared binary progress → updates both TUI + web UI.
411
- function onBinaryProgress({ phase, percent }) {
412
- const text = phase === "download"
413
- ? `Downloading tunnel binary ${percent ?? 0}%`
414
- : "Extracting tunnel binary";
415
- if (isTuiActive) updateProgressDesc(text);
416
- pushUiState({ stepDesc: text });
417
- }
418
-
419
- async function updateTunnelUrl(selectedKey, tunnelUrl) {
420
- const lanIp = getLanIp();
421
- try {
422
- await browserFetch(`${WORKER_URL}/api/session/update`, {
423
- method: "POST",
424
- headers: { "Content-Type": "application/json" },
425
- body: JSON.stringify({
426
- apiKey: selectedKey,
427
- tunnelUrl,
428
- localIp: lanIp ? `${lanIp}:${SERVER_PORT}` : null
429
- })
430
- });
431
- } catch { }
432
- }
433
-
434
- async function startServerAndTunnel(selectedKey) {
435
- console.log(ORANGE("\nšŸš€ Starting server..."));
436
- await setStep(STEP.PREPARING);
437
-
438
- // Kill existing cloudflared process
439
- try {
440
- killCloudflared();
441
- await new Promise(resolve => setTimeout(resolve, 500));
442
- } catch { }
443
-
444
- // Create session on worker
445
- try {
446
- const res = await browserFetch(`${WORKER_URL}/api/session/create`, {
447
- method: "POST",
448
- headers: { "Content-Type": "application/json" },
449
- body: JSON.stringify({ apiKey: selectedKey }),
450
- });
451
- if (!res.ok) { console.log(chalk.red(`āŒ Session create failed: ${res.status}`)); return null; }
452
- } catch (e) {
453
- console.log(chalk.red(`āŒ Session create failed: ${e.message}`)); return null;
454
- }
455
-
456
- // Skip spawning server if already running (e.g. nodemon in dev mode)
457
- const alreadyRunning = await isServerRunning();
458
- const serverManager = alreadyRunning
459
- ? { getProcess: () => null, shutdown: () => {} }
460
- : startServerWithRestart(null, null);
461
-
462
- if (!alreadyRunning) await new Promise(resolve => setTimeout(resolve, 2000));
463
-
464
- console.log(ORANGE("āœ… Starting tunnel..."));
465
- await setStep(STEP.CONNECTING);
466
-
467
- // Spawn quick tunnel — URL comes directly from cloudflared stdout
468
- let tunnelProcess, tunnelUrl;
469
- try {
470
- const result = await spawnQuickTunnel(SERVER_PORT, async (newUrl) => {
471
- // URL rotated — update worker + UI
472
- console.log(ORANGE(`šŸ”„ Tunnel URL rotated: ${newUrl}`));
473
- await updateTunnelUrl(selectedKey, newUrl);
474
- pushUiState({ tunnelUrl: newUrl });
475
- });
476
- tunnelProcess = result.child;
477
- tunnelUrl = result.tunnelUrl;
478
- } catch (error) {
479
- console.log(chalk.red(`āŒ Failed to start tunnel: ${error.message}`));
480
- serverManager.shutdown();
481
- return null;
482
- }
483
-
484
- // Health check tunnel from outside before proceeding
485
- const tunnelReady = await waitForTunnelReady(tunnelUrl);
486
- if (!tunnelReady) {
487
- console.log(chalk.yellow("\nāš ļø Tunnel health check timed out, proceeding anyway..."));
488
- }
489
-
490
- // Save tunnelUrl to worker DB
491
- await updateTunnelUrl(selectedKey, tunnelUrl);
492
-
493
- // Save local state
494
- saveState({
495
- apiKey: selectedKey,
496
- tunnelUrl,
497
- serverPid: serverManager.getProcess()?.pid,
498
- tunnelPid: tunnelProcess.pid
499
- });
500
-
501
- return { serverManager, tunnelProcess, tunnelUrl };
502
- }
503
-
504
- async function tuiMode() {
505
- console.clear();
506
- resetProgress();
507
- isTuiActive = true;
508
- await setStep(STEP.PREPARING);
509
-
510
- let keyData = await ensureKeyData();
511
-
512
- let tuiServerMgr = { getProcess: () => null, shutdown: () => {} };
513
- const alreadyRunning = await isServerRunning();
514
- if (!alreadyRunning) {
515
- tuiServerMgr = startServerWithRestart(null, null);
516
- // Poll until server ready instead of fixed sleep
517
- const deadline = Date.now() + 5000;
518
- while (Date.now() < deadline && !(await isServerRunning())) {
519
- await new Promise((r) => setTimeout(r, 200));
520
- }
521
- }
522
-
523
- try { killCloudflared(); await new Promise((r) => setTimeout(r, 300)); } catch {}
524
-
525
- await ensureCloudflared(onBinaryProgress);
526
-
527
- await setStep(STEP.CONNECTING);
528
-
529
- try {
530
- const res = await browserFetch(`${WORKER_URL}/api/session/create`, {
531
- method: "POST",
532
- headers: { "Content-Type": "application/json" },
533
- body: JSON.stringify({ apiKey: keyData.key }),
534
- });
535
- if (!res.ok) throw new Error(`Session create failed: ${res.status}`);
536
- } catch (err) {
537
- console.log(chalk.red(`\nāŒ Failed to connect: ${err.message}`));
538
- process.exit(1);
539
- }
540
-
541
- await setStep(STEP.TUNNELING);
542
-
543
- let tunnelProcess, tunnelUrl;
544
- try {
545
- const result = await spawnQuickTunnel(SERVER_PORT, async (newUrl) => {
546
- await updateTunnelUrl(keyData.key, newUrl);
547
- await pushUiState({ tunnelUrl: newUrl });
548
- });
549
- tunnelProcess = result.child;
550
- tunnelUrl = result.tunnelUrl;
551
- } catch (err) {
552
- console.log(chalk.red(`\nāŒ Tunnel failed: ${err.message}`));
553
- process.exit(1);
554
- }
555
-
556
- await setStep(STEP.VERIFYING);
557
- const tunnelReady = await waitForTunnelReady(tunnelUrl);
558
- if (!tunnelReady) {
559
- console.log(chalk.yellow("\nāš ļø Tunnel health check timed out, proceeding anyway..."));
560
- }
561
-
562
- await updateTunnelUrl(keyData.key, tunnelUrl);
563
- saveState({ apiKey: keyData.key, tunnelUrl, tunnelPid: tunnelProcess.pid });
564
-
565
- const tempKeyData = await createTempKey(keyData.key, WORKER_URL);
566
- const connectUrl = tempKeyData
567
- ? `${WORKER_URL}/login?k=${tempKeyData.tempKey}`
568
- : `${WORKER_URL}/login`;
569
-
570
- await setStep(STEP.READY, {
571
- tunnelUrl,
572
- oneTimeKey: tempKeyData?.tempKey || "",
573
- oneTimeKeyExpiresAt: tempKeyData?.expiresAt || null,
574
- permanentKey: keyData.key,
575
- qrUrl: connectUrl,
576
- workerUrl: WORKER_URL,
577
- });
578
- await new Promise((r) => setTimeout(r, 1000));
579
-
580
- let currentOneTimeKey = tempKeyData?.tempKey || "";
581
- let currentConnectUrl = connectUrl;
582
- let currentTunnelUrl = tunnelUrl;
583
-
584
- let menuHeader = await buildMenuHeader(currentOneTimeKey, keyData.key, currentConnectUrl, currentTunnelUrl);
585
-
586
- let triggerMenuRedraw = null;
587
- const logBuffer = [];
588
- const MAX_LOG_LINES = 200;
589
-
590
- let deviceApprovalBusy = false;
591
- const stopSSE = subscribeSSE(SERVER_PORT, async (type, data) => {
592
- if (type === "log" && data.message) {
593
- logBuffer.push(data.message);
594
- if (logBuffer.length > MAX_LOG_LINES) logBuffer.shift();
595
- } else if (type === "state") {
596
- const newKey = data.permanentKey || keyData.key;
597
- // Explicit check: "" means cleared (one-time key consumed), preserve existing if undefined
598
- const newOtk = data.oneTimeKey !== undefined ? data.oneTimeKey : currentOneTimeKey;
599
- const newUrl = data.qrUrl !== undefined ? data.qrUrl : currentConnectUrl;
600
- const newTunnel = data.tunnelUrl !== undefined ? data.tunnelUrl : currentTunnelUrl;
601
- if (newOtk !== currentOneTimeKey || newKey !== keyData.key || newTunnel !== currentTunnelUrl) {
602
- currentOneTimeKey = newOtk;
603
- currentConnectUrl = newUrl;
604
- currentTunnelUrl = newTunnel;
605
- if (data.permanentKey) keyData = { ...keyData, key: data.permanentKey };
606
- menuHeader = await buildMenuHeader(currentOneTimeKey, keyData.key, currentConnectUrl, currentTunnelUrl);
607
- triggerMenuRedraw?.();
608
- }
609
- } else if (type === "permissions") {
610
- // desktopEnabled or permission values changed — refresh both main menu and active submenu
611
- activeSubmenuRefresh?.();
612
- triggerMenuRedraw?.();
613
- } else if (type === "deviceApproval" && data.action === "pending") {
614
- if (deviceApprovalBusy) return;
615
- deviceApprovalBusy = true;
616
- const approved = await showDeviceApproval(data.deviceId, data.ip);
617
- const endpoint = approved ? "approve" : "reject";
618
- await apiPost(`/api/device/${endpoint}`, { socketId: data.socketId });
619
- deviceApprovalBusy = false;
620
- triggerMenuRedraw?.();
621
- }
622
- });
623
-
624
- setupExitHandler({
625
- getProcess: tuiServerMgr.getProcess,
626
- shutdown: () => { tuiServerMgr.shutdown(); stopSSE(); }
627
- }, tunnelProcess, keyData.key);
628
-
629
- // Handle web UI Start/Stop commands while TUI is running
630
- let activeTunnel = tunnelProcess;
631
- setupCmdPoller(() => activeTunnel, (t) => { activeTunnel = t; }, keyData.key);
632
-
633
- const onShutdown = () => {
634
- try { stopSSE(); } catch {}
635
- shutdownAll({
636
- serverManager: tuiServerMgr,
637
- tunnelProcess,
638
- exit: false, // let the menu loop print Goodbye and exit itself
639
- });
640
- };
641
-
642
- await tuiMenuLoop(
643
- keyData, tunnelUrl,
644
- () => menuHeader,
645
- (h) => { menuHeader = h; },
646
- (cb) => { triggerMenuRedraw = cb; },
647
- onShutdown,
648
- logBuffer
649
- );
650
- }
651
-
652
- async function fetchServerState() {
653
- const d = await apiGet("/api/ui/state");
654
- return { desktopEnabled: !!d?.desktopEnabled, remoteAvailable: !!d?.remoteAvailable };
655
- }
656
-
657
- /**
658
- * Main menu loop after Ready.
659
- * @param {object} keyData
660
- * @param {string} tunnelUrl
661
- * @param {() => string} getHeader - live header getter (SSE may update it)
662
- * @param {(newHeader: string) => void} setHeader - update header from inside loop
663
- * @param {(cb: () => void) => void} onRedrawRegister - register redraw callback
664
- */
665
- async function tuiMenuLoop(keyData, tunnelUrl, getHeader = () => "", setHeader = () => {}, onRedrawRegister = () => {}, onCtrlC = null, logBuffer = []) {
666
- while (true) {
667
- const { desktopEnabled: desktopOn, remoteAvailable } = await fetchServerState();
668
-
669
- const items = [
670
- { label: "Open Web UI", action: "webui" },
671
- { label: "New One-Time Key", action: "otk" },
672
- { label: "Regenerate Permanent Key", action: "regen" },
673
- ];
674
- if (remoteAvailable) {
675
- const desktopLabel = `Remote Desktop: ${desktopOn ? chalk.green("ON") : chalk.gray("OFF")} ā–¶`;
676
- items.push({ label: desktopLabel, action: "desktop" });
677
- }
678
- items.push(
679
- { label: "Manage Devices \u25b6", action: "devices" },
680
- { label: `View Logs (${logBuffer.length})`, action: "logs" },
681
- { label: chalk.gray("Exit"), action: "exit" },
682
- );
683
-
684
- let redrawMenu = null;
685
- onRedrawRegister(() => redrawMenu?.());
686
-
687
- const idx = await selectMenu("", items, 0, getHeader, (setRedraw) => {
688
- redrawMenu = setRedraw;
689
- }, onCtrlC);
690
-
691
- const action = idx >= 0 ? items[idx].action : "exit";
692
-
693
- if (action === "webui") {
694
- const url = `http://localhost:${SERVER_PORT}`;
695
- openBrowser(url);
696
- console.log(chalk.green(`\n🌐 Opening ${url}\n`));
697
-
698
- } else if (action === "otk") {
699
- const newTempKey = await createTempKey(keyData.key, WORKER_URL);
700
- if (newTempKey) {
701
- const newConnectUrl = `${WORKER_URL}/login?k=${newTempKey.tempKey}`;
702
- setHeader(await buildMenuHeader(newTempKey.tempKey, keyData.key, newConnectUrl, tunnelUrl));
703
- await pushUiState({ oneTimeKey: newTempKey.tempKey, oneTimeKeyExpiresAt: newTempKey.expiresAt, qrUrl: newConnectUrl });
704
- }
705
-
706
- } else if (action === "regen") {
707
- const confirmed = await tuiConfirm(chalk.yellow("āš ļø Replace current key and disconnect all sessions? Continue?"));
708
- if (confirmed) {
709
- const machineId = await getConsistentMachineId();
710
- const { key } = generateApiKeyWithMachine(machineId);
711
- keyData = saveKey(machineId, key, keyData.name || "Default");
712
- await pushUiState({ permanentKey: keyData.key });
713
- const newTmp = await createTempKey(keyData.key, WORKER_URL);
714
- if (newTmp) {
715
- const newUrl = `${WORKER_URL}/login?k=${newTmp.tempKey}`;
716
- setHeader(await buildMenuHeader(newTmp.tempKey, keyData.key, newUrl, tunnelUrl));
717
- await pushUiState({ oneTimeKey: newTmp.tempKey, oneTimeKeyExpiresAt: newTmp.expiresAt, qrUrl: newUrl });
718
- }
719
- }
720
-
721
- } else if (action === "desktop") {
722
- await tuiDesktopMenu();
723
-
724
- } else if (action === "devices") {
725
- await tuiDevicesMenu();
726
-
727
- } else if (action === "logs") {
728
- await tuiLogsView(logBuffer);
729
-
730
- } else {
731
- // Exit path — onCtrlC contains the full shutdownAll sequence
732
- try { onCtrlC?.(); } catch {}
733
- console.log(chalk.gray("\nGoodbye!\n"));
734
- process.exit(0);
735
- }
736
- }
737
- }
738
-
739
- /** View logs screen — scrollable, ESC to go back */
740
- async function tuiLogsView(logBuffer) {
741
- const header = logBuffer.length
742
- ? logBuffer.join("\n")
743
- : chalk.gray(" No logs yet");
744
- await selectMenu("Logs", [{ label: chalk.gray("← Back") }], 0, header);
745
- }
746
-
747
- /**
748
- * Remote Desktop submenu.
749
- * All state (desktopEnabled + permissions) fetched from server — no local tracking.
750
- */
751
- async function tuiDesktopMenu() {
752
- while (true) {
753
- // State captured in closure — SSE may mutate items in-place while menu is open
754
- let desktopOn = false;
755
- let perms = { screenRecording: false, accessibility: false };
756
-
757
- const buildLabels = () => ({
758
- toggle: `Toggle: ${desktopOn ? chalk.green("ON → turn OFF") : chalk.gray("OFF → turn ON")}`,
759
- sr: `Screen Recording ${perms.screenRecording ? chalk.green("āœ“") : chalk.red("āœ— (click to grant)")}`,
760
- ax: `Mouse & Keyboard control ${perms.accessibility ? chalk.green("āœ“") : chalk.red("āœ— (click to grant)")}`,
761
- });
762
-
763
- const items = [
764
- { label: "" },
765
- { label: "" },
766
- { label: "" },
767
- { label: chalk.gray("← Back") },
768
- ];
769
-
770
- let redrawMenu = null;
771
- const syncFromServer = async () => {
772
- const s = await apiGet("/api/ui/state") || {};
773
- desktopOn = !!s.desktopEnabled;
774
- perms = { screenRecording: !!s.screenRecording, accessibility: !!s.accessibility };
775
- const L = buildLabels();
776
- items[0].label = L.toggle;
777
- items[1].label = L.sr;
778
- items[2].label = L.ax;
779
- redrawMenu?.();
780
- };
781
-
782
- await syncFromServer();
783
- // Register SSE-driven refresh while this submenu is active
784
- activeSubmenuRefresh = syncFromServer;
785
- const idx = await selectMenu("Remote Desktop", items, 0, "", (setRedraw) => { redrawMenu = setRedraw; });
786
- activeSubmenuRefresh = null;
787
-
788
- if (idx === 0) {
789
- await apiPost("/api/desktop/toggle", { enabled: !desktopOn });
790
- } else if (idx === 1 && !perms.screenRecording) {
791
- if (!await apiPost("/api/permissions/request", { type: "screenRecording" })) openPermissionPane("screenRecording");
792
- } else if (idx === 2 && !perms.accessibility) {
793
- if (!await apiPost("/api/permissions/request", { type: "accessibility" })) openPermissionPane("accessibility");
794
-
795
- } else if (idx === 3 || idx === -1) {
796
- return; // Back
797
- }
798
- }
799
- }
800
-
801
- async function tuiDevicesMenu() {
802
- while (true) {
803
- const data = await apiGet("/api/device/approved");
804
- const devices = data?.devices || [];
805
-
806
- const items = [
807
- ...devices.map((d) => {
808
- const short = d.deviceId.slice(0, 8);
809
- const date = d.approvedAt ? new Date(d.approvedAt).toLocaleString() : "unknown";
810
- return { label: `${short}... ${chalk.dim(date)}` };
811
- }),
812
- { label: chalk.gray("\u2190 Back") },
813
- ];
814
-
815
- const title = `Approved Devices (${devices.length})`;
816
- const idx = await selectMenu(title, items, items.length - 1);
817
-
818
- if (idx === -1 || idx === devices.length) return; // Back/ESC
819
-
820
- // Remove selected device
821
- const deviceId = devices[idx].deviceId;
822
- const confirmed = await tuiConfirm(chalk.yellow(`Remove device ${deviceId.slice(0, 8)}...?`));
823
- if (confirmed) await apiPost("/api/device/remove", { deviceId });
824
- }
825
- }
826
-
827
- async function autoStartDev() {
828
- showBanner(getVersion());
829
- let keyData = await ensureKeyData();
830
- console.log(chalk.gray(`Using key: ${keyData.key.slice(0, 20)}... (${keyData.name})`));
831
-
832
- const result = await startServerAndTunnel(keyData.key);
833
- if (!result) process.exit(1);
834
-
835
- const { serverManager, tunnelProcess, tunnelUrl } = result;
836
-
837
- await showConnectionInfo(keyData.key, tunnelUrl);
838
- setupExitHandler(serverManager, tunnelProcess, keyData.key);
839
-
840
- let activeTunnel = tunnelProcess;
841
- setupCmdPoller(() => activeTunnel, (t) => { activeTunnel = t; }, keyData.key);
842
-
843
- // Push stats to UI every 5s
844
- const startTime = Date.now();
845
- setInterval(() => {
846
- const uptime = Math.floor((Date.now() - startTime) / 1000);
847
- const h = Math.floor(uptime / 3600), m = Math.floor((uptime % 3600) / 60), s = uptime % 60;
848
- pushUiState({ uptime: `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` });
849
- }, 5000);
850
-
851
- await new Promise(() => { });
852
- }
853
-
854
- function setupCmdPoller(getActiveTunnel, setActiveTunnel, apiKey) {
855
- let busy = false;
856
- setInterval(async () => {
857
- if (busy) return;
858
- const cmd = readAndClearCmd();
859
- if (!cmd) return;
860
- busy = true;
861
- try {
862
-
863
- if (cmd === "stop-tunnel") {
864
- const tunnel = getActiveTunnel();
865
- if (tunnel) {
866
- tunnel.kill();
867
- setActiveTunnel(null);
868
- console.log(chalk.yellow("šŸ›‘ Tunnel stopped"));
869
- }
870
- await setStep(STEP.STOPPED, { tunnelUrl: "", oneTimeKey: "", oneTimeKeyExpiresAt: null });
871
- updateTrayTooltip({ tunnelUrl: "", running: true });
872
- }
873
-
874
- if (cmd === "start-tunnel") {
875
- if (getActiveTunnel()) { busy = false; return; } // already running
876
- console.log(ORANGE("šŸš€ Starting tunnel..."));
877
- try {
878
- await setStep(STEP.PREPARING);
879
- await ensureCloudflared(onBinaryProgress);
880
-
881
- await setStep(STEP.CONNECTING);
882
- const sessionResponse = await browserFetch(`${WORKER_URL}/api/session/create`, {
883
- method: "POST",
884
- headers: { "Content-Type": "application/json" },
885
- body: JSON.stringify({ apiKey }),
886
- });
887
- if (!sessionResponse.ok) throw new Error(`Session create failed: ${sessionResponse.status}`);
888
-
889
- await setStep(STEP.TUNNELING);
890
- const result = await spawnQuickTunnel(SERVER_PORT, async (newUrl) => {
891
- await updateTunnelUrl(apiKey, newUrl);
892
- await pushUiState({ tunnelUrl: newUrl });
893
- });
894
- setActiveTunnel(result.child);
895
-
896
- await setStep(STEP.VERIFYING);
897
- const tunnelOk = await waitForTunnelReady(result.tunnelUrl);
898
- if (!tunnelOk) {
899
- console.log(chalk.yellow("\nāš ļø Tunnel health check timed out, proceeding anyway..."));
900
- }
901
-
902
- await updateTunnelUrl(apiKey, result.tunnelUrl);
903
- updateTrayTooltip({ tunnelUrl: result.tunnelUrl, running: true });
904
-
905
- // Hold for 3s so UI sees all steps complete before showing Ready screen
906
- await new Promise(r => setTimeout(r, 2000));
907
- await showConnectionInfo(apiKey, result.tunnelUrl);
908
- } catch (err) {
909
- console.log(chalk.red(`āŒ Failed to start tunnel: ${err.message}`));
910
- await setStep(STEP.STOPPED);
911
- }
912
- }
913
-
914
- if (cmd === "regenerate-key") {
915
- const machineId = await getConsistentMachineId();
916
- const { key } = generateApiKeyWithMachine(machineId);
917
- const existing = loadKey();
918
- saveKey(machineId, key, existing?.name || "Default");
919
- await pushUiState({ permanentKey: key });
920
- console.log(chalk.green(`āœ… Key regenerated: ${key}`));
921
- }
922
-
923
- if (cmd === "shutdown") {
924
- console.log(chalk.yellow("\nšŸ›‘ Shutting down 9Remote completely..."));
925
- const tunnel = getActiveTunnel();
926
- setActiveTunnel(null);
927
- shutdownAll({ tunnelProcess: tunnel });
928
- console.log(chalk.green("āœ… 9Remote stopped"));
929
- }
930
-
931
- } finally {
932
- busy = false;
933
- }
934
- }, 1000);
935
- }
936
-
937
- // Win DNS negative cache giữ ENOTFOUND lĆ¢u hĘ”n thį»i điểm Cloudflare publish subdomain.
938
- // Flush trước mį»—i attempt Ä‘į»ƒ query đi thįŗ³ng upstream, trĆ”nh chį» TTL Ć¢m hįŗæt hįŗ”n.
939
- // Async fire-and-forget + windowsHide → khĆ“ng block, khĆ“ng popup cmd window.
940
- function flushWinDns() {
941
- if (process.platform !== "win32") return;
942
- execFile("ipconfig", ["/flushdns"], { windowsHide: true }, () => {});
943
- }
944
-
945
- // Resolve DNS trį»±c tiįŗæp qua Cloudflare 1.1.1.1 (bypass resolver hệ thống / ISP cache)
946
- // → biįŗæt ngay subdomain đã propagate chʰa mĆ  khĆ“ng tốn 5s chį» fetch timeout.
947
- const dnsResolver = new dns.promises.Resolver();
948
- dnsResolver.setServers(["1.1.1.1", "1.0.0.1", "8.8.8.8"]);
949
-
950
- async function resolveTunnelDns(hostname, timeoutMs = 2000) {
951
- const t0 = Date.now();
952
- try {
953
- const addrs = await Promise.race([
954
- dnsResolver.resolve4(hostname),
955
- new Promise((_, reject) =>
956
- setTimeout(() => reject(Object.assign(new Error("DNS timeout"), { code: "ETIMEOUT" })), timeoutMs)
957
- ),
958
- ]);
959
- return { ok: true, addrs, elapsedMs: Date.now() - t0 };
960
- } catch (err) {
961
- const code = err.code || err.message;
962
- return { ok: false, code, elapsedMs: Date.now() - t0 };
963
- }
964
- }
965
-
966
- async function waitForTunnelReady(tunnelUrl, { intervalMs = 2000, timeoutMs = 180000 } = {}) {
967
- const healthUrl = `${tunnelUrl}/api/health`;
968
- const hostname = (() => {
969
- try { return new URL(tunnelUrl).hostname; } catch { return null; }
970
- })();
971
- const start = Date.now();
972
- let attempt = 0;
973
- const logs = [];
974
-
975
- // Init terminal-like panel in web UI
976
- await pushUiState({
977
- healthCheck: { running: true, timeoutMs, startedAt: start, logs: [] },
978
- });
979
-
980
- const pushLog = (entry) => {
981
- logs.push(entry);
982
- // Keep last 80 entries to stay snappy
983
- const trimmed = logs.length > 80 ? logs.slice(-80) : logs;
984
- pushUiState({
985
- healthCheck: { running: true, timeoutMs, startedAt: start, logs: trimmed },
986
- });
987
- };
988
-
989
- while (Date.now() - start < timeoutMs) {
990
- attempt++;
991
- flushWinDns();
992
-
993
- // Bước 1: DNS probe qua 1.1.1.1 (timeout 2s) — biįŗæt sį»›m subdomain đã publish chʰa
994
- // Chỉ Ć”p dỄng khi có hostname hợp lệ (tunnel URL thį»±c tįŗæ).
995
- if (hostname) {
996
- const dnsRes = await resolveTunnelDns(hostname, 2000);
997
- if (!dnsRes.ok) {
998
- const isWaiting = dnsRes.code === "ENOTFOUND" || dnsRes.code === "ETIMEOUT" || dnsRes.code === "ESERVFAIL";
999
- const status = isWaiting ? "connecting..." : dnsRes.code;
1000
- updateProgressDesc(`#${attempt} → ${status}`);
1001
- pushLog({ attempt, status, elapsedMs: dnsRes.elapsedMs, ok: false, waiting: isWaiting, time: Date.now() });
1002
- // Subdomain chʰa propagate → skip fetch 5s, chį» interval rồi thį»­ lįŗ”i.
1003
- await new Promise((r) => setTimeout(r, intervalMs));
1004
- continue;
1005
- }
1006
- }
1007
-
1008
- // Bước 2: DNS OK (hoįŗ·c khĆ“ng có hostname) → fetch health endpoint
1009
- const t0 = Date.now();
1010
- try {
1011
- const res = await browserFetch(healthUrl, {
1012
- signal: AbortSignal.timeout(5000),
1013
- });
1014
- const elapsedMs = Date.now() - t0;
1015
- updateProgressDesc(`#${attempt} → ${res.status}`);
1016
- pushLog({ attempt, status: String(res.status), elapsedMs, ok: res.ok, time: Date.now() });
1017
- if (res.ok) {
1018
- // Clear logs immediately — UI jumps straight to Ready, no lingering log
1019
- await pushUiState({
1020
- healthCheck: { running: false, timeoutMs: 0, startedAt: null, logs: [] },
1021
- });
1022
- await new Promise(r => setTimeout(r, 2000));
1023
- return true;
1024
- }
1025
- } catch (err) {
1026
- const elapsedMs = Date.now() - t0;
1027
- const code = err.cause?.code || err.code || err.message;
1028
- // ENOTFOUND = tunnel DNS chʰa propagate → đây lĆ  trįŗ”ng thĆ”i "đang chį»", khĆ“ng phįŗ£i lį»—i
1029
- const isWaiting = code === "ENOTFOUND";
1030
- const status = isWaiting ? "connecting..." : code;
1031
- updateProgressDesc(`#${attempt} → ${status}`);
1032
- pushLog({ attempt, status, elapsedMs, ok: false, waiting: isWaiting, time: Date.now() });
1033
- }
1034
- await new Promise((r) => setTimeout(r, intervalMs));
1035
- }
1036
-
1037
- await pushUiState({
1038
- healthCheck: { running: false, timeoutMs, startedAt: start, logs },
1039
- });
1040
- return false;
1041
- }
1042
-
1043
- async function isServerRunning() {
1044
- return !!(await apiGet("/api/health"));
1045
- }
1046
-
1047
- /** Spawn background process with --tray flag, open browser, exit current process */
1048
- async function launchBackground() {
1049
- const uiUrl = `http://localhost:${SERVER_PORT}`;
1050
-
1051
- // If an orphan server from a previous run is holding the port, kill it
1052
- // so our fresh agent spawns a fresh server it actually owns. Skipping
1053
- // this leaves alreadyRunning=true in startTrayMode → serverManager
1054
- // becomes a no-op → Shutdown can't kill the orphan node.exe.
1055
- if (await isServerRunning()) {
1056
- killProcessOnPort(SERVER_PORT);
1057
- await new Promise(r => setTimeout(r, 500));
1058
- }
1059
-
1060
- // Spawn CLI itself with --tray — tray logic lives in CLI, not server
1061
- // Bundle: __dirname = dist/, entry = dist/cli.cjs
1062
- // Dev: __dirname = agent/cli/, entry = agent/cli/index.js
1063
- const scriptPath = typeof __CLI_VERSION__ !== "undefined"
1064
- ? path.resolve(__dirname, "cli.cjs")
1065
- : path.resolve(__dirname, "index.js");
1066
- const bgArgs = [scriptPath, "--tray"];
1067
-
1068
- const themeArg = process.argv.find(a => a.startsWith("--theme="));
1069
- if (themeArg) bgArgs.push(themeArg);
1070
-
1071
- // Redirect child stdout/stderr to log file for debugging crashes
1072
- const logDir = path.join(os.homedir(), ".9remote");
1073
- if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
1074
- const logPath = path.join(logDir, "bg.log");
1075
-
1076
- // Log startup marker
1077
- try {
1078
- fs.appendFileSync(logPath, `\n\n=== ${new Date().toISOString()} spawn bg ===\n`);
1079
- } catch {}
1080
-
1081
- let bgPid = null;
1082
-
1083
- // Unified detached spawn for all platforms.
1084
- //
1085
- // Windows specifics:
1086
- // - node.exe is a console-subsystem binary. With plain `detached: true` Windows
1087
- // would allocate a new console for the child and flash a black cmd window.
1088
- // - `windowsHide: true` adds CREATE_NO_WINDOW, which combined with DETACHED_PROCESS
1089
- // (from `detached: true`) + non-inherit stdio makes the launch fully silent —
1090
- // no VBS/wscript wrapper needed.
1091
- // - stdio MUST be either "ignore" or a file fd (not "inherit") or Windows will
1092
- // still surface a console window tied to the parent.
1093
- //
1094
- // PID tracking:
1095
- // - We write `agent.pid` here in the PARENT before the child even boots, so the
1096
- // updater can always find it — even if the child crashes before `startTrayMode`
1097
- // gets to call writePid() itself.
1098
- try {
1099
- const logFd = fs.openSync(logPath, "a");
1100
- const bg = spawn(process.execPath, bgArgs, {
1101
- detached: true,
1102
- windowsHide: true,
1103
- stdio: ["ignore", logFd, logFd],
1104
- env: { ...process.env },
1105
- });
1106
- bg.unref();
1107
- // Close our copy of the fd — the child has its own handle now.
1108
- try { fs.closeSync(logFd); } catch {}
1109
- bgPid = bg.pid;
1110
- // Track background agent PID so the updater can release dist/cli.cjs lock
1111
- // without touching other node.exe processes on the machine.
1112
- if (bg.pid) writePid("agent", bg.pid);
1113
- } catch (err) {
1114
- console.log(chalk.red(`\nāŒ Failed to launch background: ${err.message}`));
1115
- process.exit(1);
1116
- }
1117
-
1118
- // Wait and verify server came up; Windows + first-run cloudflared download can
1119
- // be slow, so give it up to 15s before giving up.
1120
- const deadline = Date.now() + 15000;
1121
- let ready = false;
1122
- while (Date.now() < deadline) {
1123
- if (await isServerRunning()) { ready = true; break; }
1124
- await new Promise(r => setTimeout(r, 300));
1125
- }
1126
-
1127
- if (!ready) {
1128
- console.log(chalk.red(`\nāŒ Background server failed to start.`));
1129
- console.log(chalk.gray(` Check log: ${logPath}\n`));
1130
- process.exit(1);
1131
- }
1132
-
1133
- openBrowser(uiUrl);
1134
- const pidStr = bgPid ? ` (PID: ${bgPid})` : "";
1135
- console.log(chalk.green(`\n🌐 9Remote running at ${uiUrl}${pidStr}`));
1136
- console.log(chalk.gray(`šŸ’” Log: ${logPath}\n`));
1137
- // Give the detached browser-launcher child a brief moment to actually spawn
1138
- // before this parent exits — especially on Windows where cmd/start needs a tick.
1139
- await new Promise(r => setTimeout(r, 400));
1140
- process.exit(0);
1141
- }
1142
-
1143
- /** Tray mode: start server + system tray, no terminal UI */
1144
- async function startTrayMode() {
1145
- // Record own PID — this process holds dist/cli.cjs open in memory.
1146
- // Needed so `npm i -g 9remote@latest` can kill us and rename node_modules\9remote.
1147
- writePid("agent", process.pid);
1148
-
1149
- let keyData = await ensureKeyData();
1150
-
1151
- const themeArg = process.argv.find(a => a.startsWith("--theme="));
1152
- const theme = themeArg ? themeArg.split("=")[1] : null;
1153
-
1154
- const alreadyRunning = await isServerRunning();
1155
- const serverManager = alreadyRunning
1156
- ? { getProcess: () => null, shutdown: () => {} }
1157
- : startServerWithRestart(null, null);
1158
-
1159
- if (!alreadyRunning) await new Promise(r => setTimeout(r, 2000));
1160
-
1161
- const uiUrl = `http://localhost:${SERVER_PORT}`;
1162
-
1163
- let activeTunnel = null;
1164
- await pushUiState({ permanentKey: keyData.key, step: STEP.STOPPED, theme });
1165
-
1166
- const cleanup = () => {
1167
- const tunnel = activeTunnel;
1168
- activeTunnel = null;
1169
- // Tray's own onClick handler calls process.exit after this returns,
1170
- // so don't double-exit here.
1171
- shutdownAll({ serverManager, tunnelProcess: tunnel, exit: false });
1172
- };
1173
-
1174
- setupExitHandler(serverManager, null, keyData.key);
1175
- setupCmdPoller(() => activeTunnel, (t) => { activeTunnel = t; }, keyData.key);
1176
-
1177
- if (process.argv.includes("--start")) writeCmd("start-tunnel");
1178
-
1179
- const tray = await initTray({
1180
- port: SERVER_PORT,
1181
- onQuit: cleanup,
1182
- onOpenUI: () => openBrowser(uiUrl),
1183
- });
1184
-
1185
- // Show a one-shot balloon tip so the user knows the agent is running
1186
- // in the background and where to find it (tray area + local URL).
1187
- if (tray) {
1188
- showTrayNotification({
1189
- title: "9Remote is running",
1190
- message: `Open ${uiUrl} or use the tray icon to manage.`,
1191
- });
1192
- }
1193
-
1194
- await new Promise(() => {});
1195
- }
1196
-
1197
- /** UI mode (9remote ui): start server + open browser, no tray */
1198
- async function startUiMode() {
1199
- showBanner(getVersion());
1200
- let keyData = await ensureKeyData();
1201
-
1202
- const themeArg = process.argv.find(a => a.startsWith("--theme="));
1203
- const theme = themeArg ? themeArg.split("=")[1] : null;
1204
-
1205
- const alreadyRunning = await isServerRunning();
1206
- const serverManager = alreadyRunning
1207
- ? { getProcess: () => null, shutdown: () => {} }
1208
- : startServerWithRestart(null, null);
1209
-
1210
- if (!alreadyRunning) await new Promise(r => setTimeout(r, 2000));
1211
-
1212
- const uiUrl = `http://localhost:${SERVER_PORT}`;
1213
- console.log(chalk.green(`\n🌐 UI ready at ${uiUrl}`));
1214
-
1215
- let activeTunnel = null;
1216
- await pushUiState({ permanentKey: keyData.key, step: STEP.STOPPED, theme });
1217
-
1218
- setupExitHandler(serverManager, null, keyData.key);
1219
- setupCmdPoller(() => activeTunnel, (t) => { activeTunnel = t; }, keyData.key);
1220
-
1221
- if (process.argv.includes("--start")) writeCmd("start-tunnel");
1222
-
1223
- await new Promise(() => {});
1224
- }
1225
-
1226
- // Start app
1227
- async function start() {
1228
- const command = process.argv[2];
1229
-
1230
- // Kill any existing 9remote instance (agent + cloudflared) before starting
1231
- // a new one. ptyDaemon is preserved to keep terminal sessions alive.
1232
- // Skip when re-entering via --tray / --auto (child spawned by launchBackground),
1233
- // otherwise the detached child would read its own PID from agent.pid and
1234
- // kill itself right after spawn.
1235
- const isChildRespawn = process.argv.includes("--tray") || process.argv.includes("--auto");
1236
- if (!isChildRespawn) {
1237
- stopRunningInstances();
1238
- }
1239
-
1240
- if (command === "ui") {
1241
- await startUiMode();
1242
- } else if (command === "start" || process.argv.includes("--auto")) {
1243
- await autoStartDev();
1244
- } else if (process.argv.includes("--tray")) {
1245
- await startTrayMode();
1246
- } else {
1247
- await startupMenu();
1248
- }
1249
- }
1250
-
1251
- async function startupMenu() {
1252
- const version = getVersion();
1253
- const updateInfo = await checkLatestVersion();
1254
- const banner = getBannerText(version, updateInfo?.latest ?? null);
1255
-
1256
- const items = [];
1257
- if (updateInfo?.latest) {
1258
- items.push({ label: chalk.yellow(`Update to v${updateInfo.latest}`), action: "update" });
1259
- }
1260
- items.push(
1261
- { label: "Open Web UI (background)", action: "ui" },
1262
- { label: "Terminal UI", action: "tui" },
1263
- { label: chalk.gray("Exit"), action: "exit" },
1264
- );
1265
-
1266
- const idx = await selectMenu("", items, 0, banner);
1267
- const action = idx >= 0 ? items[idx].action : "exit";
1268
-
1269
- if (action === "update") {
1270
- const w = Math.min(44, process.stdout.columns || 44);
1271
- // Stop running background instances so npm install can overwrite locked files
1272
- stopRunningInstances();
1273
- console.log(ORANGE("\n" + "═".repeat(w)));
1274
- console.log(chalk.gray(" āœ“ Stopped running instances\n"));
1275
- console.log(chalk.yellow(" ⬆ Run this command to update:\n"));
1276
- console.log(chalk.white.bold(` npm i -g 9remote@latest\n`));
1277
- console.log(ORANGE("═".repeat(w)) + "\n");
1278
- process.exit(0);
1279
- } else if (action === "ui") {
1280
- await launchBackground();
1281
- } else if (action === "tui") {
1282
- await tuiMode();
1283
- } else {
1284
- process.exit(0);
1285
- }
1286
- }
1287
-
1288
- start().catch(console.error);