1111
This commit is contained in:
37
src-tauri/Cargo.lock
generated
37
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -55,6 +55,8 @@ const APP_COMMANDS: &[&str] = &[
|
||||
];
|
||||
|
||||
fn main() {
|
||||
// 勿再用 winres:tauri-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)),
|
||||
|
||||
10
src-tauri/frontend-stub/index.html
Normal file
10
src-tauri/frontend-stub/index.html
Normal 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>
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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, ®istry_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,
|
||||
¬e,
|
||||
);
|
||||
|
||||
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("当前已是最新版本");
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user