diff --git a/package.json b/package.json index 2c0fd1e..2cd29a9 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { "name": "aiclient", "private": true, - "version": "0.1.0", + "version": "0.1.1", "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", "tauri": "tauri", - "tauri:build": "tauri build && node src-tauri/scripts/zip-portable.cjs" + "tauri:build": "node src-tauri/scripts/bump-version.cjs && 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 9af45e8..094a4df 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4093,7 +4093,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -4166,12 +4166,12 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] name = "tauri-app" -version = "0.1.0" +version = "0.1.1" dependencies = [ "aes-gcm", "async-trait", @@ -4206,6 +4206,7 @@ dependencies = [ "tokio", "urlencoding", "uuid", + "windows 0.58.0", ] [[package]] @@ -4346,7 +4347,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.18", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -4387,7 +4388,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -4412,7 +4413,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -5318,10 +5319,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -5342,7 +5343,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -5392,6 +5393,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -5414,14 +5425,27 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -5433,8 +5457,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -5451,6 +5475,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -5462,6 +5497,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -5495,6 +5541,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5513,6 +5568,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -6018,7 +6083,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8922aca..06665a9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,14 +1,19 @@ [package] name = "tauri-app" -version = "0.1.0" +version = "0.1.1" description = "A Tauri App" authors = ["you"] edition = "2021" +default-run = "tauri-app" [lib] name = "tauri_app_lib" crate-type = ["staticlib", "cdylib", "rlib"] +[[bin]] +name = "updater" +path = "src/bin/updater/main.rs" + [build-dependencies] tauri-build = { version = "2", features = [] } @@ -86,3 +91,9 @@ mac_address = "1.1" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-single-instance = "2" + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", +] } diff --git a/src-tauri/scripts/bump-version.cjs b/src-tauri/scripts/bump-version.cjs new file mode 100644 index 0000000..cd5eb17 --- /dev/null +++ b/src-tauri/scripts/bump-version.cjs @@ -0,0 +1,56 @@ +/** + * 构建前将版本号 patch 段 +1,并同步写入 tauri.conf.json / Cargo.toml / package.json。 + */ +const fs = require("node:fs"); +const path = require("node:path"); + +const tauriDir = path.join(__dirname, ".."); +const rootDir = path.join(tauriDir, ".."); + +const tauriConfPath = path.join(tauriDir, "tauri.conf.json"); +const cargoTomlPath = path.join(tauriDir, "Cargo.toml"); +const packageJsonPath = path.join(rootDir, "package.json"); + +function bumpPatch(version) { + const parts = String(version) + .trim() + .split(".") + .map((p) => Number.parseInt(p, 10)); + if (parts.length === 0 || parts.some(Number.isNaN)) { + throw new Error(`无法解析版本号: ${version}`); + } + parts[parts.length - 1] += 1; + return parts.join("."); +} + +function updateJsonVersion(filePath, newVersion) { + const data = JSON.parse(fs.readFileSync(filePath, "utf8")); + const oldVersion = data.version; + data.version = newVersion; + fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8"); + return oldVersion; +} + +function updateCargoVersion(filePath, newVersion) { + const content = fs.readFileSync(filePath, "utf8"); + const match = content.match(/^version\s*=\s*"([^"]+)"/m); + if (!match) { + throw new Error(`Cargo.toml 中未找到 version 字段: ${filePath}`); + } + const oldVersion = match[1]; + const next = content.replace( + /^version\s*=\s*"[^"]+"/m, + `version = "${newVersion}"`, + ); + fs.writeFileSync(filePath, next, "utf8"); + return oldVersion; +} + +const conf = JSON.parse(fs.readFileSync(tauriConfPath, "utf8")); +const newVersion = bumpPatch(conf.version); + +updateJsonVersion(tauriConfPath, newVersion); +updateJsonVersion(packageJsonPath, newVersion); +updateCargoVersion(cargoTomlPath, newVersion); + +console.log(`版本号已更新: ${conf.version} -> ${newVersion}`); diff --git a/src-tauri/scripts/zip-portable.cjs b/src-tauri/scripts/zip-portable.cjs index 8cdae85..9bdb1c3 100644 --- a/src-tauri/scripts/zip-portable.cjs +++ b/src-tauri/scripts/zip-portable.cjs @@ -6,6 +6,31 @@ const fs = require("node:fs"); const path = require("node:path"); const { execSync } = require("node:child_process"); +const RELEASE_VERSION_URL = + "http://81.71.163.140:8001/api/setreleaseversion"; + +async function notifyReleaseVersion(version) { + const res = await fetch(RELEASE_VERSION_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ version }), + }); + const body = await res.text(); + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${body}`); + } + let parsed; + try { + parsed = JSON.parse(body); + } catch { + throw new Error(`响应不是 JSON: ${body}`); + } + if (parsed.ok === false) { + throw new Error(parsed.message || "写入 releaseversion 失败"); + } + console.log(`已同步 releaseversion 到服务器: ${version}`); +} + const tauriDir = path.join(__dirname, ".."); const conf = JSON.parse( fs.readFileSync(path.join(tauriDir, "tauri.conf.json"), "utf8"), @@ -45,6 +70,11 @@ const stagedExe = process.platform === "win32" ? `${productName}.exe` : productName; fs.copyFileSync(exePath, path.join(stageDir, stagedExe)); +const updaterPath = path.join(releaseDir, "updater.exe"); +if (process.platform === "win32" && fs.existsSync(updaterPath)) { + fs.copyFileSync(updaterPath, path.join(stageDir, "updater.exe")); +} + for (const dir of ["resources", "binaries"]) { const src = path.join(releaseDir, dir); if (fs.existsSync(src)) { @@ -78,3 +108,8 @@ for (const dir of ["msi", "nsis"]) { } console.log(`便携压缩包已生成: ${zipPath}`); + +notifyReleaseVersion(version).catch((err) => { + console.error(`同步 releaseversion 失败: ${err.message}`); + process.exit(1); +}); diff --git a/src-tauri/src/bin/updater/engine.rs b/src-tauri/src/bin/updater/engine.rs new file mode 100644 index 0000000..87db3e4 --- /dev/null +++ b/src-tauri/src/bin/updater/engine.rs @@ -0,0 +1,282 @@ +//! updater.exe 独立更新逻辑(不依赖 Tauri 主库)。 + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::Value; + +const ENV_FROM_UPDATER: &str = "AICLIENT_FROM_UPDATER"; +const MAIN_EXE: &str = "aiclient.exe"; +const CHECK_UPDATE_URL: &str = "http://81.71.163.140:8001/api/checkupdate"; +const DOWNLOAD_URL: &str = "http://81.71.163.140:80/images/mainfile/main.exe"; + +fn exe_dir() -> Result { + std::env::current_exe() + .map_err(|e| format!("无法获取当前可执行文件路径: {e}")) + .and_then(|p| { + p.parent() + .map(Path::to_path_buf) + .ok_or_else(|| "无法获取可执行文件所在目录".to_string()) + }) +} + +fn normalize_version(raw: &str) -> String { + let trimmed = raw.trim().trim_start_matches(['v', 'V']); + let mut parts: Vec = trimmed + .split('.') + .filter_map(|p| p.parse().ok()) + .collect(); + if parts.is_empty() { + return trimmed.to_string(); + } + while parts.len() > 1 && *parts.last().unwrap_or(&0) == 0 { + parts.pop(); + } + parts + .iter() + .map(|n| n.to_string()) + .collect::>() + .join(".") +} + +fn versions_match(local: &str, remote: &str) -> bool { + normalize_version(local) == normalize_version(remote) +} + +fn parse_remote_version(body: &str) -> Result { + let trimmed = body.trim(); + if trimmed.is_empty() { + return Err("更新接口返回空内容".to_string()); + } + + if let Ok(value) = serde_json::from_str::(trimmed) { + for key in ["version", "latest_version", "latestVersion", "app_version"] { + if let Some(v) = value.get(key).and_then(|x| x.as_str()) { + if !v.trim().is_empty() { + return Ok(v.trim().to_string()); + } + } + } + if let Some(data) = value.get("data") { + for key in ["version", "latest_version", "latestVersion", "app_version"] { + if let Some(v) = data.get(key).and_then(|x| x.as_str()) { + if !v.trim().is_empty() { + return Ok(v.trim().to_string()); + } + } + } + } + } + + if trimmed.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') { + return Ok(trimmed.to_string()); + } + + Err(format!("无法解析更新接口返回的版本号: {trimmed}")) +} + +async fn fetch_latest_version(client: &reqwest::Client) -> Result { + let response = client + .get(CHECK_UPDATE_URL) + .send() + .await + .map_err(|e| format!("请求更新接口失败: {e}"))?; + + if !response.status().is_success() { + return Err(format!("更新接口 HTTP {}", response.status())); + } + + let body = response + .text() + .await + .map_err(|e| format!("读取更新接口响应失败: {e}"))?; + parse_remote_version(&body) +} + +#[cfg(windows)] +fn read_exe_version(path: &Path) -> Result { + use std::ffi::c_void; + use std::os::windows::ffi::OsStrExt; + + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::{ + GetFileVersionInfoSizeW, GetFileVersionInfoW, VerQueryValueW, + }; + + let wide: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let size = unsafe { GetFileVersionInfoSizeW(PCWSTR(wide.as_ptr()), None) }; + if size == 0 { + return Err(format!("无法读取版本信息: {}", path.display())); + } + + let mut buffer = vec![0u8; size as usize]; + unsafe { + GetFileVersionInfoW( + PCWSTR(wide.as_ptr()), + 0, + size, + buffer.as_mut_ptr() as *mut c_void, + ) + .map_err(|e| format!("GetFileVersionInfoW 失败: {e}"))?; + } + + let mut value_ptr: *mut c_void = std::ptr::null_mut(); + let mut value_len: u32 = 0; + let sub_block: Vec = "\\" + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + + unsafe { + if !VerQueryValueW( + buffer.as_ptr() as *const c_void, + PCWSTR(sub_block.as_ptr()), + &mut value_ptr, + &mut value_len, + ) + .as_bool() + { + return Err("VerQueryValueW 失败".to_string()); + } + } + + if value_ptr.is_null() || value_len < 2 { + return Err("版本信息为空".to_string()); + } + + let translate = unsafe { + std::slice::from_raw_parts(value_ptr as *const u16, (value_len / 2) as usize) + }; + let lang = format!("{:04x}{:04x}", translate[0], translate[1]); + + for key in ["ProductVersion", "FileVersion"] { + let query: Vec = format!("\\StringFileInfo\\{lang}\\{key}") + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + value_ptr = std::ptr::null_mut(); + value_len = 0; + let ok = unsafe { + VerQueryValueW( + buffer.as_ptr() as *const c_void, + PCWSTR(query.as_ptr()), + &mut value_ptr, + &mut value_len, + ) + }; + if ok.as_bool() && !value_ptr.is_null() && value_len >= 2 { + let slice = unsafe { + std::slice::from_raw_parts(value_ptr as *const u16, value_len as usize) + }; + let end = slice.iter().position(|&c| c == 0).unwrap_or(slice.len()); + let version = String::from_utf16_lossy(&slice[..end]); + if !version.trim().is_empty() { + return Ok(version.trim().to_string()); + } + } + } + + Err(format!("未在 {} 中找到版本字符串", path.display())) +} + +#[cfg(not(windows))] +fn read_exe_version(path: &Path) -> Result { + let _ = path; + Err("当前平台不支持读取可执行文件版本".to_string()) +} + +async fn download_and_replace(client: &reqwest::Client, main_path: &Path) -> Result<(), String> { + let response = client + .get(DOWNLOAD_URL) + .send() + .await + .map_err(|e| format!("下载主程序失败: {e}"))?; + + if !response.status().is_success() { + return Err(format!("下载主程序 HTTP {}", response.status())); + } + + let bytes = response + .bytes() + .await + .map_err(|e| format!("读取下载内容失败: {e}"))?; + + if bytes.is_empty() { + return Err("下载的主程序为空".to_string()); + } + + let temp_path = main_path.with_extension("exe.download"); + std::fs::write(&temp_path, &bytes).map_err(|e| format!("写入临时文件失败: {e}"))?; + + if main_path.exists() { + std::fs::remove_file(main_path).map_err(|e| format!("删除旧主程序失败: {e}"))?; + } + + std::fs::rename(&temp_path, main_path).map_err(|e| { + let _ = std::fs::remove_file(&temp_path); + format!("替换主程序失败: {e}") + })?; + + Ok(()) +} + +fn launch_main_app(main_path: &Path, dir: &Path) -> Result<(), String> { + if !main_path.is_file() { + return Err(format!("主程序不存在: {}", main_path.display())); + } + + Command::new(main_path) + .current_dir(dir) + .env(ENV_FROM_UPDATER, "1") + .spawn() + .map_err(|e| format!("启动主程序失败: {e}"))?; + Ok(()) +} + +pub async fn run_updater() -> Result<(), String> { + let dir = exe_dir()?; + let main_path = dir.join(MAIN_EXE); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; + + let remote_version = fetch_latest_version(&client).await?; + let needs_update = if main_path.is_file() { + match read_exe_version(&main_path) { + Ok(local_version) => { + let matched = versions_match(&local_version, &remote_version); + if matched { + eprintln!( + "当前版本 {local_version} 已是最新 ({remote_version}),跳过下载" + ); + } else { + eprintln!( + "版本不一致: 本地 {local_version},远端 {remote_version},开始更新" + ); + } + !matched + } + Err(e) => { + eprintln!("读取本地版本失败 ({e}),将尝试下载主程序"); + true + } + } + } else { + eprintln!("主程序不存在,将下载: {}", main_path.display()); + true + }; + + if needs_update { + download_and_replace(&client, &main_path).await?; + } + + launch_main_app(&main_path, &dir)?; + Ok(()) +} diff --git a/src-tauri/src/bin/updater/main.rs b/src-tauri/src/bin/updater/main.rs new file mode 100644 index 0000000..4ae59e3 --- /dev/null +++ b/src-tauri/src/bin/updater/main.rs @@ -0,0 +1,12 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod engine; + +#[tokio::main] +async fn main() { + if let Err(e) = engine::run_updater().await { + eprintln!("updater 运行失败: {e}"); + std::process::exit(1); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c4c49f2..33fd0f4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +pub mod updater; pub mod api_client; pub mod api_crypto; pub mod app_config; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6d4cd35..39d12c6 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,5 +4,14 @@ // 心跳定时任务在 `tauri_app_lib::run` → `api_client::spawn_heartbeat_loop` 中启动(每 30 秒)。 fn main() { + #[cfg(all(windows, not(debug_assertions)))] + { + if !tauri_app_lib::updater::launched_by_updater() { + if tauri_app_lib::updater::try_launch_updater().is_ok() { + std::process::exit(0); + } + } + } + tauri_app_lib::run() } diff --git a/src-tauri/src/updater/mod.rs b/src-tauri/src/updater/mod.rs new file mode 100644 index 0000000..f29aff7 --- /dev/null +++ b/src-tauri/src/updater/mod.rs @@ -0,0 +1,39 @@ +//! 主程序启动时检测是否由 updater 拉起,否则转交 updater.exe。 + +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub const ENV_FROM_UPDATER: &str = "AICLIENT_FROM_UPDATER"; +pub const MAIN_EXE: &str = "aiclient.exe"; +pub const UPDATER_EXE: &str = "updater.exe"; + +pub fn launched_by_updater() -> bool { + std::env::var(ENV_FROM_UPDATER) + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) +} + +pub fn exe_dir() -> Result { + std::env::current_exe() + .map_err(|e| format!("无法获取当前可执行文件路径: {e}")) + .and_then(|p| { + p.parent() + .map(Path::to_path_buf) + .ok_or_else(|| "无法获取可执行文件所在目录".to_string()) + }) +} + +/// 主程序未由 updater 启动时,尝试拉起 updater。 +pub fn try_launch_updater() -> Result<(), String> { + let dir = exe_dir()?; + let updater_path = dir.join(UPDATER_EXE); + if !updater_path.is_file() { + return Err(format!("未找到 {}", updater_path.display())); + } + + Command::new(&updater_path) + .current_dir(&dir) + .spawn() + .map_err(|e| format!("spawn {} 失败: {e}", updater_path.display()))?; + Ok(()) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 04f4300..255c396 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "aiclient", - "version": "0.1.0", + "version": "0.1.1", "identifier": "com.aiclient.desktop", "build": { "beforeDevCommand": "npm run dev", @@ -11,15 +11,17 @@ }, "app": { "withGlobalTauri": true, - "windows": [ - - ], + "windows": [], "security": { - "capabilities": ["default"], + "capabilities": [ + "default" + ], "csp": null, "assetProtocol": { "enable": true, - "scope": ["**"] + "scope": [ + "**" + ] } } },