From ce1fe9988389770db052ee2fea3915ea58cdd868 Mon Sep 17 00:00:00 2001 From: "fengchuanhn@gmail.com" Date: Fri, 22 May 2026 14:40:32 +0800 Subject: [PATCH] 11 --- .cursor/rules/aiclient-overview.mdc | 2 +- .cursor/rules/tauri-desktop.mdc | 3 +- package.json | 3 +- src-tauri/Cargo.lock | 31 +++++++++ src-tauri/Cargo.toml | 5 ++ src-tauri/scripts/zip-portable.cjs | 80 ++++++++++++++++++++++ src-tauri/src/api_client.rs | 100 ++++++++++++++++++++++++++++ src-tauri/src/device_serial.rs | 48 +++++++++++++ src-tauri/src/lib.rs | 4 ++ src-tauri/src/main.rs | 2 + src-tauri/tauri.conf.json | 2 +- src/components/AuthLayout.vue | 2 +- 12 files changed, 277 insertions(+), 5 deletions(-) create mode 100644 src-tauri/scripts/zip-portable.cjs create mode 100644 src-tauri/src/device_serial.rs diff --git a/.cursor/rules/aiclient-overview.mdc b/.cursor/rules/aiclient-overview.mdc index d13ffab..bd71bea 100644 --- a/.cursor/rules/aiclient-overview.mdc +++ b/.cursor/rules/aiclient-overview.mdc @@ -30,7 +30,7 @@ src-tauri/ # Tauri Rust 与配置 - `npm run dev` — 仅前端(端口 1420) - `npm run tauri dev` — 桌面开发 -- `npm run build` / `npm run tauri build` — 构建(前端无 `tsc` 步骤) +- `npm run build` / `npm run tauri:build` — 构建桌面端(仅生成 zip 便携包,不生成 MSI/NSIS 安装包;前端无 `tsc` 步骤) ## 后端 API 约定(pythonbackend) diff --git a/.cursor/rules/tauri-desktop.mdc b/.cursor/rules/tauri-desktop.mdc index 1f91236..70059a7 100644 --- a/.cursor/rules/tauri-desktop.mdc +++ b/.cursor/rules/tauri-desktop.mdc @@ -42,4 +42,5 @@ alwaysApply: false ## 开发注意 - Vite 固定端口 `1420`(见 `vite.config.js`),与 `tauri.conf.json` 的 `devUrl` 一致 -- 修改 capabilities 或插件后需重新 `tauri dev` / `tauri build` +- 修改 capabilities 或插件后需重新 `tauri dev` / `tauri:build` +- 发布构建使用 `npm run tauri:build`:`bundle.targets` 为空,跳过 MSI/NSIS;构建结束后由 `src-tauri/scripts/zip-portable.cjs` 生成 `target/release/bundle/zip/*.zip` diff --git a/package.json b/package.json index af9b04d..2c0fd1e 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "tauri:build": "tauri build && node src-tauri/scripts/zip-portable.cjs" }, "dependencies": { "@primeuix/themes": "^1.2.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5c88fd2..3a3f27e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2319,6 +2319,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -2330,6 +2340,12 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" @@ -2443,6 +2459,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -4154,6 +4183,8 @@ dependencies = [ "hex", "hmac", "log", + "mac_address", + "md5", "mime_guess", "once_cell", "rand 0.8.6", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index efcd197..3877bfc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -61,6 +61,7 @@ urlencoding = "2.1" chrono = { version = "0.4", default-features = false, features = [ "clock", "std", + "serde", ] } # Logging + errors @@ -78,5 +79,9 @@ rand = "0.8" tempfile = "3" uuid = { version = "1", features = ["v4"] } +# 设备序列号(CPUID + MAC → MD5) +md5 = "0.7" +mac_address = "1.1" + [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/scripts/zip-portable.cjs b/src-tauri/scripts/zip-portable.cjs new file mode 100644 index 0000000..8cdae85 --- /dev/null +++ b/src-tauri/scripts/zip-portable.cjs @@ -0,0 +1,80 @@ +/** + * 将 target/release 下的可执行文件与 sidecar 资源打成 zip(便携分发,非安装包)。 + * 在 `npm run tauri:build` 中于 `tauri build` 之后调用。 + */ +const fs = require("node:fs"); +const path = require("node:path"); +const { execSync } = require("node:child_process"); + +const tauriDir = path.join(__dirname, ".."); +const conf = JSON.parse( + fs.readFileSync(path.join(tauriDir, "tauri.conf.json"), "utf8"), +); +const { productName, version } = conf; +const releaseDir = path.join(tauriDir, "target", "release"); +const cargoName = "tauri-app"; +const exeName = + process.platform === "win32" ? `${cargoName}.exe` : cargoName; + +const platformTag = + process.platform === "win32" + ? "win" + : process.platform === "darwin" + ? "osx" + : "linux"; +const archTag = process.arch === "arm64" ? "arm64" : "x64"; +const zipDir = path.join(releaseDir, "bundle", "zip"); +const zipPath = path.join( + zipDir, + `${productName}_${version}_${archTag}-${platformTag}.zip`, +); + +const exePath = path.join(releaseDir, exeName); +if (!fs.existsSync(exePath)) { + console.error(`未找到可执行文件: ${exePath},请先执行 tauri build`); + process.exit(1); +} + +const stagingRoot = path.join(releaseDir, "bundle", "_portable_staging"); +const stageDir = path.join(stagingRoot, productName); + +fs.rmSync(stagingRoot, { recursive: true, force: true }); +fs.mkdirSync(stageDir, { recursive: true }); + +const stagedExe = + process.platform === "win32" ? `${productName}.exe` : productName; +fs.copyFileSync(exePath, path.join(stageDir, stagedExe)); + +for (const dir of ["resources", "binaries"]) { + const src = path.join(releaseDir, dir); + if (fs.existsSync(src)) { + fs.cpSync(src, path.join(stageDir, dir), { recursive: true }); + } +} + +fs.mkdirSync(zipDir, { recursive: true }); +if (fs.existsSync(zipPath)) { + fs.unlinkSync(zipPath); +} + +if (process.platform === "win32") { + const psStage = stageDir.replace(/'/g, "''"); + const psZip = zipPath.replace(/'/g, "''"); + execSync( + `powershell -NoProfile -Command "Compress-Archive -Path '${psStage}\\*' -DestinationPath '${psZip}' -Force"`, + { stdio: "inherit" }, + ); +} else { + execSync(`cd "${stageDir}" && zip -qr "${zipPath}" .`, { stdio: "inherit" }); +} + +fs.rmSync(stagingRoot, { recursive: true, force: true }); + +for (const dir of ["msi", "nsis"]) { + fs.rmSync(path.join(releaseDir, "bundle", dir), { + recursive: true, + force: true, + }); +} + +console.log(`便携压缩包已生成: ${zipPath}`); diff --git a/src-tauri/src/api_client.rs b/src-tauri/src/api_client.rs index 267b57c..f0f68a0 100644 --- a/src-tauri/src/api_client.rs +++ b/src-tauri/src/api_client.rs @@ -3,11 +3,18 @@ use std::collections::HashMap; use std::time::Duration; +use chrono::Utc; +use rand::Rng; +use tauri::AppHandle; +use tauri::Manager; + use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use serde_json::Value; use crate::api_crypto::{self, ENCRYPTED_HEADER}; use crate::app_config; +use crate::auth_session::AuthSession; +use crate::device_serial; pub async fn request_json( method: &str, @@ -39,6 +46,9 @@ pub async fn request_json( HeaderName::from_static("content-type"), HeaderValue::from_static("application/json"), ); + if let Ok(val) = HeaderValue::from_str(device_serial::device_serial()) { + headers.insert(HeaderName::from_static("device_serial"), val); + } for (k, v) in extra_headers { if let (Ok(name), Ok(val)) = ( HeaderName::from_bytes(k.as_bytes()), @@ -98,3 +108,93 @@ pub async fn request_json( Ok((status, value)) } + +const HEARTBEAT_INTERVAL_SECS: u64 = 30; + +/// 与 pythonbackend `heartbeat_time_bucket` 一致:UTC ``%Y-%m-%d %H``。 +fn heartbeat_time_bucket() -> String { + Utc::now().format("%Y-%m-%d %H").to_string() +} + +/// MD5(时间桶 + verify_code),小写十六进制。 +fn compute_server_verify_code(verify_code: &str) -> String { + let raw = format!("{}{}", heartbeat_time_bucket(), verify_code); + format!("{:x}", md5::compute(raw.as_bytes())) +} + +fn random_verify_code() -> String { + const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let mut rng = rand::thread_rng(); + (0..32) + .map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char) + .collect() +} + +fn response_server_verify_code(value: &Value) -> Option<&str> { + value + .get("data") + .and_then(|d| d.get("server_verify_code")) + .and_then(|v| v.as_str()) +} + +/// 已登录时向服务端发送心跳(校验设备序列号与会员状态)。 +pub async fn send_heartbeat(token: &str) -> Result<(), String> { + let token = token.trim(); + if token.is_empty() { + return Ok(()); + } + let verify_code = random_verify_code(); + let body = serde_json::json!({ "verify_code": verify_code }).to_string(); + let expected = compute_server_verify_code(&verify_code); + + let mut headers = HashMap::new(); + headers.insert( + "Authorization".to_string(), + format!("Bearer {token}"), + ); + + let (status, value) = + request_json("POST", "/auth/heartbeat", Some(&body), headers).await?; + if status == 401 { + return Err("未登录或登录已过期".into()); + } + + let server_code = response_server_verify_code(&value); + if server_code != Some(expected.as_str()) { + return Err("会员已过期".into()); + } + + if let Some(ok) = value.get("ok").and_then(|v| v.as_bool()) { + if !ok { + let msg = value + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("心跳校验失败"); + return Err(msg.to_string()); + } + } + Ok(()) +} + +/// 应用启动后每 30 秒发送一次心跳(仅在有 token 时)。 +pub fn spawn_heartbeat_loop(app: AppHandle) { + tauri::async_runtime::spawn(async move { + let mut interval = + tokio::time::interval(Duration::from_secs(HEARTBEAT_INTERVAL_SECS)); + interval.tick().await; + loop { + interval.tick().await; + let token = app + .try_state::() + .map(|s| s.token()) + .unwrap_or_default(); + if token.trim().is_empty() { + continue; + } + match send_heartbeat(&token).await { + Ok(()) => log::debug!(target: "heartbeat", "heartbeat ok"), + Err(e) => log::warn!(target: "heartbeat", "heartbeat failed: {e}"), + } + } + }); +} diff --git a/src-tauri/src/device_serial.rs b/src-tauri/src/device_serial.rs new file mode 100644 index 0000000..1043743 --- /dev/null +++ b/src-tauri/src/device_serial.rs @@ -0,0 +1,48 @@ +//! 设备唯一序列号:MD5(cpuid 序列 + MAC 地址),进程内缓存。 + +use once_cell::sync::Lazy; + +static DEVICE_SERIAL: Lazy = Lazy::new(compute_device_serial); + +/// 返回 MD5 十六进制小写字符串(32 字符)。 +pub fn device_serial() -> &'static str { + &DEVICE_SERIAL +} + +fn compute_device_serial() -> String { + let cpuid = cpuid_serial(); + let mac = mac_serial(); + let raw = format!("{cpuid}{mac}"); + format!("{:x}", md5::compute(raw.as_bytes())) +} + +/// CPUID leaf 3 的 edx+ecx(与示例 `__cpuid(3)` 一致)。 +fn cpuid_serial() -> String { + #[cfg(target_arch = "x86_64")] + { + use std::arch::x86_64::__cpuid; + let r = __cpuid(3); + return format!("{:08X}{:08X}", r.edx, r.ecx); + } + #[cfg(target_arch = "x86")] + { + use std::arch::x86::__cpuid; + let r = __cpuid(3); + return format!("{:08X}{:08X}", r.edx, r.ecx); + } + #[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] + { + String::new() + } +} + +fn mac_serial() -> String { + match mac_address::get_mac_address() { + Ok(Some(ma)) => ma.to_string(), + Ok(None) => String::new(), + Err(e) => { + log::warn!(target: "device_serial", "读取 MAC 地址失败: {e}"); + String::new() + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c292eac..436dfb2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ pub mod auth_session; pub mod chat; pub mod commands; pub mod cover_python; +pub mod device_serial; pub mod ffmpeg; pub mod http; pub mod js_runtime; @@ -38,6 +39,7 @@ pub fn run() { .manage(publish_store::PublishStore::new()) .setup(|app| { let handle = app.handle().clone(); + let heartbeat_handle = handle.clone(); let oem_id = handle .path() .app_data_dir() @@ -64,6 +66,8 @@ pub fn run() { Ok::<(), String>(()) }) .map_err(|e| -> Box { e.into() })?; + + api_client::spawn_heartbeat_loop(heartbeat_handle); Ok(()) }) .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2abccd9..6d4cd35 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,6 +1,8 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +// 心跳定时任务在 `tauri_app_lib::run` → `api_client::spawn_heartbeat_loop` 中启动(每 30 秒)。 + fn main() { tauri_app_lib::run() } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index aa5881a..cb0be6a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -36,7 +36,7 @@ "binaries/*" ], "active": true, - "targets": "all", + "targets": [], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src/components/AuthLayout.vue b/src/components/AuthLayout.vue index 5c1a01a..d9fa629 100644 --- a/src/components/AuthLayout.vue +++ b/src/components/AuthLayout.vue @@ -9,7 +9,7 @@ defineProps({
-

aiclient

+

{{ subtitle }}