40760aa884
- 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>
293 lines
9.0 KiB
Rust
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);
|
|
}
|