初始化web服务功能

This commit is contained in:
xintaofei
2026-03-25 14:26:26 +08:00
parent ae70f17d2e
commit ac09d3db9e
99 changed files with 3253 additions and 304 deletions

33
src-tauri/src/web/auth.rs Normal file
View File

@@ -0,0 +1,33 @@
use axum::{
extract::Request,
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
};
#[derive(Clone)]
pub struct AuthToken(pub String);
pub async fn require_token(
request: Request,
next: Next,
token: String,
) -> Response {
// Allow WebSocket upgrade requests to authenticate via query param
if let Some(query) = request.uri().query() {
if query.contains(&format!("token={}", token)) {
return next.run(request).await;
}
}
// Check Authorization header
if let Some(auth_header) = request.headers().get("authorization") {
if let Ok(auth_str) = auth_header.to_str() {
if auth_str.strip_prefix("Bearer ").map_or(false, |t| t == token) {
return next.run(request).await;
}
}
}
(StatusCode::UNAUTHORIZED, "Invalid or missing token").into_response()
}

View File

@@ -0,0 +1,52 @@
use serde::Serialize;
use tokio::sync::broadcast;
#[derive(Clone, Debug, Serialize)]
pub struct WebEvent {
pub channel: String,
pub payload: serde_json::Value,
}
pub struct WebEventBroadcaster {
sender: broadcast::Sender<WebEvent>,
}
impl WebEventBroadcaster {
pub fn new() -> Self {
let (sender, _) = broadcast::channel(4096);
Self { sender }
}
pub fn send(&self, channel: &str, payload: &impl Serialize) {
if self.sender.receiver_count() == 0 {
return;
}
if let Ok(value) = serde_json::to_value(payload) {
let _ = self.sender.send(WebEvent {
channel: channel.to_string(),
payload: value,
});
}
}
pub fn subscribe(&self) -> broadcast::Receiver<WebEvent> {
self.sender.subscribe()
}
pub fn has_subscribers(&self) -> bool {
self.sender.receiver_count() > 0
}
}
/// Unified event emission: sends to both Tauri webview and Web clients.
pub fn emit_event(
app: &tauri::AppHandle,
event: &str,
payload: impl Serialize + Clone,
) {
use tauri::{Emitter, Manager};
let _ = app.emit(event, payload.clone());
if let Some(web) = app.try_state::<WebEventBroadcaster>() {
web.send(event, &payload);
}
}

View File

@@ -0,0 +1,3 @@
// ACP (Agent Communication Protocol) web handlers.
// TODO: Implement ACP handlers for web mode.
// These require special handling for connection lifecycle and streaming events.

View File

