diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.lock b/openless-all/app/src-tauri/backend-tests/Cargo.lock index b75544f5..a4d5caf4 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.lock +++ b/openless-all/app/src-tauri/backend-tests/Cargo.lock @@ -131,6 +131,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -384,12 +390,78 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dasp_sample" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -478,6 +550,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -585,6 +663,17 @@ dependencies = [ "wasip3", ] +[[package]] +name = "getset" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "glob" version = "0.3.3" @@ -645,6 +734,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "image" version = "0.25.10" @@ -797,6 +892,17 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "local-ip-address" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa08fb2b1ec3ea84575e94b489d06d4ce0cbf052d12acd515838f50e3c3d63e3" +dependencies = [ + "libc", + "neli", + "windows-sys 0.61.2", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -900,6 +1006,35 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "neli" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" +dependencies = [ + "bitflags 2.11.1", + "byteorder", + "derive_builder", + "getset", + "libc", + "log", + "neli-proc-macros", + "parking_lot", +] + +[[package]] +name = "neli-proc-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609" +dependencies = [ + "either", + "proc-macro2", + "quote", + "serde", + "syn", +] + [[package]] name = "nom" version = "7.1.3" @@ -1165,6 +1300,7 @@ dependencies = [ "enigo", "global-hotkey", "libc", + "local-ip-address", "log", "once_cell", "parking_lot", @@ -1460,6 +1596,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.117" diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.toml b/openless-all/app/src-tauri/backend-tests/Cargo.toml index 03dbe12b..a2c88032 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.toml +++ b/openless-all/app/src-tauri/backend-tests/Cargo.toml @@ -15,6 +15,7 @@ cpal = "0.15" enigo = "0.3" global-hotkey = "0.6" libc = "0.2" +local-ip-address = "^0.6" log = "0.4" once_cell = "1" parking_lot = "0.12" diff --git a/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs b/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs index ead237a3..46b4e1f6 100644 --- a/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs +++ b/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs @@ -125,6 +125,8 @@ mod hotkey; mod insertion; #[path = "../../src/remote_server/pin_persistence.rs"] mod pin_persistence; +#[path = "../../src/remote_server/lan_addresses.rs"] +mod lan_addresses; #[path = "../../src/recorder.rs"] mod recorder; #[path = "../../src/shortcut_binding.rs"] diff --git a/openless-all/app/src-tauri/src/commands/remote_input.rs b/openless-all/app/src-tauri/src/commands/remote_input.rs index 37973fbb..e39184a3 100644 --- a/openless-all/app/src-tauri/src/commands/remote_input.rs +++ b/openless-all/app/src-tauri/src/commands/remote_input.rs @@ -15,11 +15,16 @@ pub fn get_remote_input_status( } #[tauri::command] -pub fn list_local_ips() -> Vec { - crate::remote_server::local_lan_ipv4s() - .iter() - .map(|ip| ip.to_string()) - .collect() +pub async fn list_local_ips(app: AppHandle) -> Vec { + tauri::async_runtime::spawn_blocking(move || { + crate::remote_server::discover_lan_addresses(&app) + .ips + .iter() + .map(|ip| ip.to_string()) + .collect() + }) + .await + .unwrap_or_default() } #[tauri::command] @@ -28,15 +33,10 @@ pub fn regenerate_remote_pin(coord: CoordinatorState<'_>) -> Result, locale: String) { +pub fn set_remote_locale(coord: CoordinatorState<'_>, locale: String) { coord.set_remote_locale(locale); - let refresh_app = app.clone(); - if let Err(err) = app.run_on_main_thread(move || { - if let Err(err) = crate::refresh_tray_microphone_menu(&refresh_app) { - log::warn!("[tray] refresh menu after locale change failed: {err}"); - } - }) { - log::warn!("[tray] dispatch locale refresh failed: {err}"); - } } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 677784ce..dcb38db6 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1201,13 +1201,21 @@ struct Inner { shutdown: AtomicBool, #[cfg(not(mobile))] remote_audio_sink: Mutex>>, + /// 远程听写开链前先挂上的 PCM 缓冲。手机在 `start` 握手完成前就会推音频, + /// 没有这层的话前几百毫秒会被丢掉,听起来像「手机麦没声」。 + #[cfg(not(mobile))] + remote_pcm_bridge: Mutex>>, #[cfg(not(mobile))] remote_server: Mutex>, #[cfg(not(mobile))] remote_refresh_gen: AtomicU64, #[cfg(not(mobile))] + remote_refresh_generation_lock: Mutex<()>, + #[cfg(not(mobile))] remote_refresh_lock: tokio::sync::Mutex<()>, #[cfg(not(mobile))] + remote_server_starting: AtomicU64, + #[cfg(not(mobile))] remote_pin: Mutex>, #[cfg(not(mobile))] remote_locale: Mutex, @@ -1218,6 +1226,16 @@ struct Inner { less_computer_conversation: AtomicBool, } +#[cfg(not(mobile))] +fn clear_remote_server_starting(inner: &Inner, generation: u64) { + let _ = inner.remote_server_starting.compare_exchange( + generation, + 0, + Ordering::AcqRel, + Ordering::Acquire, + ); +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ActionHotkeyKind { SwitchStyle, @@ -1484,12 +1502,18 @@ impl Coordinator { #[cfg(not(mobile))] remote_audio_sink: Mutex::new(None), #[cfg(not(mobile))] + remote_pcm_bridge: Mutex::new(None), + #[cfg(not(mobile))] remote_server: Mutex::new(None), #[cfg(not(mobile))] remote_refresh_gen: AtomicU64::new(0), #[cfg(not(mobile))] + remote_refresh_generation_lock: Mutex::new(()), + #[cfg(not(mobile))] remote_refresh_lock: tokio::sync::Mutex::new(()), #[cfg(not(mobile))] + remote_server_starting: AtomicU64::new(0), + #[cfg(not(mobile))] remote_pin: Mutex::new(None), #[cfg(not(mobile))] remote_locale: Mutex::new(String::from("zh-CN")), @@ -1626,12 +1650,18 @@ impl Coordinator { #[cfg(not(mobile))] remote_audio_sink: Mutex::new(None), #[cfg(not(mobile))] + remote_pcm_bridge: Mutex::new(None), + #[cfg(not(mobile))] remote_server: Mutex::new(None), #[cfg(not(mobile))] remote_refresh_gen: AtomicU64::new(0), #[cfg(not(mobile))] + remote_refresh_generation_lock: Mutex::new(()), + #[cfg(not(mobile))] remote_refresh_lock: tokio::sync::Mutex::new(()), #[cfg(not(mobile))] + remote_server_starting: AtomicU64::new(0), + #[cfg(not(mobile))] remote_pin: Mutex::new(None), #[cfg(not(mobile))] remote_locale: Mutex::new(String::from("zh-CN")), @@ -2544,7 +2574,7 @@ impl Coordinator { #[cfg(not(mobile))] pub async fn start_remote_dictation(&self) -> Result<(), String> { - begin_session(&self.inner).await + begin_session_as(&self.inner, false, true).await } #[cfg(not(mobile))] @@ -2559,6 +2589,7 @@ impl Coordinator { } } + #[cfg(not(mobile))] pub async fn stop_remote_dictation(&self) -> Result<(), String> { if self.inner.state.lock().phase == SessionPhase::Starting { @@ -2570,8 +2601,9 @@ impl Coordinator { #[cfg(not(mobile))] pub fn cancel_remote_dictation(&self) { + let session_id = self.inner.state.lock().session_id; cancel_session(&self.inner); - *self.inner.remote_audio_sink.lock() = None; + clear_remote_mic_path(&self.inner, session_id); } #[cfg(not(mobile))] @@ -2584,16 +2616,17 @@ impl Coordinator { .map(|h| h.bound_port) .unwrap_or(prefs.remote_input_port); let pin = self.inner.remote_pin.lock().clone().unwrap_or_default(); - let urls = if running { - crate::remote_server::access_urls(port) - } else { - Vec::new() - }; + let urls = handle.as_ref().map(|h| h.urls.clone()).unwrap_or_default(); + let urls_stale = handle.as_ref().map(|h| h.urls_stale).unwrap_or(false); + let generation = self.inner.remote_refresh_gen.load(Ordering::Acquire); + let starting_generation = self.inner.remote_server_starting.load(Ordering::Acquire); crate::remote_server::RemoteInputStatus { running, + starting: generation != 0 && starting_generation == generation, port, pin, urls, + urls_stale, } } @@ -2632,8 +2665,19 @@ impl Coordinator { #[cfg(not(mobile))] pub fn refresh_remote_server(self: &Arc) { + log::info!("[remote-input] scheduling refresh"); + let gen = { + // Serialise generation publication with handle installation. This closes the + // race where an obsolete start could pass its generation check just before a + // newer refresh publishes its generation and then overwrite the new handle. + let _generation_guard = self.inner.remote_refresh_generation_lock.lock(); + let gen = self.inner.remote_refresh_gen.fetch_add(1, Ordering::SeqCst) + 1; + self.inner + .remote_server_starting + .store(gen, Ordering::Release); + gen + }; let coord = Arc::clone(self); - let gen = self.inner.remote_refresh_gen.fetch_add(1, Ordering::SeqCst) + 1; tauri::async_runtime::spawn(async move { let _serial = coord.inner.remote_refresh_lock.lock().await; if coord.inner.remote_refresh_gen.load(Ordering::SeqCst) != gen { @@ -2643,61 +2687,152 @@ impl Coordinator { if let Some(handle) = old { handle.shutdown().await; } + if coord.inner.remote_refresh_gen.load(Ordering::SeqCst) != gen { + return; + } let prefs = coord.inner.prefs.get(); let app = coord.inner.app.lock().clone(); + log::info!( + "[remote-input] refresh begin enabled={} port={} app={}", + prefs.remote_input_enabled, + prefs.remote_input_port, + app.is_some() + ); if !prefs.remote_input_enabled { + clear_remote_server_starting(&coord.inner, gen); if let Some(app) = &app { let _ = app.emit( "remote-input:running", - serde_json::json!({"running": false}), + serde_json::json!({ + "running": false, + "starting": false, + "port": prefs.remote_input_port, + "urls": [], + "urlsStale": false + }), ); } return; } let Some(app) = app else { + clear_remote_server_starting(&coord.inner, gen); return; }; - let pin = if let Some(pin) = coord.inner.remote_pin.lock().clone() { - pin - } else { - match crate::remote_server::load_or_create_pin(&app) { - Ok(pin) => { - *coord.inner.remote_pin.lock() = Some(pin.clone()); - pin + let existing_pin = coord.inner.remote_pin.lock().clone(); + let pin_app = app.clone(); + log::info!("[remote-input] loading pin"); + let pin = match tauri::async_runtime::spawn_blocking(move || { + if let Some(pin) = existing_pin { + return Ok(pin); + } + crate::remote_server::load_or_create_pin(&pin_app) + }) + .await + { + Ok(Ok(pin)) => { + if coord.inner.remote_refresh_gen.load(Ordering::SeqCst) != gen { + return; } - Err(error) => { - let reason = format!("persist pairing PIN failed: {error}"); - let _ = app.emit( - "remote-input:error", - serde_json::json!({"reason": reason, "port": prefs.remote_input_port}), - ); - log::error!("[remote-input] {reason}"); + *coord.inner.remote_pin.lock() = Some(pin.clone()); + pin + } + Ok(Err(error)) => { + if coord.inner.remote_refresh_gen.load(Ordering::SeqCst) != gen { return; } + clear_remote_server_starting(&coord.inner, gen); + let reason = format!("persist pairing PIN failed: {error}"); + let _ = app.emit( + "remote-input:error", + serde_json::json!({ + "reason": reason, + "port": prefs.remote_input_port, + "starting": false, + "urls": [], + "urlsStale": false + }), + ); + log::error!("[remote-input] {reason}"); + return; + } + Err(error) => { + if coord.inner.remote_refresh_gen.load(Ordering::SeqCst) != gen { + return; + } + clear_remote_server_starting(&coord.inner, gen); + let reason = format!("pin worker failed: {error}"); + let _ = app.emit( + "remote-input:error", + serde_json::json!({ + "reason": reason, + "port": prefs.remote_input_port, + "starting": false, + "urls": [], + "urlsStale": false + }), + ); + log::error!("[remote-input] {reason}"); + return; } }; + log::info!("[remote-input] pin ready"); let port = prefs.remote_input_port; - match crate::remote_server::start(crate::remote_server::RemoteServerConfig { + let result = crate::remote_server::start(crate::remote_server::RemoteServerConfig { port, pin: pin.clone(), coordinator: Arc::clone(&coord), app: app.clone(), }) - .await - { + .await; + if coord.inner.remote_refresh_gen.load(Ordering::SeqCst) != gen { + if let Ok(handle) = result { + handle.shutdown().await; + } + return; + } + match result { Ok(handle) => { - let urls = crate::remote_server::access_urls(port); - *coord.inner.remote_server.lock() = Some(handle); - let _ = app.emit( - "remote-input:running", - serde_json::json!({"running": true, "port": port, "urls": urls, "pin": pin}), - ); - log::info!("[remote-input] server started on port {port}"); + let bound_port = handle.bound_port; + let urls = handle.urls.clone(); + let urls_stale = handle.urls_stale; + let stale_handle = { + let _generation_guard = coord.inner.remote_refresh_generation_lock.lock(); + if coord.inner.remote_refresh_gen.load(Ordering::SeqCst) != gen { + Some(handle) + } else { + *coord.inner.remote_server.lock() = Some(handle); + clear_remote_server_starting(&coord.inner, gen); + let _ = app.emit( + "remote-input:running", + serde_json::json!({ + "running": true, + "starting": false, + "port": bound_port, + "urls": urls, + "urlsStale": urls_stale, + "pin": pin + }), + ); + None + } + }; + if let Some(handle) = stale_handle { + handle.shutdown().await; + return; + } + log::info!("[remote-input] server started on port {bound_port}"); } Err(e) => { + clear_remote_server_starting(&coord.inner, gen); let _ = app.emit( "remote-input:error", - serde_json::json!({"reason": e, "port": port}), + serde_json::json!({ + "reason": e, + "port": port, + "starting": false, + "urls": [], + "urlsStale": false + }), ); log::error!("[remote-input] server start failed: {e}"); } @@ -6126,9 +6261,21 @@ fn schedule_selection_polish_capsule_idle(inner: &Arc, event_epoch: u64, }); } +#[cfg(not(mobile))] +fn clear_remote_mic_path(inner: &Inner, session_id: SessionId) { + if inner.state.lock().session_id != session_id { + log::info!( + "[coord] skip stale remote mic cleanup for session {session_id}" + ); + return; + } + *inner.remote_audio_sink.lock() = None; + *inner.remote_pcm_bridge.lock() = None; +} + // ─────────────────────────── audio bridge ─────────────────────────── -struct DeferredAsrBridge { +pub(super) struct DeferredAsrBridge { state: Mutex, } @@ -6139,7 +6286,7 @@ struct DeferredAsrState { } impl DeferredAsrBridge { - fn new() -> Self { + pub(super) fn new() -> Self { Self { state: Mutex::new(DeferredAsrState { target: None, diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 44f55d58..eda814b3 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1,4 +1,4 @@ -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use crate::coordinator_state::{ @@ -2114,12 +2114,17 @@ pub(super) fn request_stop_during_starting(inner: &Arc, reason: &str) { } pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> { - begin_session_as(inner, false).await + begin_session_as(inner, false, false).await } /// begin_session 的带参版本,voice_agent=true 时在 Starting 阶段就标记好, /// 防止 finish_starting_session 处理 pending_stop 时丢失标志。 -pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> Result<(), String> { +/// `remote=true` 时用手机推来的 PCM,不打开电脑麦克风。 +pub(super) async fn begin_session_as( + inner: &Arc, + voice_agent: bool, + remote: bool, +) -> Result<(), String> { #[cfg(all(not(mobile), target_os = "windows"))] if super::selection_voice_session::selection_voice_blocks_other_recording(inner) { log::info!("[coord] dictation blocked: selection voice session active"); @@ -2140,6 +2145,13 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R } session_id }; + #[cfg(not(mobile))] + if remote { + let bridge = Arc::new(super::DeferredAsrBridge::new()); + *inner.remote_pcm_bridge.lock() = Some(Arc::clone(&bridge)); + *inner.remote_audio_sink.lock() = Some(bridge); + log::info!("[coord] remote mic sink armed (phone PCM, local mic skipped)"); + } // 新一次听写开始 → 上一次的手改监听作废。用户已经不在改上一段了,继续盯着只会 // 把新的输入误判成对旧文本的修改。这是「必须保证解除」的四条规则之一。 // @@ -2192,25 +2204,32 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R ); restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; + #[cfg(not(mobile))] + super::clear_remote_mic_path(inner, current_session_id); return Err(message); } - if let Err(message) = ensure_microphone_permission(inner) { - log::warn!("[coord] omni microphone permission gate failed: {message}"); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - 0, - Some(message.clone()), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - return Err(message); + if !remote { + if let Err(message) = ensure_microphone_permission(inner) { + log::warn!("[coord] omni microphone permission gate failed: {message}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(message.clone()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + #[cfg(not(mobile))] + super::clear_remote_mic_path(inner, current_session_id); + return Err(message); + } } let consumer = PcmBufferConsumer::new(); store_omni_pcm_for_session(inner, current_session_id, Arc::clone(&consumer)); - start_recorder_and_enter_listening(inner, current_session_id, "omni", consumer).await?; + start_recorder_and_enter_listening(inner, current_session_id, "omni", consumer, remote) + .await?; return Ok(()); } @@ -2226,6 +2245,8 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R ); restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; + #[cfg(not(mobile))] + super::clear_remote_mic_path(inner, current_session_id); return Err(message); } @@ -2249,24 +2270,28 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + #[cfg(not(mobile))] + super::clear_remote_mic_path(inner, current_session_id); return Err(message); } }; - if let Err(message) = ensure_microphone_permission(inner) { - log::warn!("[coord] microphone permission gate failed: {message}"); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - 0, - Some(message.clone()), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err(message); + if !remote { + if let Err(message) = ensure_microphone_permission(inner) { + log::warn!("[coord] microphone permission gate failed: {message}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(message.clone()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(message); + } } // 不在这里 emit Recording capsule —— 让 start_recorder_for_starting 在 @@ -2300,7 +2325,7 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R AsrCallLabel::new(foundry::PROVIDER_ID, Some(model_alias)), ); let consumer: Arc = local; - start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer, remote) .await?; return Ok(()); } @@ -2352,6 +2377,8 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + #[cfg(not(mobile))] + super::clear_remote_mic_path(inner, current_session_id); return Err(format!("sherpa-onnx init failed: {e}")); } }; @@ -2362,7 +2389,7 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R AsrCallLabel::new(sherpa::PROVIDER_ID, Some(model_alias)), ); let consumer: Arc = local; - start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer, remote) .await?; return Ok(()); } @@ -2386,6 +2413,8 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + #[cfg(not(mobile))] + super::clear_remote_mic_path(inner, current_session_id); return Err(format!("local ASR init failed: {e}")); } }; @@ -2401,6 +2430,7 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R current_session_id, &active_asr, consumer, + remote, ) .await?; } @@ -2420,6 +2450,7 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R current_session_id, &active_asr, consumer, + remote, ) .await?; } @@ -2440,6 +2471,8 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + #[cfg(not(mobile))] + super::clear_remote_mic_path(inner, current_session_id); return Err(format!("local Whisper init failed: {error}")); } }; @@ -2455,6 +2488,7 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R current_session_id, &active_asr, consumer, + remote, ) .await?; } @@ -2493,7 +2527,8 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R ActiveAsr::Bailian(Arc::clone(&asr)), asr_call_label, ); - start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?; + start_recorder_for_starting(inner, current_session_id, &active_asr, consumer, remote) + .await?; if let Err(e) = asr.open_session().await { log::error!("[coord] open Bailian ASR session failed: {e}"); @@ -2569,7 +2604,8 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R ActiveAsr::Qwen3Realtime(Arc::clone(&asr)), asr_call_label, ); - start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?; + start_recorder_for_starting(inner, current_session_id, &active_asr, consumer, remote) + .await?; if let Err(e) = asr.open_session().await { log::error!("[coord] open Qwen3 realtime ASR session failed: {e}"); @@ -2651,7 +2687,8 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R ActiveAsr::StepfunRealtime(Arc::clone(&asr)), asr_call_label, ); - start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?; + start_recorder_for_starting(inner, current_session_id, &active_asr, consumer, remote) + .await?; if let Err(e) = asr.open_session().await { log::error!("[coord] open StepFun realtime ASR session failed: {e}"); @@ -2729,7 +2766,7 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R asr_call_label, ); let consumer: Arc = mimo; - start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer, remote) .await?; } else if is_dashscope_multimodal_provider(&effective_asr) { let (api_key, base_url, model) = read_dashscope_multimodal_credentials(); @@ -2742,7 +2779,7 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R asr_call_label, ); let consumer: Arc = asr; - start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer, remote) .await?; } else if is_elevenlabs_provider(&effective_asr) { let (api_key, base_url, model) = read_elevenlabs_credentials(); @@ -2755,7 +2792,7 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R asr_call_label, ); let consumer: Arc = asr; - start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer, remote) .await?; } else if is_whisper_compatible_provider(&effective_asr) { let (api_key, base_url, model) = read_whisper_credentials(); @@ -2790,7 +2827,7 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R asr_call_label, ); let consumer: Arc = whisper; - start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer, remote) .await?; } else if is_xfyun_provider(&effective_asr) { // 讯飞 RTASR 实时流式:与 Bailian / 火山同构(open_session → 录音 → end → final)。 @@ -2805,7 +2842,8 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R ActiveAsr::Xfyun(Arc::clone(&asr)), asr_call_label, ); - start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?; + start_recorder_for_starting(inner, current_session_id, &active_asr, consumer, remote) + .await?; if let Err(e) = asr.open_session().await { log::error!("[coord] open iFlytek ASR session failed: {e}"); @@ -2886,7 +2924,8 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R ActiveAsr::Volcengine(Arc::clone(&asr)), asr_call_label, ); - start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?; + start_recorder_for_starting(inner, current_session_id, &active_asr, consumer, remote) + .await?; if let Err(e) = asr.open_session().await { log::error!("[coord] open ASR session failed: {e}"); @@ -2955,12 +2994,99 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R Ok(()) } +#[cfg(not(mobile))] +fn arm_remote_microphone( + inner: &Arc, + session_id: SessionId, + active_asr: &str, + consumer: Arc, + level_handler: Arc, +) -> Result<(), String> { + inner + .audio_archive_active + .store(false, std::sync::atomic::Ordering::Relaxed); + let fanout = Arc::new(RemoteMicFanout { + consumer, + level_handler, + frames: AtomicUsize::new(0), + peak_rms_milli: AtomicUsize::new(0), + }); + if let Some(bridge) = inner.remote_pcm_bridge.lock().clone() { + let flushed = bridge.attach(fanout); + log::info!( + "[coord] remote mic attached (asr={active_asr}, session={session_id}, flushed={flushed} bytes)" + ); + } else { + *inner.remote_audio_sink.lock() = Some(fanout); + log::info!("[coord] remote mic sink set (asr={active_asr}, session={session_id})"); + } + stop_recorder_if_pending_start_stop(inner); + Ok(()) +} + +struct RemoteMicFanout { + consumer: Arc, + level_handler: Arc, + frames: AtomicUsize, + peak_rms_milli: AtomicUsize, +} + +fn pcm_i16_le_rms(pcm: &[u8]) -> f32 { + let mut sum = 0.0f32; + let mut n = 0u32; + for chunk in pcm.chunks_exact(2) { + let sample = i16::from_le_bytes([chunk[0], chunk[1]]) as f32 / 32768.0; + sum += sample * sample; + n += 1; + } + if n == 0 { + return 0.0; + } + (sum / n as f32).sqrt() +} + +impl RemoteMicFanout { + fn consume(&self, pcm: &[u8]) { + let rms = pcm_i16_le_rms(pcm); + let level = (rms * 4.0).clamp(0.0, 1.0); + self.consumer.consume_pcm_chunk(pcm); + (self.level_handler)(level); + let count = self.frames.fetch_add(1, Ordering::Relaxed) + 1; + let milli = (rms * 1000.0) as usize; + self.peak_rms_milli.fetch_max(milli, Ordering::Relaxed); + if count == 1 || count % 50 == 0 { + let peak = self.peak_rms_milli.load(Ordering::Relaxed) as f32 / 1000.0; + log::info!( + "[coord] remote mic cb#{count} bytes={} rms={:.5} peak={:.5}", + pcm.len(), + rms, + peak + ); + } + } +} + +impl crate::recorder::AudioConsumer for RemoteMicFanout { + fn consume_pcm_chunk(&self, pcm: &[u8]) { + self.consume(pcm); + } +} + +impl crate::asr::AudioConsumer for RemoteMicFanout { + fn consume_pcm_chunk(&self, pcm: &[u8]) { + self.consume(pcm); + } +} + pub(super) async fn start_recorder_for_starting( inner: &Arc, session_id: SessionId, active_asr: &str, consumer: Arc, + remote: bool, ) -> Result<(), String> { + #[cfg(mobile)] + let _ = remote; let inner_for_level = Arc::clone(inner); // ── Toggle 模式「说完自动停止」(issue #860)────────────────────────── // 仅在开关开启且当前热键模式为 Toggle 时启用;默认关闭,行为与旧版一致。 @@ -3067,6 +3193,11 @@ pub(super) async fn start_recorder_for_starting( ); }); + #[cfg(not(mobile))] + if remote { + return arm_remote_microphone(inner, session_id, active_asr, consumer, level_handler); + } + let microphone_device_name = selected_microphone_device_name(inner); stop_microphone_preview_monitor(inner, "dictation recorder"); acquire_recording_mute(inner, "dictation").await; @@ -3201,8 +3332,9 @@ pub(super) async fn start_recorder_and_enter_listening( session_id: SessionId, active_asr: &str, consumer: Arc, + remote: bool, ) -> Result<(), String> { - start_recorder_for_starting(inner, session_id, active_asr, consumer).await?; + start_recorder_for_starting(inner, session_id, active_asr, consumer, remote).await?; finish_starting_session(inner, session_id).await; Ok(()) } @@ -3694,6 +3826,8 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { rec.stop(); release_recording_mute(inner, "dictation"); } + #[cfg(not(mobile))] + super::clear_remote_mic_path(inner, current_session_id); // 多模态(Omni)模式:不走 ASR 转写 + LLM 润色,录音 PCM 直接编码 WAV, // 一次调用出最终文本(issue #902)。两套配置隔离,缺 omni 配置时明确报错。 @@ -5339,6 +5473,8 @@ pub(super) fn cancel_session(inner: &Arc) -> bool { stop_recorder_for_session(inner, decision.session_id); cancel_asr_for_session(inner, decision.session_id); + #[cfg(not(mobile))] + super::clear_remote_mic_path(inner, decision.session_id); restore_prepared_windows_ime_session(inner, decision.session_id); true } @@ -5400,7 +5536,7 @@ mod tests { append_typed_prefix, batch_asr_chunk_limit_ms, build_transcribe_failed_session, coding_agent_mode_from_pref, default_done_message, drain_streaming_insert_deltas_with, eligible_polish_context_turns, finalize_polished_text, flush_streaming_insert_buffer_with, - insert_delivery_failed, pcm_duration_ms, pcm_from_wav_bytes, + insert_delivery_failed, pcm_duration_ms, pcm_from_wav_bytes, pcm_i16_le_rms, resolve_less_computer_run_outcome, resolve_macos_newline_mode, retry_error_outcome, should_arm_edit_watch, should_attempt_silent_retry, should_read_cursor_context, streaming_insert_eligible, SilentRetryOutcome, @@ -6273,4 +6409,12 @@ mod tests { fn platform_type_error() -> crate::unicode_keystroke::TypeError { crate::unicode_keystroke::TypeError::Unavailable } + + #[test] + fn pcm_i16_le_rms_silence_is_zero_and_speech_is_not() { + assert_eq!(pcm_i16_le_rms(&[]), 0.0); + assert_eq!(pcm_i16_le_rms(&[0, 0, 0, 0]), 0.0); + let loud = i16::MAX.to_le_bytes(); + assert!(pcm_i16_le_rms(&loud) > 0.9); + } } diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 96089101..0ff0d271 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -728,7 +728,7 @@ pub(super) async fn handle_less_computer_pressed(inner: &Arc) { // voice_agent=true 在 Starting 阶段就写入 state,防止 finish_starting_session // 处理 pending_stop 时(快速松手 race)丢失标志,导致意外走普通听写路径。 - if begin_session_as(inner, true).await.is_err() { + if begin_session_as(inner, true, false).await.is_err() { return; } let started = { diff --git a/openless-all/app/src-tauri/src/coordinator/resources.rs b/openless-all/app/src-tauri/src/coordinator/resources.rs index 4ceeaada..01148a9c 100644 --- a/openless-all/app/src-tauri/src/coordinator/resources.rs +++ b/openless-all/app/src-tauri/src/coordinator/resources.rs @@ -415,6 +415,8 @@ pub(super) fn stop_recorder_for_session(inner: &Arc, session_id: SessionI pub(super) fn discard_startup_resources_for_session(inner: &Arc, session_id: SessionId) { stop_recorder_for_session(inner, session_id); cancel_asr_for_session(inner, session_id); + #[cfg(not(mobile))] + super::clear_remote_mic_path(inner, session_id); } pub(super) fn stop_recorder_if_pending_start_stop(inner: &Arc) { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 096f12b8..5158ccbb 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -837,6 +837,12 @@ fn run_desktop() { coordinator.start_switch_style_hotkey_listener(); coordinator.start_open_app_hotkey_listener(); coordinator.start_style_pack_hotkey_listeners(); + // 远程输入只在 prefs 变化时 refresh;启动时若开关已开也要拉起, + // 否则重启后界面显示「已启用」但 8443 没在听。 + // 放到 Ready:setup() 里 spawn 的异步任务在 Windows 上可能还没 + // 跑到 runtime 就开始被丢掉,表现为开关开着、端口没在听。 + #[cfg(not(mobile))] + coordinator.refresh_remote_server(); } #[cfg(target_os = "macos")] RunEvent::Reopen { .. } => show_main_window(app), diff --git a/openless-all/app/src-tauri/src/remote_server/lan_addresses.rs b/openless-all/app/src-tauri/src/remote_server/lan_addresses.rs new file mode 100644 index 00000000..23370a30 --- /dev/null +++ b/openless-all/app/src-tauri/src/remote_server/lan_addresses.rs @@ -0,0 +1,404 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use uuid::Uuid; + +const CACHE_FILE: &str = "remote-input-ips-v1.txt"; +const MAX_CACHE_BYTES: u64 = 1024; +const MAX_CACHE_IPS: usize = 32; +const ENUMERATION_TIMEOUT: Duration = Duration::from_secs(1); +const ROUTE_DESTINATIONS: [&str; 4] = [ + "8.8.8.8:80", + "1.1.1.1:80", + "192.168.8.1:80", + "192.168.1.1:80", +]; + +static ENUMERATION_IN_FLIGHT: AtomicBool = AtomicBool::new(false); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LanAddressSnapshot { + pub(crate) ips: Vec, + pub(crate) stale: bool, +} + +pub(crate) fn normalize_lan_ips(ips: I) -> Vec +where + I: IntoIterator, +{ + let mut normalized = ips.into_iter().filter(is_private_lan).collect::>(); + normalized.sort_unstable(); + normalized.dedup(); + normalized.truncate(MAX_CACHE_IPS); + normalized +} + +pub(crate) fn discover_lan_addresses(config_dir: Option<&Path>) -> LanAddressSnapshot { + let live_ips = enumerate_interfaces_with_timeout(); + if let Some(ips) = live_ips.as_ref().filter(|ips| !ips.is_empty()) { + if let Some(dir) = config_dir { + if let Err(error) = persist_cached_ips(dir, ips) { + log::warn!("[remote-input] persist LAN IP cache failed: {error}"); + } + } + } + let cached_ips = if live_ips.as_ref().is_some_and(|ips| !ips.is_empty()) { + None + } else { + config_dir.and_then(load_cached_ips) + }; + let route_ips = if live_ips.as_ref().is_some_and(|ips| !ips.is_empty()) + || cached_ips.as_ref().is_some_and(|ips| !ips.is_empty()) + { + Vec::new() + } else { + local_lan_ipv4s_from_route() + }; + let snapshot = choose_snapshot(live_ips, cached_ips, route_ips); + if snapshot.stale { + log::info!("[remote-input] using cached LAN IPs after interface discovery timeout"); + } + snapshot +} + +fn choose_snapshot( + live_ips: Option>, + cached_ips: Option>, + route_ips: Vec, +) -> LanAddressSnapshot { + if let Some(ips) = live_ips.filter(|ips| !ips.is_empty()) { + return LanAddressSnapshot { ips, stale: false }; + } + if let Some(ips) = cached_ips.filter(|ips| !ips.is_empty()) { + return LanAddressSnapshot { ips, stale: true }; + } + LanAddressSnapshot { + ips: normalize_lan_ips(route_ips), + stale: false, + } +} + +fn is_private_lan(ip: &Ipv4Addr) -> bool { + let octets = ip.octets(); + !ip.is_loopback() + && !ip.is_link_local() + && ((octets[0] == 192 && octets[1] == 168) + || octets[0] == 10 + || (octets[0] == 172 && (16..=31).contains(&octets[1]))) +} + +fn enumerate_interfaces_with_timeout() -> Option> { + if ENUMERATION_IN_FLIGHT + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return None; + } + + let (tx, rx) = mpsc::sync_channel(1); + let worker = thread::Builder::new() + .name("openless-lan-addresses".to_string()) + .spawn(move || { + struct ResetInFlight; + + impl Drop for ResetInFlight { + fn drop(&mut self) { + ENUMERATION_IN_FLIGHT.store(false, Ordering::Release); + } + } + + let _reset = ResetInFlight; + let result = enumerate_interfaces(); + let _ = tx.send(result); + }); + + if worker.is_err() { + ENUMERATION_IN_FLIGHT.store(false, Ordering::Release); + return None; + } + + match rx.recv_timeout(ENUMERATION_TIMEOUT) { + Ok(Ok(ips)) if !ips.is_empty() => Some(ips), + Ok(Ok(_)) | Ok(Err(_)) | Err(_) => None, + } +} + +fn enumerate_interfaces() -> Result, String> { + let ifaces = local_ip_address::list_afinet_netifas().map_err(|error| error.to_string())?; + let ips = ifaces.into_iter().filter_map(|(_, ip)| match ip { + IpAddr::V4(ip) => Some(ip), + IpAddr::V6(_) => None, + }); + Ok(normalize_lan_ips(ips)) +} + +fn local_lan_ipv4s_from_route() -> Vec { + let mut ips = Vec::new(); + for destination in ROUTE_DESTINATIONS { + let Ok(socket) = UdpSocket::bind("0.0.0.0:0") else { + continue; + }; + let _ = socket.set_write_timeout(Some(Duration::from_millis(200))); + if socket.connect(destination).is_err() { + continue; + } + if let Ok(SocketAddr::V4(address)) = socket.local_addr() { + ips.push(*address.ip()); + } + } + normalize_lan_ips(ips) +} + +fn cache_path(config_dir: &Path) -> std::path::PathBuf { + config_dir.join(CACHE_FILE) +} + +fn load_cached_ips(config_dir: &Path) -> Option> { + let path = cache_path(config_dir); + let mut file = File::open(path).ok()?; + let mut contents = Vec::new(); + std::io::Read::by_ref(&mut file) + .take(MAX_CACHE_BYTES + 1) + .read_to_end(&mut contents) + .ok()?; + if contents.len() as u64 > MAX_CACHE_BYTES { + return None; + } + parse_cached_ips(&contents).ok() +} + +fn parse_cached_ips(contents: &[u8]) -> Result, String> { + let text = std::str::from_utf8(contents).map_err(|_| "cache is not UTF-8".to_string())?; + let mut ips = Vec::new(); + for line in text.lines() { + if line.trim().is_empty() { + return Err("cache contains an empty line".to_string()); + } + if ips.len() >= MAX_CACHE_IPS { + return Err("cache contains too many addresses".to_string()); + } + let ip = line + .trim() + .parse::() + .map_err(|_| "cache contains an invalid IPv4 address".to_string())?; + if !is_private_lan(&ip) { + return Err("cache contains a non-private IPv4 address".to_string()); + } + ips.push(ip); + } + let normalized = normalize_lan_ips(ips); + if normalized.is_empty() { + return Err("cache contains no LAN addresses".to_string()); + } + Ok(normalized) +} + +fn persist_cached_ips(config_dir: &Path, ips: &[Ipv4Addr]) -> io::Result<()> { + fs::create_dir_all(config_dir)?; + let path = cache_path(config_dir); + let temp_path = path.with_file_name(format!(".{CACHE_FILE}.tmp-{}", Uuid::new_v4().simple())); + let result = (|| { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&temp_path)?; + for ip in ips { + writeln!(file, "{ip}")?; + } + file.sync_all()?; + drop(file); + replace_cache_file(&temp_path, &path) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp_path); + } + result +} + +#[cfg(unix)] +fn replace_cache_file(temp_path: &Path, path: &Path) -> io::Result<()> { + fs::rename(temp_path, path) +} + +#[cfg(target_os = "windows")] +fn replace_cache_file(temp_path: &Path, path: &Path) -> io::Result<()> { + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::{ + MoveFileExW, ReplaceFileW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + REPLACE_FILE_FLAGS, + }; + + let replacement = windows_wide_path(temp_path); + let destination = windows_wide_path(path); + if path.exists() { + let backup = + path.with_file_name(format!(".{CACHE_FILE}.backup-{}", Uuid::new_v4().simple())); + let backup_wide = windows_wide_path(&backup); + let replaced = unsafe { + ReplaceFileW( + PCWSTR(destination.as_ptr()), + PCWSTR(replacement.as_ptr()), + PCWSTR(backup_wide.as_ptr()), + REPLACE_FILE_FLAGS(0), + None, + None, + ) + }; + replaced.map_err(windows_io_error)?; + let _ = fs::remove_file(backup); + Ok(()) + } else { + unsafe { + MoveFileExW( + PCWSTR(replacement.as_ptr()), + PCWSTR(destination.as_ptr()), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } + .map_err(windows_io_error) + } +} + +#[cfg(not(any(unix, target_os = "windows")))] +fn replace_cache_file(temp_path: &Path, path: &Path) -> io::Result<()> { + fs::rename(temp_path, path) +} + +#[cfg(target_os = "windows")] +fn windows_wide_path(path: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + + path.as_os_str().encode_wide().chain(Some(0)).collect() +} + +#[cfg(target_os = "windows")] +fn windows_io_error(error: windows::core::Error) -> io::Error { + io::Error::new(io::ErrorKind::Other, error) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_lan_ips_filters_private_addresses_and_deduplicates() { + let actual = normalize_lan_ips([ + Ipv4Addr::new(192, 168, 1, 20), + Ipv4Addr::new(8, 8, 8, 8), + Ipv4Addr::new(10, 0, 0, 2), + Ipv4Addr::new(192, 168, 1, 20), + Ipv4Addr::new(172, 16, 4, 9), + Ipv4Addr::new(127, 0, 0, 1), + ]); + + assert_eq!( + actual, + vec![ + Ipv4Addr::new(10, 0, 0, 2), + Ipv4Addr::new(172, 16, 4, 9), + Ipv4Addr::new(192, 168, 1, 20), + ] + ); + } + + #[test] + fn parse_cached_ips_rejects_public_or_malformed_entries() { + assert!(parse_cached_ips(b"8.8.8.8\n").is_err()); + assert!(parse_cached_ips(b"not-an-ip\n").is_err()); + assert!(parse_cached_ips(b"10.0.0.2\n\n10.0.0.3\n").is_err()); + } + + #[test] + fn parse_cached_ips_accepts_bounded_private_entries() { + assert_eq!( + parse_cached_ips(b"192.168.1.20\n10.0.0.2\n192.168.1.20\n").unwrap(), + vec![Ipv4Addr::new(10, 0, 0, 2), Ipv4Addr::new(192, 168, 1, 20)] + ); + } + + #[test] + fn choose_snapshot_prefers_live_then_cache_then_route() { + let live = vec![Ipv4Addr::new(10, 0, 0, 2)]; + let cached = vec![Ipv4Addr::new(192, 168, 1, 20)]; + let route = vec![Ipv4Addr::new(172, 16, 0, 3)]; + + assert_eq!( + choose_snapshot(Some(live.clone()), Some(cached.clone()), route.clone()), + LanAddressSnapshot { + ips: live, + stale: false, + } + ); + assert_eq!( + choose_snapshot(None, Some(cached.clone()), route.clone()), + LanAddressSnapshot { + ips: cached, + stale: true, + } + ); + assert_eq!( + choose_snapshot(None, None, route.clone()), + LanAddressSnapshot { + ips: route, + stale: false, + } + ); + assert_eq!( + choose_snapshot(None, None, Vec::new()), + LanAddressSnapshot { + ips: Vec::new(), + stale: false, + } + ); + } + + #[test] + fn cache_rejects_more_than_the_maximum_number_of_entries() { + let contents = (0..=MAX_CACHE_IPS) + .map(|_| "10.0.0.2") + .collect::>() + .join("\n"); + assert!(parse_cached_ips(contents.as_bytes()).is_err()); + } + + #[test] + fn oversized_cache_file_is_rejected_before_parsing() { + let directory = std::env::temp_dir().join(format!( + "openless-lan-addresses-oversized-{}", + Uuid::new_v4().simple() + )); + fs::create_dir_all(&directory).unwrap(); + fs::write( + cache_path(&directory), + vec![b'1'; (MAX_CACHE_BYTES + 1) as usize], + ) + .unwrap(); + + assert_eq!(load_cached_ips(&directory), None); + let _ = fs::remove_dir_all(directory); + } + + #[test] + fn cached_ips_round_trip_through_atomic_file() { + let directory = std::env::temp_dir().join(format!( + "openless-lan-addresses-test-{}", + Uuid::new_v4().simple() + )); + let ips = vec![Ipv4Addr::new(10, 0, 0, 2), Ipv4Addr::new(192, 168, 1, 20)]; + + persist_cached_ips(&directory, &ips).unwrap(); + + assert_eq!(load_cached_ips(&directory), Some(ips)); + let _ = fs::remove_dir_all(directory); + } +} diff --git a/openless-all/app/src-tauri/src/remote_server/mod.rs b/openless-all/app/src-tauri/src/remote_server/mod.rs index 1bc99734..5d0aed21 100644 --- a/openless-all/app/src-tauri/src/remote_server/mod.rs +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -30,6 +30,8 @@ use tokio_rustls::TlsAcceptor; use crate::coordinator::Coordinator; +mod lan_addresses; + mod assets { pub const INDEX_HTML: &str = include_str!("assets/index.html"); pub const APP_JS: &str = include_str!("assets/app.js"); @@ -84,6 +86,8 @@ pub struct RemoteServerHandle { pub bound_port: u16, #[allow(dead_code)] pub pin: String, + pub urls: Vec, + pub urls_stale: bool, } impl RemoteServerHandle { @@ -101,9 +105,11 @@ impl RemoteServerHandle { #[serde(rename_all = "camelCase")] pub struct RemoteInputStatus { pub running: bool, + pub starting: bool, pub port: u16, pub pin: String, pub urls: Vec, + pub urls_stale: bool, } // ───────────────────────── 工具函数 ───────────────────────── @@ -124,11 +130,20 @@ pub fn generate_pin() -> String { } } +fn app_config_dir(app: &AppHandle) -> Option { + // Windows 上 Tauri 的 path API 从 async runtime 调会和主线程互相等,卡住 + // 远程输入启动。标识符固定为 com.openless.app,直接拼 APPDATA 即可。 + #[cfg(target_os = "windows")] + { + if let Some(appdata) = std::env::var_os("APPDATA") { + return Some(std::path::PathBuf::from(appdata).join("com.openless.app")); + } + } + app.path().app_config_dir().ok() +} + fn pin_path(app: &AppHandle) -> Option { - app.path() - .app_config_dir() - .ok() - .map(|d| d.join("remote-input-pin.txt")) + app_config_dir(app).map(|d| d.join("remote-input-pin.txt")) } mod pin_persistence; @@ -155,36 +170,13 @@ pub fn save_pin(app: &AppHandle, pin: &str) -> std::io::Result<()> { pin_persistence::persist_pin_atomically(&path, pin) } -fn is_private_lan(ip: &Ipv4Addr) -> bool { - let o = ip.octets(); - !ip.is_loopback() - && !ip.is_link_local() - && ((o[0] == 192 && o[1] == 168) - || o[0] == 10 - || (o[0] == 172 && (16..=31).contains(&o[1]))) -} - -/// 本机所有局域网 IPv4(过滤回环 / link-local / 虚拟网卡的非私网段)。 -pub fn local_lan_ipv4s() -> Vec { - let mut out: Vec = Vec::new(); - if let Ok(ifaces) = local_ip_address::list_afinet_netifas() { - for (_name, ip) in ifaces { - if let IpAddr::V4(v4) = ip { - if is_private_lan(&v4) { - out.push(v4); - } - } - } - } - out.sort(); - out.dedup(); - out +pub(crate) fn discover_lan_addresses(app: &AppHandle) -> lan_addresses::LanAddressSnapshot { + lan_addresses::discover_lan_addresses(app_config_dir(app).as_deref()) } -/// 给前端展示的访问网址列表。 -pub fn access_urls(port: u16) -> Vec { - local_lan_ipv4s() - .iter() +/// 给前端展示的访问网址列表。地址必须来自已经完成的快照,避免状态查询再次探测网卡。 +pub fn access_urls(ips: &[Ipv4Addr], port: u16) -> Vec { + ips.iter() .map(|ip| format!("https://{ip}:{port}")) .collect() } @@ -433,13 +425,25 @@ async fn mobileconfig_handler(State(state): State>) -> impl IntoRes pub async fn start(cfg: RemoteServerConfig) -> Result { let _ = HEADER_HTML; // index 用 axum Html() 自带 content-type - let mut sans = vec!["localhost".to_string(), "127.0.0.1".to_string()]; - for ip in local_lan_ipv4s() { - sans.push(ip.to_string()); - } - // 证书目录用 app 配置目录(跨重启稳定);拿不到则退回内存生成(不持久化)。 - let cert_dir = cfg.app.path().app_config_dir().ok(); - let (cert_der, key_der) = load_or_generate_cert(cert_dir.as_deref(), &sans)?; + log::info!("[remote-input] starting server on port {}", cfg.port); + let app_for_cert = cfg.app.clone(); + let (cert_der, key_der, lan_snapshot) = tauri::async_runtime::spawn_blocking(move || { + let mut sans = vec!["localhost".to_string(), "127.0.0.1".to_string()]; + let lan_snapshot = discover_lan_addresses(&app_for_cert); + log::info!( + "[remote-input] lan ips for cert SAN: {:?} (stale={})", + lan_snapshot.ips, + lan_snapshot.stale + ); + for ip in &lan_snapshot.ips { + sans.push(ip.to_string()); + } + let cert_dir = app_config_dir(&app_for_cert); + load_or_generate_cert(cert_dir.as_deref(), &sans) + .map(|(cert, key)| (cert, key, lan_snapshot)) + }) + .await + .map_err(|e| format!("cert worker failed: {e}"))??; let rustls_config = build_server_config(cert_der.clone(), key_der)?; let acceptor = TlsAcceptor::from(rustls_config); @@ -452,6 +456,7 @@ pub async fn start(cfg: RemoteServerConfig) -> Result Result { return invokeOrMock("get_remote_input_status", undefined, () => ({ running: false, + starting: false, port: 8443, pin: "000000", urls: [], + urlsStale: false, })) } diff --git a/openless-all/app/src/pages/settings/RemoteInputSection.tsx b/openless-all/app/src/pages/settings/RemoteInputSection.tsx index 92d92505..e4336915 100644 --- a/openless-all/app/src/pages/settings/RemoteInputSection.tsx +++ b/openless-all/app/src/pages/settings/RemoteInputSection.tsx @@ -15,6 +15,7 @@ import { isTauri, type RemoteInputStatus, } from '../../lib/ipc'; +import { getRemoteInputViewState } from './remoteInputViewState'; async function copyText(text: string): Promise { try { @@ -52,36 +53,44 @@ export function RemoteInputSection() { getRemoteInputStatus() .then((s) => alive && setStatus(s)) .catch(() => {}); - refresh(); - // 进设置页时把当前界面语言同步给远程服务,确保 H5 录音页语言与 PC 一致。 - void setRemoteLocale(i18n.language).catch(() => {}); - if (!isTauri) return; const unsubs: Array<() => void> = []; - import('@tauri-apps/api/event').then(({ listen }) => { - listen('remote-input:running', () => { - if (!alive) return; - setStartError(null); - refresh(); - }).then((u) => { - // 异步注册完成时组件可能已卸载,立即退订避免监听器泄漏。 - if (!alive) { - u(); - } else { - unsubs.push(u); - } - }); - listen('remote-input:error', (e) => { - if (!alive) return; - const p = e.payload as { reason?: string; port?: number } | null; - setStartError({ reason: p?.reason ?? '', port: p?.port ?? 0 }); - }).then((u) => { - if (!alive) { - u(); - } else { - unsubs.push(u); + const registerAndRefresh = async () => { + try { + // 先注册事件,再查询快照,避免启动事件早于首次查询完成而被错过。 + if (isTauri) { + const { listen } = await import('@tauri-apps/api/event'); + if (!alive) return; + const runningUnsubscribe = await listen('remote-input:running', () => { + if (!alive) return; + setStartError(null); + refresh(); + }); + // 异步注册完成时组件可能已卸载,立即退订避免监听器泄漏。 + if (!alive) { + runningUnsubscribe(); + return; + } + unsubs.push(runningUnsubscribe); + + const errorUnsubscribe = await listen('remote-input:error', (e) => { + if (!alive) return; + const p = e.payload as { reason?: string; port?: number } | null; + setStartError({ reason: p?.reason ?? '', port: p?.port ?? 0 }); + refresh(); + }); + if (!alive) { + errorUnsubscribe(); + return; + } + unsubs.push(errorUnsubscribe); } - }); - }); + } finally { + if (alive) refresh(); + } + }; + void registerAndRefresh().catch(() => {}); + // 进设置页时把当前界面语言同步给远程服务,确保 H5 录音页语言与 PC 一致。 + void setRemoteLocale(i18n.language).catch(() => {}); return () => { alive = false; unsubs.forEach((u) => u()); @@ -91,6 +100,7 @@ export function RemoteInputSection() { if (!prefs) return null; const enabled = prefs.remoteInputEnabled; const mode = prefs.remoteInputDefaultMode ?? 'toggle'; + const viewState = getRemoteInputViewState(enabled, status, startError); // 提交端口草稿:非法(非有限数/越界离谱)则丢弃还原显示,合法则取整并 clamp 到 [1024, 65535]。 const commitPort = () => { @@ -180,46 +190,45 @@ export function RemoteInputSection() { - {enabled && status?.running && ( + {enabled && (viewState === 'running' || viewState === 'stale') && status && ( <> - -
- {(status.urls.length - ? status.urls - : [`https://localhost:${status.port}`] - ).map((u) => ( -
- - {u} - - -
- ))} -
-
+ + {u} + + + + ))} + + + )}
@@ -245,9 +254,29 @@ export function RemoteInputSection() {
+ {viewState === 'stale' && ( +
+ {t('settings.remoteInput.urlsStale')} +
+ )} )} + {enabled && viewState === 'starting' && ( +
+ {t('settings.remoteInput.starting')} +
+ )} + + {enabled && viewState === 'waiting' && ( +
+ {t( + 'settings.remoteInput.waitingStart', + '服务尚未启动。请关闭开关再打开一次,不要重启软件。', + )} +
+ )} + {enabled && startError != null && (
{startError.reason === 'port-in-use' diff --git a/openless-all/app/src/pages/settings/remoteInputViewState.test.ts b/openless-all/app/src/pages/settings/remoteInputViewState.test.ts new file mode 100644 index 00000000..e3e0f808 --- /dev/null +++ b/openless-all/app/src/pages/settings/remoteInputViewState.test.ts @@ -0,0 +1,27 @@ +import { getRemoteInputViewState } from './remoteInputViewState' + +function assertEqual(actual: unknown, expected: unknown): void { + if (actual !== expected) { + throw new Error(`expected ${String(expected)}, got ${String(actual)}`) + } +} + +const status = (overrides: Partial[1]> = {}) => ({ + running: false, + starting: false, + port: 8443, + pin: '000000', + urls: [], + urlsStale: false, + ...overrides, +}) + +assertEqual(getRemoteInputViewState(false, null, null), 'disabled') +assertEqual(getRemoteInputViewState(true, null, null), 'loading') +assertEqual(getRemoteInputViewState(true, status({ starting: true }), null), 'starting') +assertEqual(getRemoteInputViewState(true, status({ running: true }), null), 'running') +assertEqual(getRemoteInputViewState(true, status({ running: true, urlsStale: true }), null), 'stale') +assertEqual(getRemoteInputViewState(true, status(), null), 'waiting') +assertEqual(getRemoteInputViewState(true, status(), { reason: 'port-in-use', port: 8443 }), 'error') + +console.log('remote input view state tests passed') diff --git a/openless-all/app/src/pages/settings/remoteInputViewState.ts b/openless-all/app/src/pages/settings/remoteInputViewState.ts new file mode 100644 index 00000000..a4ebcc86 --- /dev/null +++ b/openless-all/app/src/pages/settings/remoteInputViewState.ts @@ -0,0 +1,23 @@ +import type { RemoteInputStatus } from '../../lib/ipc' + +export type RemoteInputViewState = + | 'disabled' + | 'loading' + | 'starting' + | 'running' + | 'stale' + | 'waiting' + | 'error' + +export function getRemoteInputViewState( + enabled: boolean, + status: RemoteInputStatus | null, + startError: { reason: string; port: number } | null, +): RemoteInputViewState { + if (!enabled) return 'disabled' + if (startError != null) return 'error' + if (status == null) return 'loading' + if (status.starting) return 'starting' + if (status.running) return status.urlsStale ? 'stale' : 'running' + return 'waiting' +}