Files
dudu/desktop/src-tauri/src/api.rs
T
wangjia 40760aa884
ci / server (push) Failing after 14s
ci / design-tokens (push) Failing after 11s
dudu MVP:五端语音输入法初始提交
- 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>
2026-06-12 00:38:37 +08:00

293 lines
9.0 KiB
Rust

//! 后端 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);
}