diff --git a/cardinal/src-tauri/Cargo.lock b/cardinal/src-tauri/Cargo.lock index e19f1ca0..3d571470 100644 --- a/cardinal/src-tauri/Cargo.lock +++ b/cardinal/src-tauri/Cargo.lock @@ -219,6 +219,58 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -385,6 +437,7 @@ name = "cardinal" version = "0.1.23" dependencies = [ "anyhow", + "axum", "base64 0.22.1", "camino", "cardinal-sdk", @@ -410,6 +463,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-prevent-default", "tauri-plugin-window-state", + "tokio", "tracing", "tracing-subscriber", ] @@ -1796,6 +1850,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -1809,6 +1869,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -2409,6 +2470,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" @@ -3623,6 +3690,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3836,6 +3909,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -3865,6 +3949,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.18.0" @@ -4750,18 +4846,30 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4893,6 +5001,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -4931,6 +5040,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/cardinal/src-tauri/Cargo.toml b/cardinal/src-tauri/Cargo.toml index 386b1944..dcb7e224 100644 --- a/cardinal/src-tauri/Cargo.toml +++ b/cardinal/src-tauri/Cargo.toml @@ -46,6 +46,8 @@ fswalk.path = "../../fswalk" fs-icon.path = "../../fs-icon" search-cancel.path = "../../search-cancel" camino = "1.2.1" +axum = "0.8.9" +tokio = { version = "1.52.1", features = ["rt-multi-thread", "macros"] } [features] default = [] diff --git a/cardinal/src-tauri/src/commands.rs b/cardinal/src-tauri/src/commands.rs index 5e1bcaac..2d8937c8 100644 --- a/cardinal/src-tauri/src/commands.rs +++ b/cardinal/src-tauri/src/commands.rs @@ -23,7 +23,7 @@ use search_cache::{SearchOptions, SearchOutcome, SearchResultNode, SlabIndex, Sl use search_cancel::CancellationToken; use serde::{Deserialize, Serialize}; use std::{cell::LazyCell, process::Command}; -use tauri::{ActivationPolicy, AppHandle, State}; +use tauri::{ActivationPolicy, AppHandle, Emitter, Manager, State}; use tracing::{error, info, warn}; #[derive(Debug, Clone)] @@ -47,6 +47,31 @@ impl From for SearchOptions { } } +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerConfig { + pub enabled: bool, + pub endpoint: String, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + enabled: false, + endpoint: "127.0.0.1:3388".to_string(), + } + } +} + +/// Load `ServerConfig` from a JSON file previously written by `set_server_config`. +/// Returns `ServerConfig::default()` if the file is absent or malformed. +pub(crate) fn load_server_config_from_file(path: &std::path::Path) -> ServerConfig { + let Ok(contents) = std::fs::read_to_string(path) else { + return ServerConfig::default(); + }; + serde_json::from_str::(&contents).unwrap_or_default() +} + #[derive(Debug, Clone)] pub struct SearchJob { pub query: String, @@ -68,22 +93,27 @@ struct SortedViewCache { } pub struct SearchState { - search_tx: Sender, - node_info_tx: Sender, + pub(crate) search_tx: Sender, + pub(crate) node_info_tx: Sender, icon_viewport_tx: Sender<(u64, Vec)>, rescan_tx: Sender, watch_config_tx: Sender, + pub(crate) server_config: Mutex, + pub(crate) server_config_tx: Sender, sorted_view_cache: Mutex>, pub(crate) update_window_state_tx: Sender<()>, } impl SearchState { + #[allow(clippy::too_many_arguments)] pub fn new( search_tx: Sender, node_info_tx: Sender, icon_viewport_tx: Sender<(u64, Vec)>, rescan_tx: Sender, watch_config_tx: Sender, + server_config: ServerConfig, + server_config_tx: Sender, update_window_state_tx: Sender<()>, ) -> Self { Self { @@ -92,6 +122,8 @@ impl SearchState { icon_viewport_tx, rescan_tx, watch_config_tx, + server_config: Mutex::new(server_config), + server_config_tx, sorted_view_cache: Mutex::new(None), update_window_state_tx, } @@ -230,7 +262,7 @@ impl SearchResponse { pub const CANCELLED: u8 = 1; } -#[derive(Serialize)] +#[derive(Clone, Serialize)] pub struct NodeInfoMetadata { pub r#type: u8, pub size: i64, @@ -430,6 +462,40 @@ pub fn set_watch_config( } } +#[tauri::command] +pub fn get_server_config(state: State<'_, SearchState>) -> ServerConfig { + state.server_config.lock().clone() +} + +#[tauri::command(async)] +pub fn set_server_config(app: AppHandle, config: ServerConfig, state: State<'_, SearchState>) { + { + let mut current = state.server_config.lock(); + *current = config.clone(); + } + + // Persist so the server can start from the correct config on next launch. + if let Ok(config_dir) = app.path().app_config_dir() { + let path = config_dir.join("server_config.json"); + match serde_json::to_string(&config) { + Ok(json) => { + if let Err(e) = std::fs::write(&path, json) { + error!("Failed to persist server config: {e:?}"); + } + } + Err(e) => error!("Failed to serialize server config: {e:?}"), + } + } + + if let Err(e) = state.server_config_tx.send(config.clone()) { + error!("Failed to request server config change: {e:?}"); + } + + if let Err(e) = app.emit("server-config-changed", config) { + error!("Failed to emit server-config-changed event: {e:?}"); + } +} + #[tauri::command] pub async fn open_in_finder(path: String) { if let Err(e) = Command::new("open").arg("-R").arg(&path).spawn() { diff --git a/cardinal/src-tauri/src/lib.rs b/cardinal/src-tauri/src/lib.rs index 920840c0..478bfce8 100644 --- a/cardinal/src-tauri/src/lib.rs +++ b/cardinal/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod commands; mod lifecycle; mod quicklook; mod search_activity; +mod server; mod sort; mod window_controls; @@ -13,11 +14,12 @@ use background::{ }; use cardinal_sdk::EventWatcher; use commands::{ - NodeInfoRequest, SearchJob, SearchState, WatchConfigUpdate, activate_main_window, - close_quicklook, copy_files_to_clipboard, get_app_status, get_nodes_info, get_sorted_view, - hide_main_window, normalize_watch_config, open_in_finder, open_path, search, - set_tray_activation_policy, set_watch_config, start_logic, toggle_main_window, - toggle_quicklook, trigger_rescan, update_icon_viewport, update_quicklook, + NodeInfoRequest, SearchJob, SearchState, ServerConfig, WatchConfigUpdate, activate_main_window, + close_quicklook, copy_files_to_clipboard, get_app_status, get_nodes_info, get_server_config, + get_sorted_view, hide_main_window, load_server_config_from_file, normalize_watch_config, + open_in_finder, open_path, search, set_server_config, set_tray_activation_policy, + set_watch_config, start_logic, toggle_main_window, toggle_quicklook, trigger_rescan, + update_icon_viewport, update_quicklook, }; use crossbeam_channel::{Receiver, RecvTimeoutError, Sender, bounded, unbounded}; use lifecycle::{ @@ -63,6 +65,7 @@ pub fn run() -> Result<()> { let (icon_viewport_tx, icon_viewport_rx) = unbounded::<(u64, Vec)>(); let (rescan_tx, rescan_rx) = unbounded::(); let (watch_config_tx, watch_config_rx) = unbounded::(); + let (server_config_tx, server_config_rx) = unbounded::(); let (icon_update_tx, icon_update_rx) = unbounded::(); let (update_window_state_tx, update_window_state_rx) = bounded::<()>(1); let (logic_start_tx, logic_start_rx) = bounded(1); @@ -114,21 +117,25 @@ pub fn run() -> Result<()> { let app = builder .manage(SearchState::new( - search_tx, - node_info_tx, + search_tx.clone(), + node_info_tx.clone(), icon_viewport_tx.clone(), rescan_tx.clone(), watch_config_tx.clone(), + ServerConfig::default(), + server_config_tx.clone(), update_window_state_tx.clone(), )) .invoke_handler(tauri::generate_handler![ search, get_nodes_info, get_sorted_view, + get_server_config, update_icon_viewport, get_app_status, trigger_rescan, set_watch_config, + set_server_config, open_in_finder, open_path, toggle_quicklook, @@ -144,6 +151,16 @@ pub fn run() -> Result<()> { .build(tauri::generate_context!()) .expect("error while running tauri application"); + let initial_server_config = app + .path() + .app_config_dir() + .map(|p| load_server_config_from_file(&p.join("server_config.json"))) + .unwrap_or_default(); + { + let state = app.state::(); + *state.server_config.lock() = initial_server_config.clone(); + } + let db_path = DB_PATH .get_or_try_init(|| app.path().app_config_dir().map(|p| p.join("cardinal.db"))) .expect("Failed to initialize database path"); @@ -161,6 +178,51 @@ pub fn run() -> Result<()> { }; emit_app_state(app_handle); let icon_update_rx = &icon_update_rx; + + // http server + let server_state = server::ServerState::new(search_tx, node_info_tx); + tauri::async_runtime::spawn(async move { + let mut current_server_handle: Option> = None; + let server_config_rx = server_config_rx; + let mut current_config = initial_server_config; + + loop { + // If enabled and not running, start it + if current_config.enabled && current_server_handle.is_none() { + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let endpoint = current_config.endpoint.clone(); + let state = server_state.clone(); + tauri::async_runtime::spawn(async move { + server::start_server(state, endpoint, async { + let _ = shutdown_rx.await; + }) + .await; + }); + current_server_handle = Some(shutdown_tx); + } else if !current_config.enabled && current_server_handle.is_some() { + // If disabled and running, stop it + if let Some(handle) = current_server_handle.take() { + let _ = handle.send(()); + } + } + + // Wait for next config update + match server_config_rx.recv() { + Ok(new_config) => { + info!("Received server config update: {:?}", new_config); + if new_config.endpoint != current_config.endpoint || !new_config.enabled { + // Restart if endpoint changed or disabled + if let Some(handle) = current_server_handle.take() { + let _ = handle.send(()); + } + } + current_config = new_config; + } + Err(_) => break, // Channel closed + } + } + }); + std::thread::scope(move |s| { s.spawn(|| { while let Ok(icon) = icon_update_rx.recv() { diff --git a/cardinal/src-tauri/src/server.rs b/cardinal/src-tauri/src/server.rs new file mode 100644 index 00000000..6837e970 --- /dev/null +++ b/cardinal/src-tauri/src/server.rs @@ -0,0 +1,211 @@ +use crate::commands::{NodeInfoMetadata, NodeInfoRequest, SearchJob, SearchOptionsPayload}; +use axum::{ + Json, Router, + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, +}; +use crossbeam_channel::{Sender, bounded}; +use search_cancel::CancellationToken; +use serde::{Deserialize, Serialize}; +use std::{future::Future, sync::Arc}; +use tokio::net::TcpListener; + +const TRUNCATE_THRES: usize = 10000; + +#[derive(Clone)] +pub struct ServerState { + pub search_tx: Sender, + pub node_info_tx: Sender, +} + +impl ServerState { + pub fn new(search_tx: Sender, node_info_tx: Sender) -> Self { + Self { + search_tx, + node_info_tx, + } + } +} + +#[derive(Deserialize)] +pub struct SearchRequest { + pub query: String, + #[serde(default)] + pub options: SearchOptionsPayload, + #[serde(default)] + pub limit: Option, + #[serde(default)] + pub offset: Option, + #[serde(default)] + pub sort: Option, +} + +#[derive(Clone, Serialize)] +pub struct ServerNodeInfo { + pub path: String, + pub metadata: Option, + pub icon: Option, +} + +#[derive(Serialize)] +pub struct ServerSearchResponse { + pub results: Vec, + pub highlights: Vec, +} + +pub async fn start_server(state: ServerState, endpoint: String, shutdown_signal: F) +where + F: Future + Send + 'static, +{ + let app = Router::new() + .route("/search", get(search_handler_get)) + .route("/search", post(search_handler)) + .with_state(Arc::new(state)); + + let listener = match TcpListener::bind(endpoint.as_str()).await { + Ok(l) => l, + Err(e) => { + tracing::error!("Failed to bind to {}: {}", endpoint, e); + return; + } + }; + + tracing::info!("Starting axum HTTP server on {}", endpoint); + if let Err(e) = axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal) + .await + { + tracing::error!("Server error: {}", e); + } +} + +async fn search_handler_get( + State(state): State>, + Query(req): Query, +) -> impl IntoResponse { + handle_search(state, req).await +} + +async fn search_handler( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + handle_search(state, req).await +} + +async fn handle_search(state: Arc, req: SearchRequest) -> impl IntoResponse { + let result = tokio::task::spawn_blocking(move || { + let (slab_indices, highlights) = { + let cancellation_token = CancellationToken::new_search(); + + let (result_tx, result_rx) = bounded(1); + if let Err(e) = state.search_tx.send(SearchJob { + query: req.query, + options: req.options, + cancellation_token, + result_tx, + }) { + tracing::error!("Failed to send search job: {:?}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + let outcome = match result_rx.recv() { + Ok(Ok(outcome)) => outcome, + Ok(Err(e)) => { + tracing::error!("Search error: {:?}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + Err(e) => { + tracing::error!("Reply channel closed: {:?}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + let mut fetched_indices = outcome.nodes.unwrap_or_default(); + + if let Some(sort) = &req.sort { + if fetched_indices.len() > 10000 { + fetched_indices.truncate(10000); + } + let (node_info_reply_tx, node_info_reply_rx) = bounded(1); + if let Err(e) = state.node_info_tx.send(NodeInfoRequest { + slab_indices: fetched_indices.clone(), + response_tx: node_info_reply_tx, + }) { + tracing::error!("Failed to send node info request for sorting: {:?}", e); + } else if let Ok(all_nodes) = node_info_reply_rx.recv() { + let mut entries: Vec<_> = fetched_indices + .into_iter() + .zip(all_nodes) + .map(|(slab_index, node)| crate::sort::SortEntry::new(slab_index, node)) + .collect(); + crate::sort::sort_entries(&mut entries, sort); + fetched_indices = entries.into_iter().map(|e| e.slab_index).collect(); + } + } + + (fetched_indices, outcome.highlights) + }; + + let mut results = slab_indices; + + if let Some(offset) = req.offset { + if offset < results.len() { + results = results[offset..].to_vec(); + } else { + results.clear(); + } + } + if let Some(limit) = req.limit { + results.truncate(limit); + } + + // prevent the runtime from getting stuck with a massive number of results. + if results.len() > TRUNCATE_THRES { + results.truncate(TRUNCATE_THRES); + } + + let (node_info_reply_tx, node_info_reply_rx) = bounded(1); + if let Err(e) = state.node_info_tx.send(NodeInfoRequest { + slab_indices: results, + response_tx: node_info_reply_tx, + }) { + tracing::error!("Failed to send node info request: {:?}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + let final_nodes = match node_info_reply_rx.recv() { + Ok(nodes) => nodes, + Err(e) => { + tracing::error!("Node info reply channel closed: {:?}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + let final_results: Vec<_> = final_nodes + .into_iter() + .map(|node| { + let path = node.path.to_string_lossy().into_owned(); + ServerNodeInfo { + path, + metadata: node.metadata.as_ref().map(NodeInfoMetadata::from_metadata), + icon: None, + } + }) + .collect(); + + Ok(Json(ServerSearchResponse { + results: final_results, + highlights, + })) + }) + .await; + + match result { + Ok(Ok(response)) => response.into_response(), + Ok(Err(status)) => status.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} diff --git a/cardinal/src-tauri/src/sort.rs b/cardinal/src-tauri/src/sort.rs index 7f4504d3..815b8f2b 100644 --- a/cardinal/src-tauri/src/sort.rs +++ b/cardinal/src-tauri/src/sort.rs @@ -1,16 +1,16 @@ use fswalk::NodeFileType; use search_cache::{SearchResultNode, SlabIndex, SlabNodeMetadataCompact}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::{cmp::Ordering as StdOrdering, path::Path}; -#[derive(Debug, Clone, Copy, Deserialize)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SortStatePayload { pub key: SortKeyPayload, pub direction: SortDirectionPayload, } -#[derive(Debug, Clone, Copy, Deserialize)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub enum SortKeyPayload { Filename, @@ -20,7 +20,7 @@ pub enum SortKeyPayload { Ctime, } -#[derive(Debug, Clone, Copy, Deserialize)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum SortDirectionPayload { Asc, diff --git a/cardinal/src/App.tsx b/cardinal/src/App.tsx index ce1170e2..1b0751fb 100644 --- a/cardinal/src/App.tsx +++ b/cardinal/src/App.tsx @@ -187,8 +187,10 @@ function App() { defaultIgnorePaths, includePaths, defaultIncludePaths, + serverConfig, + defaultServerConfig, preferencesResetToken, - handleWatchConfigChange, + handlePreferencesChange, handleResetPreferences, } = useAppPreferences({ fullDiskAccessStatus, @@ -428,11 +430,13 @@ function App() { onTrayIconEnabledChange={setTrayIconEnabled} watchRoot={watchRoot ?? defaultWatchRoot} defaultWatchRoot={defaultWatchRoot} - onWatchConfigChange={handleWatchConfigChange} + onPreferencesChange={handlePreferencesChange} ignorePaths={ignorePaths} defaultIgnorePaths={defaultIgnorePaths} includePaths={includePaths} defaultIncludePaths={defaultIncludePaths} + serverConfig={serverConfig} + defaultServerConfig={defaultServerConfig} onReset={handleResetPreferences} themeResetToken={preferencesResetToken} /> diff --git a/cardinal/src/__tests__/App.contextMenu.test.tsx b/cardinal/src/__tests__/App.contextMenu.test.tsx index c19e2b01..25302e58 100644 --- a/cardinal/src/__tests__/App.contextMenu.test.tsx +++ b/cardinal/src/__tests__/App.contextMenu.test.tsx @@ -228,8 +228,12 @@ vi.mock('../hooks/useAppPreferences', () => ({ defaultWatchRoot: '/', ignorePaths: ['/Volumes'], defaultIgnorePaths: ['/Volumes'], + includePaths: [], + defaultIncludePaths: [], + serverConfig: { enabled: false, endpoint: '127.0.0.1:3388' }, + defaultServerConfig: { enabled: false, endpoint: '127.0.0.1:3388' }, preferencesResetToken: 0, - handleWatchConfigChange: vi.fn(), + handlePreferencesChange: vi.fn(), handleResetPreferences: vi.fn(), }), })); diff --git a/cardinal/src/__tests__/App.searchNavigation.test.tsx b/cardinal/src/__tests__/App.searchNavigation.test.tsx index cb4faeb4..afabe98f 100644 --- a/cardinal/src/__tests__/App.searchNavigation.test.tsx +++ b/cardinal/src/__tests__/App.searchNavigation.test.tsx @@ -211,8 +211,12 @@ vi.mock('../hooks/useAppPreferences', () => ({ defaultWatchRoot: '/', ignorePaths: ['/Volumes'], defaultIgnorePaths: ['/Volumes'], + includePaths: [], + defaultIncludePaths: [], + serverConfig: { enabled: false, endpoint: '127.0.0.1:3388' }, + defaultServerConfig: { enabled: false, endpoint: '127.0.0.1:3388' }, preferencesResetToken: 0, - handleWatchConfigChange: vi.fn(), + handlePreferencesChange: vi.fn(), handleResetPreferences: vi.fn(), }), })); diff --git a/cardinal/src/components/PreferencesOverlay.tsx b/cardinal/src/components/PreferencesOverlay.tsx index 1b7e92bc..cb3a90c2 100644 --- a/cardinal/src/components/PreferencesOverlay.tsx +++ b/cardinal/src/components/PreferencesOverlay.tsx @@ -1,6 +1,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { getWatchRootValidation, isPathInputValid } from '../utils/watchRoot'; +import { isValidEndpoint } from '../hooks/useServerConfig'; +import type { ServerConfig } from '../hooks/useServerConfig'; import ThemeSwitcher from './ThemeSwitcher'; import LanguageSwitcher from './LanguageSwitcher'; @@ -14,15 +16,18 @@ type PreferencesOverlayProps = { onTrayIconEnabledChange: (enabled: boolean) => void; watchRoot: string; defaultWatchRoot: string; - onWatchConfigChange: (next: { + onPreferencesChange: (next: { watchRoot: string; ignorePaths: string[]; includePaths: string[]; + serverConfig: ServerConfig; }) => void; ignorePaths: string[]; defaultIgnorePaths: string[]; includePaths: string[]; defaultIncludePaths: string[]; + serverConfig: ServerConfig; + defaultServerConfig: ServerConfig; onReset: () => void; themeResetToken: number; }; @@ -37,11 +42,13 @@ export function PreferencesOverlay({ onTrayIconEnabledChange, watchRoot, defaultWatchRoot, - onWatchConfigChange, + onPreferencesChange, ignorePaths, defaultIgnorePaths, includePaths, defaultIncludePaths, + serverConfig, + defaultServerConfig, onReset, themeResetToken, }: PreferencesOverlayProps): React.JSX.Element | null { @@ -50,6 +57,10 @@ export function PreferencesOverlay({ const [watchRootInput, setWatchRootInput] = useState(() => watchRoot); const [ignorePathsInput, setIgnorePathsInput] = useState(() => ignorePaths.join('\n')); const [includePathsInput, setIncludePathsInput] = useState(() => includePaths.join('\n')); + const [serverEnabledInput, setServerEnabledInput] = useState(() => serverConfig.enabled); + const [serverEndpointInput, setServerEndpointInput] = useState( + () => serverConfig.endpoint, + ); useEffect(() => { if (!open) { @@ -81,7 +92,9 @@ export function PreferencesOverlay({ setWatchRootInput(watchRoot); setIgnorePathsInput(ignorePaths.join('\n')); setIncludePathsInput(includePaths.join('\n')); - }, [open, watchRoot, ignorePaths, includePaths]); + setServerEnabledInput(serverConfig.enabled); + setServerEndpointInput(serverConfig.endpoint); + }, [open, watchRoot, ignorePaths, includePaths, serverConfig]); const commitThreshold = useCallback(() => { const numericText = thresholdInput.replace(/[^\d]/g, ''); @@ -127,20 +140,37 @@ export function PreferencesOverlay({ return invalid ? t('includePaths.errors.absolute') : null; })(); + const trimmedServerEndpoint = serverEndpointInput.trim(); + const serverEndpointErrorMessage = !isValidEndpoint(trimmedServerEndpoint) + ? t('preferences.server.endpointError', { + defaultValue: 'Enter an endpoint like 127.0.0.1:3388 or 0.0.0.0:3388.', + }) + : null; + const handleSave = (): void => { - if (watchRootErrorMessage || ignorePathsErrorMessage || includePathsErrorMessage) { + if ( + watchRootErrorMessage || + ignorePathsErrorMessage || + includePathsErrorMessage || + serverEndpointErrorMessage + ) { return; } commitThreshold(); const trimmedWatchRoot = watchRootInput.trim(); - onWatchConfigChange({ + onPreferencesChange({ watchRoot: trimmedWatchRoot, ignorePaths: parsedIgnorePaths, includePaths: parsedIncludePaths, + serverConfig: { + enabled: serverEnabledInput, + endpoint: trimmedServerEndpoint, + }, }); setWatchRootInput(trimmedWatchRoot); setIgnorePathsInput(parsedIgnorePaths.join('\n')); setIncludePathsInput(parsedIncludePaths.join('\n')); + setServerEndpointInput(trimmedServerEndpoint); onClose(); }; @@ -149,6 +179,8 @@ export function PreferencesOverlay({ setWatchRootInput(defaultWatchRoot); setIgnorePathsInput(defaultIgnorePaths.join('\n')); setIncludePathsInput(defaultIncludePaths.join('\n')); + setServerEnabledInput(defaultServerConfig.enabled); + setServerEndpointInput(defaultServerConfig.endpoint); onReset(); }; @@ -198,6 +230,58 @@ export function PreferencesOverlay({ +
+

+ {t('preferences.server.enabled', { + defaultValue: 'HTTP search server', + })} +

+
+ +
+
+
+
+

+ {t('preferences.server.endpoint', { + defaultValue: 'HTTP server endpoint', + })} +

+
+
+ setServerEndpointInput(event.target.value)} + aria-label={t('preferences.server.endpoint', { + defaultValue: 'HTTP server endpoint', + })} + autoComplete="off" + spellCheck={false} + /> + {serverEndpointErrorMessage ? ( +

+ {serverEndpointErrorMessage} +

+ ) : null} +
+

{t('preferences.sortingLimit.label')}

@@ -300,7 +384,10 @@ export function PreferencesOverlay({ type="button" onClick={handleSave} disabled={Boolean( - watchRootErrorMessage || ignorePathsErrorMessage || includePathsErrorMessage, + watchRootErrorMessage || + ignorePathsErrorMessage || + includePathsErrorMessage || + serverEndpointErrorMessage, )} > {t('preferences.save')} diff --git a/cardinal/src/components/__tests__/PreferencesOverlay.test.tsx b/cardinal/src/components/__tests__/PreferencesOverlay.test.tsx index d47d1530..eef9eb58 100644 --- a/cardinal/src/components/__tests__/PreferencesOverlay.test.tsx +++ b/cardinal/src/components/__tests__/PreferencesOverlay.test.tsx @@ -32,47 +32,57 @@ const baseProps = { defaultIgnorePaths: ['/default/ignore'], includePaths: ['/include/a'], defaultIncludePaths: [] as string[], + serverConfig: { + enabled: false, + endpoint: '127.0.0.1:3388', + }, + defaultServerConfig: { + enabled: false, + endpoint: '127.0.0.1:3388', + }, onReset: vi.fn(), themeResetToken: 0, - onWatchConfigChange: vi.fn(), + onPreferencesChange: vi.fn(), }; describe('PreferencesOverlay', () => { - it('saves watch root updates via onWatchConfigChange', () => { - const onWatchConfigChange = vi.fn(); - render(); + it('saves watch root updates via onPreferencesChange', () => { + const onPreferencesChange = vi.fn(); + render(); const watchRootInput = screen.getByLabelText('watchRoot.label'); fireEvent.change(watchRootInput, { target: { value: '/new/root' } }); fireEvent.click(screen.getByText('preferences.save')); - expect(onWatchConfigChange).toHaveBeenCalledWith({ + expect(onPreferencesChange).toHaveBeenCalledWith({ watchRoot: '/new/root', ignorePaths: baseProps.ignorePaths, includePaths: baseProps.includePaths, + serverConfig: baseProps.serverConfig, }); }); - it('saves ignore path updates via onWatchConfigChange', () => { - const onWatchConfigChange = vi.fn(); - render(); + it('saves ignore path updates via onPreferencesChange', () => { + const onPreferencesChange = vi.fn(); + render(); const ignorePathsInput = screen.getByLabelText('ignorePaths.label'); fireEvent.change(ignorePathsInput, { target: { value: '/tmp/one\n/tmp/two' } }); fireEvent.click(screen.getByText('preferences.save')); - expect(onWatchConfigChange).toHaveBeenCalledWith({ + expect(onPreferencesChange).toHaveBeenCalledWith({ watchRoot: baseProps.watchRoot, ignorePaths: ['/tmp/one', '/tmp/two'], includePaths: baseProps.includePaths, + serverConfig: baseProps.serverConfig, }); }); - it('saves include path updates via onWatchConfigChange', () => { - const onWatchConfigChange = vi.fn(); - render(); + it('saves include path updates via onPreferencesChange', () => { + const onPreferencesChange = vi.fn(); + render(); const includePathsInput = screen.getByLabelText('includePaths.label'); fireEvent.change(includePathsInput, { @@ -81,16 +91,17 @@ describe('PreferencesOverlay', () => { fireEvent.click(screen.getByText('preferences.save')); - expect(onWatchConfigChange).toHaveBeenCalledWith({ + expect(onPreferencesChange).toHaveBeenCalledWith({ watchRoot: baseProps.watchRoot, ignorePaths: baseProps.ignorePaths, includePaths: ['/Volumes/media', '/Volumes/work'], + serverConfig: baseProps.serverConfig, }); }); it('blocks save when an include path is not absolute', () => { - const onWatchConfigChange = vi.fn(); - render(); + const onPreferencesChange = vi.fn(); + render(); const includePathsInput = screen.getByLabelText('includePaths.label'); fireEvent.change(includePathsInput, { target: { value: 'relative/path' } }); @@ -98,18 +109,67 @@ describe('PreferencesOverlay', () => { const saveButton = screen.getByText('preferences.save') as HTMLButtonElement; expect(saveButton.disabled).toBe(true); fireEvent.click(saveButton); - expect(onWatchConfigChange).not.toHaveBeenCalled(); + expect(onPreferencesChange).not.toHaveBeenCalled(); + }); + + it('saves server config updates via onPreferencesChange', () => { + const onPreferencesChange = vi.fn(); + render(); + + fireEvent.click(screen.getByLabelText('preferences.server.enabled')); + fireEvent.change(screen.getByLabelText('preferences.server.endpoint'), { + target: { value: '0.0.0.0:3390' }, + }); + fireEvent.click(screen.getByText('preferences.save')); + + expect(onPreferencesChange).toHaveBeenCalledWith({ + watchRoot: baseProps.watchRoot, + ignorePaths: baseProps.ignorePaths, + includePaths: baseProps.includePaths, + serverConfig: { + enabled: true, + endpoint: '0.0.0.0:3390', + }, + }); + }); + + it('blocks save when server endpoint has a port outside the valid range', () => { + const onPreferencesChange = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText('preferences.server.endpoint'), { + target: { value: '127.0.0.1:70000' }, + }); + + const saveButton = screen.getByText('preferences.save') as HTMLButtonElement; + expect(saveButton.disabled).toBe(true); + fireEvent.click(saveButton); + expect(onPreferencesChange).not.toHaveBeenCalled(); + }); + + it('blocks save when server endpoint contains a non-digit port suffix', () => { + const onPreferencesChange = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText('preferences.server.endpoint'), { + target: { value: '127.0.0.1:3388abc' }, + }); + + const saveButton = screen.getByText('preferences.save') as HTMLButtonElement; + expect(saveButton.disabled).toBe(true); + fireEvent.click(saveButton); + expect(onPreferencesChange).not.toHaveBeenCalled(); }); it('resets inputs to defaults before invoking onReset', () => { const onReset = vi.fn(); - const onWatchConfigChange = vi.fn(); + const onPreferencesChange = vi.fn(); const onSortThresholdChange = vi.fn(); render( , ); @@ -126,18 +186,22 @@ describe('PreferencesOverlay', () => { expect(screen.getByLabelText('includePaths.label')).toHaveValue( baseProps.defaultIncludePaths.join('\n'), ); + expect(screen.getByLabelText('preferences.server.enabled')).not.toBeChecked(); + expect(screen.getByLabelText('preferences.server.endpoint')).toHaveValue( + baseProps.defaultServerConfig.endpoint, + ); expect(onReset).toHaveBeenCalledTimes(1); expect(onSortThresholdChange).not.toHaveBeenCalled(); - expect(onWatchConfigChange).not.toHaveBeenCalled(); + expect(onPreferencesChange).not.toHaveBeenCalled(); }); it('applies staged reset values when saved', () => { - const onWatchConfigChange = vi.fn(); + const onPreferencesChange = vi.fn(); const onSortThresholdChange = vi.fn(); render( , ); @@ -146,10 +210,11 @@ describe('PreferencesOverlay', () => { fireEvent.click(screen.getByText('preferences.save')); expect(onSortThresholdChange).toHaveBeenCalledWith(baseProps.defaultSortThreshold); - expect(onWatchConfigChange).toHaveBeenCalledWith({ + expect(onPreferencesChange).toHaveBeenCalledWith({ watchRoot: baseProps.defaultWatchRoot, ignorePaths: baseProps.defaultIgnorePaths, includePaths: baseProps.defaultIncludePaths, + serverConfig: baseProps.defaultServerConfig, }); }); diff --git a/cardinal/src/hooks/__tests__/useAppPreferences.test.ts b/cardinal/src/hooks/__tests__/useAppPreferences.test.ts index 7e3d1ea1..ef9450d6 100644 --- a/cardinal/src/hooks/__tests__/useAppPreferences.test.ts +++ b/cardinal/src/hooks/__tests__/useAppPreferences.test.ts @@ -8,6 +8,7 @@ import { setWatchConfig } from '../../utils/watchConfig'; import { getBrowserLanguage } from '../../i18n/config'; import { useIgnorePaths } from '../useIgnorePaths'; import { useIncludePaths } from '../useIncludePaths'; +import { useServerConfig } from '../useServerConfig'; import { useWatchRoot } from '../useWatchRoot'; import { useAppPreferences } from '../useAppPreferences'; import { invoke } from '@tauri-apps/api/core'; @@ -28,6 +29,10 @@ vi.mock('../useIncludePaths', () => ({ useIncludePaths: vi.fn(), })); +vi.mock('../useServerConfig', () => ({ + useServerConfig: vi.fn(), +})); + vi.mock('../../trayIconPreference', () => ({ getStoredTrayIconEnabled: vi.fn(), persistTrayIconEnabled: vi.fn(), @@ -54,6 +59,7 @@ const mockedInvoke = vi.mocked(invoke); const mockedUseWatchRoot = vi.mocked(useWatchRoot); const mockedUseIgnorePaths = vi.mocked(useIgnorePaths); const mockedUseIncludePaths = vi.mocked(useIncludePaths); +const mockedUseServerConfig = vi.mocked(useServerConfig); const mockedGetStoredTrayIconEnabled = vi.mocked(getStoredTrayIconEnabled); const mockedPersistTrayIconEnabled = vi.mocked(persistTrayIconEnabled); const mockedSetTrayEnabled = vi.mocked(setTrayEnabled); @@ -66,6 +72,7 @@ describe('useAppPreferences', () => { const setWatchRoot = vi.fn(); const setIgnorePaths = vi.fn(); const setIncludePaths = vi.fn(); + const setServerConfig = vi.fn(); const changeLanguage = vi.fn().mockResolvedValue(undefined); const refreshSearchResults = vi.fn(); @@ -87,6 +94,17 @@ describe('useAppPreferences', () => { setIncludePaths, defaultIncludePaths: [], }); + mockedUseServerConfig.mockReturnValue({ + serverConfig: { + enabled: false, + endpoint: '127.0.0.1:3388', + }, + setServerConfig, + defaultServerConfig: { + enabled: false, + endpoint: '127.0.0.1:3388', + }, + }); mockedGetStoredTrayIconEnabled.mockReturnValue(true); mockedSetTrayEnabled.mockResolvedValue(undefined); mockedSetWatchConfig.mockResolvedValue(undefined); @@ -119,7 +137,38 @@ describe('useAppPreferences', () => { i18n: { changeLanguage }, }); - expect(mockedInvoke).toHaveBeenCalledTimes(1); + expect(mockedInvoke.mock.calls.filter(([command]) => command === 'start_logic')).toHaveLength( + 1, + ); + }); + + it('does not push server config to the backend on startup', async () => { + mockedUseServerConfig.mockReturnValue({ + serverConfig: { + enabled: true, + endpoint: '0.0.0.0:3390', + }, + setServerConfig, + defaultServerConfig: { + enabled: false, + endpoint: '127.0.0.1:3388', + }, + }); + + renderHook(() => + useAppPreferences({ + fullDiskAccessStatus: 'denied', + isCheckingFullDiskAccess: false, + refreshSearchResults, + i18n: { changeLanguage }, + }), + ); + + await waitFor(() => { + expect(mockedSetTrayEnabled).toHaveBeenCalledWith(true); + }); + + expect(mockedInvoke).not.toHaveBeenCalledWith('set_server_config', expect.anything()); }); it('updates watch config and refreshes search when preferences change', async () => { @@ -144,10 +193,14 @@ describe('useAppPreferences', () => { refreshSearchResults.mockClear(); act(() => { - result.current.handleWatchConfigChange({ + result.current.handlePreferencesChange({ watchRoot: '/tmp', ignorePaths: ['/tmp/ignore'], includePaths: [], + serverConfig: { + enabled: false, + endpoint: '127.0.0.1:3388', + }, }); }); @@ -185,10 +238,14 @@ describe('useAppPreferences', () => { setIgnorePaths.mockClear(); act(() => { - result.current.handleWatchConfigChange({ + result.current.handlePreferencesChange({ watchRoot: '/workspace', ignorePaths: ['/Volumes'], includePaths: [], + serverConfig: { + enabled: false, + endpoint: '127.0.0.1:3388', + }, }); }); @@ -222,10 +279,14 @@ describe('useAppPreferences', () => { setIgnorePaths.mockClear(); act(() => { - result.current.handleWatchConfigChange({ + result.current.handlePreferencesChange({ watchRoot: '/new-root', ignorePaths: ['/Volumes'], // same as before includePaths: [], + serverConfig: { + enabled: false, + endpoint: '127.0.0.1:3388', + }, }); }); @@ -263,10 +324,14 @@ describe('useAppPreferences', () => { setIgnorePaths.mockClear(); act(() => { - result.current.handleWatchConfigChange({ + result.current.handlePreferencesChange({ watchRoot: '/workspace', // same as before ignorePaths: ['/tmp/ignore'], // different includePaths: [], + serverConfig: { + enabled: false, + endpoint: '127.0.0.1:3388', + }, }); }); @@ -312,10 +377,14 @@ describe('useAppPreferences', () => { setIgnorePaths.mockClear(); act(() => { - result.current.handleWatchConfigChange({ + result.current.handlePreferencesChange({ watchRoot: '/workspace', ignorePaths: ['/System', '/Volumes'], // same items, different order includePaths: [], + serverConfig: { + enabled: false, + endpoint: '127.0.0.1:3388', + }, }); }); @@ -352,10 +421,14 @@ describe('useAppPreferences', () => { setIncludePaths.mockClear(); act(() => { - result.current.handleWatchConfigChange({ + result.current.handlePreferencesChange({ watchRoot: '/workspace', ignorePaths: ['/Volumes'], includePaths: ['/Volumes/media'], + serverConfig: { + enabled: false, + endpoint: '127.0.0.1:3388', + }, }); }); @@ -368,7 +441,54 @@ describe('useAppPreferences', () => { expect(refreshSearchResults).toHaveBeenCalledTimes(1); }); + it('updates server config when preferences change', async () => { + const { result } = renderHook(() => + useAppPreferences({ + fullDiskAccessStatus: 'denied', + isCheckingFullDiskAccess: false, + refreshSearchResults, + i18n: { changeLanguage }, + }), + ); + + act(() => { + result.current.handlePreferencesChange({ + watchRoot: '/workspace', + ignorePaths: ['/Volumes'], + includePaths: [], + serverConfig: { + enabled: true, + endpoint: '0.0.0.0:3390', + }, + }); + }); + + expect(setServerConfig).toHaveBeenCalledWith({ + enabled: true, + endpoint: '0.0.0.0:3390', + }); + expect(mockedInvoke).toHaveBeenCalledWith('set_server_config', { + config: { + enabled: true, + endpoint: '0.0.0.0:3390', + }, + }); + expect(refreshSearchResults).not.toHaveBeenCalled(); + }); + it('opens and closes preferences, and resets user preferences', async () => { + mockedUseServerConfig.mockReturnValue({ + serverConfig: { + enabled: true, + endpoint: '0.0.0.0:3390', + }, + setServerConfig, + defaultServerConfig: { + enabled: false, + endpoint: '127.0.0.1:3388', + }, + }); + const { result } = renderHook(() => useAppPreferences({ fullDiskAccessStatus: 'denied', @@ -404,6 +524,16 @@ describe('useAppPreferences', () => { expect(mockedSetTrayEnabled).toHaveBeenCalledWith(false); }); expect(mockedPersistTrayIconEnabled).toHaveBeenCalledWith(false); + expect(setServerConfig).toHaveBeenCalledWith({ + enabled: false, + endpoint: '127.0.0.1:3388', + }); + expect(mockedInvoke).toHaveBeenCalledWith('set_server_config', { + config: { + enabled: false, + endpoint: '127.0.0.1:3388', + }, + }); expect(mockedPersistThemePreference).toHaveBeenCalledWith('system'); expect(mockedApplyThemePreference).toHaveBeenCalledWith('system'); expect(changeLanguage).toHaveBeenCalledWith('fr-FR'); diff --git a/cardinal/src/hooks/__tests__/useServerConfig.test.ts b/cardinal/src/hooks/__tests__/useServerConfig.test.ts new file mode 100644 index 00000000..7adaebd8 --- /dev/null +++ b/cardinal/src/hooks/__tests__/useServerConfig.test.ts @@ -0,0 +1,113 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { invoke } from '@tauri-apps/api/core'; + +import { isValidEndpoint, useServerConfig } from '../useServerConfig'; + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +const STORAGE_KEY = 'cardinal.serverConfig'; + +const mockedInvoke = vi.mocked(invoke); + +describe('useServerConfig', () => { + beforeEach(() => { + window.localStorage.clear(); + mockedInvoke.mockReset(); + mockedInvoke.mockResolvedValue({ + enabled: false, + endpoint: '127.0.0.1:3388', + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('hydrates from the backend and overwrites stale local storage', async () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + enabled: true, + endpoint: '0.0.0.0:3390', + }), + ); + mockedInvoke.mockResolvedValue({ + enabled: false, + endpoint: '127.0.0.1:3388', + }); + + const { result } = renderHook(() => useServerConfig()); + + expect(result.current.serverConfig).toEqual({ + enabled: true, + endpoint: '0.0.0.0:3390', + }); + + await waitFor(() => { + expect(result.current.serverConfig).toEqual({ + enabled: false, + endpoint: '127.0.0.1:3388', + }); + }); + + expect(mockedInvoke).toHaveBeenCalledWith('get_server_config'); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe( + JSON.stringify({ + enabled: false, + endpoint: '127.0.0.1:3388', + }), + ); + }); + + it('persists normalized updates locally', async () => { + const { result } = renderHook(() => useServerConfig()); + + await waitFor(() => { + expect(result.current.serverConfig).toEqual({ + enabled: false, + endpoint: '127.0.0.1:3388', + }); + }); + + act(() => { + result.current.setServerConfig({ + enabled: true, + endpoint: ' 0.0.0.0:3390 ', + }); + }); + + expect(result.current.serverConfig).toEqual({ + enabled: true, + endpoint: '0.0.0.0:3390', + }); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe( + JSON.stringify({ + enabled: true, + endpoint: '0.0.0.0:3390', + }), + ); + }); + + it('hydrates from the backend only once across rerenders', async () => { + const { rerender } = renderHook(() => useServerConfig()); + + await waitFor(() => { + expect(mockedInvoke).toHaveBeenCalledWith('get_server_config'); + }); + + expect(mockedInvoke).toHaveBeenCalledTimes(1); + + rerender(); + + expect(mockedInvoke).toHaveBeenCalledTimes(1); + }); + + it('rejects ports with non-digit suffixes', () => { + expect(isValidEndpoint('127.0.0.1:3388abc')).toBe(false); + expect(isValidEndpoint('127.0.0.1:33 88')).toBe(false); + expect(isValidEndpoint('127.0.0.1:3388')).toBe(true); + }); +}); diff --git a/cardinal/src/hooks/useAppPreferences.ts b/cardinal/src/hooks/useAppPreferences.ts index 9a0c8e9c..a93c42d3 100644 --- a/cardinal/src/hooks/useAppPreferences.ts +++ b/cardinal/src/hooks/useAppPreferences.ts @@ -10,6 +10,8 @@ import { setWatchConfig } from '../utils/watchConfig'; import type { FullDiskAccessStatus } from './useFullDiskAccessPermission'; import { useIgnorePaths } from './useIgnorePaths'; import { useIncludePaths } from './useIncludePaths'; +import { useServerConfig } from './useServerConfig'; +import type { ServerConfig } from './useServerConfig'; import { useWatchRoot } from './useWatchRoot'; type WatchConfigChangePayload = { @@ -18,6 +20,12 @@ type WatchConfigChangePayload = { includePaths: string[]; }; +type ServerConfigPayload = { + serverConfig: ServerConfig; +}; + +type PreferencesChangePayload = WatchConfigChangePayload & ServerConfigPayload; + type UseAppPreferencesOptions = { fullDiskAccessStatus: FullDiskAccessStatus; isCheckingFullDiskAccess: boolean; @@ -36,8 +44,10 @@ type UseAppPreferencesResult = { defaultIgnorePaths: string[]; includePaths: string[]; defaultIncludePaths: string[]; + serverConfig: ServerConfig; + defaultServerConfig: ServerConfig; preferencesResetToken: number; - handleWatchConfigChange: (next: WatchConfigChangePayload) => void; + handlePreferencesChange: (next: PreferencesChangePayload) => void; handleResetPreferences: () => void; }; @@ -57,6 +67,7 @@ export function useAppPreferences({ const { watchRoot, setWatchRoot, defaultWatchRoot } = useWatchRoot(); const { ignorePaths, setIgnorePaths, defaultIgnorePaths } = useIgnorePaths(); const { includePaths, setIncludePaths, defaultIncludePaths } = useIncludePaths(); + const { serverConfig, setServerConfig, defaultServerConfig } = useServerConfig(); const logicStartedRef = useRef(false); const [isPreferencesOpen, setIsPreferencesOpen] = useState(false); const [trayIconEnabled, setTrayIconEnabled] = useState(() => getStoredTrayIconEnabled()); @@ -129,21 +140,35 @@ export function useAppPreferences({ ], ); - const handleWatchConfigChange = useCallback( - (next: WatchConfigChangePayload) => { + const applyServerConfig = useCallback( + (next: ServerConfig) => { + if (next.enabled === serverConfig.enabled && next.endpoint === serverConfig.endpoint) { + return; + } + + setServerConfig(next); + void invoke('set_server_config', { config: next }); + }, + [serverConfig.enabled, serverConfig.endpoint, setServerConfig], + ); + + const handlePreferencesChange = useCallback( + (next: PreferencesChangePayload) => { applyWatchConfig(next.watchRoot, next.ignorePaths, next.includePaths); + applyServerConfig(next.serverConfig); }, - [applyWatchConfig], + [applyServerConfig, applyWatchConfig], ); const handleResetPreferences = useCallback(() => { setTrayIconEnabled(false); + applyServerConfig(defaultServerConfig); persistThemePreference('system'); applyThemePreference('system'); const nextLanguage = getBrowserLanguage(); void i18n.changeLanguage(nextLanguage); setPreferencesResetToken((token) => token + 1); - }, [i18n]); + }, [applyServerConfig, defaultServerConfig, i18n]); const closePreferences = useCallback(() => setIsPreferencesOpen(false), []); @@ -158,8 +183,10 @@ export function useAppPreferences({ defaultIgnorePaths, includePaths, defaultIncludePaths, + serverConfig, + defaultServerConfig, preferencesResetToken, - handleWatchConfigChange, + handlePreferencesChange, handleResetPreferences, }; } diff --git a/cardinal/src/hooks/useServerConfig.ts b/cardinal/src/hooks/useServerConfig.ts new file mode 100644 index 00000000..8e00031c --- /dev/null +++ b/cardinal/src/hooks/useServerConfig.ts @@ -0,0 +1,113 @@ +import { invoke } from '@tauri-apps/api/core'; +import { useCallback, useEffect } from 'react'; +import { useStoredState } from './useStoredState'; + +export type ServerConfig = Readonly<{ + enabled: boolean; + endpoint: string; +}>; + +type StoredServerConfig = Partial & { + port?: number; +}; + +const STORAGE_KEY = 'cardinal.serverConfig'; +const DEFAULT_SERVER_CONFIG: ServerConfig = { + enabled: false, + endpoint: '127.0.0.1:3388', +}; + +const readStoredServerConfig = (raw: string): ServerConfig | null => { + const parsed = JSON.parse(raw) as StoredServerConfig; + if (typeof parsed !== 'object' || parsed == null) { + return null; + } + if (typeof parsed.endpoint === 'string') { + return normalizeServerConfig({ + enabled: Boolean(parsed.enabled), + endpoint: parsed.endpoint, + }); + } + return normalizeServerConfig({ + enabled: Boolean(parsed.enabled), + endpoint: + typeof parsed.port === 'number' ? `127.0.0.1:${parsed.port}` : DEFAULT_SERVER_CONFIG.endpoint, + }); +}; + +const writeStoredServerConfig = (value: ServerConfig): string => + JSON.stringify(normalizeServerConfig(value)); + +export const isValidEndpoint = (endpoint: string): boolean => { + const trimmed = endpoint.trim(); + if (!trimmed) { + return false; + } + + const portSeparator = trimmed.lastIndexOf(':'); + if (portSeparator <= 0 || portSeparator === trimmed.length - 1) { + return false; + } + + const portText = trimmed.slice(portSeparator + 1); + if (!/^\d+$/.test(portText)) { + return false; + } + + const port = Number.parseInt(portText, 10); + return Number.isInteger(port) && port >= 1 && port <= 65535; +}; + +const normalizeServerConfig = (value: ServerConfig): ServerConfig => ({ + enabled: Boolean(value.enabled), + endpoint: isValidEndpoint(value.endpoint) + ? value.endpoint.trim() + : DEFAULT_SERVER_CONFIG.endpoint, +}); + +export function useServerConfig(): { + serverConfig: ServerConfig; + setServerConfig: (next: ServerConfig) => void; + defaultServerConfig: ServerConfig; +} { + const [serverConfig, setServerConfigState] = useStoredState({ + key: STORAGE_KEY, + defaultValue: DEFAULT_SERVER_CONFIG, + read: readStoredServerConfig, + write: writeStoredServerConfig, + normalize: normalizeServerConfig, + readErrorMessage: 'Failed to read stored server config preference', + writeErrorMessage: 'Failed to persist server config preference', + }); + + const setServerConfig = useCallback( + (next: ServerConfig) => { + setServerConfigState(next); + }, + [setServerConfigState], + ); + + useEffect(() => { + let cancelled = false; + + void invoke('get_server_config') + .then((config) => { + if (!cancelled) { + setServerConfigState(config); + } + }) + .catch((error) => { + console.warn('Failed to read server config from backend', error); + }); + + return () => { + cancelled = true; + }; + }, [setServerConfigState]); + + return { + serverConfig, + setServerConfig, + defaultServerConfig: DEFAULT_SERVER_CONFIG, + }; +} diff --git a/cardinal/src/i18n/resources/ar-SA.json b/cardinal/src/i18n/resources/ar-SA.json index 042b36d4..8ba333f5 100644 --- a/cardinal/src/i18n/resources/ar-SA.json +++ b/cardinal/src/i18n/resources/ar-SA.json @@ -125,6 +125,11 @@ "trayIcon": { "label": "إظهار أيقونة العلبة" }, + "server": { + "enabled": "خادم بحث HTTP", + "endpoint": "نقطة نهاية خادم HTTP", + "endpointError": "أدخل نقطة نهاية مثل 127.0.0.1:3388 أو 0.0.0.0:3388." + }, "sortingLimit": { "label": "حد الفرز" }, diff --git a/cardinal/src/i18n/resources/de-DE.json b/cardinal/src/i18n/resources/de-DE.json index 26cdaf66..17b03be5 100644 --- a/cardinal/src/i18n/resources/de-DE.json +++ b/cardinal/src/i18n/resources/de-DE.json @@ -125,6 +125,11 @@ "trayIcon": { "label": "Tray-Symbol anzeigen" }, + "server": { + "enabled": "HTTP-Suchserver", + "endpoint": "HTTP-Server-Endpunkt", + "endpointError": "Gib einen Endpunkt wie 127.0.0.1:3388 oder 0.0.0.0:3388 ein." + }, "sortingLimit": { "label": "Sortierlimit" }, diff --git a/cardinal/src/i18n/resources/en-US.json b/cardinal/src/i18n/resources/en-US.json index f1b420dc..70d5af38 100644 --- a/cardinal/src/i18n/resources/en-US.json +++ b/cardinal/src/i18n/resources/en-US.json @@ -125,6 +125,11 @@ "trayIcon": { "label": "Show tray icon" }, + "server": { + "enabled": "HTTP search server", + "endpoint": "HTTP server endpoint", + "endpointError": "Enter an endpoint like 127.0.0.1:3388 or 0.0.0.0:3388." + }, "sortingLimit": { "label": "Sorting limit" }, diff --git a/cardinal/src/i18n/resources/es-ES.json b/cardinal/src/i18n/resources/es-ES.json index 6ed30e4c..c1873dfe 100644 --- a/cardinal/src/i18n/resources/es-ES.json +++ b/cardinal/src/i18n/resources/es-ES.json @@ -125,6 +125,11 @@ "trayIcon": { "label": "Mostrar icono en la bandeja" }, + "server": { + "enabled": "Servidor de búsqueda HTTP", + "endpoint": "Endpoint del servidor HTTP", + "endpointError": "Introduce un endpoint como 127.0.0.1:3388 o 0.0.0.0:3388." + }, "sortingLimit": { "label": "Límite de ordenación" }, diff --git a/cardinal/src/i18n/resources/fr-FR.json b/cardinal/src/i18n/resources/fr-FR.json index 69f4fc37..100eb9bc 100644 --- a/cardinal/src/i18n/resources/fr-FR.json +++ b/cardinal/src/i18n/resources/fr-FR.json @@ -125,6 +125,11 @@ "trayIcon": { "label": "Afficher l’icône dans la barre d’état" }, + "server": { + "enabled": "Serveur de recherche HTTP", + "endpoint": "Point d’accès du serveur HTTP", + "endpointError": "Saisissez un point d’accès comme 127.0.0.1:3388 ou 0.0.0.0:3388." + }, "sortingLimit": { "label": "Limite de tri" }, diff --git a/cardinal/src/i18n/resources/hi-IN.json b/cardinal/src/i18n/resources/hi-IN.json index 0c27751c..2ade5bd7 100644 --- a/cardinal/src/i18n/resources/hi-IN.json +++ b/cardinal/src/i18n/resources/hi-IN.json @@ -115,6 +115,11 @@ "trayIcon": { "label": "ट्रे आइकन दिखाएँ" }, + "server": { + "enabled": "HTTP खोज सर्वर", + "endpoint": "HTTP सर्वर एंडपॉइंट", + "endpointError": "127.0.0.1:3388 या 0.0.0.0:3388 जैसा एंडपॉइंट दर्ज करें।" + }, "sortingLimit": { "label": "सॉर्टिंग सीमा" }, diff --git a/cardinal/src/i18n/resources/it-IT.json b/cardinal/src/i18n/resources/it-IT.json index 2e101748..b4538d80 100644 --- a/cardinal/src/i18n/resources/it-IT.json +++ b/cardinal/src/i18n/resources/it-IT.json @@ -125,6 +125,11 @@ "trayIcon": { "label": "Mostra icona nel vassoio" }, + "server": { + "enabled": "Server di ricerca HTTP", + "endpoint": "Endpoint del server HTTP", + "endpointError": "Inserisci un endpoint come 127.0.0.1:3388 o 0.0.0.0:3388." + }, "sortingLimit": { "label": "Limite ordinamento" }, diff --git a/cardinal/src/i18n/resources/ja-JP.json b/cardinal/src/i18n/resources/ja-JP.json index dac3497d..7185fe4b 100644 --- a/cardinal/src/i18n/resources/ja-JP.json +++ b/cardinal/src/i18n/resources/ja-JP.json @@ -125,6 +125,11 @@ "trayIcon": { "label": "トレイアイコンを表示" }, + "server": { + "enabled": "HTTP 検索サーバー", + "endpoint": "HTTP サーバーエンドポイント", + "endpointError": "127.0.0.1:3388 または 0.0.0.0:3388 のようなエンドポイントを入力してください。" + }, "sortingLimit": { "label": "並び替えの上限" }, diff --git a/cardinal/src/i18n/resources/ko-KR.json b/cardinal/src/i18n/resources/ko-KR.json index da1c78ef..b367b72e 100644 --- a/cardinal/src/i18n/resources/ko-KR.json +++ b/cardinal/src/i18n/resources/ko-KR.json @@ -125,6 +125,11 @@ "trayIcon": { "label": "트레이 아이콘 표시" }, + "server": { + "enabled": "HTTP 검색 서버", + "endpoint": "HTTP 서버 엔드포인트", + "endpointError": "127.0.0.1:3388 또는 0.0.0.0:3388 형식의 엔드포인트를 입력하세요." + }, "sortingLimit": { "label": "정렬 한도" }, diff --git a/cardinal/src/i18n/resources/pt-BR.json b/cardinal/src/i18n/resources/pt-BR.json index bde76455..f9887365 100644 --- a/cardinal/src/i18n/resources/pt-BR.json +++ b/cardinal/src/i18n/resources/pt-BR.json @@ -125,6 +125,11 @@ "trayIcon": { "label": "Mostrar ícone na bandeja" }, + "server": { + "enabled": "Servidor de busca HTTP", + "endpoint": "Endpoint do servidor HTTP", + "endpointError": "Insira um endpoint como 127.0.0.1:3388 ou 0.0.0.0:3388." + }, "sortingLimit": { "label": "Limite de ordenação" }, diff --git a/cardinal/src/i18n/resources/ru-RU.json b/cardinal/src/i18n/resources/ru-RU.json index 899fee0e..2f839671 100644 --- a/cardinal/src/i18n/resources/ru-RU.json +++ b/cardinal/src/i18n/resources/ru-RU.json @@ -125,6 +125,11 @@ "trayIcon": { "label": "Показывать значок в трее" }, + "server": { + "enabled": "HTTP-сервер поиска", + "endpoint": "Адрес HTTP-сервера", + "endpointError": "Введите адрес вроде 127.0.0.1:3388 или 0.0.0.0:3388." + }, "sortingLimit": { "label": "Порог сортировки" }, diff --git a/cardinal/src/i18n/resources/tr-TR.json b/cardinal/src/i18n/resources/tr-TR.json index eb23c9d3..3d8575e9 100644 --- a/cardinal/src/i18n/resources/tr-TR.json +++ b/cardinal/src/i18n/resources/tr-TR.json @@ -125,6 +125,11 @@ "trayIcon": { "label": "Tepsi simgesini göster" }, + "server": { + "enabled": "HTTP arama sunucusu", + "endpoint": "HTTP sunucu uç noktası", + "endpointError": "127.0.0.1:3388 veya 0.0.0.0:3388 gibi bir uç nokta girin." + }, "sortingLimit": { "label": "Sıralama sınırı" }, diff --git a/cardinal/src/i18n/resources/uk-UA.json b/cardinal/src/i18n/resources/uk-UA.json index 8c97cb94..84d3c410 100644 --- a/cardinal/src/i18n/resources/uk-UA.json +++ b/cardinal/src/i18n/resources/uk-UA.json @@ -125,6 +125,11 @@ "trayIcon": { "label": "Показувати значок у треї" }, + "server": { + "enabled": "HTTP-сервер пошуку", + "endpoint": "Адреса HTTP-сервера", + "endpointError": "Введіть адресу на кшталт 127.0.0.1:3388 або 0.0.0.0:3388." + }, "sortingLimit": { "label": "Ліміт сортування" }, diff --git a/cardinal/src/i18n/resources/zh-CN.json b/cardinal/src/i18n/resources/zh-CN.json index 9834e169..69f2c625 100644 --- a/cardinal/src/i18n/resources/zh-CN.json +++ b/cardinal/src/i18n/resources/zh-CN.json @@ -124,6 +124,11 @@ "trayIcon": { "label": "显示托盘图标" }, + "server": { + "enabled": "HTTP 搜索服务器", + "endpoint": "HTTP 服务器端点", + "endpointError": "请输入类似 127.0.0.1:3388 或 0.0.0.0:3388 的端点。" + }, "sortingLimit": { "label": "排序上限" }, diff --git a/cardinal/src/i18n/resources/zh-TW.json b/cardinal/src/i18n/resources/zh-TW.json index 8f7bdd21..05bb18e5 100644 --- a/cardinal/src/i18n/resources/zh-TW.json +++ b/cardinal/src/i18n/resources/zh-TW.json @@ -124,6 +124,11 @@ "trayIcon": { "label": "顯示托盤圖示" }, + "server": { + "enabled": "HTTP 搜尋伺服器", + "endpoint": "HTTP 伺服器端點", + "endpointError": "請輸入類似 127.0.0.1:3388 或 0.0.0.0:3388 的端點。" + }, "sortingLimit": { "label": "排序上限" },