fix(remote-input): 全面自审——修复 2 个 HIGH 竞态与一批中危问题
按评审机器人的维度(安全/并发/资源/错误处理/边界)对远程输入全部改动面 做了一轮地毯式自审,修复如下: Rust 核心(2 HIGH + 3 medium): - [HIGH] start_remote_dictation TOCTOU:busy 预检查与 remote_source_active 置位原在锁外,竞态输家把残留标志泄给抢先启动的本地会话(被劫持进远程 分支:不开麦克风、听写全文经 remote:result 泄给手机)。新增 begin_session_with_source(remote),busy 判定与置位移入 begin_session_state 同一临界区(与本地路径同构);busy 时返回 Err(REMOTE_BUSY) 供手机回执。 - [HIGH] 服务关停/重置 PIN 不撤销存量连接:连接任务是裸 spawn,shutdown 只 停 accept loop,已配对手机仍能录音落字。新增 watch 关停广播,shutdown 时 断开全部存量 WS 连接(旧 handle 被覆盖时靠 sender drop 自动生效)。 - end_session 新增 RemoteFlagsJanitor(Drop 兜底):十余处终结路径统一在 会话回 Idle 时清远程标志;同时修复 double-stop 过早清标志导致「仅回传」 失效与 remote:result 丢失(phase 非 Idle 不清)。 - stop/cancel_remote_dictation 加守卫:手机 stop/断连不再能终止 PC 用户 正在进行的本地听写。 - refresh_remote_server 串行化(异步锁 + 代数):连点开关/连改端口不再交错 启动、误报 port-in-use;中间代直接让位只跑最后一轮。 - WS 主循环加 keepalive(30s Ping / 90s 无上行断开):手机息屏不发 FIN 时 半开连接与录音会话不再悬挂;PCM 单帧加 64KB 上限防认证后 DoS。 - 私钥 remote-key-v4.der 在 Unix 下收紧为 0600。 H5 录音页(4 medium + 1 low): - getUserMedia 超时后迟到 resolve 用代际计数作废,不再泄漏麦克风轨道; - hold 按下立即松手不再发孤立 stop(startSent 配对),transcribing/polishing 加 30s 客户端兜底超时,UI 不再可能永久卡「识别中…」; - iOS 来电/Siri 后的 'interrupted' 状态改为非 running 即 resume; - sessionStorage 被禁时写后读回校验,根除无限 reload 循环; - readyTimer/busyTimer 统一跟踪清理,杜绝迟到定时器错盖新状态。 设置页(2 medium + 1 low): - listen() 异步注册 vs 卸载竞态:注册完成若已卸载立即退订; - 端口输入改草稿+失焦提交:不再逐键重启服务,clamp 对齐 [1024,65535], 取整防 u16 反序列化失败; - 重置 PIN 用命令返回值本地更新,不再撞异步重启窗口闪烁,并加错误处理。 验证:cargo check 0 错误;cargo test --lib 508 过(5 例失败为基线既有 asr mock);tsc 通过;node --check app.js 通过。
This commit is contained in:
1 parent
23cc80ea23
commit
6e62009260
6 files changed
+286
-46
No files matched your search
@@ -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<Option<Arc<dyn crate::recorder::AudioConsumer>>>,
|
||||
/// 远程输入 HTTPS+WS 服务句柄。None = 未启动。
|
||||
remote_server: Mutex<Option<crate::remote_server::RemoteServerHandle>>,
|
||||
/// 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<Option<String>>,
|
||||
/// 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<Self>) {
|
||||
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 {
|
||||
|
||||
@@ -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<Inner>,
|
||||
}
|
||||
|
||||
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<Inner>) -> 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 {
|
||||
|
||||
@@ -18,13 +18,36 @@ pub(crate) fn request_stop_during_starting(inner: &Arc<Inner>, reason: &str) {
|
||||
}
|
||||
|
||||
pub(crate) async fn begin_session(inner: &Arc<Inner>) -> 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<Inner>,
|
||||
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<Inner>, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<tokio::sync::oneshot::Sender<()>>,
|
||||
/// 广播给所有已建立 WS 连接的关停信号。只停 accept loop 是不够的:连接任务
|
||||
/// 是独立 spawn 的,不通知它们的话,用户关掉远程输入(或重置 PIN 触发重启)
|
||||
/// 后已配对的手机会话原样存活,仍能录音、向 PC 光标落字——撤销语义失效。
|
||||
conn_shutdown_tx: tokio::sync::watch::Sender<bool>,
|
||||
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<std::collections::HashMap<IpAddr, (u32, Option<Instant>)>>,
|
||||
/// 自签名证书的 DER 原始字节,供 /cert.cer 下载给手机安装信任。
|
||||
cert_der: Vec<u8>,
|
||||
/// 服务关停广播的接收端,每条 WS 连接 clone 一份并在主循环 select 监听。
|
||||
conn_shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
}
|
||||
|
||||
/// 经 accept loop 注入的对端 IP(axum Extension)。hyper 直连 TLS 流时拿不到
|
||||
@@ -393,12 +420,14 @@ pub async fn start(cfg: RemoteServerConfig) -> Result<RemoteServerHandle, String
|
||||
})?;
|
||||
let bound_port = listener.local_addr().map(|a| a.port()).unwrap_or(cfg.port);
|
||||
|
||||
let (conn_shutdown_tx, conn_shutdown_rx) = tokio::sync::watch::channel(false);
|
||||
let state = Arc::new(WsState {
|
||||
pin: cfg.pin.clone(),
|
||||
coordinator: cfg.coordinator,
|
||||
app: cfg.app,
|
||||
pin_fails: Mutex::new(std::collections::HashMap::new()),
|
||||
cert_der,
|
||||
conn_shutdown_rx,
|
||||
});
|
||||
let router = build_router(state);
|
||||
|
||||
@@ -445,6 +474,7 @@ pub async fn start(cfg: RemoteServerConfig) -> Result<RemoteServerHandle, String
|
||||
|
||||
Ok(RemoteServerHandle {
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
conn_shutdown_tx,
|
||||
join,
|
||||
bound_port,
|
||||
pin: cfg.pin,
|
||||
@@ -563,13 +593,18 @@ async fn handle_ws(mut socket: WebSocket, state: Arc<WsState>, 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<WsState>, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ export function RemoteInputSection() {
|
||||
const [status, setStatus] = useState<RemoteInputStatus | null>(null);
|
||||
const [startError, setStartError] = useState<{ reason: string; port: number } | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
// 端口编辑草稿:失焦/回车时才解析提交,避免逐键持久化导致后端服务在中间值端口反复重启。
|
||||
const [portDraft, setPortDraft] = useState<string | null>(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();
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
@@ -202,9 +230,14 @@ export function RemoteInputSection() {
|
||||
</code>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await regenerateRemotePin();
|
||||
const s = await getRemoteInputStatus();
|
||||
setStatus(s);
|
||||
try {
|
||||
// 直接用命令返回的新 PIN 更新本地状态;后端会异步重启服务,
|
||||
// 此时查询状态可能拿到 running:false 导致闪烁,刷新交给 remote-input:running 事件。
|
||||
const pin = await regenerateRemotePin();
|
||||
setStatus((s) => (s ? { ...s, pin } : s));
|
||||
} catch (e) {
|
||||
console.warn('regenerateRemotePin failed', e);
|
||||
}
|
||||
}}
|
||||
style={smallBtn}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user