mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat: remove check version update feature
This commit is contained in:
@@ -82,7 +82,6 @@ That's it! Start coding with FREE AI models.
|
||||
9router # Start with default settings
|
||||
9router --port 8080 # Custom port
|
||||
9router --no-browser # Don't open browser
|
||||
9router --skip-update # Skip auto-update check
|
||||
9router --help # Show all options
|
||||
```
|
||||
|
||||
|
||||
+9
-142
@@ -3,7 +3,6 @@
|
||||
const { spawn, exec, execSync } = require("child_process");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
const net = require("net");
|
||||
const os = require("os");
|
||||
|
||||
@@ -26,42 +25,6 @@ function waitServerReady(port, { timeoutMs = 15000, intervalMs = 150 } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// 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");
|
||||
@@ -77,7 +40,6 @@ 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";
|
||||
@@ -106,7 +68,6 @@ const PROCESS_IDENTIFIERS = [
|
||||
let port = DEFAULT_PORT;
|
||||
let host = DEFAULT_HOST;
|
||||
let noBrowser = false;
|
||||
let skipUpdate = false;
|
||||
let showLog = false;
|
||||
let trayMode = false;
|
||||
|
||||
@@ -121,8 +82,6 @@ for (let i = 0; i < args.length; i++) {
|
||||
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";
|
||||
@@ -136,7 +95,6 @@ Options:
|
||||
-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
|
||||
`);
|
||||
@@ -147,26 +105,9 @@ Options:
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
@@ -437,55 +378,6 @@ function isRestrictedEnvironment() {
|
||||
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;
|
||||
@@ -520,14 +412,12 @@ if (!fs.existsSync(serverPath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Start server immediately; run update check in parallel (not on the critical path).
|
||||
const updatePromise = checkForUpdate();
|
||||
killAllAppProcesses(port)
|
||||
.then(() => killProcessOnPort(port))
|
||||
.then(() => startServer(updatePromise));
|
||||
.then(() => startServer());
|
||||
|
||||
// Show interface selection menu
|
||||
async function showInterfaceMenu(latestVersion) {
|
||||
async function showInterfaceMenu() {
|
||||
const { selectMenu } = require("./src/cli/utils/input");
|
||||
const { clearScreen } = require("./src/cli/utils/display");
|
||||
const { getEndpoint } = require("./src/cli/utils/endpoint");
|
||||
@@ -549,10 +439,6 @@ async function showInterfaceMenu(latestVersion) {
|
||||
|
||||
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: "💻" },
|
||||
@@ -562,21 +448,16 @@ async function showInterfaceMenu(latestVersion) {
|
||||
|
||||
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";
|
||||
if (selected === 0) return "web";
|
||||
if (selected === 1) return "terminal";
|
||||
if (selected === 2) return "hide";
|
||||
return "exit";
|
||||
}
|
||||
|
||||
const MAX_RESTARTS = 2;
|
||||
const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
|
||||
|
||||
function startServer(updatePromise) {
|
||||
// Accept either a Promise (parallel update check) or a resolved value.
|
||||
const latestVersionPromise = Promise.resolve(updatePromise);
|
||||
function startServer() {
|
||||
const displayHost = getDisplayHost();
|
||||
const url = `http://${displayHost}:${port}/dashboard`;
|
||||
// Surface real network exposure when bound to all interfaces (default 0.0.0.0).
|
||||
@@ -708,28 +589,14 @@ function startServer(updatePromise) {
|
||||
|
||||
// Wait for server to be ready, then show interface menu loop + tray
|
||||
waitServerReady(port).then(async () => {
|
||||
// Resolve parallel update check (already running); don't block server start on it.
|
||||
const latestVersion = await latestVersionPromise;
|
||||
// Start tray icon alongside TUI
|
||||
initTrayIcon();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const choice = await showInterfaceMenu(latestVersion);
|
||||
const choice = await showInterfaceMenu();
|
||||
|
||||
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") {
|
||||
if (choice === "web") {
|
||||
openBrowser(url);
|
||||
// Wait for user to come back
|
||||
const { pause } = require("./src/cli/utils/input");
|
||||
@@ -767,7 +634,7 @@ function startServer(updatePromise) {
|
||||
// Windows/Linux: spawn detached bgProcess (systray works fine in child)
|
||||
console.log(`\n⏳ Starting background process... (tray icon will appear in ~3s)`);
|
||||
|
||||
const bgProcess = spawn(process.execPath, [__filename, "--tray", "--skip-update", "-p", port.toString()], {
|
||||
const bgProcess = spawn(process.execPath, [__filename, "--tray", "-p", port.toString()], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
|
||||
@@ -248,17 +248,6 @@ if (fs.existsSync(mitmSrc)) {
|
||||
console.log("⏭️ No MITM files found\n");
|
||||
}
|
||||
|
||||
// Step 7b: Copy standalone updater (headless Node process for install progress)
|
||||
console.log("7️⃣ b Copying updater files...");
|
||||
const updaterSrc = path.join(appDir, "src", "lib", "updater");
|
||||
const updaterDest = path.join(cliAppDir, "src", "lib", "updater");
|
||||
if (fs.existsSync(updaterSrc)) {
|
||||
copyRecursive(updaterSrc, updaterDest);
|
||||
console.log("✅ Copied updater files\n");
|
||||
} else {
|
||||
console.log("⏭️ No updater files found\n");
|
||||
}
|
||||
|
||||
// Step 8: Build MITM server (config driven - see app/cli/scripts/buildMitm.js)
|
||||
console.log("8️⃣ Building MITM server...");
|
||||
try {
|
||||
|
||||
@@ -14,9 +14,7 @@ const cliDir = path.resolve(__dirname, "..");
|
||||
const appDir = path.resolve(cliDir, "..");
|
||||
const cliAppDir = process.env.NINEROUTER_CLI_APP_DIR || path.join(cliDir, "app");
|
||||
const cliMitmDir = path.join(cliAppDir, "src", "mitm");
|
||||
// Bundle everything — no externals. This keeps MITM runtime self-contained so
|
||||
// it can be copied to DATA_DIR/runtime/ and spawned from there (escapes
|
||||
// node_modules file locks that block `npm i -g 9router@latest` on Windows).
|
||||
// Bundle everything — no externals. This keeps the MITM runtime self-contained.
|
||||
const EXTERNALS = [];
|
||||
const ENTRIES = ["server.js"];
|
||||
|
||||
|
||||
@@ -170,7 +170,6 @@ function enableMacOS(cliPath) {
|
||||
<string>${nodePath}</string>
|
||||
<string>${routerScript}</string>
|
||||
<string>--tray</string>
|
||||
<string>--skip-update</string>
|
||||
</array>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
@@ -249,7 +248,7 @@ function enableWindows(cliPath) {
|
||||
// Run node + cli.js directly, hidden window. Avoids the fragile
|
||||
// `9router.cmd` lookup that depended on the npm prefix path.
|
||||
const vbsContent = `Set WshShell = CreateObject("WScript.Shell")
|
||||
WshShell.Run """${nodePath}"" ""${routerScript}"" --tray --skip-update", 0, False
|
||||
WshShell.Run """${nodePath}"" ""${routerScript}"" --tray", 0, False
|
||||
`;
|
||||
fs.writeFileSync(vbsPath, vbsContent);
|
||||
return true;
|
||||
@@ -282,7 +281,7 @@ function enableLinux(cliPath) {
|
||||
Type=Application
|
||||
Name=9Router
|
||||
Comment=9Router API Proxy
|
||||
Exec=${nodePath} ${routerScript} --tray --skip-update
|
||||
Exec=${nodePath} ${routerScript} --tray
|
||||
Hidden=false
|
||||
NoDisplay=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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." });
|
||||
}
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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.`);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user