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 # 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
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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/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",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 { 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.`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user