782 lines
25 KiB
JavaScript
Executable file
782 lines
25 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
|
|
const { spawn, exec, execSync } = require("child_process");
|
|
const path = require("path");
|
|
const fs = require("fs");
|
|
const https = require("https");
|
|
const os = require("os");
|
|
|
|
// Native spinner - no external dependency
|
|
function createSpinner(text) {
|
|
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
let i = 0;
|
|
let interval = null;
|
|
let currentText = text;
|
|
return {
|
|
start() {
|
|
if (process.stdout.isTTY) {
|
|
process.stdout.write(`\r${frames[0]} ${currentText}`);
|
|
interval = setInterval(() => {
|
|
process.stdout.write(`\r${frames[i++ % frames.length]} ${currentText}`);
|
|
}, 80);
|
|
}
|
|
return this;
|
|
},
|
|
stop() {
|
|
if (interval) {
|
|
clearInterval(interval);
|
|
interval = null;
|
|
}
|
|
if (process.stdout.isTTY) {
|
|
process.stdout.write("\r\x1b[K");
|
|
}
|
|
},
|
|
succeed(msg) {
|
|
this.stop();
|
|
console.log(`✅ ${msg}`);
|
|
},
|
|
fail(msg) {
|
|
this.stop();
|
|
console.log(`❌ ${msg}`);
|
|
}
|
|
};
|
|
}
|
|
|
|
const pkg = require("./package.json");
|
|
const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime");
|
|
const { ensureTrayRuntime } = require("./hooks/trayRuntime");
|
|
const args = process.argv.slice(2);
|
|
|
|
// Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.9router/runtime
|
|
// so the server can resolve them via NODE_PATH. Best-effort — sql.js is required,
|
|
// better-sqlite3 is optional. Logs to stderr only on failure.
|
|
try { ensureSqliteRuntime({ silent: true }); } catch {}
|
|
|
|
// Self-heal tray runtime (systray for macOS/Linux only). Windows skipped.
|
|
try { ensureTrayRuntime({ silent: true }); } catch {}
|
|
|
|
// Configuration constants
|
|
const APP_NAME = pkg.name; // Use from package.json
|
|
const INSTALL_CMD_LATEST = `npm i -g ${APP_NAME}@latest --prefer-online`;
|
|
|
|
const DEFAULT_PORT = 20128;
|
|
const DEFAULT_HOST = "0.0.0.0";
|
|
const MAX_PORT_ATTEMPTS = 10;
|
|
// Identifiers for killAllAppProcesses - only kill 9router specifically
|
|
const PROCESS_IDENTIFIERS = [
|
|
'9router' // Only package name - avoid killing other apps
|
|
];
|
|
|
|
// Parse arguments
|
|
let port = DEFAULT_PORT;
|
|
let host = DEFAULT_HOST;
|
|
let noBrowser = false;
|
|
let skipUpdate = false;
|
|
let showLog = false;
|
|
let trayMode = false;
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === "--port" || args[i] === "-p") {
|
|
port = parseInt(args[i + 1], 10) || DEFAULT_PORT;
|
|
i++;
|
|
} else if (args[i] === "--host" || args[i] === "-H") {
|
|
host = args[i + 1] || DEFAULT_HOST;
|
|
i++;
|
|
} else if (args[i] === "--no-browser" || args[i] === "-n") {
|
|
noBrowser = true;
|
|
} else if (args[i] === "--log" || args[i] === "-l") {
|
|
showLog = true;
|
|
} else if (args[i] === "--skip-update") {
|
|
skipUpdate = true;
|
|
} else if (args[i] === "--tray" || args[i] === "-t") {
|
|
trayMode = true;
|
|
process.env.TRAY_MODE = "1";
|
|
} else if (args[i] === "--help" || args[i] === "-h") {
|
|
console.log(`
|
|
Usage: ${APP_NAME} [options]
|
|
|
|
Options:
|
|
-p, --port <port> Port to run the server (default: ${DEFAULT_PORT})
|
|
-H, --host <host> Host to bind (default: ${DEFAULT_HOST})
|
|
-n, --no-browser Don't open browser automatically
|
|
-l, --log Show server logs (default: hidden)
|
|
-t, --tray Run in system tray mode (background)
|
|
--skip-update Skip auto-update check
|
|
-h, --help Show this help message
|
|
-v, --version Show version
|
|
`);
|
|
process.exit(0);
|
|
} else if (args[i] === "--version" || args[i] === "-v") {
|
|
console.log(pkg.version);
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
// Auto-relaunch after update: detached process has no TTY → fallback to tray
|
|
if (skipUpdate && !trayMode && !process.stdin.isTTY) {
|
|
trayMode = true;
|
|
process.env.TRAY_MODE = "1";
|
|
}
|
|
|
|
// Always use Node.js runtime with absolute path
|
|
const RUNTIME = process.execPath;
|
|
|
|
// Compare semver versions: returns 1 if a > b, -1 if a < b, 0 if equal
|
|
function compareVersions(a, b) {
|
|
const partsA = a.split(".").map(Number);
|
|
const partsB = b.split(".").map(Number);
|
|
for (let i = 0; i < 3; i++) {
|
|
if (partsA[i] > partsB[i]) return 1;
|
|
if (partsA[i] < partsB[i]) return -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Get app data dir (matches app/src/lib/dataDir.js convention)
|
|
function getAppDataDir() {
|
|
return process.platform === "win32"
|
|
? path.join(process.env.APPDATA || "", "9router")
|
|
: path.join(os.homedir(), ".9router");
|
|
}
|
|
|
|
// Kill PID from file (best-effort, removes file after)
|
|
function killByPidFile(pidFile) {
|
|
try {
|
|
if (!fs.existsSync(pidFile)) return;
|
|
const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
|
|
if (!pid) return;
|
|
try {
|
|
if (process.platform === "win32") {
|
|
execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 });
|
|
} else {
|
|
process.kill(pid, "SIGKILL");
|
|
}
|
|
} catch { }
|
|
try { fs.unlinkSync(pidFile); } catch { }
|
|
} catch { }
|
|
}
|
|
|
|
// Kill tunnel processes (cloudflared/tailscale) by their PID files
|
|
function killTunnelByPidFile() {
|
|
const tunnelDir = path.join(getAppDataDir(), "tunnel");
|
|
killByPidFile(path.join(tunnelDir, "cloudflared.pid"));
|
|
killByPidFile(path.join(tunnelDir, "tailscale.pid"));
|
|
}
|
|
|
|
// Kill cloudflared whose --url targets this app's port (covers stale PID file case)
|
|
function killCloudflaredByAppPort(appPort) {
|
|
if (!appPort) return [];
|
|
const portMatchers = [`localhost:${appPort}`, `127.0.0.1:${appPort}`];
|
|
const pids = [];
|
|
try {
|
|
if (process.platform === "win32") {
|
|
const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"cloudflared.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
|
|
const output = execSync(psCmd, { encoding: "utf8", windowsHide: true, timeout: 5000 });
|
|
const lines = output.split("\n").slice(1).filter(l => l.trim());
|
|
lines.forEach(line => {
|
|
if (portMatchers.some(m => line.includes(m))) {
|
|
const match = line.match(/^"(\d+)"/);
|
|
if (match && match[1]) pids.push(match[1]);
|
|
}
|
|
});
|
|
} else {
|
|
const output = execSync("ps -eo pid,command 2>/dev/null", { encoding: "utf8", timeout: 5000 });
|
|
output.split("\n").forEach(line => {
|
|
if (line.includes("cloudflared") && portMatchers.some(m => line.includes(m))) {
|
|
const parts = line.trim().split(/\s+/);
|
|
const pid = parts[0];
|
|
if (pid && !isNaN(pid)) pids.push(pid);
|
|
}
|
|
});
|
|
}
|
|
} catch { }
|
|
return pids;
|
|
}
|
|
|
|
// Kill all 9router processes
|
|
function killAllAppProcesses(appPort) {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
// Kill MITM first (admin/sudo process, needs special handling)
|
|
killMitmByPidFile();
|
|
// Kill cloudflared/tailscale by PID file (precise, only this app's tunnel)
|
|
killTunnelByPidFile();
|
|
|
|
const platform = process.platform;
|
|
let pids = [];
|
|
|
|
// Catch stale PID files: kill cloudflared bound to this app's port
|
|
pids.push(...killCloudflaredByAppPort(appPort));
|
|
|
|
if (platform === "win32") {
|
|
// Windows: use WMI to get full CommandLine (tasklist /V doesn't include it)
|
|
try {
|
|
const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"node.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
|
|
const output = execSync(psCmd, {
|
|
encoding: "utf8",
|
|
windowsHide: true,
|
|
timeout: 5000
|
|
});
|
|
const lines = output.split("\n").slice(1).filter(l => l.trim());
|
|
lines.forEach(line => {
|
|
const isAppProcess = line.toLowerCase().includes("9router") ||
|
|
line.toLowerCase().includes("next-server");
|
|
if (isAppProcess) {
|
|
const match = line.match(/^"(\d+)"/);
|
|
if (match && match[1] && match[1] !== process.pid.toString()) {
|
|
pids.push(match[1]);
|
|
}
|
|
}
|
|
});
|
|
} catch (e) {
|
|
// No processes found or error - continue
|
|
}
|
|
} else {
|
|
// macOS/Linux: use ps to find all matching processes
|
|
try {
|
|
const output = execSync('ps aux 2>/dev/null', {
|
|
encoding: 'utf8',
|
|
timeout: 5000
|
|
});
|
|
const lines = output.split('\n');
|
|
|
|
lines.forEach(line => {
|
|
const isAppProcess = line.includes("9router") || line.includes("next-server");
|
|
if (isAppProcess) {
|
|
const parts = line.trim().split(/\s+/);
|
|
const pid = parts[1];
|
|
if (pid && !isNaN(pid) && pid !== process.pid.toString()) {
|
|
pids.push(pid);
|
|
}
|
|
}
|
|
});
|
|
} catch (e) {
|
|
// No processes found or error - continue
|
|
}
|
|
}
|
|
|
|
// Kill all found processes
|
|
if (pids.length > 0) {
|
|
pids.forEach(pid => {
|
|
try {
|
|
if (platform === "win32") {
|
|
execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
|
|
} else {
|
|
execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 });
|
|
}
|
|
} catch (err) {
|
|
// Process already dead or can't kill - continue
|
|
}
|
|
});
|
|
|
|
// Wait for processes to fully terminate
|
|
setTimeout(() => resolve(), 1000);
|
|
} else {
|
|
resolve();
|
|
}
|
|
} catch (err) {
|
|
// Silent fail - continue anyway
|
|
resolve();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Sleep helper using SharedArrayBuffer wait (sync, no busy-loop)
|
|
function sleepSync(ms) {
|
|
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ }
|
|
}
|
|
|
|
// Wait until process dies or timeout reached
|
|
function waitForExit(pid, timeoutMs) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
try { process.kill(pid, 0); } catch { return true; }
|
|
sleepSync(100);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Kill MITM server by PID file (MITM runs as admin/sudo, needs special handling)
|
|
// Sends SIGTERM first so MITM can clean up /etc/hosts entries before dying.
|
|
function killMitmByPidFile() {
|
|
try {
|
|
const mitmPidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid");
|
|
if (!fs.existsSync(mitmPidFile)) return;
|
|
const pid = parseInt(fs.readFileSync(mitmPidFile, "utf8").trim(), 10);
|
|
if (!pid) return;
|
|
|
|
if (process.platform === "win32") {
|
|
// Graceful first (lets server cleanup hosts), then force
|
|
try { execSync(`taskkill /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 2000 }); } catch { }
|
|
if (!waitForExit(pid, 1500)) {
|
|
try { execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { }
|
|
}
|
|
// Last-resort: PowerShell Stop-Process (sometimes succeeds where taskkill fails on admin processes)
|
|
if (!waitForExit(pid, 500)) {
|
|
try { execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${pid} -Force"`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { }
|
|
}
|
|
} else {
|
|
// SIGTERM via cached sudo token first
|
|
try { execSync(`sudo -n kill -TERM ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); }
|
|
catch { try { process.kill(pid, "SIGTERM"); } catch { } }
|
|
if (!waitForExit(pid, 1500)) {
|
|
try { execSync(`sudo -n kill -9 ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); }
|
|
catch { try { process.kill(pid, "SIGKILL"); } catch { } }
|
|
}
|
|
}
|
|
try { fs.unlinkSync(mitmPidFile); } catch { }
|
|
} catch { }
|
|
}
|
|
|
|
// Kill any process on specific port
|
|
function killProcessOnPort(port) {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const platform = process.platform;
|
|
let pid;
|
|
|
|
if (platform === "win32") {
|
|
try {
|
|
const output = execSync(`netstat -ano | findstr :${port}`, {
|
|
encoding: 'utf8',
|
|
shell: true,
|
|
windowsHide: true,
|
|
timeout: 5000
|
|
}).trim();
|
|
const lines = output.split('\n').filter(l => l.includes('LISTENING'));
|
|
if (lines.length > 0) {
|
|
pid = lines[0].trim().split(/\s+/).pop();
|
|
execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
|
|
}
|
|
} catch (e) {
|
|
// Port is free or error
|
|
}
|
|
} else {
|
|
// macOS/Linux
|
|
try {
|
|
const pidOutput = execSync(`lsof -ti:${port}`, {
|
|
encoding: 'utf8',
|
|
stdio: ['pipe', 'pipe', 'ignore']
|
|
}).trim();
|
|
if (pidOutput) {
|
|
pid = pidOutput.split('\n')[0];
|
|
execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 });
|
|
}
|
|
} catch (e) {
|
|
// Port is free or error
|
|
}
|
|
}
|
|
|
|
// Wait for port to be released
|
|
setTimeout(() => resolve(), 500);
|
|
} catch (err) {
|
|
// Silent fail - continue anyway
|
|
resolve();
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// Detect if running in restricted environment (Codespaces, Docker)
|
|
function isRestrictedEnvironment() {
|
|
// Check for Codespaces
|
|
if (process.env.CODESPACES === "true" || process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN) {
|
|
return "GitHub Codespaces";
|
|
}
|
|
|
|
// Check for Docker
|
|
if (fs.existsSync("/.dockerenv") || (fs.existsSync("/proc/1/cgroup") && fs.readFileSync("/proc/1/cgroup", "utf8").includes("docker"))) {
|
|
return "Docker";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Check if new version available, return latest version or null
|
|
function checkForUpdate() {
|
|
return new Promise((resolve) => {
|
|
if (skipUpdate) {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
const spinner = createSpinner("Checking for updates...").start();
|
|
let resolved = false;
|
|
|
|
const safetyTimeout = setTimeout(() => {
|
|
if (!resolved) {
|
|
resolved = true;
|
|
spinner.stop();
|
|
resolve(null);
|
|
}
|
|
}, 8000);
|
|
|
|
const done = (version) => {
|
|
if (resolved) return;
|
|
resolved = true;
|
|
clearTimeout(safetyTimeout);
|
|
spinner.stop();
|
|
resolve(version);
|
|
};
|
|
|
|
const req = https.get(`https://registry.npmjs.org/${pkg.name}/latest`, { timeout: 3000 }, (res) => {
|
|
let data = "";
|
|
res.on("data", chunk => data += chunk);
|
|
res.on("end", () => {
|
|
try {
|
|
const latest = JSON.parse(data);
|
|
if (latest.version && compareVersions(latest.version, pkg.version) > 0) {
|
|
done(latest.version);
|
|
} else {
|
|
done(null);
|
|
}
|
|
} catch (e) {
|
|
done(null);
|
|
}
|
|
});
|
|
});
|
|
|
|
req.on("error", () => done(null));
|
|
req.on("timeout", () => { req.destroy(); done(null); });
|
|
});
|
|
}
|
|
|
|
// Open browser
|
|
function openBrowser(url) {
|
|
const platform = process.platform;
|
|
let cmd;
|
|
|
|
if (platform === "darwin") {
|
|
cmd = `open "${url}"`;
|
|
} else if (platform === "win32") {
|
|
cmd = `start "" "${url}"`;
|
|
} else {
|
|
cmd = `xdg-open "${url}"`;
|
|
}
|
|
|
|
exec(cmd, { windowsHide: true }, (err) => {
|
|
if (err) {
|
|
console.log(`Open browser manually: ${url}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Find standalone server (bundled in bin/app for published package)
|
|
const standaloneDir = path.join(__dirname, "app");
|
|
const serverPath = path.join(standaloneDir, "server.js");
|
|
|
|
if (!fs.existsSync(serverPath)) {
|
|
console.error("Error: Standalone build not found.");
|
|
console.error("Please run 'npm run build:cli' first.");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Check for updates FIRST, then start server
|
|
checkForUpdate().then((latestVersion) => {
|
|
killAllAppProcesses(port).then(() => {
|
|
return killProcessOnPort(port);
|
|
}).then(() => {
|
|
startServer(latestVersion);
|
|
});
|
|
});
|
|
|
|
// Show interface selection menu
|
|
async function showInterfaceMenu(latestVersion) {
|
|
const { selectMenu } = require("./src/cli/utils/input");
|
|
const { clearScreen } = require("./src/cli/utils/display");
|
|
const { getEndpoint } = require("./src/cli/utils/endpoint");
|
|
|
|
clearScreen();
|
|
|
|
const displayHost = host === DEFAULT_HOST ? "localhost" : host;
|
|
|
|
// Detect tunnel/local mode for server URL display
|
|
let serverUrl;
|
|
try {
|
|
const { endpoint, tunnelEnabled } = await getEndpoint(port);
|
|
serverUrl = tunnelEnabled ? endpoint.replace(/\/v1$/, "") : `http://${displayHost}:${port}`;
|
|
} catch (e) {
|
|
serverUrl = `http://${displayHost}:${port}`;
|
|
}
|
|
|
|
const subtitle = `🚀 Server: \x1b[32m${serverUrl}\x1b[0m`;
|
|
|
|
const menuItems = [];
|
|
|
|
if (latestVersion) {
|
|
menuItems.push({ label: `Update to v${latestVersion} (current: v${pkg.version})`, icon: "⬆" });
|
|
}
|
|
|
|
menuItems.push(
|
|
{ label: "Web UI (Open in Browser)", icon: "🌐" },
|
|
{ label: "Terminal UI (Interactive CLI)", icon: "💻" },
|
|
{ label: "Hide to Tray (Background)", icon: "🔔" },
|
|
{ label: "Exit", icon: "🚪" }
|
|
);
|
|
|
|
const selected = await selectMenu(`Choose Interface (v${pkg.version})`, menuItems, 0, subtitle);
|
|
|
|
const offset = latestVersion ? 1 : 0;
|
|
|
|
if (latestVersion && selected === 0) return "update";
|
|
if (selected === offset) return "web";
|
|
if (selected === offset + 1) return "terminal";
|
|
if (selected === offset + 2) return "hide";
|
|
return "exit";
|
|
}
|
|
|
|
const MAX_RESTARTS = 2;
|
|
const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
|
|
|
|
function startServer(latestVersion) {
|
|
const displayHost = host === DEFAULT_HOST ? "localhost" : host;
|
|
const url = `http://${displayHost}:${port}/dashboard`;
|
|
|
|
let restartCount = 0;
|
|
let serverStartTime = Date.now();
|
|
|
|
const CRASH_LOG_LINES = 50;
|
|
let crashLog = [];
|
|
|
|
function spawnServer() {
|
|
serverStartTime = Date.now();
|
|
crashLog = [];
|
|
const child = spawn(RUNTIME, ["--max-old-space-size=6144", serverPath], {
|
|
cwd: standaloneDir,
|
|
stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"],
|
|
detached: true,
|
|
windowsHide: true,
|
|
env: {
|
|
...buildEnvWithRuntime(process.env),
|
|
PORT: port.toString(),
|
|
HOSTNAME: host
|
|
}
|
|
});
|
|
if (!showLog && child.stderr) {
|
|
child.stderr.on("data", (data) => {
|
|
const lines = data.toString().split("\n").filter(Boolean);
|
|
crashLog.push(...lines);
|
|
if (crashLog.length > CRASH_LOG_LINES) crashLog = crashLog.slice(-CRASH_LOG_LINES);
|
|
});
|
|
}
|
|
return child;
|
|
}
|
|
|
|
let server = spawnServer();
|
|
|
|
// Cleanup function - force kill server process
|
|
let isCleaningUp = false;
|
|
function cleanup() {
|
|
if (isCleaningUp) return;
|
|
isCleaningUp = true;
|
|
try {
|
|
// Kill tray if running
|
|
try {
|
|
const { killTray } = require("./src/cli/tray/tray");
|
|
killTray();
|
|
} catch (e) { }
|
|
// Kill MITM server (admin/sudo process) via PID file
|
|
killMitmByPidFile();
|
|
// Kill cloudflared/tailscale via PID file (only this app's tunnel)
|
|
killTunnelByPidFile();
|
|
// Kill server process directly
|
|
if (server.pid) {
|
|
process.kill(server.pid, "SIGKILL");
|
|
}
|
|
// Also try to kill process group
|
|
process.kill(-server.pid, "SIGKILL");
|
|
} catch (e) { }
|
|
}
|
|
|
|
// Suppress all errors during shutdown (systray lib throws JSON parse errors)
|
|
let isShuttingDown = false;
|
|
process.on("uncaughtException", (err) => {
|
|
if (isShuttingDown) return;
|
|
console.error("Error:", err.message);
|
|
});
|
|
|
|
// Handle all exit scenarios
|
|
process.on("SIGINT", () => {
|
|
if (isShuttingDown) return;
|
|
isShuttingDown = true;
|
|
console.log("\nExiting...");
|
|
cleanup();
|
|
setTimeout(() => process.exit(0), 100);
|
|
});
|
|
process.on("SIGTERM", () => {
|
|
if (isShuttingDown) return;
|
|
isShuttingDown = true;
|
|
cleanup();
|
|
setTimeout(() => process.exit(0), 100);
|
|
});
|
|
process.on("SIGHUP", () => {
|
|
if (isShuttingDown) return;
|
|
isShuttingDown = true;
|
|
cleanup();
|
|
setTimeout(() => process.exit(0), 100);
|
|
});
|
|
|
|
// Initialize tray icon (runs alongside TUI)
|
|
const initTrayIcon = () => {
|
|
try {
|
|
const { initTray } = require("./src/cli/tray/tray");
|
|
initTray({
|
|
port,
|
|
onQuit: () => {
|
|
isShuttingDown = true;
|
|
console.log("\n👋 Shutting down from tray...");
|
|
cleanup();
|
|
setTimeout(() => process.exit(0), 100);
|
|
},
|
|
onOpenDashboard: () => openBrowser(url)
|
|
});
|
|
} catch (err) {
|
|
// Tray not available - continue without it
|
|
}
|
|
};
|
|
|
|
// Tray-only mode: no TUI, just tray icon
|
|
if (trayMode) {
|
|
console.log(`\n🚀 ${pkg.name} v${pkg.version}`);
|
|
console.log(`Server: http://${displayHost}:${port}`);
|
|
|
|
setTimeout(() => {
|
|
initTrayIcon();
|
|
console.log("\n💡 Router is now running in system tray. Close this terminal if you want.");
|
|
console.log(" Right-click tray icon to open dashboard or quit.\n");
|
|
}, 2000);
|
|
|
|
return;
|
|
}
|
|
|
|
// Wait for server to be ready, then show interface menu loop + tray
|
|
setTimeout(async () => {
|
|
// Start tray icon alongside TUI
|
|
initTrayIcon();
|
|
|
|
try {
|
|
while (true) {
|
|
const choice = await showInterfaceMenu(latestVersion);
|
|
|
|
if (choice === "update") {
|
|
isShuttingDown = true;
|
|
const { clearScreen } = require("./src/cli/utils/display");
|
|
clearScreen();
|
|
console.log(`\n⬆ Update v${pkg.version} → v${latestVersion}\n`);
|
|
console.log(`Run this after exit:\n`);
|
|
console.log(` \x1b[33m${INSTALL_CMD_LATEST}\x1b[0m\n`);
|
|
cleanup();
|
|
await killAllAppProcesses(port);
|
|
await killProcessOnPort(port);
|
|
setTimeout(() => process.exit(0), 200);
|
|
return;
|
|
} else if (choice === "web") {
|
|
openBrowser(url);
|
|
// Wait for user to come back
|
|
const { pause } = require("./src/cli/utils/input");
|
|
await pause("\nPress Enter to go back to menu...");
|
|
} else if (choice === "terminal") {
|
|
// Start Terminal UI - it will return when user selects Back
|
|
const { startTerminalUI } = require("./src/cli/terminalUI");
|
|
await startTerminalUI(port);
|
|
// Loop continues, show menu again
|
|
} else if (choice === "hide") {
|
|
// Hide to tray - spawn detached background process
|
|
const { clearScreen } = require("./src/cli/utils/display");
|
|
clearScreen();
|
|
|
|
// Enable auto startup on OS boot
|
|
try {
|
|
const { enableAutoStart } = require("./src/cli/tray/autostart");
|
|
const enabled = enableAutoStart(__filename);
|
|
if (enabled) {
|
|
console.log("✅ Auto-start enabled (will run on OS boot)");
|
|
}
|
|
} catch (e) { }
|
|
|
|
// Spawn new detached process with --tray flag
|
|
const bgProcess = spawn(process.execPath, [__filename, "--tray", "--skip-update", "-p", port.toString()], {
|
|
detached: true,
|
|
stdio: "ignore",
|
|
windowsHide: true,
|
|
env: { ...process.env }
|
|
});
|
|
bgProcess.unref();
|
|
|
|
console.log(`\n🔔 9Router is now running in background (PID: ${bgProcess.pid})`);
|
|
console.log(` Server: http://${displayHost}:${port}`);
|
|
console.log(`\n💡 You can close this terminal. Right-click tray icon to:`);
|
|
console.log(` • Open Dashboard`);
|
|
console.log(` • Quit\n`);
|
|
|
|
// Exit current process - background process takes over
|
|
cleanup();
|
|
process.exit(0);
|
|
} else if (choice === "exit") {
|
|
isShuttingDown = true;
|
|
console.log("\nExiting...");
|
|
cleanup();
|
|
setTimeout(() => process.exit(0), 100);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("Error:", err.message);
|
|
cleanup();
|
|
process.exit(1);
|
|
}
|
|
}, 3000);
|
|
|
|
function attachServerEvents() {
|
|
server.on("error", (err) => {
|
|
console.error("Failed to start server:", err.message);
|
|
if (!isShuttingDown) tryRestart();
|
|
else { cleanup(); process.exit(1); }
|
|
});
|
|
|
|
server.on("close", (code) => {
|
|
if (isShuttingDown || code === 0) {
|
|
process.exit(code || 0);
|
|
return;
|
|
}
|
|
tryRestart(code);
|
|
});
|
|
}
|
|
|
|
function tryRestart(code) {
|
|
const aliveMs = Date.now() - serverStartTime;
|
|
// Reset counter if last run was stable
|
|
if (aliveMs >= RESTART_RESET_MS) restartCount = 0;
|
|
|
|
if (restartCount >= MAX_RESTARTS) {
|
|
console.error(`\n⚠️ Server crashed ${MAX_RESTARTS} times. Disabling MITM and restarting...`);
|
|
try {
|
|
const dbPath = path.join(os.homedir(), process.platform === "win32" ? path.join("AppData", "Roaming", "9router", "db.json") : path.join(".9router", "db.json"));
|
|
if (fs.existsSync(dbPath)) {
|
|
const db = JSON.parse(fs.readFileSync(dbPath, "utf-8"));
|
|
if (db.settings) db.settings.mitmEnabled = false;
|
|
fs.writeFileSync(dbPath, JSON.stringify(db, null, 2));
|
|
}
|
|
} catch { /* best effort */ }
|
|
restartCount = 0;
|
|
server = spawnServer();
|
|
attachServerEvents();
|
|
return;
|
|
}
|
|
|
|
restartCount++;
|
|
const delay = Math.min(1000 * restartCount, 10000);
|
|
console.error(`\n⚠️ Server exited (code=${code ?? "unknown"}). Restarting in ${delay / 1000}s... (${restartCount}/${MAX_RESTARTS})`);
|
|
if (crashLog.length) {
|
|
console.error("\n--- Server crash log ---");
|
|
crashLog.forEach(l => console.error(l));
|
|
console.error("--- End crash log ---\n");
|
|
}
|
|
|
|
setTimeout(() => {
|
|
server = spawnServer();
|
|
attachServerEvents();
|
|
}, delay);
|
|
}
|
|
|
|
attachServerEvents();
|
|
}
|