| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335 |
- use crate::app_state::AppState;
- use crate::poller;
- use crate::settings::SettingsStore;
- use crate::state::{PetState, PetStatePayload};
- use crate::token::TokenStore;
- use std::sync::atomic::Ordering;
- use tauri::{AppHandle, Emitter, Manager, State};
- #[tauri::command]
- pub async fn on_interact(app: AppHandle, action: String, _data: Option<String>) -> Result<(), String> {
- match action.as_str() {
- "click" => {
- // 有可一键完成的待办任务时优先完成它,否则回退到喝水打卡
- let pending = app
- .try_state::<AppState>()
- .and_then(|s| *s.pending_task.lock().unwrap());
- match pending {
- Some(id) => complete_task_by_id(&app, id).await,
- None => complete_water(&app).await,
- }
- }
- "pet" => {
- let _ = app.emit("pet://bubble", serde_json::json!({"text": "嘻嘻~", "durationMs": 2000}));
- Ok(())
- }
- _ => Ok(()),
- }
- }
- #[tauri::command]
- pub async fn drag_start(app: AppHandle) -> Result<(), String> {
- if let Some(win) = app.get_webview_window("main") {
- win.start_dragging().map_err(|e| e.to_string())?;
- }
- Ok(())
- }
- #[tauri::command]
- pub async fn drag_end() -> Result<(), String> {
- Ok(())
- }
- /// 站手跟随:前端「胸部悬停满 5s」后调用。locomotion 循环会接管窗口移动并回发 `pet://perch`。
- #[tauri::command]
- pub fn start_perch(state: State<AppState>) -> Result<(), String> {
- state.follow_cursor.store(true, Ordering::Relaxed);
- Ok(())
- }
- /// 退出站手跟随(前端主动中止;抖动甩飞是循环内自动退出)。
- #[tauri::command]
- pub fn stop_perch(state: State<AppState>) -> Result<(), String> {
- state.follow_cursor.store(false, Ordering::Relaxed);
- Ok(())
- }
- #[tauri::command]
- pub fn open_settings(app: AppHandle) -> Result<(), String> {
- if let Some(win) = app.get_webview_window("settings") {
- let _ = win.show();
- let _ = win.set_focus();
- }
- Ok(())
- }
- #[tauri::command]
- pub fn quit_app(app: AppHandle) -> Result<(), String> {
- app.exit(0);
- Ok(())
- }
- #[tauri::command]
- pub fn get_settings(
- _state: State<AppState>,
- settings: State<SettingsStore>,
- ) -> Result<serde_json::Value, String> {
- let cfg = settings.load();
- let has_token = TokenStore::new().load().map(|t| t.is_some()).unwrap_or(false);
- Ok(serde_json::json!({
- "baseUrl": cfg.base_url,
- "hasToken": has_token,
- "customMascotUrl": cfg.custom_mascot_url
- }))
- }
- #[tauri::command]
- pub async fn upload_mascot(
- app: AppHandle,
- settings: State<'_, SettingsStore>,
- filename: String,
- mime: String,
- data_base64: String,
- ) -> Result<String, String> {
- use base64::Engine;
- let bytes = base64::engine::general_purpose::STANDARD
- .decode(data_base64.as_bytes())
- .map_err(|e| format!("图片解码失败: {e}"))?;
- if bytes.is_empty() {
- return Err("图片内容为空".into());
- }
- if bytes.len() > 10 * 1024 * 1024 {
- return Err("图片过大(上限 10MB)".into());
- }
- // 生成 data URL(前端 canvas 生成动作帧用,规避远程图片跨域污染)。
- let data_url = {
- let m = if mime.is_empty() { "image/png".to_string() } else { mime.clone() };
- format!("data:{};base64,{}", m, base64::engine::general_purpose::STANDARD.encode(&bytes))
- };
- let state = app.state::<AppState>();
- let record = state
- .api
- .upload_media(&filename, bytes, &mime)
- .await
- .map_err(|e| format!("上传失败: {e}"))?;
- let url = record.url.ok_or_else(|| "上传响应缺少 url".to_string())?;
- let mut cfg = settings.load();
- cfg.custom_mascot_url = Some(url.clone());
- settings.save(&cfg)?;
- let _ = app.emit(
- "pet://custom-image",
- serde_json::json!({ "url": url, "dataUrl": data_url }),
- );
- Ok(url)
- }
- #[tauri::command]
- pub fn clear_custom_mascot(app: AppHandle, settings: State<SettingsStore>) -> Result<(), String> {
- let mut cfg = settings.load();
- cfg.custom_mascot_url = None;
- settings.save(&cfg)?;
- let _ = app.emit("pet://custom-image", serde_json::json!({ "url": null }));
- Ok(())
- }
- /// 拉取远程图片并转成 data URL(供前端 canvas 生成动作帧,规避跨域污染)。
- #[tauri::command]
- pub async fn data_url_from_url(url: String) -> Result<String, String> {
- if url.starts_with("data:") {
- return Ok(url);
- }
- let resp = reqwest::get(&url).await.map_err(|e| format!("请求失败: {e}"))?;
- if !resp.status().is_success() {
- return Err(format!("请求失败: HTTP {}", resp.status()));
- }
- let mime = resp
- .headers()
- .get(reqwest::header::CONTENT_TYPE)
- .and_then(|v| v.to_str().ok())
- .unwrap_or("image/png")
- .to_string();
- let bytes = resp.bytes().await.map_err(|e| format!("读取失败: {e}"))?;
- use base64::Engine;
- let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
- Ok(format!("data:{};base64,{}", mime, b64))
- }
- /// 微信小程序码扫码登录:取小程序码图片 → 前端展示 → 后台轮询换取 JWT → 写入 keyring。
- /// 二维码图片通过 `pet://wechat-qr` 事件回传({state, qrImage});
- /// 登录结果通过 `pet://wechat-login` 事件回传
- /// (status: pending / success / notfound / expired / error / timeout)。
- #[tauri::command]
- pub async fn start_wechat_login(app: AppHandle) -> Result<serde_json::Value, String> {
- let qr = {
- let state = app.state::<AppState>();
- state
- .api
- .wechat_web_qr()
- .await
- .map_err(|e| format!("获取二维码失败: {e}"))?
- };
- // 兼容旧版后端:只返回 {state,url}(微信开放平台 qrconnect 网页扫码)而无小程序码。
- // 此时新版登录流程无法继续,明确报错,避免静默展示空码让用户困惑。
- let qr_image = qr.qr_image.clone().unwrap_or_default();
- if qr_image.is_empty() && qr.url.as_deref().map(|u| !u.is_empty()).unwrap_or(false) {
- let _ = app.emit(
- "pet://wechat-login",
- serde_json::json!({
- "status": "error",
- "message": "服务端仍是旧版登录接口(微信开放平台 qrconnect),请部署最新后端(小程序码 /scan)后重试"
- }),
- );
- return Err("服务端登录接口版本过旧:缺少小程序码,需部署最新后端".into());
- }
- // 将小程序码图片推给设置窗口展示(用户用微信扫码后在小程序内确认登录)
- let _ = app.emit(
- "pet://wechat-qr",
- serde_json::json!({ "state": qr.state.clone(), "qrImage": qr_image }),
- );
- let api = app.state::<AppState>().api.clone();
- let state_token = qr.state.clone();
- let handle = app.clone();
- tauri::async_runtime::spawn(async move {
- // 最多轮询约 3 分钟(2s 间隔 × 90 次)
- for _ in 0..90 {
- tokio::time::sleep(std::time::Duration::from_secs(2)).await;
- match api.wechat_web_poll(&state_token).await {
- Ok(resp) => match resp.status.as_str() {
- "pending" => continue,
- "success" => {
- let Some(token) = resp.token else {
- let _ = handle.emit(
- "pet://wechat-login",
- serde_json::json!({"status": "error", "message": "响应缺少 token"}),
- );
- return;
- };
- if let Err(e) = TokenStore::new().save(&token) {
- let _ = handle.emit(
- "pet://wechat-login",
- serde_json::json!({"status": "error", "message": format!("保存令牌失败: {e}")}),
- );
- return;
- }
- api.update_token(token);
- if let Some(s) = handle.try_state::<AppState>() {
- restart_polling(&handle, &s);
- }
- let _ = handle.emit(
- "pet://wechat-login",
- serde_json::json!({
- "status": "success",
- "nickname": resp.nickname,
- "userId": resp.user_id,
- "role": resp.role
- }),
- );
- return;
- }
- other => {
- let _ = handle.emit(
- "pet://wechat-login",
- serde_json::json!({"status": other, "message": resp.message}),
- );
- return;
- }
- },
- Err(_) => continue, // 瞬时网络错误:继续轮询
- }
- }
- let _ = handle.emit("pet://wechat-login", serde_json::json!({"status": "timeout"}));
- });
- Ok(serde_json::json!({ "status": "pending", "qrImage": qr.qr_image }))
- }
- #[tauri::command]
- pub fn save_settings(
- app: AppHandle,
- state: State<AppState>,
- settings: State<SettingsStore>,
- base_url: String,
- token: Option<String>,
- ) -> Result<(), String> {
- let mut cfg = settings.load();
- cfg.base_url = base_url.trim().to_string();
- settings.save(&cfg)?;
- if let Some(t) = token {
- if !t.trim().is_empty() {
- TokenStore::new().save(t.trim())?;
- }
- }
- // 更新运行时客户端并重启轮询(对齐现 ShowSettings 的 Stop/Start)
- state.api.set_base_url(cfg.base_url.clone());
- if let Some(t) = TokenStore::new().load()? {
- state.api.update_token(t);
- }
- restart_polling(&app, &state);
- if let Some(win) = app.get_webview_window("settings") {
- let _ = win.hide();
- }
- Ok(())
- }
- fn restart_polling(app: &AppHandle, state: &State<AppState>) {
- if let Some(h) = state.poll_handle.lock().unwrap().take() {
- h.abort();
- }
- let handle = poller::spawn(app.clone(), state.api.clone());
- *state.poll_handle.lock().unwrap() = Some(handle);
- }
- async fn complete_water(app: &AppHandle) -> Result<(), String> {
- let state = app.state::<AppState>();
- let api = state.api.clone();
- match api.complete_water().await {
- Ok(_) => {
- let payload = PetStatePayload {
- state: PetState::Happy,
- reason: Some("drink_complete".into()),
- data: None,
- };
- let _ = app.emit("pet://state", payload);
- let _ = app.emit("pet://bubble", serde_json::json!({"text": "喝水打卡成功!太棒了~", "durationMs": 4000}));
- Ok(())
- }
- Err(_) => {
- let _ = app.emit("pet://bubble", serde_json::json!({"text": "打卡失败,请稍后再试", "durationMs": 4000}));
- Err("checkin failed".into())
- }
- }
- }
- async fn complete_task_by_id(app: &AppHandle, task_id: i64) -> Result<(), String> {
- let state = app.state::<AppState>();
- let api = state.api.clone();
- match api.complete_task(task_id).await {
- Ok(_) => {
- if let Some(s) = app.try_state::<AppState>() {
- *s.pending_task.lock().unwrap() = None;
- }
- let payload = PetStatePayload {
- state: PetState::Happy,
- reason: Some("task_complete".into()),
- data: None,
- };
- let _ = app.emit("pet://state", payload);
- let _ = app.emit("pet://bubble", serde_json::json!({"text": "任务完成!太棒了~", "durationMs": 4000}));
- Ok(())
- }
- Err(_) => {
- let _ = app.emit("pet://bubble", serde_json::json!({"text": "完成任务失败,请稍后再试", "durationMs": 4000}));
- Err("task complete failed".into())
- }
- }
- }
|