This commit is contained in:
949036910@qq.com
2026-05-30 18:44:16 +08:00
parent e10a66ccf2
commit 9c6650373f
162 changed files with 624 additions and 26788 deletions

37
src-tauri/Cargo.lock generated
View File

@@ -1145,7 +1145,7 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
"libloading 0.7.4",
"libloading 0.8.9",
]
[[package]]
@@ -1548,6 +1548,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -5030,6 +5040,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -5091,7 +5112,7 @@ dependencies = [
[[package]]
name = "tauri-app"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"aes-gcm",
"async-trait",
@@ -5102,6 +5123,7 @@ dependencies = [
"eframe",
"egui",
"env_logger",
"flate2",
"futures-util",
"hex",
"hmac",
@@ -5118,6 +5140,7 @@ dependencies = [
"serde",
"serde_json",
"sha1",
"tar",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
@@ -7254,6 +7277,16 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix 1.1.4",
]
[[package]]
name = "xcursor"
version = "0.3.10"

View File

@@ -1,6 +1,6 @@
[package]
name = "tauri-app"
version = "0.1.3"
version = "0.1.8"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
@@ -99,3 +99,5 @@ windows = { version = "0.58", features = [
] }
eframe = { version = "0.31", default-features = false, features = ["default_fonts", "glow"] }
egui = "0.31"
flate2 = "1"
tar = "0.4"

View File

@@ -55,6 +55,8 @@ const APP_COMMANDS: &[&str] = &[
];
fn main() {
// 勿再用 winrestauri-build 已写入 VERSION 资源,重复会导致 LNK1123 / CVT1100。
// 本地版本比对优先读同目录 aiclient.version见 zip-portable.cjs、updater engine
tauri_build::try_build(
tauri_build::Attributes::new()
.app_manifest(tauri_build::AppManifest::new().commands(APP_COMMANDS)),

View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>aiclient</title>
</head>
<body>
<!-- 仅占位:运行时由 lib.rs 加载远程前端,不打包 aiclient_ui 资源 -->
</body>
</html>

View File

@@ -1,15 +1,20 @@
/**
* 构建前将版本号 patch 段 +1并同步写入 tauri.conf.json / Cargo.toml / package.json
* 构建前将版本号 patch 段 +1并同步写入 tauri.conf.json / Cargo.toml / aiclient_ui / npmrelease
*/
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");
const uiPackageJsonPath = path.join(tauriDir, "..", "aiclient_ui", "package.json");
const npmReleasePackageJsonPath = path.join(
tauriDir,
"..",
"..",
"npmrelease",
"package.json",
);
function bumpPatch(version) {
const parts = String(version)
@@ -50,7 +55,14 @@ const conf = JSON.parse(fs.readFileSync(tauriConfPath, "utf8"));
const newVersion = bumpPatch(conf.version);
updateJsonVersion(tauriConfPath, newVersion);
updateJsonVersion(packageJsonPath, newVersion);
if (fs.existsSync(uiPackageJsonPath)) {
updateJsonVersion(uiPackageJsonPath, newVersion);
}
if (fs.existsSync(npmReleasePackageJsonPath)) {
updateJsonVersion(npmReleasePackageJsonPath, newVersion);
} else {
console.warn(`未找到 npmrelease/package.json: ${npmReleasePackageJsonPath}`);
}
updateCargoVersion(cargoTomlPath, newVersion);
console.log(`版本号已更新: ${conf.version} -> ${newVersion}`);

View File

@@ -32,6 +32,7 @@ async function notifyReleaseVersion(version) {
}
const tauriDir = path.join(__dirname, "..");
const npmReleaseDir = path.join(tauriDir, "..", "..", "npmrelease");
const conf = JSON.parse(
fs.readFileSync(path.join(tauriDir, "tauri.conf.json"), "utf8"),
);
@@ -70,6 +71,25 @@ const stagedExe =
process.platform === "win32" ? `${productName}.exe` : productName;
fs.copyFileSync(exePath, path.join(stageDir, stagedExe));
function writeVersionSidecar(dir, baseName, ver) {
const file = path.join(dir, `${baseName}.version`);
fs.writeFileSync(file, `${ver}\n`, "utf8");
return file;
}
writeVersionSidecar(stageDir, productName, version);
// 复制主程序到 npmrelease供 npm publish 打包
if (process.platform === "win32") {
fs.mkdirSync(npmReleaseDir, { recursive: true });
const npmReleaseExe = path.join(npmReleaseDir, stagedExe);
fs.copyFileSync(exePath, npmReleaseExe);
writeVersionSidecar(npmReleaseDir, productName, version);
console.log(`已复制主程序到 npmrelease: ${npmReleaseExe}`);
} else {
console.warn("非 Windows 平台,跳过复制到 npmrelease");
}
const updaterPath = path.join(releaseDir, "updater.exe");
if (process.platform === "win32" && fs.existsSync(updaterPath)) {
fs.copyFileSync(updaterPath, path.join(stageDir, "updater.exe"));

View File

@@ -9,12 +9,24 @@ use serde_json::Value;
pub const ENV_FROM_UPDATER: &str = "AICLIENT_FROM_UPDATER";
pub const MAIN_EXE: &str = "aiclient.exe";
pub const CHECK_UPDATE_URL: &str = "http://81.71.163.140:8001/api/checkupdate";
pub const DOWNLOAD_URL: &str = "http://81.71.163.140:80/images/mainfile/main.exe";
/// npm 包名(发布到 npmmirror 的桌面端包)
pub const NPM_PACKAGE_NAME: &str = "yaoyanaiclient";
const NPM_REGISTRY_BASE: &str = "https://registry.npmmirror.com";
const NPM_CDN_BASE: &str = "https://cdn.npmmirror.com/packages";
const NPM_FETCH_RETRIES: u32 = 3;
#[derive(Clone, Debug)]
pub enum UpdaterMessage {
Status(String),
/// 本地/远程版本(调试用)
VersionInfo {
local_display: String,
remote_display: String,
local_normalized: String,
remote_normalized: String,
needs_update: bool,
note: String,
},
/// 0.0 ~ 1.0;无总大小时为 None界面显示不确定进度
DownloadProgress { fraction: Option<f32>, detail: String },
Launching,
@@ -48,6 +60,25 @@ impl ProgressSink {
pub fn launching(&self) {
self.send(UpdaterMessage::Launching);
}
pub fn version_info(
&self,
local_display: impl Into<String>,
remote_display: impl Into<String>,
local_normalized: impl Into<String>,
remote_normalized: impl Into<String>,
needs_update: bool,
note: impl Into<String>,
) {
self.send(UpdaterMessage::VersionInfo {
local_display: local_display.into(),
remote_display: remote_display.into(),
local_normalized: local_normalized.into(),
remote_normalized: remote_normalized.into(),
needs_update,
note: note.into(),
});
}
}
fn exe_dir() -> Result<PathBuf, String> {
@@ -62,9 +93,18 @@ fn exe_dir() -> Result<PathBuf, 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())
// Windows 资源里常见 "0, 1, 3, 0" 或 "0.1.3.0"
let unified = trimmed.replace(',', ".");
let mut parts: Vec<u32> = unified
.split(|c| c == '.' || c == '-' || c == '_')
.filter_map(|p| {
let p = p.trim();
if p.is_empty() {
None
} else {
p.parse().ok()
}
})
.collect();
if parts.is_empty() {
return trimmed.to_string();
@@ -79,65 +119,228 @@ fn normalize_version(raw: &str) -> String {
.join(".")
}
fn versions_match(local: &str, remote: &str) -> bool {
normalize_version(local) == normalize_version(remote)
fn npm_registry_url() -> String {
format!("{NPM_REGISTRY_BASE}/{NPM_PACKAGE_NAME}")
}
fn parse_remote_version(body: &str) -> Result<String, String> {
let trimmed = body.trim();
if trimmed.is_empty() {
return Err("更新接口返回空内容".to_string());
fn npm_tgz_download_url(version: &str) -> String {
format!(
"{NPM_CDN_BASE}/{NPM_PACKAGE_NAME}/{version}/{NPM_PACKAGE_NAME}-{version}.tgz"
)
}
fn parse_npm_latest_version(body: &str) -> Result<String, String> {
let value: Value = serde_json::from_str(body)
.map_err(|e| format!("解析 npm 元数据 JSON 失败: {e}"))?;
if let Some(latest) = value
.get("dist-tags")
.and_then(|t| t.get("latest"))
.and_then(|v| v.as_str())
{
let latest = latest.trim();
if !latest.is_empty() {
return Ok(latest.to_string());
}
}
if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
if value.get("ok").and_then(|v| v.as_bool()) == Some(false) {
let msg = value
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("检查更新失败");
return Err(msg.to_string());
}
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());
}
}
// 兼容仅返回 dist-tags 片段或正则兜底
if let Ok(re) = regex::Regex::new(r#""latest"\s*:\s*"([^"]+)""#) {
if let Some(caps) = re.captures(body) {
if let Some(m) = caps.get(1) {
return Ok(m.as_str().to_string());
}
}
}
if trimmed.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') {
return Ok(trimmed.to_string());
}
Err(format!("无法解析更新接口返回的版本号: {trimmed}"))
Err(format!(
"无法从 npm 元数据提取 latest 版本(包名: {NPM_PACKAGE_NAME}"
))
}
async fn fetch_latest_version(client: &reqwest::Client) -> Result<String, String> {
async fn fetch_npm_registry_once(
client: &reqwest::Client,
registry_url: &str,
) -> Result<String, String> {
let response = client
.get(CHECK_UPDATE_URL)
.get(registry_url)
.send()
.await
.map_err(|e| format!("请求更新接口失败: {e}"))?;
.map_err(|e| format!("请求 npm 仓库失败: {e}"))?;
if !response.status().is_success() {
return Err(format!("更新接口 HTTP {}", response.status()));
return Err(format!("npm 仓库 HTTP {}", response.status()));
}
let body = response
.text()
.await
.map_err(|e| format!("读取更新接口响应失败: {e}"))?;
parse_remote_version(&body)
.map_err(|e| format!("读取 npm 元数据失败: {e}"))?;
parse_npm_latest_version(&body)
}
async fn fetch_latest_version(client: &reqwest::Client) -> Result<String, String> {
let registry_url = npm_registry_url();
let mut last_error = String::from("获取 npm 版本信息失败");
for attempt in 1..=NPM_FETCH_RETRIES {
match fetch_npm_registry_once(client, &registry_url).await {
Ok(version) => return Ok(version),
Err(e) => {
last_error = e;
if attempt < NPM_FETCH_RETRIES {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
}
}
Err(last_error)
}
/// 与 exe 同目录的 `aiclient.version`(构建时由 zip-portable 写入)
fn read_version_sidecar(exe_path: &Path) -> Option<String> {
let mut sidecar = exe_path.to_path_buf();
sidecar.set_extension("version");
if !sidecar.is_file() {
return None;
}
let text = std::fs::read_to_string(&sidecar).ok()?;
let v = text.trim();
if v.is_empty() {
None
} else {
Some(v.to_string())
}
}
fn write_version_sidecar(exe_path: &Path, version: &str) -> Result<(), String> {
let mut sidecar = exe_path.to_path_buf();
sidecar.set_extension("version");
std::fs::write(&sidecar, format!("{version}\n"))
.map_err(|e| format!("写入 {} 失败: {e}", sidecar.display()))?;
Ok(())
}
fn version_from_fixed_file_info(buffer: &[u8]) -> Option<String> {
use std::ffi::c_void;
use windows::core::PCWSTR;
use windows::Win32::Storage::FileSystem::{VerQueryValueW, VS_FIXEDFILEINFO};
let root: Vec<u16> = "\\".encode_utf16().chain(std::iter::once(0)).collect();
let mut ptr: *mut c_void = std::ptr::null_mut();
let mut len: u32 = 0;
let ok = unsafe {
VerQueryValueW(
buffer.as_ptr() as *const c_void,
PCWSTR(root.as_ptr()),
&mut ptr,
&mut len,
)
};
if !ok.as_bool() || ptr.is_null() || len < std::mem::size_of::<VS_FIXEDFILEINFO>() as u32 {
return None;
}
let info = unsafe { &*(ptr as *const VS_FIXEDFILEINFO) };
let major = (info.dwProductVersionMS >> 16) as u16;
let minor = (info.dwProductVersionMS & 0xFFFF) as u16;
let build = (info.dwProductVersionLS >> 16) as u16;
let patch = (info.dwProductVersionLS & 0xFFFF) as u16;
if major == 0 && minor == 0 && build == 0 && patch == 0 {
return None;
}
if patch == 0 {
Some(format!("{major}.{minor}.{build}"))
} else {
Some(format!("{major}.{minor}.{build}.{patch}"))
}
}
fn version_from_string_table(buffer: &[u8]) -> Option<String> {
use std::ffi::c_void;
use windows::core::PCWSTR;
use windows::Win32::Storage::FileSystem::VerQueryValueW;
let sub_block: Vec<u16> = "\\"
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let mut value_ptr: *mut c_void = std::ptr::null_mut();
let mut value_len: u32 = 0;
if !unsafe {
VerQueryValueW(
buffer.as_ptr() as *const c_void,
PCWSTR(sub_block.as_ptr()),
&mut value_ptr,
&mut value_len,
)
}
.as_bool()
{
return None;
}
if value_ptr.is_null() || value_len < 4 {
return None;
}
let translate = unsafe {
std::slice::from_raw_parts(value_ptr as *const u16, (value_len / 2) as usize)
};
for i in (0..translate.len()).step_by(2) {
let Some((lang_id, code_page)) = translate.get(i).zip(translate.get(i + 1)) else {
break;
};
let lang = format!("{lang_id:04x}{code_page:04x}");
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 Some(version.trim().to_string());
}
}
}
}
None
}
/// 读取本地版本:优先 `aiclient.version`,其次 PE 资源。
fn read_local_version(exe_path: &Path) -> Result<(String, String), String> {
if let Some(v) = read_version_sidecar(exe_path) {
return Ok((v, "aiclient.version".to_string()));
}
let pe = read_exe_version(exe_path)?;
Ok((pe, "PE 资源".to_string()))
}
#[cfg(windows)]
@@ -146,9 +349,7 @@ fn read_exe_version(path: &Path) -> Result<String, String> {
use std::os::windows::ffi::OsStrExt;
use windows::core::PCWSTR;
use windows::Win32::Storage::FileSystem::{
GetFileVersionInfoSizeW, GetFileVersionInfoW, VerQueryValueW,
};
use windows::Win32::Storage::FileSystem::{GetFileVersionInfoSizeW, GetFileVersionInfoW};
let wide: Vec<u16> = path
.as_os_str()
@@ -158,7 +359,7 @@ fn read_exe_version(path: &Path) -> Result<String, String> {
let size = unsafe { GetFileVersionInfoSizeW(PCWSTR(wide.as_ptr()), None) };
if size == 0 {
return Err(format!("无法读取版本信息: {}", path.display()));
return Err(format!("exe 无版本资源块: {}", path.display()));
}
let mut buffer = vec![0u8; size as usize];
@@ -172,63 +373,17 @@ fn read_exe_version(path: &Path) -> Result<String, String> {
.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 let Some(v) = version_from_string_table(&buffer) {
return Ok(v);
}
if let Some(v) = version_from_fixed_file_info(&buffer) {
return Ok(v);
}
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()))
Err(format!(
"未在 {} 中找到版本信息(无 StringFileInfo / VS_FIXEDFILEINFO",
path.display()
))
}
#[cfg(not(windows))]
@@ -249,25 +404,69 @@ fn format_bytes(n: u64) -> String {
}
}
#[cfg(windows)]
fn extract_package_from_tgz(tgz_path: &Path, dest_exe: &Path) -> Result<(), String> {
use std::fs::File;
use std::io::copy;
use flate2::read::GzDecoder;
use tar::Archive;
let file = File::open(tgz_path).map_err(|e| format!("打开 tgz 失败: {e}"))?;
let decoder = GzDecoder::new(file);
let mut archive = Archive::new(decoder);
let entries = archive
.entries()
.map_err(|e| format!("读取 tgz 条目失败: {e}"))?;
let exe_name = MAIN_EXE;
for entry in entries {
let mut entry = entry.map_err(|e| format!("读取 tgz 条目失败: {e}"))?;
let path = entry.path().map_err(|e| format!("解析 tgz 路径失败: {e}"))?;
let path_str = path.to_string_lossy().replace('\\', "/");
if path_str.ends_with(exe_name) {
let parent = dest_exe
.parent()
.ok_or_else(|| "无法获取目标目录".to_string())?;
std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {e}"))?;
let mut outfile =
File::create(dest_exe).map_err(|e| format!("创建 exe 失败: {e}"))?;
copy(&mut entry, &mut outfile).map_err(|e| format!("写入 exe 失败: {e}"))?;
return Ok(());
}
}
Err(format!("npm 包 tgz 中未找到 {exe_name}"))
}
async fn download_and_replace(
client: &reqwest::Client,
main_path: &Path,
remote_version: &str,
sink: &ProgressSink,
) -> Result<(), String> {
let download_url = npm_tgz_download_url(remote_version);
let response = client
.get(DOWNLOAD_URL)
.get(&download_url)
.send()
.await
.map_err(|e| format!("下载主程序失败: {e}"))?;
.map_err(|e| format!("下载 npm 包失败: {e}"))?;
if !response.status().is_success() {
return Err(format!("下载主程序 HTTP {}", response.status()));
return Err(format!(
"下载 npm 包 HTTP {}{}",
response.status(),
download_url
));
}
let total = response.content_length();
let temp_path = main_path.with_extension("exe.download");
let mut file = std::fs::File::create(&temp_path)
.map_err(|e| format!("创建临时文件失败: {e}"))?;
let tgz_path = main_path.with_extension("tgz.download");
let temp_exe = main_path.with_extension("exe.download");
let mut file = std::fs::File::create(&tgz_path)
.map_err(|e| format!("创建临时 tgz 失败: {e}"))?;
let mut downloaded: u64 = 0;
let mut stream = response.bytes_stream();
@@ -276,24 +475,20 @@ async fn download_and_replace(
let chunk = chunk.map_err(|e| format!("下载数据失败: {e}"))?;
use std::io::Write;
file.write_all(&chunk)
.map_err(|e| format!("写入临时文件失败: {e}"))?;
.map_err(|e| format!("写入临时 tgz 失败: {e}"))?;
downloaded += chunk.len() as u64;
let fraction = total.map(|t| {
if t == 0 {
None
} else {
Some((downloaded as f32 / t as f32).clamp(0.0, 1.0))
}
}).flatten();
let fraction = total
.filter(|&t| t > 0)
.map(|t| (downloaded as f32 / t as f32).clamp(0.0, 1.0));
let detail = match total {
Some(t) => format!(
"{} / {}",
"{} / {} (npm)",
format_bytes(downloaded),
format_bytes(t)
),
None => format_bytes(downloaded),
None => format!("{} (npm)", format_bytes(downloaded)),
};
sink.download_progress(fraction, detail);
}
@@ -301,21 +496,42 @@ async fn download_and_replace(
drop(file);
if downloaded == 0 {
let _ = std::fs::remove_file(&temp_path);
return Err("下载的主程序为空".to_string());
let _ = std::fs::remove_file(&tgz_path);
return Err("下载的 npm 包为空".to_string());
}
sink.download_progress(Some(1.0), "正在解压主程序…".to_string());
#[cfg(windows)]
{
extract_package_from_tgz(&tgz_path, &temp_exe)?;
}
#[cfg(not(windows))]
{
let _ = (tgz_path, temp_exe);
return Err("当前平台不支持从 npm tgz 解压".to_string());
}
let _ = std::fs::remove_file(&tgz_path);
sink.download_progress(Some(1.0), "正在替换主程序…".to_string());
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);
std::fs::rename(&temp_exe, main_path).map_err(|e| {
let _ = std::fs::remove_file(&temp_exe);
format!("替换主程序失败: {e}")
})?;
// 与远端版本对齐,确保下次启动 updater 能正确比对
write_version_sidecar(main_path, remote_version)?;
sink.download_progress(
Some(1.0),
format!("已更新 {remote_version} → aiclient.version"),
);
Ok(())
}
@@ -333,7 +549,7 @@ fn launch_main_app(main_path: &Path, dir: &Path) -> Result<(), String> {
}
pub async fn run_updater(sink: ProgressSink) -> Result<(), String> {
sink.status("检查更新");
sink.status("正在从 npm 检查更新…");
let dir = exe_dir()?;
let main_path = dir.join(MAIN_EXE);
@@ -344,19 +560,60 @@ pub async fn run_updater(sink: ProgressSink) -> Result<(), String> {
.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) => !versions_match(&local_version, &remote_version),
Err(_) => true,
let remote_norm = normalize_version(&remote_version);
let npm_note = format!("npm 包 {NPM_PACKAGE_NAME} @ {NPM_REGISTRY_BASE}");
let (local_display, local_norm, needs_update, note) = if main_path.is_file() {
match read_local_version(&main_path) {
Ok((local_version, source)) => {
let local_norm = normalize_version(&local_version);
let matched = local_norm == remote_norm;
let note = if matched {
format!("版本一致(来源: {source}),跳过下载。{npm_note}")
} else {
format!(
"版本不一致(本地 {} vs npm {},来源: {source})。{npm_note}",
local_norm, remote_norm
)
};
(format!("{local_version} [{source}]"), local_norm, !matched, note)
}
Err(e) => {
let note = format!("读取本地版本失败: {e},将尝试下载。{npm_note}");
(
format!("(读取失败: {e})"),
String::new(),
true,
note,
)
}
}
} else {
true
(
"(文件不存在)".to_string(),
String::new(),
true,
format!("未找到 {}{npm_note}", main_path.display()),
)
};
sink.version_info(
&local_display,
&remote_version,
if local_norm.is_empty() {
""
} else {
&local_norm
},
&remote_norm,
needs_update,
&note,
);
if needs_update {
sink.status("下载中…");
sink.download_progress(None, "准备下载…".to_string());
download_and_replace(&client, &main_path, &sink).await?;
download_and_replace(&client, &main_path, &remote_version, &sink).await?;
} else {
sink.status("当前已是最新版本");
}

View File

@@ -7,8 +7,8 @@ use eframe::egui;
use crate::engine::{ProgressSink, UpdaterMessage};
const WINDOW_WIDTH: f32 = 420.0;
const WINDOW_HEIGHT: f32 = 160.0;
const WINDOW_WIDTH: f32 = 440.0;
const WINDOW_HEIGHT: f32 = 260.0;
pub fn run() -> ! {
let options = eframe::NativeOptions {
@@ -41,6 +41,12 @@ struct UpdaterApp {
detail: String,
progress: Option<f32>,
show_bar: bool,
local_version: String,
remote_version: String,
local_normalized: String,
remote_normalized: String,
needs_update: Option<bool>,
version_note: String,
error: Option<String>,
done: bool,
should_close: bool,
@@ -76,6 +82,12 @@ impl UpdaterApp {
detail: String::new(),
progress: None,
show_bar: false,
local_version: "".to_string(),
remote_version: "".to_string(),
local_normalized: "".to_string(),
remote_normalized: "".to_string(),
needs_update: None,
version_note: String::new(),
error: None,
done: false,
should_close: false,
@@ -91,6 +103,21 @@ impl UpdaterApp {
self.detail.clear();
}
}
UpdaterMessage::VersionInfo {
local_display,
remote_display,
local_normalized,
remote_normalized,
needs_update,
note,
} => {
self.local_version = local_display;
self.remote_version = remote_display;
self.local_normalized = local_normalized;
self.remote_normalized = remote_normalized;
self.needs_update = Some(needs_update);
self.version_note = note;
}
UpdaterMessage::DownloadProgress { fraction, detail } => {
self.show_bar = true;
self.status = "下载中…".to_string();
@@ -129,12 +156,59 @@ impl eframe::App for UpdaterApp {
}
egui::CentralPanel::default().show(ctx, |ui| {
ui.add_space(12.0);
ui.add_space(8.0);
ui.vertical_centered(|ui| {
ui.heading(&self.status);
});
ui.add_space(16.0);
ui.add_space(10.0);
egui::Frame::group(ui.style())
.inner_margin(egui::Margin::symmetric(12, 8))
.show(ui, |ui| {
ui.label(egui::RichText::new("版本信息(调试)").strong().size(13.0));
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("本地版本:");
ui.monospace(&self.local_version);
});
ui.horizontal(|ui| {
ui.label("远程版本:");
ui.monospace(&self.remote_version);
});
ui.horizontal(|ui| {
ui.label("npm 包:");
ui.monospace(crate::engine::NPM_PACKAGE_NAME);
});
ui.horizontal(|ui| {
ui.label("归一化本地:");
ui.monospace(&self.local_normalized);
});
ui.horizontal(|ui| {
ui.label("归一化远程:");
ui.monospace(&self.remote_normalized);
});
if let Some(needs) = self.needs_update {
let (text, color) = if needs {
("需要下载", egui::Color32::from_rgb(230, 160, 60))
} else {
("无需下载", egui::Color32::from_rgb(80, 180, 120))
};
ui.horizontal(|ui| {
ui.label("比对结果:");
ui.colored_label(color, text);
});
}
if !self.version_note.is_empty() {
ui.add_space(4.0);
ui.label(
egui::RichText::new(&self.version_note)
.size(12.0)
.color(ui.visuals().weak_text_color()),
);
}
});
ui.add_space(10.0);
if self.show_bar {
let mut bar = egui::ProgressBar::new(self.progress.unwrap_or(0.0))

View File

@@ -32,6 +32,20 @@ pub mod publish_store;
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
/// 开发:本地 Vite发布服务器静态资源与 aiclient_ui `build:deploy` 输出目录一致)
const DEV_WEB_URL: &str = "http://localhost:1420/";
const PROD_WEB_URL: &str = "http://81.71.163.140/images/newyaoyan/index.html";
fn webview_url() -> WebviewUrl {
let url = if cfg!(debug_assertions) {
DEV_WEB_URL
} else {
PROD_WEB_URL
};
WebviewUrl::External(url.parse().expect("webview url"))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let _ = env_logger::try_init();
@@ -81,25 +95,8 @@ pub fn run() {
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
api_client::spawn_heartbeat_loop(heartbeat_handle);
let url = if cfg!(debug_assertions) {
WebviewUrl::External(
"http://localhost:1420/"
.parse()
.unwrap()
)
} else {
WebviewUrl::External(
"http://81.71.163.140/images/newyaoyan/index.html"
.parse()
.unwrap()
)
};
WebviewWindowBuilder::new(
app,
"main",
url,
)
WebviewWindowBuilder::new(app, "main", webview_url())
.title("MyApp")
.build()?;

View File

@@ -1,13 +1,12 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "aiclient",
"version": "0.1.3",
"version": "0.1.8",
"identifier": "com.aiclient.desktop",
"build": {
"beforeDevCommand": "npm run dev",
"beforeDevCommand": "npm run dev --prefix ../aiclient_ui",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
"frontendDist": "frontend-stub"
},
"app": {
"withGlobalTauri": true,