9remote 0.1.64 ā 2.0.1
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/README.md +179 -62
- package/cli/index.js +701 -293
- package/cli/utils/assets/trayIcon.ico +0 -0
- package/cli/utils/cloudflared.js +72 -36
- package/cli/utils/permissions.js +5 -5
- package/cli/utils/pids.js +114 -0
- package/cli/utils/token.js +3 -1
- package/cli/utils/tray.js +251 -0
- package/cli/utils/tui.js +209 -34
- package/cli/utils/updateChecker.js +107 -13
- package/dist/assets/trayIcon.ico +0 -0
- package/dist/cli.cjs +1 -36
- package/dist/ptyDaemon.cjs +1 -10
- package/dist/server.cjs +2 -184
- package/dist/ui/assets/{index-BfTPkO8b.css ā index-COWVKicT.css} +1 -1
- package/dist/ui/assets/index-njTKNAa6.js +8 -0
- package/dist/ui/index.html +2 -2
- package/index.js +174 -618
- package/lib/constants.js +64 -0
- package/lib/deviceApproval.js +116 -0
- package/lib/router.js +134 -0
- package/lib/socketio.js +168 -25
- package/package.json +6 -1
- package/dist/ui/assets/index-B37vtDoz.js +0 -8
package/cli/index.js
CHANGED
|
@@ -3,27 +3,28 @@
|
|
|
3
3
|
import inquirer from "inquirer";
|
|
4
4
|
import chalk from "chalk";
|
|
5
5
|
import qrcode from "qrcode-terminal";
|
|
6
|
-
import { spawn, execSync } from "child_process";
|
|
6
|
+
import { spawn, execSync, execFile } from "child_process";
|
|
7
7
|
import path from "path";
|
|
8
8
|
import { fileURLToPath } from "url";
|
|
9
9
|
import fs from "fs";
|
|
10
10
|
import os from "os";
|
|
11
|
+
import dns from "dns";
|
|
11
12
|
import { getConsistentMachineId } from "./utils/machineId.js";
|
|
12
13
|
import { generateApiKeyWithMachine } from "./utils/apiKey.js";
|
|
13
14
|
import { loadKey, saveKey, loadState, saveState, clearState, readAndClearCmd, writeCmd } from "./utils/state.js";
|
|
14
15
|
import { createTempKey } from "./utils/token.js";
|
|
15
|
-
import { checkAndUpdate, checkLatestVersion } from "./utils/updateChecker.js";
|
|
16
|
+
import { checkAndUpdate, checkLatestVersion, stopRunningInstances } from "./utils/updateChecker.js";
|
|
17
|
+
import { writePid, clearPid } from "./utils/pids.js";
|
|
16
18
|
import { spawnQuickTunnel, killCloudflared, resetRestartCounter, ensureCloudflared } from "./utils/cloudflared.js";
|
|
17
|
-
import { showBanner, renderProgress, resetProgress, selectMenu, confirm as tuiConfirm, subscribeSSE, openPermissionPane } from "./utils/tui.js";
|
|
19
|
+
import { showBanner, getBannerText, renderProgress, resetProgress, updateProgressDesc, setProgressInfo, selectMenu, confirm as tuiConfirm, subscribeSSE, openPermissionPane, showDeviceApproval } from "./utils/tui.js";
|
|
18
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";
|
|
19
23
|
|
|
20
|
-
// Parse --skip-update flag
|
|
21
24
|
const skipUpdate = process.argv.includes("--skip-update");
|
|
22
25
|
|
|
23
26
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
27
|
const PROJECT_ROOT = path.resolve(__dirname, "../..");
|
|
25
|
-
// When bundled (dist/cli.cjs), server.cjs is in same dist/ folder
|
|
26
|
-
// When dev (server/cli/index.js), use server/index.js directly
|
|
27
28
|
const STANDALONE_SERVER = path.resolve(__dirname, "../dist/server.cjs");
|
|
28
29
|
const DEV_SERVER = path.resolve(__dirname, "../index.js");
|
|
29
30
|
const WORKER_URL = "https://9remote.cc";
|
|
@@ -31,20 +32,28 @@ const SERVER_PORT = 2208;
|
|
|
31
32
|
const MAX_RESTART_ATTEMPTS = 10;
|
|
32
33
|
const RESTART_WINDOW_MS = 60000; // 1 minute
|
|
33
34
|
|
|
34
|
-
// Orange color from gitbook (#E68A6E)
|
|
35
35
|
const ORANGE = chalk.rgb(230, 138, 110);
|
|
36
36
|
const ORANGE_DIM = chalk.rgb(200, 120, 95);
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
+
|
|
41
52
|
function getVersion() {
|
|
42
|
-
// When bundled, version is injected at build time
|
|
43
53
|
if (typeof __CLI_VERSION__ !== "undefined") {
|
|
44
54
|
return __CLI_VERSION__;
|
|
45
55
|
}
|
|
46
56
|
|
|
47
|
-
// Dev mode: read from package.json
|
|
48
57
|
try {
|
|
49
58
|
const packagePath = path.join(__dirname, "..", "package.json");
|
|
50
59
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf-8"));
|
|
@@ -54,10 +63,6 @@ function getVersion() {
|
|
|
54
63
|
}
|
|
55
64
|
}
|
|
56
65
|
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Helper: Show QR code for connect URL
|
|
60
|
-
*/
|
|
61
66
|
function showQRCode(url, title = "š± Scan QR to connect:") {
|
|
62
67
|
console.log(ORANGE(`\n${title}`));
|
|
63
68
|
qrcode.generate(url, { small: true, type: "terminal", margin: 0 }, (qr) => {
|
|
@@ -65,7 +70,6 @@ function showQRCode(url, title = "š± Scan QR to connect:") {
|
|
|
65
70
|
});
|
|
66
71
|
}
|
|
67
72
|
|
|
68
|
-
/** Build QR block as string (for use in headerContent) */
|
|
69
73
|
function buildQRString(url) {
|
|
70
74
|
return new Promise((resolve) => {
|
|
71
75
|
qrcode.generate(url, { small: true, type: "terminal", margin: 0 }, (qr) => {
|
|
@@ -74,9 +78,6 @@ function buildQRString(url) {
|
|
|
74
78
|
});
|
|
75
79
|
}
|
|
76
80
|
|
|
77
|
-
/**
|
|
78
|
-
* Helper: Show connection info
|
|
79
|
-
*/
|
|
80
81
|
async function showConnectionInfo(selectedKey, tunnelUrl) {
|
|
81
82
|
const tempKeyData = await createTempKey(selectedKey, WORKER_URL);
|
|
82
83
|
|
|
@@ -88,9 +89,7 @@ async function showConnectionInfo(selectedKey, tunnelUrl) {
|
|
|
88
89
|
const connectUrl = `${WORKER_URL}/login?k=${tempKeyData.tempKey}`;
|
|
89
90
|
const width = Math.min(44, process.stdout.columns || 55);
|
|
90
91
|
|
|
91
|
-
|
|
92
|
-
pushUiState({
|
|
93
|
-
step: 4,
|
|
92
|
+
await setStep(STEP.READY, {
|
|
94
93
|
tunnelUrl,
|
|
95
94
|
oneTimeKey: tempKeyData.tempKey,
|
|
96
95
|
oneTimeKeyExpiresAt: tempKeyData.expiresAt,
|
|
@@ -105,17 +104,14 @@ async function showConnectionInfo(selectedKey, tunnelUrl) {
|
|
|
105
104
|
|
|
106
105
|
console.log(ORANGE("ā".repeat(width)));
|
|
107
106
|
|
|
108
|
-
// App URL
|
|
109
107
|
const appLabel = "App URL";
|
|
110
108
|
const appValue = `${WORKER_URL}/login`;
|
|
111
109
|
console.log(chalk.white(appLabel.padEnd(14)) + chalk.gray(appValue));
|
|
112
110
|
|
|
113
|
-
// One-Time Key
|
|
114
111
|
const keyLabel = "One-Time Key";
|
|
115
112
|
const keyValue = tempKeyData.tempKey;
|
|
116
113
|
console.log(chalk.white(keyLabel.padEnd(14)) + ORANGE.bold(keyValue));
|
|
117
114
|
|
|
118
|
-
// Permanent Key
|
|
119
115
|
const permLabel = "Key";
|
|
120
116
|
const permValue = selectedKey;
|
|
121
117
|
console.log(chalk.white(permLabel.padEnd(14)) + chalk.gray(permValue));
|
|
@@ -123,10 +119,7 @@ async function showConnectionInfo(selectedKey, tunnelUrl) {
|
|
|
123
119
|
console.log(ORANGE("ā".repeat(width)));
|
|
124
120
|
}
|
|
125
121
|
|
|
126
|
-
|
|
127
|
-
* Build full header string: QR + keys info (for selectMenu headerContent)
|
|
128
|
-
*/
|
|
129
|
-
async function buildMenuHeader(oneTimeKey, permanentKey, connectUrl) {
|
|
122
|
+
async function buildMenuHeader(oneTimeKey, permanentKey, connectUrl, tunnelUrl = "") {
|
|
130
123
|
const w = Math.min(44, process.stdout.columns || 44);
|
|
131
124
|
const lines = [];
|
|
132
125
|
|
|
@@ -141,6 +134,13 @@ async function buildMenuHeader(oneTimeKey, permanentKey, connectUrl) {
|
|
|
141
134
|
lines.push(
|
|
142
135
|
ORANGE("ā".repeat(w)),
|
|
143
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
144
|
chalk.white("One-Time Key".padEnd(14)) + (oneTimeKey ? ORANGE.bold(oneTimeKey) + chalk.dim(" (expires in 30m)") : chalk.gray("ā")),
|
|
145
145
|
chalk.white("Key".padEnd(14)) + chalk.dim(permanentKey),
|
|
146
146
|
ORANGE("ā".repeat(w)),
|
|
@@ -148,13 +148,10 @@ async function buildMenuHeader(oneTimeKey, permanentKey, connectUrl) {
|
|
|
148
148
|
return lines.join("\n");
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
/**
|
|
152
|
-
* Kill process on specific port
|
|
153
|
-
*/
|
|
154
151
|
function killProcessOnPort(port) {
|
|
155
152
|
try {
|
|
156
153
|
if (process.platform === "win32") {
|
|
157
|
-
execSync(`for /f "tokens=5" %a in ('netstat -aon ^| findstr :${port}') do taskkill /F /PID %a`, { stdio: "ignore" });
|
|
154
|
+
execSync(`for /f "tokens=5" %a in ('netstat -aon ^| findstr :${port}') do taskkill /F /PID %a`, { stdio: "ignore", windowsHide: true });
|
|
158
155
|
} else {
|
|
159
156
|
const nullDevice = "/dev/null";
|
|
160
157
|
execSync(`lsof -ti:${port} | xargs kill -9 2>${nullDevice} || true`, { stdio: "ignore" });
|
|
@@ -162,9 +159,6 @@ function killProcessOnPort(port) {
|
|
|
162
159
|
} catch { }
|
|
163
160
|
}
|
|
164
161
|
|
|
165
|
-
/**
|
|
166
|
-
* Start server with auto-restart on crash
|
|
167
|
-
*/
|
|
168
162
|
function startServerWithRestart(onReady, onServerCrash) {
|
|
169
163
|
const restartTimes = [];
|
|
170
164
|
let currentProcess = null;
|
|
@@ -172,15 +166,12 @@ function startServerWithRestart(onReady, onServerCrash) {
|
|
|
172
166
|
let isFirstStart = true;
|
|
173
167
|
|
|
174
168
|
const spawnServer = () => {
|
|
175
|
-
|
|
176
|
-
if (isFirstStart) {
|
|
169
|
+
if (isFirstStart) {
|
|
177
170
|
killProcessOnPort(SERVER_PORT);
|
|
178
171
|
isFirstStart = false;
|
|
179
|
-
} else {
|
|
180
172
|
}
|
|
181
173
|
|
|
182
|
-
|
|
183
|
-
const useDevServer = process.env.NODE_ENV === "development" && fs.existsSync(DEV_SERVER);
|
|
174
|
+
const useDevServer = process.env.NODE_ENV === "development" && fs.existsSync(DEV_SERVER);
|
|
184
175
|
const serverPath = useDevServer ? DEV_SERVER : STANDALONE_SERVER;
|
|
185
176
|
|
|
186
177
|
if (!fs.existsSync(serverPath)) {
|
|
@@ -188,17 +179,18 @@ function startServerWithRestart(onReady, onServerCrash) {
|
|
|
188
179
|
process.exit(1);
|
|
189
180
|
}
|
|
190
181
|
|
|
191
|
-
|
|
192
|
-
const spawnEnv = { ...process.env, PORT: String(SERVER_PORT) };
|
|
182
|
+
const spawnEnv = { ...process.env, PORT: String(SERVER_PORT) };
|
|
193
183
|
if (!useDevServer) delete spawnEnv.NODE_ENV;
|
|
194
184
|
|
|
195
185
|
currentProcess = spawn("node", [serverPath], {
|
|
196
186
|
cwd: path.dirname(serverPath),
|
|
197
|
-
stdio:
|
|
187
|
+
stdio: "inherit",
|
|
198
188
|
detached: false,
|
|
189
|
+
windowsHide: true,
|
|
199
190
|
env: spawnEnv,
|
|
200
191
|
});
|
|
201
|
-
|
|
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.
|
|
202
194
|
|
|
203
195
|
currentProcess.on("exit", (code, signal) => {
|
|
204
196
|
if (isShuttingDown) {
|
|
@@ -213,18 +205,20 @@ function startServerWithRestart(onReady, onServerCrash) {
|
|
|
213
205
|
const now = Date.now();
|
|
214
206
|
restartTimes.push(now);
|
|
215
207
|
|
|
216
|
-
|
|
217
|
-
while (restartTimes.length > 0 && restartTimes[0] < now - RESTART_WINDOW_MS) {
|
|
208
|
+
while (restartTimes.length > 0 && restartTimes[0] < now - RESTART_WINDOW_MS) {
|
|
218
209
|
restartTimes.shift();
|
|
219
210
|
}
|
|
220
211
|
|
|
221
212
|
if (restartTimes.length > MAX_RESTART_ATTEMPTS) {
|
|
222
213
|
console.log(chalk.red(`ā Too many restarts (${MAX_RESTART_ATTEMPTS} in ${RESTART_WINDOW_MS / 1000}s). Giving up.`));
|
|
223
|
-
|
|
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;
|
|
224
219
|
}
|
|
225
220
|
|
|
226
221
|
console.log(chalk.yellow(`š Restarting server... (attempt ${restartTimes.length}/${MAX_RESTART_ATTEMPTS})`));
|
|
227
|
-
console.log(ORANGE_DIM("ā ļø [DEBUG] NOTE: Tunnel connection may be stale - will restart tunnel"));
|
|
228
222
|
|
|
229
223
|
// ā
Callback Äį» restart cloudflared
|
|
230
224
|
if (onServerCrash) {
|
|
@@ -232,11 +226,9 @@ function startServerWithRestart(onReady, onServerCrash) {
|
|
|
232
226
|
onServerCrash();
|
|
233
227
|
}
|
|
234
228
|
|
|
235
|
-
|
|
236
|
-
setTimeout(() => {
|
|
229
|
+
setTimeout(() => {
|
|
237
230
|
spawnServer();
|
|
238
231
|
}, 1000);
|
|
239
|
-
} else {
|
|
240
232
|
}
|
|
241
233
|
});
|
|
242
234
|
|
|
@@ -256,38 +248,125 @@ function startServerWithRestart(onReady, onServerCrash) {
|
|
|
256
248
|
shutdown: () => {
|
|
257
249
|
isShuttingDown = true;
|
|
258
250
|
if (currentProcess) {
|
|
259
|
-
|
|
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 {}
|
|
260
255
|
}
|
|
261
256
|
}
|
|
262
257
|
};
|
|
263
258
|
}
|
|
264
259
|
|
|
265
260
|
/**
|
|
266
|
-
*
|
|
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
|
|
267
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
|
+
|
|
268
287
|
let exitHandlerRegistered = false;
|
|
269
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
|
+
*/
|
|
270
310
|
function setupExitHandler(serverManager, tunnelProcess, apiKey) {
|
|
271
311
|
if (exitHandlerRegistered) return;
|
|
272
312
|
exitHandlerRegistered = true;
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
if
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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);
|
|
285
367
|
});
|
|
286
368
|
}
|
|
287
369
|
|
|
288
|
-
/**
|
|
289
|
-
* Get first non-internal LAN IPv4 address
|
|
290
|
-
*/
|
|
291
370
|
function getLanIp() {
|
|
292
371
|
const interfaces = os.networkInterfaces();
|
|
293
372
|
for (const iface of Object.values(interfaces)) {
|
|
@@ -298,24 +377,49 @@ function getLanIp() {
|
|
|
298
377
|
return null;
|
|
299
378
|
}
|
|
300
379
|
|
|
301
|
-
/**
|
|
302
|
-
async function
|
|
380
|
+
/** Local API helpers */
|
|
381
|
+
async function apiPost(path, data) {
|
|
303
382
|
try {
|
|
304
|
-
await fetch(`http://localhost:${SERVER_PORT}
|
|
383
|
+
return await fetch(`http://localhost:${SERVER_PORT}${path}`, {
|
|
305
384
|
method: "POST",
|
|
306
385
|
headers: { "Content-Type": "application/json" },
|
|
307
386
|
body: JSON.stringify(data),
|
|
308
387
|
});
|
|
309
|
-
} catch {
|
|
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 });
|
|
310
417
|
}
|
|
311
418
|
|
|
312
|
-
/**
|
|
313
|
-
* Update session tunnelUrl on worker + push to UI
|
|
314
|
-
*/
|
|
315
419
|
async function updateTunnelUrl(selectedKey, tunnelUrl) {
|
|
316
420
|
const lanIp = getLanIp();
|
|
317
421
|
try {
|
|
318
|
-
await
|
|
422
|
+
await browserFetch(`${WORKER_URL}/api/session/update`, {
|
|
319
423
|
method: "POST",
|
|
320
424
|
headers: { "Content-Type": "application/json" },
|
|
321
425
|
body: JSON.stringify({
|
|
@@ -327,12 +431,9 @@ async function updateTunnelUrl(selectedKey, tunnelUrl) {
|
|
|
327
431
|
} catch { }
|
|
328
432
|
}
|
|
329
433
|
|
|
330
|
-
/**
|
|
331
|
-
* Helper: Start server and quick tunnel
|
|
332
|
-
*/
|
|
333
434
|
async function startServerAndTunnel(selectedKey) {
|
|
334
435
|
console.log(ORANGE("\nš Starting server..."));
|
|
335
|
-
|
|
436
|
+
await setStep(STEP.PREPARING);
|
|
336
437
|
|
|
337
438
|
// Kill existing cloudflared process
|
|
338
439
|
try {
|
|
@@ -342,19 +443,14 @@ async function startServerAndTunnel(selectedKey) {
|
|
|
342
443
|
|
|
343
444
|
// Create session on worker
|
|
344
445
|
try {
|
|
345
|
-
const
|
|
446
|
+
const res = await browserFetch(`${WORKER_URL}/api/session/create`, {
|
|
346
447
|
method: "POST",
|
|
347
448
|
headers: { "Content-Type": "application/json" },
|
|
348
|
-
body: JSON.stringify({ apiKey: selectedKey })
|
|
449
|
+
body: JSON.stringify({ apiKey: selectedKey }),
|
|
349
450
|
});
|
|
350
|
-
if (!
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
return null;
|
|
354
|
-
}
|
|
355
|
-
} catch (error) {
|
|
356
|
-
console.log(chalk.red(`ā Failed to create session: ${error.message}`));
|
|
357
|
-
return null;
|
|
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;
|
|
358
454
|
}
|
|
359
455
|
|
|
360
456
|
// Skip spawning server if already running (e.g. nodemon in dev mode)
|
|
@@ -366,7 +462,7 @@ async function startServerAndTunnel(selectedKey) {
|
|
|
366
462
|
if (!alreadyRunning) await new Promise(resolve => setTimeout(resolve, 2000));
|
|
367
463
|
|
|
368
464
|
console.log(ORANGE("ā
Starting tunnel..."));
|
|
369
|
-
|
|
465
|
+
await setStep(STEP.CONNECTING);
|
|
370
466
|
|
|
371
467
|
// Spawn quick tunnel ā URL comes directly from cloudflared stdout
|
|
372
468
|
let tunnelProcess, tunnelUrl;
|
|
@@ -385,6 +481,11 @@ async function startServerAndTunnel(selectedKey) {
|
|
|
385
481
|
return null;
|
|
386
482
|
}
|
|
387
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
|
+
}
|
|
388
489
|
|
|
389
490
|
// Save tunnelUrl to worker DB
|
|
390
491
|
await updateTunnelUrl(selectedKey, tunnelUrl);
|
|
@@ -400,53 +501,33 @@ async function startServerAndTunnel(selectedKey) {
|
|
|
400
501
|
return { serverManager, tunnelProcess, tunnelUrl };
|
|
401
502
|
}
|
|
402
503
|
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* TUI mode ā main flow for `9remote` (no subcommand)
|
|
406
|
-
*/
|
|
407
504
|
async function tuiMode() {
|
|
408
505
|
console.clear();
|
|
506
|
+
resetProgress();
|
|
507
|
+
isTuiActive = true;
|
|
508
|
+
await setStep(STEP.PREPARING);
|
|
409
509
|
|
|
410
|
-
|
|
411
|
-
const machineId = await getConsistentMachineId();
|
|
412
|
-
let keyData = loadKey();
|
|
413
|
-
if (!keyData.key) {
|
|
414
|
-
const { key } = generateApiKeyWithMachine(machineId);
|
|
415
|
-
keyData = saveKey(machineId, key, "Default");
|
|
416
|
-
}
|
|
510
|
+
let keyData = await ensureKeyData();
|
|
417
511
|
|
|
418
|
-
// Run update check & server start in parallel
|
|
419
512
|
let tuiServerMgr = { getProcess: () => null, shutdown: () => {} };
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
(
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
|
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
|
+
}
|
|
438
522
|
|
|
439
|
-
// Kill stale cloudflared
|
|
440
523
|
try { killCloudflared(); await new Promise((r) => setTimeout(r, 300)); } catch {}
|
|
441
524
|
|
|
442
|
-
|
|
443
|
-
await ensureCloudflared();
|
|
525
|
+
await ensureCloudflared(onBinaryProgress);
|
|
444
526
|
|
|
445
|
-
|
|
527
|
+
await setStep(STEP.CONNECTING);
|
|
446
528
|
|
|
447
|
-
// Step 2 ā Connecting (create session)
|
|
448
529
|
try {
|
|
449
|
-
const res = await
|
|
530
|
+
const res = await browserFetch(`${WORKER_URL}/api/session/create`, {
|
|
450
531
|
method: "POST",
|
|
451
532
|
headers: { "Content-Type": "application/json" },
|
|
452
533
|
body: JSON.stringify({ apiKey: keyData.key }),
|
|
@@ -457,9 +538,8 @@ async function tuiMode() {
|
|
|
457
538
|
process.exit(1);
|
|
458
539
|
}
|
|
459
540
|
|
|
460
|
-
|
|
541
|
+
await setStep(STEP.TUNNELING);
|
|
461
542
|
|
|
462
|
-
// Step 3 ā Tunnel
|
|
463
543
|
let tunnelProcess, tunnelUrl;
|
|
464
544
|
try {
|
|
465
545
|
const result = await spawnQuickTunnel(SERVER_PORT, async (newUrl) => {
|
|
@@ -473,17 +553,21 @@ async function tuiMode() {
|
|
|
473
553
|
process.exit(1);
|
|
474
554
|
}
|
|
475
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
|
+
|
|
476
562
|
await updateTunnelUrl(keyData.key, tunnelUrl);
|
|
477
563
|
saveState({ apiKey: keyData.key, tunnelUrl, tunnelPid: tunnelProcess.pid });
|
|
478
564
|
|
|
479
|
-
// āā 4. Create temp key + push ready state āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
480
565
|
const tempKeyData = await createTempKey(keyData.key, WORKER_URL);
|
|
481
566
|
const connectUrl = tempKeyData
|
|
482
567
|
? `${WORKER_URL}/login?k=${tempKeyData.tempKey}`
|
|
483
568
|
: `${WORKER_URL}/login`;
|
|
484
569
|
|
|
485
|
-
await
|
|
486
|
-
step: 4,
|
|
570
|
+
await setStep(STEP.READY, {
|
|
487
571
|
tunnelUrl,
|
|
488
572
|
oneTimeKey: tempKeyData?.tempKey || "",
|
|
489
573
|
oneTimeKeyExpiresAt: tempKeyData?.expiresAt || null,
|
|
@@ -491,33 +575,48 @@ async function tuiMode() {
|
|
|
491
575
|
qrUrl: connectUrl,
|
|
492
576
|
workerUrl: WORKER_URL,
|
|
493
577
|
});
|
|
578
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
494
579
|
|
|
495
|
-
// āā 5. Load initial state from server āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
496
580
|
let currentOneTimeKey = tempKeyData?.tempKey || "";
|
|
497
581
|
let currentConnectUrl = connectUrl;
|
|
582
|
+
let currentTunnelUrl = tunnelUrl;
|
|
498
583
|
|
|
499
|
-
|
|
500
|
-
let menuHeader = await buildMenuHeader(currentOneTimeKey, keyData.key, currentConnectUrl);
|
|
584
|
+
let menuHeader = await buildMenuHeader(currentOneTimeKey, keyData.key, currentConnectUrl, currentTunnelUrl);
|
|
501
585
|
|
|
502
|
-
// Mutable ref for menu re-render callback (set by selectMenu)
|
|
503
586
|
let triggerMenuRedraw = null;
|
|
587
|
+
const logBuffer = [];
|
|
588
|
+
const MAX_LOG_LINES = 200;
|
|
504
589
|
|
|
505
|
-
|
|
590
|
+
let deviceApprovalBusy = false;
|
|
506
591
|
const stopSSE = subscribeSSE(SERVER_PORT, async (type, data) => {
|
|
507
|
-
if (type === "
|
|
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") {
|
|
508
596
|
const newKey = data.permanentKey || keyData.key;
|
|
509
597
|
// Explicit check: "" means cleared (one-time key consumed), preserve existing if undefined
|
|
510
598
|
const newOtk = data.oneTimeKey !== undefined ? data.oneTimeKey : currentOneTimeKey;
|
|
511
599
|
const newUrl = data.qrUrl !== undefined ? data.qrUrl : currentConnectUrl;
|
|
512
|
-
|
|
600
|
+
const newTunnel = data.tunnelUrl !== undefined ? data.tunnelUrl : currentTunnelUrl;
|
|
601
|
+
if (newOtk !== currentOneTimeKey || newKey !== keyData.key || newTunnel !== currentTunnelUrl) {
|
|
513
602
|
currentOneTimeKey = newOtk;
|
|
514
603
|
currentConnectUrl = newUrl;
|
|
604
|
+
currentTunnelUrl = newTunnel;
|
|
515
605
|
if (data.permanentKey) keyData = { ...keyData, key: data.permanentKey };
|
|
516
|
-
menuHeader = await buildMenuHeader(currentOneTimeKey, keyData.key, currentConnectUrl);
|
|
606
|
+
menuHeader = await buildMenuHeader(currentOneTimeKey, keyData.key, currentConnectUrl, currentTunnelUrl);
|
|
517
607
|
triggerMenuRedraw?.();
|
|
518
608
|
}
|
|
519
609
|
} else if (type === "permissions") {
|
|
520
|
-
// desktopEnabled changed ā
|
|
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;
|
|
521
620
|
triggerMenuRedraw?.();
|
|
522
621
|
}
|
|
523
622
|
});
|
|
@@ -527,23 +626,32 @@ async function tuiMode() {
|
|
|
527
626
|
shutdown: () => { tuiServerMgr.shutdown(); stopSSE(); }
|
|
528
627
|
}, tunnelProcess, keyData.key);
|
|
529
628
|
|
|
530
|
-
//
|
|
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
|
+
|
|
531
642
|
await tuiMenuLoop(
|
|
532
643
|
keyData, tunnelUrl,
|
|
533
644
|
() => menuHeader,
|
|
534
645
|
(h) => { menuHeader = h; },
|
|
535
646
|
(cb) => { triggerMenuRedraw = cb; },
|
|
536
|
-
|
|
647
|
+
onShutdown,
|
|
648
|
+
logBuffer
|
|
537
649
|
);
|
|
538
650
|
}
|
|
539
651
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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;
|
|
652
|
+
async function fetchServerState() {
|
|
653
|
+
const d = await apiGet("/api/ui/state");
|
|
654
|
+
return { desktopEnabled: !!d?.desktopEnabled, remoteAvailable: !!d?.remoteAvailable };
|
|
547
655
|
}
|
|
548
656
|
|
|
549
657
|
/**
|
|
@@ -554,125 +662,135 @@ async function fetchDesktopEnabled() {
|
|
|
554
662
|
* @param {(newHeader: string) => void} setHeader - update header from inside loop
|
|
555
663
|
* @param {(cb: () => void) => void} onRedrawRegister - register redraw callback
|
|
556
664
|
*/
|
|
557
|
-
async function tuiMenuLoop(keyData, tunnelUrl, getHeader = () => "", setHeader = () => {}, onRedrawRegister = () => {}, onCtrlC = null) {
|
|
665
|
+
async function tuiMenuLoop(keyData, tunnelUrl, getHeader = () => "", setHeader = () => {}, onRedrawRegister = () => {}, onCtrlC = null, logBuffer = []) {
|
|
558
666
|
while (true) {
|
|
559
|
-
const desktopOn = await
|
|
560
|
-
|
|
667
|
+
const { desktopEnabled: desktopOn, remoteAvailable } = await fetchServerState();
|
|
668
|
+
|
|
561
669
|
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") },
|
|
670
|
+
{ label: "Open Web UI", action: "webui" },
|
|
671
|
+
{ label: "New One-Time Key", action: "otk" },
|
|
672
|
+
{ label: "Regenerate Permanent Key", action: "regen" },
|
|
567
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
|
+
);
|
|
568
683
|
|
|
569
|
-
// Register SSE-triggered redraw with selectMenu
|
|
570
684
|
let redrawMenu = null;
|
|
571
685
|
onRedrawRegister(() => redrawMenu?.());
|
|
572
686
|
|
|
573
|
-
const idx = await selectMenu("
|
|
687
|
+
const idx = await selectMenu("", items, 0, getHeader, (setRedraw) => {
|
|
574
688
|
redrawMenu = setRedraw;
|
|
575
689
|
}, onCtrlC);
|
|
576
690
|
|
|
577
|
-
|
|
578
|
-
|
|
691
|
+
const action = idx >= 0 ? items[idx].action : "exit";
|
|
692
|
+
|
|
693
|
+
if (action === "webui") {
|
|
579
694
|
const url = `http://localhost:${SERVER_PORT}`;
|
|
580
|
-
|
|
581
|
-
spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
|
|
695
|
+
openBrowser(url);
|
|
582
696
|
console.log(chalk.green(`\nš Opening ${url}\n`));
|
|
583
697
|
|
|
584
|
-
} else if (
|
|
585
|
-
// New One-Time Key ā rebuild header with fresh QR
|
|
698
|
+
} else if (action === "otk") {
|
|
586
699
|
const newTempKey = await createTempKey(keyData.key, WORKER_URL);
|
|
587
700
|
if (newTempKey) {
|
|
588
701
|
const newConnectUrl = `${WORKER_URL}/login?k=${newTempKey.tempKey}`;
|
|
589
|
-
setHeader(await buildMenuHeader(newTempKey.tempKey, keyData.key, newConnectUrl));
|
|
702
|
+
setHeader(await buildMenuHeader(newTempKey.tempKey, keyData.key, newConnectUrl, tunnelUrl));
|
|
590
703
|
await pushUiState({ oneTimeKey: newTempKey.tempKey, oneTimeKeyExpiresAt: newTempKey.expiresAt, qrUrl: newConnectUrl });
|
|
591
704
|
}
|
|
592
705
|
|
|
593
|
-
} else if (
|
|
594
|
-
// Regenerate Key
|
|
706
|
+
} else if (action === "regen") {
|
|
595
707
|
const confirmed = await tuiConfirm(chalk.yellow("ā ļø Replace current key and disconnect all sessions? Continue?"));
|
|
596
708
|
if (confirmed) {
|
|
597
709
|
const machineId = await getConsistentMachineId();
|
|
598
710
|
const { key } = generateApiKeyWithMachine(machineId);
|
|
599
711
|
keyData = saveKey(machineId, key, keyData.name || "Default");
|
|
600
712
|
await pushUiState({ permanentKey: keyData.key });
|
|
601
|
-
// Rebuild header with new key
|
|
602
713
|
const newTmp = await createTempKey(keyData.key, WORKER_URL);
|
|
603
714
|
if (newTmp) {
|
|
604
715
|
const newUrl = `${WORKER_URL}/login?k=${newTmp.tempKey}`;
|
|
605
|
-
setHeader(await buildMenuHeader(newTmp.tempKey, keyData.key, newUrl));
|
|
716
|
+
setHeader(await buildMenuHeader(newTmp.tempKey, keyData.key, newUrl, tunnelUrl));
|
|
606
717
|
await pushUiState({ oneTimeKey: newTmp.tempKey, oneTimeKeyExpiresAt: newTmp.expiresAt, qrUrl: newUrl });
|
|
607
718
|
}
|
|
608
719
|
}
|
|
609
720
|
|
|
610
|
-
} else if (
|
|
611
|
-
// Remote Desktop submenu
|
|
721
|
+
} else if (action === "desktop") {
|
|
612
722
|
await tuiDesktopMenu();
|
|
613
723
|
|
|
724
|
+
} else if (action === "devices") {
|
|
725
|
+
await tuiDevicesMenu();
|
|
726
|
+
|
|
727
|
+
} else if (action === "logs") {
|
|
728
|
+
await tuiLogsView(logBuffer);
|
|
729
|
+
|
|
614
730
|
} else {
|
|
615
|
-
// Exit ā
|
|
616
|
-
|
|
731
|
+
// Exit path ā onCtrlC contains the full shutdownAll sequence
|
|
732
|
+
try { onCtrlC?.(); } catch {}
|
|
617
733
|
console.log(chalk.gray("\nGoodbye!\n"));
|
|
618
734
|
process.exit(0);
|
|
619
735
|
}
|
|
620
736
|
}
|
|
621
737
|
}
|
|
622
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
|
+
|
|
623
747
|
/**
|
|
624
748
|
* Remote Desktop submenu.
|
|
625
749
|
* All state (desktopEnabled + permissions) fetched from server ā no local tracking.
|
|
626
750
|
*/
|
|
627
751
|
async function tuiDesktopMenu() {
|
|
628
752
|
while (true) {
|
|
629
|
-
//
|
|
630
|
-
let desktopOn = false
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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)")}`;
|
|
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
|
+
});
|
|
643
762
|
|
|
644
|
-
const
|
|
645
|
-
{ label:
|
|
646
|
-
{ label:
|
|
647
|
-
{ label:
|
|
763
|
+
const items = [
|
|
764
|
+
{ label: "" },
|
|
765
|
+
{ label: "" },
|
|
766
|
+
{ label: "" },
|
|
648
767
|
{ label: chalk.gray("ā Back") },
|
|
649
|
-
]
|
|
768
|
+
];
|
|
650
769
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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;
|
|
660
787
|
|
|
788
|
+
if (idx === 0) {
|
|
789
|
+
await apiPost("/api/desktop/toggle", { enabled: !desktopOn });
|
|
661
790
|
} else if (idx === 1 && !perms.screenRecording) {
|
|
662
|
-
|
|
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
|
-
|
|
791
|
+
if (!await apiPost("/api/permissions/request", { type: "screenRecording" })) openPermissionPane("screenRecording");
|
|
669
792
|
} else if (idx === 2 && !perms.accessibility) {
|
|
670
|
-
|
|
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"); }
|
|
793
|
+
if (!await apiPost("/api/permissions/request", { type: "accessibility" })) openPermissionPane("accessibility");
|
|
676
794
|
|
|
677
795
|
} else if (idx === 3 || idx === -1) {
|
|
678
796
|
return; // Back
|
|
@@ -680,30 +798,39 @@ async function tuiDesktopMenu() {
|
|
|
680
798
|
}
|
|
681
799
|
}
|
|
682
800
|
|
|
801
|
+
async function tuiDevicesMenu() {
|
|
802
|
+
while (true) {
|
|
803
|
+
const data = await apiGet("/api/device/approved");
|
|
804
|
+
const devices = data?.devices || [];
|
|
683
805
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
+
];
|
|
689
814
|
|
|
690
|
-
|
|
691
|
-
|
|
815
|
+
const title = `Approved Devices (${devices.length})`;
|
|
816
|
+
const idx = await selectMenu(title, items, items.length - 1);
|
|
692
817
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
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 });
|
|
699
824
|
}
|
|
825
|
+
}
|
|
700
826
|
|
|
827
|
+
async function autoStartDev() {
|
|
828
|
+
showBanner(getVersion());
|
|
829
|
+
let keyData = await ensureKeyData();
|
|
701
830
|
console.log(chalk.gray(`Using key: ${keyData.key.slice(0, 20)}... (${keyData.name})`));
|
|
702
831
|
|
|
703
832
|
const result = await startServerAndTunnel(keyData.key);
|
|
704
|
-
if (!result)
|
|
705
|
-
process.exit(1);
|
|
706
|
-
}
|
|
833
|
+
if (!result) process.exit(1);
|
|
707
834
|
|
|
708
835
|
const { serverManager, tunnelProcess, tunnelUrl } = result;
|
|
709
836
|
|
|
@@ -721,14 +848,9 @@ async function autoStartDev() {
|
|
|
721
848
|
pushUiState({ uptime: `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` });
|
|
722
849
|
}, 5000);
|
|
723
850
|
|
|
724
|
-
// Keep process alive
|
|
725
851
|
await new Promise(() => { });
|
|
726
852
|
}
|
|
727
853
|
|
|
728
|
-
|
|
729
|
-
/**
|
|
730
|
-
* Listen for key regen, stop-tunnel, start-tunnel from server UI
|
|
731
|
-
*/
|
|
732
854
|
function setupCmdPoller(getActiveTunnel, setActiveTunnel, apiKey) {
|
|
733
855
|
let busy = false;
|
|
734
856
|
setInterval(async () => {
|
|
@@ -745,40 +867,47 @@ function setupCmdPoller(getActiveTunnel, setActiveTunnel, apiKey) {
|
|
|
745
867
|
setActiveTunnel(null);
|
|
746
868
|
console.log(chalk.yellow("š Tunnel stopped"));
|
|
747
869
|
}
|
|
748
|
-
await
|
|
870
|
+
await setStep(STEP.STOPPED, { tunnelUrl: "", oneTimeKey: "", oneTimeKeyExpiresAt: null });
|
|
871
|
+
updateTrayTooltip({ tunnelUrl: "", running: true });
|
|
749
872
|
}
|
|
750
873
|
|
|
751
874
|
if (cmd === "start-tunnel") {
|
|
752
875
|
if (getActiveTunnel()) { busy = false; return; } // already running
|
|
753
876
|
console.log(ORANGE("š Starting tunnel..."));
|
|
754
877
|
try {
|
|
755
|
-
|
|
756
|
-
await
|
|
757
|
-
await ensureCloudflared();
|
|
878
|
+
await setStep(STEP.PREPARING);
|
|
879
|
+
await ensureCloudflared(onBinaryProgress);
|
|
758
880
|
|
|
759
|
-
|
|
760
|
-
await
|
|
761
|
-
const sessionResponse = await fetch(`${WORKER_URL}/api/session/create`, {
|
|
881
|
+
await setStep(STEP.CONNECTING);
|
|
882
|
+
const sessionResponse = await browserFetch(`${WORKER_URL}/api/session/create`, {
|
|
762
883
|
method: "POST",
|
|
763
884
|
headers: { "Content-Type": "application/json" },
|
|
764
885
|
body: JSON.stringify({ apiKey }),
|
|
765
886
|
});
|
|
766
887
|
if (!sessionResponse.ok) throw new Error(`Session create failed: ${sessionResponse.status}`);
|
|
767
888
|
|
|
768
|
-
|
|
769
|
-
await pushUiState({ step: 3 });
|
|
889
|
+
await setStep(STEP.TUNNELING);
|
|
770
890
|
const result = await spawnQuickTunnel(SERVER_PORT, async (newUrl) => {
|
|
771
891
|
await updateTunnelUrl(apiKey, newUrl);
|
|
772
892
|
await pushUiState({ tunnelUrl: newUrl });
|
|
773
893
|
});
|
|
774
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
|
+
|
|
775
902
|
await updateTunnelUrl(apiKey, result.tunnelUrl);
|
|
903
|
+
updateTrayTooltip({ tunnelUrl: result.tunnelUrl, running: true });
|
|
776
904
|
|
|
777
|
-
//
|
|
905
|
+
// Hold for 3s so UI sees all steps complete before showing Ready screen
|
|
906
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
778
907
|
await showConnectionInfo(apiKey, result.tunnelUrl);
|
|
779
908
|
} catch (err) {
|
|
780
909
|
console.log(chalk.red(`ā Failed to start tunnel: ${err.message}`));
|
|
781
|
-
await
|
|
910
|
+
await setStep(STEP.STOPPED);
|
|
782
911
|
}
|
|
783
912
|
}
|
|
784
913
|
|
|
@@ -791,89 +920,368 @@ function setupCmdPoller(getActiveTunnel, setActiveTunnel, apiKey) {
|
|
|
791
920
|
console.log(chalk.green(`ā
Key regenerated: ${key}`));
|
|
792
921
|
}
|
|
793
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
|
+
|
|
794
931
|
} finally {
|
|
795
932
|
busy = false;
|
|
796
933
|
}
|
|
797
934
|
}, 1000);
|
|
798
935
|
}
|
|
799
936
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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();
|
|
804
952
|
try {
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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 };
|
|
809
963
|
}
|
|
810
964
|
}
|
|
811
965
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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 = [];
|
|
818
974
|
|
|
819
|
-
|
|
820
|
-
|
|
975
|
+
// Init terminal-like panel in web UI
|
|
976
|
+
await pushUiState({
|
|
977
|
+
healthCheck: { running: true, timeoutMs, startedAt: start, logs: [] },
|
|
978
|
+
});
|
|
821
979
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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));
|
|
825
1035
|
}
|
|
826
1036
|
|
|
827
|
-
|
|
828
|
-
|
|
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="));
|
|
829
1152
|
const theme = themeArg ? themeArg.split("=")[1] : null;
|
|
830
1153
|
|
|
831
|
-
// Start server only (no tunnel yet ā wait for UI Connect button)
|
|
832
1154
|
const alreadyRunning = await isServerRunning();
|
|
833
1155
|
const serverManager = alreadyRunning
|
|
834
1156
|
? { getProcess: () => null, shutdown: () => {} }
|
|
835
1157
|
: startServerWithRestart(null, null);
|
|
836
1158
|
|
|
837
|
-
if (!alreadyRunning) await new Promise(
|
|
1159
|
+
if (!alreadyRunning) await new Promise(r => setTimeout(r, 2000));
|
|
838
1160
|
|
|
839
|
-
|
|
1161
|
+
const uiUrl = `http://localhost:${SERVER_PORT}`;
|
|
840
1162
|
|
|
841
|
-
// Mutable ref for active tunnel
|
|
842
1163
|
let activeTunnel = null;
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
+
};
|
|
848
1173
|
|
|
849
1174
|
setupExitHandler(serverManager, null, keyData.key);
|
|
850
|
-
setupCmdPoller(
|
|
1175
|
+
setupCmdPoller(() => activeTunnel, (t) => { activeTunnel = t; }, keyData.key);
|
|
851
1176
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
+
});
|
|
855
1192
|
}
|
|
856
1193
|
|
|
857
|
-
await new Promise(() => {
|
|
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(() => {});
|
|
858
1224
|
}
|
|
859
1225
|
|
|
860
1226
|
// Start app
|
|
861
1227
|
async function start() {
|
|
862
|
-
// Disable auto-update - TUI already shows update notification
|
|
863
|
-
// const hasUpdate = await checkAndUpdate(skipUpdate);
|
|
864
|
-
// if (hasUpdate) return;
|
|
865
|
-
|
|
866
1228
|
const command = process.argv[2];
|
|
867
|
-
|
|
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
|
+
|
|
868
1240
|
if (command === "ui") {
|
|
869
|
-
// UI mode: 9remote ui
|
|
870
1241
|
await startUiMode();
|
|
871
1242
|
} else if (command === "start" || process.argv.includes("--auto")) {
|
|
872
|
-
// Direct start: 9remote start
|
|
873
1243
|
await autoStartDev();
|
|
1244
|
+
} else if (process.argv.includes("--tray")) {
|
|
1245
|
+
await startTrayMode();
|
|
874
1246
|
} else {
|
|
875
|
-
|
|
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") {
|
|
876
1282
|
await tuiMode();
|
|
1283
|
+
} else {
|
|
1284
|
+
process.exit(0);
|
|
877
1285
|
}
|
|
878
1286
|
}
|
|
879
1287
|
|