feat: remove check version update feature

This commit is contained in:
2026-07-13 16:32:20 +07:00
parent 275f20e2b8
commit b4ffea7deb
19 changed files with 26 additions and 995 deletions
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { UPDATER_CONFIG } from "@/shared/constants/config";
import { APP_CONFIG } from "@/shared/constants/config";
const STORAGE_KEY = "9router.cliToolEndpointPresets";
const CUSTOM_VALUE = "__custom__";
@@ -33,7 +33,7 @@ const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tai
const opts = [];
const wrap = (url) => (withV1 ? ensureV1(url) : (url || "").replace(/\/+$/, ""));
if (!requiresExternalUrl) {
const localUrl = wrap(`http://127.0.0.1:${UPDATER_CONFIG.appPort}`);
const localUrl = wrap(`http://127.0.0.1:${APP_CONFIG.defaultPort}`);
opts.push({ value: "local", label: localUrl, url: localUrl });
}
if (tunnelEnabled && tunnelPublicUrl) {
+1 -34
View File
@@ -2,7 +2,7 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, Toggle, Input } from "@/shared/components";
import Modal, { ConfirmModal } from "@/shared/components/Modal";
import Modal from "@/shared/components/Modal";
import LanguageSwitcher from "@/shared/components/LanguageSwitcher";
import { useTheme } from "@/shared/hooks/useTheme";
import { cn } from "@/shared/utils/cn";
@@ -29,8 +29,6 @@ export default function ProfilePage() {
const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser);
const [locale, setLocale] = useState("en");
const [langOpen, setLangOpen] = useState(false);
const [shutdownOpen, setShutdownOpen] = useState(false);
const [isShuttingDown, setIsShuttingDown] = useState(false);
const [settings, setSettings] = useState({ fallbackStrategy: "fill-first" });
const [loading, setLoading] = useState(true);
const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" });
@@ -257,17 +255,6 @@ export default function ProfilePage() {
else if (mode === "import") await runImportDatabase(password);
};
const handleShutdown = async () => {
setIsShuttingDown(true);
try {
await fetch("/api/version/shutdown", { method: "POST" });
} catch (e) {
// Expected to fail as server shuts down; ignore error
}
setIsShuttingDown(false);
setShutdownOpen(false);
};
const handleLogout = async () => {
try {
const res = await fetch("/api/auth/logout", { method: "POST" });
@@ -492,15 +479,6 @@ export default function ProfilePage() {
{/* Account actions */}
<div className="flex flex-col sm:flex-row gap-2">
<Button
variant="outline"
fullWidth
icon="power_settings_new"
onClick={() => setShutdownOpen(true)}
className="text-red-500 border-red-200 hover:bg-red-50 hover:border-red-300"
>
Shutdown
</Button>
<Button
variant="outline"
fullWidth
@@ -526,17 +504,6 @@ export default function ProfilePage() {
setLocale(next);
}}
/>
<ConfirmModal
isOpen={shutdownOpen}
onClose={() => setShutdownOpen(false)}
onConfirm={handleShutdown}
title="Close Proxy"
message="Are you sure you want to close the proxy server?"
confirmText="Close"
cancelText="Cancel"
variant="danger"
loading={isShuttingDown}
/>
<Modal
isOpen={dbAuth.open}
+2 -2
View File
@@ -1,5 +1,5 @@
import { getApiKeys } from "@/lib/localDb";
import { UPDATER_CONFIG } from "@/shared/constants/config";
import { APP_CONFIG } from "@/shared/constants/config";
import { getConsistentMachineId } from "@/shared/utils/machineId";
const CLI_TOKEN_SALT = "9r-cli-auth";
@@ -50,7 +50,7 @@ async function getInternalHeaders() {
return headers;
}
export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:${process.env.PORT || UPDATER_CONFIG.appPort}`) {
export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:${process.env.PORT || APP_CONFIG.defaultPort}`) {
const headers = await getInternalHeaders();
const start = Date.now();
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { getProviderConnectionById } from "@/lib/localDb";
import { getProviderModels, PROVIDER_ID_TO_ALIAS } from "open-sse/config/providerModels.js";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { UPDATER_CONFIG } from "@/shared/constants/config";
import { APP_CONFIG } from "@/shared/constants/config";
import { pingModelByKind } from "@/app/api/models/test/ping";
import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
@@ -26,7 +26,7 @@ export async function POST(request, { params }) {
let models = getProviderModels(alias);
const baseUrl = `http://127.0.0.1:${process.env.PORT || UPDATER_CONFIG.appPort}`;
const baseUrl = `http://127.0.0.1:${process.env.PORT || APP_CONFIG.defaultPort}`;
// Compatible providers: fetch live model list
if (isCompatible && models.length === 0) {
-61
View File
@@ -1,61 +0,0 @@
import https from "https";
import pkg from "../../../../package.json" with { type: "json" };
const NPM_PACKAGE_NAME = "9router";
const VERSION_CACHE_TTL_MS = 3600000; // cache npm latest lookup for 1h
// Survive hot reload; one cache per process
const versionCache = (global.__npmVersionCache ??= { value: null, fetchedAt: 0 });
// Fetch latest version from npm registry
function fetchLatestVersion() {
return new Promise((resolve) => {
const req = https.get(
`https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`,
{ timeout: 4000 },
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
try {
resolve(JSON.parse(data).version || null);
} catch {
resolve(null);
}
});
}
);
req.on("error", () => resolve(null));
req.on("timeout", () => { req.destroy(); resolve(null); });
});
}
function compareVersions(a, b) {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if (pa[i] > pb[i]) return 1;
if (pa[i] < pb[i]) return -1;
}
return 0;
}
async function getLatestVersionCached() {
if (versionCache.value && Date.now() - versionCache.fetchedAt < VERSION_CACHE_TTL_MS) {
return versionCache.value;
}
const latest = await fetchLatestVersion();
if (latest) {
versionCache.value = latest;
versionCache.fetchedAt = Date.now();
}
return latest;
}
export async function GET() {
const latestVersion = await getLatestVersionCached();
const currentVersion = pkg.version;
const hasUpdate = latestVersion ? compareVersions(latestVersion, currentVersion) > 0 : false;
return Response.json({ currentVersion, latestVersion, hasUpdate });
}
-15
View File
@@ -1,15 +0,0 @@
import { NextResponse } from "next/server";
import { killAppProcesses } from "@/lib/appUpdater";
// Shutdown app to release file locks for manual update
export async function POST() {
try {
await killAppProcesses();
} catch { /* best effort */ }
const response = NextResponse.json({ success: true, message: "Shutting down for manual update..." });
setTimeout(() => process.exit(0), 500);
return response;
}
-21
View File
@@ -1,21 +0,0 @@
import { NextResponse } from "next/server";
import { killAppProcesses, spawnUpdaterAndExit } from "@/lib/appUpdater";
export async function POST() {
if (process.env.NODE_ENV !== "production") {
return NextResponse.json(
{ success: false, message: "Update is only available in production build (9router CLI)" },
{ status: 403 }
);
}
try {
// Kill sibling processes (cloudflared, MITM, stray next-server) to release file locks on Windows
await killAppProcesses();
} catch { /* best effort */ }
// Schedule detached updater then exit current server process
spawnUpdaterAndExit();
return NextResponse.json({ success: true, message: "Updater started. This app will exit shortly." });
}
-3
View File
@@ -27,7 +27,6 @@ const PUBLIC_API_PATHS = [
"/api/auth/logout",
"/api/auth/status",
"/api/auth/oidc",
"/api/version",
];
// Public top-level prefixes (LLM API endpoints with their own API key auth).
@@ -37,8 +36,6 @@ const PUBLIC_PREFIXES = ["/v1", "/v1beta", "/api/v1", "/api/v1beta", "/codex"];
const ALWAYS_PROTECTED = [
"/api/shutdown",
"/api/settings/database",
"/api/version/shutdown",
"/api/version/update",
"/api/oauth/cursor/auto-import",
"/api/oauth/kiro/auto-import",
];
-200
View File
@@ -1,200 +0,0 @@
import { spawn, execSync } from "child_process";
import path from "path";
import fs from "fs";
import os from "os";
import { UPDATER_CONFIG } from "@/shared/constants/config";
const KILL_TIMEOUT_MS = 5000;
const PROCESS_WAIT_MS = 1500;
// Kill MITM server by PID file (MITM may run as admin/sudo)
function killMitmByPidFile() {
try {
const mitmPidFile = path.join(
process.platform === "win32"
? path.join(process.env.APPDATA || "", "9router")
: path.join(os.homedir(), ".9router"),
"mitm",
".mitm.pid"
);
if (!fs.existsSync(mitmPidFile)) return;
const pid = parseInt(fs.readFileSync(mitmPidFile, "utf8").trim(), 10);
if (!pid) return;
if (process.platform === "win32") {
// taskkill first (works if same user); fallback to PowerShell Stop-Process which can kill admin process if our token allows
try { execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch {
try { execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${pid} -Force"`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { /* best effort */ }
}
} else {
try {
execSync(`sudo -n kill -9 ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 3000 });
} catch {
try { process.kill(pid, "SIGKILL"); } catch { /* best effort */ }
}
}
try { fs.unlinkSync(mitmPidFile); } catch { /* best effort */ }
} catch { /* best effort */ }
}
// Collect PIDs of all 9router-related processes (excluding current)
function collectAppPids() {
const pids = [];
const platform = process.platform;
if (platform === "win32") {
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: KILL_TIMEOUT_MS });
const lines = output.split("\n").slice(1).filter(l => l.trim());
lines.forEach(line => {
const lower = line.toLowerCase();
// Match anything running from 9router install dir or wrapper cli.js
const isAppProcess = lower.includes("9router") ||
lower.includes("next-server") ||
lower.includes("\\bin\\app\\") ||
lower.includes("/bin/app/") ||
lower.includes("cli.js");
if (isAppProcess) {
const match = line.match(/^"(\d+)"/);
if (match && match[1] && match[1] !== process.pid.toString()) pids.push(match[1]);
}
});
} catch { /* no processes */ }
// Kill cloudflared + tray binaries (hold app dir lock)
for (const procName of ["cloudflared", "tray_windows_release"]) {
try {
const cmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-Process ${procName} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id"`;
const out = execSync(cmd, { encoding: "utf8", windowsHide: true, timeout: KILL_TIMEOUT_MS });
out.split("\n").forEach(l => {
const pid = l.trim();
if (pid && !isNaN(pid)) pids.push(pid);
});
} catch { /* not running */ }
}
} else {
try {
const output = execSync("ps aux 2>/dev/null", { encoding: "utf8", timeout: KILL_TIMEOUT_MS });
output.split("\n").forEach(line => {
const isAppProcess = line.includes("9router") ||
line.includes("next-server") ||
line.includes("cloudflared") ||
line.includes("/bin/app/") ||
line.includes("tray_darwin") ||
line.includes("tray_linux");
if (isAppProcess) {
const parts = line.trim().split(/\s+/);
const pid = parts[1];
if (pid && !isNaN(pid) && pid !== process.pid.toString()) pids.push(pid);
}
});
} catch { /* no processes */ }
}
return pids;
}
// Copy updater.js into DATA_DIR so npm -g can overwrite node_modules safely
function getDataDir() {
if (process.env.DATA_DIR) return process.env.DATA_DIR;
if (process.platform === "win32") {
return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "9router");
}
return path.join(os.homedir(), ".9router");
}
function resolveBundledUpdaterPath() {
if (process.env.UPDATER_SCRIPT_PATH && fs.existsSync(process.env.UPDATER_SCRIPT_PATH)) {
return process.env.UPDATER_SCRIPT_PATH;
}
// Production standalone: cwd is binAppDir (see bin/cli.js)
// Dev: cwd is app/
const fromCwd = path.join(process.cwd(), "src", "lib", "updater", "updater.js");
if (fs.existsSync(fromCwd)) return fromCwd;
const fromParent = path.join(process.cwd(), "..", "src", "lib", "updater", "updater.js");
if (fs.existsSync(fromParent)) return fromParent;
return fromCwd;
}
function ensureRuntimeUpdater(bundledPath) {
try {
if (!bundledPath || !fs.existsSync(bundledPath)) return bundledPath;
const runtimeDir = path.join(getDataDir(), "runtime", "updater");
const runtimePath = path.join(runtimeDir, "updater.js");
if (fs.existsSync(runtimePath)) {
try {
if (fs.statSync(bundledPath).size === fs.statSync(runtimePath).size) return runtimePath;
} catch { /* recopy */ }
}
fs.mkdirSync(runtimeDir, { recursive: true });
fs.copyFileSync(bundledPath, runtimePath);
return runtimePath;
} catch {
return bundledPath;
}
}
// Kill all app-related processes to release file locks (esp. on Windows)
export async function killAppProcesses() {
killMitmByPidFile();
const pids = collectAppPids();
const platform = process.platform;
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 { /* already dead */ }
});
if (pids.length > 0) {
await new Promise(r => setTimeout(r, PROCESS_WAIT_MS));
}
}
// Resolve npx/9router binary to relaunch after update (cross-platform)
function resolveRelaunchCommand() {
const isWin = process.platform === "win32";
// Prefer `npx 9router` — works regardless of global bin path changes after npm i -g
const npx = isWin ? "npx.cmd" : "npx";
return { cmd: npx, args: [UPDATER_CONFIG.npmPackageName] };
}
// Spawn detached headless updater (Node process) then exit current server
export function spawnUpdaterAndExit(packageName = UPDATER_CONFIG.npmPackageName) {
const updaterPath = ensureRuntimeUpdater(resolveBundledUpdaterPath());
const isTray = process.env.TRAY_MODE === "1";
const relaunch = resolveRelaunchCommand();
// Relaunch matching original env: tray stays tray, foreground stays foreground
const relaunchArgs = isTray
? [...relaunch.args, "--tray", "--skip-update"]
: [...relaunch.args, "--skip-update"];
spawn(process.execPath, [updaterPath], {
detached: true,
stdio: "ignore",
windowsHide: true,
env: {
...process.env,
UPDATER_PKG_NAME: packageName,
UPDATER_PORT: String(UPDATER_CONFIG.statusPort),
UPDATER_TAIL_LINES: String(UPDATER_CONFIG.statusLogTailLines),
UPDATER_RETRIES: String(UPDATER_CONFIG.installRetries),
UPDATER_RETRY_DELAY_MS: String(UPDATER_CONFIG.installRetryDelayMs),
UPDATER_LINGER_MS: String(UPDATER_CONFIG.lingerAfterDoneMs),
UPDATER_WAIT_MIN_MS: String(UPDATER_CONFIG.waitForExitMinMs),
UPDATER_WAIT_MAX_MS: String(UPDATER_CONFIG.waitForExitMaxMs),
UPDATER_WAIT_CHECK_MS: String(UPDATER_CONFIG.waitForExitCheckMs),
UPDATER_APP_PORT: String(UPDATER_CONFIG.appPort),
UPDATER_RELAUNCH: "1",
UPDATER_RELAUNCH_CMD: relaunch.cmd,
UPDATER_RELAUNCH_ARGS: JSON.stringify(relaunchArgs),
},
}).unref();
setTimeout(() => process.exit(0), UPDATER_CONFIG.exitDelayMs);
}
-235
View File
@@ -1,235 +0,0 @@
// Standalone detached updater process.
// Spawns `npm i -g <pkg>@latest`, exposes progress via tiny HTTP server.
// Survives after parent Next server exits (detached + unref by spawner).
const { spawn } = require("child_process");
const http = require("http");
const net = require("net");
const path = require("path");
const fs = require("fs");
const os = require("os");
const packageName = process.env.UPDATER_PKG_NAME || "9router";
const port = parseInt(process.env.UPDATER_PORT || "20129", 10);
const tailLines = parseInt(process.env.UPDATER_TAIL_LINES || "8", 10);
const maxRetries = parseInt(process.env.UPDATER_RETRIES || "3", 10);
const retryDelayMs = parseInt(process.env.UPDATER_RETRY_DELAY_MS || "5000", 10);
const lingerMs = parseInt(process.env.UPDATER_LINGER_MS || "30000", 10);
const waitMinMs = parseInt(process.env.UPDATER_WAIT_MIN_MS || "3000", 10);
const waitMaxMs = parseInt(process.env.UPDATER_WAIT_MAX_MS || "15000", 10);
const waitCheckMs = parseInt(process.env.UPDATER_WAIT_CHECK_MS || "500", 10);
const appPort = parseInt(process.env.UPDATER_APP_PORT || "20128", 10);
// Data directory (match mitm/paths.js logic)
function getDataDir() {
if (process.env.DATA_DIR) return process.env.DATA_DIR;
if (process.platform === "win32") {
return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "9router");
}
return path.join(os.homedir(), ".9router");
}
const updateDir = path.join(getDataDir(), "update");
try { fs.mkdirSync(updateDir, { recursive: true }); } catch { /* best effort */ }
const statusFile = path.join(updateDir, "status.json");
const logFile = path.join(updateDir, "install.log");
const state = {
phase: "starting",
packageName,
startedAt: Date.now(),
finishedAt: null,
attempt: 0,
maxRetries,
done: false,
success: false,
exitCode: null,
error: null,
logTail: [],
};
function pushLog(line) {
const trimmed = line.replace(/\r?\n$/, "");
if (!trimmed) return;
state.logTail.push(trimmed);
if (state.logTail.length > tailLines) state.logTail = state.logTail.slice(-tailLines);
try { fs.appendFileSync(logFile, `${trimmed}\n`); } catch { /* best effort */ }
}
function persistStatus() {
try { fs.writeFileSync(statusFile, JSON.stringify(state, null, 2)); } catch { /* best effort */ }
}
function setPhase(phase) {
state.phase = phase;
persistStatus();
}
// HTTP server exposing status (browser polls this while Next server is dead)
const server = http.createServer((req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Cache-Control", "no-store");
if (req.url === "/update/status" || req.url === "/") {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(state));
return;
}
res.statusCode = 404;
res.end("not found");
});
server.on("error", (e) => {
state.error = `status server error: ${e.message}`;
persistStatus();
});
server.listen(port, "127.0.0.1", () => {
persistStatus();
waitForAppExit().then(runInstall);
});
// Check if app port is still being listened on (= app server still alive)
function isAppPortBusy() {
return new Promise((resolve) => {
const socket = new net.Socket();
const done = (busy) => {
socket.destroy();
resolve(busy);
};
socket.setTimeout(300);
socket.once("connect", () => done(true));
socket.once("timeout", () => done(false));
socket.once("error", () => done(false));
socket.connect(appPort, "127.0.0.1");
});
}
// Wait for app process to fully exit before running npm (avoids Windows file-lock)
async function waitForAppExit() {
setPhase("waitingForExit");
pushLog(`[updater] waiting for app to exit (min ${Math.round(waitMinMs / 1000)}s)...`);
// Hard minimum delay: OS needs time to release file handles
await sleep(waitMinMs);
// Poll app port until free or max timeout
const deadline = Date.now() + (waitMaxMs - waitMinMs);
while (Date.now() < deadline) {
const busy = await isAppPortBusy();
if (!busy) {
pushLog(`[updater] app port :${appPort} is free, proceeding`);
return;
}
await sleep(waitCheckMs);
}
pushLog(`[updater] timeout waiting for app, proceeding anyway`);
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function runInstall() {
state.attempt += 1;
setPhase("installing");
pushLog(`[updater] attempt ${state.attempt}/${maxRetries} — npm i -g ${packageName} --prefer-online`);
const isWin = process.platform === "win32";
const cmd = isWin ? "npm.cmd" : "npm";
const args = ["i", "-g", packageName, "--prefer-online"];
const child = spawn(cmd, args, {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
shell: isWin,
});
child.stdout.on("data", (buf) => {
buf.toString().split(/\r?\n/).forEach(pushLog);
persistStatus();
});
child.stderr.on("data", (buf) => {
buf.toString().split(/\r?\n/).forEach(pushLog);
persistStatus();
});
child.on("error", (e) => {
pushLog(`[updater] spawn error: ${e.message}`);
finalize(false, null, e.message);
});
child.on("close", (code) => {
pushLog(`[updater] npm exited with code ${code}`);
if (code === 0) {
finalize(true, code, null);
return;
}
if (state.attempt < maxRetries) {
pushLog(`[updater] retrying in ${Math.round(retryDelayMs / 1000)}s...`);
setTimeout(runInstall, retryDelayMs);
return;
}
finalize(false, code, `Install failed after ${maxRetries} attempts`);
});
}
function openBrowser(url) {
const platform = process.platform;
const cmd = platform === "darwin" ? `open "${url}"`
: platform === "win32" ? `start "" "${url}"`
: `xdg-open "${url}"`;
try { spawn(cmd, { shell: true, detached: true, stdio: "ignore" }).unref(); } catch { /* ignore */ }
}
// Wait until app port is listening (server alive again), then open dashboard
async function waitForAppAndOpenBrowser() {
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
const busy = await isAppPortBusy();
if (busy) {
openBrowser(`http://localhost:${appPort}/dashboard`);
pushLog(`[updater] app ready, opened dashboard`);
return;
}
await sleep(1000);
}
pushLog(`[updater] app not responding within 30s, skip browser open`);
}
function relaunchApp() {
if (process.env.UPDATER_RELAUNCH !== "1") return;
const cmd = process.env.UPDATER_RELAUNCH_CMD;
if (!cmd) return;
let args = [];
try { args = JSON.parse(process.env.UPDATER_RELAUNCH_ARGS || "[]"); } catch { /* noop */ }
const isWin = process.platform === "win32";
try {
const child = spawn(cmd, args, {
detached: true,
stdio: "ignore",
windowsHide: true,
shell: isWin,
env: { ...process.env, UPDATER_RELAUNCH: "", UPDATER_RELAUNCH_CMD: "", UPDATER_RELAUNCH_ARGS: "" },
});
child.unref();
pushLog(`[updater] relaunched: ${cmd} ${args.join(" ")} (pid=${child.pid})`);
// Wait for new app to come up, then auto-open browser so user sees the result
waitForAppAndOpenBrowser();
} catch (e) {
pushLog(`[updater] relaunch failed: ${e.message}`);
}
}
function finalize(success, exitCode, error) {
state.done = true;
state.success = success;
state.exitCode = exitCode;
state.error = error;
state.finishedAt = Date.now();
setPhase(success ? "done" : "error");
if (success) relaunchApp();
// Linger so browser can poll final status, then exit & close the port
setTimeout(() => {
try { server.close(); } catch { /* ignore */ }
process.exit(success ? 0 : 1);
}, lingerMs);
}
+5 -36
View File
@@ -13,7 +13,7 @@ const IS_MAC = process.platform === "darwin";
const { generateCert } = require("./cert/generate");
const { installCert, uninstallCert } = require("./cert/install");
const { isCertExpired } = require("./cert/rootCA");
const { DATA_DIR, MITM_DIR } = require("./paths");
const { MITM_DIR } = require("./paths");
const { log, err } = require("./logger");
const { LSOF_BIN } = require("./config");
@@ -62,38 +62,7 @@ function resolveBundledServerPath() {
return fromCwd;
}
// Copy bundled server.js into DATA_DIR so MITM doesn't lock node_modules
// (prevents EBUSY on `npm i -g 9router@latest` while MITM is running).
function ensureRuntimeServer(bundledPath) {
try {
if (!bundledPath || !fs.existsSync(bundledPath)) return bundledPath;
// Dev mode: source file has relative requires (./logger, ./config...),
// only the bundled file inside node_modules is self-contained + safe to copy.
if (!bundledPath.includes(`${path.sep}node_modules${path.sep}`)) {
return bundledPath;
}
const runtimeDir = path.join(DATA_DIR, "runtime", "mitm");
const runtimeServer = path.join(runtimeDir, "server.js");
// Skip copy if sizes match (bundle unchanged since last run)
if (fs.existsSync(runtimeServer)) {
try {
if (fs.statSync(bundledPath).size === fs.statSync(runtimeServer).size) return runtimeServer;
} catch { /* recopy */ }
}
fs.mkdirSync(runtimeDir, { recursive: true });
fs.copyFileSync(bundledPath, runtimeServer);
return runtimeServer;
} catch (e) {
try { log(`[MITM] runtime copy failed: ${e.message}`); } catch { /* ignore */ }
return bundledPath;
}
}
const SERVER_PATH = ensureRuntimeServer(resolveBundledServerPath());
const SERVER_PATH = resolveBundledServerPath();
const ENCRYPT_ALGO = "aes-256-gcm";
const ENCRYPT_SALT = "9router-mitm-pwd";
@@ -572,11 +541,11 @@ async function startServer(apiKey, sudoPassword, forceKillPort443 = false) {
}
// Step 2: Spawn server (Root CA already installed in Step 1.5)
// Verify server.js exists — recopy if runtime file was deleted (antivirus/cleanup)
// Verify the bundled server is present before spawning it.
let effectiveServerPath = SERVER_PATH;
if (!effectiveServerPath || !fs.existsSync(effectiveServerPath)) {
log(`[MITM] server.js missing at ${effectiveServerPath} → recopying`);
effectiveServerPath = ensureRuntimeServer(resolveBundledServerPath());
log(`[MITM] server.js missing at ${effectiveServerPath} → resolving again`);
effectiveServerPath = resolveBundledServerPath();
if (!effectiveServerPath || !fs.existsSync(effectiveServerPath)) {
throw new Error(`MITM server.js not found at ${effectiveServerPath}. Reinstall 9router.`);
}
-31
View File
@@ -4,7 +4,6 @@ import { useState, useEffect, useRef } from "react";
import PropTypes from "prop-types";
import { useTheme } from "@/shared/hooks/useTheme";
import ChangelogModal from "./ChangelogModal";
import { ConfirmModal } from "./Modal";
function MenuItem({ icon, label, onClick, trailing, danger }) {
return (
@@ -36,22 +35,9 @@ MenuItem.propTypes = {
export default function HeaderMenu({ onLogout }) {
const [isOpen, setIsOpen] = useState(false);
const [changelogOpen, setChangelogOpen] = useState(false);
const [shutdownOpen, setShutdownOpen] = useState(false);
const [isShuttingDown, setIsShuttingDown] = useState(false);
const { toggleTheme, isDark } = useTheme();
const menuRef = useRef(null);
const handleShutdown = async () => {
setIsShuttingDown(true);
try {
await fetch("/api/version/shutdown", { method: "POST" });
} catch (e) {
// Expected to fail as server shuts down; ignore error
}
setIsShuttingDown(false);
setShutdownOpen(false);
};
useEffect(() => {
const handleClickOutside = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) {
@@ -89,12 +75,6 @@ export default function HeaderMenu({ onLogout }) {
label="Theme"
onClick={() => { toggleTheme(); close(); }}
/>
<MenuItem
icon="power_settings_new"
label="Shutdown"
danger
onClick={() => { close(); setShutdownOpen(true); }}
/>
<MenuItem
icon="logout"
label="Logout"
@@ -106,17 +86,6 @@ export default function HeaderMenu({ onLogout }) {
</div>
<ChangelogModal isOpen={changelogOpen} onClose={() => setChangelogOpen(false)} />
<ConfirmModal
isOpen={shutdownOpen}
onClose={() => setShutdownOpen(false)}
onConfirm={handleShutdown}
title="Close Proxy"
message="Are you sure you want to close the proxy server?"
confirmText="Close"
cancelText="Cancel"
variant="danger"
loading={isShuttingDown}
/>
</>
);
}
+1 -174
View File
@@ -5,12 +5,9 @@ import PropTypes from "prop-types";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/shared/utils/cn";
import { APP_CONFIG, UPDATER_CONFIG } from "@/shared/constants/config";
import { APP_CONFIG } from "@/shared/constants/config";
import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import useUserStore from "@/store/userStore";
import Button from "./Button";
import { ConfirmModal } from "./Modal";
// const VISIBLE_MEDIA_KINDS = ["embedding", "image", "imageToText", "tts", "stt", "webSearch", "webFetch", "video", "music"];
const VISIBLE_MEDIA_KINDS = ["embedding", "image", "tts", "stt"];
@@ -43,17 +40,9 @@ const systemItems = [
export default function Sidebar({ onClose }) {
const pathname = usePathname();
const [mediaOpen, setMediaOpen] = useState(false);
const [isDisconnected, setIsDisconnected] = useState(false);
const [updateInfo, setUpdateInfo] = useState(null);
const [showUpdateModal, setShowUpdateModal] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [shutdownCountdown, setShutdownCountdown] = useState(0);
const [enableTranslator, setEnableTranslator] = useState(false);
const user = useUserStore((state) => state.user);
const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser);
const { copied, copy } = useCopyToClipboard(2000);
const INSTALL_CMD = UPDATER_CONFIG.installCmdLatest;
useEffect(() => {
fetch("/api/settings")
@@ -66,14 +55,6 @@ export default function Sidebar({ onClose }) {
if (!user) fetchCurrentUser();
}, [fetchCurrentUser, user]);
// Lazy check for new npm version on mount
useEffect(() => {
fetch("/api/version")
.then(res => res.json())
.then(data => { if (data.hasUpdate) setUpdateInfo(data); })
.catch(() => {});
}, []);
const isActive = (href) => {
if (href === "/dashboard/endpoint") {
return pathname === "/dashboard" || pathname.startsWith("/dashboard/endpoint");
@@ -81,37 +62,6 @@ export default function Sidebar({ onClose }) {
return pathname.startsWith(href);
};
// Open manual update panel (no countdown yet — user must click Copy to trigger shutdown)
const handleUpdate = () => {
setShowUpdateModal(false);
setIsUpdating(true);
};
// Triggered by Copy button inside ManualUpdatePanel: copy + countdown + shutdown
const handleCopyAndShutdown = async () => {
try { await navigator.clipboard.writeText(INSTALL_CMD); } catch { /* clipboard blocked */ }
copy(INSTALL_CMD);
let remaining = UPDATER_CONFIG.shutdownCountdownSec;
setShutdownCountdown(remaining);
const timer = setInterval(() => {
remaining -= 1;
setShutdownCountdown(remaining);
if (remaining <= 0) {
clearInterval(timer);
fetch("/api/version/shutdown", { method: "POST" }).catch(() => {});
setIsDisconnected(true);
}
}, 1000);
};
const handleCancelUpdate = () => {
setIsUpdating(false);
setShutdownCountdown(0);
};
// Note: legacy updater poll removed. New flow: copy install cmd + shutdown server,
// user runs the command manually in another terminal.
return (
<>
@@ -136,30 +86,6 @@ export default function Sidebar({ onClose }) {
<span className="text-xs text-text-muted">v{APP_CONFIG.version}</span>
</div>
</Link>
{updateInfo && (
<div className="flex flex-col gap-1.5 rounded p-1 -m-1">
<span className="text-xs font-semibold text-green-600 dark:text-amber-500">
New version available: v{updateInfo.latestVersion}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setShowUpdateModal(true)}
className="px-2 py-1 rounded bg-green-600 hover:bg-green-700 dark:bg-amber-500 dark:hover:bg-amber-600 text-white text-[11px] font-semibold transition-colors cursor-pointer"
>
Update now
</button>
<button
onClick={() => copy(INSTALL_CMD)}
title="Copy install command"
className="flex-1 text-left hover:opacity-80 transition-opacity cursor-pointer min-w-0"
>
<code className="block text-[10px] text-green-600/80 dark:text-amber-400/70 font-mono truncate">
{copied ? "✓ copied!" : INSTALL_CMD}
</code>
</button>
</div>
</div>
)}
</div>
{/* Navigation */}
@@ -343,46 +269,6 @@ export default function Sidebar({ onClose }) {
</nav>
</aside>
{/* Update Confirmation Modal */}
<ConfirmModal
isOpen={showUpdateModal}
onClose={() => setShowUpdateModal(false)}
onConfirm={handleUpdate}
title="Update 9Router"
message={`Show install command for v${updateInfo?.latestVersion || ""}? You can copy it and shutdown to install manually.`}
confirmText="Show Command"
cancelText="Cancel"
variant="primary"
/>
{/* Disconnected / Updating Overlay */}
{(isDisconnected || isUpdating) && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-6">
{isUpdating ? (
<ManualUpdatePanel
latestVersion={updateInfo?.latestVersion}
installCmd={INSTALL_CMD}
copied={copied}
onCopyAndShutdown={handleCopyAndShutdown}
onCancel={handleCancelUpdate}
countdown={shutdownCountdown}
isDisconnected={isDisconnected}
/>
) : (
<div className="text-center p-8">
<div className="flex items-center justify-center size-16 rounded-full bg-red-500/20 text-red-500 mx-auto mb-4">
<span className="material-symbols-outlined text-[32px]">power_off</span>
</div>
<h2 className="text-xl font-semibold text-white mb-2">Server Disconnected</h2>
<p className="text-text-muted mb-6">The proxy server has been stopped.</p>
<Button variant="secondary" onClick={() => globalThis.location.reload()}>
Reload Page
</Button>
</div>
)}
</div>
)}
</>
);
}
@@ -390,62 +276,3 @@ export default function Sidebar({ onClose }) {
Sidebar.propTypes = {
onClose: PropTypes.func,
};
function ManualUpdatePanel({ latestVersion, installCmd, copied, onCopyAndShutdown, onCancel, countdown, isDisconnected }) {
const isCountingDown = countdown > 0;
return (
<div className="w-full max-w-lg rounded-xl bg-neutral-900/95 border border-white/10 p-6 text-white">
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center justify-center size-11 rounded-full bg-amber-500/20 text-amber-400">
<span className="material-symbols-outlined text-[24px]">content_copy</span>
</div>
<div>
<h2 className="text-lg font-semibold">Update 9Router{latestVersion ? ` to v${latestVersion}` : ""}</h2>
<p className="text-xs text-white/60">
{isDisconnected
? "Server stopped. Paste the command into a terminal to install."
: isCountingDown
? `Command copied. Server will stop in ${countdown}s...`
: "Click the button below to copy the install command and shutdown."}
</p>
</div>
</div>
<p className="text-sm text-white/80 mb-2">Install command:</p>
<div className="w-full px-3 py-2 rounded bg-white/5 mb-4">
<code className="text-xs font-mono text-amber-400 break-all">{installCmd}</code>
</div>
<ol className="text-xs text-white/70 space-y-1 list-decimal list-inside mb-4">
<li>Click <strong>Copy & Shutdown</strong> below.</li>
<li>Paste the command into your terminal and press Enter.</li>
<li>Run <code className="px-1 rounded bg-white/10 text-green-400">9router</code> again after install.</li>
</ol>
{isDisconnected ? (
<Button variant="secondary" fullWidth onClick={() => globalThis.location.reload()}>
Reload Page
</Button>
) : (
<div className="flex gap-2">
<Button variant="secondary" onClick={onCancel} disabled={isCountingDown}>
Cancel
</Button>
<Button variant="primary" fullWidth onClick={onCopyAndShutdown} disabled={isCountingDown}>
{copied ? "✓ Copied — shutting down..." : isCountingDown ? `Shutting down in ${countdown}s` : "Copy & Shutdown"}
</Button>
</div>
)}
</div>
);
}
ManualUpdatePanel.propTypes = {
latestVersion: PropTypes.string,
installCmd: PropTypes.string.isRequired,
copied: PropTypes.bool,
onCopyAndShutdown: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
countdown: PropTypes.number,
isDisconnected: PropTypes.bool,
};
+1 -19
View File
@@ -5,6 +5,7 @@ export const APP_CONFIG = {
name: "9Router Proxy",
description: "AI Infrastructure Management",
version: pkg.version,
defaultPort: 20128,
};
// GitHub configuration
@@ -13,25 +14,6 @@ export const GITHUB_CONFIG = {
donateUrl: "https://9router.com/api/donate",
};
// Updater configuration
export const UPDATER_CONFIG = {
npmPackageName: "9router",
installCmd: "npm i -g 9router",
installCmdLatest: "npm i -g 9router@latest --prefer-online",
shutdownCountdownSec: 3,
exitDelayMs: 500,
statusPort: 20129,
statusPollIntervalMs: 1000,
statusLogTailLines: 8,
installRetries: 3,
installRetryDelayMs: 5000,
lingerAfterDoneMs: 30000,
waitForExitMinMs: 5000,
waitForExitMaxMs: 20000,
waitForExitCheckMs: 500,
appPort: 20128,
};
// Theme configuration
export const THEME_CONFIG = {
storageKey: "theme",