diff --git a/CHANGELOG.md b/CHANGELOG.md index d401b24d..56c2b991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# v0.4.27 (2026-05-09) + +## Features +- Add 3-tier DB driver fallback: better-sqlite3 → node:sqlite (Node ≥22.5) → sql.js + +## Fixes +- Fix authentication logic for several providers + # v0.4.25 (2026-05-09) ## Features diff --git a/package.json b/package.json index 118af2da..2efa813b 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "9router-app", - "version": "0.4.25", + "version": "0.4.27", "description": "9Router web dashboard", "private": true, "scripts": { - "dev": "next dev --webpack --hostname 127.0.0.1 --port 20128", + "dev": "next dev --webpack --hostname 0.0.0.0 --port 20128", "build": "NODE_ENV=production next build --webpack", "start": "NODE_ENV=production next start", "dev:bun": "bun --bun next dev --webpack --port 20128", diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js index f123e636..3d2ef59a 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js @@ -119,7 +119,7 @@ export default function CLIToolsPageClient({ machineId }) { if (tunnelEnabled && tunnelPublicUrl) return tunnelPublicUrl; if (cloudEnabled && CLOUD_URL) return CLOUD_URL; if (typeof window !== "undefined") return window.location.origin; - return "http://127.0.0.1:20128"; + return "http://localhost:20128"; }; if (loading) { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js index 7fe661fa..8595d42d 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js @@ -21,7 +21,7 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba : (!cloudEnabled ? "sk_9router" : "your-api-key"); // Add /v1 suffix only if not already present (DRY - avoid duplicate) - const normalizedBaseUrl = baseUrl || "http://127.0.0.1:20128"; + const normalizedBaseUrl = baseUrl || "http://localhost:20128"; const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`; diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js index 03cd8ed4..51fab54e 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from "react"; import { Card, Button, Badge, Input } from "@/shared/components"; -const DEFAULT_MITM_ROUTER_BASE = "http://127.0.0.1:20128"; +const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128"; /** * Shared MITM infrastructure card — manages SSL cert + server start/stop. diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 71be28e7..57a22872 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -15,7 +15,23 @@ const TUNNEL_BENEFITS = [ const TUNNEL_PING_INTERVAL_MS = 2000; const TUNNEL_PING_MAX_MS = 300000; const STATUS_POLL_INTERVAL_MS = 5000; -const REACHABLE_MISS_THRESHOLD = 2; +const REACHABLE_MISS_THRESHOLD = 5; +const CLIENT_PING_INTERVAL_MS = 10000; +const CLIENT_PING_TIMEOUT_MS = 5000; + +// Browser-side health probe: bypasses backend DNS issues (1.1.1.1 vs OS resolver). +// Uses no-cors → opaque response means TLS+DNS reach succeeded, which is enough. +async function clientPingUrl(url) { + if (!url) return false; + try { + await fetch(`${url}/api/health`, { + mode: "no-cors", + cache: "no-store", + signal: AbortSignal.timeout(CLIENT_PING_TIMEOUT_MS), + }); + return true; + } catch { return false; } +} const CAVEMAN_LEVELS = [ { id: "lite", label: "Lite", desc: "Drop filler, keep grammar" }, @@ -69,6 +85,9 @@ export default function APIPageClient({ machineId }) { // Only flip UI to "reconnecting" after N consecutive misses to avoid spinner flicker. const tunnelMissRef = useRef(0); const tsMissRef = useRef(0); + // Browser-side reachable cache (independent of backend DNS quirks) + const tunnelClientReachableRef = useRef(false); + const tsClientReachableRef = useRef(false); // Track whether reachable=true was ever observed in this session. // Distinguishes "Checking..." (initial cold cache) from "Reconnecting..." (lost connection). const tunnelEverReachableRef = useRef(false); @@ -99,10 +118,34 @@ export default function APIPageClient({ machineId }) { }; }, []); - // Update reachable state with miss-debounce: avoids spinner flicker when server - // briefly returns reachable=false during background probe refresh. - // Also flips everReachable on first success (UI uses it to distinguish Checking vs Reconnecting). - const updateReachable = useCallback((reachable, missRef, setter, everRef, everSetter) => { + // Browser-side periodic ping: probes tunnel/tailscale URLs directly so UI stays + // "reachable" even when backend DNS (1.1.1.1) hiccups on *.ts.net or *.trycloudflare.com. + useEffect(() => { + const probeBoth = async () => { + if (tunnelEnabled && (tunnelPublicUrl || tunnelUrl)) { + const ok = await clientPingUrl(tunnelPublicUrl || tunnelUrl); + tunnelClientReachableRef.current = ok; + if (ok) { tunnelMissRef.current = 0; setTunnelReachable(true); if (!tunnelEverReachableRef.current) { tunnelEverReachableRef.current = true; setTunnelEverReachable(true); } } + } else { + tunnelClientReachableRef.current = false; + } + if (tsEnabled && tsUrl) { + const ok = await clientPingUrl(tsUrl); + tsClientReachableRef.current = ok; + if (ok) { tsMissRef.current = 0; setTsReachable(true); if (!tsEverReachableRef.current) { tsEverReachableRef.current = true; setTsEverReachable(true); } } + } else { + tsClientReachableRef.current = false; + } + }; + probeBoth(); + const id = setInterval(probeBoth, CLIENT_PING_INTERVAL_MS); + return () => clearInterval(id); + }, [tunnelEnabled, tunnelPublicUrl, tunnelUrl, tsEnabled, tsUrl]); + + // Effective reachable = serverReachable OR clientReachable (1 of 2 is enough). + // Miss-debounce: only flip to false after N consecutive misses on BOTH sides. + const updateReachable = useCallback((serverReachable, clientRef, missRef, setter, everRef, everSetter) => { + const reachable = serverReachable || clientRef.current; if (reachable) { missRef.current = 0; setter(true); @@ -128,13 +171,13 @@ export default function APIPageClient({ machineId }) { setTunnelUrl(tUrl); setTunnelPublicUrl(tPublicUrl); setTunnelEnabled(tEnabled); - updateReachable(!!data.tunnel?.reachable, tunnelMissRef, setTunnelReachable, tunnelEverReachableRef, setTunnelEverReachable); + updateReachable(!!data.tunnel?.reachable, tunnelClientReachableRef, tunnelMissRef, setTunnelReachable, tunnelEverReachableRef, setTunnelEverReachable); const tsEn = data.tailscale?.settingsEnabled ?? data.tailscale?.enabled ?? false; const tsUrlVal = data.tailscale?.tunnelUrl || ""; setTsUrl(tsUrlVal); setTsEnabled(tsEn); - updateReachable(!!data.tailscale?.reachable, tsMissRef, setTsReachable, tsEverReachableRef, setTsEverReachable); + updateReachable(!!data.tailscale?.reachable, tsClientReachableRef, tsMissRef, setTsReachable, tsEverReachableRef, setTsEverReachable); } catch { /* ignore poll errors */ } }; @@ -163,13 +206,13 @@ export default function APIPageClient({ machineId }) { setTunnelUrl(tUrl); setTunnelPublicUrl(tPublicUrl); setTunnelEnabled(tEnabled); - updateReachable(!!data.tunnel?.reachable, tunnelMissRef, setTunnelReachable, tunnelEverReachableRef, setTunnelEverReachable); + updateReachable(!!data.tunnel?.reachable, tunnelClientReachableRef, tunnelMissRef, setTunnelReachable, tunnelEverReachableRef, setTunnelEverReachable); const tsEn = data.tailscale?.settingsEnabled ?? data.tailscale?.enabled ?? false; const tsUrlVal = data.tailscale?.tunnelUrl || ""; setTsUrl(tsUrlVal); setTsEnabled(tsEn); - updateReachable(!!data.tailscale?.reachable, tsMissRef, setTsReachable, tsEverReachableRef, setTsEverReachable); + updateReachable(!!data.tailscale?.reachable, tsClientReachableRef, tsMissRef, setTsReachable, tsEverReachableRef, setTsEverReachable); } } catch (error) { console.log("Error loading settings:", error); diff --git a/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js index f00c9e45..45c15460 100644 --- a/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js @@ -235,7 +235,7 @@ export default function ComboDetailPage() { const examplePath = EXAMPLE_PATHS[combo.kind]; const exampleBody = combo.kind && EXAMPLE_BODIES[combo.kind] ? EXAMPLE_BODIES[combo.kind](combo.name) : null; const curlExample = examplePath - ? `curl -X POST http://127.0.0.1:20128${examplePath} \\\n -H "Content-Type: application/json" \\\n -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\\n -d '${JSON.stringify(exampleBody)}'` + ? `curl -X POST http://localhost:20128${examplePath} \\\n -H "Content-Type: application/json" \\\n -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\\n -d '${JSON.stringify(exampleBody)}'` : ""; const backHref = getListingHref(combo.kind); diff --git a/src/app/api/cli-tools/antigravity-mitm/route.js b/src/app/api/cli-tools/antigravity-mitm/route.js index 3d8d75ba..7d505714 100644 --- a/src/app/api/cli-tools/antigravity-mitm/route.js +++ b/src/app/api/cli-tools/antigravity-mitm/route.js @@ -16,7 +16,7 @@ import { getSettings, updateSettings } from "@/lib/localDb"; initDbHooks(getSettings, updateSettings); -const DEFAULT_MITM_ROUTER_BASE = "http://127.0.0.1:20128"; +const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128"; function normalizeMitmRouterBaseUrlInput(input) { if (input == null || String(input).trim() === "") { diff --git a/src/app/api/version/shutdown/route.js b/src/app/api/version/shutdown/route.js new file mode 100644 index 00000000..bb1cd9dc --- /dev/null +++ b/src/app/api/version/shutdown/route.js @@ -0,0 +1,15 @@ +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; +} diff --git a/src/app/landing/components/GetStarted.js b/src/app/landing/components/GetStarted.js index 682f9ffd..564213ab 100644 --- a/src/app/landing/components/GetStarted.js +++ b/src/app/landing/components/GetStarted.js @@ -40,7 +40,7 @@ export default function GetStarted() {
3

