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:
ciddwd committed 2026-06-10 15:19:09 +08:00
1 parent 23cc80ea23
commit 6e62009260
6 files changed
+286 -46

No files matched your search

+45 -16
View File
@@ -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}
>