From 5fde1266d54c9343f1b3bbcf847eb40088f82f16 Mon Sep 17 00:00:00 2001 From: whoisa Date: Tue, 29 Sep 2026 01:07:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(update):=20=E6=94=AF=E6=8C=81=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=86=85=E6=A3=80=E6=9F=A5=E5=B9=B6=E5=AE=89=E8=A3=85?= =?UTF-8?q?=20GitHub=20Release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置页和主窗口可以发现新版本,下载对应平台安装包并核对发布附带的 SHA-256 后再安装。 Co-authored-by: Cursor --- README.en.md | 2 + README.md | 2 + src-tauri/src/i18n.rs | 30 ++ src-tauri/src/lib.rs | 23 + src-tauri/src/updater.rs | 946 +++++++++++++++++++++++++++++++++++++++ src/locales/en.js | 26 ++ src/locales/zh.js | 26 ++ src/main.js | 50 +++ src/settings.js | 68 +++ src/styles.css | 41 ++ src/update.js | 70 +++ 11 files changed, 1284 insertions(+) create mode 100644 src-tauri/src/updater.rs create mode 100644 src/update.js diff --git a/README.en.md b/README.en.md index 8b19365..1c164a1 100644 --- a/README.en.md +++ b/README.en.md @@ -14,6 +14,7 @@ A Tauri 2 desktop tool for one-click switching between multiple ZCode accounts, - **Claim promotions**: one-click claim for eligible promotions; "Auto claim" toggle (off by default) checks and claims periodically — manual actions take priority - **Encrypted import / export**: `.zsb` bundle, PBKDF2(100k) + AES-256-GCM password encryption - **Bilingual UI (中文 / English)**: one-click switch in Settings — main window, tray, error messages and CLI output all covered; first run follows your OS language +- **In-app updates**: the main window notices a new version; download and install from Settings - **Tray / autostart / CLI automation** ## Security Design @@ -24,6 +25,7 @@ A Tauri 2 desktop tool for one-click switching between multiple ZCode accounts, - **Path traversal protection**: account id whitelist (`[A-Za-z0-9-]`); delete/read cannot escape the account store directory - **Encrypted export**: PBKDF2-HMAC-SHA256 (100k iterations) + AES-256-GCM, random salt/nonce; wrong password simply fails, no plaintext traces - **Credentials decrypted locally only**: decryption is used solely to display username/email; export files are password-encrypted +- **Update verification**: in-app updates download only from this repo's GitHub Releases and check the published SHA-256 before installing or opening the package ## CLI diff --git a/README.md b/README.md index f03fc44..2457edf 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Tauri 2 桌面工具:在多个 ZCode 账号之间一键切换,自动显示 - **活动领取**:可领套餐一键领取;「自动领取」开关(默认关)定时自动检测并领取,手动操作优先 - **加密导入导出**:`.zsb` 捆绑包,PBKDF2(100k) + AES-256-GCM 口令加密 - **中英双语**:设置里一键切换 中文 / English,主窗、托盘、错误提示、CLI 输出全覆盖;首次运行按系统语言自动选择 +- **应用内更新**:发现新版本时主窗口会提示,也可在设置里下载并安装 - **托盘 / 开机自启 / CLI 自动化** ## 安全设计 @@ -24,6 +25,7 @@ Tauri 2 桌面工具:在多个 ZCode 账号之间一键切换,自动显示 - **路径穿越防护**:账号 id 白名单(`[A-Za-z0-9-]`),删除/读取均不可逃出账号库目录 - **加密导出**:PBKDF2-HMAC-SHA256(100k 迭代)+ AES-256-GCM,随机 salt/nonce;错密码即失败,无明文痕迹 - **凭据只在本地解密**:凭据解密仅用于显示用户名/邮箱;导出文件凭口令加密 +- **更新包校验**:应用内更新只下载本仓库 GitHub Releases 的安装包,核对随发布附带的 SHA-256 后才安装或打开 ## CLI diff --git a/src-tauri/src/i18n.rs b/src-tauri/src/i18n.rs index 9870728..f1ba7a9 100644 --- a/src-tauri/src/i18n.rs +++ b/src-tauri/src/i18n.rs @@ -214,6 +214,21 @@ const ZH: &[(&str, &str)] = &[ ("cli.read_fail", "读取失败:{e}"), ("cli.json_fail", "JSON 解析失败:{e}"), ("err.lang.unknown", "未知语言:{lang}(支持 zh / en)"), + ("err.update.rate", "GitHub 接口繁忙,请稍后再试"), + ("err.update.http", "请求失败(HTTP {code})"), + ("err.update.network", "网络请求失败:{e}"), + ("err.update.parse", "无法解析发布信息"), + ("err.update.bad_version", "无法识别当前版本号"), + ("err.update.none", "没有可安装的更新"), + ("err.update.no_asset", "没有适用于当前系统的安装包"), + ("err.update.checksum_missing", "发布包缺少 SHA-256 校验和,已取消安装"), + ("err.update.checksum_mismatch", "安装包校验失败,已取消安装"), + ("err.update.too_big", "安装包过大,已取消下载"), + ("err.update.download", "下载失败:{e}"), + ("err.update.apply", "无法启动安装:{e}"), + ("err.update.path", "安装路径异常,已取消安装"), + ("err.update.busy", "正在安装更新"), + ("err.update.write", "无法写入更新文件:{e}"), ]; const EN: &[(&str, &str)] = &[ @@ -371,6 +386,21 @@ const EN: &[(&str, &str)] = &[ ("cli.read_fail", "Read failed: {e}"), ("cli.json_fail", "JSON parse failed: {e}"), ("err.lang.unknown", "Unknown language: {lang} (supported: zh / en)"), + ("err.update.rate", "GitHub is rate-limiting requests — try again shortly"), + ("err.update.http", "Request failed (HTTP {code})"), + ("err.update.network", "Network request failed: {e}"), + ("err.update.parse", "Could not parse the release feed"), + ("err.update.bad_version", "The current version number is not recognized"), + ("err.update.none", "No update to install"), + ("err.update.no_asset", "This release has no package for the current system"), + ("err.update.checksum_missing", "The release has no SHA-256 checksum — installation cancelled"), + ("err.update.checksum_mismatch", "The package failed its checksum — installation cancelled"), + ("err.update.too_big", "The package is too large — download cancelled"), + ("err.update.download", "Download failed: {e}"), + ("err.update.apply", "Could not start the installer: {e}"), + ("err.update.path", "The install path is not usable — installation cancelled"), + ("err.update.busy", "An update is already being installed"), + ("err.update.write", "Could not write the update file: {e}"), ]; fn table_for(lang: Lang) -> &'static [(&'static str, &'static str)] { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d7b0fb5..db4167a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod oauth; mod probe; mod quota; mod store; +mod updater; mod zcrypto; use serde_json::{json, Value}; @@ -1123,6 +1124,26 @@ async fn open_external(url: String) -> Result<(), String> { store::open_url(&url) } +#[tauri::command] +async fn check_app_update(app: AppHandle) -> Result { + let current = app.package_info().version.to_string(); + let exe = std::env::current_exe().ok(); + match tauri::async_runtime::spawn_blocking(move || updater::check(¤t, exe.as_deref())).await { + Ok(r) => r, + Err(e) => Err(i18n::trf("err.update.apply", &[("e", &e.to_string())])), + } +} + +#[tauri::command] +async fn install_app_update(app: AppHandle) -> Result { + let current = app.package_info().version.to_string(); + let exe = std::env::current_exe().map_err(|e| i18n::trf("err.update.apply", &[("e", &e.to_string())]))?; + match tauri::async_runtime::spawn_blocking(move || updater::install(app, ¤t, &exe)).await { + Ok(r) => r, + Err(e) => Err(i18n::trf("err.update.apply", &[("e", &e.to_string())])), + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -1168,6 +1189,8 @@ pub fn run() { set_zcode_path, launch_zcode, open_external, + check_app_update, + install_app_update, open_settings, reveal_main, ]) diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs new file mode 100644 index 0000000..5f69b19 --- /dev/null +++ b/src-tauri/src/updater.rs @@ -0,0 +1,946 @@ +//! 应用内更新:只查询本仓库的 GitHub Releases。 +//! 下载地址由 tag 和文件名拼出,不用接口返回的任意 URL; +//! 安装前用同一次发布里的 `checksums-*.txt` 核对 SHA-256。 + +use serde::Deserialize; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; +use tauri::{AppHandle, Emitter}; + +use crate::i18n::{tr, trf}; + +const REPO: &str = "pjpv/zcode-switch"; +const RELEASES_URL: &str = + "https://api.github.com/repos/pjpv/zcode-switch/releases?per_page=30"; +const UA: &str = concat!("zcode-switch/", env!("CARGO_PKG_VERSION")); +const MAX_INSTALLER: u64 = 80 * 1024 * 1024; +const MAX_CHECKSUM: u64 = 1024 * 1024; +const MAX_API: u64 = 2 * 1024 * 1024; + +static INSTALLING: AtomicBool = AtomicBool::new(false); + +struct InstallGuard; +impl Drop for InstallGuard { + fn drop(&mut self) { + INSTALLING.store(false, Ordering::SeqCst); + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct UpdateCheck { + pub current: String, + pub latest: String, + pub available: bool, + pub prerelease: bool, + pub notes: String, + pub html_url: String, + pub asset_name: String, + pub mode: String, + pub installable: bool, + pub quitting: bool, + /// "" | "dev" | "no_asset" + pub reason: String, + #[serde(skip)] + tag: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct UpdateApply { + pub mode: String, + pub quitting: bool, +} + +#[derive(Debug, Clone)] +struct ReleaseInfo { + tag: String, + version_text: String, + version: SemVer, + prerelease: bool, + draft: bool, + notes: String, + assets: Vec, +} + +#[derive(Debug, Clone)] +struct Host { + os: &'static str, + arch: &'static str, + portable: bool, + appimage: bool, + dev: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SemVer { + major: u64, + minor: u64, + patch: u64, + pre: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PreId { + Num(u64), + Str(String), +} + +impl Ord for SemVer { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + (self.major, self.minor, self.patch) + .cmp(&(other.major, other.minor, other.patch)) + .then_with(|| cmp_pre(&self.pre, &other.pre)) + } +} + +impl PartialOrd for SemVer { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +fn cmp_pre(a: &[PreId], b: &[PreId]) -> std::cmp::Ordering { + match (a.is_empty(), b.is_empty()) { + (true, true) => std::cmp::Ordering::Equal, + (true, false) => std::cmp::Ordering::Greater, + (false, true) => std::cmp::Ordering::Less, + (false, false) => { + for (x, y) in a.iter().zip(b.iter()) { + let o = cmp_id(x, y); + if o != std::cmp::Ordering::Equal { + return o; + } + } + a.len().cmp(&b.len()) + } + } +} + +fn cmp_id(a: &PreId, b: &PreId) -> std::cmp::Ordering { + match (a, b) { + (PreId::Num(x), PreId::Num(y)) => x.cmp(y), + (PreId::Str(x), PreId::Str(y)) => x.cmp(y), + (PreId::Num(_), PreId::Str(_)) => std::cmp::Ordering::Less, + (PreId::Str(_), PreId::Num(_)) => std::cmp::Ordering::Greater, + } +} + +fn parse_num(s: &str) -> Option { + if s.is_empty() || (s.len() > 1 && s.starts_with('0')) { + return None; + } + if !s.chars().all(|c| c.is_ascii_digit()) { + return None; + } + s.parse().ok() +} + +fn parse_semver(raw: &str) -> Option { + let s = raw.trim(); + let s = s.strip_prefix('v').or_else(|| s.strip_prefix('V')).unwrap_or(s); + let s = s.split('+').next().unwrap_or(s); + if s.is_empty() { + return None; + } + let (core, pre) = match s.split_once('-') { + Some((c, p)) => (c, Some(p)), + None => (s, None), + }; + let mut parts = core.split('.'); + let major = parse_num(parts.next()?)?; + let minor = parse_num(parts.next()?)?; + let patch = parse_num(parts.next()?)?; + if parts.next().is_some() { + return None; + } + let pre = match pre { + None => Vec::new(), + Some(p) if p.is_empty() => return None, + Some(p) => p + .split('.') + .map(|id| { + if id.is_empty() { + return None; + } + if id.chars().all(|c| c.is_ascii_digit()) { + Some(PreId::Num(parse_num(id)?)) + } else if id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + Some(PreId::Str(id.to_string())) + } else { + None + } + }) + .collect::>>()?, + }; + Some(SemVer { major, minor, patch, pre }) +} + +fn version_text(raw: &str) -> String { + let s = raw.trim(); + s.strip_prefix('v').or_else(|| s.strip_prefix('V')).unwrap_or(s).to_string() +} + +fn safe_token(s: &str) -> bool { + !s.is_empty() + && s.len() <= 180 + && !s.starts_with('.') + && !s.ends_with('.') + && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) +} + +fn clip(s: &str) -> String { + let mut out = String::new(); + for (i, ch) in s.chars().enumerate() { + if i >= 180 { + out.push('…'); + break; + } + if ch == '\n' || ch == '\r' { + out.push(' '); + continue; + } + out.push(ch); + } + out +} + +fn excerpt(body: &str) -> String { + let t = body.trim().replace("\r\n", "\n").replace('\r', "\n"); + let mut out = String::new(); + for (i, ch) in t.chars().enumerate() { + if i >= 800 { + out.push('…'); + break; + } + out.push(ch); + } + out +} + +fn releases_page(tag: &str) -> String { + format!("https://github.com/{REPO}/releases/tag/{tag}") +} + +fn latest_page() -> String { + format!("https://github.com/{REPO}/releases/latest") +} + +fn download_url(tag: &str, name: &str) -> Result { + let rest = tag.strip_prefix('v').or_else(|| tag.strip_prefix('V')).unwrap_or(""); + if rest.is_empty() || !safe_token(rest) || !safe_token(name) { + return Err(tr("err.update.path")); + } + Ok(format!("https://github.com/{REPO}/releases/download/{tag}/{name}")) +} + +#[derive(Deserialize)] +struct RawAsset { + name: String, +} + +#[derive(Deserialize)] +struct RawRelease { + tag_name: String, + #[serde(default)] + body: Option, + #[serde(default)] + prerelease: bool, + #[serde(default)] + draft: bool, + #[serde(default)] + assets: Vec, +} + +fn parse_releases(text: &str) -> Result, String> { + let v: serde_json::Value = serde_json::from_str(text).map_err(|_| tr("err.update.parse"))?; + if let Some(msg) = v.get("message").and_then(|m| m.as_str()) { + let low = msg.to_ascii_lowercase(); + if low.contains("rate limit") || low.contains("abuse") { + return Err(tr("err.update.rate")); + } + return Err(tr("err.update.parse")); + } + let raw: Vec = serde_json::from_value(v).map_err(|_| tr("err.update.parse"))?; + let mut out = Vec::new(); + for r in raw { + let Some(ver) = parse_semver(&r.tag_name) else { continue }; + let Some(rest) = r.tag_name.strip_prefix('v').or_else(|| r.tag_name.strip_prefix('V')) else { continue }; + if !safe_token(rest) { + continue; + } + let assets = r.assets.into_iter().map(|a| a.name).filter(|n| safe_token(n)).collect(); + out.push(ReleaseInfo { + version_text: version_text(&r.tag_name), + tag: r.tag_name, + version: ver, + prerelease: r.prerelease, + draft: r.draft, + notes: excerpt(r.body.as_deref().unwrap_or("")), + assets, + }); + } + Ok(out) +} + +fn detect_host(exe: Option<&Path>) -> Host { + let os = if cfg!(windows) { + "windows" + } else if cfg!(target_os = "macos") { + "macos" + } else { + "linux" + }; + let arch = if cfg!(target_arch = "aarch64") { + "aarch64" + } else if cfg!(target_arch = "x86_64") { + "x86_64" + } else { + "other" + }; + let portable = exe.map(is_portable_name).unwrap_or(false); + let appimage = std::env::var_os("APPIMAGE").is_some(); + let dev = cfg!(debug_assertions) || exe.map(path_has_target).unwrap_or(false); + Host { os, arch, portable, appimage, dev } +} + +fn is_portable_name(p: &Path) -> bool { + p.file_name() + .and_then(|s| s.to_str()) + .map(|n| n.to_ascii_lowercase().contains("portable")) + .unwrap_or(false) +} + +fn path_has_target(p: &Path) -> bool { + p.components().any(|c| c.as_os_str() == "target") +} + +fn asset_matches(name: &str, mode: &str, arch: &str) -> bool { + let n = name.to_ascii_lowercase(); + match mode { + "setup" => arch == "x86_64" && n.ends_with("-setup.exe"), + "portable" => arch == "x86_64" && n.ends_with("_portable.exe"), + "dmg" => match arch { + "aarch64" => n.ends_with("_aarch64.dmg"), + "x86_64" => n.ends_with("_x64.dmg"), + _ => false, + }, + "appimage" => arch == "x86_64" && n.ends_with(".appimage"), + "deb" => arch == "x86_64" && n.ends_with(".deb"), + _ => false, + } +} + +fn find_asset<'a>(assets: &'a [String], mode: &str, arch: &str) -> Option<&'a str> { + assets.iter().find(|n| asset_matches(n, mode, arch)).map(|s| s.as_str()) +} + +fn preferred_mode(host: &Host) -> &'static str { + match host.os { + "windows" if host.portable => "portable", + "windows" => "setup", + "macos" => "dmg", + _ if host.appimage => "appimage", + _ => "deb", + } +} + +fn plan(host: &Host, release: &ReleaseInfo) -> (String, String, bool, bool, String) { + if host.dev { + return ("page".into(), String::new(), false, false, "dev".into()); + } + let mode = preferred_mode(host); + match find_asset(&release.assets, mode, host.arch) { + Some(name) => { + let quitting = matches!(mode, "setup" | "portable" | "appimage"); + (mode.into(), name.to_string(), true, quitting, String::new()) + } + None => ("page".into(), String::new(), false, false, "no_asset".into()), + } +} + +fn none_available(current: &str) -> UpdateCheck { + UpdateCheck { + current: current.to_string(), + latest: current.to_string(), + available: false, + prerelease: false, + notes: String::new(), + html_url: latest_page(), + asset_name: String::new(), + mode: String::new(), + installable: false, + quitting: false, + reason: String::new(), + tag: String::new(), + } +} + +fn resolve(current_raw: &str, releases: &[ReleaseInfo], host: &Host) -> Result { + let current_ver = parse_semver(current_raw).ok_or_else(|| tr("err.update.bad_version"))?; + let current = version_text(current_raw); + let include_pre = !current_ver.pre.is_empty(); + let best = releases + .iter() + .filter(|r| !r.draft && r.version > current_ver && (include_pre || !r.prerelease)) + .max_by(|a, b| a.version.cmp(&b.version)); + let Some(rel) = best else { + return Ok(none_available(¤t)); + }; + let (mode, asset_name, installable, quitting, reason) = plan(host, rel); + Ok(UpdateCheck { + current, + latest: rel.version_text.clone(), + available: true, + prerelease: rel.prerelease, + notes: rel.notes.clone(), + html_url: releases_page(&rel.tag), + asset_name, + mode, + installable, + quitting, + reason, + tag: rel.tag.clone(), + }) +} + +pub fn check(current: &str, exe: Option<&Path>) -> Result { + let releases = fetch_releases()?; + resolve(current, &releases, &detect_host(exe)) +} + +pub fn install(app: AppHandle, current: &str, exe: &Path) -> Result { + if INSTALLING.swap(true, Ordering::SeqCst) { + return Err(tr("err.update.busy")); + } + let _guard = InstallGuard; + let host = detect_host(Some(exe)); + let releases = fetch_releases()?; + let info = resolve(current, &releases, &host)?; + if !info.available { + return Err(tr("err.update.none")); + } + if !info.installable { + crate::store::open_url(&info.html_url)?; + return Ok(UpdateApply { mode: "page".into(), quitting: false }); + } + let rel = releases.iter().find(|r| r.tag == info.tag).ok_or_else(|| tr("err.update.none"))?; + let dest = download_verified(&app, rel, &info.asset_name)?; + apply(&app, &info, &dest, exe) +} + +fn apply(app: &AppHandle, info: &UpdateCheck, dest: &Path, exe: &Path) -> Result { + emit_progress(app, "apply", 0, 0); + if dest == exe { + let _ = fs::remove_file(dest); + return Err(tr("err.update.path")); + } + match info.mode.as_str() { + #[cfg(windows)] + "setup" => apply_setup(app, dest), + #[cfg(windows)] + "portable" => apply_portable(app, dest, exe), + #[cfg(not(windows))] + "setup" | "portable" => Err(tr("err.update.no_asset")), + #[cfg(unix)] + "appimage" => apply_appimage(app, dest), + #[cfg(not(unix))] + "appimage" => Err(tr("err.update.no_asset")), + #[cfg(not(windows))] + "dmg" | "deb" => { + open_path(dest)?; + Ok(UpdateApply { mode: info.mode.clone(), quitting: false }) + } + #[cfg(windows)] + "dmg" | "deb" => Err(tr("err.update.no_asset")), + "page" => { + crate::store::open_url(&info.html_url)?; + Ok(UpdateApply { mode: "page".into(), quitting: false }) + } + _ => Err(tr("err.update.no_asset")), + } +} + +fn download_verified(app: &AppHandle, rel: &ReleaseInfo, asset_name: &str) -> Result { + if !safe_token(asset_name) { + return Err(tr("err.update.path")); + } + emit_progress(app, "checksum", 0, 0); + let checksum_names: Vec<&String> = rel.assets.iter().filter(|n| is_checksum_name(n)).collect(); + if checksum_names.is_empty() { + return Err(tr("err.update.checksum_missing")); + } + let mut entries = Vec::new(); + let mut last_err: Option = None; + for name in checksum_names { + let url = download_url(&rel.tag, name)?; + let resp = match call(&file_agent(), &url, "text/plain,*/*") { + Ok(r) => r, + Err(e) => { + last_err = Some(e); + continue; + } + }; + match read_capped(resp, MAX_CHECKSUM) { + Ok(bytes) => entries.extend(parse_checksums(&String::from_utf8_lossy(&bytes))), + Err(e) => last_err = Some(e), + } + } + if entries.is_empty() { + return Err(last_err.unwrap_or_else(|| tr("err.update.checksum_missing"))); + } + let expected = match lookup_hash(&entries, asset_name) { + Ok(Some(h)) => h, + Ok(None) => return Err(tr("err.update.checksum_missing")), + Err(()) => return Err(tr("err.update.checksum_mismatch")), + }; + + let url = download_url(&rel.tag, asset_name)?; + let dir = std::env::temp_dir().join("zcode-switch-update"); + fs::create_dir_all(&dir).map_err(|e| trf("err.update.write", &[("e", &clip(&e.to_string()))]))?; + let dest = dir.join(asset_name); + let hashed = download_hashed(&file_agent(), &url, &dest, MAX_INSTALLER, app, "download"); + let got = match hashed { + Ok(h) => h, + Err(e) => { + let _ = fs::remove_file(&dest); + return Err(e); + } + }; + emit_progress(app, "verify", 0, 0); + if !got.eq_ignore_ascii_case(&expected) { + let _ = fs::remove_file(&dest); + return Err(tr("err.update.checksum_mismatch")); + } + Ok(dest) +} + +fn is_checksum_name(name: &str) -> bool { + let n = name.to_ascii_lowercase(); + n.starts_with("checksums-") && n.ends_with(".txt") +} + +fn parse_checksums(text: &str) -> Vec<(String, String)> { + text.lines().filter_map(parse_checksum_line).collect() +} + +fn parse_checksum_line(line: &str) -> Option<(String, String)> { + let line = line.trim().trim_start_matches('\u{feff}'); + if line.is_empty() || line.starts_with('#') { + return None; + } + let mut parts = line.split_whitespace(); + let hash = parts.next()?.to_ascii_lowercase(); + if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let name = parts.next()?; + if parts.next().is_some() { + return None; + } + let name = name.trim_start_matches('*'); + let name = name.rsplit(['/', '\\']).next().unwrap_or(name); + if !safe_token(name) { + return None; + } + Some((hash, name.to_string())) +} + +fn lookup_hash(entries: &[(String, String)], name: &str) -> Result, ()> { + let mut found: Option = None; + for (hash, fname) in entries { + if fname.eq_ignore_ascii_case(name) { + if found.as_ref().is_some_and(|h| h != hash) { + return Err(()); + } + found = Some(hash.clone()); + } + } + Ok(found) +} + +fn api_agent() -> ureq::Agent { + ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(15)) + .timeout(Duration::from_secs(20)) + .redirects(5) + .build() +} + +fn file_agent() -> ureq::Agent { + ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(15)) + .timeout(Duration::from_secs(180)) + .redirects(8) + .build() +} + +fn map_http(e: ureq::Error) -> String { + match e { + ureq::Error::Status(code, _) if code == 403 || code == 429 => tr("err.update.rate"), + ureq::Error::Status(code, _) => trf("err.update.http", &[("code", &code.to_string())]), + other => trf("err.update.network", &[("e", &clip(&other.to_string()))]), + } +} + +fn call(agent: &ureq::Agent, url: &str, accept: &str) -> Result { + agent + .get(url) + .set("User-Agent", UA) + .set("Accept", accept) + .set("X-GitHub-Api-Version", "2022-11-28") + .call() + .map_err(map_http) +} + +fn fetch_releases() -> Result, String> { + let resp = call(&api_agent(), RELEASES_URL, "application/vnd.github+json")?; + let bytes = read_capped(resp, MAX_API)?; + parse_releases(&String::from_utf8_lossy(&bytes)) +} + +fn read_capped(resp: ureq::Response, max: u64) -> Result, String> { + let mut buf = Vec::new(); + let mut reader = resp.into_reader(); + let mut chunk = [0u8; 8192]; + loop { + let n = reader.read(&mut chunk).map_err(|e| trf("err.update.network", &[("e", &clip(&e.to_string()))]))?; + if n == 0 { + break; + } + if buf.len() as u64 + n as u64 > max { + return Err(tr("err.update.too_big")); + } + buf.extend_from_slice(&chunk[..n]); + } + Ok(buf) +} + +fn download_hashed(agent: &ureq::Agent, url: &str, dest: &Path, max: u64, app: &AppHandle, phase: &str) -> Result { + let resp = call(agent, url, "application/octet-stream")?; + let total = resp.header("content-length").and_then(|s| s.parse::().ok()).unwrap_or(0); + if total > max { + return Err(tr("err.update.too_big")); + } + let mut file = File::create(dest).map_err(|e| trf("err.update.write", &[("e", &clip(&e.to_string()))]))?; + let mut hasher = Sha256::new(); + let mut reader = resp.into_reader(); + let mut buf = [0u8; 64 * 1024]; + let mut received = 0u64; + let mut last_emit = 0u64; + emit_progress(app, phase, 0, total); + loop { + let n = reader.read(&mut buf).map_err(|e| trf("err.update.download", &[("e", &clip(&e.to_string()))]))?; + if n == 0 { + break; + } + received += n as u64; + if received > max { + return Err(tr("err.update.too_big")); + } + hasher.update(&buf[..n]); + file.write_all(&buf[..n]).map_err(|e| trf("err.update.write", &[("e", &clip(&e.to_string()))]))?; + if received - last_emit >= 256 * 1024 || (total > 0 && received == total) { + last_emit = received; + emit_progress(app, phase, received, total); + } + } + file.sync_all().map_err(|e| trf("err.update.write", &[("e", &clip(&e.to_string()))]))?; + Ok(format!("{:x}", hasher.finalize())) +} + +fn emit_progress(app: &AppHandle, phase: &str, received: u64, total: u64) { + let _ = app.emit("update-progress", serde_json::json!({ + "phase": phase, + "received": received, + "total": total, + })); +} + +#[cfg(windows)] +fn spawn_detached(cmd: &mut std::process::Command) -> std::io::Result { + use std::os::windows::process::CommandExt; + use std::process::Stdio; + cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); + const BASE: u32 = 0x0000_0008 | 0x0000_0200 | 0x0800_0000; + const BREAKAWAY: u32 = 0x0100_0000; + cmd.creation_flags(BASE | BREAKAWAY); + match cmd.spawn() { + Ok(child) => Ok(child), + Err(_) => { + cmd.creation_flags(BASE); + cmd.spawn() + } + } +} + +#[cfg(not(windows))] +fn spawn_detached(cmd: &mut std::process::Command) -> std::io::Result { + use std::os::unix::process::CommandExt; + use std::process::Stdio; + cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); + cmd.process_group(0); + cmd.spawn() +} + +#[cfg(windows)] +fn apply_setup(app: &AppHandle, dest: &Path) -> Result { + let mut cmd = std::process::Command::new(dest); + spawn_detached(&mut cmd).map_err(|e| trf("err.update.apply", &[("e", &clip(&e.to_string()))]))?; + exit_soon(app); + Ok(UpdateApply { mode: "setup".into(), quitting: true }) +} + +#[cfg(windows)] +fn apply_portable(app: &AppHandle, dest: &Path, exe: &Path) -> Result { + plain_path(exe)?; + plain_path(dest)?; + let src = ps_literal(dest)?; + let dst = ps_literal(exe)?; + let body = format!( + "$ErrorActionPreference = 'Stop'\n\ + $src = {src}\n\ + $dst = {dst}\n\ + for ($i = 0; $i -lt 40; $i++) {{\n\ + Start-Sleep -Milliseconds 500\n\ + try {{\n\ + Copy-Item -LiteralPath $src -Destination $dst -Force\n\ + Start-Process -FilePath $dst\n\ + Remove-Item -LiteralPath $src -Force -ErrorAction SilentlyContinue\n\ + exit 0\n\ + }} catch {{}}\n\ + }}\n\ + exit 1\n" + ); + let script = dest.parent().unwrap_or(Path::new(".")).join("zcode-switch-update.ps1"); + write_ps1(&script, &body)?; + let mut cmd = std::process::Command::new("powershell.exe"); + cmd.args(["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File"]); + cmd.arg(&script); + spawn_detached(&mut cmd).map_err(|e| trf("err.update.apply", &[("e", &clip(&e.to_string()))]))?; + exit_soon(app); + Ok(UpdateApply { mode: "portable".into(), quitting: true }) +} + +#[cfg(windows)] +fn write_ps1(path: &Path, body: &str) -> Result<(), String> { + let mut bytes = vec![0xEF, 0xBB, 0xBF]; + bytes.extend(body.as_bytes()); + fs::write(path, bytes).map_err(|e| trf("err.update.write", &[("e", &clip(&e.to_string()))])) +} + +#[cfg(windows)] +fn ps_literal(p: &Path) -> Result { + let s = p.to_str().filter(|s| !s.is_empty() && !s.contains('\n') && !s.contains('\r') && !s.contains('\0')).ok_or_else(|| tr("err.update.path"))?; + Ok(format!("'{}'", s.replace('\'', "''"))) +} + +fn exit_soon(app: &AppHandle) { + let app = app.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(400)); + app.exit(0); + }); +} + +fn plain_path(p: &Path) -> Result<(), String> { + let s = p.to_str().ok_or_else(|| tr("err.update.path"))?; + if s.is_empty() || s.contains('\n') || s.contains('\r') || s.contains('\0') || !p.is_absolute() { + return Err(tr("err.update.path")); + } + Ok(()) +} + +#[cfg(unix)] +fn apply_appimage(app: &AppHandle, dest: &Path) -> Result { + use std::os::unix::fs::PermissionsExt; + let target = appimage_path()?; + if dest == target { + return Err(tr("err.update.path")); + } + fs::set_permissions(dest, fs::Permissions::from_mode(0o755)).map_err(|e| trf("err.update.write", &[("e", &clip(&e.to_string()))]))?; + fs::copy(dest, &target).map_err(|e| trf("err.update.write", &[("e", &clip(&e.to_string()))]))?; + fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).map_err(|e| trf("err.update.write", &[("e", &clip(&e.to_string()))]))?; + let quoted = sh_quote(&target)?; + let script_path = dest.parent().unwrap_or(Path::new(".")).join("zcode-switch-update.sh"); + let body = format!("#!/bin/sh\nsleep 2\nexec {quoted}\n"); + fs::write(&script_path, body).map_err(|e| trf("err.update.write", &[("e", &clip(&e.to_string()))]))?; + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).map_err(|e| trf("err.update.write", &[("e", &clip(&e.to_string()))]))?; + let mut cmd = std::process::Command::new(&script_path); + spawn_detached(&mut cmd).map_err(|e| trf("err.update.apply", &[("e", &clip(&e.to_string()))]))?; + exit_soon(app); + Ok(UpdateApply { mode: "appimage".into(), quitting: true }) +} + +#[cfg(unix)] +fn sh_quote(p: &Path) -> Result { + let s = p.to_str().filter(|s| !s.is_empty() && !s.contains('\n') && !s.contains('\r') && !s.contains('\0')).ok_or_else(|| tr("err.update.path"))?; + Ok(format!("'{}'", s.replace('\'', "'\\''"))) +} + +#[cfg(unix)] +fn appimage_path() -> Result { + let raw = std::env::var("APPIMAGE").map_err(|_| tr("err.update.path"))?; + let path = PathBuf::from(&raw); + plain_path(&path)?; + Ok(path) +} + +#[cfg(not(windows))] +fn open_path(path: &Path) -> Result<(), String> { + #[cfg(target_os = "macos")] + let mut cmd = std::process::Command::new("open"); + #[cfg(not(target_os = "macos"))] + let mut cmd = std::process::Command::new("xdg-open"); + cmd.arg(path); + spawn_detached(&mut cmd).map(|_| ()).map_err(|e| trf("err.update.apply", &[("e", &clip(&e.to_string()))])) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn host(os: &'static str, arch: &'static str) -> Host { + Host { os, arch, portable: false, appimage: false, dev: false } + } + + fn rel(tag: &str, pre: bool, assets: &[&str]) -> ReleaseInfo { + ReleaseInfo { + version_text: version_text(tag), + version: parse_semver(tag).unwrap(), + tag: tag.to_string(), + prerelease: pre, + draft: false, + notes: String::new(), + assets: assets.iter().map(|s| s.to_string()).collect(), + } + } + + #[test] + fn semver_orders_prerelease_and_numeric_ids() { + let v = |s| parse_semver(s).unwrap(); + assert!(v("1.6.0") < v("1.7.0")); + assert!(v("1.9.0") < v("1.10.0")); + assert!(v("1.7.0-beta.1") < v("1.7.0-beta.2")); + assert!(v("1.7.0-beta.2") < v("1.7.0-beta.10")); + assert!(v("1.7.0-beta.1") < v("1.7.0")); + assert_eq!(v("v1.6.0"), v("1.6.0")); + assert!(parse_semver("1.7").is_none()); + assert!(parse_semver("1.7.0-beta.01").is_none()); + } + + #[test] + fn stable_channel_skips_newer_prerelease() { + let releases = vec![ + rel("v1.6.0", false, &["zcode-switch_1.6.0_x64-setup.exe"]), + rel("v1.7.0-beta.1", true, &["zcode-switch_1.7.0-beta.1_x64-setup.exe"]), + ]; + let info = resolve("1.6.0", &releases, &host("windows", "x86_64")).unwrap(); + assert!(!info.available); + + let info = resolve("1.5.0", &releases, &host("windows", "x86_64")).unwrap(); + assert!(info.available); + assert_eq!(info.latest, "1.6.0"); + assert_eq!(info.mode, "setup"); + assert!(info.installable); + assert!(info.quitting); + assert_eq!(info.asset_name, "zcode-switch_1.6.0_x64-setup.exe"); + } + + #[test] + fn beta_channel_can_move_to_newer_beta_or_stable() { + let releases = vec![ + rel("v1.6.0", false, &["zcode-switch_1.6.0_x64-setup.exe"]), + rel("v1.7.0-beta.2", true, &["zcode-switch_1.7.0-beta.2_x64-setup.exe"]), + ]; + let info = resolve("1.7.0-beta.1", &releases, &host("windows", "x86_64")).unwrap(); + assert_eq!(info.latest, "1.7.0-beta.2"); + assert!(info.prerelease); + + let releases = vec![rel("v1.7.0", false, &["zcode-switch_1.7.0_x64-setup.exe", "zcode-switch_1.7.0_portable.exe"])]; + let mut portable = host("windows", "x86_64"); + portable.portable = true; + let info = resolve("1.7.0-beta.1", &releases, &portable).unwrap(); + assert_eq!(info.latest, "1.7.0"); + assert_eq!(info.mode, "portable"); + assert_eq!(info.asset_name, "zcode-switch_1.7.0_portable.exe"); + } + + #[test] + fn draft_is_ignored_and_dev_build_opens_the_release_page() { + let mut draft = rel("v9.0.0", false, &["zcode-switch_9.0.0_x64-setup.exe"]); + draft.draft = true; + let info = resolve("1.0.0", &[draft], &host("windows", "x86_64")).unwrap(); + assert!(!info.available); + + let mut dev = host("windows", "x86_64"); + dev.dev = true; + let info = resolve("1.0.0", &[rel("v1.2.0", false, &["zcode-switch_1.2.0_x64-setup.exe"])], &dev).unwrap(); + assert!(info.available); + assert!(!info.installable); + assert_eq!(info.mode, "page"); + assert_eq!(info.reason, "dev"); + assert_eq!(info.html_url, "https://github.com/pjpv/zcode-switch/releases/tag/v1.2.0"); + } + + #[test] + fn picks_platform_asset_or_falls_back_to_the_page() { + let assets = [ + "zcode-switch_1.6.0_x64-setup.exe", + "zcode-switch_1.6.0_portable.exe", + "zcode-switch_1.6.0_aarch64.dmg", + "zcode-switch_1.6.0_x64.dmg", + "zcode-switch_1.6.0_amd64.deb", + "zcode-switch_1.6.0_amd64.AppImage", + ]; + let rel = rel("v1.6.0", false, &assets); + assert_eq!(find_asset(&rel.assets, "dmg", "aarch64"), Some("zcode-switch_1.6.0_aarch64.dmg")); + assert_eq!(find_asset(&rel.assets, "dmg", "x86_64"), Some("zcode-switch_1.6.0_x64.dmg")); + assert_eq!(find_asset(&rel.assets, "deb", "aarch64"), None); + + let mut linux = host("linux", "x86_64"); + linux.appimage = true; + let info = resolve("1.0.0", &[rel.clone()], &linux).unwrap(); + assert_eq!(info.mode, "appimage"); + assert!(info.quitting); + + let info = resolve("1.0.0", &[rel], &host("linux", "aarch64")).unwrap(); + assert!(info.available); + assert_eq!(info.reason, "no_asset"); + assert!(!info.installable); + } + + #[test] + fn parse_drops_unsafe_names_and_reads_checksums() { + let raw = r#"[{ + "tag_name":"v1.2.0", + "prerelease":false, + "draft":false, + "body":"hello", + "assets":[ + {"name":"../evil.exe","browser_download_url":"https://evil.example/x"}, + {"name":"zcode-switch_1.2.0_x64-setup.exe"}, + {"name":"checksums-Windows-v1.2.0.txt"} + ] + }]"#; + let rels = parse_releases(raw).unwrap(); + assert_eq!(rels[0].assets, vec!["zcode-switch_1.2.0_x64-setup.exe".to_string(), "checksums-Windows-v1.2.0.txt".to_string()]); + let url = download_url(&rels[0].tag, &rels[0].assets[0]).unwrap(); + assert_eq!(url, "https://github.com/pjpv/zcode-switch/releases/download/v1.2.0/zcode-switch_1.2.0_x64-setup.exe"); + assert!(download_url("v1.2.0", "../evil.exe").is_err()); + + let text = "\ +0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef zcode-switch_1.2.0_x64-setup.exe\n\ +fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 *other.exe\n"; + let entries = parse_checksums(text); + assert_eq!(lookup_hash(&entries, "zcode-switch_1.2.0_x64-setup.exe").unwrap().unwrap(), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + + let conflict = "\ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa a.exe\n\ +bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb a.exe\n"; + assert!(lookup_hash(&parse_checksums(conflict), "a.exe").is_err()); + } +} diff --git a/src/locales/en.js b/src/locales/en.js index 38576f5..73ead1f 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -186,6 +186,32 @@ export const en = { "s.proxySaved": "Proxy address saved", "s.proxySavedOn": "Now active for the login window", "s.proxySavedOff": "The toggle is off — not active yet", + "s.updateLabel": "UPDATE · App update", + "s.updateCheck": "Check for updates", + "s.updateChecking": "Checking…", + "s.updateLatest": "Up to date · v{ver}", + "s.updateInstall": "Download and install", + "s.updateOpen": "Open download page", + "s.updateFail": "Update check failed", + "u.found": "New version v{ver}", + "u.foundPre": "New version v{ver} (pre-release)", + "u.install": "Update", + "u.later": "Later", + "u.openPage": "Open download page", + "u.confirmTitle": "Update to v{ver}", + "u.confirmQuit": "The package will be downloaded and checked against its SHA-256. After it checks out, this app will quit — follow the installer to finish updating.", + "u.confirmRestart": "The new version will be downloaded and checked against its SHA-256. After it checks out, this app will quit and reopen.", + "u.confirmOpen": "The package will be downloaded, checked against its SHA-256, then opened. Follow the system prompt to finish installing.", + "u.confirmPage": "This is a development build, so it will not replace the running program. The release page will open for a manual download.", + "u.confirmManual": "This version has no package that can be installed automatically. The release page will open for a manual download.", + "u.downloading": "Downloading update…", + "u.progress": "Downloading {pct}%", + "u.verifying": "Checking SHA-256…", + "u.applying": "Starting the installer…", + "u.installingQuit": "Checksum OK — the app will quit and install", + "u.restarting": "Checksum OK — the app will quit and reopen", + "u.opened": "Installer opened", + "u.openedPage": "Release page opened", "c.title": "Security verification", "c.preparing": "Preparing security verification…", diff --git a/src/locales/zh.js b/src/locales/zh.js index 432a763..d9ee5da 100644 --- a/src/locales/zh.js +++ b/src/locales/zh.js @@ -186,6 +186,32 @@ export const zh = { "s.proxySaved": "代理地址已保存", "s.proxySavedOn": "已对登录窗口生效", "s.proxySavedOff": "开关未开启,暂不生效", + "s.updateLabel": "UPDATE · 应用更新", + "s.updateCheck": "检查更新", + "s.updateChecking": "正在检查…", + "s.updateLatest": "已是最新版本 v{ver}", + "s.updateInstall": "下载并安装", + "s.updateOpen": "前往下载", + "s.updateFail": "检查更新失败", + "u.found": "发现新版本 v{ver}", + "u.foundPre": "发现新版本 v{ver}(预发布)", + "u.install": "更新", + "u.later": "稍后", + "u.openPage": "前往下载", + "u.confirmTitle": "更新到 v{ver}", + "u.confirmQuit": "将下载安装包并核对 SHA-256。校验通过后本工具会退出,请按安装程序提示完成更新。", + "u.confirmRestart": "将下载新版本并核对 SHA-256。校验通过后本工具会退出并自动重新打开。", + "u.confirmOpen": "将下载安装包并核对 SHA-256,然后打开它,请按系统提示完成安装。", + "u.confirmPage": "当前是开发构建,不会覆盖正在运行的程序,将打开发布页供手动下载。", + "u.confirmManual": "这个版本没有可自动安装的包,将打开发布页手动下载。", + "u.downloading": "正在下载更新…", + "u.progress": "正在下载 {pct}%", + "u.verifying": "正在核对 SHA-256…", + "u.applying": "正在启动安装…", + "u.installingQuit": "校验通过,即将退出并安装", + "u.restarting": "校验通过,即将退出并重新打开", + "u.opened": "已打开安装包", + "u.openedPage": "已打开发布页", "c.title": "安全验证", "c.preparing": "正在准备安全验证…", diff --git a/src/main.js b/src/main.js index d1c2ffb..76ae12d 100644 --- a/src/main.js +++ b/src/main.js @@ -3,12 +3,17 @@ import { listen } from "@tauri-apps/api/event"; import { esc, toast, openPwModal, openConfirmModal, openProviderModal, installDelegation, dismissSplash } from "./ui.js"; import { ic } from "./icons.js"; import { init, t, has, lang, localeTag, stripErr } from "./i18n.js"; +import { bindUpdateProgress, checkAppUpdate, confirmAndInstall } from "./update.js"; const $app = document.getElementById("app"); let state = null; let renaming = null; let busy = false; let appVer = ""; +let updateOffer = null; +let updateDismissed = false; +let updateBusy = false; +let updateDetail = ""; let acctQuota = {}; let claimable = {}; let claimAllRunning = false; @@ -286,6 +291,28 @@ const actions = { try { await invoke("open_settings"); } catch (e) { toast(stripErr(e), "err"); } }, + + installUpdate() { + if (!updateOffer?.available || updateBusy) return; + confirmAndInstall(updateOffer, { + onStart() { + updateBusy = true; + updateDetail = t("u.downloading"); + if (!uiLocked()) render(); + }, + onEnd() { + updateBusy = false; + updateDetail = ""; + if (!uiLocked()) render(); + }, + }); + }, + + dismissUpdate() { + updateDismissed = true; + if (!uiLocked()) render(); + }, + acctQuota(id) { const dueAt = quotaDue[id]; loadAcctQuota(id).then(() => { @@ -737,6 +764,17 @@ function restoreScroll(cap) { } } +function updateBanner() { + if (!updateOffer?.available || updateDismissed) return ""; + const label = updateOffer.prerelease ? t("u.foundPre", { ver: updateOffer.latest }) : t("u.found", { ver: updateOffer.latest }); + const btn = updateOffer.installable ? t("u.install") : t("u.openPage"); + return `
+ + + +
`; +} + function render() { const scrollCap = captureScroll(); if (!state) { @@ -819,6 +857,7 @@ function render() { ${esc(statusText)} + ${updateBanner()}
+ +
+
${esc(updateStatusText())}
+ ${updateInfo?.available && updateInfo.notes ? `
${esc(updateInfo.notes)}
` : ""} +
+ + ${updateInfo?.available ? `` : ""} +
+
${t("s.hint")}
${t("s.githubLink")}${appVer ? `v${esc(appVer)}` : ""}
`; @@ -210,12 +271,19 @@ listen("state-changed", () => { refresh().then(render).catch(() => {}); }); +bindUpdateProgress((text) => { + updateDetail = text; + const el = document.querySelector(".upd-status"); + if (el) el.textContent = text; +}); + (async () => { try { appVer = await invoke("app_version").catch(() => ""); await refresh(); render(); dismissSplash(); + runCheck(false); } catch (e) { $app.innerHTML = `
${t("common.loadFail", { e: esc(String(e)) })}
`; dismissSplash(); diff --git a/src/styles.css b/src/styles.css index eced75f..d8ef906 100644 --- a/src/styles.css +++ b/src/styles.css @@ -479,6 +479,47 @@ input:focus-visible, } .gh-link:hover { text-decoration: underline; } +.update-banner { + margin: 10px 18px 0; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 8px 12px; + border: 1px solid #4a3d22; + border-radius: 8px; + background: rgba(232, 163, 61, .08); + font-size: 14px; +} +.update-banner .banner-text { flex: 1 1 160px; min-width: 0; } +.update-banner .btn-ghost, +.update-banner .btn-primary { + height: 32px; + padding: 0 12px; + flex: none; +} +.upd-box { margin-top: 4px; } +.upd-status { + font-size: 14.5px; + color: var(--ink); + line-height: 1.5; +} +.upd-notes { + margin-top: 8px; + max-height: 120px; + overflow: auto; + font-size: 13px; + line-height: 1.55; + color: var(--ink-dim); + white-space: pre-wrap; + border: 1px solid var(--hairline-soft); + border-radius: 6px; + padding: 8px 10px; +} +.upd-actions { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; } +.upd-actions .btn-ghost, +.upd-actions .btn-primary { height: 34px; } + .toast-zone { position: fixed; left: 50%; diff --git a/src/update.js b/src/update.js new file mode 100644 index 0000000..8e4ab83 --- /dev/null +++ b/src/update.js @@ -0,0 +1,70 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { esc, openConfirmModal, toast } from "./ui.js"; +import { t, stripErr } from "./i18n.js"; + +let installing = false; +let listening = false; +const listeners = new Set(); + +export function checkAppUpdate() { + return invoke("check_app_update"); +} + +export function formatProgress(p) { + if (!p || !p.phase) return ""; + if (p.phase === "download") { + if (p.total > 0) { + const pct = Math.min(100, Math.floor((p.received * 100) / p.total)); + return t("u.progress", { pct }); + } + return t("u.downloading"); + } + if (p.phase === "checksum" || p.phase === "verify") return t("u.verifying"); + if (p.phase === "apply") return t("u.applying"); + return ""; +} + +export function bindUpdateProgress(applyText) { + listeners.add(applyText); + if (!listening) { + listening = true; + listen("update-progress", (ev) => { + const text = formatProgress(ev.payload || {}); + if (!text) return; + for (const fn of listeners) fn(text); + }); + } +} + +function confirmDesc(info) { + if (!info.installable) return info.reason === "dev" ? t("u.confirmPage") : t("u.confirmManual"); + if (info.mode === "setup") return t("u.confirmQuit"); + if (info.quitting) return t("u.confirmRestart"); + return t("u.confirmOpen"); +} + +export function confirmAndInstall(info, { onStart, onEnd } = {}) { + if (!info?.available || installing) return; + openConfirmModal({ + title: t("u.confirmTitle", { ver: info.latest }), + desc: esc(confirmDesc(info)), + yesLabel: info.installable ? t("u.install") : t("u.openPage"), + onYes: async () => { + if (installing) return; + installing = true; + onStart?.(); + try { + const r = await invoke("install_app_update"); + if (r?.quitting) toast(info.mode === "setup" ? t("u.installingQuit") : t("u.restarting")); + else if (!info.installable || r?.mode === "page") toast(t("u.openedPage")); + else toast(t("u.opened")); + } catch (e) { + toast(stripErr(e), "err"); + } finally { + installing = false; + onEnd?.(); + } + }, + }); +}