9remote 2.0.2 ā 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/dist/cli.cjs +1 -1
- package/dist/install.cjs +2 -0
- package/dist/ptyDaemon.cjs +1 -1
- package/dist/server.cjs +1 -1
- package/dist/ui/assets/{index-COWVKicT.css ā index-BMHG73CL.css} +1 -1
- package/dist/ui/assets/index-Bg86Demx.js +8 -0
- package/dist/ui/index.html +2 -2
- package/package.json +4 -6
- package/cli/index.js +0 -1330
- package/cli/scripts/install.js +0 -19
- package/cli/utils/apiKey.js +0 -77
- package/cli/utils/assets/trayIcon.ico +0 -0
- package/cli/utils/cloudflared.js +0 -493
- package/cli/utils/machineId.js +0 -22
- package/cli/utils/permissions.js +0 -45
- package/cli/utils/pids.js +0 -114
- package/cli/utils/state.js +0 -115
- package/cli/utils/token.js +0 -32
- package/cli/utils/tray.js +0 -251
- package/cli/utils/tui.js +0 -445
- package/cli/utils/updateChecker.js +0 -358
- package/dist/ui/assets/index-BWfJSBGG.js +0 -8
- package/index.js +0 -275
- package/lib/constants.js +0 -64
- package/lib/deviceApproval.js +0 -152
- package/lib/router.js +0 -134
- package/lib/socketio.js +0 -240
package/cli/index.js
DELETED
|
@@ -1,1330 +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
|
-
|
|
592
|
-
// Skip redraws while device approval prompt is active ā otherwise SSE-driven
|
|
593
|
-
// menu repaints (state/permissions/log) would clear the screen and overwrite
|
|
594
|
-
// the prompt, making it invisible to the user.
|
|
595
|
-
const safeRedraw = () => { if (!deviceApprovalBusy) triggerMenuRedraw?.(); };
|
|
596
|
-
|
|
597
|
-
// Handle pending approval ā shared by SSE event and fallback poll.
|
|
598
|
-
// Fallback recovers from missed SSE events (reconnect, TUI in submenu, etc.)
|
|
599
|
-
const handlePendingApproval = async (socketId, deviceId, ip) => {
|
|
600
|
-
if (deviceApprovalBusy) return;
|
|
601
|
-
deviceApprovalBusy = true;
|
|
602
|
-
const approved = await showDeviceApproval(deviceId, ip);
|
|
603
|
-
const endpoint = approved ? "approve" : "reject";
|
|
604
|
-
await apiPost(`/api/device/${endpoint}`, { socketId });
|
|
605
|
-
deviceApprovalBusy = false;
|
|
606
|
-
triggerMenuRedraw?.();
|
|
607
|
-
};
|
|
608
|
-
|
|
609
|
-
const stopSSE = subscribeSSE(SERVER_PORT, async (type, data) => {
|
|
610
|
-
if (type === "log" && data.message) {
|
|
611
|
-
logBuffer.push(data.message);
|
|
612
|
-
if (logBuffer.length > MAX_LOG_LINES) logBuffer.shift();
|
|
613
|
-
} else if (type === "state") {
|
|
614
|
-
const newKey = data.permanentKey || keyData.key;
|
|
615
|
-
// Explicit check: "" means cleared (one-time key consumed), preserve existing if undefined
|
|
616
|
-
const newOtk = data.oneTimeKey !== undefined ? data.oneTimeKey : currentOneTimeKey;
|
|
617
|
-
const newUrl = data.qrUrl !== undefined ? data.qrUrl : currentConnectUrl;
|
|
618
|
-
const newTunnel = data.tunnelUrl !== undefined ? data.tunnelUrl : currentTunnelUrl;
|
|
619
|
-
if (newOtk !== currentOneTimeKey || newKey !== keyData.key || newTunnel !== currentTunnelUrl) {
|
|
620
|
-
currentOneTimeKey = newOtk;
|
|
621
|
-
currentConnectUrl = newUrl;
|
|
622
|
-
currentTunnelUrl = newTunnel;
|
|
623
|
-
if (data.permanentKey) keyData = { ...keyData, key: data.permanentKey };
|
|
624
|
-
menuHeader = await buildMenuHeader(currentOneTimeKey, keyData.key, currentConnectUrl, currentTunnelUrl);
|
|
625
|
-
safeRedraw();
|
|
626
|
-
}
|
|
627
|
-
} else if (type === "permissions") {
|
|
628
|
-
// desktopEnabled or permission values changed ā refresh both main menu and active submenu
|
|
629
|
-
if (!deviceApprovalBusy) activeSubmenuRefresh?.();
|
|
630
|
-
safeRedraw();
|
|
631
|
-
} else if (type === "deviceApproval" && data.action === "pending") {
|
|
632
|
-
await handlePendingApproval(data.socketId, data.deviceId, data.ip);
|
|
633
|
-
}
|
|
634
|
-
});
|
|
635
|
-
|
|
636
|
-
// Fallback poll: recover from missed SSE pending events
|
|
637
|
-
const pendingPoll = setInterval(async () => {
|
|
638
|
-
if (deviceApprovalBusy) return;
|
|
639
|
-
const d = await apiGet("/api/device/pending");
|
|
640
|
-
const first = d?.pending?.[0];
|
|
641
|
-
if (first) await handlePendingApproval(first.socketId, first.deviceId, first.ip);
|
|
642
|
-
}, 3000);
|
|
643
|
-
|
|
644
|
-
setupExitHandler({
|
|
645
|
-
getProcess: tuiServerMgr.getProcess,
|
|
646
|
-
shutdown: () => { tuiServerMgr.shutdown(); stopSSE(); clearInterval(pendingPoll); }
|
|
647
|
-
}, tunnelProcess, keyData.key);
|
|
648
|
-
|
|
649
|
-
// Handle web UI Start/Stop commands while TUI is running
|
|
650
|
-
let activeTunnel = tunnelProcess;
|
|
651
|
-
setupCmdPoller(() => activeTunnel, (t) => { activeTunnel = t; }, keyData.key);
|
|
652
|
-
|
|
653
|
-
const onShutdown = () => {
|
|
654
|
-
try { stopSSE(); } catch {}
|
|
655
|
-
try { clearInterval(pendingPoll); } catch {}
|
|
656
|
-
shutdownAll({
|
|
657
|
-
serverManager: tuiServerMgr,
|
|
658
|
-
tunnelProcess,
|
|
659
|
-
exit: false, // let the menu loop print Goodbye and exit itself
|
|
660
|
-
});
|
|
661
|
-
};
|
|
662
|
-
|
|
663
|
-
await tuiMenuLoop(
|
|
664
|
-
keyData, tunnelUrl,
|
|
665
|
-
() => menuHeader,
|
|
666
|
-
(h) => { menuHeader = h; },
|
|
667
|
-
(cb) => { triggerMenuRedraw = cb; },
|
|
668
|
-
onShutdown,
|
|
669
|
-
logBuffer
|
|
670
|
-
);
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
async function fetchServerState() {
|
|
674
|
-
const [d, a] = await Promise.all([
|
|
675
|
-
apiGet("/api/ui/state"),
|
|
676
|
-
apiGet("/api/device/auto-approve"),
|
|
677
|
-
]);
|
|
678
|
-
return {
|
|
679
|
-
desktopEnabled: !!d?.desktopEnabled,
|
|
680
|
-
remoteAvailable: !!d?.remoteAvailable,
|
|
681
|
-
autoApprove: !!a?.enabled,
|
|
682
|
-
};
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
/**
|
|
686
|
-
* Main menu loop after Ready.
|
|
687
|
-
* @param {object} keyData
|
|
688
|
-
* @param {string} tunnelUrl
|
|
689
|
-
* @param {() => string} getHeader - live header getter (SSE may update it)
|
|
690
|
-
* @param {(newHeader: string) => void} setHeader - update header from inside loop
|
|
691
|
-
* @param {(cb: () => void) => void} onRedrawRegister - register redraw callback
|
|
692
|
-
*/
|
|
693
|
-
async function tuiMenuLoop(keyData, tunnelUrl, getHeader = () => "", setHeader = () => {}, onRedrawRegister = () => {}, onCtrlC = null, logBuffer = []) {
|
|
694
|
-
while (true) {
|
|
695
|
-
const { desktopEnabled: desktopOn, remoteAvailable, autoApprove } = await fetchServerState();
|
|
696
|
-
|
|
697
|
-
const items = [
|
|
698
|
-
{ label: "Open Web UI", action: "webui" },
|
|
699
|
-
{ label: "New One-Time Key", action: "otk" },
|
|
700
|
-
{ label: "Regenerate Permanent Key", action: "regen" },
|
|
701
|
-
];
|
|
702
|
-
if (remoteAvailable) {
|
|
703
|
-
const desktopLabel = `Remote Desktop: ${desktopOn ? chalk.green("ON") : chalk.gray("OFF")} ā¶`;
|
|
704
|
-
items.push({ label: desktopLabel, action: "desktop" });
|
|
705
|
-
}
|
|
706
|
-
const autoLabel = autoApprove ? chalk.green("ON") : chalk.gray("OFF");
|
|
707
|
-
items.push(
|
|
708
|
-
{ label: `Manage Devices \u25b6 ${chalk.dim("(Auto-approve:")} ${autoLabel}${chalk.dim(")")}`, action: "devices" },
|
|
709
|
-
{ label: `View Logs (${logBuffer.length})`, action: "logs" },
|
|
710
|
-
{ label: chalk.gray("Exit"), action: "exit" },
|
|
711
|
-
);
|
|
712
|
-
|
|
713
|
-
let redrawMenu = null;
|
|
714
|
-
onRedrawRegister(() => redrawMenu?.());
|
|
715
|
-
|
|
716
|
-
const idx = await selectMenu("", items, 0, getHeader, (setRedraw) => {
|
|
717
|
-
redrawMenu = setRedraw;
|
|
718
|
-
}, onCtrlC);
|
|
719
|
-
|
|
720
|
-
const action = idx >= 0 ? items[idx].action : "exit";
|
|
721
|
-
|
|
722
|
-
if (action === "webui") {
|
|
723
|
-
const url = `http://localhost:${SERVER_PORT}`;
|
|
724
|
-
openBrowser(url);
|
|
725
|
-
console.log(chalk.green(`\nš Opening ${url}\n`));
|
|
726
|
-
|
|
727
|
-
} else if (action === "otk") {
|
|
728
|
-
const newTempKey = await createTempKey(keyData.key, WORKER_URL);
|
|
729
|
-
if (newTempKey) {
|
|
730
|
-
const newConnectUrl = `${WORKER_URL}/login?k=${newTempKey.tempKey}`;
|
|
731
|
-
setHeader(await buildMenuHeader(newTempKey.tempKey, keyData.key, newConnectUrl, tunnelUrl));
|
|
732
|
-
await pushUiState({ oneTimeKey: newTempKey.tempKey, oneTimeKeyExpiresAt: newTempKey.expiresAt, qrUrl: newConnectUrl });
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
} else if (action === "regen") {
|
|
736
|
-
const confirmed = await tuiConfirm(chalk.yellow("ā ļø Replace current key and disconnect all sessions? Continue?"));
|
|
737
|
-
if (confirmed) {
|
|
738
|
-
const machineId = await getConsistentMachineId();
|
|
739
|
-
const { key } = generateApiKeyWithMachine(machineId);
|
|
740
|
-
keyData = saveKey(machineId, key, keyData.name || "Default");
|
|
741
|
-
await pushUiState({ permanentKey: keyData.key });
|
|
742
|
-
const newTmp = await createTempKey(keyData.key, WORKER_URL);
|
|
743
|
-
if (newTmp) {
|
|
744
|
-
const newUrl = `${WORKER_URL}/login?k=${newTmp.tempKey}`;
|
|
745
|
-
setHeader(await buildMenuHeader(newTmp.tempKey, keyData.key, newUrl, tunnelUrl));
|
|
746
|
-
await pushUiState({ oneTimeKey: newTmp.tempKey, oneTimeKeyExpiresAt: newTmp.expiresAt, qrUrl: newUrl });
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
} else if (action === "desktop") {
|
|
751
|
-
await tuiDesktopMenu();
|
|
752
|
-
|
|
753
|
-
} else if (action === "devices") {
|
|
754
|
-
await tuiDevicesMenu();
|
|
755
|
-
|
|
756
|
-
} else if (action === "logs") {
|
|
757
|
-
await tuiLogsView(logBuffer);
|
|
758
|
-
|
|
759
|
-
} else {
|
|
760
|
-
// Exit path ā onCtrlC contains the full shutdownAll sequence
|
|
761
|
-
try { onCtrlC?.(); } catch {}
|
|
762
|
-
console.log(chalk.gray("\nGoodbye!\n"));
|
|
763
|
-
process.exit(0);
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
/** View logs screen ā scrollable, ESC to go back */
|
|
769
|
-
async function tuiLogsView(logBuffer) {
|
|
770
|
-
const header = logBuffer.length
|
|
771
|
-
? logBuffer.join("\n")
|
|
772
|
-
: chalk.gray(" No logs yet");
|
|
773
|
-
await selectMenu("Logs", [{ label: chalk.gray("ā Back") }], 0, header);
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
/**
|
|
777
|
-
* Remote Desktop submenu.
|
|
778
|
-
* All state (desktopEnabled + permissions) fetched from server ā no local tracking.
|
|
779
|
-
*/
|
|
780
|
-
async function tuiDesktopMenu() {
|
|
781
|
-
while (true) {
|
|
782
|
-
// State captured in closure ā SSE may mutate items in-place while menu is open
|
|
783
|
-
let desktopOn = false;
|
|
784
|
-
let perms = { screenRecording: false, accessibility: false };
|
|
785
|
-
|
|
786
|
-
const buildLabels = () => ({
|
|
787
|
-
toggle: `Toggle: ${desktopOn ? chalk.green("ON ā turn OFF") : chalk.gray("OFF ā turn ON")}`,
|
|
788
|
-
sr: `Screen Recording ${perms.screenRecording ? chalk.green("ā") : chalk.red("ā (click to grant)")}`,
|
|
789
|
-
ax: `Mouse & Keyboard control ${perms.accessibility ? chalk.green("ā") : chalk.red("ā (click to grant)")}`,
|
|
790
|
-
});
|
|
791
|
-
|
|
792
|
-
const items = [
|
|
793
|
-
{ label: "" },
|
|
794
|
-
{ label: "" },
|
|
795
|
-
{ label: "" },
|
|
796
|
-
{ label: chalk.gray("ā Back") },
|
|
797
|
-
];
|
|
798
|
-
|
|
799
|
-
let redrawMenu = null;
|
|
800
|
-
const syncFromServer = async () => {
|
|
801
|
-
const s = await apiGet("/api/ui/state") || {};
|
|
802
|
-
desktopOn = !!s.desktopEnabled;
|
|
803
|
-
perms = { screenRecording: !!s.screenRecording, accessibility: !!s.accessibility };
|
|
804
|
-
const L = buildLabels();
|
|
805
|
-
items[0].label = L.toggle;
|
|
806
|
-
items[1].label = L.sr;
|
|
807
|
-
items[2].label = L.ax;
|
|
808
|
-
redrawMenu?.();
|
|
809
|
-
};
|
|
810
|
-
|
|
811
|
-
await syncFromServer();
|
|
812
|
-
// Register SSE-driven refresh while this submenu is active
|
|
813
|
-
activeSubmenuRefresh = syncFromServer;
|
|
814
|
-
const idx = await selectMenu("Remote Desktop", items, 0, "", (setRedraw) => { redrawMenu = setRedraw; });
|
|
815
|
-
activeSubmenuRefresh = null;
|
|
816
|
-
|
|
817
|
-
if (idx === 0) {
|
|
818
|
-
await apiPost("/api/desktop/toggle", { enabled: !desktopOn });
|
|
819
|
-
} else if (idx === 1 && !perms.screenRecording) {
|
|
820
|
-
if (!await apiPost("/api/permissions/request", { type: "screenRecording" })) openPermissionPane("screenRecording");
|
|
821
|
-
} else if (idx === 2 && !perms.accessibility) {
|
|
822
|
-
if (!await apiPost("/api/permissions/request", { type: "accessibility" })) openPermissionPane("accessibility");
|
|
823
|
-
|
|
824
|
-
} else if (idx === 3 || idx === -1) {
|
|
825
|
-
return; // Back
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
async function tuiDevicesMenu() {
|
|
831
|
-
while (true) {
|
|
832
|
-
const [approvedData, autoData] = await Promise.all([
|
|
833
|
-
apiGet("/api/device/approved"),
|
|
834
|
-
apiGet("/api/device/auto-approve"),
|
|
835
|
-
]);
|
|
836
|
-
const devices = approvedData?.devices || [];
|
|
837
|
-
const autoOn = !!autoData?.enabled;
|
|
838
|
-
|
|
839
|
-
const toggleLabel = `Auto-approve new devices: ${autoOn ? chalk.green("ON") : chalk.gray("OFF")}`;
|
|
840
|
-
const items = [
|
|
841
|
-
{ label: toggleLabel, action: "toggle" },
|
|
842
|
-
...devices.map((d) => {
|
|
843
|
-
const short = d.deviceId.slice(0, 8);
|
|
844
|
-
const date = d.approvedAt ? new Date(d.approvedAt).toLocaleString() : "unknown";
|
|
845
|
-
return { label: `${short}... ${chalk.dim(date)}`, action: "remove", deviceId: d.deviceId };
|
|
846
|
-
}),
|
|
847
|
-
{ label: chalk.gray("\u2190 Back"), action: "back" },
|
|
848
|
-
];
|
|
849
|
-
|
|
850
|
-
const title = `Approved Devices (${devices.length})`;
|
|
851
|
-
const idx = await selectMenu(title, items, 0);
|
|
852
|
-
|
|
853
|
-
if (idx === -1) return; // ESC
|
|
854
|
-
const sel = items[idx];
|
|
855
|
-
if (sel.action === "back") return;
|
|
856
|
-
|
|
857
|
-
if (sel.action === "toggle") {
|
|
858
|
-
await apiPost("/api/device/auto-approve", { enabled: !autoOn });
|
|
859
|
-
continue;
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
if (sel.action === "remove") {
|
|
863
|
-
const confirmed = await tuiConfirm(chalk.yellow(`Remove device ${sel.deviceId.slice(0, 8)}...?`));
|
|
864
|
-
if (confirmed) await apiPost("/api/device/remove", { deviceId: sel.deviceId });
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
async function autoStartDev() {
|
|
870
|
-
showBanner(getVersion());
|
|
871
|
-
let keyData = await ensureKeyData();
|
|
872
|
-
console.log(chalk.gray(`Using key: ${keyData.key.slice(0, 20)}... (${keyData.name})`));
|
|
873
|
-
|
|
874
|
-
const result = await startServerAndTunnel(keyData.key);
|
|
875
|
-
if (!result) process.exit(1);
|
|
876
|
-
|
|
877
|
-
const { serverManager, tunnelProcess, tunnelUrl } = result;
|
|
878
|
-
|
|
879
|
-
await showConnectionInfo(keyData.key, tunnelUrl);
|
|
880
|
-
setupExitHandler(serverManager, tunnelProcess, keyData.key);
|
|
881
|
-
|
|
882
|
-
let activeTunnel = tunnelProcess;
|
|
883
|
-
setupCmdPoller(() => activeTunnel, (t) => { activeTunnel = t; }, keyData.key);
|
|
884
|
-
|
|
885
|
-
// Push stats to UI every 5s
|
|
886
|
-
const startTime = Date.now();
|
|
887
|
-
setInterval(() => {
|
|
888
|
-
const uptime = Math.floor((Date.now() - startTime) / 1000);
|
|
889
|
-
const h = Math.floor(uptime / 3600), m = Math.floor((uptime % 3600) / 60), s = uptime % 60;
|
|
890
|
-
pushUiState({ uptime: `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` });
|
|
891
|
-
}, 5000);
|
|
892
|
-
|
|
893
|
-
await new Promise(() => { });
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
function setupCmdPoller(getActiveTunnel, setActiveTunnel, apiKey) {
|
|
897
|
-
let busy = false;
|
|
898
|
-
setInterval(async () => {
|
|
899
|
-
if (busy) return;
|
|
900
|
-
const cmd = readAndClearCmd();
|
|
901
|
-
if (!cmd) return;
|
|
902
|
-
busy = true;
|
|
903
|
-
try {
|
|
904
|
-
|
|
905
|
-
if (cmd === "stop-tunnel") {
|
|
906
|
-
const tunnel = getActiveTunnel();
|
|
907
|
-
if (tunnel) {
|
|
908
|
-
tunnel.kill();
|
|
909
|
-
setActiveTunnel(null);
|
|
910
|
-
console.log(chalk.yellow("š Tunnel stopped"));
|
|
911
|
-
}
|
|
912
|
-
await setStep(STEP.STOPPED, { tunnelUrl: "", oneTimeKey: "", oneTimeKeyExpiresAt: null });
|
|
913
|
-
updateTrayTooltip({ tunnelUrl: "", running: true });
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
if (cmd === "start-tunnel") {
|
|
917
|
-
if (getActiveTunnel()) { busy = false; return; } // already running
|
|
918
|
-
console.log(ORANGE("š Starting tunnel..."));
|
|
919
|
-
try {
|
|
920
|
-
await setStep(STEP.PREPARING);
|
|
921
|
-
await ensureCloudflared(onBinaryProgress);
|
|
922
|
-
|
|
923
|
-
await setStep(STEP.CONNECTING);
|
|
924
|
-
const sessionResponse = await browserFetch(`${WORKER_URL}/api/session/create`, {
|
|
925
|
-
method: "POST",
|
|
926
|
-
headers: { "Content-Type": "application/json" },
|
|
927
|
-
body: JSON.stringify({ apiKey }),
|
|
928
|
-
});
|
|
929
|
-
if (!sessionResponse.ok) throw new Error(`Session create failed: ${sessionResponse.status}`);
|
|
930
|
-
|
|
931
|
-
await setStep(STEP.TUNNELING);
|
|
932
|
-
const result = await spawnQuickTunnel(SERVER_PORT, async (newUrl) => {
|
|
933
|
-
await updateTunnelUrl(apiKey, newUrl);
|
|
934
|
-
await pushUiState({ tunnelUrl: newUrl });
|
|
935
|
-
});
|
|
936
|
-
setActiveTunnel(result.child);
|
|
937
|
-
|
|
938
|
-
await setStep(STEP.VERIFYING);
|
|
939
|
-
const tunnelOk = await waitForTunnelReady(result.tunnelUrl);
|
|
940
|
-
if (!tunnelOk) {
|
|
941
|
-
console.log(chalk.yellow("\nā ļø Tunnel health check timed out, proceeding anyway..."));
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
await updateTunnelUrl(apiKey, result.tunnelUrl);
|
|
945
|
-
updateTrayTooltip({ tunnelUrl: result.tunnelUrl, running: true });
|
|
946
|
-
|
|
947
|
-
// Hold for 3s so UI sees all steps complete before showing Ready screen
|
|
948
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
949
|
-
await showConnectionInfo(apiKey, result.tunnelUrl);
|
|
950
|
-
} catch (err) {
|
|
951
|
-
console.log(chalk.red(`ā Failed to start tunnel: ${err.message}`));
|
|
952
|
-
await setStep(STEP.STOPPED);
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
if (cmd === "regenerate-key") {
|
|
957
|
-
const machineId = await getConsistentMachineId();
|
|
958
|
-
const { key } = generateApiKeyWithMachine(machineId);
|
|
959
|
-
const existing = loadKey();
|
|
960
|
-
saveKey(machineId, key, existing?.name || "Default");
|
|
961
|
-
await pushUiState({ permanentKey: key });
|
|
962
|
-
console.log(chalk.green(`ā
Key regenerated: ${key}`));
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
if (cmd === "shutdown") {
|
|
966
|
-
console.log(chalk.yellow("\nš Shutting down 9Remote completely..."));
|
|
967
|
-
const tunnel = getActiveTunnel();
|
|
968
|
-
setActiveTunnel(null);
|
|
969
|
-
shutdownAll({ tunnelProcess: tunnel });
|
|
970
|
-
console.log(chalk.green("ā
9Remote stopped"));
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
} finally {
|
|
974
|
-
busy = false;
|
|
975
|
-
}
|
|
976
|
-
}, 1000);
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
// Win DNS negative cache giữ ENOTFOUND lĆ¢u hĘ”n thį»i Äiį»m Cloudflare publish subdomain.
|
|
980
|
-
// Flush trʰį»c mį»i attempt Äį» query Äi thįŗ³ng upstream, trĆ”nh chį» TTL Ć¢m hįŗæt hįŗ”n.
|
|
981
|
-
// Async fire-and-forget + windowsHide ā khĆ“ng block, khĆ“ng popup cmd window.
|
|
982
|
-
function flushWinDns() {
|
|
983
|
-
if (process.platform !== "win32") return;
|
|
984
|
-
execFile("ipconfig", ["/flushdns"], { windowsHide: true }, () => {});
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
// Resolve DNS trį»±c tiįŗæp qua Cloudflare 1.1.1.1 (bypass resolver hį» thį»ng / ISP cache)
|
|
988
|
-
// ā biįŗæt ngay subdomain ÄĆ£ propagate chʰa mĆ khĆ“ng tį»n 5s chį» fetch timeout.
|
|
989
|
-
const dnsResolver = new dns.promises.Resolver();
|
|
990
|
-
dnsResolver.setServers(["1.1.1.1", "1.0.0.1", "8.8.8.8"]);
|
|
991
|
-
|
|
992
|
-
async function resolveTunnelDns(hostname, timeoutMs = 2000) {
|
|
993
|
-
const t0 = Date.now();
|
|
994
|
-
try {
|
|
995
|
-
const addrs = await Promise.race([
|
|
996
|
-
dnsResolver.resolve4(hostname),
|
|
997
|
-
new Promise((_, reject) =>
|
|
998
|
-
setTimeout(() => reject(Object.assign(new Error("DNS timeout"), { code: "ETIMEOUT" })), timeoutMs)
|
|
999
|
-
),
|
|
1000
|
-
]);
|
|
1001
|
-
return { ok: true, addrs, elapsedMs: Date.now() - t0 };
|
|
1002
|
-
} catch (err) {
|
|
1003
|
-
const code = err.code || err.message;
|
|
1004
|
-
return { ok: false, code, elapsedMs: Date.now() - t0 };
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
async function waitForTunnelReady(tunnelUrl, { intervalMs = 2000, timeoutMs = 180000 } = {}) {
|
|
1009
|
-
const healthUrl = `${tunnelUrl}/api/health`;
|
|
1010
|
-
const hostname = (() => {
|
|
1011
|
-
try { return new URL(tunnelUrl).hostname; } catch { return null; }
|
|
1012
|
-
})();
|
|
1013
|
-
const start = Date.now();
|
|
1014
|
-
let attempt = 0;
|
|
1015
|
-
const logs = [];
|
|
1016
|
-
|
|
1017
|
-
// Init terminal-like panel in web UI
|
|
1018
|
-
await pushUiState({
|
|
1019
|
-
healthCheck: { running: true, timeoutMs, startedAt: start, logs: [] },
|
|
1020
|
-
});
|
|
1021
|
-
|
|
1022
|
-
const pushLog = (entry) => {
|
|
1023
|
-
logs.push(entry);
|
|
1024
|
-
// Keep last 80 entries to stay snappy
|
|
1025
|
-
const trimmed = logs.length > 80 ? logs.slice(-80) : logs;
|
|
1026
|
-
pushUiState({
|
|
1027
|
-
healthCheck: { running: true, timeoutMs, startedAt: start, logs: trimmed },
|
|
1028
|
-
});
|
|
1029
|
-
};
|
|
1030
|
-
|
|
1031
|
-
while (Date.now() - start < timeoutMs) {
|
|
1032
|
-
attempt++;
|
|
1033
|
-
flushWinDns();
|
|
1034
|
-
|
|
1035
|
-
// Bʰį»c 1: DNS probe qua 1.1.1.1 (timeout 2s) ā biįŗæt sį»m subdomain ÄĆ£ publish chʰa
|
|
1036
|
-
// ChỠÔp dỄng khi có hostname hợp lỠ(tunnel URL thực tế).
|
|
1037
|
-
if (hostname) {
|
|
1038
|
-
const dnsRes = await resolveTunnelDns(hostname, 2000);
|
|
1039
|
-
if (!dnsRes.ok) {
|
|
1040
|
-
const isWaiting = dnsRes.code === "ENOTFOUND" || dnsRes.code === "ETIMEOUT" || dnsRes.code === "ESERVFAIL";
|
|
1041
|
-
const status = isWaiting ? "connecting..." : dnsRes.code;
|
|
1042
|
-
updateProgressDesc(`#${attempt} ā ${status}`);
|
|
1043
|
-
pushLog({ attempt, status, elapsedMs: dnsRes.elapsedMs, ok: false, waiting: isWaiting, time: Date.now() });
|
|
1044
|
-
// Subdomain chʰa propagate ā skip fetch 5s, chį» interval rį»i thį» lįŗ”i.
|
|
1045
|
-
await new Promise((r) => setTimeout(r, intervalMs));
|
|
1046
|
-
continue;
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
// Bʰį»c 2: DNS OK (hoįŗ·c khĆ“ng có hostname) ā fetch health endpoint
|
|
1051
|
-
const t0 = Date.now();
|
|
1052
|
-
try {
|
|
1053
|
-
const res = await browserFetch(healthUrl, {
|
|
1054
|
-
signal: AbortSignal.timeout(5000),
|
|
1055
|
-
});
|
|
1056
|
-
const elapsedMs = Date.now() - t0;
|
|
1057
|
-
updateProgressDesc(`#${attempt} ā ${res.status}`);
|
|
1058
|
-
pushLog({ attempt, status: String(res.status), elapsedMs, ok: res.ok, time: Date.now() });
|
|
1059
|
-
if (res.ok) {
|
|
1060
|
-
// Clear logs immediately ā UI jumps straight to Ready, no lingering log
|
|
1061
|
-
await pushUiState({
|
|
1062
|
-
healthCheck: { running: false, timeoutMs: 0, startedAt: null, logs: [] },
|
|
1063
|
-
});
|
|
1064
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
1065
|
-
return true;
|
|
1066
|
-
}
|
|
1067
|
-
} catch (err) {
|
|
1068
|
-
const elapsedMs = Date.now() - t0;
|
|
1069
|
-
const code = err.cause?.code || err.code || err.message;
|
|
1070
|
-
// ENOTFOUND = tunnel DNS chʰa propagate ā ÄĆ¢y lĆ trįŗ”ng thĆ”i "Äang chį»", khĆ“ng phįŗ£i lį»i
|
|
1071
|
-
const isWaiting = code === "ENOTFOUND";
|
|
1072
|
-
const status = isWaiting ? "connecting..." : code;
|
|
1073
|
-
updateProgressDesc(`#${attempt} ā ${status}`);
|
|
1074
|
-
pushLog({ attempt, status, elapsedMs, ok: false, waiting: isWaiting, time: Date.now() });
|
|
1075
|
-
}
|
|
1076
|
-
await new Promise((r) => setTimeout(r, intervalMs));
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
await pushUiState({
|
|
1080
|
-
healthCheck: { running: false, timeoutMs, startedAt: start, logs },
|
|
1081
|
-
});
|
|
1082
|
-
return false;
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
async function isServerRunning() {
|
|
1086
|
-
return !!(await apiGet("/api/health"));
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
/** Spawn background process with --tray flag, open browser, exit current process */
|
|
1090
|
-
async function launchBackground() {
|
|
1091
|
-
const uiUrl = `http://localhost:${SERVER_PORT}`;
|
|
1092
|
-
|
|
1093
|
-
// If an orphan server from a previous run is holding the port, kill it
|
|
1094
|
-
// so our fresh agent spawns a fresh server it actually owns. Skipping
|
|
1095
|
-
// this leaves alreadyRunning=true in startTrayMode ā serverManager
|
|
1096
|
-
// becomes a no-op ā Shutdown can't kill the orphan node.exe.
|
|
1097
|
-
if (await isServerRunning()) {
|
|
1098
|
-
killProcessOnPort(SERVER_PORT);
|
|
1099
|
-
await new Promise(r => setTimeout(r, 500));
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
// Spawn CLI itself with --tray ā tray logic lives in CLI, not server
|
|
1103
|
-
// Bundle: __dirname = dist/, entry = dist/cli.cjs
|
|
1104
|
-
// Dev: __dirname = agent/cli/, entry = agent/cli/index.js
|
|
1105
|
-
const scriptPath = typeof __CLI_VERSION__ !== "undefined"
|
|
1106
|
-
? path.resolve(__dirname, "cli.cjs")
|
|
1107
|
-
: path.resolve(__dirname, "index.js");
|
|
1108
|
-
const bgArgs = [scriptPath, "--tray"];
|
|
1109
|
-
|
|
1110
|
-
const themeArg = process.argv.find(a => a.startsWith("--theme="));
|
|
1111
|
-
if (themeArg) bgArgs.push(themeArg);
|
|
1112
|
-
|
|
1113
|
-
// Redirect child stdout/stderr to log file for debugging crashes
|
|
1114
|
-
const logDir = path.join(os.homedir(), ".9remote");
|
|
1115
|
-
if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
|
|
1116
|
-
const logPath = path.join(logDir, "bg.log");
|
|
1117
|
-
|
|
1118
|
-
// Log startup marker
|
|
1119
|
-
try {
|
|
1120
|
-
fs.appendFileSync(logPath, `\n\n=== ${new Date().toISOString()} spawn bg ===\n`);
|
|
1121
|
-
} catch {}
|
|
1122
|
-
|
|
1123
|
-
let bgPid = null;
|
|
1124
|
-
|
|
1125
|
-
// Unified detached spawn for all platforms.
|
|
1126
|
-
//
|
|
1127
|
-
// Windows specifics:
|
|
1128
|
-
// - node.exe is a console-subsystem binary. With plain `detached: true` Windows
|
|
1129
|
-
// would allocate a new console for the child and flash a black cmd window.
|
|
1130
|
-
// - `windowsHide: true` adds CREATE_NO_WINDOW, which combined with DETACHED_PROCESS
|
|
1131
|
-
// (from `detached: true`) + non-inherit stdio makes the launch fully silent ā
|
|
1132
|
-
// no VBS/wscript wrapper needed.
|
|
1133
|
-
// - stdio MUST be either "ignore" or a file fd (not "inherit") or Windows will
|
|
1134
|
-
// still surface a console window tied to the parent.
|
|
1135
|
-
//
|
|
1136
|
-
// PID tracking:
|
|
1137
|
-
// - We write `agent.pid` here in the PARENT before the child even boots, so the
|
|
1138
|
-
// updater can always find it ā even if the child crashes before `startTrayMode`
|
|
1139
|
-
// gets to call writePid() itself.
|
|
1140
|
-
try {
|
|
1141
|
-
const logFd = fs.openSync(logPath, "a");
|
|
1142
|
-
const bg = spawn(process.execPath, bgArgs, {
|
|
1143
|
-
detached: true,
|
|
1144
|
-
windowsHide: true,
|
|
1145
|
-
stdio: ["ignore", logFd, logFd],
|
|
1146
|
-
env: { ...process.env },
|
|
1147
|
-
});
|
|
1148
|
-
bg.unref();
|
|
1149
|
-
// Close our copy of the fd ā the child has its own handle now.
|
|
1150
|
-
try { fs.closeSync(logFd); } catch {}
|
|
1151
|
-
bgPid = bg.pid;
|
|
1152
|
-
// Track background agent PID so the updater can release dist/cli.cjs lock
|
|
1153
|
-
// without touching other node.exe processes on the machine.
|
|
1154
|
-
if (bg.pid) writePid("agent", bg.pid);
|
|
1155
|
-
} catch (err) {
|
|
1156
|
-
console.log(chalk.red(`\nā Failed to launch background: ${err.message}`));
|
|
1157
|
-
process.exit(1);
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
// Wait and verify server came up; Windows + first-run cloudflared download can
|
|
1161
|
-
// be slow, so give it up to 15s before giving up.
|
|
1162
|
-
const deadline = Date.now() + 15000;
|
|
1163
|
-
let ready = false;
|
|
1164
|
-
while (Date.now() < deadline) {
|
|
1165
|
-
if (await isServerRunning()) { ready = true; break; }
|
|
1166
|
-
await new Promise(r => setTimeout(r, 300));
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
if (!ready) {
|
|
1170
|
-
console.log(chalk.red(`\nā Background server failed to start.`));
|
|
1171
|
-
console.log(chalk.gray(` Check log: ${logPath}\n`));
|
|
1172
|
-
process.exit(1);
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
openBrowser(uiUrl);
|
|
1176
|
-
const pidStr = bgPid ? ` (PID: ${bgPid})` : "";
|
|
1177
|
-
console.log(chalk.green(`\nš 9Remote running at ${uiUrl}${pidStr}`));
|
|
1178
|
-
console.log(chalk.gray(`š” Log: ${logPath}\n`));
|
|
1179
|
-
// Give the detached browser-launcher child a brief moment to actually spawn
|
|
1180
|
-
// before this parent exits ā especially on Windows where cmd/start needs a tick.
|
|
1181
|
-
await new Promise(r => setTimeout(r, 400));
|
|
1182
|
-
process.exit(0);
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
/** Tray mode: start server + system tray, no terminal UI */
|
|
1186
|
-
async function startTrayMode() {
|
|
1187
|
-
// Record own PID ā this process holds dist/cli.cjs open in memory.
|
|
1188
|
-
// Needed so `npm i -g 9remote@latest` can kill us and rename node_modules\9remote.
|
|
1189
|
-
writePid("agent", process.pid);
|
|
1190
|
-
|
|
1191
|
-
let keyData = await ensureKeyData();
|
|
1192
|
-
|
|
1193
|
-
const themeArg = process.argv.find(a => a.startsWith("--theme="));
|
|
1194
|
-
const theme = themeArg ? themeArg.split("=")[1] : null;
|
|
1195
|
-
|
|
1196
|
-
const alreadyRunning = await isServerRunning();
|
|
1197
|
-
const serverManager = alreadyRunning
|
|
1198
|
-
? { getProcess: () => null, shutdown: () => {} }
|
|
1199
|
-
: startServerWithRestart(null, null);
|
|
1200
|
-
|
|
1201
|
-
if (!alreadyRunning) await new Promise(r => setTimeout(r, 2000));
|
|
1202
|
-
|
|
1203
|
-
const uiUrl = `http://localhost:${SERVER_PORT}`;
|
|
1204
|
-
|
|
1205
|
-
let activeTunnel = null;
|
|
1206
|
-
await pushUiState({ permanentKey: keyData.key, step: STEP.STOPPED, theme });
|
|
1207
|
-
|
|
1208
|
-
const cleanup = () => {
|
|
1209
|
-
const tunnel = activeTunnel;
|
|
1210
|
-
activeTunnel = null;
|
|
1211
|
-
// Tray's own onClick handler calls process.exit after this returns,
|
|
1212
|
-
// so don't double-exit here.
|
|
1213
|
-
shutdownAll({ serverManager, tunnelProcess: tunnel, exit: false });
|
|
1214
|
-
};
|
|
1215
|
-
|
|
1216
|
-
setupExitHandler(serverManager, null, keyData.key);
|
|
1217
|
-
setupCmdPoller(() => activeTunnel, (t) => { activeTunnel = t; }, keyData.key);
|
|
1218
|
-
|
|
1219
|
-
if (process.argv.includes("--start")) writeCmd("start-tunnel");
|
|
1220
|
-
|
|
1221
|
-
const tray = await initTray({
|
|
1222
|
-
port: SERVER_PORT,
|
|
1223
|
-
onQuit: cleanup,
|
|
1224
|
-
onOpenUI: () => openBrowser(uiUrl),
|
|
1225
|
-
});
|
|
1226
|
-
|
|
1227
|
-
// Show a one-shot balloon tip so the user knows the agent is running
|
|
1228
|
-
// in the background and where to find it (tray area + local URL).
|
|
1229
|
-
if (tray) {
|
|
1230
|
-
showTrayNotification({
|
|
1231
|
-
title: "9Remote is running",
|
|
1232
|
-
message: `Open ${uiUrl} or use the tray icon to manage.`,
|
|
1233
|
-
});
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
await new Promise(() => {});
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
/** UI mode (9remote ui): start server + open browser, no tray */
|
|
1240
|
-
async function startUiMode() {
|
|
1241
|
-
showBanner(getVersion());
|
|
1242
|
-
let keyData = await ensureKeyData();
|
|
1243
|
-
|
|
1244
|
-
const themeArg = process.argv.find(a => a.startsWith("--theme="));
|
|
1245
|
-
const theme = themeArg ? themeArg.split("=")[1] : null;
|
|
1246
|
-
|
|
1247
|
-
const alreadyRunning = await isServerRunning();
|
|
1248
|
-
const serverManager = alreadyRunning
|
|
1249
|
-
? { getProcess: () => null, shutdown: () => {} }
|
|
1250
|
-
: startServerWithRestart(null, null);
|
|
1251
|
-
|
|
1252
|
-
if (!alreadyRunning) await new Promise(r => setTimeout(r, 2000));
|
|
1253
|
-
|
|
1254
|
-
const uiUrl = `http://localhost:${SERVER_PORT}`;
|
|
1255
|
-
console.log(chalk.green(`\nš UI ready at ${uiUrl}`));
|
|
1256
|
-
|
|
1257
|
-
let activeTunnel = null;
|
|
1258
|
-
await pushUiState({ permanentKey: keyData.key, step: STEP.STOPPED, theme });
|
|
1259
|
-
|
|
1260
|
-
setupExitHandler(serverManager, null, keyData.key);
|
|
1261
|
-
setupCmdPoller(() => activeTunnel, (t) => { activeTunnel = t; }, keyData.key);
|
|
1262
|
-
|
|
1263
|
-
if (process.argv.includes("--start")) writeCmd("start-tunnel");
|
|
1264
|
-
|
|
1265
|
-
await new Promise(() => {});
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
// Start app
|
|
1269
|
-
async function start() {
|
|
1270
|
-
const command = process.argv[2];
|
|
1271
|
-
|
|
1272
|
-
// Kill any existing 9remote instance (agent + cloudflared) before starting
|
|
1273
|
-
// a new one. ptyDaemon is preserved to keep terminal sessions alive.
|
|
1274
|
-
// Skip when re-entering via --tray / --auto (child spawned by launchBackground),
|
|
1275
|
-
// otherwise the detached child would read its own PID from agent.pid and
|
|
1276
|
-
// kill itself right after spawn.
|
|
1277
|
-
const isChildRespawn = process.argv.includes("--tray") || process.argv.includes("--auto");
|
|
1278
|
-
if (!isChildRespawn) {
|
|
1279
|
-
stopRunningInstances();
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
if (command === "ui") {
|
|
1283
|
-
await startUiMode();
|
|
1284
|
-
} else if (command === "start" || process.argv.includes("--auto")) {
|
|
1285
|
-
await autoStartDev();
|
|
1286
|
-
} else if (process.argv.includes("--tray")) {
|
|
1287
|
-
await startTrayMode();
|
|
1288
|
-
} else {
|
|
1289
|
-
await startupMenu();
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
async function startupMenu() {
|
|
1294
|
-
const version = getVersion();
|
|
1295
|
-
const updateInfo = await checkLatestVersion();
|
|
1296
|
-
const banner = getBannerText(version, updateInfo?.latest ?? null);
|
|
1297
|
-
|
|
1298
|
-
const items = [];
|
|
1299
|
-
if (updateInfo?.latest) {
|
|
1300
|
-
items.push({ label: chalk.yellow(`Update to v${updateInfo.latest}`), action: "update" });
|
|
1301
|
-
}
|
|
1302
|
-
items.push(
|
|
1303
|
-
{ label: "Open Web UI (background)", action: "ui" },
|
|
1304
|
-
{ label: "Terminal UI", action: "tui" },
|
|
1305
|
-
{ label: chalk.gray("Exit"), action: "exit" },
|
|
1306
|
-
);
|
|
1307
|
-
|
|
1308
|
-
const idx = await selectMenu("", items, 0, banner);
|
|
1309
|
-
const action = idx >= 0 ? items[idx].action : "exit";
|
|
1310
|
-
|
|
1311
|
-
if (action === "update") {
|
|
1312
|
-
const w = Math.min(44, process.stdout.columns || 44);
|
|
1313
|
-
// Stop running background instances so npm install can overwrite locked files
|
|
1314
|
-
stopRunningInstances();
|
|
1315
|
-
console.log(ORANGE("\n" + "ā".repeat(w)));
|
|
1316
|
-
console.log(chalk.gray(" ā Stopped running instances\n"));
|
|
1317
|
-
console.log(chalk.yellow(" ⬠Run this command to update:\n"));
|
|
1318
|
-
console.log(chalk.white.bold(` npm i -g 9remote@latest\n`));
|
|
1319
|
-
console.log(ORANGE("ā".repeat(w)) + "\n");
|
|
1320
|
-
process.exit(0);
|
|
1321
|
-
} else if (action === "ui") {
|
|
1322
|
-
await launchBackground();
|
|
1323
|
-
} else if (action === "tui") {
|
|
1324
|
-
await tuiMode();
|
|
1325
|
-
} else {
|
|
1326
|
-
process.exit(0);
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
start().catch(console.error);
|