dudu MVP:五端语音输入法初始提交
ci / server (push) Failing after 14s
ci / design-tokens (push) Failing after 11s

- server:Go 网关(WS 流式识别中继/计费配额/微信登录支付 mock/反馈/埋点),gummy provider 已真实联调
- desktop:Tauri 2(全局快捷键 push-to-talk/浮层/托盘/设置/登录购买/反馈/首启引导)
- android:Compose 主 App + IME(键盘内录音直传)
- ios:App + 键盘扩展(1A spike 实证键盘内不可录音,走 deep link 听写)
- design/design-pipeline:设计系统 + token 导出 iOS/Android 主题
- doc:前后端设计文档(HTML);web:官网宣传页;todo:任务看板

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-12 00:38:37 +08:00
commit 40760aa884
252 changed files with 40789 additions and 0 deletions
+6019
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "dudu-desktop"
version = "0.1.0"
description = "dudu 语音输入法桌面端"
edition = "2021"
[lib]
name = "dudu_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-global-shortcut = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
futures-util = "0.3"
cpal = "0.15"
arboard = "3"
enigo = "0.2"
parking_lot = "0.12"
uuid = { version = "1", features = ["v4"] }
reqwest = { version = "0.12", features = ["json", "multipart", "rustls-tls"], default-features = false }
base64 = "0.22"
log = "0.4"
env_logger = "0.11"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "所有窗口的基础能力:核心 IPCinvoke/listen+ 全局快捷键",
"windows": ["settings", "overlay", "tray", "login", "feedback", "onboarding"],
"permissions": ["core:default", "global-shortcut:default"]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"所有窗口的基础能力:核心 IPCinvoke/listen+ 全局快捷键","local":true,"windows":["settings","overlay","tray","login","feedback","onboarding"],"permissions":["core:default","global-shortcut:default"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+292
View File
@@ -0,0 +1,292 @@
//! 后端 HTTP API 封装 + 窗口控制命令。
use serde_json::{json, Value};
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::{AppHandle, Manager};
/// 全局暂停开关(11B 托盘菜单"暂停使用"):暂停时快捷键不再触发听写。
#[derive(Default)]
pub struct Paused(AtomicBool);
pub fn is_app_paused(app: &AppHandle) -> bool {
app.state::<Paused>().0.load(Ordering::Relaxed)
}
#[tauri::command]
pub fn is_paused(app: AppHandle) -> bool {
is_app_paused(&app)
}
/// 切换暂停状态,返回新状态。
#[tauri::command]
pub fn toggle_pause(app: AppHandle) -> bool {
let paused = &app.state::<Paused>().0;
let next = !paused.load(Ordering::Relaxed);
paused.store(next, Ordering::Relaxed);
if next {
crate::dictation::stop(&app, true); // 录音中暂停 → 直接取消
}
if let Some(tray) = app.tray_by_id("main") {
let _ = tray.set_tooltip(Some(if next { "dudu — 已暂停" } else { "dudu — 就绪" }));
}
next
}
fn base(app: &AppHandle) -> (String, String) {
let s = app.state::<crate::settings::SettingsStore>().get();
(s.server_url, s.token)
}
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}"));
if authed {
if token.is_empty() {
return None;
}
req = req.bearer_auth(token);
}
let resp = req.send().await.ok()?;
if !resp.status().is_success() {
return None;
}
resp.json().await.ok()
}
#[tauri::command]
pub async fn fetch_me(app: AppHandle) -> Option<Value> {
get_json(&app, "/v1/me", true).await
}
#[tauri::command]
pub async fn fetch_packs(app: AppHandle) -> Option<Value> {
let v = get_json(&app, "/v1/packs", false).await?;
v.get("packs").cloned()
}
#[tauri::command]
pub async fn login_qr_create(app: AppHandle) -> Option<Value> {
let (url, _) = base(&app);
reqwest::Client::new()
.post(format!("{url}/v1/auth/qr"))
.send()
.await
.ok()?
.json()
.await
.ok()
}
#[tauri::command]
pub async fn login_qr_poll(app: AppHandle, state: String) -> Option<Value> {
let v = get_json(&app, &format!("/v1/auth/qr/{state}"), false).await?;
if v.get("status").and_then(|s| s.as_str()) == Some("confirmed") {
if let Some(token) = v.get("token").and_then(|t| t.as_str()) {
let store = app.state::<crate::settings::SettingsStore>();
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);
}
}
Some(v)
}
#[tauri::command]
pub async fn create_order(app: AppHandle, pack_id: String) -> Option<Value> {
let (url, token) = base(&app);
reqwest::Client::new()
.post(format!("{url}/v1/orders"))
.bearer_auth(token)
.json(&serde_json::json!({"pack_id": pack_id, "channel": "native"}))
.send()
.await
.ok()?
.json()
.await
.ok()
}
#[tauri::command]
pub async fn order_status(app: AppHandle, order_id: String) -> Option<Value> {
get_json(&app, &format!("/v1/orders/{order_id}"), true).await
}
// ─── 反馈(11E)──────────────────────────────────────────────────────────────
#[derive(serde::Deserialize)]
pub struct FeedbackImage {
pub name: String,
/// base64(不含 data: 前缀)
pub data: String,
}
fn image_mime(name: &str) -> &'static str {
let lower = name.to_ascii_lowercase();
if lower.ends_with(".png") {
"image/png"
} else if lower.ends_with(".webp") {
"image/webp"
} else {
"image/jpeg"
}
}
#[cfg(target_os = "macos")]
fn os_version() -> String {
std::process::Command::new("sw_vers")
.arg("-productVersion")
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default()
}
#[cfg(not(target_os = "macos"))]
fn os_version() -> String {
String::new()
}
/// multipart POST /v1/feedback:文字 + 图片(≤3 张 ≤5MB)+ 可选诊断信息。
/// 错误以字符串 code 返回:NOT_LOGGED_IN / FEEDBACK_RATE_LIMITED / HTTP_xxx / 网络错误描述。
#[tauri::command]
pub async fn submit_feedback(
app: AppHandle,
content: String,
images: Vec<FeedbackImage>,
include_diagnostics: bool,
) -> Result<Value, String> {
let (url, token) = base(&app);
if token.is_empty() {
return Err("NOT_LOGGED_IN".into());
}
let app_version = app.package_info().version.to_string();
let mut form = reqwest::multipart::Form::new().text("content", content);
for img in images.into_iter().take(3) {
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(img.data.as_bytes())
.map_err(|e| e.to_string())?;
let part = reqwest::multipart::Part::bytes(bytes)
.mime_str(image_mime(&img.name))
.map_err(|e| e.to_string())?
.file_name(img.name);
form = form.part("images[]", part);
}
if include_diagnostics {
let s = app.state::<crate::settings::SettingsStore>().get();
let diag = json!({
"app_version": app_version,
"platform": std::env::consts::OS,
"os_version": os_version(),
"device_id": s.device_id,
});
form = form.text("diagnostics", diag.to_string());
}
let resp = reqwest::Client::new()
.post(format!("{url}/v1/feedback"))
.bearer_auth(token)
.header("X-Platform", std::env::consts::OS)
.header("X-App-Version", app_version)
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?;
match resp.status().as_u16() {
200 => resp.json().await.map_err(|e| e.to_string()),
429 => Err("FEEDBACK_RATE_LIMITED".into()),
s => Err(format!("HTTP_{s}")),
}
}
// ─── 窗口控制 ────────────────────────────────────────────────────────────────
#[tauri::command]
pub fn open_settings(app: AppHandle) {
if let Some(w) = app.get_webview_window("settings") {
let _ = w.show();
let _ = w.set_focus();
}
}
#[tauri::command]
pub fn open_login(app: AppHandle) {
if let Some(w) = app.get_webview_window("login") {
let _ = w.show();
let _ = w.set_focus();
}
}
#[tauri::command]
pub fn close_login(app: AppHandle) {
if let Some(w) = app.get_webview_window("login") {
let _ = w.hide();
}
}
#[tauri::command]
pub fn open_feedback(app: AppHandle) {
if let Some(w) = app.get_webview_window("feedback") {
let _ = w.show();
let _ = w.set_focus();
}
}
#[tauri::command]
pub fn close_feedback(app: AppHandle) {
if let Some(w) = app.get_webview_window("feedback") {
let _ = w.hide();
}
}
/// 完成首次启动引导(11F):置 onboarding_done 并关闭引导窗。
#[tauri::command]
pub fn finish_onboarding(app: AppHandle) {
let store = app.state::<crate::settings::SettingsStore>();
let mut s = store.get();
if !s.onboarding_done {
s.onboarding_done = true;
store.set(s);
}
if let Some(w) = app.get_webview_window("onboarding") {
let _ = w.hide();
}
}
/// 检查更新:GET /v1/app/latest,有新版则打开下载页;返回结果供 UI 提示。
#[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 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() {
open_external(dl);
}
Some(json!({"current": current, "latest": version, "update_available": available, "url": dl}))
}
/// 用系统默认方式打开外部链接。
fn open_external(url: &str) {
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open").arg(url).spawn();
#[cfg(target_os = "windows")]
let _ = std::process::Command::new("cmd").args(["/C", "start", "", url]).spawn();
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
}
#[tauri::command]
pub fn quit_app(app: AppHandle) {
app.exit(0);
}
+185
View File
@@ -0,0 +1,185 @@
//! 音频采集:cpal 输入流 → 单声道 → 线性重采样 16kHz i16 → 100ms 帧(3200B)。
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use std::sync::mpsc::Sender;
pub const TARGET_RATE: u32 = 16000;
pub const FRAME_SAMPLES: usize = 1600; // 100ms @16k
pub fn list_devices() -> Vec<String> {
let host = cpal::default_host();
host.input_devices()
.map(|it| it.filter_map(|d| d.name().ok()).collect())
.unwrap_or_default()
}
/// 触发麦克风授权(11F 引导页):短暂起一个输入流,首次会弹系统授权框。
/// 返回 true 表示流可建立(设备可用 / 已授权或用户刚授权)。
#[tauri::command]
pub async fn request_microphone() -> bool {
tauri::async_runtime::spawn_blocking(|| {
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
match start("", tx) {
Ok(capture) => {
std::thread::sleep(std::time::Duration::from_millis(300));
drop(rx);
drop(capture);
true
}
Err(_) => false,
}
})
.await
.unwrap_or(false)
}
/// 采集句柄:drop 即停止。cpal::Stream 在 macOS 上非 Send
/// 因此流由专用线程持有,句柄只保留停止信号端。
pub struct Capture {
stop: Option<Sender<()>>,
}
impl Drop for Capture {
fn drop(&mut self) {
self.stop.take(); // 关闭通道 → 采集线程退出并销毁流
}
}
/// 启动采集;每凑满 100ms 帧经 `tx` 发出。
pub fn start(device_name: &str, tx: Sender<Vec<u8>>) -> Result<Capture, String> {
let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>();
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
let device_name = device_name.to_string();
std::thread::spawn(move || {
let built = build_stream(&device_name, tx);
match built {
Ok(stream) => {
if let Err(e) = stream.play() {
let _ = ready_tx.send(Err(e.to_string()));
return;
}
let _ = ready_tx.send(Ok(()));
// 阻塞直至句柄 drop(发送端关闭 → recv Err
let _ = stop_rx.recv();
drop(stream);
}
Err(e) => {
let _ = ready_tx.send(Err(e));
}
}
});
ready_rx
.recv()
.map_err(|_| "采集线程异常退出".to_string())??;
Ok(Capture { stop: Some(stop_tx) })
}
fn build_stream(device_name: &str, tx: Sender<Vec<u8>>) -> Result<cpal::Stream, String> {
let host = cpal::default_host();
let device = if device_name.is_empty() {
host.default_input_device()
} else {
host.input_devices()
.ok()
.and_then(|mut it| it.find(|d| d.name().map(|n| n == device_name).unwrap_or(false)))
.or_else(|| host.default_input_device())
}
.ok_or("没有可用麦克风")?;
let config = device.default_input_config().map_err(|e| e.to_string())?;
let src_rate = config.sample_rate().0;
let channels = config.channels() as usize;
let mut resampler = Resampler::new(src_rate, channels, tx);
let err_fn = |e| log::error!("audio stream error: {e}");
match config.sample_format() {
cpal::SampleFormat::F32 => device.build_input_stream(
&config.into(),
move |data: &[f32], _| resampler.push_f32(data),
err_fn,
None,
),
cpal::SampleFormat::I16 => device.build_input_stream(
&config.into(),
move |data: &[i16], _| resampler.push_i16(data),
err_fn,
None,
),
f => return Err(format!("不支持的采样格式: {f:?}")),
}
.map_err(|e| e.to_string())
}
/// 线性插值重采样 + 分帧。
struct Resampler {
src_rate: u32,
channels: usize,
pos: f64,
step: f64,
prev: f32,
buf: Vec<i16>,
tx: Sender<Vec<u8>>,
}
impl Resampler {
fn new(src_rate: u32, channels: usize, tx: Sender<Vec<u8>>) -> Self {
Self {
src_rate,
channels,
pos: 0.0,
step: src_rate as f64 / TARGET_RATE as f64,
prev: 0.0,
buf: Vec::with_capacity(FRAME_SAMPLES * 2),
tx,
}
}
fn push_f32(&mut self, data: &[f32]) {
let mono: Vec<f32> = data
.chunks(self.channels)
.map(|c| c.iter().sum::<f32>() / self.channels as f32)
.collect();
self.resample(&mono);
}
fn push_i16(&mut self, data: &[i16]) {
let mono: Vec<f32> = data
.chunks(self.channels)
.map(|c| c.iter().map(|&s| s as f32 / 32768.0).sum::<f32>() / self.channels as f32)
.collect();
self.resample(&mono);
}
fn resample(&mut self, mono: &[f32]) {
if self.src_rate == TARGET_RATE {
for &s in mono {
self.emit(s);
}
return;
}
for &s in mono {
while self.pos < 1.0 {
let v = self.prev + (s - self.prev) * self.pos as f32;
self.emit(v);
self.pos += self.step;
}
self.pos -= 1.0;
self.prev = s;
}
}
fn emit(&mut self, sample: f32) {
let v = (sample.clamp(-1.0, 1.0) * 32767.0) as i16;
self.buf.push(v);
if self.buf.len() >= FRAME_SAMPLES {
let frame: Vec<u8> = self.buf[..FRAME_SAMPLES]
.iter()
.flat_map(|s| s.to_le_bytes())
.collect();
self.buf.drain(..FRAME_SAMPLES);
let _ = self.tx.send(frame);
}
}
}
+218
View File
@@ -0,0 +1,218 @@
//! 听写编排(push-to-talk 核心):
//! 快捷键按下 → 浮层弹出(光标附近,不抢焦点)→ 采集推流;
//! 松开 → stop → 等尾部 final → 注入 committed 文本 → 浮层淡出。
use parking_lot::Mutex;
use serde_json::{json, Value};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, Manager};
#[derive(Default)]
pub struct DictationState {
inner: Mutex<Option<Session>>,
}
struct Session {
id: String,
capture: Option<crate::audio::Capture>,
final_text: String,
partial_text: String,
started: Instant,
got_first_partial: bool,
}
pub fn start(app: &AppHandle) {
let state = app.state::<DictationState>();
if state.inner.lock().is_some() {
return; // 已在录音
}
if crate::api::is_app_paused(app) {
return; // 已暂停使用
}
let session_id = uuid::Uuid::new_v4().to_string();
set_tray_tooltip(app, "dudu — 录音中");
let _ = app.emit("hotkey", json!({"state": "down"}));
show_overlay(app);
let ws = app.state::<crate::ws::WsHandle>().tx.clone();
let _ = ws.send(crate::ws::WsCmd::Start {
session_id: session_id.clone(),
});
// 音频帧经 std channel → 转发 tokio channel
let (tx, rx) = mpsc::channel::<Vec<u8>>();
let mic = app.state::<crate::settings::SettingsStore>().get().mic;
let t0 = Instant::now();
let capture = match crate::audio::start(&mic, tx) {
Ok(c) => {
let m = app.state::<crate::metrics::Metrics>();
m.record("audio.start_ms", json!({"ms": t0.elapsed().as_millis() as i64}));
Some(c)
}
Err(e) => {
log::error!("audio start failed: {e}");
let _ = app.emit("asr", json!({"type":"error","code":"AUDIO","message": e}));
None
}
};
{
let ws = ws.clone();
std::thread::spawn(move || {
while let Ok(frame) = rx.recv() {
if ws.send(crate::ws::WsCmd::Audio(frame)).is_err() {
break;
}
}
});
}
*state.inner.lock() = Some(Session {
id: session_id,
capture,
final_text: String::new(),
partial_text: String::new(),
started: Instant::now(),
got_first_partial: false,
});
}
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"}));
set_tray_tooltip(app, "dudu — 就绪");
sess.capture.take(); // 停止采集
let ws = app.state::<crate::ws::WsHandle>().tx.clone();
let cmd = if canceled {
crate::ws::WsCmd::Cancel { session_id: sess.id.clone() }
} else {
crate::ws::WsCmd::Stop { session_id: sess.id.clone() }
};
let _ = ws.send(cmd);
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()
};
if !committed.is_empty() {
if !crate::inject::accessibility_ok() {
// 无辅助功能权限(macOS):注入必失败 → 浮层引导去授权(10E)
let _ = app.emit(
"asr",
json!({"type":"error","code":"NO_ACCESSIBILITY","message":"需要辅助功能权限"}),
);
tokio::time::sleep(Duration::from_millis(2400)).await; // 让用户看清引导
} else {
let t = committed.clone();
let injected =
tauri::async_runtime::spawn_blocking(move || crate::inject::inject_text(&t)).await;
match injected {
Ok(Ok(())) => {
let m = app.state::<crate::metrics::Metrics>();
m.record(
"asr.release_to_commit_ms",
json!({"ms": release_at.elapsed().as_millis() as i64}),
);
}
_ => {
log::error!("inject failed: {injected:?}");
if !crate::inject::accessibility_ok() {
let _ = app.emit(
"asr",
json!({"type":"error","code":"NO_ACCESSIBILITY","message":"需要辅助功能权限"}),
);
tokio::time::sleep(Duration::from_millis(2400)).await;
}
}
}
}
}
} else {
app.state::<CommitBuffer>().take();
}
hide_overlay(&app);
});
}
/// CommitBuffer 跨 ws 任务与停止流程共享的"待注入文本"。
#[derive(Default)]
pub struct CommitBuffer {
text: Mutex<(String, String)>, // (final 累计, 最新 partial)
}
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
}
}
/// ws 下行回调:维护文本缓冲 + 首字延迟打点(在 ws.rs 收包处调用)。
pub fn on_server_msg(app: &AppHandle, v: &Value) {
let buf = app.state::<CommitBuffer>();
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 state = app.state::<DictationState>();
let mut guard = state.inner.lock();
if let Some(sess) = guard.as_mut() {
if !sess.got_first_partial {
sess.got_first_partial = true;
let m = app.state::<crate::metrics::Metrics>();
m.record(
"asr.first_partial_ms",
json!({"ms": sess.started.elapsed().as_millis() as i64}),
);
}
}
}
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();
}
}
_ => {}
}
}
/// 托盘提示按录音状态切换(11B)。
fn set_tray_tooltip(app: &AppHandle, text: &str) {
if let Some(tray) = app.tray_by_id("main") {
let _ = tray.set_tooltip(Some(text));
}
}
fn show_overlay(app: &AppHandle) {
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));
}
let _ = w.show();
}
}
fn hide_overlay(app: &AppHandle) {
if let Some(w) = app.get_webview_window("overlay") {
let _ = w.hide();
}
}
+74
View File
@@ -0,0 +1,74 @@
//! 文字注入(10E/10F):剪贴板写入 → 模拟粘贴(mac ⌘V / win Ctrl+V)→ 恢复剪贴板。
//! 兼容性最好的方案;macOS 需辅助功能权限(首次启动引导授予)。
use enigo::{Direction, Enigo, Key, Keyboard, Settings as EnigoSettings};
use std::{thread, time::Duration};
// ─── macOS 辅助功能权限(10E)────────────────────────────────────────────────
#[cfg(target_os = "macos")]
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
/// 进程是否已被授予辅助功能权限(不弹系统提示)。
fn AXIsProcessTrusted() -> u8;
}
/// 是否已具备注入所需的辅助功能权限;非 macOS 恒为 true。
pub fn accessibility_ok() -> bool {
#[cfg(target_os = "macos")]
{
unsafe { AXIsProcessTrusted() != 0 }
}
#[cfg(not(target_os = "macos"))]
{
true
}
}
#[tauri::command]
pub fn check_accessibility() -> bool {
accessibility_ok()
}
/// 打开系统设置的辅助功能面板(macOS);其他平台 no-op。
#[tauri::command]
pub fn open_accessibility_settings() {
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
.spawn();
}
}
pub fn inject_text(text: &str) -> Result<(), String> {
if text.is_empty() {
return Ok(());
}
let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
let saved = clipboard.get_text().ok();
clipboard.set_text(text).map_err(|e| e.to_string())?;
thread::sleep(Duration::from_millis(30)); // 等剪贴板生效
let mut enigo = Enigo::new(&EnigoSettings::default()).map_err(|e| e.to_string())?;
#[cfg(target_os = "macos")]
let modifier = Key::Meta;
#[cfg(not(target_os = "macos"))]
let modifier = Key::Control;
enigo.key(modifier, Direction::Press).map_err(|e| e.to_string())?;
enigo
.key(Key::Unicode('v'), Direction::Click)
.map_err(|e| e.to_string())?;
enigo
.key(modifier, Direction::Release)
.map_err(|e| e.to_string())?;
// 粘贴完成后恢复用户剪贴板
thread::sleep(Duration::from_millis(120));
if let Some(old) = saved {
let _ = clipboard.set_text(old);
}
Ok(())
}
+155
View File
@@ -0,0 +1,155 @@
//! dudu 桌面端(Tauri 2)。模块划分见 doc/frontend-design.html 4.1。
pub mod api;
pub mod audio;
pub mod dictation;
pub mod inject;
pub mod metrics;
pub mod settings;
pub mod ws;
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::Manager;
use tauri_plugin_global_shortcut::{Shortcut, ShortcutState};
/// 托盘图标 rect → 菜单窗口左上角物理坐标(图标正下方、水平居中)。
fn tray_menu_position(rect: &tauri::Rect, scale: f64, win_width: f64) -> (f64, f64) {
let (x, y) = match rect.position {
tauri::Position::Physical(p) => (p.x as f64, p.y as f64),
tauri::Position::Logical(p) => (p.x * scale, p.y * scale),
};
let (w, h) = match rect.size {
tauri::Size::Physical(s) => (s.width as f64, s.height as f64),
tauri::Size::Logical(s) => (s.width * scale, s.height * scale),
};
(x + w / 2.0 - win_width / 2.0, y + h + 4.0 * scale)
}
/// 左键点击托盘图标 → 在图标位置弹出 / 收起自绘菜单窗(11B)。
fn toggle_tray_menu(app: &tauri::AppHandle, rect: &tauri::Rect) {
let Some(w) = app.get_webview_window("tray") else {
return;
};
if w.is_visible().unwrap_or(false) {
let _ = w.hide();
return;
}
let scale = w.scale_factor().unwrap_or(1.0);
let win_width = w
.outer_size()
.map(|s| s.width as f64)
.unwrap_or(248.0 * scale);
let (x, y) = tray_menu_position(rect, scale, win_width);
let _ = w.set_position(tauri::PhysicalPosition::new(x, y));
let _ = w.show();
let _ = w.set_focus(); // 获焦后才能在失焦时自动收起
}
pub fn run() {
env_logger::init();
tauri::Builder::default()
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, _shortcut: &Shortcut, event| {
match event.state() {
ShortcutState::Pressed => dictation::start(app),
ShortcutState::Released => dictation::stop(app, false),
}
})
.build(),
)
.on_window_event(|window, event| match event {
// 托盘菜单窗失焦自动收起
tauri::WindowEvent::Focused(false) if window.label() == "tray" => {
let _ = window.hide();
}
// 常驻托盘:功能窗口"关闭"仅隐藏,应用不退出
tauri::WindowEvent::CloseRequested { api, .. } => {
match window.label() {
"settings" | "login" | "feedback" => {
api.prevent_close();
let _ = window.hide();
}
"onboarding" => {
// 关闭引导窗 = 完成引导(11F)
api.prevent_close();
api::finish_onboarding(window.app_handle().clone());
}
_ => {}
}
}
_ => {}
})
.setup(|app| {
let handle = app.handle().clone();
// 设置 → 管理状态
app.manage(settings::SettingsStore::load(&handle));
app.manage(dictation::DictationState::default());
app.manage(dictation::CommitBuffer::default());
app.manage(api::Paused::default());
// WS 预连接 + 打点队列
app.manage(ws::spawn(handle.clone()));
app.manage(metrics::spawn(handle.clone()));
// 系统托盘(11B):左键弹出自绘菜单窗,tooltip 随录音状态切换
let icon = app
.default_window_icon()
.cloned()
.expect("missing app icon");
TrayIconBuilder::with_id("main")
.icon(icon)
.tooltip("dudu — 就绪")
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
rect,
..
} = event
{
toggle_tray_menu(tray.app_handle(), &rect);
}
})
.build(app)?;
// 注册 push-to-talk 快捷键
let s = app.state::<settings::SettingsStore>().get();
use tauri_plugin_global_shortcut::GlobalShortcutExt;
if let Err(e) = app.global_shortcut().register(s.hotkey.as_str()) {
log::error!("register hotkey failed: {e}");
}
// 首次启动 → 引导窗(11F);之后均静默启动(仅托盘常驻)
if !s.onboarding_done {
if let Some(w) = app.get_webview_window("onboarding") {
let _ = w.show();
let _ = w.set_focus();
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
settings::get_settings,
settings::set_settings,
settings::list_mic_devices,
audio::request_microphone,
inject::check_accessibility,
inject::open_accessibility_settings,
api::fetch_me,
api::fetch_packs,
api::login_qr_create,
api::login_qr_poll,
api::create_order,
api::order_status,
api::submit_feedback,
api::is_paused,
api::toggle_pause,
api::open_settings,
api::open_login,
api::close_login,
api::open_feedback,
api::close_feedback,
api::finish_onboarding,
api::check_update,
api::quit_app,
])
.run(tauri::generate_context!())
.expect("error while running dudu");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
dudu_desktop_lib::run()
}
+67
View File
@@ -0,0 +1,67 @@
//! 客户端打点(10H):内存队列,满 20 条或 60s 批量上报;失败丢弃不影响功能。
use parking_lot::Mutex;
use serde_json::{json, Value};
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Manager};
#[derive(Clone)]
pub struct Metrics {
queue: Arc<Mutex<Vec<Value>>>,
}
impl Metrics {
pub fn record(&self, event: &str, props: Value) {
let mut q = self.queue.lock();
q.push(json!({
"event": event,
"props": props,
"client_ts": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0),
}));
if q.len() > 1000 {
let overflow = q.len() - 1000;
q.drain(..overflow); // 上限丢最旧
}
}
}
pub fn spawn(app: AppHandle) -> Metrics {
let m = Metrics {
queue: Arc::new(Mutex::new(Vec::new())),
};
let queue = m.queue.clone();
tauri::async_runtime::spawn(async move {
let client = reqwest::Client::new();
let mut tick = tokio::time::interval(Duration::from_secs(10));
loop {
tick.tick().await;
let (events, url, device_id) = {
let mut q = queue.lock();
if q.is_empty() || (q.len() < 20 && tickcount_skip(&q)) {
continue;
}
let events: Vec<Value> = q.drain(..).collect();
let s = app.state::<crate::settings::SettingsStore>().get();
(events, format!("{}/v1/metrics/batch", s.server_url), s.device_id)
};
let body = json!({
"device_id": device_id,
"platform": std::env::consts::OS,
"app_version": env!("CARGO_PKG_VERSION"),
"os_version": "",
"events": events,
});
let _ = client.post(&url).json(&body).send().await; // 失败即丢弃
}
});
m
}
// 不满 20 条时按 60s 节奏(tick 10s × 6 次取模近似)上报;保持实现极简。
fn tickcount_skip(_q: &[Value]) -> bool {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
N.fetch_add(1, Ordering::Relaxed) % 6 != 0
}
+94
View File
@@ -0,0 +1,94 @@
//! 应用设置:本地 JSON 持久化(MVP;token 后续迁系统钥匙串)。
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::Manager;
#[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
pub hotkey: String,
pub mic: String,
pub sound: bool,
pub autostart: bool,
pub theme: String,
pub server_url: String,
pub token: String,
pub device_id: String,
/// 首次启动引导是否已完成(11F)。
pub onboarding_done: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
hotkey: "CmdOrCtrl+Shift+Space".into(),
mic: String::new(),
sound: false,
autostart: true,
theme: "system".into(),
server_url: "http://localhost:8080".into(),
token: String::new(),
device_id: uuid::Uuid::new_v4().to_string(),
onboarding_done: false,
}
}
}
pub struct SettingsStore {
path: PathBuf,
pub current: Mutex<Settings>,
}
impl SettingsStore {
pub fn load(app: &tauri::AppHandle) -> Self {
let dir = app
.path()
.app_config_dir()
.unwrap_or_else(|_| std::env::temp_dir());
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("settings.json");
let current = std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
let store = Self {
path,
current: Mutex::new(current),
};
store.save(); // 首次生成 device_id 后立即落盘
store
}
pub fn save(&self) {
let s = self.current.lock().clone();
if let Ok(b) = serde_json::to_vec_pretty(&s) {
let _ = std::fs::write(&self.path, b);
}
}
pub fn get(&self) -> Settings {
self.current.lock().clone()
}
pub fn set(&self, s: Settings) {
*self.current.lock() = s;
self.save();
}
}
#[tauri::command]
pub fn get_settings(store: tauri::State<SettingsStore>) -> Settings {
store.get()
}
#[tauri::command]
pub fn set_settings(store: tauri::State<SettingsStore>, settings: Settings) {
store.set(settings);
}
#[tauri::command]
pub fn list_mic_devices() -> Vec<String> {
crate::audio::list_devices()
}
+125
View File
@@ -0,0 +1,125 @@
//! WS 客户端(10C):预连接 + 心跳 + 指数退避重连;
//! 上行音频二进制帧 / 控制 JSON,下行 ServerMsg → emit("asr") + 维护听写文本状态。
use futures_util::{SinkExt, StreamExt};
use serde_json::json;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message;
#[derive(Debug)]
pub enum WsCmd {
Start { session_id: String },
Audio(Vec<u8>),
Stop { session_id: String },
Cancel { session_id: String },
Reconnect, // 设置变更(token/服务器)后强制重连
}
#[derive(Clone)]
pub struct WsHandle {
pub tx: mpsc::UnboundedSender<WsCmd>,
}
/// 启动常驻连接任务(应用启动即预连接,按键时零握手)。
pub fn spawn(app: AppHandle) -> WsHandle {
let (tx, mut rx) = mpsc::unbounded_channel::<WsCmd>();
tauri::async_runtime::spawn(async move {
let mut backoff = Duration::from_millis(500);
loop {
let (url, token, device_id) = {
let store = app.state::<crate::settings::SettingsStore>();
let s = store.get();
let ws_url = s.server_url.replace("http://", "ws://").replace("https://", "wss://");
(format!("{ws_url}/v1/asr/stream"), s.token, s.device_id)
};
if token.is_empty() {
// 未登录:等待命令但丢弃音频,定期重查
tokio::time::sleep(Duration::from_secs(2)).await;
while let Ok(cmd) = rx.try_recv() {
drop(cmd);
}
continue;
}
let mut req = match url.clone().into_client_request() {
Ok(r) => r,
Err(e) => {
log::error!("bad ws url: {e}");
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
}
};
req.headers_mut().insert(
"Authorization",
format!("Bearer {token}").parse().unwrap(),
);
req.headers_mut()
.insert("X-Device-ID", device_id.parse().unwrap());
let conn = tokio_tungstenite::connect_async(req).await;
let (mut sink, mut stream) = match conn {
Ok((ws, _)) => {
log::info!("ws connected");
backoff = Duration::from_millis(500);
ws.split()
}
Err(e) => {
log::warn!("ws connect failed: {e}; retry in {backoff:?}");
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(Duration::from_secs(8));
continue;
}
};
let mut ping = tokio::time::interval(Duration::from_secs(30));
'conn: loop {
tokio::select! {
cmd = rx.recv() => {
let Some(cmd) = cmd else { return };
let msg = match cmd {
WsCmd::Start { session_id } => Message::Text(
json!({"type":"start","session_id":session_id,"sample_rate":16000,"format":"pcm16"}).to_string()),
WsCmd::Audio(frame) => Message::Binary(frame),
WsCmd::Stop { session_id } => Message::Text(
json!({"type":"stop","session_id":session_id}).to_string()),
WsCmd::Cancel { session_id } => Message::Text(
json!({"type":"cancel","session_id":session_id}).to_string()),
WsCmd::Reconnect => break 'conn,
};
if sink.send(msg).await.is_err() {
let _ = app.emit("asr", json!({"type":"error","code":"NETWORK"}));
break 'conn;
}
}
incoming = stream.next() => {
match incoming {
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);
}
}
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"}));
break 'conn;
}
None => break 'conn,
}
}
_ = ping.tick() => {
if sink.send(Message::Ping(Vec::new())).await.is_err() { break 'conn; }
}
}
}
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(Duration::from_secs(8));
}
});
WsHandle { tx }
}
+90
View File
@@ -0,0 +1,90 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "dudu",
"version": "0.1.0",
"identifier": "app.dudu.desktop",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:5181",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "settings",
"title": "dudu 设置",
"url": "index.html",
"width": 440,
"height": 560,
"resizable": false,
"visible": false
},
{
"label": "overlay",
"title": "dudu",
"url": "overlay.html",
"width": 360,
"height": 140,
"visible": false,
"transparent": true,
"decorations": false,
"alwaysOnTop": true,
"focus": false,
"skipTaskbar": true,
"shadow": false
},
{
"label": "login",
"title": "dudu — 登录",
"url": "login.html",
"width": 400,
"height": 520,
"resizable": false,
"visible": false
},
{
"label": "tray",
"title": "dudu",
"url": "tray.html",
"width": 248,
"height": 300,
"resizable": false,
"visible": false,
"transparent": true,
"decorations": false,
"alwaysOnTop": true,
"skipTaskbar": true,
"shadow": false
},
{
"label": "feedback",
"title": "反馈问题",
"url": "feedback.html",
"width": 460,
"height": 560,
"resizable": false,
"center": true,
"visible": false
},
{
"label": "onboarding",
"title": "欢迎使用 dudu",
"url": "onboarding.html",
"width": 560,
"height": 480,
"resizable": false,
"center": true,
"visible": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": ["icons/icon.png"]
}
}