Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
111 changes: 109 additions & 2 deletions frontend/src-tauri/src/backend.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<Vec<CommandChild>>,
backend_path: PathBuf,
log_dir: PathBuf,
app: AppHandle,
/// The port the backend is listening on (discovered from port file)
port: Mutex<Option<u16>>,
}

const MAIN_MODULE: &str = "valuecell.server.main";
Expand Down Expand Up @@ -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<u16> {
let port_file = Self::get_port_file_path();
fs::read_to_string(&port_file)
.ok()
.and_then(|s| s.trim().parse::<u16>().ok())
}

/// Wait for the port file to appear and read the port
fn wait_for_port_file(&self) -> Result<u16> {
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<u16> {
*self.port.lock().unwrap()
}

/// Get the backend URL
pub fn get_backend_url(&self) -> Option<String> {
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;
Expand Down Expand Up @@ -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() {
Expand All @@ -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(())
Expand Down
15 changes: 14 additions & 1 deletion frontend/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<BackendManager>) -> Option<String> {
manager.get_backend_url()
}

/// Get the backend port
#[tauri::command]
fn get_backend_port(manager: State<BackendManager>) -> Option<u16> {
manager.get_port()
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/components/valuecell/app/backend-health-check.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout>;
Expand All @@ -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}</>;
}

Expand Down
44 changes: 43 additions & 1 deletion frontend/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
// Only in Tauri environment
if (typeof window !== "undefined" && "__TAURI__" in window) {
try {
const { invoke } = await import("@tauri-apps/api/core");
const url = await invoke<string | null>("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;
Expand Down Expand Up @@ -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 {
Expand Down
38 changes: 37 additions & 1 deletion python/valuecell/server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import io
import socket
import sys
import threading
from typing import Callable, Optional, TextIO
Expand All @@ -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__"

Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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__":
Expand Down
Loading