diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 64099eaf..6c19b141 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -9,7 +9,7 @@ //! insertion, persists history, emits `capsule:state` events to the capsule //! window. -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc; use std::sync::Arc; use std::time::Instant; @@ -257,6 +257,12 @@ pub(crate) struct Inner { remote_audio_sink: Mutex>>, /// 远程输入 HTTPS+WS 服务句柄。None = 未启动。 remote_server: Mutex>, + /// refresh_remote_server 的代数:每次调用自增,spawn 出的任务持自己的代数, + /// 持锁后发现已有更新代排队则直接让位(连点开关/连改端口只跑最后一轮)。 + remote_refresh_gen: AtomicU64, + /// 串行化「停旧 → 启新」全流程的异步锁。无串行化时两轮 refresh 可交错: + /// 后到者 take 到 None 跳过关停、去 bind 旧服务尚未释放的端口 → 误报 port-in-use。 + remote_refresh_lock: tokio::sync::Mutex<()>, /// 当前远程输入配对码(6 位数字)。进程内有效,不持久化(每次启动可轮换)。 remote_pin: Mutex>, /// PC 端当前界面语言(BCP-47,如 "zh-CN")。前端切换语言时经命令同步, @@ -343,6 +349,8 @@ impl Coordinator { remote_source_active: AtomicBool::new(false), remote_audio_sink: Mutex::new(None), remote_server: Mutex::new(None), + remote_refresh_gen: AtomicU64::new(0), + remote_refresh_lock: tokio::sync::Mutex::new(()), remote_pin: Mutex::new(None), remote_locale: Mutex::new(String::from("zh-CN")), remote_no_insert: AtomicBool::new(false), @@ -417,6 +425,8 @@ impl Coordinator { remote_source_active: AtomicBool::new(false), remote_audio_sink: Mutex::new(None), remote_server: Mutex::new(None), + remote_refresh_gen: AtomicU64::new(0), + remote_refresh_lock: tokio::sync::Mutex::new(()), remote_pin: Mutex::new(None), remote_locale: Mutex::new(String::from("zh-CN")), remote_no_insert: AtomicBool::new(false), @@ -1004,15 +1014,16 @@ impl Coordinator { } pub async fn start_remote_dictation(&self) -> Result<(), String> { - if !matches!(self.inner.state.lock().phase, SessionPhase::Idle) { - return Err("busy".into()); - } - self.inner - .remote_source_active - .store(true, Ordering::SeqCst); - let r = begin_session(&self.inner).await; - if r.is_err() { - self.clear_remote_source(); + // busy 判定与 remote_source_active 置位都在 begin_session_with_source 的 + // state 临界区内原子完成(与本地热键的 begin_session_state 同构)。之前是 + // 锁外预检查 + 锁外置位,竞态输家会把残留标志泄给抢先启动的本地会话。 + let r = begin_session_with_source(&self.inner, true).await; + if let Err(e) = &r { + // busy = 标志从未置位,不能清——清了会破坏正在进行的远程会话 + // (手机重复点「开始」就会走到这里)。置位之后的失败(ASR 凭据等)才回滚。 + if e != REMOTE_BUSY { + self.clear_remote_source(); + } } r } @@ -1033,21 +1044,31 @@ impl Coordinator { } /// 手机点"停止"。Starting 阶段记 pending_stop(等启动完成自动收尾);否则走 - /// end_session(转写→润色→光标落字,与本地一致),随后清 remote 标志。 + /// end_session(转写→润色→光标落字,与本地一致)。 + /// 远程标志的清理不在这里做:end_session 内的 RemoteFlagsJanitor 在会话真正 + /// 回到 Idle 时统一清。这里清会在 double-stop(第二次调用对 Processing 中的 + /// 在飞 end_session 早退后)把标志过早清掉——在飞调用读到 false 后, + /// 「仅回传」开关失效(文字落到 PC)且 remote:result 不再回传手机。 pub async fn stop_remote_dictation(&self) -> Result<(), String> { + // 守卫:当前会话不是远程发起的则忽略。否则手机的 stop 会终止 PC 用户 + // 正在进行的本地听写(stop/cancel 方向没有 busy 那样的天然互斥)。 + if !self.inner.remote_source_active.load(Ordering::SeqCst) { + return Ok(()); + } if self.inner.state.lock().phase == SessionPhase::Starting { request_stop_during_starting(&self.inner, "remote stop"); return Ok(()); } - let r = end_session(&self.inner).await; - self.clear_remote_source(); - r + end_session(&self.inner).await } /// 手机断连 / 点取消:丢弃本次,不落字。 - /// stop 正常收尾后的断连也会走到这里(double-cancel):此时会话已 Idle, - /// cancel_session 内部 begin_cancel_session_state 返回 None 早退,安全 no-op。 + /// 手机锁屏/切后台/Wi-Fi 抖动都会触发 WS 断连进而走到这里——守卫确保只 + /// 取消远程发起的会话,不误杀 PC 用户正在进行的本地听写。 pub fn cancel_remote_dictation(&self) { + if !self.inner.remote_source_active.load(Ordering::SeqCst) { + return; + } cancel_session(&self.inner); self.clear_remote_source(); } @@ -1108,7 +1129,15 @@ impl Coordinator { /// 按 prefs 启停 / 重启远程输入服务。在 setup 与 prefs 变更(端口/开关)时调用。 pub fn refresh_remote_server(self: &Arc) { let coord = Arc::clone(self); + let gen = self.inner.remote_refresh_gen.fetch_add(1, Ordering::SeqCst) + 1; tauri::async_runtime::spawn(async move { + // 串行化整个「停旧 → 启新」:并发的两轮 refresh 交错时,后到者会 take 到 + // None 跳过关停、去 bind 旧服务还没释放的端口 → 误报 port-in-use。 + let _serial = coord.inner.remote_refresh_lock.lock().await; + // 已有更新代排队(用户连点开关/连改端口):本代直接让位,只跑最后一轮。 + if coord.inner.remote_refresh_gen.load(Ordering::SeqCst) != gen { + return; + } // 先停旧(优雅关停) let old = coord.inner.remote_server.lock().take(); if let Some(handle) = old { diff --git a/openless-all/app/src-tauri/src/coordinator/dictation_end.rs b/openless-all/app/src-tauri/src/coordinator/dictation_end.rs index e094af40..7becc51a 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation_end.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation_end.rs @@ -6,7 +6,27 @@ use crate::correction::apply_correction_rules; use super::resources::*; use super::*; +/// 远程标志清道夫。end_session 的终结路径有十余处(正常收尾、ASR 失败/超时、 +/// 空转写、cancel 丢弃……),任何一处漏清 remote_source_active 都会把下一次本地 +/// 听写错引到远程分支(跳过 cpal、永远等不到手机 PCM)。逐点补调用维护不动, +/// 改用 Drop 统一兜底:end_session 以任何方式退出时,若会话已回 Idle 则清远程 +/// 标志(本地会话下是 no-op)。phase 非 Idle 时不清——比如 double-stop 的第二次 +/// 调用对着 Processing 中的在飞 end_session 早退,此刻清会让在飞调用读到 false: +/// 「仅回传」开关失效、remote:result 不回传。 +struct RemoteFlagsJanitor<'a> { + inner: &'a Arc, +} + +impl Drop for RemoteFlagsJanitor<'_> { + fn drop(&mut self) { + if self.inner.state.lock().phase == SessionPhase::Idle { + clear_remote_source_flags(self.inner); + } + } +} + pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { + let _remote_janitor = RemoteFlagsJanitor { inner }; let current_session_id = { let mut state = inner.state.lock(); let Some(session_id) = start_processing_if_listening(&mut state) else { diff --git a/openless-all/app/src-tauri/src/coordinator/dictation_session.rs b/openless-all/app/src-tauri/src/coordinator/dictation_session.rs index 458f999f..c99ae3b1 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation_session.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation_session.rs @@ -18,13 +18,36 @@ pub(crate) fn request_stop_during_starting(inner: &Arc, reason: &str) { } pub(crate) async fn begin_session(inner: &Arc) -> Result<(), String> { + begin_session_with_source(inner, false).await +} + +/// 远程会话被拒(busy)时的错误值。remote_server 据此给手机回 busy 提示; +/// start_remote_dictation 据此区分「未置位无需回滚」与「置位后失败需回滚」。 +pub(crate) const REMOTE_BUSY: &str = "busy"; + +/// `remote=true`:busy 时返回 `Err(REMOTE_BUSY)`(手机需要回执,不能像本地热键 +/// 那样静默吞掉);并且 `remote_source_active` 的置位发生在 Idle→Starting 转移的 +/// **同一临界区**内。之前是「预检查 → 锁外置位 → begin_session」三段式,本地热键 +/// 会话在窗口内抢先启动会读到残留的远程标志,被劫持进远程分支(不开麦克风、 +/// 听写全文经 remote:result 泄给手机)。 +pub(crate) async fn begin_session_with_source( + inner: &Arc, + remote: bool, +) -> Result<(), String> { let current_session_id = { let mut state = inner.state.lock(); let Some(session_id) = begin_session_state(&mut state, capture_focus_target(), capture_frontmost_app()) else { - return Ok(()); + return if remote { + Err(REMOTE_BUSY.into()) + } else { + Ok(()) + }; }; + if remote { + inner.remote_source_active.store(true, Ordering::SeqCst); + } if let Some(label) = state.front_app.as_deref() { log::info!("[coord] front_app captured: {label}"); } @@ -631,10 +654,8 @@ pub(crate) async fn finish_starting_session(inner: &Arc, session_id: Sess log::info!("[coord] session started"); if matches!(outcome, BeginOutcome::PendingStop) { log::info!("[coord] applying pending_stop edge → end_session immediately"); + // 远程标志的清理由 end_session 内的 RemoteFlagsJanitor 统一兜底。 let _ = end_session(inner).await; - // 远程会话经 pending_stop 收尾时,stop_remote_dictation 已经早退, - // 不会再有人清远程标志 —— 在这里兜底(本地会话下是 no-op)。 - clear_remote_source_flags(inner); } } } diff --git a/openless-all/app/src-tauri/src/remote_server/assets/app.js b/openless-all/app/src-tauri/src/remote_server/assets/app.js index ab8d1497..957ef25a 100644 --- a/openless-all/app/src-tauri/src/remote_server/assets/app.js +++ b/openless-all/app/src-tauri/src/remote_server/assets/app.js @@ -343,6 +343,7 @@ var ws = null; var authed = false; var recording = false; // 是否正在录音(决定是否 send 音频) + var startSent = false; // 本次录音的 {type:'start'} 是否已真正发出(等 ensureAudio 异步就绪后才发) var busy = false; // PC 端忙,本次禁用 var mode = readMode(); // 'toggle' | 'hold' var lastPin = ''; @@ -355,6 +356,10 @@ var scriptNode = null; var workletUrl = null; var usingWorklet = false; + // 音频代际计数:每次重置/释放音频时自增。getUserMedia 可能在 withTimeout 超时后 + // 迟到 resolve,若不校验代际,迟到的 stream 会泄漏活跃麦克风轨道,甚至覆盖丢失 + // 用户重试成功后的新流。 + var audioGen = 0; // ScriptProcessor 兜底用的重采样状态(跨块保留) var resampleState = { phase: 0, last: 0, hasLast: false }; @@ -495,6 +500,33 @@ if (!recording && authed) setStatus(L.ready, null); }, 2500); } + // 录音/停止/取消入口都要清掉 readyTimer,否则上一次 done 的回 ready 定时器会迟到 + // 触发,把"识别中…"等新状态错盖成"准备就绪"。 + function clearReadyTimer() { + if (readyTimer) { clearTimeout(readyTimer); readyTimer = null; } + } + + // busy 提示的解除定时器:跟踪起来,新状态到来时清除,避免多个 busy 消息叠加定时器 + // 或迟到的定时器覆盖新状态。 + var busyTimer = null; + + // 识别/润色阶段的客户端兜底超时:服务端任何原因不回 done/error(如孤立会话、进程异常) + // 时,30 秒后显示通用错误并回 ready,防止 UI 永久卡在"识别中…"。 + var workTimer = null; + function armWorkTimeout() { + clearWorkTimeout(); + workTimer = setTimeout(function () { + workTimer = null; + if (!recording && authed) { + setStatus('❌ ' + L.errGeneric, 'error'); + setLevel(0); + scheduleReady(); + } + }, 30000); + } + function clearWorkTimeout() { + if (workTimer) { clearTimeout(workTimer); workTimer = null; } + } // ============================================================ // WebSocket @@ -621,11 +653,14 @@ case 'busy': busy = true; recording = false; + startSent = false; // 本次会话被服务端拒绝,复位 start 标记 teardownAudioCapture(); // 停止采集但保留 ctx updateRecordBtnUI(); setStatus(fmt(L.busy, { reason: msg.reason || L.busyDefault }), 'error'); - // 短暂后解除忙态,允许重试 - setTimeout(function () { + // 短暂后解除忙态,允许重试。定时器存入 busyTimer 跟踪,重入时先清,避免叠加。 + if (busyTimer) clearTimeout(busyTimer); + busyTimer = setTimeout(function () { + busyTimer = null; busy = false; updateRecordBtnUI(); if (!recording) setStatus(L.ready, null); @@ -640,6 +675,12 @@ } function applyStatusKind(msg) { + // 真实状态到来即解除 busy 兜底定时,避免它迟到触发把新状态错盖成"准备就绪"。 + if (busyTimer) { + clearTimeout(busyTimer); busyTimer = null; + busy = false; + updateRecordBtnUI(); + } switch (msg.kind) { case 'recording': setStatus(stripLeadingIcon(L.statusRecording), 'work'); @@ -647,11 +688,14 @@ case 'transcribing': setStatus(stripLeadingIcon(L.statusTranscribing), 'work'); if (statusDots) statusDots.hidden = false; // 识别中:三点加载动效 + armWorkTimeout(); // 工作状态续上兜底超时,防止服务端中途无响应卡死 break; case 'polishing': setStatus(L.statusPolishing, 'work'); // 润色保留 ✨ + armWorkTimeout(); // 同上 break; case 'done': + clearWorkTimeout(); // 正常收尾,解除兜底超时 var n = (typeof msg.insertedChars === 'number') ? msg.insertedChars : 0; setStatus(stripLeadingIcon(fmt(L.statusDone, { n: n })), 'ok'); if (statusIcon) { statusIcon.src = '/done.png'; statusIcon.hidden = false; } // 完成:对勾图 @@ -659,6 +703,7 @@ scheduleReady(); break; case 'error': + clearWorkTimeout(); // 服务端已明确报错,解除兜底超时 setStatus('❌ ' + (msg.message || L.errGeneric), 'error'); setLevel(0); break; @@ -865,6 +910,9 @@ } // 先乐观置态,保证 iOS 在手势同步栈内 resume() recording = true; + startSent = false; // start 尚未真正发出(等 ensureAudio 异步完成后才发) + clearReadyTimer(); // 防止上一次 done 的回 ready 定时器迟到覆盖本次状态 + clearWorkTimeout(); // 新一次录音开始,作废上一轮的识别兜底超时 updateRecordBtnUI(); setStatus(L.preparingMic, 'work'); clearResult(); // 清掉上一次的识别结果,避免新录音时还显示旧文字 @@ -877,6 +925,7 @@ return; } wsSendJSON({ type: 'start' }); + startSent = true; // start 已发出,stopRecording 才需要配对发 stop setStatus(stripLeadingIcon(L.statusRecording), 'work'); }) .catch(function (err) { @@ -893,13 +942,23 @@ function stopRecording() { detachHoldEnd(); if (!recording) return; + clearReadyTimer(); // 防止迟到的回 ready 定时器覆盖"识别中…" recording = false; updateRecordBtnUI(); teardownAudioCapture(); + // start 还没发出(hold 按下后立即松手,ensureAudio 尚未完成)→ 按本地取消处理: + // 不发孤立 stop,否则 PC 无对应会话、不回 done/error,UI 会永久卡在"识别中…"。 + if (!startSent) { + setStatus(L.ready, null); + setLevel(0); + return; + } + startSent = false; wsSendJSON({ type: 'stop' }); setStatus(stripLeadingIcon(L.statusTranscribing), 'work'); if (statusDots) statusDots.hidden = false; setLevel(0); + armWorkTimeout(); // 兜底:30 秒内服务端不回 done/error 则强制回 ready } function cancelRecording() { @@ -909,10 +968,14 @@ teardownAudioCapture(); return; } + clearReadyTimer(); + clearWorkTimeout(); recording = false; updateRecordBtnUI(); teardownAudioCapture(); - wsSendJSON({ type: 'cancel' }); + // 同 stopRecording:start 未发出就不发孤立 cancel + if (startSent) wsSendJSON({ type: 'cancel' }); + startSent = false; setStatus(L.cancelled, null); setLevel(0); } @@ -951,7 +1014,9 @@ audioCtx = new AC(); } - var resumeP = (audioCtx.state === 'suspended') + // 注意:iOS Safari 来电/Siri 后 ctx 处于私有的 'interrupted' 状态,只判 'suspended' + // 不命中,会导致录音静默无声 —— 凡是非 running 都尝试 resume。 + var resumeP = (audioCtx.state !== 'running') ? audioCtx.resume().catch(function () {}) : Promise.resolve(); @@ -959,6 +1024,9 @@ .then(function () { // 2) 麦克风流(已存在则复用) if (mediaStream) return mediaStream; + // 捕获当前代际:迟到 resolve 时若代际已变(超时重置/断线释放),停掉轨道并放弃, + // 避免泄漏麦克风或覆盖重试成功的新流。 + var gen = audioGen; return navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, @@ -968,6 +1036,10 @@ }, video: false }).then(function (stream) { + if (gen !== audioGen) { + try { stream.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {} + return null; // 交给下一步判空直接放弃 + } mediaStream = stream; return stream; }); @@ -1201,6 +1273,7 @@ // 彻底释放(断线时):停止麦克风轨道并关闭 ctx。 function teardownAudio() { + audioGen++; // 代际推进:作废所有在途的 getUserMedia 迟到回调 teardownAudioCapture(); if (mediaStream) { try { @@ -1219,6 +1292,7 @@ // 与 teardownAudio 的区别:这里 close 并置空 audioCtx —— 超时根因往往是 ctx 自身坏掉 // (resume 永不 settle),保留它只会让下次继续卡。 function resetAudioContext() { + audioGen++; // 代际推进:作废所有在途的 getUserMedia 迟到回调 teardownAudioCapture(); if (mediaStream) { try { @@ -1254,9 +1328,17 @@ var reloadedOnce = false; try { reloadedOnce = sessionStorage.getItem('ol_reloaded_once') === '1'; } catch (e) {} if (!reloadedOnce) { - try { sessionStorage.setItem('ol_reloaded_once', '1'); } catch (e) {} - location.reload(); - return; + // 写后立即读回校验:sessionStorage 被禁用(写入抛异常/写不进去)时标记永远落不下, + // 若仍 reload 会无限循环刷新 —— 校验失败就放弃刷新,直接继续初始化。 + var marked = false; + try { + sessionStorage.setItem('ol_reloaded_once', '1'); + marked = sessionStorage.getItem('ol_reloaded_once') === '1'; + } catch (e) {} + if (marked) { + location.reload(); + return; + } } applyStaticI18n(); 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 abd6dee5..d6eb39c4 100644 --- a/openless-all/app/src-tauri/src/remote_server/mod.rs +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -50,6 +50,15 @@ const PIN_MAX_FAILS: u32 = 5; const PIN_LOCK_SECS: u64 = 60; /// pin_fails 表的容量上限:超过即清理已过期/已解锁的条目,防止伪造海量源 IP 撑爆内存。 const PIN_FAILS_MAX_ENTRIES: usize = 256; +/// 单个 PCM 二进制帧的上限。16kHz/16bit 实时流正常每帧只有几 KB,64KB ≈ 2 秒音频; +/// 超限帧直接丢弃,防已配对客户端(或驱动它的恶意网页)推超大帧造成内存压力。 +const MAX_PCM_FRAME_BYTES: usize = 64 * 1024; +/// 服务端 keepalive:每 KEEPALIVE_PING_SECS 发一次 WS Ping(浏览器自动回 Pong); +/// 连续 IDLE_TIMEOUT_SECS 收不到任何上行帧(含 Pong)则视为半开死链断开。 +/// 手机息屏/Wi-Fi 漂移常常不发 TCP FIN,没有探活时 recv() 永久挂起:连接任务、 +/// 事件订阅、进行中的远程会话全部悬挂。 +const KEEPALIVE_PING_SECS: u64 = 30; +const IDLE_TIMEOUT_SECS: u64 = 90; // ───────────────────────── 对外类型 ───────────────────────── @@ -63,6 +72,10 @@ pub struct RemoteServerConfig { /// 运行中的服务句柄。drop / shutdown 触发优雅关停。 pub struct RemoteServerHandle { shutdown_tx: Option>, + /// 广播给所有已建立 WS 连接的关停信号。只停 accept loop 是不够的:连接任务 + /// 是独立 spawn 的,不通知它们的话,用户关掉远程输入(或重置 PIN 触发重启) + /// 后已配对的手机会话原样存活,仍能录音、向 PC 光标落字——撤销语义失效。 + conn_shutdown_tx: tokio::sync::watch::Sender, join: tauri::async_runtime::JoinHandle<()>, pub bound_port: u16, #[allow(dead_code)] @@ -70,11 +83,12 @@ pub struct RemoteServerHandle { } impl RemoteServerHandle { - /// 通知 accept loop 退出并等待其结束。 + /// 通知 accept loop 与所有存量 WS 连接退出,并等待 accept loop 结束。 pub async fn shutdown(mut self) { if let Some(tx) = self.shutdown_tx.take() { let _ = tx.send(()); } + let _ = self.conn_shutdown_tx.send(true); let _ = self.join.await; } } @@ -224,6 +238,17 @@ fn load_or_generate_cert( let _ = std::fs::create_dir_all(dir); let _ = std::fs::write(dir.join(CERT_FILE), &cert_der); let _ = std::fs::write(dir.join(KEY_FILE), &key_der); + // 私钥收紧为 0600:app 配置目录通常已是用户私有,但多用户/共享主机上 + // 默认 umask 可能给到组/其他用户可读。Windows 下 %APPDATA% 的 ACL + // 本身仅限本用户,无对应权限位可设。 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions( + dir.join(KEY_FILE), + std::fs::Permissions::from_mode(0o600), + ); + } let _ = std::fs::write(dir.join(SANS_FILE), sans.join("\n")); log::info!("[remote-input] generated new self-signed server cert (SAN={sans:?})"); } @@ -260,6 +285,8 @@ struct WsState { pin_fails: Mutex)>>, /// 自签名证书的 DER 原始字节,供 /cert.cer 下载给手机安装信任。 cert_der: Vec, + /// 服务关停广播的接收端,每条 WS 连接 clone 一份并在主循环 select 监听。 + conn_shutdown_rx: tokio::sync::watch::Receiver, } /// 经 accept loop 注入的对端 IP(axum Extension)。hyper 直连 TLS 流时拿不到 @@ -393,12 +420,14 @@ pub async fn start(cfg: RemoteServerConfig) -> Result Result, peer_ip: IpAddr) }) }; - // 3) 主循环:手机上行(控制 / PCM) + 后端状态下行。 + // 3) 主循环:手机上行(控制 / PCM) + 后端状态下行 + keepalive 探活 + 关停广播。 + let mut conn_shutdown_rx = state.conn_shutdown_rx.clone(); + let mut keepalive = tokio::time::interval(Duration::from_secs(KEEPALIVE_PING_SECS)); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut last_rx = Instant::now(); loop { tokio::select! { incoming = socket.recv() => { + last_rx = Instant::now(); match incoming { Some(Ok(Message::Binary(pcm))) => { - if pcm.len() >= 2 && pcm.len() % 2 == 0 { + if pcm.len() >= 2 && pcm.len() % 2 == 0 && pcm.len() <= MAX_PCM_FRAME_BYTES { state.coordinator.feed_remote_pcm(&pcm); } } @@ -587,6 +622,26 @@ async fn handle_ws(mut socket: WebSocket, state: Arc, peer_ip: IpAddr) break; } } + _ = keepalive.tick() => { + // 半开探活:浏览器收到 Ping 自动回 Pong(上面 recv 收到即刷新 last_rx)。 + // 超时无任何上行 → 死链,break 走下方统一收尾(cancel + unlisten), + // 避免录音中掉线时远程会话与标志悬挂。 + if last_rx.elapsed() > Duration::from_secs(IDLE_TIMEOUT_SECS) { + log::info!("[remote-input] 连接 {}s 无上行(含 Pong),按半开死链断开", IDLE_TIMEOUT_SECS); + break; + } + if socket.send(Message::Ping(Vec::new())).await.is_err() { + break; + } + } + changed = conn_shutdown_rx.changed() => { + // 服务关停(用户关闭远程输入 / 重置 PIN / 改端口触发重启): + // 主动断开存量连接,撤销已配对手机的会话与落字能力。 + if changed.is_err() || *conn_shutdown_rx.borrow() { + log::info!("[remote-input] 服务关停,断开存量手机连接"); + break; + } + } } } diff --git a/openless-all/app/src/pages/settings/RemoteInputSection.tsx b/openless-all/app/src/pages/settings/RemoteInputSection.tsx index b8bb2cf3..002a4c6c 100644 --- a/openless-all/app/src/pages/settings/RemoteInputSection.tsx +++ b/openless-all/app/src/pages/settings/RemoteInputSection.tsx @@ -43,6 +43,8 @@ export function RemoteInputSection() { const [status, setStatus] = useState(null); const [startError, setStartError] = useState<{ reason: string; port: number } | null>(null); const [copied, setCopied] = useState(null); + // 端口编辑草稿:失焦/回车时才解析提交,避免逐键持久化导致后端服务在中间值端口反复重启。 + const [portDraft, setPortDraft] = useState(null); useEffect(() => { let alive = true; @@ -57,13 +59,28 @@ export function RemoteInputSection() { const unsubs: Array<() => void> = []; import('@tauri-apps/api/event').then(({ listen }) => { listen('remote-input:running', () => { + if (!alive) return; setStartError(null); refresh(); - }).then((u) => unsubs.push(u)); + }).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; - if (alive) setStartError({ reason: p?.reason ?? '', port: p?.port ?? 0 }); - }).then((u) => unsubs.push(u)); + setStartError({ reason: p?.reason ?? '', port: p?.port ?? 0 }); + }).then((u) => { + if (!alive) { + u(); + } else { + unsubs.push(u); + } + }); }); return () => { alive = false; @@ -75,6 +92,21 @@ export function RemoteInputSection() { const enabled = prefs.remoteInputEnabled; const mode = prefs.remoteInputDefaultMode ?? 'toggle'; + // 提交端口草稿:非法(非有限数/越界离谱)则丢弃还原显示,合法则取整并 clamp 到 [1024, 65535]。 + const commitPort = () => { + if (portDraft == null) return; + const n = Math.round(Number(portDraft)); + if (!Number.isFinite(n) || n <= 0) { + setPortDraft(null); + return; + } + const port = Math.max(1024, Math.min(65535, n)); + setPortDraft(null); + if (port !== prefs.remoteInputPort) { + updatePrefs({ ...prefs, remoteInputPort: port }); + } + }; + const doCopy = async (url: string, pin: string) => { await copyText(`${url}\n${t('settings.remoteInput.pinLabel')}:${pin}`); setCopied(url); @@ -110,16 +142,12 @@ export function RemoteInputSection() { min={1024} max={65535} style={{ ...inputStyle, maxWidth: 140 }} - value={prefs.remoteInputPort} - onChange={(e) => - updatePrefs({ - ...prefs, - remoteInputPort: Math.max( - 1, - Math.min(65535, Number(e.currentTarget.value) || 0), - ), - }) - } + value={portDraft ?? String(prefs.remoteInputPort)} + onChange={(e) => setPortDraft(e.currentTarget.value)} + onBlur={commitPort} + onKeyDown={(e) => { + if (e.key === 'Enter') commitPort(); + }} /> @@ -202,9 +230,14 @@ export function RemoteInputSection() {