This commit is contained in:
949036910@qq.com
2026-05-30 17:04:39 +08:00
parent 53d0a6eb5f
commit e10a66ccf2
7 changed files with 1643 additions and 134 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "aiclient", "name": "aiclient",
"private": true, "private": true,
"version": "0.1.1", "version": "0.1.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

1380
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2,13 +2,53 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::sync::mpsc::Sender;
use futures_util::StreamExt;
use serde_json::Value; use serde_json::Value;
const ENV_FROM_UPDATER: &str = "AICLIENT_FROM_UPDATER"; pub const ENV_FROM_UPDATER: &str = "AICLIENT_FROM_UPDATER";
const MAIN_EXE: &str = "aiclient.exe"; pub const MAIN_EXE: &str = "aiclient.exe";
const CHECK_UPDATE_URL: &str = "http://81.71.163.140:8001/api/checkupdate"; pub 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"; pub const DOWNLOAD_URL: &str = "http://81.71.163.140:80/images/mainfile/main.exe";
#[derive(Clone, Debug)]
pub enum UpdaterMessage {
Status(String),
/// 0.0 ~ 1.0;无总大小时为 None界面显示不确定进度
DownloadProgress { fraction: Option<f32>, detail: String },
Launching,
Finished(Result<(), String>),
}
pub struct ProgressSink {
tx: Sender<UpdaterMessage>,
}
impl ProgressSink {
pub fn new(tx: Sender<UpdaterMessage>) -> Self {
Self { tx }
}
fn send(&self, msg: UpdaterMessage) {
let _ = self.tx.send(msg);
}
pub fn status(&self, text: impl Into<String>) {
self.send(UpdaterMessage::Status(text.into()));
}
pub fn download_progress(&self, fraction: Option<f32>, detail: impl Into<String>) {
self.send(UpdaterMessage::DownloadProgress {
fraction,
detail: detail.into(),
});
}
pub fn launching(&self) {
self.send(UpdaterMessage::Launching);
}
}
fn exe_dir() -> Result<PathBuf, String> { fn exe_dir() -> Result<PathBuf, String> {
std::env::current_exe() std::env::current_exe()
@@ -50,6 +90,13 @@ fn parse_remote_version(body: &str) -> Result<String, String> {
} }
if let Ok(value) = serde_json::from_str::<Value>(trimmed) { 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"] { for key in ["version", "latest_version", "latestVersion", "app_version"] {
if let Some(v) = value.get(key).and_then(|x| x.as_str()) { if let Some(v) = value.get(key).and_then(|x| x.as_str()) {
if !v.trim().is_empty() { if !v.trim().is_empty() {
@@ -190,7 +237,23 @@ fn read_exe_version(path: &Path) -> Result<String, String> {
Err("当前平台不支持读取可执行文件版本".to_string()) Err("当前平台不支持读取可执行文件版本".to_string())
} }
async fn download_and_replace(client: &reqwest::Client, main_path: &Path) -> Result<(), String> { fn format_bytes(n: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
if n >= MB {
format!("{:.1} MB", n as f64 / MB as f64)
} else if n >= KB {
format!("{:.1} KB", n as f64 / KB as f64)
} else {
format!("{n} B")
}
}
async fn download_and_replace(
client: &reqwest::Client,
main_path: &Path,
sink: &ProgressSink,
) -> Result<(), String> {
let response = client let response = client
.get(DOWNLOAD_URL) .get(DOWNLOAD_URL)
.send() .send()
@@ -201,17 +264,48 @@ async fn download_and_replace(client: &reqwest::Client, main_path: &Path) -> Res
return Err(format!("下载主程序 HTTP {}", response.status())); return Err(format!("下载主程序 HTTP {}", response.status()));
} }
let bytes = response let total = response.content_length();
.bytes() let temp_path = main_path.with_extension("exe.download");
.await let mut file = std::fs::File::create(&temp_path)
.map_err(|e| format!("读取下载内容失败: {e}"))?; .map_err(|e| format!("创建临时文件失败: {e}"))?;
if bytes.is_empty() { let mut downloaded: u64 = 0;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("下载数据失败: {e}"))?;
use std::io::Write;
file.write_all(&chunk)
.map_err(|e| format!("写入临时文件失败: {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 detail = match total {
Some(t) => format!(
"{} / {}",
format_bytes(downloaded),
format_bytes(t)
),
None => format_bytes(downloaded),
};
sink.download_progress(fraction, detail);
}
drop(file);
if downloaded == 0 {
let _ = std::fs::remove_file(&temp_path);
return Err("下载的主程序为空".to_string()); return Err("下载的主程序为空".to_string());
} }
let temp_path = main_path.with_extension("exe.download"); sink.download_progress(Some(1.0), "正在替换主程序…".to_string());
std::fs::write(&temp_path, &bytes).map_err(|e| format!("写入临时文件失败: {e}"))?;
if main_path.exists() { if main_path.exists() {
std::fs::remove_file(main_path).map_err(|e| format!("删除旧主程序失败: {e}"))?; std::fs::remove_file(main_path).map_err(|e| format!("删除旧主程序失败: {e}"))?;
@@ -238,45 +332,36 @@ fn launch_main_app(main_path: &Path, dir: &Path) -> Result<(), String> {
Ok(()) Ok(())
} }
pub async fn run_updater() -> Result<(), String> { pub async fn run_updater(sink: ProgressSink) -> Result<(), String> {
sink.status("检查更新中…");
let dir = exe_dir()?; let dir = exe_dir()?;
let main_path = dir.join(MAIN_EXE); let main_path = dir.join(MAIN_EXE);
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120)) .timeout(std::time::Duration::from_secs(300))
.build() .build()
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; .map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
let remote_version = fetch_latest_version(&client).await?; let remote_version = fetch_latest_version(&client).await?;
let needs_update = if main_path.is_file() { let needs_update = if main_path.is_file() {
match read_exe_version(&main_path) { match read_exe_version(&main_path) {
Ok(local_version) => { Ok(local_version) => !versions_match(&local_version, &remote_version),
let matched = versions_match(&local_version, &remote_version); Err(_) => true,
if matched {
eprintln!(
"当前版本 {local_version} 已是最新 ({remote_version}),跳过下载"
);
} else {
eprintln!(
"版本不一致: 本地 {local_version},远端 {remote_version},开始更新"
);
}
!matched
}
Err(e) => {
eprintln!("读取本地版本失败 ({e}),将尝试下载主程序");
true
}
} }
} else { } else {
eprintln!("主程序不存在,将下载: {}", main_path.display());
true true
}; };
if needs_update { if needs_update {
download_and_replace(&client, &main_path).await?; sink.status("下载中…");
sink.download_progress(None, "准备下载…".to_string());
download_and_replace(&client, &main_path, &sink).await?;
} else {
sink.status("当前已是最新版本");
} }
sink.launching();
launch_main_app(&main_path, &dir)?; launch_main_app(&main_path, &dir)?;
Ok(()) Ok(())
} }

View File

@@ -1,11 +1,24 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!! // Release 使用 GUI不弹出控制台窗口
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(all(not(debug_assertions), windows), windows_subsystem = "windows")]
mod engine; mod engine;
#[cfg(windows)]
mod ui;
#[cfg(windows)]
fn main() {
ui::run();
}
#[cfg(not(windows))]
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
if let Err(e) = engine::run_updater().await { let (tx, rx) = std::sync::mpsc::channel();
let sink = engine::ProgressSink::new(tx);
let result = engine::run_updater(sink).await;
drop(rx);
if let Err(e) = result {
eprintln!("updater 运行失败: {e}"); eprintln!("updater 运行失败: {e}");
std::process::exit(1); std::process::exit(1);
} }

View File

@@ -0,0 +1,219 @@
//! updater 图形界面Windows
use std::sync::mpsc::{self, Receiver};
use std::time::Duration;
use eframe::egui;
use crate::engine::{ProgressSink, UpdaterMessage};
const WINDOW_WIDTH: f32 = 420.0;
const WINDOW_HEIGHT: f32 = 160.0;
pub fn run() -> ! {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([WINDOW_WIDTH, WINDOW_HEIGHT])
.with_min_inner_size([WINDOW_WIDTH, WINDOW_HEIGHT])
.with_max_inner_size([WINDOW_WIDTH, WINDOW_HEIGHT])
.with_resizable(false)
.with_title("aiclient 更新")
.with_always_on_top(),
..Default::default()
};
let result = eframe::run_native(
"aiclient 更新",
options,
Box::new(|cc| Ok(Box::new(UpdaterApp::new(cc)))),
);
if let Err(e) = result {
eprintln!("updater 界面启动失败: {e}");
std::process::exit(1);
}
std::process::exit(0);
}
struct UpdaterApp {
rx: Receiver<UpdaterMessage>,
status: String,
detail: String,
progress: Option<f32>,
show_bar: bool,
error: Option<String>,
done: bool,
should_close: bool,
}
impl UpdaterApp {
fn new(cc: &eframe::CreationContext<'_>) -> Self {
setup_chinese_fonts(&cc.egui_ctx);
let (tx, rx) = mpsc::channel();
let sink = ProgressSink::new(tx.clone());
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(UpdaterMessage::Finished(Err(format!(
"初始化运行时失败: {e}"
))));
return;
}
};
let result = rt.block_on(crate::engine::run_updater(sink));
let _ = tx.send(UpdaterMessage::Finished(result));
});
Self {
rx,
status: "检查更新中…".to_string(),
detail: String::new(),
progress: None,
show_bar: false,
error: None,
done: false,
should_close: false,
}
}
fn poll_messages(&mut self) {
while let Ok(msg) = self.rx.try_recv() {
match msg {
UpdaterMessage::Status(text) => {
self.status = text;
if !self.show_bar {
self.detail.clear();
}
}
UpdaterMessage::DownloadProgress { fraction, detail } => {
self.show_bar = true;
self.status = "下载中…".to_string();
self.progress = fraction;
self.detail = detail;
}
UpdaterMessage::Launching => {
self.show_bar = false;
self.status = "正在启动主程序…".to_string();
self.detail.clear();
}
UpdaterMessage::Finished(result) => {
self.done = true;
match result {
Ok(()) => {
self.should_close = true;
}
Err(e) => {
self.error = Some(e);
self.status = "更新失败".to_string();
}
}
}
}
}
}
}
impl eframe::App for UpdaterApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.poll_messages();
if self.should_close {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
return;
}
egui::CentralPanel::default().show(ctx, |ui| {
ui.add_space(12.0);
ui.vertical_centered(|ui| {
ui.heading(&self.status);
});
ui.add_space(16.0);
if self.show_bar {
let mut bar = egui::ProgressBar::new(self.progress.unwrap_or(0.0))
.animate(self.progress.is_none());
if self.progress.is_some() {
bar = bar.show_percentage();
}
ui.add(bar);
if !self.detail.is_empty() {
ui.add_space(8.0);
ui.label(
egui::RichText::new(&self.detail)
.size(13.0)
.color(ui.visuals().weak_text_color()),
);
}
} else if !self.detail.is_empty() {
ui.label(&self.detail);
}
if let Some(err) = &self.error {
ui.add_space(12.0);
ui.separator();
ui.add_space(8.0);
ui.label(
egui::RichText::new(err)
.color(egui::Color32::from_rgb(220, 80, 80)),
);
ui.add_space(12.0);
ui.vertical_centered(|ui| {
if ui.button("关闭").clicked() {
std::process::exit(1);
}
});
}
});
if !self.done {
ctx.request_repaint_after(Duration::from_millis(80));
}
}
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
if self.error.is_some() {
std::process::exit(1);
}
}
}
fn setup_chinese_fonts(ctx: &egui::Context) {
let mut fonts = egui::FontDefinitions::default();
let candidates = [
"C:\\Windows\\Fonts\\msyh.ttc",
"C:\\Windows\\Fonts\\msyhbd.ttc",
"C:\\Windows\\Fonts\\simhei.ttf",
"C:\\Windows\\Fonts\\simsun.ttc",
];
for path in candidates {
if let Ok(bytes) = std::fs::read(path) {
fonts.font_data.insert(
"chinese".to_owned(),
egui::FontData::from_owned(bytes).into(),
);
fonts
.families
.entry(egui::FontFamily::Proportional)
.or_default()
.insert(0, "chinese".to_owned());
fonts
.families
.entry(egui::FontFamily::Monospace)
.or_default()
.insert(0, "chinese".to_owned());
break;
}
}
ctx.set_fonts(fonts);
}

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "aiclient", "productName": "aiclient",
"version": "0.1.1", "version": "0.1.3",
"identifier": "com.aiclient.desktop", "identifier": "com.aiclient.desktop",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",