Route Requests

-

Point your CLI tools to http://127.0.0.1:20128

+

Point your CLI tools to http://localhost:20128

@@ -72,8 +72,8 @@ export default function GetStarted() {
> Starting 9Router...
- > Server running on http://127.0.0.1:20128
- > Dashboard: http://127.0.0.1:20128/dashboard
+ > Server running on http://localhost:20128
+ > Dashboard: http://localhost:20128/dashboard
> Ready to route! ✓
diff --git a/src/lib/appUpdater.js b/src/lib/appUpdater.js index 51976002..6357341f 100644 --- a/src/lib/appUpdater.js +++ b/src/lib/appUpdater.js @@ -22,7 +22,10 @@ function killMitmByPidFile() { if (!pid) return; if (process.platform === "win32") { - execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); + // 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 }); @@ -45,7 +48,13 @@ function collectAppPids() { 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 isAppProcess = line.toLowerCase().includes("9router") || line.toLowerCase().includes("next-server"); + 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]); @@ -53,19 +62,27 @@ function collectAppPids() { }); } catch { /* no processes */ } - try { - const cfCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-Process cloudflared -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id"`; - const cfOut = execSync(cfCmd, { encoding: "utf8", windowsHide: true, timeout: KILL_TIMEOUT_MS }); - cfOut.split("\n").forEach(l => { - const pid = l.trim(); - if (pid && !isNaN(pid)) pids.push(pid); - }); - } catch { /* no cloudflared */ } + // Kill cloudflared + tray binaries (giữ lock app dir) + 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"); + 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]; diff --git a/src/lib/db/adapters/nodeSqliteAdapter.js b/src/lib/db/adapters/nodeSqliteAdapter.js new file mode 100644 index 00000000..1b9c16dd --- /dev/null +++ b/src/lib/db/adapters/nodeSqliteAdapter.js @@ -0,0 +1,83 @@ +// Built-in node:sqlite adapter — available in Node >= 22.5.0. +// No native build, no npm install. API mirrors betterSqliteAdapter. +import { PRAGMA_SQL } from "../schema.js"; + +const CHECKPOINT_INTERVAL_MS = 60 * 1000; + +export async function createNodeSqliteAdapter(filePath) { + // Suppress "ExperimentalWarning: SQLite is an experimental feature" from node:sqlite. + // Stable enough for production use as of Node 22.x (RC quality). + const origEmit = process.emit; + process.emit = function (name, data, ...rest) { + if (name === "warning" && data?.name === "ExperimentalWarning" && /SQLite/i.test(data.message || "")) { + return false; + } + return origEmit.call(process, name, data, ...rest); + }; + + // Dynamic import — fails on Node < 22.5 → driver.js falls back to sql.js + const sqlite = await import("node:sqlite"); + const Database = sqlite.DatabaseSync; + const db = new Database(filePath); + + db.exec(PRAGMA_SQL); + + const stmtCache = new Map(); + function prepare(sql) { + let stmt = stmtCache.get(sql); + if (!stmt) { + stmt = db.prepare(sql); + stmtCache.set(sql, stmt); + } + return stmt; + } + + // Periodic WAL checkpoint to keep -wal/-shm small + const checkpointTimer = setInterval(() => { + try { db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); } catch {} + }, CHECKPOINT_INTERVAL_MS); + if (typeof checkpointTimer.unref === "function") checkpointTimer.unref(); + + function gracefulClose() { + try { db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); } catch {} + try { stmtCache.clear(); } catch {} + try { db.close(); } catch {} + } + const onShutdown = () => gracefulClose(); + process.once("beforeExit", onShutdown); + process.once("SIGINT", () => { onShutdown(); process.exit(0); }); + process.once("SIGTERM", () => { onShutdown(); process.exit(0); }); + + return { + driver: "node:sqlite", + run(sql, params = []) { + const r = prepare(sql).run(...params); + return { changes: Number(r.changes ?? 0), lastInsertRowid: Number(r.lastInsertRowid ?? 0) }; + }, + get(sql, params = []) { + return prepare(sql).get(...params); + }, + all(sql, params = []) { + return prepare(sql).all(...params); + }, + exec(sql) { return db.exec(sql); }, + transaction(fn) { + // node:sqlite has no built-in transaction wrapper → manual BEGIN/COMMIT + db.exec("BEGIN"); + try { + const r = fn(); + db.exec("COMMIT"); + return r; + } catch (e) { + try { db.exec("ROLLBACK"); } catch {} + throw e; + } + }, + checkpoint() { try { db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); } catch {} }, + close() { + clearInterval(checkpointTimer); + gracefulClose(); + }, + raw: db, + }; +} diff --git a/src/lib/db/driver.js b/src/lib/db/driver.js index 417ef821..d3f09376 100644 --- a/src/lib/db/driver.js +++ b/src/lib/db/driver.js @@ -14,6 +14,19 @@ async function tryBetterSqlite() { } } +async function tryNodeSqlite() { + // Built-in since Node 22.5.0 — no install needed. + const [maj, min] = process.versions.node.split(".").map(Number); + if (maj < 22 || (maj === 22 && min < 5)) return null; + try { + const { createNodeSqliteAdapter } = await import("./adapters/nodeSqliteAdapter.js"); + return await createNodeSqliteAdapter(DATA_FILE); + } catch (e) { + console.warn(`[DB] node:sqlite unavailable: ${e.message}`); + return null; + } +} + async function trySqlJs() { try { const { createSqlJsAdapter } = await import("./adapters/sqljsAdapter.js"); @@ -26,9 +39,11 @@ async function trySqlJs() { async function initAdapter() { ensureDirs(); + // Order: native (fastest) → built-in (no install) → pure JS (universal) let adapter = await tryBetterSqlite(); + if (!adapter) adapter = await tryNodeSqlite(); if (!adapter) adapter = await trySqlJs(); - if (!adapter) throw new Error("[DB] No SQLite driver available (better-sqlite3 + sql.js both failed)"); + if (!adapter) throw new Error("[DB] No SQLite driver available (better-sqlite3 + node:sqlite + sql.js all failed)"); if (!state.logged) { console.log(`[DB] Driver: ${adapter.driver} | file: ${DATA_FILE}`); diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js index 2388c8d1..20c33071 100644 --- a/src/lib/db/repos/settingsRepo.js +++ b/src/lib/db/repos/settingsRepo.js @@ -1,7 +1,7 @@ import { getAdapter } from "../driver.js"; import { parseJson, stringifyJson } from "../helpers/jsonCol.js"; -const DEFAULT_MITM_ROUTER_BASE = "http://127.0.0.1:20128"; +const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128"; const DEFAULT_SETTINGS = { cloudEnabled: false, diff --git a/src/lib/tunnel/cloudflared.js b/src/lib/tunnel/cloudflared.js index d16155ab..3da1b243 100644 --- a/src/lib/tunnel/cloudflared.js +++ b/src/lib/tunnel/cloudflared.js @@ -197,6 +197,7 @@ export async function spawnCloudflared(tunnelToken) { const child = spawn(binaryPath, ["tunnel", "run", "--dns-resolver-addrs", "1.1.1.1:53", "--token", tunnelToken], { detached: false, windowsHide: true, + cwd: os.tmpdir(), stdio: ["ignore", "pipe", "pipe"] }); @@ -291,6 +292,7 @@ export async function spawnQuickTunnel(localPort, onUrlUpdate) { const child = spawn(binaryPath, ["tunnel", "--url", `http://127.0.0.1:${localPort}`, "--config", configPath, "--no-autoupdate"], { detached: false, windowsHide: true, + cwd: os.tmpdir(), env: { ...process.env, TUNNEL_TRANSPORT_PROTOCOL: tunnelProtocol, @@ -340,12 +342,14 @@ export async function spawnQuickTunnel(localPort, onUrlUpdate) { lastUrl = tunnelUrl; clearTimeout(timeout); cleanup(); + console.log(`[Tunnel] cloudflared URL: ${tunnelUrl}`); resolve({ child, tunnelUrl }); return; } // URL changed after initial connect — notify caller to re-register if (tunnelUrl !== lastUrl) { + console.log(`[Tunnel] cloudflared URL changed: ${tunnelUrl}`); lastUrl = tunnelUrl; if (onUrlUpdate) onUrlUpdate(tunnelUrl); } @@ -365,6 +369,7 @@ export async function spawnQuickTunnel(localPort, onUrlUpdate) { child.on("exit", (code, signal) => { cloudflaredProcess = null; clearPid(); + console.log(`[Tunnel] cloudflared exit code=${code} signal=${signal}`); if (!resolved) { resolved = true; clearTimeout(timeout); diff --git a/src/lib/tunnel/tailscale.js b/src/lib/tunnel/tailscale.js index 3f1d9922..c00676ec 100644 --- a/src/lib/tunnel/tailscale.js +++ b/src/lib/tunnel/tailscale.js @@ -419,18 +419,26 @@ async function ensureUserOwnedDir(dir) { /** Start tailscaled in userspace-networking mode (no root, no sudo prompt). */ export async function startDaemonWithPassword(_sudoPasswordUnused) { if (IS_WINDOWS) { - // Windows: tailscale runs as a Windows Service, try to start it - try { - const bin = getTailscaleBin(); - if (bin) { - execSync(`"${bin}" status --json`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); - return; // Already running - } - } catch { /* not running */ } - try { - execSync("net start Tailscale", { stdio: "ignore", windowsHide: true, timeout: 10000 }); - await new Promise((r) => setTimeout(r, 3000)); - } catch { /* may need admin, or already running */ } + // Windows: tailscale runs as a Windows Service. Start it then poll BackendState + // until daemon finishes init (avoids "NoState" errors when calling funnel/up too early). + const bin = getTailscaleBin(); + console.log("[Tailscale] win: net start Tailscale"); + try { execSync("net start Tailscale", { stdio: "ignore", windowsHide: true, timeout: 10000 }); } + catch { /* may need admin, or already running */ } + if (!bin) return; + // Poll up to ~10s for backend to leave NoState + for (let i = 0; i < 20; i++) { + try { + const out = execSync(`"${bin}" status --json`, { encoding: "utf8", windowsHide: true, timeout: 2000 }); + const j = JSON.parse(out); + if (j.BackendState && j.BackendState !== "NoState") { + console.log(`[Tailscale] win: BackendState=${j.BackendState} after ${i*500}ms`); + return; + } + } catch { /* daemon not ready */ } + await new Promise((r) => setTimeout(r, 500)); + } + console.log("[Tailscale] win: BackendState still NoState after poll"); return; } @@ -486,6 +494,7 @@ export async function startDaemonWithPassword(_sudoPasswordUnused) { const child = spawn(tailscaledBin, args, { detached: true, stdio: "ignore", + cwd: os.tmpdir(), env: { ...process.env, PATH: EXTENDED_PATH }, }); child.unref(); @@ -499,9 +508,24 @@ function ensureDaemon() { startDaemonWithPassword("").catch(() => {}); } +/** Read AuthURL from `tailscale status --json` (Win exposes it there, not stdout). */ +function getAuthUrlFromStatus() { + const bin = getTailscaleBin(); + if (!bin) return null; + try { + const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { + encoding: "utf8", windowsHide: true, timeout: 2000 + }); + const j = JSON.parse(out); + if (j.AuthURL) return j.AuthURL; + return null; + } catch { return null; } +} + /** * Run `tailscale up` and capture the auth URL for browser login. * Resolves with { authUrl } or { alreadyLoggedIn: true }. + * On Windows, AuthURL comes from `status --json` (not stdout) — must poll status. */ export function startLogin(hostname) { const bin = getTailscaleBin(); @@ -517,8 +541,8 @@ export function startLogin(hostname) { return; } - // Spawn detached so process survives API request lifecycle - const args = tsArgs("up", "--accept-routes"); + // Force re-auth on Win when device may have been removed from tailnet + const args = tsArgs("up", "--accept-routes", "--force-reauth"); if (hostname) args.push(`--hostname=${hostname}`); const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"], @@ -529,31 +553,42 @@ export function startLogin(hostname) { let resolved = false; let output = ""; - const timeout = setTimeout(() => { - if (resolved) return; - resolved = true; - // Don't kill — let tailscale up keep waiting for auth - child.unref(); - const url = parseAuthUrl(output); - if (url) resolve({ authUrl: url }); - else reject(new Error("tailscale up timed out without auth URL")); - }, 15000); - const parseAuthUrl = (text) => { const match = text.match(/https:\/\/login\.tailscale\.com\/a\/[a-zA-Z0-9]+/); return match ? match[0] : null; }; + const finishWithUrl = (url, source) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + clearInterval(statusPoll); + console.log(`[Tailscale] login authUrl detected (${source})`); + child.unref(); + resolve({ authUrl: url }); + }; + + // Poll status --json every 500ms — Windows exposes AuthURL only there + const statusPoll = setInterval(() => { + if (resolved) return; + const url = getAuthUrlFromStatus(); + if (url) finishWithUrl(url, "status"); + }, 500); + + const timeout = setTimeout(() => { + if (resolved) return; + resolved = true; + clearInterval(statusPoll); + child.unref(); + const url = parseAuthUrl(output) || getAuthUrlFromStatus(); + if (url) resolve({ authUrl: url }); + else reject(new Error("tailscale up timed out without auth URL")); + }, 15000); + const handleData = (data) => { output += data.toString(); const url = parseAuthUrl(output); - if (url && !resolved) { - resolved = true; - clearTimeout(timeout); - // Keep process alive — unref so it doesn't block Node exit - child.unref(); - resolve({ authUrl: url }); - } + if (url) finishWithUrl(url, "stdout"); }; child.stdout.on("data", handleData); @@ -563,17 +598,30 @@ export function startLogin(hostname) { if (resolved) return; resolved = true; clearTimeout(timeout); + clearInterval(statusPoll); + console.error(`[Tailscale] login spawn error: ${err.message}`); reject(err); }); child.on("exit", (code) => { if (resolved) return; - resolved = true; - clearTimeout(timeout); - const url = parseAuthUrl(output); - if (url) resolve({ authUrl: url }); - else if (code === 0 || isTailscaleLoggedIn()) resolve({ alreadyLoggedIn: true }); - else reject(new Error(`tailscale up exited with code ${code}: ${output.trim() || "no output"}`)); + console.log(`[Tailscale] login exit code=${code}`); + // Don't trust exit code alone — Win `tailscale up` exits 0 even when not logged in. + // Let status poll continue until AuthURL appears or timeout. + const url = parseAuthUrl(output) || getAuthUrlFromStatus(); + if (url) { + finishWithUrl(url, "exit"); + return; + } + // Only resolve alreadyLoggedIn if status confirms BackendState=Running + if (isTailscaleLoggedIn()) { + resolved = true; + clearTimeout(timeout); + clearInterval(statusPoll); + resolve({ alreadyLoggedIn: true }); + return; + } + // Otherwise keep polling — daemon may publish AuthURL shortly after exit }); }); } @@ -641,6 +689,7 @@ export async function startFunnel(port) { if (resolved) return; resolved = true; clearTimeout(timeout); + console.log(`[Tailscale] funnel exit code=${code} output="${output.trim().slice(0, 200)}"`); const url = parseFunnelUrl(output) || getTailscaleFunnelUrl(port); if (url) resolve({ tunnelUrl: url }); else reject(new Error(`tailscale funnel failed (code ${code}): ${output.trim()}`)); diff --git a/src/lib/tunnel/tunnelManager.js b/src/lib/tunnel/tunnelManager.js index 473f8854..56ece185 100644 --- a/src/lib/tunnel/tunnelManager.js +++ b/src/lib/tunnel/tunnelManager.js @@ -85,6 +85,7 @@ function throwIfCancelled(token, label) { } export async function enableTunnel(localPort = 20128) { + console.log(`[Tunnel] enable start (port=${localPort})`); tunnelSvc.cancelToken = { cancelled: false }; tunnelSvc.activeLocalPort = localPort; tunnelSvc.spawnInProgress = true; @@ -95,11 +96,13 @@ export async function enableTunnel(localPort = 20128) { const existing = loadState(); if (existing?.tunnelUrl && await probeUrlAlive(existing.tunnelUrl)) { const publicUrl = `https://r${existing.shortId}.9router.com`; + console.log(`[Tunnel] already running, reuse: ${existing.tunnelUrl}`); return { success: true, tunnelUrl: existing.tunnelUrl, shortId: existing.shortId, publicUrl, alreadyRunning: true }; } } killCloudflared(localPort); + console.log("[Tunnel] killed existing cloudflared"); throwIfCancelled(token, "tunnel"); const machineId = getMachineId(); @@ -108,36 +111,46 @@ export async function enableTunnel(localPort = 20128) { const onUrlUpdate = async (url) => { if (token.cancelled) return; + console.log(`[Tunnel] url updated: ${url}`); await registerTunnelUrl(shortId, url); saveState({ shortId, machineId, tunnelUrl: url }); await updateSettings({ tunnelEnabled: true, tunnelUrl: url }); }; const { tunnelUrl } = await spawnQuickTunnel(localPort, onUrlUpdate); + console.log(`[Tunnel] spawned: ${tunnelUrl}`); throwIfCancelled(token, "tunnel"); const publicUrl = `https://r${shortId}.9router.com`; await registerTunnelUrl(shortId, tunnelUrl); saveState({ shortId, machineId, tunnelUrl }); await updateSettings({ tunnelEnabled: true, tunnelUrl }); + console.log(`[Tunnel] registered shortId=${shortId} publicUrl=${publicUrl}`); // Verify direct tunnel URL is reachable first (avoid CDN-cache false positive on publicUrl) await waitForHealth(tunnelUrl, token); + console.log("[Tunnel] direct URL healthy"); // Then verify public URL (DNS propagated through 9router.com worker) await waitForHealth(publicUrl, token); + console.log("[Tunnel] public URL healthy"); // Prime reachable cache so UI shows correct state immediately tunnelReachable.value = true; tunnelReachable.url = tunnelUrl; tunnelReachable.fetchedAt = Date.now(); + console.log("[Tunnel] enable success"); return { success: true, tunnelUrl, shortId, publicUrl }; + } catch (e) { + console.error(`[Tunnel] enable error: ${e.message}`); + throw e; } finally { tunnelSvc.spawnInProgress = false; } } export async function disableTunnel() { + console.log("[Tunnel] disable"); tunnelSvc.cancelToken.cancelled = true; setUnexpectedExitHandler(null); killCloudflared(tunnelSvc.activeLocalPort); @@ -177,6 +190,7 @@ export async function getTunnelStatus() { // ─── Tailscale Funnel ───────────────────────────────────────────────────────── export async function enableTailscale(localPort = 20128) { + console.log(`[Tailscale] enable start (port=${localPort})`); tailscaleSvc.cancelToken = { cancelled: false }; tailscaleSvc.activeLocalPort = localPort; tailscaleSvc.spawnInProgress = true; @@ -185,36 +199,60 @@ export async function enableTailscale(localPort = 20128) { try { const sudoPass = getCachedPassword() || await loadEncryptedPassword() || ""; await startDaemonWithPassword(sudoPass); + console.log("[Tailscale] daemon ready"); throwIfCancelled(token, "tailscale"); const existing = loadState(); const shortId = existing?.shortId || generateShortId(); const tsHostname = shortId; - if (!isTailscaleLoggedIn()) { + const loggedIn = isTailscaleLoggedIn(); + console.log(`[Tailscale] loggedIn=${loggedIn}`); + if (!loggedIn) { const loginResult = await startLogin(tsHostname); - if (loginResult.authUrl) return { success: false, needsLogin: true, authUrl: loginResult.authUrl }; + if (loginResult.authUrl) { + console.log(`[Tailscale] needs login, authUrl=${loginResult.authUrl}`); + return { success: false, needsLogin: true, authUrl: loginResult.authUrl }; + } + console.log("[Tailscale] login resolved alreadyLoggedIn"); } throwIfCancelled(token, "tailscale"); stopFunnel(); - const result = await startFunnel(localPort); + let result; + try { + console.log("[Tailscale] starting funnel"); + result = await startFunnel(localPort); + } catch (e) { + console.error(`[Tailscale] funnel error: ${e.message}`); + // Daemon not logged in / not ready → auto-trigger login flow so user stays in-app + if (/NoState|unexpected state|not logged in|Logged ?out|NeedsLogin/i.test(e.message || "")) { + console.log("[Tailscale] retry via startLogin"); + const loginResult = await startLogin(tsHostname); + if (loginResult.authUrl) return { success: false, needsLogin: true, authUrl: loginResult.authUrl }; + } + throw e; + } throwIfCancelled(token, "tailscale"); if (result.funnelNotEnabled) { + console.log(`[Tailscale] funnel not enabled, enableUrl=${result.enableUrl}`); return { success: false, funnelNotEnabled: true, enableUrl: result.enableUrl }; } // Strict probe: bypass cache so we don't false-negative on first invocation if (!isTailscaleLoggedIn() || !isTailscaleRunningStrict()) { + console.error("[Tailscale] strict probe failed (device removed?)"); stopFunnel(); return { success: false, error: "Tailscale not connected. Device may have been removed. Please re-login." }; } await updateSettings({ tailscaleEnabled: true, tailscaleUrl: result.tunnelUrl }); + console.log(`[Tailscale] funnel up: ${result.tunnelUrl}`); // Verify funnel actually serves /api/health await waitForHealth(result.tunnelUrl, token); + console.log("[Tailscale] enable success"); // Prime reachable cache so UI shows correct state immediately tailscaleReachable.value = true; @@ -222,12 +260,16 @@ export async function enableTailscale(localPort = 20128) { tailscaleReachable.fetchedAt = Date.now(); return { success: true, tunnelUrl: result.tunnelUrl }; + } catch (e) { + console.error(`[Tailscale] enable error: ${e.message}`); + throw e; } finally { tailscaleSvc.spawnInProgress = false; } } export async function disableTailscale() { + console.log("[Tailscale] disable"); tailscaleSvc.cancelToken.cancelled = true; stopFunnel(); await updateSettings({ tailscaleEnabled: false, tailscaleUrl: "" }); diff --git a/src/lib/updater/updater.js b/src/lib/updater/updater.js index ec3f4d03..0ad48af8 100644 --- a/src/lib/updater/updater.js +++ b/src/lib/updater/updater.js @@ -172,6 +172,29 @@ function runInstall() { }); } +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; @@ -189,6 +212,8 @@ function relaunchApp() { }); 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}`); } diff --git a/src/mitm/handlers/base.js b/src/mitm/handlers/base.js index 6baedf3b..74ac35bb 100644 --- a/src/mitm/handlers/base.js +++ b/src/mitm/handlers/base.js @@ -1,6 +1,6 @@ const { log, err } = require("../logger"); -const DEFAULT_LOCAL_ROUTER = "http://127.0.0.1:20128"; +const DEFAULT_LOCAL_ROUTER = "http://localhost:20128"; const ROUTER_BASE = String(process.env.MITM_ROUTER_BASE || DEFAULT_LOCAL_ROUTER) .trim() .replace(/\/+$/, "") || DEFAULT_LOCAL_ROUTER; diff --git a/src/mitm/manager.js b/src/mitm/manager.js index 12384157..6ff000db 100644 --- a/src/mitm/manager.js +++ b/src/mitm/manager.js @@ -16,7 +16,7 @@ const { isCertExpired } = require("./cert/rootCA"); const { DATA_DIR, MITM_DIR } = require("./paths"); const { log, err } = require("./logger"); -const DEFAULT_MITM_ROUTER_BASE = "http://127.0.0.1:20128"; +const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128"; function shellQuoteSingle(str) { if (str == null || str === "") return "''"; @@ -576,12 +576,14 @@ async function startServer(apiKey, sudoPassword, forceKillPort443 = false) { } // Spawn directly — process already has admin rights + // cwd=tmpdir so process doesn't lock the install dir on Windows (EBUSY on update) serverProcess = spawn( process.execPath, [effectiveServerPath], { detached: false, windowsHide: true, + cwd: os.tmpdir(), stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, @@ -615,6 +617,7 @@ async function startServer(apiKey, sudoPassword, forceKillPort443 = false) { serverProcess = spawn(process.execPath, [effectiveServerPath], { detached: false, windowsHide: true, + cwd: os.tmpdir(), stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index f21e8a39..bec3a639 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -46,12 +46,11 @@ export default function Sidebar({ onClose }) { const [updateInfo, setUpdateInfo] = useState(null); const [showUpdateModal, setShowUpdateModal] = useState(false); const [isUpdating, setIsUpdating] = useState(false); - const [updateStatus, setUpdateStatus] = useState(null); + const [shutdownCountdown, setShutdownCountdown] = useState(0); const [enableTranslator, setEnableTranslator] = useState(false); const { copied, copy } = useCopyToClipboard(2000); - const INSTALL_CMD = UPDATER_CONFIG.installCmd; - const STATUS_URL = `http://127.0.0.1:${UPDATER_CONFIG.statusPort}/update/status`; + const INSTALL_CMD = UPDATER_CONFIG.installCmdLatest; useEffect(() => { fetch("/api/settings") @@ -75,40 +74,37 @@ export default function Sidebar({ onClose }) { return pathname.startsWith(href); }; - const handleUpdate = async () => { - setIsUpdating(true); + // Open manual update panel (no countdown yet — user must click Copy to trigger shutdown) + const handleUpdate = () => { setShowUpdateModal(false); - try { - const res = await fetch("/api/version/update", { method: "POST" }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - alert(data.message || "Update failed. Please run the install command manually."); - setIsUpdating(false); - return; - } - setIsDisconnected(true); - } catch (e) { - setIsDisconnected(true); - } + setIsUpdating(true); }; - // Poll updater status server while updating (Next server is dead, updater.js is alive) - useEffect(() => { - if (!isUpdating || !isDisconnected) return; - let stopped = false; - const tick = async () => { - try { - const res = await fetch(STATUS_URL, { cache: "no-store" }); - if (res.ok) { - const data = await res.json(); - if (!stopped) setUpdateStatus(data); - } - } catch { /* updater not ready yet or finished */ } - }; - tick(); - const id = setInterval(tick, UPDATER_CONFIG.statusPollIntervalMs); - return () => { stopped = true; clearInterval(id); }; - }, [isUpdating, isDisconnected, STATUS_URL]); + // 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. + const handleShutdown = async () => { setIsShuttingDown(true); @@ -364,23 +360,24 @@ export default function Sidebar({ onClose }) { onClose={() => setShowUpdateModal(false)} onConfirm={handleUpdate} title="Update 9Router" - message={`This will close 9Router and install v${updateInfo?.latestVersion || ""} in a separate window. Continue?`} - confirmText="Update" + message={`Show install command for v${updateInfo?.latestVersion || ""}? You can copy it and shutdown to install manually.`} + confirmText="Show Command" cancelText="Cancel" variant="primary" - loading={isUpdating} /> - {/* Disconnected Overlay */} - {isDisconnected && ( + {/* Disconnected / Updating Overlay */} + {(isDisconnected || isUpdating) && (
{isUpdating ? ( - copy(INSTALL_CMD)} + onCopyAndShutdown={handleCopyAndShutdown} + onCancel={handleCancelUpdate} + countdown={shutdownCountdown} + isDisconnected={isDisconnected} /> ) : (
@@ -404,136 +401,61 @@ Sidebar.propTypes = { onClose: PropTypes.func, }; -function UpdateProgress({ status, latestVersion, installCmd, copied, onCopy }) { - const phase = status?.phase || "connecting"; - const done = status?.done === true; - const success = status?.success === true; - const attempt = status?.attempt || 0; - const maxRetries = status?.maxRetries || 0; - const logTail = status?.logTail || []; - const errorMsg = status?.error; - - const steps = [ - { key: "stopped", label: "Stopped 9Router server", state: "done" }, - { - key: "launched", - label: "Launched background installer", - state: status ? "done" : "active", - }, - { - key: "waiting", - label: "Waiting for app processes to exit", - state: phase === "waitingForExit" ? "active" : - (status && phase !== "starting" ? "done" : "pending"), - }, - { - key: "installing", - label: attempt > 1 ? `Installing v${latestVersion || "latest"} (attempt ${attempt}/${maxRetries})` : `Installing v${latestVersion || "latest"}`, - state: done ? (success ? "done" : "error") : (phase === "installing" ? "active" : "pending"), - }, - { - key: "finished", - label: done && success ? "Installed — ready to restart" : "Waiting to finish", - state: done && success ? "done" : (done && !success ? "error" : "pending"), - }, - ]; - +function ManualUpdatePanel({ latestVersion, installCmd, copied, onCopyAndShutdown, onCancel, countdown, isDisconnected }) { + const isCountingDown = countdown > 0; return (
-
- - {done && success ? "check_circle" : done && !success ? "error" : "progress_activity"} - +
+ content_copy
-

- {done && success ? "Update Completed" : done && !success ? "Update Failed" : "Updating 9Router"} -

+

Update 9Router{latestVersion ? ` to v${latestVersion}` : ""}

- {done && success - ? `Installed v${latestVersion || "latest"} successfully` - : done && !success - ? (errorMsg || "Installation failed") - : `Installing v${latestVersion || "latest"} from npm...`} + {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."}

- {/* Timeline */} -
    - {steps.map((s) => ( -
  • - - {s.state === "done" ? "check_circle" : - s.state === "error" ? "cancel" : - s.state === "active" ? "radio_button_checked" : "radio_button_unchecked"} - - {s.label} -
  • - ))} -
+

Install command:

+
+ {installCmd} +
- {/* Log tail */} - {logTail.length > 0 && ( -
-
-            {logTail.join("\n")}
-          
-
- )} +
    +
  1. Click Copy & Shutdown below.
  2. +
  3. Paste the command into your terminal and press Enter.
  4. +
  5. Run 9router again after install.
  6. +
- {/* Actions */} - {done && success ? ( -
-

- Run 9router in your terminal to start the new version. -

- + ) : ( +
+ +
- ) : done && !success ? ( -
-

Run the install command manually:

- -
- ) : ( -

- This may take 30-60 seconds. Please don't close this window. -

)}
); } -UpdateProgress.propTypes = { - status: PropTypes.object, +ManualUpdatePanel.propTypes = { latestVersion: PropTypes.string, installCmd: PropTypes.string.isRequired, copied: PropTypes.bool, - onCopy: PropTypes.func.isRequired, + onCopyAndShutdown: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + countdown: PropTypes.number, + isDisconnected: PropTypes.bool, }; diff --git a/src/shared/constants/config.js b/src/shared/constants/config.js index dc1592eb..4253bdac 100644 --- a/src/shared/constants/config.js +++ b/src/shared/constants/config.js @@ -16,6 +16,8 @@ export const GITHUB_CONFIG = { 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, @@ -23,8 +25,8 @@ export const UPDATER_CONFIG = { installRetries: 3, installRetryDelayMs: 5000, lingerAfterDoneMs: 30000, - waitForExitMinMs: 3000, - waitForExitMaxMs: 15000, + waitForExitMinMs: 5000, + waitForExitMaxMs: 20000, waitForExitCheckMs: 500, appPort: 20128, }; diff --git a/src/shared/services/cloudSyncScheduler.js b/src/shared/services/cloudSyncScheduler.js index 0250206c..44de2489 100644 --- a/src/shared/services/cloudSyncScheduler.js +++ b/src/shared/services/cloudSyncScheduler.js @@ -4,7 +4,7 @@ import { isCloudEnabled } from "@/lib/localDb"; const INTERNAL_BASE_URL = process.env.BASE_URL || process.env.NEXT_PUBLIC_BASE_URL || - "http://127.0.0.1:20128"; + "http://localhost:20128"; /** * Cloud sync scheduler diff --git a/tests/unit/db-driver-chain.test.js b/tests/unit/db-driver-chain.test.js new file mode 100644 index 00000000..cc96d5aa --- /dev/null +++ b/tests/unit/db-driver-chain.test.js @@ -0,0 +1,59 @@ +// Verify 3-tier driver fallback: better-sqlite3 → node:sqlite → sql.js +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +let tempDir; +const originalDataDir = process.env.DATA_DIR; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-chain-")); + process.env.DATA_DIR = tempDir; + delete global._dbAdapter; + vi.resetModules(); +}); + +afterEach(() => { + try { global._dbAdapter?.instance?.close?.(); } catch {} + delete global._dbAdapter; + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; +}); + +describe("Driver fallback chain", () => { + it("default → picks better-sqlite3 when available", async () => { + const { getAdapter } = await import("@/lib/db/driver.js"); + const db = await getAdapter(); + expect(["better-sqlite3", "node:sqlite", "sql.js"]).toContain(db.driver); + }); + + it("falls back to node:sqlite when better-sqlite3 unavailable", async () => { + // Mock the better-sqlite3 adapter to throw + vi.doMock("@/lib/db/adapters/betterSqliteAdapter.js", () => { + throw new Error("simulated unavailable"); + }); + const { getAdapter } = await import("@/lib/db/driver.js"); + const db = await getAdapter(); + // Node 22.5+ should give node:sqlite, else sql.js + const [maj, min] = process.versions.node.split(".").map(Number); + if (maj > 22 || (maj === 22 && min >= 5)) { + expect(db.driver).toBe("node:sqlite"); + } else { + expect(db.driver).toBe("sql.js"); + } + }); + + it("falls back to sql.js when both native drivers unavailable", async () => { + vi.doMock("@/lib/db/adapters/betterSqliteAdapter.js", () => { + throw new Error("simulated unavailable"); + }); + vi.doMock("@/lib/db/adapters/nodeSqliteAdapter.js", () => { + throw new Error("simulated unavailable"); + }); + const { getAdapter } = await import("@/lib/db/driver.js"); + const db = await getAdapter(); + expect(db.driver).toBe("sql.js"); + }); +});