From 75ad0bef8e793f59ca668ab393a7c77ab1b5d2b2 Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 16 Apr 2026 10:58:35 +0700 Subject: [PATCH] Refactor error handling and localDb structure, and fix usage tracking bug. --- CHANGELOG.md | 15 +++ package.json | 2 +- .../cli-tools/components/ClaudeToolCard.js | 2 +- .../dashboard/endpoint/EndpointPageClient.js | 23 +++++ .../api/cli-tools/claude-settings/route.js | 1 + src/app/api/tunnel/status/route.js | 4 +- src/lib/dataDir.js | 14 +++ src/lib/localDb.js | 22 +---- src/lib/requestDetailsDb.js | 22 +---- src/lib/tunnel/cloudflared.js | 94 ++++++++++++++++--- src/lib/tunnel/state.js | 4 +- src/lib/tunnel/tailscale.js | 23 ++++- src/lib/usageDb.js | 45 +-------- 13 files changed, 162 insertions(+), 109 deletions(-) create mode 100644 src/lib/dataDir.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7862d6c..c3900d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +# v0.3.91 (2026-04-15) + +## Features +- Add Kiro AWS Identity Center device flow for provider OAuth +- Add TTS (Text-to-Speech) core handler and TTS models config +- Add media providers dashboard page +- Add suggested models API endpoint + +## Improvements +- Refactor error handling to config-driven approach with centralized error rules +- Refactor localDb and usageDb for cleaner structure + +## Fixes +- Fix usage tracking bug + # v0.3.90 (2026-04-14) ## Features diff --git a/package.json b/package.json index 9e3acc8..4623358 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.3.91", + "version": "0.3.94", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index e518b9b..71b6c0a 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -217,7 +217,7 @@ export default function ClaudeToolCard({ return [ { filename: "~/.claude/settings.json", - content: JSON.stringify({ env }, null, 2), + content: JSON.stringify({ hasCompletedOnboarding: true, env }, null, 2), }, ]; }; diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index e7b8601..f875a44 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -226,8 +226,30 @@ export default function APIPageClient({ machineId }) { setTunnelLoading(true); setTunnelStatus(null); setTunnelProgress("Creating tunnel..."); + + // Poll download progress while enable request is pending + let polling = true; + const pollProgress = async () => { + while (polling) { + try { + const r = await fetch("/api/tunnel/status"); + if (r.ok) { + const s = await r.json(); + if (s.download?.downloading) { + setTunnelProgress(`Downloading cloudflared... ${s.download.progress}%`); + } else if (polling) { + setTunnelProgress("Creating tunnel..."); + } + } + } catch { /* ignore */ } + await new Promise((r) => setTimeout(r, 1000)); + } + }; + pollProgress(); + try { const res = await fetch("/api/tunnel/enable", { method: "POST" }); + polling = false; const data = await res.json(); if (!res.ok) { setTunnelStatus({ type: "error", message: data.error || "Failed to enable tunnel" }); @@ -246,6 +268,7 @@ export default function APIPageClient({ machineId }) { } catch (error) { setTunnelStatus({ type: "error", message: error.message }); } finally { + polling = false; setTunnelLoading(false); setTunnelProgress(""); } diff --git a/src/app/api/cli-tools/claude-settings/route.js b/src/app/api/cli-tools/claude-settings/route.js index d3251c7..121879d 100644 --- a/src/app/api/cli-tools/claude-settings/route.js +++ b/src/app/api/cli-tools/claude-settings/route.js @@ -120,6 +120,7 @@ export async function POST(request) { // Merge new env with existing settings const newSettings = { ...currentSettings, + hasCompletedOnboarding: true, env: { ...(currentSettings.env || {}), ...env, diff --git a/src/app/api/tunnel/status/route.js b/src/app/api/tunnel/status/route.js index c651cdc..907cb77 100644 --- a/src/app/api/tunnel/status/route.js +++ b/src/app/api/tunnel/status/route.js @@ -1,10 +1,12 @@ import { NextResponse } from "next/server"; import { getTunnelStatus, getTailscaleStatus } from "@/lib/tunnel/tunnelManager"; +import { getDownloadStatus } from "@/lib/tunnel/cloudflared"; export async function GET() { try { const [tunnel, tailscale] = await Promise.all([getTunnelStatus(), getTailscaleStatus()]); - return NextResponse.json({ tunnel, tailscale }); + const download = getDownloadStatus(); + return NextResponse.json({ tunnel, tailscale, download }); } catch (error) { console.error("Tunnel status error:", error); return NextResponse.json({ error: error.message }, { status: 500 }); diff --git a/src/lib/dataDir.js b/src/lib/dataDir.js new file mode 100644 index 0000000..946a708 --- /dev/null +++ b/src/lib/dataDir.js @@ -0,0 +1,14 @@ +import path from "path"; +import os from "os"; + +const APP_NAME = "9router"; + +export 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"), APP_NAME); + } + return path.join(os.homedir(), `.${APP_NAME}`); +} + +export const DATA_DIR = getDataDir(); diff --git a/src/lib/localDb.js b/src/lib/localDb.js index bb89702..f4ec5b4 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -2,32 +2,12 @@ import { Low } from "lowdb"; import { JSONFile } from "lowdb/node"; import { v4 as uuidv4 } from "uuid"; import path from "node:path"; -import os from "node:os"; import fs from "node:fs"; import lockfile from "proper-lockfile"; +import { DATA_DIR } from "@/lib/dataDir.js"; const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128"; const isCloud = typeof caches !== 'undefined' || typeof caches === 'object'; - -function getAppName() { - return "9router"; -} - -function getUserDataDir() { - if (isCloud) return "/tmp"; - if (process.env.DATA_DIR) return process.env.DATA_DIR; - - const platform = process.platform; - const homeDir = os.homedir(); - const appName = getAppName(); - - if (platform === "win32") { - return path.join(process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), appName); - } - return path.join(homeDir, `.${appName}`); -} - -const DATA_DIR = getUserDataDir(); const DB_FILE = isCloud ? null : path.join(DATA_DIR, "db.json"); if (!isCloud && !fs.existsSync(DATA_DIR)) { diff --git a/src/lib/requestDetailsDb.js b/src/lib/requestDetailsDb.js index da03c8e..e6526e2 100644 --- a/src/lib/requestDetailsDb.js +++ b/src/lib/requestDetailsDb.js @@ -1,8 +1,8 @@ import { Low } from "lowdb"; import { JSONFile } from "lowdb/node"; import path from "node:path"; -import os from "node:os"; import fs from "node:fs"; +import { DATA_DIR } from "@/lib/dataDir.js"; const isCloud = typeof caches !== "undefined" && typeof caches === "object"; @@ -12,26 +12,6 @@ const DEFAULT_FLUSH_INTERVAL_MS = 5000; const DEFAULT_MAX_JSON_SIZE = 5 * 1024; // 5KB default, configurable via settings const CONFIG_CACHE_TTL_MS = 5000; const MAX_TOTAL_DB_SIZE = 50 * 1024 * 1024; // 50MB hard limit for total DB file - -function getAppName() { - return "9router"; -} - -function getUserDataDir() { - if (isCloud) return "/tmp"; - if (process.env.DATA_DIR) return process.env.DATA_DIR; - - const platform = process.platform; - const homeDir = os.homedir(); - const appName = getAppName(); - - if (platform === "win32") { - return path.join(process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), appName); - } - return path.join(homeDir, `.${appName}`); -} - -const DATA_DIR = getUserDataDir(); const DB_FILE = isCloud ? null : path.join(DATA_DIR, "request-details.json"); if (!isCloud && !fs.existsSync(DATA_DIR)) { diff --git a/src/lib/tunnel/cloudflared.js b/src/lib/tunnel/cloudflared.js index a17b6be..882fadb 100644 --- a/src/lib/tunnel/cloudflared.js +++ b/src/lib/tunnel/cloudflared.js @@ -4,15 +4,15 @@ import https from "https"; import os from "os"; import { execSync, spawn } from "child_process"; import { savePid, loadPid, clearPid } from "./state.js"; +import { DATA_DIR } from "@/lib/dataDir.js"; -const BIN_DIR = path.join(os.homedir(), ".9router", "bin"); +const BIN_DIR = path.join(DATA_DIR, "bin"); const BINARY_NAME = "cloudflared"; const IS_WINDOWS = os.platform() === "win32"; const BIN_NAME = IS_WINDOWS ? `${BINARY_NAME}.exe` : BINARY_NAME; const BIN_PATH = path.join(BIN_DIR, BIN_NAME); -const CLOUDFLARED_VERSION = "2026.3.0"; -const GITHUB_BASE_URL = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}`; +const GITHUB_BASE_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download"; const PLATFORM_MAPPINGS = { darwin: { @@ -21,7 +21,8 @@ const PLATFORM_MAPPINGS = { }, win32: { x64: "cloudflared-windows-amd64.exe", - x32: "cloudflared-windows-386.exe" + ia32: "cloudflared-windows-386.exe", + arm64: "cloudflared-windows-386.exe" }, linux: { x64: "cloudflared-linux-amd64", @@ -29,6 +30,13 @@ const PLATFORM_MAPPINGS = { } }; +// Fallback order: prefer smallest/most-compatible binary per platform +const PLATFORM_FALLBACK = { + darwin: "cloudflared-darwin-amd64.tgz", + win32: "cloudflared-windows-386.exe", + linux: "cloudflared-linux-amd64" +}; + function getDownloadUrl() { const platform = os.platform(); const arch = os.arch(); @@ -38,20 +46,23 @@ function getDownloadUrl() { throw new Error(`Unsupported platform: ${platform}`); } - const binaryName = platformMapping[arch]; - if (!binaryName) { - throw new Error(`Unsupported architecture: ${arch} for platform ${platform}`); - } - + const binaryName = platformMapping[arch] || PLATFORM_FALLBACK[platform]; return `${GITHUB_BASE_URL}/${binaryName}`; } +// Download state — shared so status API can read it +const dlState = { downloading: false, progress: 0 }; + +export function getDownloadStatus() { + return { downloading: dlState.downloading, progress: dlState.progress }; +} + function downloadFile(url, dest) { return new Promise((resolve, reject) => { const file = fs.createWriteStream(dest); https.get(url, (response) => { - if ([301, 302].includes(response.statusCode)) { + if ([301, 302, 303, 307, 308].includes(response.statusCode)) { file.close(); fs.unlinkSync(dest); downloadFile(response.headers.location, dest).then(resolve).catch(reject); @@ -65,18 +76,34 @@ function downloadFile(url, dest) { return; } + const totalBytes = parseInt(response.headers["content-length"], 10) || 0; + let receivedBytes = 0; + dlState.downloading = true; + dlState.progress = 0; + + response.on("data", (chunk) => { + receivedBytes += chunk.length; + if (totalBytes > 0) dlState.progress = Math.round((receivedBytes / totalBytes) * 100); + }); + response.pipe(file); file.on("finish", () => { + dlState.downloading = false; + dlState.progress = 100; file.close(() => resolve(dest)); }); file.on("error", (err) => { + dlState.downloading = false; + dlState.progress = 0; file.close(); fs.unlinkSync(dest); reject(err); }); }).on("error", (err) => { + dlState.downloading = false; + dlState.progress = 0; file.close(); if (fs.existsSync(dest)) fs.unlinkSync(dest); reject(err); @@ -84,27 +111,66 @@ function downloadFile(url, dest) { }); } +const MIN_BINARY_SIZE = 1024 * 1024; // 1MB - cloudflared is ~30MB+ + +// Validate binary is executable on current platform and not truncated +function isValidBinary(filePath) { + try { + const stat = fs.statSync(filePath); + if (stat.size < MIN_BINARY_SIZE) return false; + const fd = fs.openSync(filePath, "r"); + const buf = Buffer.alloc(4); + fs.readSync(fd, buf, 0, 4, 0); + fs.closeSync(fd); + const magic = buf.toString("hex"); + if (IS_WINDOWS) return magic.startsWith("4d5a"); // PE (MZ) + if (os.platform() === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); + return magic.startsWith("7f454c46"); // ELF (Linux) + } catch { + return false; + } +} + +let downloadPromise = null; + export async function ensureCloudflared() { + if (downloadPromise) return downloadPromise; + downloadPromise = _ensureCloudflared().finally(() => { downloadPromise = null; }); + return downloadPromise; +} + +async function _ensureCloudflared() { if (!fs.existsSync(BIN_DIR)) { fs.mkdirSync(BIN_DIR, { recursive: true }); } + // Clean up incomplete downloads from previous runs + const tmpPath = `${BIN_PATH}.tmp`; + if (fs.existsSync(tmpPath)) { + try { fs.unlinkSync(tmpPath); } catch { /* ignore */ } + } + if (fs.existsSync(BIN_PATH)) { - if (!IS_WINDOWS) { - fs.chmodSync(BIN_PATH, "755"); + if (!isValidBinary(BIN_PATH)) { + console.log("[cloudflared] Invalid binary detected, re-downloading..."); + fs.unlinkSync(BIN_PATH); + } else { + if (!IS_WINDOWS) fs.chmodSync(BIN_PATH, "755"); + return BIN_PATH; } - return BIN_PATH; } const url = getDownloadUrl(); const isArchive = url.endsWith(".tgz"); - const downloadDest = isArchive ? path.join(BIN_DIR, "cloudflared.tgz") : BIN_PATH; + const downloadDest = isArchive ? path.join(BIN_DIR, "cloudflared.tgz.tmp") : tmpPath; await downloadFile(url, downloadDest); if (isArchive) { execSync(`tar -xzf "${downloadDest}" -C "${BIN_DIR}"`, { stdio: "pipe", windowsHide: true }); fs.unlinkSync(downloadDest); + } else { + fs.renameSync(downloadDest, BIN_PATH); } if (!IS_WINDOWS) { diff --git a/src/lib/tunnel/state.js b/src/lib/tunnel/state.js index cbb386b..72fcfdb 100644 --- a/src/lib/tunnel/state.js +++ b/src/lib/tunnel/state.js @@ -1,8 +1,8 @@ import fs from "fs"; import path from "path"; -import os from "os"; +import { DATA_DIR } from "@/lib/dataDir.js"; -const TUNNEL_DIR = path.join(os.homedir(), ".9router", "tunnel"); +const TUNNEL_DIR = path.join(DATA_DIR, "tunnel"); const STATE_FILE = path.join(TUNNEL_DIR, "state.json"); const CLOUDFLARED_PID_FILE = path.join(TUNNEL_DIR, "cloudflared.pid"); const TAILSCALE_PID_FILE = path.join(TUNNEL_DIR, "tailscale.pid"); diff --git a/src/lib/tunnel/tailscale.js b/src/lib/tunnel/tailscale.js index abafec1..4ccad5a 100644 --- a/src/lib/tunnel/tailscale.js +++ b/src/lib/tunnel/tailscale.js @@ -4,15 +4,16 @@ import os from "os"; import { execSync, spawn } from "child_process"; import { execWithPassword } from "@/mitm/dns/dnsConfig"; import { saveTailscalePid, loadTailscalePid, clearTailscalePid } from "./state.js"; +import { DATA_DIR } from "@/lib/dataDir.js"; -const BIN_DIR = path.join(os.homedir(), ".9router", "bin"); +const BIN_DIR = path.join(DATA_DIR, "bin"); const IS_MAC = os.platform() === "darwin"; const IS_LINUX = os.platform() === "linux"; const IS_WINDOWS = os.platform() === "win32"; const TAILSCALE_BIN = path.join(BIN_DIR, IS_WINDOWS ? "tailscale.exe" : "tailscale"); // Custom socket for userspace-networking mode (no root required) -const TAILSCALE_DIR = path.join(os.homedir(), ".9router", "tailscale"); +const TAILSCALE_DIR = path.join(DATA_DIR, "tailscale"); export const TAILSCALE_SOCKET = path.join(TAILSCALE_DIR, "tailscaled.sock"); const SOCKET_FLAG = IS_WINDOWS ? [] : ["--socket", TAILSCALE_SOCKET]; @@ -281,7 +282,21 @@ async function installTailscaleWindows(log) { /** Start tailscaled with sudo (TUN mode required for Funnel) */ export async function startDaemonWithPassword(sudoPassword) { - if (IS_WINDOWS) return; + 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 */ } + return; + } // Check if daemon already responds try { @@ -387,7 +402,7 @@ export function startLogin(hostname) { clearTimeout(timeout); const url = parseAuthUrl(output); if (url) resolve({ authUrl: url }); - else if (isTailscaleLoggedIn()) resolve({ alreadyLoggedIn: true }); + else if (code === 0 || isTailscaleLoggedIn()) resolve({ alreadyLoggedIn: true }); else reject(new Error(`tailscale up exited with code ${code}`)); }); }); diff --git a/src/lib/usageDb.js b/src/lib/usageDb.js index 0b763d7..6d9bf03 100644 --- a/src/lib/usageDb.js +++ b/src/lib/usageDb.js @@ -2,53 +2,10 @@ import { Low } from "lowdb"; import { JSONFile } from "lowdb/node"; import { EventEmitter } from "events"; import path from "path"; -import os from "os"; import fs from "fs"; -import { fileURLToPath } from "url"; +import { DATA_DIR } from "@/lib/dataDir.js"; const isCloud = typeof caches !== 'undefined' || typeof caches === 'object'; - -// Get app name from root package.json config -function getAppName() { - if (isCloud) return "9router"; // Skip file system access in Workers - - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - // Look for root package.json (monorepo root) - const rootPkgPath = path.resolve(__dirname, "../../../package.json"); - try { - const pkg = JSON.parse(fs.readFileSync(rootPkgPath, "utf-8")); - return pkg.config?.appName || "9router"; - } catch { - return "9router"; - } -} - -// Get user data directory based on platform -function getUserDataDir() { - if (isCloud) return "/tmp"; // Fallback for Workers - - if (process.env.DATA_DIR) return process.env.DATA_DIR; - - try { - const platform = process.platform; - const homeDir = os.homedir(); - const appName = getAppName(); - - if (platform === "win32") { - return path.join(process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), appName); - } else { - // macOS & Linux: ~/.{appName} - return path.join(homeDir, `.${appName}`); - } - } catch (error) { - console.error("[usageDb] Failed to get user data directory:", error.message); - // Fallback to cwd if homedir fails - return path.join(process.cwd(), ".9router"); - } -} - -// Data file path - stored in user home directory -const DATA_DIR = getUserDataDir(); const DB_FILE = isCloud ? null : path.join(DATA_DIR, "usage.json"); const LOG_FILE = isCloud ? null : path.join(DATA_DIR, "log.txt");