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
View File
@@ -82,7 +82,6 @@ That's it! Start coding with FREE AI models.
9router # Start with default settings 9router # Start with default settings
9router --port 8080 # Custom port 9router --port 8080 # Custom port
9router --no-browser # Don't open browser 9router --no-browser # Don't open browser
9router --skip-update # Skip auto-update check
9router --help # Show all options 9router --help # Show all options
``` ```
+9 -142
View File
@@ -3,7 +3,6 @@
const { spawn, exec, execSync } = require("child_process"); const { spawn, exec, execSync } = require("child_process");
const path = require("path"); const path = require("path");
const fs = require("fs"); const fs = require("fs");
const https = require("https");
const net = require("net"); const net = require("net");
const os = require("os"); 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 pkg = require("./package.json");
const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime"); const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime");
const { ensureTrayRuntime } = require("./hooks/trayRuntime"); const { ensureTrayRuntime } = require("./hooks/trayRuntime");
@@ -77,7 +40,6 @@ try { ensureTrayRuntime({ silent: true }); } catch {}
// Configuration constants // Configuration constants
const APP_NAME = pkg.name; // Use from package.json 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_PORT = 20128;
const DEFAULT_HOST = "0.0.0.0"; const DEFAULT_HOST = "0.0.0.0";
@@ -106,7 +68,6 @@ const PROCESS_IDENTIFIERS = [
let port = DEFAULT_PORT; let port = DEFAULT_PORT;
let host = DEFAULT_HOST; let host = DEFAULT_HOST;
let noBrowser = false; let noBrowser = false;
let skipUpdate = false;
let showLog = false; let showLog = false;
let trayMode = false; let trayMode = false;
@@ -121,8 +82,6 @@ for (let i = 0; i < args.length; i++) {
noBrowser = true; noBrowser = true;
} else if (args[i] === "--log" || args[i] === "-l") { } else if (args[i] === "--log" || args[i] === "-l") {
showLog = true; showLog = true;
} else if (args[i] === "--skip-update") {
skipUpdate = true;
} else if (args[i] === "--tray" || args[i] === "-t") { } else if (args[i] === "--tray" || args[i] === "-t") {
trayMode = true; trayMode = true;
process.env.TRAY_MODE = "1"; process.env.TRAY_MODE = "1";
@@ -136,7 +95,6 @@ Options:
-n, --no-browser Don't open browser automatically -n, --no-browser Don't open browser automatically
-l, --log Show server logs (default: hidden) -l, --log Show server logs (default: hidden)
-t, --tray Run in system tray mode (background) -t, --tray Run in system tray mode (background)
--skip-update Skip auto-update check
-h, --help Show this help message -h, --help Show this help message
-v, --version Show version -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 // Always use Node.js runtime with absolute path
const RUNTIME = process.execPath; 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) // Get app data dir (matches app/src/lib/dataDir.js convention)
function getAppDataDir() { function getAppDataDir() {
return process.platform === "win32" return process.platform === "win32"
@@ -437,55 +378,6 @@ function isRestrictedEnvironment() {
return null; 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 // Open browser
function openBrowser(url) { function openBrowser(url) {
const platform = process.platform; const platform = process.platform;
@@ -520,14 +412,12 @@ if (!fs.existsSync(serverPath)) {
process.exit(1); process.exit(1);
} }
// Start server immediately; run update check in parallel (not on the critical path).
const updatePromise = checkForUpdate();
killAllAppProcesses(port) killAllAppProcesses(port)
.then(() => killProcessOnPort(port)) .then(() => killProcessOnPort(port))
.then(() => startServer(updatePromise)); .then(() => startServer());
// Show interface selection menu // Show interface selection menu
async function showInterfaceMenu(latestVersion) { async function showInterfaceMenu() {
const { selectMenu } = require("./src/cli/utils/input"); const { selectMenu } = require("./src/cli/utils/input");
const { clearScreen } = require("./src/cli/utils/display"); const { clearScreen } = require("./src/cli/utils/display");
const { getEndpoint } = require("./src/cli/utils/endpoint"); const { getEndpoint } = require("./src/cli/utils/endpoint");
@@ -549,10 +439,6 @@ async function showInterfaceMenu(latestVersion) {
const menuItems = []; const menuItems = [];
if (latestVersion) {
menuItems.push({ label: `Update to v${latestVersion} (current: v${pkg.version})`, icon: "⬆" });
}
menuItems.push( menuItems.push(
{ label: "Web UI (Open in Browser)", icon: "🌐" }, { label: "Web UI (Open in Browser)", icon: "🌐" },
{ label: "Terminal UI (Interactive CLI)", 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 selected = await selectMenu(`Choose Interface (v${pkg.version})`, menuItems, 0, subtitle);
const offset = latestVersion ? 1 : 0; if (selected === 0) return "web";
if (selected === 1) return "terminal";
if (latestVersion && selected === 0) return "update"; if (selected === 2) return "hide";
if (selected === offset) return "web";
if (selected === offset + 1) return "terminal";
if (selected === offset + 2) return "hide";
return "exit"; return "exit";
} }
const MAX_RESTARTS = 2; const MAX_RESTARTS = 2;
const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
function startServer(updatePromise) { function startServer() {
// Accept either a Promise (parallel update check) or a resolved value.
const latestVersionPromise = Promise.resolve(updatePromise);
const displayHost = getDisplayHost(); const displayHost = getDisplayHost();
const url = `http://${displayHost}:${port}/dashboard`; const url = `http://${displayHost}:${port}/dashboard`;
// Surface real network exposure when bound to all interfaces (default 0.0.0.0). // 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 // Wait for server to be ready, then show interface menu loop + tray
waitServerReady(port).then(async () => { 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 // Start tray icon alongside TUI
initTrayIcon(); initTrayIcon();
try { try {
while (true) { while (true) {
const choice = await showInterfaceMenu(latestVersion); const choice = await showInterfaceMenu();
if (choice === "update") { if (choice === "web") {
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); openBrowser(url);
// Wait for user to come back // Wait for user to come back
const { pause } = require("./src/cli/utils/input"); const { pause } = require("./src/cli/utils/input");
@@ -767,7 +634,7 @@ function startServer(updatePromise) {
// Windows/Linux: spawn detached bgProcess (systray works fine in child) // Windows/Linux: spawn detached bgProcess (systray works fine in child)
console.log(`\n⏳ Starting background process... (tray icon will appear in ~3s)`); 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, detached: true,
stdio: "ignore", stdio: "ignore",
windowsHide: true, windowsHide: true,
-11
View File
@@ -248,17 +248,6 @@ if (fs.existsSync(mitmSrc)) {
console.log("⏭️ No MITM files found\n"); 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) // Step 8: Build MITM server (config driven - see app/cli/scripts/buildMitm.js)
console.log("8️⃣ Building MITM server..."); console.log("8️⃣ Building MITM server...");
try { try {
+1 -3
View File
@@ -14,9 +14,7 @@ const cliDir = path.resolve(__dirname, "..");
const appDir = path.resolve(cliDir, ".."); const appDir = path.resolve(cliDir, "..");
const cliAppDir = process.env.NINEROUTER_CLI_APP_DIR || path.join(cliDir, "app"); const cliAppDir = process.env.NINEROUTER_CLI_APP_DIR || path.join(cliDir, "app");
const cliMitmDir = path.join(cliAppDir, "src", "mitm"); const cliMitmDir = path.join(cliAppDir, "src", "mitm");
// Bundle everything — no externals. This keeps MITM runtime self-contained so // Bundle everything — no externals. This keeps the MITM runtime self-contained.
// 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).
const EXTERNALS = []; const EXTERNALS = [];
const ENTRIES = ["server.js"]; const ENTRIES = ["server.js"];
+2 -3
View File
@@ -170,7 +170,6 @@ function enableMacOS(cliPath) {
<string>${nodePath}</string> <string>${nodePath}</string>
<string>${routerScript}</string> <string>${routerScript}</string>
<string>--tray</string> <string>--tray</string>
<string>--skip-update</string>
</array> </array>
<key>EnvironmentVariables</key> <key>EnvironmentVariables</key>
<dict> <dict>
@@ -249,7 +248,7 @@ function enableWindows(cliPath) {
// Run node + cli.js directly, hidden window. Avoids the fragile // Run node + cli.js directly, hidden window. Avoids the fragile
// `9router.cmd` lookup that depended on the npm prefix path. // `9router.cmd` lookup that depended on the npm prefix path.
const vbsContent = `Set WshShell = CreateObject("WScript.Shell") 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); fs.writeFileSync(vbsPath, vbsContent);
return true; return true;
@@ -282,7 +281,7 @@ function enableLinux(cliPath) {
Type=Application Type=Application
Name=9Router Name=9Router
Comment=9Router API Proxy Comment=9Router API Proxy
Exec=${nodePath} ${routerScript} --tray --skip-update Exec=${nodePath} ${routerScript} --tray
Hidden=false Hidden=false
NoDisplay=false NoDisplay=false
X-GNOME-Autostart-enabled=true X-GNOME-Autostart-enabled=true
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; 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 STORAGE_KEY = "9router.cliToolEndpointPresets";
const CUSTOM_VALUE = "__custom__"; const CUSTOM_VALUE = "__custom__";
@@ -33,7 +33,7 @@ const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tai
const opts = []; const opts = [];
const wrap = (url) => (withV1 ? ensureV1(url) : (url || "").replace(/\/+$/, "")); const wrap = (url) => (withV1 ? ensureV1(url) : (url || "").replace(/\/+$/, ""));
if (!requiresExternalUrl) { 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 }); opts.push({ value: "local", label: localUrl, url: localUrl });
} }
if (tunnelEnabled && tunnelPublicUrl) { if (tunnelEnabled && tunnelPublicUrl) {
+1 -34
View File
@@ -2,7 +2,7 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Card, Button, Toggle, Input } from "@/shared/components"; 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 LanguageSwitcher from "@/shared/components/LanguageSwitcher";
import { useTheme } from "@/shared/hooks/useTheme"; import { useTheme } from "@/shared/hooks/useTheme";
import { cn } from "@/shared/utils/cn"; import { cn } from "@/shared/utils/cn";
@@ -29,8 +29,6 @@ export default function ProfilePage() {
const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser); const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser);
const [locale, setLocale] = useState("en"); const [locale, setLocale] = useState("en");
const [langOpen, setLangOpen] = useState(false); const [langOpen, setLangOpen] = useState(false);
const [shutdownOpen, setShutdownOpen] = useState(false);
const [isShuttingDown, setIsShuttingDown] = useState(false);
const [settings, setSettings] = useState({ fallbackStrategy: "fill-first" }); const [settings, setSettings] = useState({ fallbackStrategy: "fill-first" });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" }); const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" });
@@ -257,17 +255,6 @@ export default function ProfilePage() {
else if (mode === "import") await runImportDatabase(password); 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 () => { const handleLogout = async () => {
try { try {
const res = await fetch("/api/auth/logout", { method: "POST" }); const res = await fetch("/api/auth/logout", { method: "POST" });
@@ -492,15 +479,6 @@ export default function ProfilePage() {
{/* Account actions */} {/* Account actions */}
<div className="flex flex-col sm:flex-row gap-2"> <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 <Button
variant="outline" variant="outline"
fullWidth fullWidth
@@ -526,17 +504,6 @@ export default function ProfilePage() {
setLocale(next); 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 <Modal
isOpen={dbAuth.open} isOpen={dbAuth.open}
+2 -2
View File
@@ -1,5 +1,5 @@
import { getApiKeys } from "@/lib/localDb"; 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"; import { getConsistentMachineId } from "@/shared/utils/machineId";
const CLI_TOKEN_SALT = "9r-cli-auth"; const CLI_TOKEN_SALT = "9r-cli-auth";
@@ -50,7 +50,7 @@ async function getInternalHeaders() {
return headers; 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 headers = await getInternalHeaders();
const start = Date.now(); const start = Date.now();
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { getProviderConnectionById } from "@/lib/localDb"; import { getProviderConnectionById } from "@/lib/localDb";
import { getProviderModels, PROVIDER_ID_TO_ALIAS } from "open-sse/config/providerModels.js"; import { getProviderModels, PROVIDER_ID_TO_ALIAS } from "open-sse/config/providerModels.js";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; 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 { pingModelByKind } from "@/app/api/models/test/ping";
import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess"; import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
@@ -26,7 +26,7 @@ export async function POST(request, { params }) {
let models = getProviderModels(alias); 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 // Compatible providers: fetch live model list
if (isCompatible && models.length === 0) { 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/logout",
"/api/auth/status", "/api/auth/status",
"/api/auth/oidc", "/api/auth/oidc",
"/api/version",
]; ];
// Public top-level prefixes (LLM API endpoints with their own API key auth). // 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 = [ const ALWAYS_PROTECTED = [
"/api/shutdown", "/api/shutdown",
"/api/settings/database", "/api/settings/database",
"/api/version/shutdown",
"/api/version/update",
"/api/oauth/cursor/auto-import", "/api/oauth/cursor/auto-import",
"/api/oauth/kiro/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 { generateCert } = require("./cert/generate");
const { installCert, uninstallCert } = require("./cert/install"); const { installCert, uninstallCert } = require("./cert/install");
const { isCertExpired } = require("./cert/rootCA"); const { isCertExpired } = require("./cert/rootCA");
const { DATA_DIR, MITM_DIR } = require("./paths"); const { MITM_DIR } = require("./paths");
const { log, err } = require("./logger"); const { log, err } = require("./logger");
const { LSOF_BIN } = require("./config"); const { LSOF_BIN } = require("./config");
@@ -62,38 +62,7 @@ function resolveBundledServerPath() {
return fromCwd; return fromCwd;
} }
// Copy bundled server.js into DATA_DIR so MITM doesn't lock node_modules const SERVER_PATH = resolveBundledServerPath();
// (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 ENCRYPT_ALGO = "aes-256-gcm"; const ENCRYPT_ALGO = "aes-256-gcm";
const ENCRYPT_SALT = "9router-mitm-pwd"; 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) // 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; let effectiveServerPath = SERVER_PATH;
if (!effectiveServerPath || !fs.existsSync(effectiveServerPath)) { if (!effectiveServerPath || !fs.existsSync(effectiveServerPath)) {
log(`[MITM] server.js missing at ${effectiveServerPath} → recopying`); log(`[MITM] server.js missing at ${effectiveServerPath} → resolving again`);
effectiveServerPath = ensureRuntimeServer(resolveBundledServerPath()); effectiveServerPath = resolveBundledServerPath();
if (!effectiveServerPath || !fs.existsSync(effectiveServerPath)) { if (!effectiveServerPath || !fs.existsSync(effectiveServerPath)) {
throw new Error(`MITM server.js not found at ${effectiveServerPath}. Reinstall 9router.`); 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 PropTypes from "prop-types";
import { useTheme } from "@/shared/hooks/useTheme"; import { useTheme } from "@/shared/hooks/useTheme";
import ChangelogModal from "./ChangelogModal"; import ChangelogModal from "./ChangelogModal";
import { ConfirmModal } from "./Modal";
function MenuItem({ icon, label, onClick, trailing, danger }) { function MenuItem({ icon, label, onClick, trailing, danger }) {
return ( return (
@@ -36,22 +35,9 @@ MenuItem.propTypes = {
export default function HeaderMenu({ onLogout }) { export default function HeaderMenu({ onLogout }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [changelogOpen, setChangelogOpen] = useState(false); const [changelogOpen, setChangelogOpen] = useState(false);
const [shutdownOpen, setShutdownOpen] = useState(false);
const [isShuttingDown, setIsShuttingDown] = useState(false);
const { toggleTheme, isDark } = useTheme(); const { toggleTheme, isDark } = useTheme();
const menuRef = useRef(null); 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(() => { useEffect(() => {
const handleClickOutside = (e) => { const handleClickOutside = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) { if (menuRef.current && !menuRef.current.contains(e.target)) {
@@ -89,12 +75,6 @@ export default function HeaderMenu({ onLogout }) {
label="Theme" label="Theme"
onClick={() => { toggleTheme(); close(); }} onClick={() => { toggleTheme(); close(); }}
/> />
<MenuItem
icon="power_settings_new"
label="Shutdown"
danger
onClick={() => { close(); setShutdownOpen(true); }}
/>
<MenuItem <MenuItem
icon="logout" icon="logout"
label="Logout" label="Logout"
@@ -106,17 +86,6 @@ export default function HeaderMenu({ onLogout }) {
</div> </div>
<ChangelogModal isOpen={changelogOpen} onClose={() => setChangelogOpen(false)} /> <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 Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { cn } from "@/shared/utils/cn"; 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 { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import useUserStore from "@/store/userStore"; 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", "imageToText", "tts", "stt", "webSearch", "webFetch", "video", "music"];
const VISIBLE_MEDIA_KINDS = ["embedding", "image", "tts", "stt"]; const VISIBLE_MEDIA_KINDS = ["embedding", "image", "tts", "stt"];
@@ -43,17 +40,9 @@ const systemItems = [
export default function Sidebar({ onClose }) { export default function Sidebar({ onClose }) {
const pathname = usePathname(); const pathname = usePathname();
const [mediaOpen, setMediaOpen] = useState(false); 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 [enableTranslator, setEnableTranslator] = useState(false);
const user = useUserStore((state) => state.user); const user = useUserStore((state) => state.user);
const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser); const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser);
const { copied, copy } = useCopyToClipboard(2000);
const INSTALL_CMD = UPDATER_CONFIG.installCmdLatest;
useEffect(() => { useEffect(() => {
fetch("/api/settings") fetch("/api/settings")
@@ -66,14 +55,6 @@ export default function Sidebar({ onClose }) {
if (!user) fetchCurrentUser(); if (!user) fetchCurrentUser();
}, [fetchCurrentUser, user]); }, [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) => { const isActive = (href) => {
if (href === "/dashboard/endpoint") { if (href === "/dashboard/endpoint") {
return pathname === "/dashboard" || pathname.startsWith("/dashboard/endpoint"); return pathname === "/dashboard" || pathname.startsWith("/dashboard/endpoint");
@@ -81,37 +62,6 @@ export default function Sidebar({ onClose }) {
return pathname.startsWith(href); 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 ( return (
<> <>
@@ -136,30 +86,6 @@ export default function Sidebar({ onClose }) {
<span className="text-xs text-text-muted">v{APP_CONFIG.version}</span> <span className="text-xs text-text-muted">v{APP_CONFIG.version}</span>
</div> </div>
</Link> </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> </div>
{/* Navigation */} {/* Navigation */}
@@ -343,46 +269,6 @@ export default function Sidebar({ onClose }) {
</nav> </nav>
</aside> </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 = { Sidebar.propTypes = {
onClose: PropTypes.func, 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", name: "9Router Proxy",
description: "AI Infrastructure Management", description: "AI Infrastructure Management",
version: pkg.version, version: pkg.version,
defaultPort: 20128,
}; };
// GitHub configuration // GitHub configuration
@@ -13,25 +14,6 @@ export const GITHUB_CONFIG = {
donateUrl: "https://9router.com/api/donate", 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 // Theme configuration
export const THEME_CONFIG = { export const THEME_CONFIG = {
storageKey: "theme", storageKey: "theme",