diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 314fa2ca5..f8c6ed990 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ log = "0.4" tauri-plugin-store = "2" tauri-plugin-deep-link = "2" reqwest = { version = "0.12", features = ["blocking", "json"] } +dirs = "5" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-process = "2" diff --git a/frontend/src-tauri/src/backend.rs b/frontend/src-tauri/src/backend.rs index ed6f419d7..5506ca94d 100644 --- a/frontend/src-tauri/src/backend.rs +++ b/frontend/src-tauri/src/backend.rs @@ -1,5 +1,5 @@ use anyhow::{anyhow, Context, Result}; -use std::fs::{create_dir_all, OpenOptions}; +use std::fs::{self, create_dir_all, OpenOptions}; use std::io::Write; use std::path::PathBuf; use std::sync::Mutex; @@ -13,12 +13,23 @@ use tauri_plugin_shell::ShellExt; #[cfg(windows)] use std::os::windows::process::CommandExt; +/// Port file name (must match Python's PORT_FILE_NAME) +const PORT_FILE_NAME: &str = "backend.port"; + +/// Maximum time to wait for port file (in milliseconds) +const PORT_FILE_TIMEOUT_MS: u64 = 30000; + +/// Polling interval for port file (in milliseconds) +const PORT_FILE_POLL_MS: u64 = 100; + /// Backend process manager pub struct BackendManager { processes: Mutex>, backend_path: PathBuf, log_dir: PathBuf, app: AppHandle, + /// The port the backend is listening on (discovered from port file) + port: Mutex>, } const MAIN_MODULE: &str = "valuecell.server.main"; @@ -162,9 +173,84 @@ impl BackendManager { backend_path, log_dir, app, + port: Mutex::new(None), }) } + /// Get the system config directory path (must match Python's get_system_env_dir) + fn get_system_config_dir() -> PathBuf { + #[cfg(target_os = "macos")] + { + dirs::home_dir() + .map(|h| h.join("Library/Application Support/ValueCell")) + .unwrap_or_else(|| PathBuf::from("/tmp/ValueCell")) + } + + #[cfg(target_os = "windows")] + { + std::env::var("APPDATA") + .map(PathBuf::from) + .map(|p| p.join("ValueCell")) + .unwrap_or_else(|_| { + dirs::home_dir() + .map(|h| h.join("AppData/Roaming/ValueCell")) + .unwrap_or_else(|| PathBuf::from("C:\\ValueCell")) + }) + } + + #[cfg(target_os = "linux")] + { + dirs::home_dir() + .map(|h| h.join(".config/valuecell")) + .unwrap_or_else(|| PathBuf::from("/tmp/valuecell")) + } + } + + /// Get the port file path + fn get_port_file_path() -> PathBuf { + Self::get_system_config_dir().join(PORT_FILE_NAME) + } + + /// Read the backend port from the port file + fn read_port_file() -> Option { + let port_file = Self::get_port_file_path(); + fs::read_to_string(&port_file) + .ok() + .and_then(|s| s.trim().parse::().ok()) + } + + /// Wait for the port file to appear and read the port + fn wait_for_port_file(&self) -> Result { + let start = std::time::Instant::now(); + let timeout = Duration::from_millis(PORT_FILE_TIMEOUT_MS); + + log::info!("Waiting for backend port file..."); + + while start.elapsed() < timeout { + if let Some(port) = Self::read_port_file() { + log::info!("Backend port discovered: {}", port); + return Ok(port); + } + std::thread::sleep(Duration::from_millis(PORT_FILE_POLL_MS)); + } + + Err(anyhow!( + "Timeout waiting for backend port file after {}ms", + PORT_FILE_TIMEOUT_MS + )) + } + + /// Get the backend port (if discovered) + pub fn get_port(&self) -> Option { + *self.port.lock().unwrap() + } + + /// Get the backend URL + pub fn get_backend_url(&self) -> Option { + self.get_port() + .map(|port| format!("http://127.0.0.1:{}", port)) + } + fn decide_index_url() -> bool { const IPAPI_URL: &str = "https://ipapi.co/json/"; const TIMEOUT_SECS: u64 = 3; @@ -232,6 +318,9 @@ impl BackendManager { pub fn start_all(&self) -> Result<()> { self.install_dependencies()?; + // Remove stale port file before starting + let _ = fs::remove_file(Self::get_port_file_path()); + let mut processes = self.processes.lock().unwrap(); match self.spawn_backend_process() { @@ -240,7 +329,25 @@ impl BackendManager { log::info!("Process {} added to process list", child.pid()); processes.push(child); } - Err(e) => log::error!("Failed to start backend server: {}", e), + Err(e) => { + log::error!("Failed to start backend server: {}", e); + return Err(e); + } + } + + // Release lock before waiting for port file + drop(processes); + + // Wait for port file and store the discovered port + match self.wait_for_port_file() { + Ok(port) => { + *self.port.lock().unwrap() = Some(port); + log::info!("Backend started on port {}", port); + } + Err(e) => { + log::error!("Failed to discover backend port: {}", e); + return Err(e); + } } Ok(()) diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 827fd7482..9d4bcc34b 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,7 +1,19 @@ mod backend; use backend::BackendManager; -use tauri::Manager; +use tauri::{Manager, State}; + +/// Get the backend URL that the frontend should connect to +#[tauri::command] +fn get_backend_url(manager: State) -> Option { + manager.get_backend_url() +} + +/// Get the backend port +#[tauri::command] +fn get_backend_port(manager: State) -> Option { + manager.get_port() +} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -42,6 +54,7 @@ pub fn run() { Ok(()) }) + .invoke_handler(tauri::generate_handler![get_backend_url, get_backend_port]) .on_window_event(|window, event| { // Handle window close events to ensure proper cleanup if let tauri::WindowEvent::Destroyed = event { diff --git a/frontend/src/components/valuecell/app/backend-health-check.tsx b/frontend/src/components/valuecell/app/backend-health-check.tsx index fbfeeb29a..86b906f84 100644 --- a/frontend/src/components/valuecell/app/backend-health-check.tsx +++ b/frontend/src/components/valuecell/app/backend-health-check.tsx @@ -4,15 +4,24 @@ import type React from "react"; import { useEffect, useState } from "react"; import { useBackendHealth } from "@/api/system"; import { Button } from "@/components/ui/button"; +import { initBackendUrl } from "@/lib/api-client"; export function BackendHealthCheck({ children, }: { children: React.ReactNode; }) { + const [backendUrlInitialized, setBackendUrlInitialized] = useState(false); const { isError, refetch, isFetching, isSuccess } = useBackendHealth(); const [showError, setShowError] = useState(false); + // Initialize backend URL from Tauri on mount + useEffect(() => { + initBackendUrl().finally(() => { + setBackendUrlInitialized(true); + }); + }, []); + // Debounce showing the error screen to avoid flickering on initial load or brief network blips useEffect(() => { let timer: ReturnType; @@ -24,7 +33,8 @@ export function BackendHealthCheck({ return () => clearTimeout(timer); }, [isError]); - if (isSuccess && !showError) { + // Don't render children until backend URL is initialized and health check succeeds + if (backendUrlInitialized && isSuccess && !showError) { return <>{children}; } diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index 17fc90a20..aec55e78b 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -4,6 +4,46 @@ import { VALUECELL_BACKEND_URL } from "@/constants/api"; import { useSystemStore } from "@/store/system-store"; import type { SystemInfo } from "@/types/system"; +// Backend URL cache for Tauri app +let cachedBackendUrl: string | null = null; + +/** + * Initialize backend URL from Tauri (call this on app startup) + */ +export async function initBackendUrl(): Promise { + // Only in Tauri environment + if (typeof window !== "undefined" && "__TAURI__" in window) { + try { + const { invoke } = await import("@tauri-apps/api/core"); + const url = await invoke("get_backend_url"); + if (url) { + cachedBackendUrl = url; + console.log("[API] Backend URL from Tauri:", cachedBackendUrl); + } + } catch (e) { + console.warn("[API] Failed to get backend URL from Tauri:", e); + } + } +} + +/** + * Get the backend base URL + */ +export function getBackendBaseUrl(): string { + // 1. Check cached URL from Tauri + if (cachedBackendUrl) { + return cachedBackendUrl; + } + + // 2. Check environment variable + if (import.meta.env.VITE_API_BASE_URL) { + return import.meta.env.VITE_API_BASE_URL; + } + + // 3. Default fallback for development + return "http://localhost:8000"; +} + // API error type export class ApiError extends Error { public status: number; @@ -33,7 +73,9 @@ export interface RequestConfig { export const getServerUrl = (endpoint: string) => { if (endpoint.startsWith("http")) return endpoint; - return `${import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000/api/v1"}${endpoint.startsWith("/") ? endpoint : `/${endpoint}`}`; + const baseUrl = getBackendBaseUrl(); + const apiBase = `${baseUrl}/api/v1`; + return `${apiBase}${endpoint.startsWith("/") ? endpoint : `/${endpoint}`}`; }; class ApiClient { diff --git a/python/valuecell/server/main.py b/python/valuecell/server/main.py index c009f275f..4e21624eb 100644 --- a/python/valuecell/server/main.py +++ b/python/valuecell/server/main.py @@ -3,6 +3,7 @@ from __future__ import annotations import io +import socket import sys import threading from typing import Callable, Optional, TextIO @@ -12,6 +13,11 @@ from valuecell.server.api.app import create_app from valuecell.server.config.settings import get_settings +from valuecell.utils.env import ( + auto_port_enabled, + remove_port_file, + write_port_file, +) EXIT_COMMAND: str = "__EXIT__" @@ -22,6 +28,21 @@ app = create_app() +def find_available_port(host: str = "127.0.0.1") -> int: + """Find an available port by binding to port 0. + + Args: + host: The host to bind to. + + Returns: + An available port number. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind((host, 0)) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + return sock.getsockname()[1] + + def control_loop( request_stop: Callable[[], None], command_stream: Optional[TextIO] = None, @@ -47,10 +68,22 @@ def main() -> None: settings = get_settings() + # Determine the port to use + if auto_port_enabled(): + # Auto-allocate an available port + actual_port = find_available_port(settings.API_HOST) + logger.info("Auto-allocated port: {port}", port=actual_port) + else: + actual_port = settings.API_PORT + + # Write port file for client discovery + port_file = write_port_file(actual_port) + logger.info("Port file written to: {path}", path=str(port_file)) + config = uvicorn.Config( app, host=settings.API_HOST, - port=settings.API_PORT, + port=actual_port, log_level="debug" if settings.API_DEBUG else "info", ) server = uvicorn.Server(config) @@ -81,6 +114,9 @@ def request_stop() -> None: request_stop() finally: request_stop() + # Clean up port file on shutdown + remove_port_file() + logger.info("Port file removed") if __name__ == "__main__": diff --git a/python/valuecell/utils/env.py b/python/valuecell/utils/env.py index eb10da29d..1bc7ef85b 100644 --- a/python/valuecell/utils/env.py +++ b/python/valuecell/utils/env.py @@ -59,3 +59,66 @@ def agent_debug_mode_enabled() -> bool: """ flag = os.getenv("AGENT_DEBUG_MODE", "false") return str(flag).lower() == "true" + + +# Port file management for dynamic port allocation +PORT_FILE_NAME = "backend.port" + + +def get_port_file_path() -> Path: + """Return the full path to the backend port file. + + The port file is stored alongside the .env file in the system config directory. + """ + return get_system_env_dir() / PORT_FILE_NAME + + +def write_port_file(port: int) -> Path: + """Write the backend port to a file for the client to read. + + Args: + port: The port number the backend is listening on. + + Returns: + Path to the port file. + """ + ensure_system_env_dir() + port_file = get_port_file_path() + port_file.write_text(str(port), encoding="utf-8") + return port_file + + +def read_port_file() -> int | None: + """Read the backend port from the port file. + + Returns: + The port number if the file exists and is valid, None otherwise. + """ + port_file = get_port_file_path() + if not port_file.exists(): + return None + try: + content = port_file.read_text(encoding="utf-8").strip() + return int(content) + except (ValueError, OSError): + return None + + +def remove_port_file() -> None: + """Remove the port file if it exists.""" + port_file = get_port_file_path() + if port_file.exists(): + try: + port_file.unlink() + except OSError: + pass + + +def auto_port_enabled() -> bool: + """Return whether auto port allocation is enabled. + + When API_PORT is set to "0" or "auto", the server will automatically + find an available port. + """ + port_env = os.getenv("API_PORT", "8000") + return port_env == "0" or port_env.lower() == "auto" diff --git a/start.ps1 b/start.ps1 index b27bf3132..ef6cb483c 100644 --- a/start.ps1 +++ b/start.ps1 @@ -18,6 +18,64 @@ $PY_DIR = Join-Path $SCRIPT_DIR "python" $BACKEND_PROCESS = $null $FRONTEND_PROCESS = $null +# Port file name (must match Python's PORT_FILE_NAME) +$PORT_FILE_NAME = "backend.port" + +# Get system config directory (must match Python's get_system_env_dir) +function Get-SystemConfigDir { + $appData = $env:APPDATA + if ($appData) { + return Join-Path $appData "ValueCell" + } + return Join-Path $env:USERPROFILE "AppData\Roaming\ValueCell" +} + +# Get port file path +function Get-PortFilePath { + return Join-Path (Get-SystemConfigDir) $PORT_FILE_NAME +} + +# Read backend port from port file +function Read-BackendPort { + $portFile = Get-PortFilePath + if (Test-Path $portFile) { + $content = Get-Content $portFile -Raw -ErrorAction SilentlyContinue + if ($content) { + return $content.Trim() + } + } + return $null +} + +# Wait for port file and return the port +function Wait-ForPortFile { + param( + [int]$TimeoutSeconds = 30 + ) + + $portFile = Get-PortFilePath + $elapsed = 0 + + Write-Info "Waiting for backend port file..." + + while ((-not (Test-Path $portFile)) -and ($elapsed -lt $TimeoutSeconds)) { + Start-Sleep -Milliseconds 500 + $elapsed++ + } + + if (Test-Path $portFile) { + $port = Get-Content $portFile -Raw -ErrorAction SilentlyContinue + if ($port) { + $port = $port.Trim() + Write-Success "Backend started on port: $port" + return $port + } + } + + Write-Err "Timeout waiting for backend port file" + return $null +} + # Color output functions function Write-Info($message) { Write-Host "[INFO] $message" -ForegroundColor Cyan @@ -156,31 +214,67 @@ function Compile { } function Start-Backend { + param( + [switch]$AsJob + ) + if (-not (Test-Path $PY_DIR)) { Write-Warn "Backend directory not found; skipping backend start" return } - Write-Info "Starting backend in debug mode (AGENT_DEBUG_MODE=true)..." - Push-Location $PY_DIR - try { - # Set debug mode for local development - $env:AGENT_DEBUG_MODE = "true" - & uv run python -m valuecell.server.main - } catch { - Write-Err "Failed to start backend: $_" - } finally { - Pop-Location + # Remove stale port file + $portFile = Get-PortFilePath + if (Test-Path $portFile) { + Remove-Item $portFile -Force -ErrorAction SilentlyContinue + } + + Write-Info "Starting backend in debug mode (AGENT_DEBUG_MODE=true, API_PORT=auto)..." + + if ($AsJob) { + # Start as background job + $script:BACKEND_PROCESS = Start-Process -FilePath "uv" ` + -ArgumentList "run", "python", "-m", "valuecell.server.main" ` + -WorkingDirectory $PY_DIR ` + -NoNewWindow -PassThru ` + -Environment @{ + "AGENT_DEBUG_MODE" = "true" + "API_PORT" = "auto" + } + Write-Info "Backend PID: $($script:BACKEND_PROCESS.Id)" + } else { + Push-Location $PY_DIR + try { + # Set debug mode and auto port for local development + $env:AGENT_DEBUG_MODE = "true" + $env:API_PORT = "auto" + & uv run python -m valuecell.server.main + } catch { + Write-Err "Failed to start backend: $_" + } finally { + Pop-Location + } } } function Start-Frontend { + param( + [string]$BackendPort + ) + if (-not (Test-Path $FRONTEND_DIR)) { Write-Warn "Frontend directory not found; skipping frontend start" return } Write-Info "Starting frontend dev server (bun run dev)..." + + # If backend port is provided, set VITE_API_BASE_URL for the frontend + if ($BackendPort) { + Write-Info "Setting VITE_API_BASE_URL to http://localhost:$BackendPort" + $env:VITE_API_BASE_URL = "http://localhost:$BackendPort" + } + Push-Location $FRONTEND_DIR try { # Try to find the actual bun.exe first @@ -279,14 +373,31 @@ try { # Compile/install dependencies Compile - # Start services based on flags - if (-not $NoFrontend) { - Start-Frontend - Start-Sleep -Seconds 5 # Give frontend a moment to start - } + $backendPort = $null + # Start backend first (in background if frontend is also starting) if (-not $NoBackend) { - Start-Backend + if (-not $NoFrontend) { + # Start backend in background and wait for port file + Start-Backend -AsJob + Start-Sleep -Seconds 2 # Give backend a moment to start writing port file + + # Wait for port file to appear + $backendPort = Wait-ForPortFile -TimeoutSeconds 30 + if (-not $backendPort) { + Write-Err "Failed to start backend" + exit 1 + } + } else { + # Only backend, run in foreground + Start-Backend + } + } + + # Start frontend with discovered backend port + if (-not $NoFrontend) { + Start-Frontend -BackendPort $backendPort + Start-Sleep -Seconds 5 # Give frontend a moment to start } # If frontend is running, wait for it diff --git a/start.sh b/start.sh index 04af90de4..66c2b781e 100755 --- a/start.sh +++ b/start.sh @@ -17,6 +17,64 @@ success(){ echo "[ OK ] $*"; } warn() { echo "[WARN] $*"; } error() { echo "[ERR ] $*" 1>&2; } +# Get system config directory (must match Python's get_system_env_dir) +get_system_config_dir() { + case "$(uname -s)" in + Darwin) + echo "$HOME/Library/Application Support/ValueCell" + ;; + Linux) + echo "$HOME/.config/valuecell" + ;; + *) + echo "$HOME/.config/valuecell" + ;; + esac +} + +# Get port file path +get_port_file_path() { + echo "$(get_system_config_dir)/backend.port" +} + +# Read backend port from port file +read_backend_port() { + local port_file + port_file="$(get_port_file_path)" + if [[ -f "$port_file" ]]; then + cat "$port_file" 2>/dev/null || echo "" + else + echo "" + fi +} + +# Wait for port file and return the port +wait_for_port_file() { + local timeout=${1:-30} + local port_file + port_file="$(get_port_file_path)" + local elapsed=0 + + info "Waiting for backend port file..." + while [[ ! -f "$port_file" ]] && (( elapsed < timeout )); do + sleep 0.5 + elapsed=$((elapsed + 1)) + done + + if [[ -f "$port_file" ]]; then + local port + port=$(cat "$port_file" 2>/dev/null) + if [[ -n "$port" ]]; then + success "Backend started on port: $port" + echo "$port" + return 0 + fi + fi + + error "Timeout waiting for backend port file" + return 1 +} + command_exists() { command -v "$1" >/dev/null 2>&1; } ensure_brew_on_macos() { @@ -101,16 +159,32 @@ start_backend() { warn "Backend directory not found; skipping backend start" return 0 fi - info "Starting backend in debug mode (AGENT_DEBUG_MODE=true)..." - cd "$PY_DIR" && AGENT_DEBUG_MODE=true uv run python -m valuecell.server.main + + # Remove stale port file + local port_file + port_file="$(get_port_file_path)" + rm -f "$port_file" 2>/dev/null || true + + info "Starting backend in debug mode (AGENT_DEBUG_MODE=true, API_PORT=auto)..." + cd "$PY_DIR" && AGENT_DEBUG_MODE=true API_PORT=auto uv run python -m valuecell.server.main } start_frontend() { + local backend_port="${1:-}" + if [[ ! -d "$FRONTEND_DIR" ]]; then warn "Frontend directory not found; skipping frontend start" return 0 fi + info "Starting frontend dev server (bun run dev)..." + + # If backend port is provided, set VITE_API_BASE_URL for the frontend + if [[ -n "$backend_port" ]]; then + info "Setting VITE_API_BASE_URL to http://localhost:$backend_port" + export VITE_API_BASE_URL="http://localhost:$backend_port" + fi + ( cd "$FRONTEND_DIR" && bun run dev ) & FRONTEND_PID=$! @@ -171,13 +245,28 @@ main() { compile - if (( start_frontend_flag )); then - start_frontend - fi - sleep 5 # Give frontend a moment to start + local backend_port="" + # Start backend first (in background if frontend is also starting) if (( start_backend_flag )); then - start_backend + if (( start_frontend_flag )); then + # Start backend in background and wait for port file + (start_backend) & + BACKEND_PID=$! + + # Wait for port file to appear + backend_port=$(wait_for_port_file 30) || { + error "Failed to start backend" + exit 1 + } + else + # Only backend, run in foreground + start_backend + fi + fi + + if (( start_frontend_flag )); then + start_frontend "$backend_port" fi # Wait for background jobs