From 771f5853da1f4c9617afdb5b4898170b4c186ab2 Mon Sep 17 00:00:00 2001 From: hsqStephenZhang Date: Fri, 24 Apr 2026 22:06:55 +0200 Subject: [PATCH 01/15] feat: http api --- cardinal/src-tauri/Cargo.lock | 251 ++++++++++++++++++++++++++++- cardinal/src-tauri/Cargo.toml | 1 + cardinal/src-tauri/src/commands.rs | 2 +- cardinal/src-tauri/src/lib.rs | 11 +- cardinal/src-tauri/src/server.rs | 246 ++++++++++++++++++++++++++++ cardinal/src-tauri/src/sort.rs | 8 +- 6 files changed, 505 insertions(+), 14 deletions(-) create mode 100644 cardinal/src-tauri/src/server.rs diff --git a/cardinal/src-tauri/Cargo.lock b/cardinal/src-tauri/Cargo.lock index e19f1ca0..8a680792 100644 --- a/cardinal/src-tauri/Cargo.lock +++ b/cardinal/src-tauri/Cargo.lock @@ -8,6 +8,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -53,6 +59,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -219,6 +231,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -298,6 +316,17 @@ dependencies = [ "piper", ] +[[package]] +name = "brotli" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor 2.5.1", +] + [[package]] name = "brotli" version = "8.0.2" @@ -306,7 +335,17 @@ checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", - "brotli-decompressor", + "brotli-decompressor 5.0.0", +] + +[[package]] +name = "brotli-decompressor" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", ] [[package]] @@ -319,6 +358,16 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "buf_redux" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b953a6887648bb07a535631f2bc00fbdb2a2216f135552cb3f534ed136b9c07f" +dependencies = [ + "memchr", + "safemem", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -398,6 +447,7 @@ dependencies = [ "once_cell", "parking_lot", "rayon", + "rouille", "search-cache", "search-cancel", "serde", @@ -521,6 +571,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "cobs" version = "0.3.0" @@ -803,6 +859,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "deflate" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86f7e25f518f4b81808a2cf1c50996a61f5c2eb394b2393bd87f2a4780a432f" +dependencies = [ + "adler32", + "gzip-header", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1153,6 +1219,17 @@ dependencies = [ "xattr", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1683,6 +1760,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "gzip-header" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86848f4fd157d91041a62c78046fb7b248bcc2dce78376d436a1756e9a038577" +dependencies = [ + "crc32fast", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1796,6 +1882,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" @@ -2303,7 +2395,10 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ + "bitflags 2.11.0", "libc", + "plain", + "redox_syscall 0.7.4", ] [[package]] @@ -2439,6 +2534,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2481,6 +2586,24 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "multipart" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00dec633863867f29cb39df64a397cdf4a6354708ddd7759f70c7fb51c5f9182" +dependencies = [ + "buf_redux", + "httparse", + "log", + "mime", + "mime_guess", + "quick-error", + "rand 0.8.5", + "safemem", + "tempfile", + "twoway", +] + [[package]] name = "namepool" version = "0.1.0" @@ -2560,6 +2683,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -2582,6 +2715,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc" version = "0.2.7" @@ -2943,7 +3085,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -3170,6 +3312,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.8.0" @@ -3349,6 +3497,12 @@ dependencies = [ name = "query-segmentation" version = "0.1.0" +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-xml" version = "0.38.4" @@ -3495,6 +3649,15 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3589,6 +3752,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rouille" +version = "3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3716fbf57fc1084d7a706adf4e445298d123e4a44294c4e8213caf1b85fcc921" +dependencies = [ + "base64 0.13.1", + "brotli 3.5.0", + "chrono", + "deflate", + "filetime", + "multipart", + "percent-encoding", + "rand 0.8.5", + "serde", + "serde_derive", + "serde_json", + "sha1_smol", + "threadpool", + "time", + "tiny_http", + "url", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3623,6 +3810,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "safemem" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" + [[package]] name = "same-file" version = "1.0.6" @@ -3937,6 +4130,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -4037,7 +4236,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -4353,7 +4552,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" dependencies = [ "base64 0.22.1", - "brotli", + "brotli 8.0.2", "ico", "json-patch", "plist", @@ -4558,7 +4757,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" dependencies = [ "anyhow", - "brotli", + "brotli 8.0.2", "cargo_metadata", "ctor", "dunce", @@ -4692,6 +4891,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + [[package]] name = "time" version = "0.3.47" @@ -4700,7 +4908,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -4723,6 +4933,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -4750,9 +4972,9 @@ 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", @@ -5014,6 +5236,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "twoway" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59b11b2b5241ba34be09c3cc85a36e56e48f9888862e19cedf23336d35316ed1" +dependencies = [ + "memchr", +] + [[package]] name = "typed-num" version = "0.3.0" @@ -5087,6 +5318,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/cardinal/src-tauri/Cargo.toml b/cardinal/src-tauri/Cargo.toml index 386b1944..1709fb2e 100644 --- a/cardinal/src-tauri/Cargo.toml +++ b/cardinal/src-tauri/Cargo.toml @@ -39,6 +39,7 @@ tauri-plugin-prevent-default = "4" tauri-plugin-macos-permissions = "2.3.0" tauri-plugin-window-state = "2" once_cell = { version = "1.20", features = ["parking_lot"] } +rouille = "3.6.2" cardinal-sdk.path = "../../cardinal-sdk" search-cache.path = "../../search-cache" diff --git a/cardinal/src-tauri/src/commands.rs b/cardinal/src-tauri/src/commands.rs index 5e1bcaac..947e6401 100644 --- a/cardinal/src-tauri/src/commands.rs +++ b/cardinal/src-tauri/src/commands.rs @@ -230,7 +230,7 @@ impl SearchResponse { pub const CANCELLED: u8 = 1; } -#[derive(Serialize)] +#[derive(Clone, Serialize)] pub struct NodeInfoMetadata { pub r#type: u8, pub size: i64, diff --git a/cardinal/src-tauri/src/lib.rs b/cardinal/src-tauri/src/lib.rs index 920840c0..f8706271 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; @@ -114,8 +115,8 @@ 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(), @@ -161,6 +162,12 @@ pub fn run() -> Result<()> { }; emit_app_state(app_handle); let icon_update_rx = &icon_update_rx; + let server_state = + server::ServerState::new(search_tx.clone(), result_rx.clone(), node_info_tx.clone()); + std::thread::spawn(move || { + server::start_server(server_state, "127.0.0.1:3388"); + }); + 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..a36d08b9 --- /dev/null +++ b/cardinal/src-tauri/src/server.rs @@ -0,0 +1,246 @@ +use crate::{ + commands::{NodeInfoMetadata, NodeInfoRequest, SearchJob, SearchOptionsPayload}, + sort::SortEntry, +}; +use anyhow::Result; +use crossbeam_channel::{Receiver, Sender, bounded}; +use rouille::Response; +use search_cache::SearchOutcome; +use search_cancel::CancellationToken; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + net::ToSocketAddrs, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, Instant}, +}; + +static SEARCH_VERSION: AtomicU64 = AtomicU64::new(1000000); +static CACHE_TIMEOUT: u64 = 20; + +struct CacheEntry { + results: Vec, + highlights: Vec, + timestamp: Instant, +} + +#[derive(Clone)] +pub struct ServerState { + pub search_tx: Sender, + pub result_rx: Receiver>, + pub node_info_tx: Sender, + cache: Arc>>, + search_lock: Arc>, +} + +impl ServerState { + pub fn new( + search_tx: Sender, + result_rx: Receiver>, + node_info_tx: Sender, + ) -> Self { + Self { + search_tx, + result_rx, + node_info_tx, + cache: Arc::new(Mutex::new(HashMap::new())), + search_lock: Arc::new(Mutex::new(())), + } + } +} + +#[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 fn start_server(state: ServerState, addr: impl ToSocketAddrs) { + let state = Arc::new(state); + rouille::start_server(addr, move |request| { + rouille::log(request, std::io::stdout(), || { + rouille::router!(request, + (GET) (/search) => { + let query = request.get_param("query").unwrap_or_default(); + let limit = request.get_param("limit").and_then(|v| v.parse().ok()); + let offset = request.get_param("offset").and_then(|v| v.parse().ok()); + let req = SearchRequest { + query, + options: SearchOptionsPayload::default(), + limit, + offset, + sort: None, + }; + handle_search(state.clone(), req) + }, + (POST) (/search) => { + let req: SearchRequest = match rouille::input::json_input(request) { + Ok(r) => r, + Err(_) => return Response::text("Invalid JSON").with_status_code(400), + }; + handle_search(state.clone(), req) + }, + _ => Response::empty_404() + ) + }) + }); +} + +fn handle_search(state: Arc, req: SearchRequest) -> Response { + let cache_key = format!( + "{}_{}_{}", + req.query, + req.options.case_insensitive, + serde_json::to_string(&req.sort).unwrap_or_default() + ); + + let mut cached_results = None; + let mut cached_highlights = Vec::new(); + + { + let mut cache = state.cache.lock().unwrap(); + cache.retain(|_, entry| entry.timestamp.elapsed() < Duration::from_secs(CACHE_TIMEOUT)); + + if let Some(entry) = cache.get(&cache_key) { + cached_results = Some(entry.results.clone()); + cached_highlights = entry.highlights.clone(); + } + } + + let (slab_indices, highlights) = if let Some(results) = cached_results { + (results, cached_highlights) + } else { + // Clear any stale results + // TODO: better logic? + let _lock = state.search_lock.lock().unwrap(); + while let Ok(_) = state.result_rx.try_recv() {} + + let version = SEARCH_VERSION.fetch_add(1, Ordering::Relaxed); + let cancellation_token = CancellationToken::new(version); + + if let Err(e) = state.search_tx.send(SearchJob { + query: req.query, + options: req.options, + cancellation_token, + }) { + tracing::error!("Failed to send search job: {:?}", e); + return Response::text("Internal Server Error").with_status_code(500); + } + + let outcome = match state.result_rx.recv() { + Ok(Ok(outcome)) => outcome, + Ok(Err(e)) => { + tracing::error!("Search error: {:?}", e); + return Response::text("Internal Server Error").with_status_code(500); + } + Err(e) => { + tracing::error!("Reply channel closed: {:?}", e); + return Response::text("Internal Server Error").with_status_code(500); + } + }; + + let mut fetched_indices = outcome.nodes.unwrap_or_default(); + + if let Some(sort) = &req.sort { + 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)| SortEntry::new(slab_index, node)) + .collect(); + crate::sort::sort_entries(&mut entries, sort); + fetched_indices = entries.into_iter().map(|e| e.slab_index).collect(); + } + } + + { + let mut cache = state.cache.lock().unwrap(); + cache.insert( + cache_key, + CacheEntry { + results: fetched_indices.clone(), + highlights: outcome.highlights.clone(), + timestamp: Instant::now(), + }, + ); + } + + (fetched_indices, outcome.highlights) + }; + + let mut results: Vec = 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); + } + + 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 Response::text("Internal Server Error").with_status_code(500); + } + + let final_nodes = match node_info_reply_rx.recv() { + Ok(nodes) => nodes, + Err(e) => { + tracing::error!("Node info reply channel closed: {:?}", e); + return Response::text("Internal Server Error").with_status_code(500); + } + }; + + 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(); + + Response::json(&ServerSearchResponse { + results: final_results, + highlights, + }) +} 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, From 86182669a3bc6556c85f603f5f945766ef7e3206 Mon Sep 17 00:00:00 2001 From: hsqStephenZhang Date: Sun, 26 Apr 2026 11:35:34 +0200 Subject: [PATCH 02/15] fix: use axum instead of rouille --- cardinal/src-tauri/Cargo.lock | 104 +++++++++++ cardinal/src-tauri/Cargo.toml | 2 + cardinal/src-tauri/src/lib.rs | 4 +- cardinal/src-tauri/src/server.rs | 290 ++++++++++++++----------------- 4 files changed, 237 insertions(+), 163 deletions(-) diff --git a/cardinal/src-tauri/Cargo.lock b/cardinal/src-tauri/Cargo.lock index 8a680792..04b5c2cf 100644 --- a/cardinal/src-tauri/Cargo.lock +++ b/cardinal/src-tauri/Cargo.lock @@ -231,6 +231,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.13.1" @@ -434,6 +486,7 @@ name = "cardinal" version = "0.1.23" dependencies = [ "anyhow", + "axum", "base64 0.22.1", "camino", "cardinal-sdk", @@ -460,6 +513,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-prevent-default", "tauri-plugin-window-state", + "tokio", "tracing", "tracing-subscriber", ] @@ -1901,6 +1955,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -2504,6 +2559,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" @@ -3810,6 +3871,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 = "safemem" version = "0.3.3" @@ -4029,6 +4096,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" @@ -4058,6 +4136,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" @@ -4981,9 +5071,21 @@ dependencies = [ "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" @@ -5115,6 +5217,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -5153,6 +5256,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 1709fb2e..53486b82 100644 --- a/cardinal/src-tauri/Cargo.toml +++ b/cardinal/src-tauri/Cargo.toml @@ -47,6 +47,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/lib.rs b/cardinal/src-tauri/src/lib.rs index f8706271..5fefda11 100644 --- a/cardinal/src-tauri/src/lib.rs +++ b/cardinal/src-tauri/src/lib.rs @@ -164,8 +164,8 @@ pub fn run() -> Result<()> { let icon_update_rx = &icon_update_rx; let server_state = server::ServerState::new(search_tx.clone(), result_rx.clone(), node_info_tx.clone()); - std::thread::spawn(move || { - server::start_server(server_state, "127.0.0.1:3388"); + tauri::async_runtime::spawn(async move { + server::start_server(server_state, 3388).await; }); std::thread::scope(move |s| { diff --git a/cardinal/src-tauri/src/server.rs b/cardinal/src-tauri/src/server.rs index a36d08b9..ca8b2a58 100644 --- a/cardinal/src-tauri/src/server.rs +++ b/cardinal/src-tauri/src/server.rs @@ -1,38 +1,32 @@ -use crate::{ - commands::{NodeInfoMetadata, NodeInfoRequest, SearchJob, SearchOptionsPayload}, - sort::SortEntry, -}; +use crate::commands::{NodeInfoMetadata, NodeInfoRequest, SearchJob, SearchOptionsPayload}; use anyhow::Result; +use axum::{ + Json, Router, + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, +}; use crossbeam_channel::{Receiver, Sender, bounded}; -use rouille::Response; use search_cache::SearchOutcome; use search_cancel::CancellationToken; use serde::{Deserialize, Serialize}; use std::{ - collections::HashMap, - net::ToSocketAddrs, + net::SocketAddr, sync::{ Arc, Mutex, atomic::{AtomicU64, Ordering}, }, - time::{Duration, Instant}, }; +use tokio::net::TcpListener; static SEARCH_VERSION: AtomicU64 = AtomicU64::new(1000000); -static CACHE_TIMEOUT: u64 = 20; - -struct CacheEntry { - results: Vec, - highlights: Vec, - timestamp: Instant, -} #[derive(Clone)] pub struct ServerState { pub search_tx: Sender, pub result_rx: Receiver>, pub node_info_tx: Sender, - cache: Arc>>, search_lock: Arc>, } @@ -46,7 +40,6 @@ impl ServerState { search_tx, result_rx, node_info_tx, - cache: Arc::new(Mutex::new(HashMap::new())), search_lock: Arc::new(Mutex::new(())), } } @@ -78,169 +71,144 @@ pub struct ServerSearchResponse { pub highlights: Vec, } -pub fn start_server(state: ServerState, addr: impl ToSocketAddrs) { - let state = Arc::new(state); - rouille::start_server(addr, move |request| { - rouille::log(request, std::io::stdout(), || { - rouille::router!(request, - (GET) (/search) => { - let query = request.get_param("query").unwrap_or_default(); - let limit = request.get_param("limit").and_then(|v| v.parse().ok()); - let offset = request.get_param("offset").and_then(|v| v.parse().ok()); - let req = SearchRequest { - query, - options: SearchOptionsPayload::default(), - limit, - offset, - sort: None, - }; - handle_search(state.clone(), req) - }, - (POST) (/search) => { - let req: SearchRequest = match rouille::input::json_input(request) { - Ok(r) => r, - Err(_) => return Response::text("Invalid JSON").with_status_code(400), - }; - handle_search(state.clone(), req) - }, - _ => Response::empty_404() - ) - }) - }); +pub async fn start_server(state: ServerState, port: u16) { + let app = Router::new() + .route("/search", get(search_handler_get)) + .route("/search", post(search_handler)) + .with_state(Arc::new(state)); + + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + if let Ok(listener) = TcpListener::bind(addr).await { + tracing::info!("Starting axum HTTP server on {}", addr); + if let Err(e) = axum::serve(listener, app).await { + tracing::error!("Server error: {}", e); + } + } } -fn handle_search(state: Arc, req: SearchRequest) -> Response { - let cache_key = format!( - "{}_{}_{}", - req.query, - req.options.case_insensitive, - serde_json::to_string(&req.sort).unwrap_or_default() - ); +async fn search_handler_get( + State(state): State>, + Query(req): Query, +) -> impl IntoResponse { + handle_search(state, req).await +} - let mut cached_results = None; - let mut cached_highlights = Vec::new(); +async fn search_handler( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + handle_search(state, req).await +} - { - let mut cache = state.cache.lock().unwrap(); - cache.retain(|_, entry| entry.timestamp.elapsed() < Duration::from_secs(CACHE_TIMEOUT)); +async fn handle_search(state: Arc, req: SearchRequest) -> impl IntoResponse { + let result = tokio::task::spawn_blocking(move || { + let (slab_indices, highlights) = { + let _lock = state.search_lock.lock().unwrap(); - if let Some(entry) = cache.get(&cache_key) { - cached_results = Some(entry.results.clone()); - cached_highlights = entry.highlights.clone(); - } - } + while let Ok(_) = state.result_rx.try_recv() {} - let (slab_indices, highlights) = if let Some(results) = cached_results { - (results, cached_highlights) - } else { - // Clear any stale results - // TODO: better logic? - let _lock = state.search_lock.lock().unwrap(); - while let Ok(_) = state.result_rx.try_recv() {} - - let version = SEARCH_VERSION.fetch_add(1, Ordering::Relaxed); - let cancellation_token = CancellationToken::new(version); - - if let Err(e) = state.search_tx.send(SearchJob { - query: req.query, - options: req.options, - cancellation_token, - }) { - tracing::error!("Failed to send search job: {:?}", e); - return Response::text("Internal Server Error").with_status_code(500); - } + let version = SEARCH_VERSION.fetch_add(1, Ordering::Relaxed); + let cancellation_token = CancellationToken::new(version); - let outcome = match state.result_rx.recv() { - Ok(Ok(outcome)) => outcome, - Ok(Err(e)) => { - tracing::error!("Search error: {:?}", e); - return Response::text("Internal Server Error").with_status_code(500); + if let Err(e) = state.search_tx.send(SearchJob { + query: req.query, + options: req.options, + cancellation_token, + }) { + tracing::error!("Failed to send search job: {:?}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); } - Err(e) => { - tracing::error!("Reply channel closed: {:?}", e); - return Response::text("Internal Server Error").with_status_code(500); + + let outcome = match state.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 fetched_indices = outcome.nodes.unwrap_or_default(); + let mut results = slab_indices; - if let Some(sort) = &req.sort { - 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)| SortEntry::new(slab_index, node)) - .collect(); - crate::sort::sort_entries(&mut entries, sort); - fetched_indices = entries.into_iter().map(|e| e.slab_index).collect(); + if let Some(offset) = req.offset { + if offset < results.len() { + results = results[offset..].to_vec(); + } else { + results.clear(); } } - - { - let mut cache = state.cache.lock().unwrap(); - cache.insert( - cache_key, - CacheEntry { - results: fetched_indices.clone(), - highlights: outcome.highlights.clone(), - timestamp: Instant::now(), - }, - ); + if let Some(limit) = req.limit { + results.truncate(limit); } - (fetched_indices, outcome.highlights) - }; - - let mut results: Vec = slab_indices; - - if let Some(offset) = req.offset { - if offset < results.len() { - results = results[offset..].to_vec(); - } else { - results.clear(); + 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); } - } - if let Some(limit) = req.limit { - results.truncate(limit); - } - 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 Response::text("Internal Server Error").with_status_code(500); - } - - let final_nodes = match node_info_reply_rx.recv() { - Ok(nodes) => nodes, - Err(e) => { - tracing::error!("Node info reply channel closed: {:?}", e); - return Response::text("Internal Server Error").with_status_code(500); - } - }; - - 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, + 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); } - }) - .collect(); + }; - Response::json(&ServerSearchResponse { - results: final_results, - highlights, + 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(), + } } From 545d6de189ebeaadd9176c27daa3618c7a3f0a18 Mon Sep 17 00:00:00 2001 From: hsqStephenZhang Date: Tue, 28 Apr 2026 11:11:53 +0200 Subject: [PATCH 03/15] fix: errors after rebase --- cardinal/src-tauri/src/lib.rs | 3 +-- cardinal/src-tauri/src/server.rs | 24 ++++++------------------ 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/cardinal/src-tauri/src/lib.rs b/cardinal/src-tauri/src/lib.rs index 5fefda11..4f1dd661 100644 --- a/cardinal/src-tauri/src/lib.rs +++ b/cardinal/src-tauri/src/lib.rs @@ -162,8 +162,7 @@ pub fn run() -> Result<()> { }; emit_app_state(app_handle); let icon_update_rx = &icon_update_rx; - let server_state = - server::ServerState::new(search_tx.clone(), result_rx.clone(), node_info_tx.clone()); + let server_state = server::ServerState::new(search_tx.clone(), node_info_tx.clone()); tauri::async_runtime::spawn(async move { server::start_server(server_state, 3388).await; }); diff --git a/cardinal/src-tauri/src/server.rs b/cardinal/src-tauri/src/server.rs index ca8b2a58..e79b4b21 100644 --- a/cardinal/src-tauri/src/server.rs +++ b/cardinal/src-tauri/src/server.rs @@ -1,5 +1,4 @@ use crate::commands::{NodeInfoMetadata, NodeInfoRequest, SearchJob, SearchOptionsPayload}; -use anyhow::Result; use axum::{ Json, Router, extract::{Query, State}, @@ -7,14 +6,13 @@ use axum::{ response::IntoResponse, routing::{get, post}, }; -use crossbeam_channel::{Receiver, Sender, bounded}; -use search_cache::SearchOutcome; +use crossbeam_channel::{Sender, bounded}; use search_cancel::CancellationToken; use serde::{Deserialize, Serialize}; use std::{ net::SocketAddr, sync::{ - Arc, Mutex, + Arc, atomic::{AtomicU64, Ordering}, }, }; @@ -25,22 +23,14 @@ static SEARCH_VERSION: AtomicU64 = AtomicU64::new(1000000); #[derive(Clone)] pub struct ServerState { pub search_tx: Sender, - pub result_rx: Receiver>, pub node_info_tx: Sender, - search_lock: Arc>, } impl ServerState { - pub fn new( - search_tx: Sender, - result_rx: Receiver>, - node_info_tx: Sender, - ) -> Self { + pub fn new(search_tx: Sender, node_info_tx: Sender) -> Self { Self { search_tx, - result_rx, node_info_tx, - search_lock: Arc::new(Mutex::new(())), } } } @@ -103,23 +93,21 @@ async fn search_handler( async fn handle_search(state: Arc, req: SearchRequest) -> impl IntoResponse { let result = tokio::task::spawn_blocking(move || { let (slab_indices, highlights) = { - let _lock = state.search_lock.lock().unwrap(); - - while let Ok(_) = state.result_rx.try_recv() {} - let version = SEARCH_VERSION.fetch_add(1, Ordering::Relaxed); let cancellation_token = CancellationToken::new(version); + 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 state.result_rx.recv() { + let outcome = match result_rx.recv() { Ok(Ok(outcome)) => outcome, Ok(Err(e)) => { tracing::error!("Search error: {:?}", e); From 14e514c89e5c78d967d82b976fc45c8380a5c344 Mon Sep 17 00:00:00 2001 From: hsqStephenZhang Date: Wed, 6 May 2026 19:33:26 +0200 Subject: [PATCH 04/15] fix: use new_search api --- cardinal/src-tauri/src/server.rs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/cardinal/src-tauri/src/server.rs b/cardinal/src-tauri/src/server.rs index e79b4b21..42c41f93 100644 --- a/cardinal/src-tauri/src/server.rs +++ b/cardinal/src-tauri/src/server.rs @@ -9,17 +9,9 @@ use axum::{ use crossbeam_channel::{Sender, bounded}; use search_cancel::CancellationToken; use serde::{Deserialize, Serialize}; -use std::{ - net::SocketAddr, - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }, -}; +use std::{net::SocketAddr, sync::Arc}; use tokio::net::TcpListener; -static SEARCH_VERSION: AtomicU64 = AtomicU64::new(1000000); - #[derive(Clone)] pub struct ServerState { pub search_tx: Sender, @@ -93,8 +85,7 @@ async fn search_handler( async fn handle_search(state: Arc, req: SearchRequest) -> impl IntoResponse { let result = tokio::task::spawn_blocking(move || { let (slab_indices, highlights) = { - let version = SEARCH_VERSION.fetch_add(1, Ordering::Relaxed); - let cancellation_token = CancellationToken::new(version); + let cancellation_token = CancellationToken::new_search(); let (result_tx, result_rx) = bounded(1); if let Err(e) = state.search_tx.send(SearchJob { From 26c23ea1f55d3b9edf5fca249904c317974fc3e0 Mon Sep 17 00:00:00 2001 From: hsqStephenZhang Date: Wed, 6 May 2026 19:48:00 +0200 Subject: [PATCH 05/15] fix: hard limit for http api --- cardinal/src-tauri/src/server.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cardinal/src-tauri/src/server.rs b/cardinal/src-tauri/src/server.rs index 42c41f93..8c589629 100644 --- a/cardinal/src-tauri/src/server.rs +++ b/cardinal/src-tauri/src/server.rs @@ -12,6 +12,8 @@ use serde::{Deserialize, Serialize}; use std::{net::SocketAddr, sync::Arc}; use tokio::net::TcpListener; +const TRUNCATE_THRES: usize = 10000; + #[derive(Clone)] pub struct ServerState { pub search_tx: Sender, @@ -149,6 +151,11 @@ async fn handle_search(state: Arc, req: SearchRequest) -> impl Into 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, From 4be6fbf8dd23e4e56d063d858be1d3d859a1ba07 Mon Sep 17 00:00:00 2001 From: hsqStephenZhang Date: Fri, 8 May 2026 15:39:15 +0200 Subject: [PATCH 06/15] feat: add http server enable/reload command for tauri --- cardinal/src-tauri/src/commands.rs | 40 ++++++++++++++++-- cardinal/src-tauri/src/lib.rs | 67 ++++++++++++++++++++++++++---- cardinal/src-tauri/src/server.rs | 25 +++++++---- 3 files changed, 115 insertions(+), 17 deletions(-) diff --git a/cardinal/src-tauri/src/commands.rs b/cardinal/src-tauri/src/commands.rs index 947e6401..9e5dcb8d 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, State}; use tracing::{error, info, warn}; #[derive(Debug, Clone)] @@ -47,6 +47,13 @@ impl From for SearchOptions { } } +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerConfig { + pub enabled: bool, + pub endpoint: String, +} + #[derive(Debug, Clone)] pub struct SearchJob { pub query: String, @@ -68,11 +75,13 @@ 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<()>, } @@ -84,6 +93,8 @@ impl SearchState { 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 +103,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, } @@ -430,6 +443,27 @@ 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(); + } + + 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 4f1dd661..5026b3f7 100644 --- a/cardinal/src-tauri/src/lib.rs +++ b/cardinal/src-tauri/src/lib.rs @@ -14,11 +14,11 @@ 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, 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::{ @@ -64,6 +64,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); @@ -120,16 +121,23 @@ pub fn run() -> Result<()> { icon_viewport_tx.clone(), rescan_tx.clone(), watch_config_tx.clone(), + ServerConfig { + enabled: false, + endpoint: "127.0.0.1:3388".to_string(), + }, + 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, @@ -162,9 +170,54 @@ pub fn run() -> Result<()> { }; emit_app_state(app_handle); let icon_update_rx = &icon_update_rx; - let server_state = server::ServerState::new(search_tx.clone(), node_info_tx.clone()); + + // http server + let server_state = server::ServerState::new(search_tx, node_info_tx); tauri::async_runtime::spawn(async move { - server::start_server(server_state, 3388).await; + let mut current_server_handle: Option> = None; + let server_config_rx = server_config_rx; + + // Default configuration + let mut current_config = ServerConfig { + enabled: false, + endpoint: "127.0.0.1:3388".to_string(), + }; + + 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| { diff --git a/cardinal/src-tauri/src/server.rs b/cardinal/src-tauri/src/server.rs index 8c589629..6837e970 100644 --- a/cardinal/src-tauri/src/server.rs +++ b/cardinal/src-tauri/src/server.rs @@ -9,7 +9,7 @@ use axum::{ use crossbeam_channel::{Sender, bounded}; use search_cancel::CancellationToken; use serde::{Deserialize, Serialize}; -use std::{net::SocketAddr, sync::Arc}; +use std::{future::Future, sync::Arc}; use tokio::net::TcpListener; const TRUNCATE_THRES: usize = 10000; @@ -55,18 +55,29 @@ pub struct ServerSearchResponse { pub highlights: Vec, } -pub async fn start_server(state: ServerState, port: u16) { +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 addr = SocketAddr::from(([127, 0, 0, 1], port)); - if let Ok(listener) = TcpListener::bind(addr).await { - tracing::info!("Starting axum HTTP server on {}", addr); - if let Err(e) = axum::serve(listener, app).await { - tracing::error!("Server error: {}", e); + 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); } } From dfd975cd9f25333f9192e189448d936a5049ffb8 Mon Sep 17 00:00:00 2001 From: hsqStephenZhang Date: Fri, 8 May 2026 15:46:13 +0200 Subject: [PATCH 07/15] feat: supporty http server config in preference --- cardinal/src/App.tsx | 8 +- .../src/__tests__/App.contextMenu.test.tsx | 6 +- .../__tests__/App.searchNavigation.test.tsx | 6 +- .../src/components/PreferencesOverlay.tsx | 109 +++++++++++++++- .../__tests__/PreferencesOverlay.test.tsx | 95 ++++++++++---- .../hooks/__tests__/useAppPreferences.test.ts | 120 +++++++++++++++++- cardinal/src/hooks/useAppPreferences.ts | 42 +++++- cardinal/src/hooks/useServerConfig.ts | 86 +++++++++++++ cardinal/src/i18n/resources/ar-SA.json | 5 + cardinal/src/i18n/resources/de-DE.json | 5 + cardinal/src/i18n/resources/en-US.json | 5 + cardinal/src/i18n/resources/es-ES.json | 5 + cardinal/src/i18n/resources/fr-FR.json | 5 + cardinal/src/i18n/resources/hi-IN.json | 5 + cardinal/src/i18n/resources/it-IT.json | 5 + cardinal/src/i18n/resources/ja-JP.json | 5 + cardinal/src/i18n/resources/ko-KR.json | 5 + cardinal/src/i18n/resources/pt-BR.json | 5 + cardinal/src/i18n/resources/ru-RU.json | 5 + cardinal/src/i18n/resources/tr-TR.json | 5 + cardinal/src/i18n/resources/uk-UA.json | 5 + cardinal/src/i18n/resources/zh-CN.json | 5 + cardinal/src/i18n/resources/zh-TW.json | 5 + 23 files changed, 502 insertions(+), 45 deletions(-) create mode 100644 cardinal/src/hooks/useServerConfig.ts 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..9991c424 100644 --- a/cardinal/src/components/PreferencesOverlay.tsx +++ b/cardinal/src/components/PreferencesOverlay.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { getWatchRootValidation, isPathInputValid } from '../utils/watchRoot'; +import type { ServerConfig } from '../hooks/useServerConfig'; import ThemeSwitcher from './ThemeSwitcher'; import LanguageSwitcher from './LanguageSwitcher'; @@ -14,15 +15,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 +41,13 @@ export function PreferencesOverlay({ onTrayIconEnabledChange, watchRoot, defaultWatchRoot, - onWatchConfigChange, + onPreferencesChange, ignorePaths, defaultIgnorePaths, includePaths, defaultIncludePaths, + serverConfig, + defaultServerConfig, onReset, themeResetToken, }: PreferencesOverlayProps): React.JSX.Element | null { @@ -50,6 +56,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 +91,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 +139,48 @@ export function PreferencesOverlay({ return invalid ? t('includePaths.errors.absolute') : null; })(); + const trimmedServerEndpoint = serverEndpointInput.trim(); + const serverPortSeparatorIndex = trimmedServerEndpoint.lastIndexOf(':'); + const parsedServerPort = + serverPortSeparatorIndex >= 0 + ? Number.parseInt(trimmedServerEndpoint.slice(serverPortSeparatorIndex + 1), 10) + : Number.NaN; + const serverEndpointErrorMessage = + trimmedServerEndpoint.length === 0 || + serverPortSeparatorIndex <= 0 || + serverPortSeparatorIndex === trimmedServerEndpoint.length - 1 || + Number.isNaN(parsedServerPort) || + parsedServerPort < 1 || + parsedServerPort > 65535 + ? 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 +189,8 @@ export function PreferencesOverlay({ setWatchRootInput(defaultWatchRoot); setIgnorePathsInput(defaultIgnorePaths.join('\n')); setIncludePathsInput(defaultIncludePaths.join('\n')); + setServerEnabledInput(defaultServerConfig.enabled); + setServerEndpointInput(defaultServerConfig.endpoint); onReset(); }; @@ -198,6 +240,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 +394,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..f2152651 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,53 @@ 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('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 +172,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 +196,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..0bd6410c 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,41 @@ describe('useAppPreferences', () => { i18n: { changeLanguage }, }); - expect(mockedInvoke).toHaveBeenCalledTimes(1); + expect(mockedInvoke.mock.calls.filter(([command]) => command === 'start_logic')).toHaveLength( + 1, + ); + }); + + it('syncs stored 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(mockedInvoke).toHaveBeenCalledWith('set_server_config', { + config: { + enabled: true, + endpoint: '0.0.0.0:3390', + }, + }); + }); }); it('updates watch config and refreshes search when preferences change', async () => { @@ -144,10 +196,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 +241,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 +282,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 +327,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 +380,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 +424,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,6 +444,35 @@ 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(refreshSearchResults).not.toHaveBeenCalled(); + }); + it('opens and closes preferences, and resets user preferences', async () => { const { result } = renderHook(() => useAppPreferences({ @@ -404,6 +509,7 @@ describe('useAppPreferences', () => { expect(mockedSetTrayEnabled).toHaveBeenCalledWith(false); }); expect(mockedPersistTrayIconEnabled).toHaveBeenCalledWith(false); + expect(setServerConfig).not.toHaveBeenCalled(); expect(mockedPersistThemePreference).toHaveBeenCalledWith('system'); expect(mockedApplyThemePreference).toHaveBeenCalledWith('system'); expect(changeLanguage).toHaveBeenCalledWith('fr-FR'); diff --git a/cardinal/src/hooks/useAppPreferences.ts b/cardinal/src/hooks/useAppPreferences.ts index 9a0c8e9c..f1ae55b7 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()); @@ -67,6 +78,10 @@ export function useAppPreferences({ void setTrayEnabled(trayIconEnabled); }, [trayIconEnabled]); + useEffect(() => { + void invoke('set_server_config', { config: serverConfig }); + }, [serverConfig]); + useEffect(() => { if (typeof window === 'undefined') { return; @@ -129,21 +144,34 @@ 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); + }, + [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 +186,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..82086a81 --- /dev/null +++ b/cardinal/src/hooks/useServerConfig.ts @@ -0,0 +1,86 @@ +import { useCallback } 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 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 port = Number.parseInt(trimmed.slice(portSeparator + 1), 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: (raw) => { + 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, + }); + }, + write: (value) => JSON.stringify(normalizeServerConfig(value)), + 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], + ); + + 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": "排序上限" }, From 72adfe501684aea638d8cfa1d2e7d62f319e037c Mon Sep 17 00:00:00 2001 From: hsqStephenZhang Date: Fri, 8 May 2026 16:21:54 +0200 Subject: [PATCH 08/15] fix: remove rouille dep --- cardinal/src-tauri/Cargo.lock | 241 +--------------------------------- cardinal/src-tauri/Cargo.toml | 1 - 2 files changed, 5 insertions(+), 237 deletions(-) diff --git a/cardinal/src-tauri/Cargo.lock b/cardinal/src-tauri/Cargo.lock index 04b5c2cf..3d571470 100644 --- a/cardinal/src-tauri/Cargo.lock +++ b/cardinal/src-tauri/Cargo.lock @@ -8,12 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "adler32" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" - [[package]] name = "aho-corasick" version = "1.1.4" @@ -59,12 +53,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "ascii" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" - [[package]] name = "async-broadcast" version = "0.7.2" @@ -283,12 +271,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.21.7" @@ -368,17 +350,6 @@ dependencies = [ "piper", ] -[[package]] -name = "brotli" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor 2.5.1", -] - [[package]] name = "brotli" version = "8.0.2" @@ -387,17 +358,7 @@ checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", - "brotli-decompressor 5.0.0", -] - -[[package]] -name = "brotli-decompressor" -version = "2.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "brotli-decompressor", ] [[package]] @@ -410,16 +371,6 @@ dependencies = [ "alloc-stdlib", ] -[[package]] -name = "buf_redux" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b953a6887648bb07a535631f2bc00fbdb2a2216f135552cb3f534ed136b9c07f" -dependencies = [ - "memchr", - "safemem", -] - [[package]] name = "bumpalo" version = "3.20.2" @@ -500,7 +451,6 @@ dependencies = [ "once_cell", "parking_lot", "rayon", - "rouille", "search-cache", "search-cancel", "serde", @@ -625,12 +575,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "chunked_transfer" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" - [[package]] name = "cobs" version = "0.3.0" @@ -913,16 +857,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "deflate" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c86f7e25f518f4b81808a2cf1c50996a61f5c2eb394b2393bd87f2a4780a432f" -dependencies = [ - "adler32", - "gzip-header", -] - [[package]] name = "deranged" version = "0.5.8" @@ -1273,17 +1207,6 @@ dependencies = [ "xattr", ] -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1814,15 +1737,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "gzip-header" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86848f4fd157d91041a62c78046fb7b248bcc2dce78376d436a1756e9a038577" -dependencies = [ - "crc32fast", -] - [[package]] name = "hashbrown" version = "0.12.3" @@ -2450,10 +2364,7 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags 2.11.0", "libc", - "plain", - "redox_syscall 0.7.4", ] [[package]] @@ -2595,16 +2506,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2647,24 +2548,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "multipart" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00dec633863867f29cb39df64a397cdf4a6354708ddd7759f70c7fb51c5f9182" -dependencies = [ - "buf_redux", - "httparse", - "log", - "mime", - "mime_guess", - "quick-error", - "rand 0.8.5", - "safemem", - "tempfile", - "twoway", -] - [[package]] name = "namepool" version = "0.1.0" @@ -2744,16 +2627,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - [[package]] name = "num_enum" version = "0.7.6" @@ -2776,15 +2649,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - [[package]] name = "objc" version = "0.2.7" @@ -3146,7 +3010,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link 0.2.1", ] @@ -3373,12 +3237,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "plist" version = "1.8.0" @@ -3558,12 +3416,6 @@ dependencies = [ name = "query-segmentation" version = "0.1.0" -[[package]] -name = "quick-error" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" - [[package]] name = "quick-xml" version = "0.38.4" @@ -3710,15 +3562,6 @@ dependencies = [ "bitflags 2.11.0", ] -[[package]] -name = "redox_syscall" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" -dependencies = [ - "bitflags 2.11.0", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -3813,30 +3656,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "rouille" -version = "3.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3716fbf57fc1084d7a706adf4e445298d123e4a44294c4e8213caf1b85fcc921" -dependencies = [ - "base64 0.13.1", - "brotli 3.5.0", - "chrono", - "deflate", - "filetime", - "multipart", - "percent-encoding", - "rand 0.8.5", - "serde", - "serde_derive", - "serde_json", - "sha1_smol", - "threadpool", - "time", - "tiny_http", - "url", -] - [[package]] name = "rustc-hash" version = "2.1.2" @@ -3877,12 +3696,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "safemem" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" - [[package]] name = "same-file" version = "1.0.6" @@ -4220,12 +4033,6 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "sha1_smol" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" - [[package]] name = "sha2" version = "0.10.9" @@ -4326,7 +4133,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall 0.5.18", + "redox_syscall", "tracing", "wasm-bindgen", "web-sys", @@ -4642,7 +4449,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" dependencies = [ "base64 0.22.1", - "brotli 8.0.2", + "brotli", "ico", "json-patch", "plist", @@ -4847,7 +4654,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" dependencies = [ "anyhow", - "brotli 8.0.2", + "brotli", "cargo_metadata", "ctor", "dunce", @@ -4981,15 +4788,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "threadpool" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" -dependencies = [ - "num_cpus", -] - [[package]] name = "time" version = "0.3.47" @@ -4998,9 +4796,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", - "libc", "num-conv", - "num_threads", "powerfmt", "serde_core", "time-core", @@ -5023,18 +4819,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tiny_http" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" -dependencies = [ - "ascii", - "chunked_transfer", - "httpdate", - "log", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -5340,15 +5124,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "twoway" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59b11b2b5241ba34be09c3cc85a36e56e48f9888862e19cedf23336d35316ed1" -dependencies = [ - "memchr", -] - [[package]] name = "typed-num" version = "0.3.0" @@ -5422,12 +5197,6 @@ dependencies = [ "unic-common", ] -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/cardinal/src-tauri/Cargo.toml b/cardinal/src-tauri/Cargo.toml index 53486b82..dcb7e224 100644 --- a/cardinal/src-tauri/Cargo.toml +++ b/cardinal/src-tauri/Cargo.toml @@ -39,7 +39,6 @@ tauri-plugin-prevent-default = "4" tauri-plugin-macos-permissions = "2.3.0" tauri-plugin-window-state = "2" once_cell = { version = "1.20", features = ["parking_lot"] } -rouille = "3.6.2" cardinal-sdk.path = "../../cardinal-sdk" search-cache.path = "../../search-cache" From 974ae328bf2ba0154f66e53d29c2e1f0906a531c Mon Sep 17 00:00:00 2001 From: hsqStephenZhang Date: Fri, 8 May 2026 16:36:58 +0200 Subject: [PATCH 09/15] fix: load server config in startup --- cardinal/src-tauri/src/commands.rs | 33 +++++++++++++++++++++++++++++- cardinal/src-tauri/src/lib.rs | 25 +++++++++++----------- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/cardinal/src-tauri/src/commands.rs b/cardinal/src-tauri/src/commands.rs index 9e5dcb8d..7bc1feb6 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, Emitter, State}; +use tauri::{ActivationPolicy, AppHandle, Emitter, Manager, State}; use tracing::{error, info, warn}; #[derive(Debug, Clone)] @@ -54,6 +54,24 @@ pub struct ServerConfig { 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, @@ -455,6 +473,19 @@ pub fn set_server_config(app: AppHandle, config: ServerConfig, state: State<'_, *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:?}"); } diff --git a/cardinal/src-tauri/src/lib.rs b/cardinal/src-tauri/src/lib.rs index 5026b3f7..5124bb7e 100644 --- a/cardinal/src-tauri/src/lib.rs +++ b/cardinal/src-tauri/src/lib.rs @@ -16,9 +16,10 @@ use cardinal_sdk::EventWatcher; use commands::{ 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, 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, + 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::{ @@ -121,10 +122,7 @@ pub fn run() -> Result<()> { icon_viewport_tx.clone(), rescan_tx.clone(), watch_config_tx.clone(), - ServerConfig { - enabled: false, - endpoint: "127.0.0.1:3388".to_string(), - }, + ServerConfig::default(), server_config_tx.clone(), update_window_state_tx.clone(), )) @@ -157,6 +155,12 @@ pub fn run() -> Result<()> { .get_or_try_init(|| app.path().app_config_dir().map(|p| p.join("cardinal.db"))) .expect("Failed to initialize database path"); + 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 app_handle = &app.handle().to_owned(); let channels = BackgroundLoopChannels { finish_rx, @@ -176,12 +180,7 @@ pub fn run() -> Result<()> { tauri::async_runtime::spawn(async move { let mut current_server_handle: Option> = None; let server_config_rx = server_config_rx; - - // Default configuration - let mut current_config = ServerConfig { - enabled: false, - endpoint: "127.0.0.1:3388".to_string(), - }; + let mut current_config = initial_server_config; loop { // If enabled and not running, start it From 1987fd8213c82f584e5ed0394aabd95ea0863fbf Mon Sep 17 00:00:00 2001 From: hsqStephenZhang Date: Fri, 8 May 2026 17:32:11 +0200 Subject: [PATCH 10/15] chore: extract isValidEndpoint helper --- .../src/components/PreferencesOverlay.tsx | 22 +++++-------------- cardinal/src/hooks/useServerConfig.ts | 2 +- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/cardinal/src/components/PreferencesOverlay.tsx b/cardinal/src/components/PreferencesOverlay.tsx index 9991c424..cb3a90c2 100644 --- a/cardinal/src/components/PreferencesOverlay.tsx +++ b/cardinal/src/components/PreferencesOverlay.tsx @@ -1,6 +1,7 @@ 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'; @@ -140,22 +141,11 @@ export function PreferencesOverlay({ })(); const trimmedServerEndpoint = serverEndpointInput.trim(); - const serverPortSeparatorIndex = trimmedServerEndpoint.lastIndexOf(':'); - const parsedServerPort = - serverPortSeparatorIndex >= 0 - ? Number.parseInt(trimmedServerEndpoint.slice(serverPortSeparatorIndex + 1), 10) - : Number.NaN; - const serverEndpointErrorMessage = - trimmedServerEndpoint.length === 0 || - serverPortSeparatorIndex <= 0 || - serverPortSeparatorIndex === trimmedServerEndpoint.length - 1 || - Number.isNaN(parsedServerPort) || - parsedServerPort < 1 || - parsedServerPort > 65535 - ? t('preferences.server.endpointError', { - defaultValue: 'Enter an endpoint like 127.0.0.1:3388 or 0.0.0.0:3388.', - }) - : null; + 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 ( diff --git a/cardinal/src/hooks/useServerConfig.ts b/cardinal/src/hooks/useServerConfig.ts index 82086a81..7f9e34a1 100644 --- a/cardinal/src/hooks/useServerConfig.ts +++ b/cardinal/src/hooks/useServerConfig.ts @@ -16,7 +16,7 @@ const DEFAULT_SERVER_CONFIG: ServerConfig = { endpoint: '127.0.0.1:3388', }; -const isValidEndpoint = (endpoint: string): boolean => { +export const isValidEndpoint = (endpoint: string): boolean => { const trimmed = endpoint.trim(); if (!trimmed) { return false; From a144ec55850c9152f778ac235801137cd5de821b Mon Sep 17 00:00:00 2001 From: hsqStephenZhang Date: Fri, 8 May 2026 18:31:05 +0200 Subject: [PATCH 11/15] fix: cleaner server config load/store logic --- cardinal/src-tauri/src/lib.rs | 12 ++- .../hooks/__tests__/useAppPreferences.test.ts | 40 ++++++-- .../hooks/__tests__/useServerConfig.test.ts | 93 +++++++++++++++++++ cardinal/src/hooks/useAppPreferences.ts | 5 +- cardinal/src/hooks/useServerConfig.ts | 21 ++++- 5 files changed, 154 insertions(+), 17 deletions(-) create mode 100644 cardinal/src/hooks/__tests__/useServerConfig.test.ts diff --git a/cardinal/src-tauri/src/lib.rs b/cardinal/src-tauri/src/lib.rs index 5124bb7e..478bfce8 100644 --- a/cardinal/src-tauri/src/lib.rs +++ b/cardinal/src-tauri/src/lib.rs @@ -151,15 +151,19 @@ pub fn run() -> Result<()> { .build(tauri::generate_context!()) .expect("error while running tauri application"); - 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"); - 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"); let app_handle = &app.handle().to_owned(); let channels = BackgroundLoopChannels { diff --git a/cardinal/src/hooks/__tests__/useAppPreferences.test.ts b/cardinal/src/hooks/__tests__/useAppPreferences.test.ts index 0bd6410c..ef9450d6 100644 --- a/cardinal/src/hooks/__tests__/useAppPreferences.test.ts +++ b/cardinal/src/hooks/__tests__/useAppPreferences.test.ts @@ -142,7 +142,7 @@ describe('useAppPreferences', () => { ); }); - it('syncs stored server config to the backend on startup', async () => { + it('does not push server config to the backend on startup', async () => { mockedUseServerConfig.mockReturnValue({ serverConfig: { enabled: true, @@ -165,13 +165,10 @@ describe('useAppPreferences', () => { ); await waitFor(() => { - expect(mockedInvoke).toHaveBeenCalledWith('set_server_config', { - config: { - enabled: true, - endpoint: '0.0.0.0:3390', - }, - }); + expect(mockedSetTrayEnabled).toHaveBeenCalledWith(true); }); + + expect(mockedInvoke).not.toHaveBeenCalledWith('set_server_config', expect.anything()); }); it('updates watch config and refreshes search when preferences change', async () => { @@ -470,10 +467,28 @@ describe('useAppPreferences', () => { 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', @@ -509,7 +524,16 @@ describe('useAppPreferences', () => { expect(mockedSetTrayEnabled).toHaveBeenCalledWith(false); }); expect(mockedPersistTrayIconEnabled).toHaveBeenCalledWith(false); - expect(setServerConfig).not.toHaveBeenCalled(); + 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..06dc679d --- /dev/null +++ b/cardinal/src/hooks/__tests__/useServerConfig.test.ts @@ -0,0 +1,93 @@ +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 { 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', + }), + ); + }); +}); diff --git a/cardinal/src/hooks/useAppPreferences.ts b/cardinal/src/hooks/useAppPreferences.ts index f1ae55b7..a93c42d3 100644 --- a/cardinal/src/hooks/useAppPreferences.ts +++ b/cardinal/src/hooks/useAppPreferences.ts @@ -78,10 +78,6 @@ export function useAppPreferences({ void setTrayEnabled(trayIconEnabled); }, [trayIconEnabled]); - useEffect(() => { - void invoke('set_server_config', { config: serverConfig }); - }, [serverConfig]); - useEffect(() => { if (typeof window === 'undefined') { return; @@ -151,6 +147,7 @@ export function useAppPreferences({ } setServerConfig(next); + void invoke('set_server_config', { config: next }); }, [serverConfig.enabled, serverConfig.endpoint, setServerConfig], ); diff --git a/cardinal/src/hooks/useServerConfig.ts b/cardinal/src/hooks/useServerConfig.ts index 7f9e34a1..d8e3e573 100644 --- a/cardinal/src/hooks/useServerConfig.ts +++ b/cardinal/src/hooks/useServerConfig.ts @@ -1,4 +1,5 @@ -import { useCallback } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { useCallback, useEffect } from 'react'; import { useStoredState } from './useStoredState'; export type ServerConfig = Readonly<{ @@ -78,6 +79,24 @@ export function useServerConfig(): { [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, From 46696c53c0a314e3aaa794ef00b7639d31b86f74 Mon Sep 17 00:00:00 2001 From: hsqStephenZhang Date: Fri, 8 May 2026 18:33:53 +0200 Subject: [PATCH 12/15] fix: robust port parsing logic --- .../__tests__/PreferencesOverlay.test.tsx | 14 ++++++++++++++ .../src/hooks/__tests__/useServerConfig.test.ts | 8 +++++++- cardinal/src/hooks/useServerConfig.ts | 7 ++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/cardinal/src/components/__tests__/PreferencesOverlay.test.tsx b/cardinal/src/components/__tests__/PreferencesOverlay.test.tsx index f2152651..eef9eb58 100644 --- a/cardinal/src/components/__tests__/PreferencesOverlay.test.tsx +++ b/cardinal/src/components/__tests__/PreferencesOverlay.test.tsx @@ -147,6 +147,20 @@ describe('PreferencesOverlay', () => { 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 onPreferencesChange = vi.fn(); diff --git a/cardinal/src/hooks/__tests__/useServerConfig.test.ts b/cardinal/src/hooks/__tests__/useServerConfig.test.ts index 06dc679d..a45b8d35 100644 --- a/cardinal/src/hooks/__tests__/useServerConfig.test.ts +++ b/cardinal/src/hooks/__tests__/useServerConfig.test.ts @@ -2,7 +2,7 @@ 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 { useServerConfig } from '../useServerConfig'; +import { isValidEndpoint, useServerConfig } from '../useServerConfig'; vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), @@ -90,4 +90,10 @@ describe('useServerConfig', () => { }), ); }); + + 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/useServerConfig.ts b/cardinal/src/hooks/useServerConfig.ts index d8e3e573..cef5c489 100644 --- a/cardinal/src/hooks/useServerConfig.ts +++ b/cardinal/src/hooks/useServerConfig.ts @@ -28,7 +28,12 @@ export const isValidEndpoint = (endpoint: string): boolean => { return false; } - const port = Number.parseInt(trimmed.slice(portSeparator + 1), 10); + 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; }; From d927a914546cd63e365d2c35287918fb9420c736 Mon Sep 17 00:00:00 2001 From: hsqStephenZhang Date: Fri, 8 May 2026 20:15:34 +0200 Subject: [PATCH 13/15] fix: avoid rehydrating server config on rerender --- .../hooks/__tests__/useServerConfig.test.ts | 14 ++++++ cardinal/src/hooks/useServerConfig.ts | 45 ++++++++++--------- 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/cardinal/src/hooks/__tests__/useServerConfig.test.ts b/cardinal/src/hooks/__tests__/useServerConfig.test.ts index a45b8d35..7adaebd8 100644 --- a/cardinal/src/hooks/__tests__/useServerConfig.test.ts +++ b/cardinal/src/hooks/__tests__/useServerConfig.test.ts @@ -91,6 +91,20 @@ describe('useServerConfig', () => { ); }); + 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); diff --git a/cardinal/src/hooks/useServerConfig.ts b/cardinal/src/hooks/useServerConfig.ts index cef5c489..0444ed5f 100644 --- a/cardinal/src/hooks/useServerConfig.ts +++ b/cardinal/src/hooks/useServerConfig.ts @@ -17,6 +17,29 @@ const DEFAULT_SERVER_CONFIG: ServerConfig = { 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) { @@ -52,26 +75,8 @@ export function useServerConfig(): { const [serverConfig, setServerConfigState] = useStoredState({ key: STORAGE_KEY, defaultValue: DEFAULT_SERVER_CONFIG, - read: (raw) => { - 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, - }); - }, - write: (value) => JSON.stringify(normalizeServerConfig(value)), + read: readStoredServerConfig, + write: writeStoredServerConfig, normalize: normalizeServerConfig, readErrorMessage: 'Failed to read stored server config preference', writeErrorMessage: 'Failed to persist server config preference', From 874163ba1ed454fbe0a0925f6263206ed1d5b72c Mon Sep 17 00:00:00 2001 From: hsqStephenZhang Date: Fri, 8 May 2026 20:18:00 +0200 Subject: [PATCH 14/15] chore: fmt --- cardinal/src/hooks/useServerConfig.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cardinal/src/hooks/useServerConfig.ts b/cardinal/src/hooks/useServerConfig.ts index 0444ed5f..8e00031c 100644 --- a/cardinal/src/hooks/useServerConfig.ts +++ b/cardinal/src/hooks/useServerConfig.ts @@ -31,9 +31,7 @@ const readStoredServerConfig = (raw: string): ServerConfig | null => { return normalizeServerConfig({ enabled: Boolean(parsed.enabled), endpoint: - typeof parsed.port === 'number' - ? `127.0.0.1:${parsed.port}` - : DEFAULT_SERVER_CONFIG.endpoint, + typeof parsed.port === 'number' ? `127.0.0.1:${parsed.port}` : DEFAULT_SERVER_CONFIG.endpoint, }); }; From cc7cbefc7749f3267edc5b5b752472502c8d47d8 Mon Sep 17 00:00:00 2001 From: hsqStephenZhang Date: Fri, 8 May 2026 20:20:49 +0200 Subject: [PATCH 15/15] chore: allow too_many_arguments for SearchState::new --- cardinal/src-tauri/src/commands.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/cardinal/src-tauri/src/commands.rs b/cardinal/src-tauri/src/commands.rs index 7bc1feb6..2d8937c8 100644 --- a/cardinal/src-tauri/src/commands.rs +++ b/cardinal/src-tauri/src/commands.rs @@ -105,6 +105,7 @@ pub struct SearchState { } impl SearchState { + #[allow(clippy::too_many_arguments)] pub fn new( search_tx: Sender, node_info_tx: Sender,