diff --git a/open-sse/config/providers.js b/open-sse/config/providers.js index 91a3132..b3a077f 100644 --- a/open-sse/config/providers.js +++ b/open-sse/config/providers.js @@ -33,6 +33,7 @@ export const PROVIDERS = { claude: { baseUrl: "https://api.anthropic.com/v1/messages", format: "claude", + retry: { 429: 0 }, headers: { "Anthropic-Version": "2023-06-01", "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05", diff --git a/open-sse/config/runtimeConfig.js b/open-sse/config/runtimeConfig.js index 19ac15a..b72ce83 100644 --- a/open-sse/config/runtimeConfig.js +++ b/open-sse/config/runtimeConfig.js @@ -60,12 +60,19 @@ export const MEMORY_CONFIG = { export const DEFAULT_MAX_TOKENS = 64000; export const DEFAULT_MIN_TOKENS = 32000; -// Retry config for 429 responses +// Retry config for 429 responses (legacy - kept for backward compatibility) export const RETRY_CONFIG = { maxAttempts: 2, delayMs: 2000 }; +// Default retry config by status code (number of retry attempts) +export const DEFAULT_RETRY_CONFIG = { + 429: 2, // Rate limit - retry 2 times + 503: 0, // Service unavailable - no retry + 502: 0 // Bad gateway - no retry +}; + // Exponential backoff config for rate limits export const BACKOFF_CONFIG = { base: 1000, diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index 61fb0f6..74ca14d 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -1,4 +1,4 @@ -import { HTTP_STATUS, RETRY_CONFIG } from "../config/runtimeConfig.js"; +import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG } from "../config/runtimeConfig.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; /** @@ -81,6 +81,9 @@ export class BaseExecutor { let lastError = null; let lastStatus = 0; const retryAttemptsByUrl = {}; + + // Merge default retry config with provider-specific config + const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...this.config.retry }; for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, stream, urlIndex, credentials); @@ -97,10 +100,11 @@ export class BaseExecutor { signal }, proxyOptions); - // Retry 429 with fixed delay before falling back to next URL - if (response.status === HTTP_STATUS.RATE_LIMITED && retryAttemptsByUrl[urlIndex] < RETRY_CONFIG.maxAttempts) { + // Retry based on status code config + const maxRetries = retryConfig[response.status] || 0; + if (maxRetries > 0 && retryAttemptsByUrl[urlIndex] < maxRetries) { retryAttemptsByUrl[urlIndex]++; - log?.debug?.("RETRY", `429 retry ${retryAttemptsByUrl[urlIndex]}/${RETRY_CONFIG.maxAttempts} after ${RETRY_CONFIG.delayMs / 1000}s`); + log?.debug?.("RETRY", `${response.status} retry ${retryAttemptsByUrl[urlIndex]}/${maxRetries} after ${RETRY_CONFIG.delayMs / 1000}s`); await new Promise(resolve => setTimeout(resolve, RETRY_CONFIG.delayMs)); urlIndex--; continue; diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index cc9ebdc..aeb612e 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -3,6 +3,7 @@ import { PROVIDERS } from "../config/providers.js"; import { v4 as uuidv4 } from "uuid"; import { refreshKiroToken } from "../services/tokenRefresh.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG } from "../config/runtimeConfig.js"; /** * KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer) @@ -32,29 +33,45 @@ export class KiroExecutor extends BaseExecutor { } /** - * Custom execute for Kiro - handles AWS EventStream binary response + * Custom execute for Kiro - handles AWS EventStream binary response with retry support */ async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { const url = this.buildUrl(model, stream, 0); - const headers = this.buildHeaders(credentials, stream); const transformedBody = this.transformRequest(model, body, stream, credentials); + + // Merge default retry config with provider-specific config + const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...this.config.retry }; + let retryAttempts = 0; - const response = await proxyAwareFetch(url, { - method: "POST", - headers, - body: JSON.stringify(transformedBody), - signal - }, proxyOptions); + while (true) { + const headers = this.buildHeaders(credentials, stream); + + const response = await proxyAwareFetch(url, { + method: "POST", + headers, + body: JSON.stringify(transformedBody), + signal + }, proxyOptions); - if (!response.ok) { - return { response, url, headers, transformedBody }; + // Check if should retry based on status code + const maxRetries = retryConfig[response.status] || 0; + if (!response.ok && maxRetries > 0 && retryAttempts < maxRetries) { + retryAttempts++; + log?.debug?.("RETRY", `${response.status} retry ${retryAttempts}/${maxRetries} after ${RETRY_CONFIG.delayMs / 1000}s`); + await new Promise(resolve => setTimeout(resolve, RETRY_CONFIG.delayMs)); + continue; + } + + if (!response.ok) { + return { response, url, headers, transformedBody }; + } + + // Success - transform and return + // For Kiro, we need to transform the binary EventStream to SSE + // Create a TransformStream to convert binary to SSE text + const transformedResponse = this.transformEventStreamToSSE(response, model); + return { response: transformedResponse, url, headers, transformedBody }; } - - // For Kiro, we need to transform the binary EventStream to SSE - // Create a TransformStream to convert binary to SSE text - const transformedResponse = this.transformEventStreamToSSE(response, model); - - return { response: transformedResponse, url, headers, transformedBody }; } /** diff --git a/package.json b/package.json index fe034c3..6849f7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.3.61", + "version": "0.3.62", "description": "9Router web dashboard", "private": true, "scripts": { @@ -27,6 +27,7 @@ "node-machine-id": "^1.1.12", "open": "^11.0.0", "ora": "^9.1.0", + "proper-lockfile": "^4.1.2", "react": "19.2.4", "react-dom": "19.2.4", "react-is": "^16.13.1", diff --git a/src/app/api/9remote/install/route.js b/src/app/api/9remote/install/route.js new file mode 100644 index 0000000..2332687 --- /dev/null +++ b/src/app/api/9remote/install/route.js @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { exec } from "child_process"; +import { join, dirname } from "path"; + +// Use npm from the same Node.js that runs Next.js — ensures 9remote +// lands in the correct global bin (nvm or system, whichever is active) +const npmBin = join(dirname(process.execPath), "npm"); + +function installPackage() { + return new Promise((resolve, reject) => { + exec(`"${npmBin}" install -g 9remote`, { windowsHide: true }, (err, stdout, stderr) => { + if (err) reject(new Error(stderr || err.message)); + else resolve(stdout); + }); + }); +} + +export async function POST() { + try { + await installPackage(); + return NextResponse.json({ ok: true }); + } catch (error) { + return NextResponse.json({ ok: false, error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/9remote/start/route.js b/src/app/api/9remote/start/route.js new file mode 100644 index 0000000..67b60f9 --- /dev/null +++ b/src/app/api/9remote/start/route.js @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { spawn } from "child_process"; +import { join, dirname } from "path"; +import os from "os"; +import { setRemoteProcess } from "@/lib/9remoteManager"; + +const bin9remote = join(dirname(process.execPath), "9remote"); + +export async function POST() { + try { + const nodeDir = dirname(process.execPath); + const existingPath = process.env.PATH || ""; + const path = existingPath.includes(nodeDir) + ? existingPath + : `${nodeDir}:${existingPath}`; + + const env = { + HOME: os.homedir(), + PATH: path, + USER: process.env.USER || process.env.LOGNAME, + LANG: process.env.LANG || "en_US.UTF-8", + TERM: process.env.TERM || "xterm-256color", + TMPDIR: process.env.TMPDIR || os.tmpdir(), + SHELL: process.env.SHELL, + }; + const home = os.homedir(); + + // Spawn without detached - process will be child of Next.js and receive SIGTERM + const child = spawn(bin9remote, ["ui", "--start"], { + cwd: home, + stdio: "ignore", + env, + windowsHide: process.platform === "win32", + }); + + // Store child process for manual cleanup if needed + setRemoteProcess(child); + + return NextResponse.json({ ok: true }); + } catch (error) { + return NextResponse.json({ ok: false, error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/9remote/status/route.js b/src/app/api/9remote/status/route.js new file mode 100644 index 0000000..735b22b --- /dev/null +++ b/src/app/api/9remote/status/route.js @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { existsSync } from "fs"; +import { join, dirname } from "path"; + +const bin9remote = join(dirname(process.execPath), "9remote"); +const AGENT_URL = "http://localhost:2208"; + +async function isRunning() { + try { + const res = await fetch(`${AGENT_URL}/api/health`, { + signal: AbortSignal.timeout(1500), + }); + return res.ok; + } catch { + return false; + } +} + +export async function GET() { + const running = await isRunning(); + if (running) return NextResponse.json({ installed: true, running: true }); + + const installed = existsSync(bin9remote); + return NextResponse.json({ installed, running: false }); +} diff --git a/src/lib/9remoteManager.js b/src/lib/9remoteManager.js new file mode 100644 index 0000000..0b608ee --- /dev/null +++ b/src/lib/9remoteManager.js @@ -0,0 +1,35 @@ +// 9remote process lifecycle manager +let remoteProcess = null; + +export function setRemoteProcess(child) { + remoteProcess = child; +} + +export function getRemoteProcess() { + return remoteProcess; +} + +export function killRemote() { + if (!remoteProcess) return; + + try { + remoteProcess.kill("SIGTERM"); + console.log(`[9remote] Killed process ${remoteProcess.pid}`); + remoteProcess = null; + } catch (err) { + console.log(`[9remote] Failed to kill:`, err.message); + remoteProcess = null; + } +} + +// Register cleanup handlers +if (typeof process !== "undefined") { + const cleanup = () => { + killRemote(); + process.exit(0); + }; + + process.on("SIGTERM", cleanup); + process.on("SIGINT", cleanup); + process.on("beforeExit", killRemote); +} diff --git a/src/lib/localDb.js b/src/lib/localDb.js index 5d634be..de44066 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -4,6 +4,7 @@ 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"; const isCloud = typeof caches !== 'undefined' || typeof caches === 'object'; @@ -161,6 +162,78 @@ function ensureDbShape(data) { // Singleton instance let dbInstance = null; +// Lock options for proper-lockfile +const LOCK_OPTIONS = { + retries: { + retries: 5, + minTimeout: 100, + maxTimeout: 2000, + }, + stale: 10000, // Consider lock stale after 10s +}; + +/** + * Safely read database with file locking + */ +async function safeRead(db) { + if (isCloud) { + await db.read(); + return; + } + + let release = null; + try { + // Acquire lock before reading + release = await lockfile.lock(DB_FILE, LOCK_OPTIONS); + await db.read(); + } catch (error) { + if (error.code === "ELOCKED") { + console.warn("[DB] File is locked, retrying read..."); + throw error; + } + throw error; + } finally { + if (release) { + try { + await release(); + } catch (err) { + // Ignore unlock errors + } + } + } +} + +/** + * Safely write database with file locking + */ +async function safeWrite(db) { + if (isCloud) { + await db.write(); + return; + } + + let release = null; + try { + // Acquire lock before writing + release = await lockfile.lock(DB_FILE, LOCK_OPTIONS); + await db.write(); + } catch (error) { + if (error.code === "ELOCKED") { + console.warn("[DB] File is locked, retrying write..."); + throw error; + } + throw error; + } finally { + if (release) { + try { + await release(); + } catch (err) { + // Ignore unlock errors + } + } + } +} + /** * Get database instance (singleton) */ @@ -182,12 +255,12 @@ export async function getDb() { // Always read latest disk state to avoid stale singleton data across route workers. try { - await dbInstance.read(); + await safeRead(dbInstance); } catch (error) { if (error instanceof SyntaxError) { console.warn('[DB] Corrupt JSON detected, resetting to defaults...'); dbInstance.data = cloneDefaultData(); - await dbInstance.write(); + await safeWrite(dbInstance); } else { throw error; } @@ -196,12 +269,12 @@ export async function getDb() { // Initialize/migrate missing keys for older DB schema versions. if (!dbInstance.data) { dbInstance.data = cloneDefaultData(); - await dbInstance.write(); + await safeWrite(dbInstance); } else { const { data, changed } = ensureDbShape(dbInstance.data); dbInstance.data = data; if (changed) { - await dbInstance.write(); + await safeWrite(dbInstance); } } @@ -279,7 +352,7 @@ export async function createProviderNode(data) { }; db.data.providerNodes.push(node); - await db.write(); + await safeWrite(db); return node; } @@ -303,7 +376,7 @@ export async function updateProviderNode(id, data) { updatedAt: new Date().toISOString(), }; - await db.write(); + await safeWrite(db); return db.data.providerNodes[index]; } @@ -322,7 +395,7 @@ export async function deleteProviderNode(id) { if (index === -1) return null; const [removed] = db.data.providerNodes.splice(index, 1); - await db.write(); + await safeWrite(db); return removed; } @@ -380,7 +453,7 @@ export async function createProxyPool(data) { }; db.data.proxyPools.push(pool); - await db.write(); + await safeWrite(db); return pool; } @@ -403,7 +476,7 @@ export async function updateProxyPool(id, data) { updatedAt: new Date().toISOString(), }; - await db.write(); + await safeWrite(db); return db.data.proxyPools[index]; } @@ -420,7 +493,7 @@ export async function deleteProxyPool(id) { if (index === -1) return null; const [removed] = db.data.proxyPools.splice(index, 1); - await db.write(); + await safeWrite(db); return removed; } @@ -435,7 +508,7 @@ export async function deleteProviderConnectionsByProvider(providerId) { (connection) => connection.provider !== providerId ); const deletedCount = beforeCount - db.data.providerConnections.length; - await db.write(); + await safeWrite(db); return deletedCount; } @@ -474,7 +547,7 @@ export async function createProviderConnection(data) { ...data, updatedAt: now, }; - await db.write(); + await safeWrite(db); return db.data.providerConnections[existingIndex]; } @@ -535,7 +608,7 @@ export async function createProviderConnection(data) { } db.data.providerConnections.push(connection); - await db.write(); + await safeWrite(db); // Reorder to ensure consistency await reorderProviderConnections(data.provider); @@ -560,7 +633,7 @@ export async function updateProviderConnection(id, data) { updatedAt: new Date().toISOString(), }; - await db.write(); + await safeWrite(db); // Reorder if priority was changed if (data.priority !== undefined) { @@ -582,7 +655,7 @@ export async function deleteProviderConnection(id) { const providerId = db.data.providerConnections[index].provider; db.data.providerConnections.splice(index, 1); - await db.write(); + await safeWrite(db); // Reorder to fill gaps await reorderProviderConnections(providerId); @@ -612,7 +685,7 @@ export async function reorderProviderConnections(providerId) { conn.priority = index + 1; }); - await db.write(); + await safeWrite(db); } // ============ Model Aliases ============ @@ -631,7 +704,7 @@ export async function getModelAliases() { export async function setModelAlias(alias, model) { const db = await getDb(); db.data.modelAliases[alias] = model; - await db.write(); + await safeWrite(db); } /** @@ -640,7 +713,7 @@ export async function setModelAlias(alias, model) { export async function deleteModelAlias(alias) { const db = await getDb(); delete db.data.modelAliases[alias]; - await db.write(); + await safeWrite(db); } // ============ MITM Alias ============ @@ -656,7 +729,7 @@ export async function setMitmAliasAll(toolName, mappings) { const db = await getDb(); if (!db.data.mitmAlias) db.data.mitmAlias = {}; db.data.mitmAlias[toolName] = mappings || {}; - await db.write(); + await safeWrite(db); } // ============ Combos ============ @@ -702,7 +775,7 @@ export async function createCombo(data) { }; db.data.combos.push(combo); - await db.write(); + await safeWrite(db); return combo; } @@ -722,7 +795,7 @@ export async function updateCombo(id, data) { updatedAt: new Date().toISOString(), }; - await db.write(); + await safeWrite(db); return db.data.combos[index]; } @@ -737,7 +810,7 @@ export async function deleteCombo(id) { if (index === -1) return false; db.data.combos.splice(index, 1); - await db.write(); + await safeWrite(db); return true; } @@ -790,7 +863,7 @@ export async function createApiKey(name, machineId) { }; db.data.apiKeys.push(apiKey); - await db.write(); + await safeWrite(db); return apiKey; } @@ -805,7 +878,7 @@ export async function deleteApiKey(id) { if (index === -1) return false; db.data.apiKeys.splice(index, 1); - await db.write(); + await safeWrite(db); return true; } @@ -829,7 +902,7 @@ export async function updateApiKey(id, data) { ...db.data.apiKeys[index], ...data, }; - await db.write(); + await safeWrite(db); return db.data.apiKeys[index]; } @@ -873,7 +946,7 @@ export async function cleanupProviderConnections() { } if (cleaned > 0) { - await db.write(); + await safeWrite(db); } return cleaned; } @@ -897,7 +970,7 @@ export async function updateSettings(updates) { ...db.data.settings, ...updates }; - await db.write(); + await safeWrite(db); return db.data.settings; } @@ -931,7 +1004,7 @@ export async function importDb(payload) { const { data: normalized } = ensureDbShape(nextData); const db = await getDb(); db.data = normalized; - await db.write(); + await safeWrite(db); return db.data; } @@ -1071,7 +1144,7 @@ export async function updatePricing(pricingData) { } } - await db.write(); + await safeWrite(db); return db.data.pricing; } @@ -1101,7 +1174,7 @@ export async function resetPricing(provider, model) { delete db.data.pricing[provider]; } - await db.write(); + await safeWrite(db); return db.data.pricing; } @@ -1111,6 +1184,6 @@ export async function resetPricing(provider, model) { export async function resetAllPricing() { const db = await getDb(); db.data.pricing = {}; - await db.write(); + await safeWrite(db); return db.data.pricing; } diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index 66eccce..380c565 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -6,6 +6,7 @@ import Link from "next/link"; import PropTypes from "prop-types"; import ProviderIcon from "@/shared/components/ProviderIcon"; import { ThemeToggle, LanguageSwitcher } from "@/shared/components"; +import NineRemoteButton from "@/shared/components/NineRemoteButton"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; import { translate } from "@/i18n/runtime"; @@ -187,6 +188,9 @@ export default function Header({ onMenuClick, showMenuButton = true }) { {/* Right actions */}
+ {/* 9Remote button */} + + {/* Language switcher */} diff --git a/src/shared/components/LanguageSwitcher.js b/src/shared/components/LanguageSwitcher.js index c45008d..abe5cf4 100644 --- a/src/shared/components/LanguageSwitcher.js +++ b/src/shared/components/LanguageSwitcher.js @@ -110,10 +110,7 @@ export default function LanguageSwitcher({ className = "" }) { data-i18n-skip="true" > {getLocaleInfo(locale).flag} - {getLocaleInfo(locale).name} - - {isOpen ? "expand_less" : "expand_more"} - + {locale.split("-")[0]} {/* Portal modal - renders at document.body to avoid parent layout constraints */} diff --git a/src/shared/components/NineRemoteButton.js b/src/shared/components/NineRemoteButton.js new file mode 100644 index 0000000..8c2d1af --- /dev/null +++ b/src/shared/components/NineRemoteButton.js @@ -0,0 +1,104 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import NineRemoteModal from "./NineRemoteModal"; + +// step 0-4 từ 9remote SSE: 0=Stopped,1=Preparing,2=Connecting,3=Tunneling,4=Ready +const STEP = { STOPPED: 0, PREPARING: 1, CONNECTING: 2, TUNNELING: 3, READY: 4 }; + +// Retry interval khi 9remote chưa chạy (30s để không spam) +const RETRY_MS = 30000; + +const stepStyle = (step, installed) => { + if (!installed) return { color: "text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5", glow: null }; + switch (step) { + case STEP.READY: return { color: "text-emerald-500 hover:bg-emerald-500/10", glow: "drop-shadow(0 0 6px rgb(16 185 129 / 0.7))" }; + case STEP.STOPPED: return { color: "text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5", glow: null }; + default: return { color: "text-amber-400 hover:bg-amber-400/10", glow: null }; + } +}; + +export default function NineRemoteButton() { + const [isOpen, setIsOpen] = useState(false); + const [installed, setInstalled] = useState(false); + const [step, setStep] = useState(STEP.STOPPED); + const esRef = useRef(null); + const retryRef = useRef(null); + + const scheduleRetry = () => { + clearTimeout(retryRef.current); + retryRef.current = setTimeout(connect, RETRY_MS); + }; + + const connect = () => { + esRef.current?.close(); + clearTimeout(retryRef.current); + + const es = new EventSource("http://localhost:2208/api/ui/events"); + + es.onmessage = (e) => { + try { + const data = JSON.parse(e.data); + if (data.type === "state") { + setInstalled(true); + setStep(data.step ?? STEP.STOPPED); + } + } catch {} + }; + + es.onerror = () => { + es.close(); + setStep(STEP.STOPPED); + // 9remote not running — retry after delay + scheduleRetry(); + }; + + esRef.current = es; + }; + + useEffect(() => { + // Check installed once on mount (no polling, just 1 call) + fetch("/api/9remote/status") + .then((r) => r.json()) + .then((d) => setInstalled(d.installed)) + .catch(() => {}); + + connect(); + + return () => { + esRef.current?.close(); + clearTimeout(retryRef.current); + }; + }, []); + + // When modal closes, reconnect SSE immediately (user may have just started 9remote) + const handleClose = () => { + setIsOpen(false); + setTimeout(connect, 800); + }; + + const { color, glow } = stepStyle(step, installed); + const isPulsing = installed && step >= STEP.PREPARING && step <= STEP.TUNNELING; + + return ( + <> + + + setInstalled(true)} + /> + + ); +} diff --git a/src/shared/components/NineRemoteModal.js b/src/shared/components/NineRemoteModal.js new file mode 100644 index 0000000..e79ca8e --- /dev/null +++ b/src/shared/components/NineRemoteModal.js @@ -0,0 +1,253 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; +import { createPortal } from "react-dom"; + +const S = { + CHECKING: "checking", + NOT_INSTALLED: "not_installed", + NOT_RUNNING: "not_running", + INSTALLING: "installing", + STARTING: "starting", + RUNNING: "running", + ERROR: "error", +}; + +const POLL_INTERVAL_MS = 2000; +const POLL_TIMEOUT_MS = 60000; + +const FEATURES = [ + { icon: "terminal", label: "Terminal", desc: "Full shell access" }, + { icon: "cast", label: "Desktop", desc: "Screen sharing" }, + { icon: "folder_open", label: "Files", desc: "Browse & edit files" }, +]; + +const BULLETS = [ + { icon: "qr_code_scanner", text: "Scan QR to connect instantly" }, + { icon: "wifi_off", text: "No port forwarding needed" }, + { icon: "devices", text: "Works on any device" }, +]; + +export default function NineRemoteModal({ isOpen, onClose, onInstalled }) { + const [state, setState] = useState(S.CHECKING); + const [errorMsg, setErrorMsg] = useState(""); + const pollRef = useRef(null); + + const stopPolling = () => { + if (pollRef.current) { clearTimeout(pollRef.current); pollRef.current = null; } + }; + + const pollUntilRunning = useCallback(() => { + stopPolling(); + const startedAt = Date.now(); + const poll = async () => { + if (Date.now() - startedAt > POLL_TIMEOUT_MS) { + setState(S.ERROR); + setErrorMsg("9Remote is taking too long to start. Try running `9remote ui` manually."); + return; + } + try { + const res = await fetch("/api/9remote/status"); + const data = await res.json(); + if (data.running) { setState(S.RUNNING); return; } + } catch {} + pollRef.current = setTimeout(poll, POLL_INTERVAL_MS); + }; + poll(); + }, []); + + const checkAndInit = useCallback(async () => { + setState(S.CHECKING); + setErrorMsg(""); + try { + const res = await fetch("/api/9remote/status"); + const data = await res.json(); + if (data.running) { setState(S.RUNNING); return; } + if (!data.installed) { setState(S.NOT_INSTALLED); return; } + setState(S.NOT_RUNNING); + } catch (err) { setState(S.ERROR); setErrorMsg(err.message); } + }, []); + + const handleStart = useCallback(async () => { + setState(S.STARTING); + try { + const res = await fetch("/api/9remote/start", { method: "POST" }); + if (!res.ok) throw new Error("Failed to start 9Remote"); + pollUntilRunning(); + } catch (err) { setState(S.ERROR); setErrorMsg(err.message); } + }, [pollUntilRunning]); + + const handleInstall = async () => { + setState(S.INSTALLING); + setErrorMsg(""); + try { + const res = await fetch("/api/9remote/install", { method: "POST" }); + if (!res.ok) { const d = await res.json(); throw new Error(d.error || "Install failed"); } + onInstalled?.(); + await handleStart(); + } catch (err) { setState(S.ERROR); setErrorMsg(err.message); } + }; + + useEffect(() => { + if (isOpen) checkAndInit(); + else stopPolling(); + return stopPolling; + }, [isOpen, checkAndInit]); + + useEffect(() => { + if (!isOpen) return; + document.body.style.overflow = "hidden"; + const onEsc = (e) => { if (e.key === "Escape") onClose(); }; + document.addEventListener("keydown", onEsc); + return () => { document.body.style.overflow = ""; document.removeEventListener("keydown", onEsc); }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const btnCfg = { + [S.NOT_INSTALLED]: { label: "Install 9Remote", icon: "download", onClick: handleInstall, loading: false }, + [S.INSTALLING]: { label: "Installing...", icon: "hourglass_top", onClick: null, loading: true }, + [S.NOT_RUNNING]: { label: "Start 9Remote", icon: "play_arrow", onClick: handleStart, loading: false }, + [S.STARTING]: { label: "Starting...", icon: "hourglass_top", onClick: null, loading: true }, + [S.CHECKING]: { label: "Checking...", icon: "hourglass_top", onClick: null, loading: true }, + }[state]; + + // Running — iframe only + if (state === S.RUNNING) { + return createPortal( +
+
+
+