commands.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. use crate::app_state::AppState;
  2. use crate::poller;
  3. use crate::settings::SettingsStore;
  4. use crate::state::{PetState, PetStatePayload};
  5. use crate::token::TokenStore;
  6. use std::sync::atomic::Ordering;
  7. use tauri::{AppHandle, Emitter, Manager, State};
  8. #[tauri::command]
  9. pub async fn on_interact(app: AppHandle, action: String, _data: Option<String>) -> Result<(), String> {
  10. match action.as_str() {
  11. "click" => {
  12. // 有可一键完成的待办任务时优先完成它,否则回退到喝水打卡
  13. let pending = app
  14. .try_state::<AppState>()
  15. .and_then(|s| *s.pending_task.lock().unwrap());
  16. match pending {
  17. Some(id) => complete_task_by_id(&app, id).await,
  18. None => complete_water(&app).await,
  19. }
  20. }
  21. "pet" => {
  22. let _ = app.emit("pet://bubble", serde_json::json!({"text": "嘻嘻~", "durationMs": 2000}));
  23. Ok(())
  24. }
  25. _ => Ok(()),
  26. }
  27. }
  28. #[tauri::command]
  29. pub async fn drag_start(app: AppHandle) -> Result<(), String> {
  30. if let Some(win) = app.get_webview_window("main") {
  31. win.start_dragging().map_err(|e| e.to_string())?;
  32. }
  33. Ok(())
  34. }
  35. #[tauri::command]
  36. pub async fn drag_end() -> Result<(), String> {
  37. Ok(())
  38. }
  39. /// 站手跟随:前端「胸部悬停满 5s」后调用。locomotion 循环会接管窗口移动并回发 `pet://perch`。
  40. #[tauri::command]
  41. pub fn start_perch(state: State<AppState>) -> Result<(), String> {
  42. state.follow_cursor.store(true, Ordering::Relaxed);
  43. Ok(())
  44. }
  45. /// 退出站手跟随(前端主动中止;抖动甩飞是循环内自动退出)。
  46. #[tauri::command]
  47. pub fn stop_perch(state: State<AppState>) -> Result<(), String> {
  48. state.follow_cursor.store(false, Ordering::Relaxed);
  49. Ok(())
  50. }
  51. #[tauri::command]
  52. pub fn open_settings(app: AppHandle) -> Result<(), String> {
  53. if let Some(win) = app.get_webview_window("settings") {
  54. let _ = win.show();
  55. let _ = win.set_focus();
  56. }
  57. Ok(())
  58. }
  59. #[tauri::command]
  60. pub fn quit_app(app: AppHandle) -> Result<(), String> {
  61. app.exit(0);
  62. Ok(())
  63. }
  64. #[tauri::command]
  65. pub fn get_settings(
  66. _state: State<AppState>,
  67. settings: State<SettingsStore>,
  68. ) -> Result<serde_json::Value, String> {
  69. let cfg = settings.load();
  70. let has_token = TokenStore::new().load().map(|t| t.is_some()).unwrap_or(false);
  71. Ok(serde_json::json!({
  72. "baseUrl": cfg.base_url,
  73. "hasToken": has_token,
  74. "customMascotUrl": cfg.custom_mascot_url
  75. }))
  76. }
  77. #[tauri::command]
  78. pub async fn upload_mascot(
  79. app: AppHandle,
  80. settings: State<'_, SettingsStore>,
  81. filename: String,
  82. mime: String,
  83. data_base64: String,
  84. ) -> Result<String, String> {
  85. use base64::Engine;
  86. let bytes = base64::engine::general_purpose::STANDARD
  87. .decode(data_base64.as_bytes())
  88. .map_err(|e| format!("图片解码失败: {e}"))?;
  89. if bytes.is_empty() {
  90. return Err("图片内容为空".into());
  91. }
  92. if bytes.len() > 10 * 1024 * 1024 {
  93. return Err("图片过大(上限 10MB)".into());
  94. }
  95. // 生成 data URL(前端 canvas 生成动作帧用,规避远程图片跨域污染)。
  96. let data_url = {
  97. let m = if mime.is_empty() { "image/png".to_string() } else { mime.clone() };
  98. format!("data:{};base64,{}", m, base64::engine::general_purpose::STANDARD.encode(&bytes))
  99. };
  100. let state = app.state::<AppState>();
  101. let record = state
  102. .api
  103. .upload_media(&filename, bytes, &mime)
  104. .await
  105. .map_err(|e| format!("上传失败: {e}"))?;
  106. let url = record.url.ok_or_else(|| "上传响应缺少 url".to_string())?;
  107. let mut cfg = settings.load();
  108. cfg.custom_mascot_url = Some(url.clone());
  109. settings.save(&cfg)?;
  110. let _ = app.emit(
  111. "pet://custom-image",
  112. serde_json::json!({ "url": url, "dataUrl": data_url }),
  113. );
  114. Ok(url)
  115. }
  116. #[tauri::command]
  117. pub fn clear_custom_mascot(app: AppHandle, settings: State<SettingsStore>) -> Result<(), String> {
  118. let mut cfg = settings.load();
  119. cfg.custom_mascot_url = None;
  120. settings.save(&cfg)?;
  121. let _ = app.emit("pet://custom-image", serde_json::json!({ "url": null }));
  122. Ok(())
  123. }
  124. /// 拉取远程图片并转成 data URL(供前端 canvas 生成动作帧,规避跨域污染)。
  125. #[tauri::command]
  126. pub async fn data_url_from_url(url: String) -> Result<String, String> {
  127. if url.starts_with("data:") {
  128. return Ok(url);
  129. }
  130. let resp = reqwest::get(&url).await.map_err(|e| format!("请求失败: {e}"))?;
  131. if !resp.status().is_success() {
  132. return Err(format!("请求失败: HTTP {}", resp.status()));
  133. }
  134. let mime = resp
  135. .headers()
  136. .get(reqwest::header::CONTENT_TYPE)
  137. .and_then(|v| v.to_str().ok())
  138. .unwrap_or("image/png")
  139. .to_string();
  140. let bytes = resp.bytes().await.map_err(|e| format!("读取失败: {e}"))?;
  141. use base64::Engine;
  142. let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
  143. Ok(format!("data:{};base64,{}", mime, b64))
  144. }
  145. /// 微信小程序码扫码登录:取小程序码图片 → 前端展示 → 后台轮询换取 JWT → 写入 keyring。
  146. /// 二维码图片通过 `pet://wechat-qr` 事件回传({state, qrImage});
  147. /// 登录结果通过 `pet://wechat-login` 事件回传
  148. /// (status: pending / success / notfound / expired / error / timeout)。
  149. #[tauri::command]
  150. pub async fn start_wechat_login(app: AppHandle) -> Result<serde_json::Value, String> {
  151. let qr = {
  152. let state = app.state::<AppState>();
  153. state
  154. .api
  155. .wechat_web_qr()
  156. .await
  157. .map_err(|e| format!("获取二维码失败: {e}"))?
  158. };
  159. // 兼容旧版后端:只返回 {state,url}(微信开放平台 qrconnect 网页扫码)而无小程序码。
  160. // 此时新版登录流程无法继续,明确报错,避免静默展示空码让用户困惑。
  161. let qr_image = qr.qr_image.clone().unwrap_or_default();
  162. if qr_image.is_empty() && qr.url.as_deref().map(|u| !u.is_empty()).unwrap_or(false) {
  163. let _ = app.emit(
  164. "pet://wechat-login",
  165. serde_json::json!({
  166. "status": "error",
  167. "message": "服务端仍是旧版登录接口(微信开放平台 qrconnect),请部署最新后端(小程序码 /scan)后重试"
  168. }),
  169. );
  170. return Err("服务端登录接口版本过旧:缺少小程序码,需部署最新后端".into());
  171. }
  172. // 将小程序码图片推给设置窗口展示(用户用微信扫码后在小程序内确认登录)
  173. let _ = app.emit(
  174. "pet://wechat-qr",
  175. serde_json::json!({ "state": qr.state.clone(), "qrImage": qr_image }),
  176. );
  177. let api = app.state::<AppState>().api.clone();
  178. let state_token = qr.state.clone();
  179. let handle = app.clone();
  180. tauri::async_runtime::spawn(async move {
  181. // 最多轮询约 3 分钟(2s 间隔 × 90 次)
  182. for _ in 0..90 {
  183. tokio::time::sleep(std::time::Duration::from_secs(2)).await;
  184. match api.wechat_web_poll(&state_token).await {
  185. Ok(resp) => match resp.status.as_str() {
  186. "pending" => continue,
  187. "success" => {
  188. let Some(token) = resp.token else {
  189. let _ = handle.emit(
  190. "pet://wechat-login",
  191. serde_json::json!({"status": "error", "message": "响应缺少 token"}),
  192. );
  193. return;
  194. };
  195. if let Err(e) = TokenStore::new().save(&token) {
  196. let _ = handle.emit(
  197. "pet://wechat-login",
  198. serde_json::json!({"status": "error", "message": format!("保存令牌失败: {e}")}),
  199. );
  200. return;
  201. }
  202. api.update_token(token);
  203. if let Some(s) = handle.try_state::<AppState>() {
  204. restart_polling(&handle, &s);
  205. }
  206. let _ = handle.emit(
  207. "pet://wechat-login",
  208. serde_json::json!({
  209. "status": "success",
  210. "nickname": resp.nickname,
  211. "userId": resp.user_id,
  212. "role": resp.role
  213. }),
  214. );
  215. return;
  216. }
  217. other => {
  218. let _ = handle.emit(
  219. "pet://wechat-login",
  220. serde_json::json!({"status": other, "message": resp.message}),
  221. );
  222. return;
  223. }
  224. },
  225. Err(_) => continue, // 瞬时网络错误:继续轮询
  226. }
  227. }
  228. let _ = handle.emit("pet://wechat-login", serde_json::json!({"status": "timeout"}));
  229. });
  230. Ok(serde_json::json!({ "status": "pending", "qrImage": qr.qr_image }))
  231. }
  232. #[tauri::command]
  233. pub fn save_settings(
  234. app: AppHandle,
  235. state: State<AppState>,
  236. settings: State<SettingsStore>,
  237. base_url: String,
  238. token: Option<String>,
  239. ) -> Result<(), String> {
  240. let mut cfg = settings.load();
  241. cfg.base_url = base_url.trim().to_string();
  242. settings.save(&cfg)?;
  243. if let Some(t) = token {
  244. if !t.trim().is_empty() {
  245. TokenStore::new().save(t.trim())?;
  246. }
  247. }
  248. // 更新运行时客户端并重启轮询(对齐现 ShowSettings 的 Stop/Start)
  249. state.api.set_base_url(cfg.base_url.clone());
  250. if let Some(t) = TokenStore::new().load()? {
  251. state.api.update_token(t);
  252. }
  253. restart_polling(&app, &state);
  254. if let Some(win) = app.get_webview_window("settings") {
  255. let _ = win.hide();
  256. }
  257. Ok(())
  258. }
  259. fn restart_polling(app: &AppHandle, state: &State<AppState>) {
  260. if let Some(h) = state.poll_handle.lock().unwrap().take() {
  261. h.abort();
  262. }
  263. let handle = poller::spawn(app.clone(), state.api.clone());
  264. *state.poll_handle.lock().unwrap() = Some(handle);
  265. }
  266. async fn complete_water(app: &AppHandle) -> Result<(), String> {
  267. let state = app.state::<AppState>();
  268. let api = state.api.clone();
  269. match api.complete_water().await {
  270. Ok(_) => {
  271. let payload = PetStatePayload {
  272. state: PetState::Happy,
  273. reason: Some("drink_complete".into()),
  274. data: None,
  275. };
  276. let _ = app.emit("pet://state", payload);
  277. let _ = app.emit("pet://bubble", serde_json::json!({"text": "喝水打卡成功!太棒了~", "durationMs": 4000}));
  278. Ok(())
  279. }
  280. Err(_) => {
  281. let _ = app.emit("pet://bubble", serde_json::json!({"text": "打卡失败,请稍后再试", "durationMs": 4000}));
  282. Err("checkin failed".into())
  283. }
  284. }
  285. }
  286. async fn complete_task_by_id(app: &AppHandle, task_id: i64) -> Result<(), String> {
  287. let state = app.state::<AppState>();
  288. let api = state.api.clone();
  289. match api.complete_task(task_id).await {
  290. Ok(_) => {
  291. if let Some(s) = app.try_state::<AppState>() {
  292. *s.pending_task.lock().unwrap() = None;
  293. }
  294. let payload = PetStatePayload {
  295. state: PetState::Happy,
  296. reason: Some("task_complete".into()),
  297. data: None,
  298. };
  299. let _ = app.emit("pet://state", payload);
  300. let _ = app.emit("pet://bubble", serde_json::json!({"text": "任务完成!太棒了~", "durationMs": 4000}));
  301. Ok(())
  302. }
  303. Err(_) => {
  304. let _ = app.emit("pet://bubble", serde_json::json!({"text": "完成任务失败,请稍后再试", "durationMs": 4000}));
  305. Err("task complete failed".into())
  306. }
  307. }
  308. }