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) -> Result<(), String> { match action.as_str() { "click" => { // 有可一键完成的待办任务时优先完成它,否则回退到喝水打卡 let pending = app .try_state::() .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) -> Result<(), String> { state.follow_cursor.store(true, Ordering::Relaxed); Ok(()) } /// 退出站手跟随(前端主动中止;抖动甩飞是循环内自动退出)。 #[tauri::command] pub fn stop_perch(state: State) -> 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, settings: State, ) -> Result { 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 { 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::(); 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) -> 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 { 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 { let qr = { let state = app.state::(); 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::().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::() { 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, settings: State, base_url: String, token: Option, ) -> 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) { 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::(); 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::(); let api = state.api.clone(); match api.complete_task(task_id).await { Ok(_) => { if let Some(s) = app.try_state::() { *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()) } } }