@@ -0,0 +1,264 @@
use axum::{extract::Extension, Json};
use serde::Deserialize;
use tauri::Manager;
use crate::app_error::AppCommandError;
use crate::db::service::{conversation_service, folder_service, import_service};
use crate::db::AppDatabase;
use crate::models::*;
use crate::parsers::claude::ClaudeParser;
use crate::parsers::codex::CodexParser;
use crate::parsers::gemini::GeminiParser;
use crate::parsers::openclaw::OpenClawParser;
use crate::parsers::opencode::OpenCodeParser;
use crate::parsers::AgentParser;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListFolderConversationsParams {
pub folder_id: i32,
pub agent_type: Option<AgentType>,
pub search: Option<String>,
pub sort_by: Option<String>,
pub status: Option<String>,
}
pub async fn list_folder_conversations(
Extension(app): Extension<tauri::AppHandle>,
Json(params): Json<ListFolderConversationsParams>,
) -> Result<Json<Vec<DbConversationSummary>>, AppCommandError> {
let db = app.state::<AppDatabase>();
let result = conversation_service::list_by_folder(
&db.conn,
params.folder_id,
params.agent_type,
params.search,
params.sort_by,
params.status,
)
.await
.map_err(AppCommandError::from)?;
Ok(Json(result))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListConversationsParams {
pub agent_type: Option<AgentType>,
pub search: Option<String>,
pub sort_by: Option<String>,
pub folder_path: Option<String>,
}
pub async fn list_conversations(
Json(params): Json<ListConversationsParams>,
) -> Result<Json<Vec<ConversationSummary>>, AppCommandError> {
let result = crate::commands::conversations::list_conversations_for_web(
params.agent_type,
params.search,
params.sort_by,
params.folder_path,
)
.await?;
Ok(Json(result))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetConversationParams {
pub agent_type: AgentType,
pub conversation_id: String,
}
pub async fn get_conversation(
Json(params): Json<GetConversationParams>,
) -> Result<Json<ConversationDetail>, AppCommandError> {
let at = params.agent_type;
let cid = params.conversation_id;
let result = tokio::task::spawn_blocking(move || -> Result<ConversationDetail, AppCommandError> {
let parser: Box<dyn AgentParser> = match at {
AgentType::ClaudeCode => Box::new(ClaudeParser::new()),
AgentType::Codex => Box::new(CodexParser::new()),
AgentType::OpenCode => Box::new(OpenCodeParser::new()),
AgentType::Gemini => Box::new(GeminiParser::new()),
AgentType::OpenClaw => Box::new(OpenClawParser::new()),
};
parser
.get_conversation(&cid)
.map_err(|e| AppCommandError::not_found("Conversation not found").with_detail(e.to_string()))
})
.await
.map_err(|e| {
AppCommandError::task_execution_failed("Failed to load conversation")
.with_detail(e.to_string())
})??;
Ok(Json(result))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetFolderConversationParams {
pub conversation_id: i32,
}
pub async fn get_folder_conversation(
Extension(app): Extension<tauri::AppHandle>,
Json(params): Json<GetFolderConversationParams>,
) -> Result<Json<DbConversationDetail>, AppCommandError> {
let db = app.state::<AppDatabase>();
let summary = conversation_service::get_by_id(&db.conn, params.conversation_id)
.await
.map_err(AppCommandError::from)?;
let (turns, session_stats, _resolved_ext_id) = if let Some(ref ext_id) = summary.external_id {
let at = summary.agent_type;
let eid = ext_id.clone();
tokio::task::spawn_blocking(move || -> Result<_, AppCommandError> {
let parser: Box<dyn AgentParser> = match at {
AgentType::ClaudeCode => Box::new(ClaudeParser::new()),
AgentType::Codex => Box::new(CodexParser::new()),
AgentType::OpenCode => Box::new(OpenCodeParser::new()),
AgentType::Gemini => Box::new(GeminiParser::new()),
AgentType::OpenClaw => Box::new(OpenClawParser::new()),
};
match parser.get_conversation(&eid) {
Ok(d) => Ok((d.turns, d.session_stats, None::<String>)),
Err(_) => Ok((vec![], None, None)),
}
})
.await
.map_err(|e| {
AppCommandError::task_execution_failed("Failed to read conversation turns")
.with_detail(e.to_string())
})??
} else {
(vec![], None, None)
};
let mut summary = summary;
summary.message_count = turns.len() as u32;
Ok(Json(DbConversationDetail {
summary,
turns,
session_stats,
}))
}
pub async fn list_folders() -> Result<Json<Vec<FolderInfo>>, AppCommandError> {
let result = crate::commands::conversations::list_folders_for_web().await?;
Ok(Json(result))
}
pub async fn get_stats() -> Result<Json<AgentStats>, AppCommandError> {
let result = crate::commands::conversations::get_stats_for_web().await?;
Ok(Json(result))
}
pub async fn get_sidebar_data() -> Result<Json<SidebarData>, AppCommandError> {
let result = crate::commands::conversations::get_sidebar_data_for_web().await?;
Ok(Json(result))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportLocalConversationsParams {
pub folder_id: i32,
}
pub async fn import_local_conversations(
Extension(app): Extension<tauri::AppHandle>,
Json(params): Json<ImportLocalConversationsParams>,
) -> Result<Json<ImportResult>, AppCommandError> {
let db = app.state::<AppDatabase>();
let folder = folder_service::get_folder_by_id(&db.conn, params.folder_id)
.await
.map_err(AppCommandError::from)?
.ok_or_else(|| AppCommandError::not_found("Folder not found"))?;
let result = import_service::import_local_conversations(&db.conn, params.folder_id, &folder.path)
.await
.map_err(AppCommandError::from)?;
Ok(Json(result))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateConversationParams {
pub folder_id: i32,
pub agent_type: AgentType,
pub title: Option<String>,
}
pub async fn create_conversation(
Extension(app): Extension<tauri::AppHandle>,
Json(params): Json<CreateConversationParams>,
) -> Result<Json<i32>, AppCommandError> {
let db = app.state::<AppDatabase>();
let model = conversation_service::create(
&db.conn,
params.folder_id,
params.agent_type,
params.title,
None,
)
.await
.map_err(AppCommandError::from)?;
Ok(Json(model.id))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateConversationStatusParams {
pub conversation_id: i32,
pub status: String,
}
pub async fn update_conversation_status(
Extension(app): Extension<tauri::AppHandle>,
Json(params): Json<UpdateConversationStatusParams>,
) -> Result<Json<()>, AppCommandError> {
let db = app.state::<AppDatabase>();
let status_enum: crate::db::entities::conversation::ConversationStatus =
serde_json::from_value(serde_json::Value::String(params.status)).map_err(|e| {
AppCommandError::invalid_input("Invalid conversation status").with_detail(e.to_string())
})?;
conversation_service::update_status(&db.conn, params.conversation_id, status_enum)
.await
.map_err(AppCommandError::from)?;
Ok(Json(()))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateConversationTitleParams {
pub conversation_id: i32,
pub title: String,
}
pub async fn update_conversation_title(
Extension(app): Extension<tauri::AppHandle>,
Json(params): Json<UpdateConversationTitleParams>,
) -> Result<Json<()>, AppCommandError> {
let db = app.state::<AppDatabase>();
conversation_service::update_title(&db.conn, params.conversation_id, params.title)
.await
.map_err(AppCommandError::from)?;
Ok(Json(()))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteConversationParams {
pub conversation_id: i32,
}
pub async fn delete_conversation(
Extension(app): Extension<tauri::AppHandle>,
Json(params): Json<DeleteConversationParams>,
) -> Result<Json<()>, AppCommandError> {
let db = app.state::<AppDatabase>();
conversation_service::soft_delete(&db.conn, params.conversation_id)
.await
.map_err(AppCommandError::from)?;
Ok(Json(()))
}

View File

@@ -0,0 +1,29 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use crate::app_error::{AppCommandError, AppErrorCode};
impl IntoResponse for AppCommandError {
fn into_response(self) -> Response {
let status = match self.code {
AppErrorCode::InvalidInput => StatusCode::BAD_REQUEST,
AppErrorCode::NotFound => StatusCode::NOT_FOUND,
AppErrorCode::AlreadyExists => StatusCode::CONFLICT,
AppErrorCode::PermissionDenied => StatusCode::FORBIDDEN,
AppErrorCode::AuthenticationFailed => StatusCode::UNAUTHORIZED,
AppErrorCode::ConfigurationMissing
| AppErrorCode::ConfigurationInvalid
| AppErrorCode::DependencyMissing => StatusCode::UNPROCESSABLE_ENTITY,
AppErrorCode::NetworkError
| AppErrorCode::DatabaseError
| AppErrorCode::IoError
| AppErrorCode::ExternalCommandFailed
| AppErrorCode::WindowOperationFailed
| AppErrorCode::TaskExecutionFailed => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(self)).into_response()
}
}

View File

@@ -0,0 +1,2 @@
// Folder commands web handlers.
// TODO: Implement folder command CRUD handlers for web mode.

View File

@@ -0,0 +1,58 @@
use axum::{extract::Extension, Json};
use serde::Deserialize;
use tauri::Manager;
use crate::app_error::AppCommandError;
use crate::db::service::folder_service;
use crate::db::AppDatabase;
use crate::models::*;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FolderIdParams {
pub folder_id: i32,
}
pub async fn load_folder_history(
Extension(app): Extension<tauri::AppHandle>,
) -> Result<Json<Vec<FolderHistoryEntry>>, AppCommandError> {
let db = app.state::<AppDatabase>();
let result = folder_service::list_folders(&db.conn)
.await
.map_err(AppCommandError::from)?;
Ok(Json(result))
}
pub async fn get_folder(
Extension(app): Extension<tauri::AppHandle>,
Json(params): Json<FolderIdParams>,
) -> Result<Json<FolderDetail>, AppCommandError> {
let db = app.state::<AppDatabase>();
let folder = folder_service::get_folder_by_id(&db.conn, params.folder_id)
.await
.map_err(AppCommandError::from)?
.ok_or_else(|| AppCommandError::not_found("Folder not found"))?;
Ok(Json(folder))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AddFolderParams {
pub path: String,
}
/// Web equivalent of `open_folder_window`: adds the folder to DB and returns its ID.
/// The web client then navigates to `/folder?id=N` itself.
pub async fn open_folder_window(
Extension(app): Extension<tauri::AppHandle>,
Json(params): Json<AddFolderParams>,
) -> Result<Json<FolderHistoryEntry>, AppCommandError> {
let db = app.state::<AppDatabase>();
let entry = folder_service::add_folder(&db.conn, &params.path)
.await
.map_err(AppCommandError::from)?;
Ok(Json(entry))
}
// TODO: Add remaining folder handlers (git operations, file operations, etc.)
// These will be added incrementally as needed.

View File

@@ -0,0 +1,2 @@
// MCP (Model Context Protocol) web handlers.
// TODO: Implement MCP marketplace and server management handlers for web mode.

View File

@@ -0,0 +1,9 @@
mod error;
pub mod conversations;
pub mod folders;
pub mod acp;
pub mod terminal;
pub mod system_settings;
pub mod version_control;
pub mod folder_commands;
pub mod mcp;

View File

@@ -0,0 +1,38 @@
use axum::{extract::Extension, Json};
use tauri::Manager;
use crate::app_error::AppCommandError;
use crate::db::service::app_metadata_service;
use crate::db::AppDatabase;
use crate::models::*;
const SYSTEM_PROXY_SETTINGS_KEY: &str = "system_proxy_settings";
const SYSTEM_LANGUAGE_SETTINGS_KEY: &str = "system_language_settings";
pub async fn get_system_proxy_settings(
Extension(app): Extension<tauri::AppHandle>,
) -> Result<Json<SystemProxySettings>, AppCommandError> {
let db = app.state::<AppDatabase>();
let raw = app_metadata_service::get_value(&db.conn, SYSTEM_PROXY_SETTINGS_KEY)
.await
.map_err(AppCommandError::from)?;
let settings = raw
.and_then(|v| serde_json::from_str::<SystemProxySettings>(&v).ok())
.unwrap_or_default();
Ok(Json(settings))
}
pub async fn get_system_language_settings(
Extension(app): Extension<tauri::AppHandle>,
) -> Result<Json<SystemLanguageSettings>, AppCommandError> {
let db = app.state::<AppDatabase>();
let raw = app_metadata_service::get_value(&db.conn, SYSTEM_LANGUAGE_SETTINGS_KEY)
.await
.map_err(AppCommandError::from)?;
let settings = raw
.and_then(|v| serde_json::from_str::<SystemLanguageSettings>(&v).ok())
.unwrap_or_default();
Ok(Json(settings))
}

View File

@@ -0,0 +1,3 @@
// Terminal web handlers.
// TODO: Implement terminal handlers for web mode.
// Terminal I/O streams over WebSocket instead of Tauri events.

View File

@@ -0,0 +1,2 @@
// Version control web handlers.
// TODO: Implement git settings and GitHub account handlers for web mode.

189
src-tauri/src/web/mod.rs Normal file
View File

@@ -0,0 +1,189 @@
pub mod auth;
pub mod event_bridge;
pub mod handlers;
pub mod router;
pub mod ws;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU16, Ordering};
use std::sync::Mutex;
use serde::Serialize;
use tauri::Manager;
use crate::app_error::{AppCommandError, AppErrorCode};
pub struct WebServerState {
handle: Mutex<Option<tauri::async_runtime::JoinHandle<()>>>,
port: AtomicU16,
token: Mutex<String>,
running: std::sync::atomic::AtomicBool,
}
impl WebServerState {
pub fn new() -> Self {
Self {
handle: Mutex::new(None),
port: AtomicU16::new(0),
token: Mutex::new(String::new()),
running: std::sync::atomic::AtomicBool::new(false),
}
}
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WebServerInfo {
pub port: u16,
pub token: String,
pub addresses: Vec<String>,
}
fn generate_random_token() -> String {
uuid::Uuid::new_v4().to_string().replace('-', "")
}
fn find_static_dir(app: &tauri::AppHandle) -> PathBuf {
// 1. Production: Tauri bundles frontendDist into the resource directory.
let resource = app.path().resource_dir().ok();
if let Some(ref dir) = resource {
// In production builds, the HTML files are at the resource root.
if dir.join("index.html").exists() {
eprintln!("[WEB] Serving static files from resource dir: {}", dir.display());
return dir.clone();
}
// Or possibly in an "out" subdirectory.
let out = dir.join("out");
if out.join("index.html").exists() {
eprintln!("[WEB] Serving static files from resource/out: {}", out.display());
return out;
}
}
// 2. Dev mode: "out/" is at the project root, which is one level above src-tauri/.
// The Cargo manifest dir at compile time gives us the src-tauri/ path.
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let project_out = manifest_dir.parent().map(|p| p.join("out"));
if let Some(ref out) = project_out {
if out.join("index.html").exists() {
eprintln!("[WEB] Serving static files from project out/: {}", out.display());
return out.clone();
}
}
// 3. Fallback: current working directory / out
let cwd_out = std::env::current_dir()
.map(|d| d.join("out"))
.unwrap_or_else(|_| PathBuf::from("out"));
eprintln!(
"[WEB] Fallback static dir (may not exist): {}",
cwd_out.display()
);
cwd_out
}
fn get_local_addresses(port: u16) -> Vec<String> {
let mut addrs = vec![format!("http://127.0.0.1:{}", port)];
// Try to get LAN IPs
if let Ok(interfaces) = std::net::UdpSocket::bind("0.0.0.0:0") {
// Connect to a public DNS to determine local IP
if interfaces.connect("8.8.8.8:80").is_ok() {
if let Ok(local_addr) = interfaces.local_addr() {
addrs.push(format!("http://{}:{}", local_addr.ip(), port));
}
}
}
addrs
}
#[tauri::command]
pub async fn start_web_server(
app: tauri::AppHandle,
state: tauri::State<'_, WebServerState>,
port: Option<u16>,
host: Option<String>,
) -> Result<WebServerInfo, AppCommandError> {
// Check if already running
if state.running.load(Ordering::Relaxed) {
return Err(AppCommandError::new(
AppErrorCode::AlreadyExists,
"Web server is already running",
));
}
let port = port.unwrap_or(3080);
let host = host.unwrap_or_else(|| "0.0.0.0".to_string());
let token = generate_random_token();
// Determine static directory for serving the frontend.
// In production: files are bundled into the resource directory.
// In dev: the "out/" directory is at the project root (one level above src-tauri/).
let static_dir = find_static_dir(&app);
let router = router::build_router(app.clone(), token.clone(), static_dir);
let addr: SocketAddr = format!("{}:{}", host, port)
.parse()
.map_err(|e: std::net::AddrParseError| {
AppCommandError::invalid_input("Invalid host/port").with_detail(e.to_string())
})?;
let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| {
AppCommandError::io_error("Failed to bind address").with_detail(e.to_string())
})?;
let actual_port = listener.local_addr().map(|a| a.port()).unwrap_or(port);
eprintln!("[WEB] Starting web server on {}", addr);
let handle = tauri::async_runtime::spawn(async move {
if let Err(e) = axum::serve(listener, router).await {
eprintln!("[WEB] Server error: {}", e);
}
});
// Store state
*state.handle.lock().unwrap() = Some(handle);
state.port.store(actual_port, Ordering::Relaxed);
*state.token.lock().unwrap() = token.clone();
state.running.store(true, Ordering::Relaxed);
let addresses = get_local_addresses(actual_port);
Ok(WebServerInfo {
port: actual_port,
token,
addresses,
})
}
#[tauri::command]
pub async fn stop_web_server(
state: tauri::State<'_, WebServerState>,
) -> Result<(), AppCommandError> {
if let Some(handle) = state.handle.lock().unwrap().take() {
handle.abort();
}
state.running.store(false, Ordering::Relaxed);
state.port.store(0, Ordering::Relaxed);
*state.token.lock().unwrap() = String::new();
eprintln!("[WEB] Web server stopped");
Ok(())
}
#[tauri::command]
pub async fn get_web_server_status(
state: tauri::State<'_, WebServerState>,
) -> Result<Option<WebServerInfo>, AppCommandError> {
if !state.running.load(Ordering::Relaxed) {
return Ok(None);
}
let port = state.port.load(Ordering::Relaxed);
let token = state.token.lock().unwrap().clone();
let addresses = get_local_addresses(port);
Ok(Some(WebServerInfo {
port,
token,
addresses,
}))
}

116
src-tauri/src/web/router.rs Normal file
View File

@@ -0,0 +1,116 @@
use axum::{
extract::Extension,
http::{StatusCode, Uri},
middleware::{self, Next},
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
use super::{auth, handlers, ws};
pub fn build_router(app: tauri::AppHandle, token: String, static_dir: std::path::PathBuf) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let token_for_ws = token.clone();
let api = Router::new()
// Health check (lightweight, used for token validation)
.route("/health", post(health_check))
// Conversations
.route("/list_conversations", post(handlers::conversations::list_conversations))
.route("/get_conversation", post(handlers::conversations::get_conversation))
.route("/list_folder_conversations", post(handlers::conversations::list_folder_conversations))
.route("/get_folder_conversation", post(handlers::conversations::get_folder_conversation))
.route("/import_local_conversations", post(handlers::conversations::import_local_conversations))
.route("/list_folders", post(handlers::conversations::list_folders))
.route("/get_stats", post(handlers::conversations::get_stats))
.route("/get_sidebar_data", post(handlers::conversations::get_sidebar_data))
.route("/create_conversation", post(handlers::conversations::create_conversation))
.route("/update_conversation_status", post(handlers::conversations::update_conversation_status))
.route("/update_conversation_title", post(handlers::conversations::update_conversation_title))
.route("/delete_conversation", post(handlers::conversations::delete_conversation))
// Folders
.route("/load_folder_history", post(handlers::folders::load_folder_history))
.route("/get_folder", post(handlers::folders::get_folder))
.route("/open_folder_window", post(handlers::folders::open_folder_window))
// System settings
.route("/get_system_proxy_settings", post(handlers::system_settings::get_system_proxy_settings))
.route("/get_system_language_settings", post(handlers::system_settings::get_system_language_settings))
// Catch-all: return proper JSON 404 for unimplemented API endpoints
.fallback(api_not_found)
// Auth middleware for API routes
.layer(middleware::from_fn(move |req, next| {
auth::require_token(req, next, token.clone())
}));
// WebSocket route (auth via query param)
let ws_route = Router::new()
.route("/ws/events", get(ws::ws_handler))
.layer(middleware::from_fn(move |req, next| {
auth::require_token(req, next, token_for_ws.clone())
}));
// Static file serving.
// Next.js static export produces "folder.html" for "/folder" route.
// We use a middleware to rewrite "/folder" → "/folder.html" before ServeDir.
let fallback = ServeDir::new(&static_dir)
.fallback(ServeFile::new(static_dir.join("index.html")));
let static_dir_for_mw = static_dir.clone();
let html_rewrite = middleware::from_fn(move |req: axum::extract::Request, next: Next| {
let dir = static_dir_for_mw.clone();
async move {
let path = req.uri().path();
// If path has no extension (not a file) and a .html version exists, rewrite
if path != "/" && !path.contains('.') && !path.starts_with("/api") && !path.starts_with("/ws") {
let html_path = format!("{}.html", path.trim_end_matches('/'));
let html_file = dir.join(html_path.trim_start_matches('/'));
if html_file.exists() {
// Rebuild URI with .html suffix preserving query string
let new_path = if let Some(q) = req.uri().query() {
format!("{}?{}", html_path, q)
} else {
html_path
};
if let Ok(new_uri) = new_path.parse::<Uri>() {
let (mut parts, body) = req.into_parts();
parts.uri = new_uri;
let req = axum::extract::Request::from_parts(parts, body);
return next.run(req).await;
}
}
}
next.run(req).await
}
});
Router::new()
.nest("/api", api)
.merge(ws_route)
.fallback_service(fallback)
.layer(html_rewrite)
.layer(cors)
.layer(Extension(app))
}
async fn health_check() -> impl IntoResponse {
Json(serde_json::json!({ "status": "ok" }))
}
async fn api_not_found(uri: axum::http::Uri) -> impl IntoResponse {
let command = uri.path().trim_start_matches('/');
eprintln!("[WEB] Unimplemented API endpoint: {}", command);
(
StatusCode::NOT_IMPLEMENTED,
Json(serde_json::json!({
"code": "not_implemented",
"message": format!("API endpoint '{}' is not available in web mode", command),
})),
)
}

50
src-tauri/src/web/ws.rs Normal file
View File

@@ -0,0 +1,50 @@
use axum::{
extract::{Extension, WebSocketUpgrade},
response::IntoResponse,
};
use axum::extract::ws::{Message, WebSocket};
use tauri::Manager;
use super::event_bridge::WebEventBroadcaster;
pub async fn ws_handler(
ws: WebSocketUpgrade,
Extension(app): Extension<tauri::AppHandle>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_ws_connection(socket, app))
}
async fn handle_ws_connection(mut socket: WebSocket, app: tauri::AppHandle) {
let broadcaster = app.state::<WebEventBroadcaster>();
let mut rx = broadcaster.subscribe();
loop {
tokio::select! {
result = rx.recv() => {
match result {
Ok(event) => {
if let Ok(msg) = serde_json::to_string(&event) {
if socket.send(Message::Text(msg.into())).await.is_err() {
break;
}
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
eprintln!("[WS] receiver lagged, skipped {n} events");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
break;
}
}
}
msg = socket.recv() => {
match msg {
Some(Ok(_)) => {
// Client messages currently unused; reserved for future use
}
_ => break,
}
}
}
}
}