11
This commit is contained in:
1380
src-tauri/Cargo.lock
generated
1380
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tauri-app"
|
||||
version = "0.1.1"
|
||||
version = "0.1.3"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -97,3 +97,5 @@ windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Storage_FileSystem",
|
||||
] }
|
||||
eframe = { version = "0.31", default-features = false, features = ["default_fonts", "glow"] }
|
||||
egui = "0.31"
|
||||
|
||||
@@ -2,13 +2,53 @@
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
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";
|
||||
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";
|
||||
|
||||
#[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> {
|
||||
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 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() {
|
||||
@@ -190,7 +237,23 @@ fn read_exe_version(path: &Path) -> Result<String, 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
|
||||
.get(DOWNLOAD_URL)
|
||||
.send()
|
||||
@@ -201,17 +264,48 @@ async fn download_and_replace(client: &reqwest::Client, main_path: &Path) -> Res
|
||||
return Err(format!("下载主程序 HTTP {}", response.status()));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("读取下载内容失败: {e}"))?;
|
||||
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}"))?;
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
let temp_path = main_path.with_extension("exe.download");
|
||||
std::fs::write(&temp_path, &bytes).map_err(|e| format!("写入临时文件失败: {e}"))?;
|
||||
sink.download_progress(Some(1.0), "正在替换主程序…".to_string());
|
||||
|
||||
if main_path.exists() {
|
||||
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(())
|
||||
}
|
||||
|
||||
pub async fn run_updater() -> Result<(), String> {
|
||||
pub async fn run_updater(sink: ProgressSink) -> Result<(), String> {
|
||||
sink.status("检查更新中…");
|
||||
|
||||
let dir = exe_dir()?;
|
||||
let main_path = dir.join(MAIN_EXE);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.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
|
||||
}
|
||||
Ok(local_version) => !versions_match(&local_version, &remote_version),
|
||||
Err(_) => true,
|
||||
}
|
||||
} else {
|
||||
eprintln!("主程序不存在,将下载: {}", main_path.display());
|
||||
true
|
||||
};
|
||||
|
||||
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)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
// Release 使用 GUI,不弹出控制台窗口
|
||||
#![cfg_attr(all(not(debug_assertions), windows), windows_subsystem = "windows")]
|
||||
|
||||
mod engine;
|
||||
|
||||
#[cfg(windows)]
|
||||
mod ui;
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
ui::run();
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::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}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
219
src-tauri/src/bin/updater/ui.rs
Normal file
219
src-tauri/src/bin/updater/ui.rs
Normal 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);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "aiclient",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"identifier": "com.aiclient.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user