This commit is contained in:
949036910@qq.com
2026-05-30 12:46:13 +08:00
parent 58240c3d76
commit 53d0a6eb5f
11 changed files with 536 additions and 24 deletions

95
src-tauri/Cargo.lock generated
View File

@@ -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",

View File

@@ -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",
] }

View File

@@ -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}`);

View File

@@ -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);
});

View File

@@ -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<PathBuf, String> {
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<u32> = 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::<Vec<_>>()
.join(".")
}
fn versions_match(local: &str, remote: &str) -> bool {
normalize_version(local) == normalize_version(remote)
}
fn parse_remote_version(body: &str) -> Result<String, String> {
let trimmed = body.trim();
if trimmed.is_empty() {
return Err("更新接口返回空内容".to_string());
}
if let Ok(value) = serde_json::from_str::<Value>(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<String, String> {
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<String, String> {
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<u16> = 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<u16> = "\\"
.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<u16> = 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<String, String> {
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(())
}

View File

@@ -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);
}
}

View File

@@ -1,3 +1,4 @@
pub mod updater;
pub mod api_client;
pub mod api_crypto;
pub mod app_config;

View File

@@ -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()
}

View File

@@ -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<PathBuf, String> {
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(())
}

View File

@@ -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": [
"**"
]
}
}
},