fix: 应用 xhigh 代码评审的跨端修复
来自 xhigh code review 的正确性/健壮性修复,覆盖全部五端: - server:鉴权 fail-closed、计量交叉校验与配额扣穿处理、WS 网关并发与关闭顺序、 billing 行锁、redis Lua 过期与设备槽刷新、config 解析 - desktop:会话 epoch 防串话、WS 重连与 401 处理、api 客户端复用、统一 usePoll 轮询 - android:握手时序、请求头封装、账户状态派生、按需重组 - ios:finalize 宽限、串行采集、错误文案服务端优先、删除死代码 CommitController Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,24 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
/// 协议平台标识(18H):mac / win / linux(区别于 std::env::consts::OS 的 macos/windows)。
|
||||
const PLATFORM: &str = if cfg!(target_os = "macos") {
|
||||
"mac"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"win"
|
||||
} else {
|
||||
"linux"
|
||||
};
|
||||
|
||||
/// 共享 reqwest::Client(18H):复用连接池,避免每次请求新建 Client。
|
||||
fn http() -> &'static reqwest::Client {
|
||||
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
CLIENT.get_or_init(reqwest::Client::new)
|
||||
}
|
||||
|
||||
/// 全局暂停开关(11B 托盘菜单"暂停使用"):暂停时快捷键不再触发听写。
|
||||
#[derive(Default)]
|
||||
pub struct Paused(AtomicBool);
|
||||
@@ -39,7 +55,7 @@ fn base(app: &AppHandle) -> (String, String) {
|
||||
|
||||
async fn get_json(app: &AppHandle, path: &str, authed: bool) -> Option<Value> {
|
||||
let (url, token) = base(app);
|
||||
let mut req = reqwest::Client::new().get(format!("{url}{path}"));
|
||||
let mut req = http().get(format!("{url}{path}"));
|
||||
if authed {
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
@@ -67,7 +83,7 @@ pub async fn fetch_packs(app: AppHandle) -> Option<Value> {
|
||||
#[tauri::command]
|
||||
pub async fn login_qr_create(app: AppHandle) -> Option<Value> {
|
||||
let (url, _) = base(&app);
|
||||
reqwest::Client::new()
|
||||
http()
|
||||
.post(format!("{url}/v1/auth/qr"))
|
||||
.send()
|
||||
.await
|
||||
@@ -86,8 +102,8 @@ pub async fn login_qr_poll(app: AppHandle, state: String) -> Option<Value> {
|
||||
let mut s = store.get();
|
||||
s.token = token.to_string();
|
||||
store.set(s);
|
||||
// 通知 ws 重连携带新 token
|
||||
let _ = app.state::<crate::ws::WsHandle>().tx.send(crate::ws::WsCmd::Reconnect);
|
||||
// 通知 ws 重连携带新 token(bounded channel:用 WsHandle::send 非阻塞投递)
|
||||
let _ = app.state::<crate::ws::WsHandle>().send(crate::ws::WsCmd::Reconnect);
|
||||
}
|
||||
}
|
||||
Some(v)
|
||||
@@ -96,7 +112,7 @@ pub async fn login_qr_poll(app: AppHandle, state: String) -> Option<Value> {
|
||||
#[tauri::command]
|
||||
pub async fn create_order(app: AppHandle, pack_id: String) -> Option<Value> {
|
||||
let (url, token) = base(&app);
|
||||
reqwest::Client::new()
|
||||
http()
|
||||
.post(format!("{url}/v1/orders"))
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({"pack_id": pack_id, "channel": "native"}))
|
||||
@@ -179,17 +195,17 @@ pub async fn submit_feedback(
|
||||
let s = app.state::<crate::settings::SettingsStore>().get();
|
||||
let diag = json!({
|
||||
"app_version": app_version,
|
||||
"platform": std::env::consts::OS,
|
||||
"platform": PLATFORM,
|
||||
"os_version": os_version(),
|
||||
"device_id": s.device_id,
|
||||
});
|
||||
form = form.text("diagnostics", diag.to_string());
|
||||
}
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
let resp = http()
|
||||
.post(format!("{url}/v1/feedback"))
|
||||
.bearer_auth(token)
|
||||
.header("X-Platform", std::env::consts::OS)
|
||||
.header("X-Platform", PLATFORM)
|
||||
.header("X-App-Version", app_version)
|
||||
.multipart(form)
|
||||
.send()
|
||||
@@ -256,24 +272,54 @@ pub fn finish_onboarding(app: AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查更新:GET /v1/app/latest,有新版则打开下载页;返回结果供 UI 提示。
|
||||
/// 比较语义化版本:a 是否严格大于 b(仅按 . 分段的数字比较,非数字段视为 0)。
|
||||
/// 用于避免把降级(服务端版本反而更旧)误判为可更新(18H)。
|
||||
fn version_gt(a: &str, b: &str) -> bool {
|
||||
let parse = |s: &str| -> Vec<u64> {
|
||||
s.split('.')
|
||||
.map(|seg| {
|
||||
seg.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u64>()
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let (va, vb) = (parse(a), parse(b));
|
||||
let n = va.len().max(vb.len());
|
||||
for i in 0..n {
|
||||
let x = va.get(i).copied().unwrap_or(0);
|
||||
let y = vb.get(i).copied().unwrap_or(0);
|
||||
if x != y {
|
||||
return x > y;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 检查更新:GET /v1/app/latest。返回结果供 UI 提示;force 时走强制更新路径。
|
||||
#[tauri::command]
|
||||
pub async fn check_update(app: AppHandle) -> Option<Value> {
|
||||
let current = app.package_info().version.to_string();
|
||||
#[cfg(target_os = "macos")]
|
||||
let platform = "mac";
|
||||
#[cfg(target_os = "windows")]
|
||||
let platform = "win";
|
||||
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
|
||||
let platform = "linux";
|
||||
let latest = get_json(&app, &format!("/v1/app/latest?platform={platform}"), false).await?;
|
||||
let latest = get_json(&app, &format!("/v1/app/latest?platform={PLATFORM}"), false).await?;
|
||||
let version = latest.get("version").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let dl = latest.get("url").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let available = !version.is_empty() && version != current;
|
||||
if available && !dl.is_empty() {
|
||||
let force = latest.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
// 仅当服务端版本严格高于当前版本才算可更新;降级/同版不提示(18H)。
|
||||
let available = !version.is_empty() && version_gt(version, ¤t);
|
||||
// 普通可更新:自动打开下载页。强制更新:不自动打开,交由 UI 走强制更新提示路径,
|
||||
// 避免与普通更新等同(用户可能被强制弹窗拦截,需 UI 显式引导)。
|
||||
if available && !force && !dl.is_empty() {
|
||||
open_external(dl);
|
||||
}
|
||||
Some(json!({"current": current, "latest": version, "update_available": available, "url": dl}))
|
||||
Some(json!({
|
||||
"current": current,
|
||||
"latest": version,
|
||||
"update_available": available,
|
||||
"force": force && available,
|
||||
"url": dl,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 用系统默认方式打开外部链接。
|
||||
|
||||
@@ -4,9 +4,19 @@
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tauri::{AppHandle, Emitter, EventTarget, Manager};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// 浮层窗口 label(仅 overlay 监听 asr/hotkey 事件,见 tauri.conf.json)。
|
||||
const OVERLAY: &str = "overlay";
|
||||
|
||||
/// 向 overlay 窗口定向 emit(18G):避免广播给全部 webview。
|
||||
fn emit_overlay(app: &AppHandle, event: &str, payload: Value) {
|
||||
let _ = app.emit_to(EventTarget::webview_window(OVERLAY), event, payload);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DictationState {
|
||||
@@ -15,9 +25,9 @@ pub struct DictationState {
|
||||
|
||||
struct Session {
|
||||
id: String,
|
||||
/// 会话代际(18A/18B):每次 start 自增,on_server_msg / 收尾任务校验后才生效。
|
||||
epoch: u64,
|
||||
capture: Option<crate::audio::Capture>,
|
||||
final_text: String,
|
||||
partial_text: String,
|
||||
started: Instant,
|
||||
got_first_partial: bool,
|
||||
}
|
||||
@@ -31,11 +41,16 @@ pub fn start(app: &AppHandle) {
|
||||
return; // 已暂停使用
|
||||
}
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// 开新会话代际:递增 epoch,重置 buffer / 丢弃标志 / 收尾信号(18A/18B/18C)。
|
||||
let buf = app.state::<CommitBuffer>();
|
||||
let epoch = buf.begin_session();
|
||||
|
||||
set_tray_tooltip(app, "dudu — 录音中");
|
||||
let _ = app.emit("hotkey", json!({"state": "down"}));
|
||||
emit_overlay(app, "hotkey", json!({"state": "down"}));
|
||||
show_overlay(app);
|
||||
|
||||
let ws = app.state::<crate::ws::WsHandle>().tx.clone();
|
||||
let ws = (*app.state::<crate::ws::WsHandle>()).clone();
|
||||
let _ = ws.send(crate::ws::WsCmd::Start {
|
||||
session_id: session_id.clone(),
|
||||
});
|
||||
@@ -52,7 +67,7 @@ pub fn start(app: &AppHandle) {
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("audio start failed: {e}");
|
||||
let _ = app.emit("asr", json!({"type":"error","code":"AUDIO","message": e}));
|
||||
emit_overlay(app, "asr", json!({"type":"error","code":"AUDIO","message": e}));
|
||||
None
|
||||
}
|
||||
};
|
||||
@@ -69,24 +84,35 @@ pub fn start(app: &AppHandle) {
|
||||
|
||||
*state.inner.lock() = Some(Session {
|
||||
id: session_id,
|
||||
epoch,
|
||||
capture,
|
||||
final_text: String::new(),
|
||||
partial_text: String::new(),
|
||||
started: Instant::now(),
|
||||
got_first_partial: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// 当前是否有进行中的录音会话(18E:ws 重连后据此判断是否需中止会话)。
|
||||
pub fn is_recording(app: &AppHandle) -> bool {
|
||||
app.state::<DictationState>().inner.lock().is_some()
|
||||
}
|
||||
|
||||
pub fn stop(app: &AppHandle, canceled: bool) {
|
||||
let state = app.state::<DictationState>();
|
||||
let Some(mut sess) = state.inner.lock().take() else {
|
||||
return;
|
||||
};
|
||||
let _ = app.emit("hotkey", json!({"state": "up"}));
|
||||
let epoch = sess.epoch;
|
||||
emit_overlay(app, "hotkey", json!({"state": "up"}));
|
||||
set_tray_tooltip(app, "dudu — 就绪");
|
||||
sess.capture.take(); // 停止采集
|
||||
|
||||
let ws = app.state::<crate::ws::WsHandle>().tx.clone();
|
||||
let buf = app.state::<CommitBuffer>();
|
||||
if canceled {
|
||||
// 取消即丢弃:标记该 epoch,后续晚到 final 直接丢弃不入 buffer(18A)。
|
||||
buf.discard(epoch);
|
||||
}
|
||||
|
||||
let ws = (*app.state::<crate::ws::WsHandle>()).clone();
|
||||
let cmd = if canceled {
|
||||
crate::ws::WsCmd::Cancel { session_id: sess.id.clone() }
|
||||
} else {
|
||||
@@ -94,23 +120,31 @@ pub fn stop(app: &AppHandle, canceled: bool) {
|
||||
};
|
||||
let _ = ws.send(cmd);
|
||||
|
||||
// 取尾部 final 完成信号(事件驱动注入,18C)。
|
||||
let finalized = buf.finalize_signal();
|
||||
|
||||
let app = app.clone();
|
||||
let release_at = Instant::now();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if !canceled {
|
||||
// 等网关 flush 尾部 final(句间已实时累计,这里只兜尾部)
|
||||
tokio::time::sleep(Duration::from_millis(350)).await;
|
||||
let committed = {
|
||||
let state = app.state::<DictationState>();
|
||||
let pending = state.inner.lock();
|
||||
drop(pending);
|
||||
let buf = app.state::<CommitBuffer>();
|
||||
buf.take()
|
||||
// 事件驱动:收到 stop 后的尾部 final(或 usage 结算帧)即触发注入;
|
||||
// 350ms 仅作超时兜底,避免 flush 慢于固定 sleep 截丢尾 final(18C)。
|
||||
tokio::select! {
|
||||
_ = finalized.notified() => {}
|
||||
_ = tokio::time::sleep(Duration::from_millis(350)) => {}
|
||||
}
|
||||
|
||||
let buf = app.state::<CommitBuffer>();
|
||||
// 会话代际校验:本任务只处理自己的 epoch,快速连说时旧任务醒来不串新会话(18B)。
|
||||
let Some(committed) = buf.take_for_epoch(epoch) else {
|
||||
// epoch 不匹配(已被新会话覆盖)或已被丢弃:不注入、不 hide(18A/18B)。
|
||||
return;
|
||||
};
|
||||
if !committed.is_empty() {
|
||||
if !crate::inject::accessibility_ok() {
|
||||
// 无辅助功能权限(macOS):注入必失败 → 浮层引导去授权(10E)
|
||||
let _ = app.emit(
|
||||
emit_overlay(
|
||||
&app,
|
||||
"asr",
|
||||
json!({"type":"error","code":"NO_ACCESSIBILITY","message":"需要辅助功能权限"}),
|
||||
);
|
||||
@@ -130,7 +164,8 @@ pub fn stop(app: &AppHandle, canceled: bool) {
|
||||
_ => {
|
||||
log::error!("inject failed: {injected:?}");
|
||||
if !crate::inject::accessibility_ok() {
|
||||
let _ = app.emit(
|
||||
emit_overlay(
|
||||
&app,
|
||||
"asr",
|
||||
json!({"type":"error","code":"NO_ACCESSIBILITY","message":"需要辅助功能权限"}),
|
||||
);
|
||||
@@ -141,34 +176,99 @@ pub fn stop(app: &AppHandle, canceled: bool) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app.state::<CommitBuffer>().take();
|
||||
// 取消:清空 buffer(仅当仍是本 epoch,避免误清新会话),不上屏(18A)。
|
||||
let buf = app.state::<CommitBuffer>();
|
||||
let _ = buf.take_for_epoch(epoch);
|
||||
}
|
||||
hide_overlay(&app);
|
||||
});
|
||||
}
|
||||
|
||||
/// CommitBuffer 跨 ws 任务与停止流程共享的"待注入文本"。
|
||||
#[derive(Default)]
|
||||
/// CommitBuffer 跨 ws 任务与停止流程共享的"待注入文本"+ 会话代际/丢弃标志/收尾信号。
|
||||
pub struct CommitBuffer {
|
||||
text: Mutex<(String, String)>, // (final 累计, 最新 partial)
|
||||
inner: Mutex<BufferInner>,
|
||||
/// 当前会话代际(18A/18B):start 自增,on_server_msg / 收尾任务据此校验归属。
|
||||
epoch: AtomicU64,
|
||||
/// 尾部 final 到达信号,驱动收尾注入(18C)。
|
||||
finalized: std::sync::Arc<Notify>,
|
||||
}
|
||||
|
||||
struct BufferInner {
|
||||
text: (String, String), // (final 累计, 最新 partial)
|
||||
/// text 归属的会话代际。
|
||||
text_epoch: u64,
|
||||
/// 被取消的会话代际:该 epoch 的后续 final 直接丢弃(18A)。
|
||||
discarded_epoch: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for CommitBuffer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(BufferInner {
|
||||
text: (String::new(), String::new()),
|
||||
text_epoch: 0,
|
||||
discarded_epoch: None,
|
||||
}),
|
||||
epoch: AtomicU64::new(0),
|
||||
finalized: std::sync::Arc::new(Notify::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommitBuffer {
|
||||
fn take(&self) -> String {
|
||||
let mut t = self.text.lock();
|
||||
let committed = format!("{}{}", t.0, t.1);
|
||||
*t = (String::new(), String::new());
|
||||
committed
|
||||
/// 开新会话:epoch+1,清空文本与丢弃标志,返回新 epoch(18A/18B)。
|
||||
fn begin_session(&self) -> u64 {
|
||||
let epoch = self.epoch.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let mut g = self.inner.lock();
|
||||
g.text = (String::new(), String::new());
|
||||
g.text_epoch = epoch;
|
||||
g.discarded_epoch = None;
|
||||
epoch
|
||||
}
|
||||
|
||||
fn current_epoch(&self) -> u64 {
|
||||
self.epoch.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 标记某会话被取消,其后续 final 一律丢弃(18A)。
|
||||
fn discard(&self, epoch: u64) {
|
||||
self.inner.lock().discarded_epoch = Some(epoch);
|
||||
}
|
||||
|
||||
/// 收尾信号克隆,供 stop 任务 await(18C)。
|
||||
fn finalize_signal(&self) -> std::sync::Arc<Notify> {
|
||||
self.finalized.clone()
|
||||
}
|
||||
|
||||
/// 取出并清空文本——仅当 buffer 仍属指定 epoch 且未被丢弃时返回(18A/18B)。
|
||||
/// epoch 不匹配(已被新会话覆盖)返回 None,调用方据此放弃注入/hide。
|
||||
fn take_for_epoch(&self, epoch: u64) -> Option<String> {
|
||||
let mut g = self.inner.lock();
|
||||
if g.text_epoch != epoch {
|
||||
return None; // 已被新会话覆盖
|
||||
}
|
||||
if g.discarded_epoch == Some(epoch) {
|
||||
g.text = (String::new(), String::new());
|
||||
return Some(String::new()); // 本会话已取消,返回空(不注入但允许 hide 自身浮层)
|
||||
}
|
||||
let committed = format!("{}{}", g.text.0, g.text.1);
|
||||
g.text = (String::new(), String::new());
|
||||
Some(committed)
|
||||
}
|
||||
}
|
||||
|
||||
/// ws 下行回调:维护文本缓冲 + 首字延迟打点(在 ws.rs 收包处调用)。
|
||||
pub fn on_server_msg(app: &AppHandle, v: &Value) {
|
||||
let buf = app.state::<CommitBuffer>();
|
||||
let cur = buf.current_epoch();
|
||||
match v.get("type").and_then(|t| t.as_str()) {
|
||||
Some("partial") => {
|
||||
if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
|
||||
buf.text.lock().1 = text.to_string();
|
||||
let mut g = buf.inner.lock();
|
||||
// 仅当属于当前会话且未被丢弃才写入(18A/18B)。
|
||||
if g.text_epoch == cur && g.discarded_epoch != Some(cur) {
|
||||
g.text.1 = text.to_string();
|
||||
}
|
||||
}
|
||||
let state = app.state::<DictationState>();
|
||||
let mut guard = state.inner.lock();
|
||||
@@ -185,10 +285,19 @@ pub fn on_server_msg(app: &AppHandle, v: &Value) {
|
||||
}
|
||||
Some("final") => {
|
||||
if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
|
||||
let mut t = buf.text.lock();
|
||||
t.0.push_str(text);
|
||||
t.1.clear();
|
||||
let mut g = buf.inner.lock();
|
||||
// 校验会话:不属当前会话或已被取消 → 丢弃,不写 buffer(18A/18B)。
|
||||
if g.text_epoch == cur && g.discarded_epoch != Some(cur) {
|
||||
g.text.0.push_str(text);
|
||||
g.text.1.clear();
|
||||
}
|
||||
}
|
||||
// 尾部 final 到达 → 唤醒收尾注入任务(18C)。
|
||||
buf.finalized.notify_waiters();
|
||||
}
|
||||
// usage 结算帧(参考 iOS):作为收尾兜底信号,立即触发注入(18C)。
|
||||
Some("usage") => {
|
||||
buf.finalized.notify_waiters();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -202,7 +311,7 @@ fn set_tray_tooltip(app: &AppHandle, text: &str) {
|
||||
}
|
||||
|
||||
fn show_overlay(app: &AppHandle) {
|
||||
if let Some(w) = app.get_webview_window("overlay") {
|
||||
if let Some(w) = app.get_webview_window(OVERLAY) {
|
||||
// 跟随光标:浮层出现在指针下方 24px,水平居中
|
||||
if let Ok(pos) = app.cursor_position() {
|
||||
let _ = w.set_position(tauri::PhysicalPosition::new(pos.x - 180.0, pos.y + 24.0));
|
||||
@@ -212,7 +321,7 @@ fn show_overlay(app: &AppHandle) {
|
||||
}
|
||||
|
||||
fn hide_overlay(app: &AppHandle) {
|
||||
if let Some(w) = app.get_webview_window("overlay") {
|
||||
if let Some(w) = app.get_webview_window(OVERLAY) {
|
||||
let _ = w.hide();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,15 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
/// 协议平台标识(18H):mac / win / linux(区别于 std::env::consts::OS 的 macos/windows)。
|
||||
const PLATFORM: &str = if cfg!(target_os = "macos") {
|
||||
"mac"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"win"
|
||||
} else {
|
||||
"linux"
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Metrics {
|
||||
queue: Arc<Mutex<Vec<Value>>>,
|
||||
@@ -48,7 +57,8 @@ pub fn spawn(app: AppHandle) -> Metrics {
|
||||
};
|
||||
let body = json!({
|
||||
"device_id": device_id,
|
||||
"platform": std::env::consts::OS,
|
||||
// 协议平台标识 mac/win/linux(18H,区别于 std::env::consts::OS 的 macos/windows)。
|
||||
"platform": PLATFORM,
|
||||
"app_version": env!("CARGO_PKG_VERSION"),
|
||||
"os_version": "",
|
||||
"events": events,
|
||||
|
||||
+122
-13
@@ -2,13 +2,25 @@
|
||||
//! 上行音频二进制帧 / 控制 JSON,下行 ServerMsg → emit("asr") + 维护听写文本状态。
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tauri::{AppHandle, Emitter, EventTarget, Manager};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
/// 音频命令 channel 容量(18F):200 帧 × 100ms = 20s 音频,满时丢最旧帧避免无限堆积。
|
||||
const CMD_CAP: usize = 200;
|
||||
|
||||
/// 浮层窗口 label(仅 overlay 监听 asr 事件)。
|
||||
const OVERLAY: &str = "overlay";
|
||||
|
||||
/// 定向 emit 到 overlay 窗口(18G)。
|
||||
fn emit_overlay(app: &AppHandle, event: &str, payload: Value) {
|
||||
let _ = app.emit_to(EventTarget::webview_window(OVERLAY), event, payload);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum WsCmd {
|
||||
Start { session_id: String },
|
||||
@@ -20,12 +32,28 @@ pub enum WsCmd {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WsHandle {
|
||||
pub tx: mpsc::UnboundedSender<WsCmd>,
|
||||
pub tx: mpsc::Sender<WsCmd>,
|
||||
}
|
||||
|
||||
impl WsHandle {
|
||||
/// 投递命令;channel 满时(音频积压)丢弃当前帧并记录,避免无限堆积(18F)。
|
||||
/// 对非音频控制帧仍尽量投递(try_send 满时记录但不阻塞)。
|
||||
pub fn send(&self, cmd: WsCmd) -> Result<(), ()> {
|
||||
match self.tx.try_send(cmd) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(mpsc::error::TrySendError::Full(c)) => {
|
||||
log::warn!("ws cmd channel full, dropping frame: {c:?}");
|
||||
Err(())
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动常驻连接任务(应用启动即预连接,按键时零握手)。
|
||||
pub fn spawn(app: AppHandle) -> WsHandle {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<WsCmd>();
|
||||
// bounded channel(18F):容量 CMD_CAP,满时由 WsHandle::send 丢最旧/当前帧并记录。
|
||||
let (tx, mut rx) = mpsc::channel::<WsCmd>(CMD_CAP);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut backoff = Duration::from_millis(500);
|
||||
loop {
|
||||
@@ -52,12 +80,36 @@ pub fn spawn(app: AppHandle) -> WsHandle {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
req.headers_mut().insert(
|
||||
"Authorization",
|
||||
format!("Bearer {token}").parse().unwrap(),
|
||||
);
|
||||
req.headers_mut()
|
||||
.insert("X-Device-ID", device_id.parse().unwrap());
|
||||
// header 用 from_str 处理 Result(18D):token/device_id 含非法字符(换行/中文)
|
||||
// 时不 unwrap panic(否则杀死唯一 WS 泵任务致听写永久静默),而是 emit 错误并跳过本次连接。
|
||||
let auth = match HeaderValue::from_str(&format!("Bearer {token}")) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::error!("invalid Authorization header (token): {e}");
|
||||
emit_overlay(
|
||||
&app,
|
||||
"asr",
|
||||
json!({"type":"error","code":"AUTH_HEADER","message":"登录凭证异常,请重新登录"}),
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let dev = match HeaderValue::from_str(&device_id) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::error!("invalid X-Device-ID header: {e}");
|
||||
emit_overlay(
|
||||
&app,
|
||||
"asr",
|
||||
json!({"type":"error","code":"DEVICE_HEADER","message":"设备标识异常"}),
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
req.headers_mut().insert("Authorization", auth);
|
||||
req.headers_mut().insert("X-Device-ID", dev);
|
||||
|
||||
let conn = tokio_tungstenite::connect_async(req).await;
|
||||
let (mut sink, mut stream) = match conn {
|
||||
@@ -67,6 +119,18 @@ pub fn spawn(app: AppHandle) -> WsHandle {
|
||||
ws.split()
|
||||
}
|
||||
Err(e) => {
|
||||
// 401 握手被拒(18F):清 token + emit UNAUTHORIZED + 停止无脑重连。
|
||||
if is_unauthorized(&e) {
|
||||
log::warn!("ws handshake unauthorized (401); clearing token");
|
||||
clear_token(&app);
|
||||
emit_overlay(
|
||||
&app,
|
||||
"asr",
|
||||
json!({"type":"error","code":"UNAUTHORIZED","message":"登录已失效,请重新登录"}),
|
||||
);
|
||||
// token 已清空,loop 顶部会进入"未登录"分支等待重新登录后的 Reconnect。
|
||||
continue;
|
||||
}
|
||||
log::warn!("ws connect failed: {e}; retry in {backoff:?}");
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(Duration::from_secs(8));
|
||||
@@ -74,6 +138,10 @@ pub fn spawn(app: AppHandle) -> WsHandle {
|
||||
}
|
||||
};
|
||||
|
||||
// 重连成功(18E):旧会话在断连期间的 start/stop 已丢失,服务端不认旧 session。
|
||||
// 不续旧会话——清空积压的旧会话音频帧 + 停止当前录音 + emit 会话级错误,避免静默丢音频。
|
||||
abort_active_session(&app, &mut rx);
|
||||
|
||||
let mut ping = tokio::time::interval(Duration::from_secs(30));
|
||||
'conn: loop {
|
||||
tokio::select! {
|
||||
@@ -90,7 +158,7 @@ pub fn spawn(app: AppHandle) -> WsHandle {
|
||||
WsCmd::Reconnect => break 'conn,
|
||||
};
|
||||
if sink.send(msg).await.is_err() {
|
||||
let _ = app.emit("asr", json!({"type":"error","code":"NETWORK"}));
|
||||
emit_overlay(&app, "asr", json!({"type":"error","code":"NETWORK"}));
|
||||
break 'conn;
|
||||
}
|
||||
}
|
||||
@@ -99,14 +167,14 @@ pub fn spawn(app: AppHandle) -> WsHandle {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
crate::dictation::on_server_msg(&app, &v);
|
||||
let _ = app.emit("asr", v);
|
||||
emit_overlay(&app, "asr", v);
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Ping(p))) => { let _ = sink.send(Message::Pong(p)).await; }
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(e)) => {
|
||||
log::warn!("ws read error: {e}");
|
||||
let _ = app.emit("asr", json!({"type":"error","code":"NETWORK"}));
|
||||
emit_overlay(&app, "asr", json!({"type":"error","code":"NETWORK"}));
|
||||
break 'conn;
|
||||
}
|
||||
None => break 'conn,
|
||||
@@ -123,3 +191,44 @@ pub fn spawn(app: AppHandle) -> WsHandle {
|
||||
});
|
||||
WsHandle { tx }
|
||||
}
|
||||
|
||||
/// 握手错误是否为 401 未授权(18F)。
|
||||
fn is_unauthorized(e: &tokio_tungstenite::tungstenite::Error) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
tokio_tungstenite::tungstenite::Error::Http(resp)
|
||||
if resp.status() == tokio_tungstenite::tungstenite::http::StatusCode::UNAUTHORIZED
|
||||
)
|
||||
}
|
||||
|
||||
/// 清空本地 token(18F):401 后需重新登录,避免用失效 token 无脑重连。
|
||||
fn clear_token(app: &AppHandle) {
|
||||
let store = app.state::<crate::settings::SettingsStore>();
|
||||
let mut s = store.get();
|
||||
if !s.token.is_empty() {
|
||||
s.token.clear();
|
||||
store.set(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// 重连成功后中止仍在进行的录音会话(18E):
|
||||
/// 通知 dictation 停止采集 + emit 会话级错误(让用户知道这次听写失败需重说),
|
||||
/// 并清空积压的旧会话音频/控制帧,避免发往新连接被服务端静默丢弃。
|
||||
fn abort_active_session(app: &AppHandle, rx: &mut mpsc::Receiver<WsCmd>) {
|
||||
if !crate::dictation::is_recording(app) {
|
||||
// 没有进行中的会话:仍清掉可能积压的陈旧帧。
|
||||
while rx.try_recv().is_ok() {}
|
||||
return;
|
||||
}
|
||||
log::warn!("ws reconnected mid-session; aborting active dictation session");
|
||||
// 停止采集(取消模式,丢弃已采集文本,不上屏)。
|
||||
crate::dictation::stop(app, true);
|
||||
// 清空积压的旧会话音频/控制帧(新连接无对应 session,服务端会静默丢弃)。
|
||||
while rx.try_recv().is_ok() {}
|
||||
// 会话级错误:告知用户本次听写因网络重连失败,需重说。
|
||||
emit_overlay(
|
||||
app,
|
||||
"asr",
|
||||
json!({"type":"error","code":"SESSION_ABORTED","message":"网络已重连,本次听写中断,请重试"}),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user