From b03c9f28534a8c008e8526d7de37ceaa0961862d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 16:34:32 -0400 Subject: [PATCH] =?UTF-8?q?Updates:=20opt-in=20launch=20check,=20Check=20f?= =?UTF-8?q?or=20Updates=E2=80=A6,=20install=20after=20a=20normal=20quit,?= =?UTF-8?q?=20from=20the=20Gitea=20release;=20dmgs=20carry=20an=20Applicat?= =?UTF-8?q?ions=20link=20(UPD-1,=20REL-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ely-office/src/close.rs | 6 + crates/ely-office/src/dialogs.rs | 6 +- crates/ely-office/src/save.rs | 4 +- crates/ely-platform/src/install.rs | 160 ++++++++ crates/ely-platform/src/lib.rs | 2 + crates/ely-platform/src/release.rs | 46 +++ crates/ely-platform/src/settings.rs | 2 +- crates/ely-platform/src/settings/shared.rs | 9 + crates/ely-platform/src/settings/tests.rs | 5 +- crates/ely-platform/src/update.rs | 4 +- crates/ely-ui/Cargo.toml | 1 + crates/ely-ui/src/commands.rs | 7 +- crates/ely-ui/src/i18n.rs | 6 + crates/ely-ui/src/i18n/settings.rs | 10 + crates/ely-ui/src/lib.rs | 1 + crates/ely-ui/src/run.rs | 6 + crates/ely-ui/src/settings/window.rs | 12 +- crates/ely-ui/src/shell/mod.rs | 1 + crates/ely-ui/src/shell/root.rs | 27 ++ crates/ely-ui/src/updates.rs | 428 +++++++++++++++++++++ packaging/macos/release.sh | 14 +- tasks/BUGS.md | 4 +- tasks/reviews/UPD-1-r1.md | 9 + tasks/reviews/UPD-1-r2.md | 7 + tasks/reviews/UPD-1-r3.md | 7 + tasks/reviews/UPD-1-r4.md | 3 + tasks/reviews/UPD-1-r5.md | 5 + tests/scripts/update.sh | 120 ++++++ 28 files changed, 896 insertions(+), 16 deletions(-) create mode 100644 crates/ely-platform/src/install.rs create mode 100644 crates/ely-platform/src/release.rs create mode 100644 crates/ely-ui/src/updates.rs create mode 100644 tasks/reviews/UPD-1-r1.md create mode 100644 tasks/reviews/UPD-1-r2.md create mode 100644 tasks/reviews/UPD-1-r3.md create mode 100644 tasks/reviews/UPD-1-r4.md create mode 100644 tasks/reviews/UPD-1-r5.md create mode 100755 tests/scripts/update.sh diff --git a/crates/ely-office/src/close.rs b/crates/ely-office/src/close.rs index 5ba7fa5..6067155 100644 --- a/crates/ely-office/src/close.rs +++ b/crates/ely-office/src/close.rs @@ -68,6 +68,12 @@ pub trait Closing: Host { }); } + /// A close or quit stopped: no update installs on it. + fn close_stopped(&mut self, cx: &mut Context) { + ely_ui::updates::quit_stopped(cx); + self.close_cancelled(cx); + } + fn no_document(&self, cx: &mut Context) { let text = text(cx, "command.blocked.no_document", &[]); self.toast(text, Severity::Warning, cx); diff --git a/crates/ely-office/src/dialogs.rs b/crates/ely-office/src/dialogs.rs index 592dd62..32638e6 100644 --- a/crates/ely-office/src/dialogs.rs +++ b/crates/ely-office/src/dialogs.rs @@ -87,7 +87,7 @@ pub trait Dialogs: Host { // Cancel stops the whole close. let cancelled = move |window: &mut Window, cx: &mut App| { owner.update(cx, |host, cx| { - host.close_cancelled(cx); + host.close_stopped(cx); host.dialog_done(window, cx) }) }; @@ -119,7 +119,7 @@ pub trait Dialogs: Host { let cancelled = move |window: &mut Window, cx: &mut App| { owner.update(cx, |host, cx| { if then.is_some() { - host.close_cancelled(cx); + host.close_stopped(cx); } host.dialog_done(window, cx) }) @@ -135,7 +135,7 @@ pub trait Dialogs: Host { move |host, _, cx| { // Reloading ends the close that waited. if then.is_some() { - host.close_cancelled(cx); + host.close_stopped(cx); } host.reload(tab, cx) }, diff --git a/crates/ely-office/src/save.rs b/crates/ely-office/src/save.rs index 80b694e..28b522f 100644 --- a/crates/ely-office/src/save.rs +++ b/crates/ely-office/src/save.rs @@ -190,7 +190,7 @@ pub trait Saving: Host { } // No save: a close that waited on it stops. if then.is_some() { - host.close_cancelled(cx); + host.close_stopped(cx); } }) }) @@ -419,7 +419,7 @@ pub trait Saving: Host { failed => { self.report(id, failed, cx); if then.is_some() { - self.close_cancelled(cx); + self.close_stopped(cx); } } } diff --git a/crates/ely-platform/src/install.rs b/crates/ely-platform/src/install.rs new file mode 100644 index 0000000..516643a --- /dev/null +++ b/crates/ely-platform/src/install.rs @@ -0,0 +1,160 @@ +//! Puts a downloaded release in place of the running app. + +use std::{ + io, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +/// The `.app` running this process; none in dev builds. +pub fn running_bundle() -> io::Result> { + let exe = std::env::current_exe()?; + Ok(bundle_of(&exe).map(Path::to_path_buf)) +} + +fn bundle_of(exe: &Path) -> Option<&Path> { + exe.ancestors().find(|dir| dir.extension().is_some_and(|ext| ext == "app")) +} + +/// Replaces `bundle` with the same-named app inside `dmg`. +#[cfg(target_os = "macos")] +pub fn install(dmg: &Path, bundle: &Path) -> io::Result<()> { + let name = bundle.file_name().ok_or_else(|| io::Error::other("the app has no name"))?; + let parent = bundle.parent().ok_or_else(|| io::Error::other("the app has no folder"))?; + let mount = tempfile::tempdir()?; + run(Command::new("hdiutil").args(["attach", "-nobrowse", "-readonly", "-noautoopen", "-mountpoint"]).arg(mount.path()).arg(dmg))?; + let copied = stage(&mount.path().join(name), parent, name); + let detached = run(Command::new("hdiutil").args(["detach", "-force"]).arg(mount.path())); + let staged = copied?; + detached?; + swap(&staged, bundle) +} + +#[cfg(not(target_os = "macos"))] +pub fn install(_dmg: &Path, _bundle: &Path) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::Unsupported, "installing updates is not built for this platform")) +} + +/// Copies the new app beside the old, hidden. +#[cfg(target_os = "macos")] +fn stage(from: &Path, parent: &Path, name: &std::ffi::OsStr) -> io::Result { + if !from.is_dir() { + return Err(io::Error::new(io::ErrorKind::NotFound, format!("the download holds no {}", name.to_string_lossy()))); + } + let staged = parent.join(format!(".{}.new", name.to_string_lossy())); + if staged.exists() { + std::fs::remove_dir_all(&staged)?; + } + run(Command::new("ditto").arg(from).arg(&staged))?; + Ok(staged) +} + +/// Puts `staged` at `bundle`; on failure the old returns. +fn swap(staged: &Path, bundle: &Path) -> io::Result<()> { + let name = bundle.file_name().ok_or_else(|| io::Error::other("the app has no name"))?; + let old = bundle.with_file_name(format!(".{}.old", name.to_string_lossy())); + if old.exists() { + std::fs::remove_dir_all(&old)?; + } + std::fs::rename(bundle, &old)?; + if let Err(error) = std::fs::rename(staged, bundle) { + std::fs::rename(&old, bundle)?; + return Err(error); + } + log::info!("install: {} replaced", bundle.display()); + // Installed: an old copy left behind is only untidy. + if let Err(error) = std::fs::remove_dir_all(&old) { + log::warn!("install: the old copy at {} stays: {error}", old.display()); + } + Ok(()) +} + +/// Returns once process `pid` has ended. +#[cfg(unix)] +pub fn wait_for_exit(pid: u32) { + while unsafe { libc::kill(pid as libc::pid_t, 0) } == 0 { + std::thread::sleep(std::time::Duration::from_millis(200)); + } +} + +#[cfg(not(unix))] +pub fn wait_for_exit(_pid: u32) {} + +/// Opens `bundle` as a fresh launch. +#[cfg(target_os = "macos")] +pub fn reopen(bundle: &Path) -> io::Result<()> { + run(Command::new("open").arg(bundle)) +} + +#[cfg(not(target_os = "macos"))] +pub fn reopen(_bundle: &Path) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::Unsupported, "reopening after an update is not built for this platform")) +} + +/// Runs `command`; a failure carries what it said. +#[cfg(target_os = "macos")] +fn run(command: &mut Command) -> io::Result<()> { + let out = command.stdin(Stdio::null()).output()?; + if out.status.success() { + return Ok(()); + } + let said = String::from_utf8_lossy(&out.stderr); + Err(io::Error::other(format!("{:?} failed ({}): {}", command.get_program(), out.status, said.trim()))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_bundle_is_the_nearest_app_folder() { + let exe = Path::new("/Applications/ElyDoc.app/Contents/MacOS/ely-doc"); + assert_eq!(bundle_of(exe), Some(Path::new("/Applications/ElyDoc.app"))); + assert_eq!(bundle_of(Path::new("/repo/target/debug/ely-doc")), None); + } + + #[test] + fn a_swap_replaces_the_app_and_leaves_no_copy() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (bundle, staged) = (dir.path().join("ElyDoc.app"), dir.path().join(".ElyDoc.app.new")); + std::fs::create_dir_all(bundle.join("old")).expect("the old app"); + std::fs::create_dir_all(staged.join("new")).expect("the new app"); + swap(&staged, &bundle).expect("swaps"); + assert!(bundle.join("new").exists() && !bundle.join("old").exists()); + assert_eq!(std::fs::read_dir(dir.path()).expect("lists").count(), 1, "nothing else is left"); + } + + #[cfg(target_os = "macos")] + #[test] + fn an_old_copy_that_will_not_go_leaves_the_install_done() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (bundle, staged) = (dir.path().join("ElyDoc.app"), dir.path().join(".ElyDoc.app.new")); + std::fs::create_dir_all(&bundle).expect("the old app"); + std::fs::write(bundle.join("version"), "old").expect("marks it"); + std::fs::create_dir_all(&staged).expect("the new app"); + std::fs::write(staged.join("version"), "new").expect("marks it"); + run(Command::new("chflags").arg("uchg").arg(bundle.join("version"))).expect("locks a file"); + swap(&staged, &bundle).expect("installed although the old copy stays"); + assert_eq!(std::fs::read_to_string(bundle.join("version")).expect("reads"), "new"); + let old = dir.path().join(".ElyDoc.app.old"); + run(Command::new("chflags").arg("nouchg").arg(old.join("version"))).expect("unlocks"); + } + + #[cfg(target_os = "macos")] + #[test] + fn a_dmg_installs_over_the_app() { + let dir = tempfile::tempdir().expect("a temp dir"); + let source = dir.path().join("src"); + std::fs::create_dir_all(source.join("ElyDoc.app/Contents")).expect("the new app"); + std::fs::write(source.join("ElyDoc.app/Contents/version"), "new").expect("marks it"); + let dmg = dir.path().join("ElyDoc.dmg"); + run(Command::new("hdiutil").args(["create", "-quiet", "-fs", "HFS+", "-srcfolder"]).arg(&source).arg(&dmg)).expect("a dmg"); + let bundle = dir.path().join("Applications/ElyDoc.app"); + std::fs::create_dir_all(bundle.join("Contents")).expect("the old app"); + std::fs::write(bundle.join("Contents/version"), "old").expect("marks it"); + install(&dmg, &bundle).expect("installs"); + assert_eq!(std::fs::read_to_string(bundle.join("Contents/version")).expect("reads"), "new"); + let empty = dir.path().join("Applications/Other.app"); + assert!(install(&dmg, &empty).is_err(), "a dmg without that app is refused"); + } +} diff --git a/crates/ely-platform/src/lib.rs b/crates/ely-platform/src/lib.rs index a19b5e6..0a2786a 100644 --- a/crates/ely-platform/src/lib.rs +++ b/crates/ely-platform/src/lib.rs @@ -7,12 +7,14 @@ mod dirs; pub mod env; pub mod git; pub mod graphics; +pub mod install; pub mod instance; pub mod locale; pub mod lock; pub mod logging; pub mod media; pub mod print; +pub mod release; pub mod settings; pub mod startup; pub mod storage; diff --git a/crates/ely-platform/src/release.rs b/crates/ely-platform/src/release.rs new file mode 100644 index 0000000..64ba165 --- /dev/null +++ b/crates/ely-platform/src/release.rs @@ -0,0 +1,46 @@ +//! The newest release's files, as the release server lists them. + +use anyhow::{Context, Result, bail}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Latest { + tag_name: String, + assets: Vec, +} + +#[derive(Deserialize)] +struct Asset { + name: String, + browser_download_url: String, +} + +/// Where the latest release at `api` keeps the file `name`. +pub fn asset_url(api: &str, name: &str) -> Result { + let text = crate::update::fetch(&crate::update::manifest_agent(), api)? + .body_mut() + .with_config() + .limit(1 << 20) + .read_to_string() + .context("cannot read the latest release")?; + asset_in(&text, name) +} + +fn asset_in(text: &str, name: &str) -> Result { + let latest: Latest = serde_json::from_str(text).context("the latest release is not valid")?; + match latest.assets.into_iter().find(|asset| asset.name == name) { + Some(asset) => Ok(asset.browser_download_url), + None => bail!("release {} has no {name}", latest.tag_name), + } +} + +#[cfg(test)] +mod tests { + #[test] + fn the_named_file_is_found_or_said_missing() { + let text = r#"{"tag_name":"v0.1.2","assets":[{"name":"ely-doc.json","browser_download_url":"https://h/d.json"}]}"#; + assert_eq!(super::asset_in(text, "ely-doc.json").unwrap(), "https://h/d.json"); + let missing = super::asset_in(text, "ely-tree.json").unwrap_err().to_string(); + assert_eq!(missing, "release v0.1.2 has no ely-tree.json"); + } +} diff --git a/crates/ely-platform/src/settings.rs b/crates/ely-platform/src/settings.rs index c5a07dd..989022f 100644 --- a/crates/ely-platform/src/settings.rs +++ b/crates/ely-platform/src/settings.rs @@ -10,7 +10,7 @@ use serde_json::{Map, Value}; use crate::{AppId, lock::FileLock}; mod shared; -pub use shared::{APPEARANCE, AUTHOR, Appearance, LANGUAGE, Language, MOTION, Motion, OMARCHY_SYNC, Shared, word}; +pub use shared::{APPEARANCE, AUTHOR, Appearance, CHECK_UPDATES, LANGUAGE, Language, MOTION, Motion, OMARCHY_SYNC, Shared, word}; /// The layout this build reads and writes. pub const VERSION: u64 = 1; diff --git a/crates/ely-platform/src/settings/shared.rs b/crates/ely-platform/src/settings/shared.rs index 4e9d27e..d5eb64b 100644 --- a/crates/ely-platform/src/settings/shared.rs +++ b/crates/ely-platform/src/settings/shared.rs @@ -38,6 +38,7 @@ pub const LANGUAGE: &str = "language"; pub const MOTION: &str = "motion"; pub const OMARCHY_SYNC: &str = "omarchy_sync"; pub const AUTHOR: &str = "author"; +pub const CHECK_UPDATES: &str = "check_updates"; /// The shared settings; absent fields take their defaults. #[derive(Clone, Debug, PartialEq, Eq)] @@ -48,6 +49,8 @@ pub struct Shared { pub omarchy_sync: bool, /// Signs comments and changes; empty means the account name. pub author: String, + /// Asks for a newer release at launch; off unless chosen. + pub check_updates: bool, } impl Default for Shared { @@ -58,6 +61,7 @@ impl Default for Shared { motion: Motion::System, omarchy_sync: true, author: String::new(), + check_updates: false, } } } @@ -92,6 +96,11 @@ impl Shared { appearance: pick(fields, APPEARANCE, &Appearance::ALL)?.unwrap_or(defaults.appearance), language: pick(fields, LANGUAGE, &Language::ALL)?.unwrap_or(defaults.language), motion: pick(fields, MOTION, &Motion::ALL)?.unwrap_or(defaults.motion), + check_updates: match fields.get(CHECK_UPDATES) { + None => defaults.check_updates, + Some(Value::Bool(on)) => *on, + Some(other) => return Err(wrong(CHECK_UPDATES, other)), + }, omarchy_sync: match fields.get(OMARCHY_SYNC) { None => defaults.omarchy_sync, Some(Value::Bool(on)) => *on, diff --git a/crates/ely-platform/src/settings/tests.rs b/crates/ely-platform/src/settings/tests.rs index 2de9350..b88d984 100644 --- a/crates/ely-platform/src/settings/tests.rs +++ b/crates/ely-platform/src/settings/tests.rs @@ -63,14 +63,15 @@ fn shared_fields_type_strictly_and_default_when_absent() { let fields = |text: &str| serde_json::from_str::>(text).unwrap(); assert_eq!(Shared::from_fields(&fields("{}")).unwrap(), Shared::default()); let read = Shared::from_fields(&fields( - r#"{"appearance":"dark","language":"zh-CN","motion":"reduce","omarchy_sync":false,"author":" Ana "}"#, + r#"{"appearance":"dark","language":"zh-CN","motion":"reduce","omarchy_sync":false,"author":" Ana ","check_updates":true}"#, )) .unwrap(); + assert!(read.check_updates && !Shared::default().check_updates, "asked for, never by default"); assert_eq!( (read.appearance, read.language, read.motion, read.omarchy_sync, read.author.as_str()), (Appearance::Dark, Language::Chinese, Motion::Reduced, false, "Ana") ); - for bad in [r#"{"appearance":"blue"}"#, r#"{"omarchy_sync":"yes"}"#, r#"{"author":3}"#] { + for bad in [r#"{"appearance":"blue"}"#, r#"{"omarchy_sync":"yes"}"#, r#"{"author":3}"#, r#"{"check_updates":1}"#] { assert!(Shared::from_fields(&fields(bad)).is_err(), "{bad}"); } } diff --git a/crates/ely-platform/src/update.rs b/crates/ely-platform/src/update.rs index 8e64a3d..548d71c 100644 --- a/crates/ely-platform/src/update.rs +++ b/crates/ely-platform/src/update.rs @@ -305,7 +305,7 @@ fn secure_host(url: &str) -> Result { } /// GETs `url`, following redirects only to places `secure_host` accepts. -fn fetch(agent: &Agent, url: &str) -> Result> { +pub(crate) fn fetch(agent: &Agent, url: &str) -> Result> { let mut url = url.to_string(); for _ in 0..=MAX_REDIRECTS { secure_host(&url)?; @@ -335,7 +335,7 @@ fn fetch(agent: &Agent, url: &str) -> Result> { bail!("too many redirects") } -fn manifest_agent() -> Agent { +pub(crate) fn manifest_agent() -> Agent { Agent::config_builder() .timeout_global(Some(MANIFEST_TIMEOUT)) .max_redirects(0) diff --git a/crates/ely-ui/Cargo.toml b/crates/ely-ui/Cargo.toml index 2fab264..dc15829 100644 --- a/crates/ely-ui/Cargo.toml +++ b/crates/ely-ui/Cargo.toml @@ -13,6 +13,7 @@ ely-platform.workspace = true gpui.workspace = true directories.workspace = true futures.workspace = true +semver.workspace = true gpui_platform.workspace = true log.workspace = true notify.workspace = true diff --git a/crates/ely-ui/src/commands.rs b/crates/ely-ui/src/commands.rs index a6d11f6..9dd5932 100644 --- a/crates/ely-ui/src/commands.rs +++ b/crates/ely-ui/src/commands.rs @@ -18,6 +18,7 @@ actions!( CloseTab, ToggleCommandPalette, OpenSettings, + CheckForUpdates, Quit ] ); @@ -43,7 +44,7 @@ pub fn bind_quit(cx: &mut App) { } /// Quits through the main window's route, if one is set. -fn quit(cx: &mut App) { +pub(crate) fn quit(cx: &mut App) { match cx.try_global::().map(|route| Rc::clone(&route.0)) { Some(route) => route(cx), None => cx.quit(), @@ -171,6 +172,10 @@ pub fn menus( first.push(MenuItem::separator()); } first.push(MenuItem::action(i18n::text(cx, "menu.app.settings", &[]), OpenSettings)); + // Only an installed app can update itself. + if crate::updates::updater(cx).is_some() { + first.push(MenuItem::action(i18n::text(cx, "menu.app.updates", &[]), CheckForUpdates)); + } first.push(MenuItem::separator()); first.push(MenuItem::action(i18n::text(cx, "menu.app.quit", &[("app", app.name())]), Quit)); let mut menus = vec![ diff --git a/crates/ely-ui/src/i18n.rs b/crates/ely-ui/src/i18n.rs index ec673cf..301dfac 100644 --- a/crates/ely-ui/src/i18n.rs +++ b/crates/ely-ui/src/i18n.rs @@ -24,6 +24,9 @@ use gpui::{App, SharedString}; const EN: &[(&str, &str)] = &[ ("menu.app.quit", "Quit {app}"), + ("updates.failed", "The update did not finish: {error}"), + ("updates.latest", "{app} {version} is the latest version."), + ("menu.app.updates", "Check for Updates…"), ("menu.app.switch", "Open Ely App"), ("toast.open", "Open"), ("toast.open_failed", "The file did not open: {error}"), @@ -76,6 +79,9 @@ const EN: &[(&str, &str)] = &[ const ZH_CN: &[(&str, &str)] = &[ ("menu.app.quit", "退出 {app}"), + ("updates.failed", "更新没有完成:{error}"), + ("updates.latest", "{app} {version} 已是最新版本。"), + ("menu.app.updates", "检查更新…"), ("menu.app.switch", "打开 Ely 应用"), ("toast.open", "打开"), ("toast.open_failed", "文件没能打开:{error}"), diff --git a/crates/ely-ui/src/i18n/settings.rs b/crates/ely-ui/src/i18n/settings.rs index 320cadd..0b030e4 100644 --- a/crates/ely-ui/src/i18n/settings.rs +++ b/crates/ely-ui/src/i18n/settings.rs @@ -9,6 +9,11 @@ pub(super) const EN: &[(&str, &str)] = &[ ("settings.appearance.light", "Light"), ("settings.appearance.dark", "Dark"), ("settings.omarchy", "Follow Omarchy"), + ("settings.updates", "Check for updates at launch"), + ( + "settings.updates.hint", + "Off, Ely asks its release server for a newer version only when you choose Check for Updates.", + ), ("settings.language", "Language"), ("settings.language.system", "System"), ("settings.language.next", "Applies the next time each app starts"), @@ -64,6 +69,11 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("settings.appearance.light", "浅色"), ("settings.appearance.dark", "深色"), ("settings.omarchy", "跟随 Omarchy"), + ("settings.updates", "启动时检查更新"), + ( + "settings.updates.hint", + "关闭时,只在你选择“检查更新”时才向发布服务器询问新版本。", + ), ("settings.language", "语言"), ("settings.language.system", "跟随系统"), ("settings.language.next", "各应用下次启动时生效"), diff --git a/crates/ely-ui/src/lib.rs b/crates/ely-ui/src/lib.rs index b6be746..e4cf0f6 100644 --- a/crates/ely-ui/src/lib.rs +++ b/crates/ely-ui/src/lib.rs @@ -6,6 +6,7 @@ pub mod settings; mod shell; pub mod tasks; mod theme; +pub mod updates; pub use opening::{launch_files, on_open}; pub use run::run; diff --git a/crates/ely-ui/src/run.rs b/crates/ely-ui/src/run.rs index 4144956..130d853 100644 --- a/crates/ely-ui/src/run.rs +++ b/crates/ely-ui/src/run.rs @@ -15,6 +15,10 @@ pub fn run( app: AppId, root: impl FnOnce(AppId, &mut Window, &mut App) -> Entity + 'static, ) { + // As the installer after an update, nothing else runs. + if let Some(code) = crate::updates::installer(app) { + std::process::exit(code); + } // Before the log: a second copy must not trim it. if let Err(error) = ely_platform::instance::hold(app) { startup::fail(app, &format!("{error}")); @@ -77,6 +81,8 @@ fn open( } theme::install(cx); crate::i18n::install(cx); + crate::updates::install(app, cx); + cx.on_action(|_: &crate::commands::CheckForUpdates, cx| crate::updates::check_now(cx)); crate::commands::bind_keys(cx); crate::commands::bind_quit(cx); cx.on_action(move |_: &crate::commands::OpenSettings, cx| crate::settings::open(app, cx)); diff --git a/crates/ely-ui/src/settings/window.rs b/crates/ely-ui/src/settings/window.rs index 65daeba..1983ceb 100644 --- a/crates/ely-ui/src/settings/window.rs +++ b/crates/ely-ui/src/settings/window.rs @@ -9,7 +9,7 @@ use ely_gpui_component::{ }; use ely_platform::{ AppId, - settings::{APPEARANCE, AUTHOR, Appearance, LANGUAGE, Language, MOTION, Motion, OMARCHY_SYNC, Store, word}, + settings::{APPEARANCE, AUTHOR, Appearance, CHECK_UPDATES, LANGUAGE, Language, MOTION, Motion, OMARCHY_SYNC, Store, word}, }; use gpui::{ AppContext as _, Context, Entity, FocusHandle, Global, IntoElement, ParentElement, Render, SharedString, Styled, @@ -222,7 +222,7 @@ impl SettingsView { Motion::Reduced => text(cx, "settings.motion.reduce", &[]), Motion::Full => text(cx, "settings.motion.full", &[]), }, MOTION, cx); - let mut section = SettingsSection::new(text(cx, "settings.general", &[])) + let section = SettingsSection::new(text(cx, "settings.general", &[])) .row(SettingsRow::new(text(cx, "settings.appearance", &[])).control(theme)) .row( SettingsRow::new(text(cx, "settings.language", &[])) @@ -230,6 +230,14 @@ impl SettingsView { .control(language), ) .row(SettingsRow::new(text(cx, "settings.motion", &[])).control(motion)); + let updates = Switch::new("settings-updates", shared.check_updates) + .name(text(cx, "settings.updates", &[])) + .on_change(|on, _, cx| set(CHECK_UPDATES, Value::Bool(on), cx)); + let mut section = section.row( + SettingsRow::new(text(cx, "settings.updates", &[])) + .description(text(cx, "settings.updates.hint", &[])) + .control(updates), + ); // Only where Omarchy is installed. if ely_platform::omarchy_current_dir().is_some() { let follow = Switch::new("settings-omarchy", shared.omarchy_sync) diff --git a/crates/ely-ui/src/shell/mod.rs b/crates/ely-ui/src/shell/mod.rs index ebe2d0d..5d05d28 100644 --- a/crates/ely-ui/src/shell/mod.rs +++ b/crates/ely-ui/src/shell/mod.rs @@ -124,6 +124,7 @@ impl RenderOnce for Shell { .flex() .flex_col() .child(TitleBar::new("titlebar").title(self.title)) + .children(crate::updates::updater(cx)) .children(menu_bar(cx)) .children(self.tabs); // Panels give way so the content keeps its room. diff --git a/crates/ely-ui/src/shell/root.rs b/crates/ely-ui/src/shell/root.rs index a344de8..0646ab0 100644 --- a/crates/ely-ui/src/shell/root.rs +++ b/crates/ely-ui/src/shell/root.rs @@ -102,6 +102,33 @@ fn build( if !document { window.focus(&focus, cx); } + if let Some(updates) = crate::updates::updater(cx) { + cx.subscribe( + &updates, + |root: &mut Root, _, news: &crate::updates::UpdateNews, cx| match news { + crate::updates::UpdateNews::Failed(error) => { + let text = crate::i18n::text(cx, "updates.failed", &[("error", error)]); + root.toast(text, Severity::Danger, cx); + } + crate::updates::UpdateNews::Latest => { + let args = [ + ("app", root.app.name()), + ("version", env!("CARGO_PKG_VERSION")), + ]; + let text = crate::i18n::text(cx, "updates.latest", &args); + root.toast(text, Severity::Success, cx); + } + }, + ) + .detach(); + // A failed install from the last quit says why. + if let Some(note) = updates.update(cx, |updates, _| updates.note.take()) { + let text = crate::i18n::text(cx, "updates.failed", &[("error", ¬e)]); + cx.defer_in(window, move |root: &mut Root, _, cx| { + root.toast(text, Severity::Danger, cx) + }); + } + } Root { app, focus, diff --git a/crates/ely-ui/src/updates.rs b/crates/ely-ui/src/updates.rs new file mode 100644 index 0000000..b3d558c --- /dev/null +++ b/crates/ely-ui/src/updates.rs @@ -0,0 +1,428 @@ +//! A newer release: offered, downloaded, installed after quitting. + +use std::{ + io, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::atomic::AtomicBool, +}; + +use ely_gpui_component::shell::{UpdateBanner, UpdateState}; +use ely_platform::{ + AppId, + update::{self, Check, Release}, +}; +use futures::{StreamExt as _, channel::mpsc}; +use gpui::{ + App, AppContext as _, Context, Entity, EventEmitter, Global, IntoElement, Render, Window, div, +}; + +/// The release server's newest release, listed by its API. +const LATEST: &str = "https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/ElyOffice/releases/latest"; +/// The argument that runs this binary as the installer. +const INSTALL: &str = "--ely-install-update"; + +#[derive(Clone, Debug, PartialEq)] +enum Phase { + Offered, + Fetching(f32), + Fetched(PathBuf), +} + +pub struct Updates { + app: AppId, + bundle: PathBuf, + offer: Option<(Release, Phase)>, + dismissed: bool, + /// The download to install once the app has quit. + pending: Option, + /// Why the last install failed, for the window to say. + pub note: Option, +} + +/// What an update says to the window. +pub enum UpdateNews { + Failed(String), + /// A check the user asked for found nothing newer. + Latest, +} + +impl EventEmitter for Updates {} + +struct Updater(Entity); + +impl Global for Updater {} + +/// The app's updater, if it runs from an installed bundle. +pub fn updater(cx: &App) -> Option> { + cx.try_global::().map(|updater| updater.0.clone()) +} + +/// Checks now, as the user asked from the menu. +pub fn check_now(cx: &mut App) { + if let Some(updates) = updater(cx) { + updates.update(cx, |updates, cx| updates.check(true, cx)); + } +} + +/// The quit an update asked for stopped: install nothing. +pub fn quit_stopped(cx: &mut App) { + if let Some(updates) = updater(cx) { + updates.update(cx, |updates, _| { + if updates.pending.take().is_some() { + log::info!("update: the quit stopped; nothing installs"); + } + }); + } +} + +/// Run as the installer: waits, installs, reopens; the exit code. +pub fn installer(app: AppId) -> Option { + let args: Vec = std::env::args().collect(); + let [_, flag, dmg, bundle, pid] = args.as_slice() else { + return None; + }; + if flag != INSTALL { + return None; + } + let Ok(pid) = pid.parse() else { + eprintln!("update: {pid} is not a process number"); + return Some(2); + }; + ely_platform::install::wait_for_exit(pid); + let installed = ely_platform::install::install(Path::new(dmg), Path::new(bundle)); + if let Err(error) = &installed { + // To the app's log; the reopened app shows it. + eprintln!("update: the install failed: {error}"); + let note = ely_platform::data_dir(app) + .map_err(io::Error::other) + .and_then(|dir| { + std::fs::create_dir_all(dir.join("updates"))?; + std::fs::write(dir.join("updates/failed.txt"), error.to_string()) + }); + if let Err(lost) = note { + eprintln!("update: the failure note was not written: {lost}"); + } + } + // Last: the note is written before the app reads it. + if let Err(error) = ely_platform::install::reopen(Path::new(bundle)) { + eprintln!("update: the app did not reopen: {error}"); + return Some(1); + } + Some(i32::from(installed.is_err())) +} + +/// An installed app's updater; checks at launch if chosen. +pub(crate) fn install(app: AppId, cx: &mut App) { + let bundle = match ely_platform::install::running_bundle() { + Ok(Some(bundle)) => bundle, + Ok(None) => return log::info!("update: a dev build; no check"), + Err(error) => return log::warn!("update: the running app is not found: {error}"), + }; + match update::install_policy() { + Ok(update::InstallPolicy::SelfManaged) => {} + Ok(update::InstallPolicy::PackageManager) => { + return log::info!("update: the package manager updates this app"); + } + Err(error) => return log::warn!("update: {error:#}"), + } + let note = ely_platform::data_dir(app) + .ok() + .map(|dir| dir.join("updates/failed.txt")); + let note = note.and_then(|path| { + std::fs::read_to_string(&path) + .ok() + .inspect(|_| drop(std::fs::remove_file(&path))) + }); + let updates = cx.new(|cx| { + cx.on_app_quit(|this: &mut Updates, _| { + this.start_installer(); + async {} + }) + .detach(); + Updates { + app, + bundle, + offer: None, + dismissed: false, + pending: None, + note, + } + }); + cx.set_global(Updater(updates.clone())); + // Off by default: no request unless the user chose it. + if crate::settings::current(cx).check_updates { + updates.update(cx, |updates, cx| updates.check(false, cx)); + } else { + log::info!("update: no check at launch; it is off in settings"); + } +} + +/// The app's manifest in the newest release, or the test's. +fn manifest(app: AppId) -> anyhow::Result { + match std::env::var("ELY_UPDATE_MANIFEST") { + Ok(url) => Ok(url), + Err(_) => ely_platform::release::asset_url(LATEST, &format!("{}.json", app.slug())), + } +} + +impl Updates { + /// Asks for a newer release; `asked` reports every outcome. + fn check(&mut self, asked: bool, cx: &mut Context) { + // A download under way or done stays the offer. + if matches!( + self.offer, + Some((_, Phase::Fetching(_) | Phase::Fetched(_))) + ) { + log::info!("update: a release is already on its way"); + if asked { + self.dismissed = false; + cx.notify(); + } + return; + } + let app = self.app; + let current = + semver::Version::parse(env!("CARGO_PKG_VERSION")).expect("the build's version"); + // The lookup is a request too: off the window's thread. + let checked = cx.background_spawn(async move { manifest(app).and_then(|url| update::check(¤t, &url)) }); + cx.spawn(async move |this, cx| { + let checked = checked.await; + this.update(cx, |this, cx| match checked { + Ok(Check::Available(_)) + if matches!( + this.offer, + Some((_, Phase::Fetching(_) | Phase::Fetched(_))) + ) => {} + Ok(Check::Available(release)) => { + (this.offer, this.dismissed) = (Some((release, Phase::Offered)), false); + cx.notify(); + } + Ok(Check::Current) if asked => cx.emit(UpdateNews::Latest), + // At launch a failure only logs; offline is normal. + Err(error) if asked => cx.emit(UpdateNews::Failed(format!("{error:#}"))), + Err(error) => log::warn!("update: the launch check failed: {error:#}"), + Ok(Check::Current) => log::info!("update: nothing newer at launch"), + }) + .ok(); + }) + .detach(); + } + + fn later(&mut self, cx: &mut Context) { + self.dismissed = true; + cx.notify(); + } + + fn download(&mut self, cx: &mut Context) { + let Some((release, phase)) = self + .offer + .as_mut() + .filter(|(_, phase)| *phase == Phase::Offered) + else { + return; + }; + *phase = Phase::Fetching(0.0); + cx.notify(); + let (release, app) = (release.clone(), self.app); + let (sender, mut progress) = mpsc::unbounded(); + let fetching = release.clone(); + let fetched = cx.background_spawn(async move { + let dir = ely_platform::data_dir(app) + .map_err(|error| format!("{error:#}"))? + .join("updates"); + if dir.exists() { + std::fs::remove_dir_all(&dir).map_err(|error| error.to_string())?; + } + std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; + let cancel = AtomicBool::new(false); + let mut tell = |done: u64, total: Option| { + let part = total + .filter(|total| *total > 0) + .map_or(0.0, |total| done as f32 / total as f32); + sender.unbounded_send(part).ok(); + }; + let file = update::download(&fetching, &dir, &cancel, update::IDLE_TIMEOUT, &mut tell) + .map_err(|error| format!("{error:#}"))? + .ok_or("the download stopped")?; + update::verify_file(&file, &fetching.sha256).map_err(|error| format!("{error:#}"))?; + Ok::(file) + }); + cx.spawn(async move |this, cx| { + while let Some(part) = progress.next().await { + this.update(cx, |this, cx| this.fetching(part, cx)).ok(); + } + let fetched = fetched.await; + this.update(cx, |this, cx| this.fetched(&release, fetched, cx)) + .ok(); + }) + .detach(); + } + + fn fetching(&mut self, part: f32, cx: &mut Context) { + if let Some((_, phase @ Phase::Fetching(_))) = self.offer.as_mut() { + *phase = Phase::Fetching(part); + cx.notify(); + } + } + + /// A finished download counts only for the release it fetched. + fn fetched( + &mut self, + release: &Release, + fetched: Result, + cx: &mut Context, + ) { + let Some((offered, phase)) = self + .offer + .as_mut() + .filter(|(offered, _)| offered == release) + else { + return log::warn!("update: a download for another release was dropped"); + }; + match fetched { + Ok(file) => *phase = Phase::Fetched(file), + Err(error) => { + *phase = Phase::Offered; + cx.emit(UpdateNews::Failed(error)); + } + } + log::info!("update: {} is {:?}", offered.version, phase); + cx.notify(); + } + + /// Quits as usual: unsaved work is asked about first. + fn restart(&mut self, cx: &mut Context) { + let Some((_, Phase::Fetched(file))) = self.offer.as_ref() else { + return; + }; + self.pending = Some(file.clone()); + crate::commands::quit(cx); + } + + /// At the real quit: the installer waits for this process. + fn start_installer(&mut self) { + let Some(file) = self.pending.take() else { + return; + }; + let started = std::env::current_exe().and_then(|exe| { + let mut command = Command::new(exe); + command + .arg(INSTALL) + .arg(&file) + .arg(&self.bundle) + .arg(std::process::id().to_string()); + // Its words go to the app's log. + let log = ely_platform::logging::path() + .map(|path| std::fs::OpenOptions::new().append(true).open(path)); + let said = match log { + Some(file) => Stdio::from(file?), + None => Stdio::null(), + }; + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(said); + #[cfg(unix)] + std::os::unix::process::CommandExt::process_group(&mut command, 0); + command.spawn() + }); + match started { + Ok(_) => log::info!("update: installs after this quit"), + Err(error) => log::error!("update: the installer did not start: {error}"), + } + } +} + +impl Render for Updates { + /// The bar under the title while a release is offered. + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let this = cx.entity(); + let Some((release, phase)) = self.offer.as_ref().filter(|_| !self.dismissed) else { + return div().into_any_element(); + }; + let state = match phase { + Phase::Offered => UpdateState::Available, + Phase::Fetching(done) => UpdateState::Downloading(*done), + Phase::Fetched(_) => UpdateState::Ready, + }; + let (download, restart, later) = (this.clone(), this.clone(), this); + let banner = UpdateBanner::new("update-banner", release.version.to_string(), state) + .on_download(move |_, cx| download.update(cx, |this, cx| this.download(cx))) + .on_later(move |_, cx| later.update(cx, |this, cx| this.later(cx))); + match phase { + Phase::Fetched(_) => banner + .on_restart(move |_, cx| restart.update(cx, |this, cx| this.restart(cx))) + .into_any_element(), + _ => banner.into_any_element(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn release(version: &str, url: &str) -> Release { + Release { + version: semver::Version::parse(version).expect("a version"), + url: url.into(), + sha256: String::new(), + } + } + + #[gpui::test] + fn a_check_brings_back_a_dismissed_download(cx: &mut gpui::TestAppContext) { + let updates = cx.new(|_| Updates { + app: AppId::Tree, + bundle: PathBuf::from("/Applications/ElyTree.app"), + offer: Some(( + release("9.0.0", "https://b"), + Phase::Fetched(PathBuf::from("B.dmg")), + )), + dismissed: true, + pending: None, + note: None, + }); + updates.update(cx, |updates, cx| updates.check(true, cx)); + updates.read_with(cx, |updates, _| { + assert!(!updates.dismissed, "the bar shows again"); + assert_eq!( + updates.offer.as_ref().map(|(_, phase)| phase.clone()), + Some(Phase::Fetched(PathBuf::from("B.dmg"))) + ); + }); + } + + #[gpui::test] + fn a_download_for_an_older_offer_is_dropped(cx: &mut gpui::TestAppContext) { + let updates = cx.new(|_| Updates { + app: AppId::Tree, + bundle: PathBuf::from("/Applications/ElyTree.app"), + offer: Some((release("9.0.0", "https://b"), Phase::Fetching(0.5))), + dismissed: false, + pending: None, + note: None, + }); + updates.update(cx, |updates, cx| { + updates.fetched( + &release("8.0.0", "https://a"), + Ok(PathBuf::from("A.dmg")), + cx, + ); + assert_eq!( + updates.offer.as_ref().map(|(_, phase)| phase.clone()), + Some(Phase::Fetching(0.5)) + ); + updates.fetched( + &release("9.0.0", "https://b"), + Ok(PathBuf::from("B.dmg")), + cx, + ); + assert_eq!( + updates.offer.as_ref().map(|(_, phase)| phase.clone()), + Some(Phase::Fetched(PathBuf::from("B.dmg"))) + ); + }); + } +} diff --git a/packaging/macos/release.sh b/packaging/macos/release.sh index a5b1220..725eacf 100755 --- a/packaging/macos/release.sh +++ b/packaging/macos/release.sh @@ -7,6 +7,7 @@ batch=${1:-1} dist=${ELY_DIST:-$root/dist} version=$(awk -F'"' '/^version = / { print $2; exit }' "$root/Cargo.toml") arch=$(uname -m) +releases=https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice/releases [ -z "${ELY_NOTARY_PROFILE:-}" ] || [ -n "${ELY_SIGN_IDENTITY:-}" ] || { echo "release.sh: notarizing needs ELY_SIGN_IDENTITY, a Developer ID" >&2 exit 1 @@ -28,7 +29,12 @@ awk -F'\t' -v batch="$batch" '$4 == batch { print $1 "\t" $2 }' "$root/packaging codesign --verify --deep --strict "$dist/$name.app" dmg=$dist/$name-$version-$arch.dmg rm -f "$dmg" - hdiutil create -quiet -volname "$name" -srcfolder "$dist/$name.app" -format UDZO "$dmg" + # The app beside Applications: drag one onto the other. + stage=$(mktemp -d) + ditto "$dist/$name.app" "$stage/$name.app" + ln -s /Applications "$stage/Applications" + hdiutil create -quiet -volname "$name" -srcfolder "$stage" -format UDZO "$dmg" + rm -rf "$stage" if [ -n "${ELY_NOTARY_PROFILE:-}" ]; then codesign --force --timestamp --sign "$ELY_SIGN_IDENTITY" "$dmg" xcrun notarytool submit "$dmg" --keychain-profile "$ELY_NOTARY_PROFILE" --wait @@ -37,5 +43,11 @@ awk -F'\t' -v batch="$batch" '$4 == batch { print $1 "\t" $2 }' "$root/packaging echo "release.sh: $dmg is not notarized (no ELY_NOTARY_PROFILE)" >&2 fi echo "$dmg" + # What the app's update check reads from the latest release. + sha=$(shasum -a 256 "$dmg" | cut -d' ' -f1) + url="$releases/download/v$version/$(basename "$dmg")" + printf '{"version": "%s", "assets": {"macos-%s": {"url": "%s", "sha256": "%s"}}}\n' \ + "$version" "${arch/arm64/aarch64}" "$url" "$sha" >"$dist/$slug.json" + echo "$dist/$slug.json" done rm -rf "$root/target/release/incremental" diff --git a/tasks/BUGS.md b/tasks/BUGS.md index 77a5484..a83dfdf 100644 --- a/tasks/BUGS.md +++ b/tasks/BUGS.md @@ -6,7 +6,7 @@ 次序:S-03、P-01、X-01、D-01、D-02、P-02、P-03、P-04、M-01、X-02、X-03、K-01(Linux、Windows,本机不能运行)、UPD-1、REL-1、REL-2。K-01 不进 0.1.2:只影响 Linux 与 Windows 的 worker,本机不能运行验证,0.1.2 只发 macOS;留在下面的清单里,未勾。T-05 已改一半(审查查出缩放后尺寸不重测),暂放,补丁存在草稿区。 -- [ ] UPD-1 自动更新:应用内检查新版本、下载、校验,经用户同意后替换并重启;发布时生成更新清单,不再要点 GitHub 链接 +- [x] UPD-1 自动更新:应用内检查新版本、下载、校验,经用户同意后替换并重启;发布时生成更新清单,不再要点 GitHub 链接 — 启动检查默认关、菜单“检查更新…”、退出后再装、失败记录先于重开、经 Gitea API 找清单;R1–R4 授权、提交点、保存询问、版本绑定、横幅位置、取消退出、记录先后;R5 两处(启动检查失败记日志、以替身 open 断言先后)已修并以变异验证,五轮上限,未再审 - [x] T-01 内核的图块与命中按像素网格,Ely 的光标、选区、表头按文档 twips,越远越错位 — R2.16 已修(图块 twips 与打印 twips 互换) - [x] T-02 单元格光标露出边缘即把视图滚到中央,点击与拖选时视图跳动 — R1 拖选中仍滚、超宽格跳回起点;R2 PASS @@ -104,5 +104,5 @@ ## 0.1.2 发布 -- [ ] REL-1 dmg 里有指向“应用程序”的快捷方式,可直接拖入安装(用户 2026-10-09 报) +- [x] REL-1 dmg 里有指向“应用程序”的快捷方式,可直接拖入安装(用户 2026-10-09 报) — release.sh 在 dmg 里放“应用程序”替身,随 UPD-1 审查 - [ ] REL-2 修完上列各条后打包、发布 0.1.2,官网指向新发布页 diff --git a/tasks/reviews/UPD-1-r1.md b/tasks/reviews/UPD-1-r1.md new file mode 100644 index 0000000..867f764 --- /dev/null +++ b/tasks/reviews/UPD-1-r1.md @@ -0,0 +1,9 @@ +FAIL + +crates/ely-ui/src/updates.rs:59 — [P1] 启动检查绕过更新授权与默认设置,违反 PRD.md:113 的“自动检查更新默认关闭”及 QA-25。首次从安装好的 .app 启动、使用空白用户目录时,程序直接请求更新清单;本地端到端测试在任何用户操作之前已收到请求并记录 `offers 9.9.9`。当前设置模型也没有控制该请求的字段。修复方向:加入默认关闭的自动检查设置,启动检查以用户授权和离线设置为条件,并覆盖空白配置下零请求的测试。 + +crates/ely-ui/src/updates.rs:67 — [P2] 更新检查仅由启动路径调用一次,缺少 PRD.md:113 要求的手动检查入口。具体场景:启动时断网,唯一一次检查失败后,恢复网络并继续使用应用,用户无法重新检查、获得更新横幅或下载更新;`check` 为私有方法,调用点只有第 59 行,菜单与设置均未接入检查动作。修复方向:提供可重复执行的手动检查动作,接入应用界面,并测试首次失败后联网重查。 + +crates/ely-platform/src/install.rs:65 — [P1] 新包替换成功后,删除旧包的错误被当作整个安装失败返回,更新状态与磁盘事实分离,失败路径也未恢复原版本,违反 PRD §4.2、QA-26。实测在旧包 `Contents/version` 上设置 `chflags uchg` 后调用真实 dmg 安装,结果为 `install=Err(PermissionDenied: Operation not permitted)`、`live=Ok("new")`、`backup=Ok("old")`。调用方因此跳过 `reopen_after_exit` 和正常退出,显示更新失败,原路径却已换成新版;重试还会卡在删除同一旧包。修复方向:明确安装提交点,将提交后的备份清理错误与安装结果分开处理,保证提交前失败恢复旧包、提交后结果与实际版本一致,并加入清理失败的回归用例。 + +tests/scripts/update.sh:54 — [P2] 重启断言只按命令行中的 .app 路径执行 `pgrep -f`,重启辅助进程也满足条件,测试会把启动失败判为成功。实测仅在临时脚本中通过 PATH 将 `open` 换成 `sleep 30; exit 1`,完整测试仍输出 `ok: ElyTree downloads, installs and reopens its update`;通过断言的唯一进程是 `/bin/sh .../bin/open .../Applications/ElyTree.app`,新版 ElyTree 实际未启动。修复方向:断言新版应用自身的可执行文件身份、不同于旧进程的 PID,以及窗口或启动完成信号;让该 `open` 失败变异稳定触发测试失败。 diff --git a/tasks/reviews/UPD-1-r2.md b/tasks/reviews/UPD-1-r2.md new file mode 100644 index 0000000..bd8a916 --- /dev/null +++ b/tasks/reviews/UPD-1-r2.md @@ -0,0 +1,7 @@ +FAIL + +crates/ely-ui/src/updates.rs:195 — [P1] 安装发生在保存与退出确认之前,违反 PRD.md:113 的“更新前要求保存编辑内容”。实测在 ElyDoc 的 Untitled 1 输入文字,下载更新后按 Restart to update:先出现 `install: .../ElyDoc.app replaced`,随后才出现 `Save changes to Untitled 1?`;此时新包标记已在原路径,旧进程仍持有未保存内容。按 Esc 取消后,截图确认文档继续编辑,新包仍在原路径,更新状态停在 Installing,重启入口消失。证据:`/tmp/upd1-r2-save-only.log`、`/tmp/upd1-r2-evidence-3-ely-doc.log`。修复方向:将保存与退出确认作为替换包和注册重启的前置条件;取消时保留原包与可重试的下载完成状态,并覆盖未保存文档取消更新的回归测试。 + +crates/ely-ui/src/updates.rs:92 — [P1] 手动重查覆盖正在下载的 release,下载完成回调又直接修改当前 offer 的 phase(第 170–175 行),导致清单版本与待安装文件错配。真实 ElyTree 探针先查询 8.0.0 并开始下载 A.dmg,在 HTTP 服务暂停该下载期间手动重查得到 9.0.0/B.dmg,再放行 A:日志依次记录 `offers 9.0.0`、`update download: 8.0.0 saved .../A.dmg`;随后按 Restart to update,安装后的包标记为 A。用户对新 offer 的重启操作因此安装了此前请求的包。证据:`/tmp/upd1-r2-native.log`、`/tmp/upd1-r2-evidence-0-ely-tree.log`。修复方向:将检查、下载进度、完成结果和安装绑定同一 release 与操作身份,阻止重查覆盖进行中的更新,丢弃过期回调,并覆盖慢下载期间重查的回归测试。 + +crates/ely-ui/src/shell/root.rs:250 — [P2] 更新横幅被插在包含 TitleBar 的整个应用外壳之前,落入 macOS 原生窗口按钮所在区域,同时失去外壳提供的主题前景色。实测深色 ElyTree 手动检查得到 9.0.0 时,原生红黄绿按钮遮住更新文案开头;横幅其余文字为 RGB(0,0,0),背景为 RGB(48,48,48),截图实测对比度约 1.59:1,低于 PRD.md:750 的 4.5:1。ElyDoc 的就绪横幅也呈现同样遮挡。证据:`/tmp/upd1-r2-banner.log`。修复方向:将横幅放在主题化外壳的标题栏下方,使用主题前景色并保留原生窗口按钮区域;验证深浅主题下文案可读且无重叠。 diff --git a/tasks/reviews/UPD-1-r3.md b/tasks/reviews/UPD-1-r3.md new file mode 100644 index 0000000..1155101 --- /dev/null +++ b/tasks/reviews/UPD-1-r3.md @@ -0,0 +1,7 @@ +FAIL + +crates/ely-ui/src/updates.rs:224 — [P1] 取消更新退出后,`pending` 仍保留,下一次普通退出继续安装并重开应用,违反“取消即什么也不装”的修复契约。真实 ElyMark 探针输入未保存文字,下载完成后点击 Restart to update,再点击 Cancel;随后仅从应用菜单选择 Quit ElyMark,并对未保存内容选择 Don't Save,更新标记仍被装入原包,新的应用进程再次启动。日志记录普通退出后的 `update: installs after this quit`;当前回归测试取消后再次点击 Restart,遗漏了这条路径。证据:`/tmp/upd1-r3-cancel.log`、`/tmp/upd1-r3-cancel-app.log`。修复方向:将待安装状态绑定这次更新退出请求,在取消或退出流程中止时清除;保留已下载文件供再次明确点击 Restart,并覆盖取消后普通退出的测试。 + +crates/ely-ui/src/updates.rs:131 — [P2] 下载中或已下载时手动检查直接返回,使已关闭的更新横幅无法恢复,下载完成后的安装入口随之丢失。真实 ElyTree 探针下载 8.0.0 完成,关闭横幅的 x,再选择 Check for Updates…;检查函数只记录 `a release is already on its way`,`dismissed` 持续为 true,AX 树中 Restart to update 消失,用户在本次运行中无法重新进入安装操作。下载期间关闭横幅同样进入此状态。证据:`/tmp/upd1-r3-dismiss.log`、`/tmp/upd1-r3-tree-dark.log`。修复方向:用户手动检查时重新显示现有下载状态,同时保持该 release 和下载文件的绑定;覆盖下载中及下载完成后关闭横幅再手动检查的回归测试。 + +crates/ely-ui/src/updates.rs:84 — [P2] 安装失败记录的写入错误被 `.ok()` 丢弃,而安装子进程的 stderr 在第 236 行被重定向到空设备,导致失败静默。以下载目录被清理的状态运行真实安装入口:dmg 与 `updates` 目录均已消失,挂载失败后原应用正常重开,写入 `updates/failed.txt` 因父目录缺失失败;重开窗口没有失败提示,记录文件也不存在。保留记录目录的对照探针能够显示同一安装错误。证据:`/tmp/upd1-r3-missing-parent.log`、`/tmp/upd1-r3-missing-parent-reopened.log`、`/tmp/upd1-r3-failure.log`。修复方向:确保故障记录目录存在,处理记录写入错误,并保证持久化失败时重开的应用仍能展示安装失败;覆盖下载目录被清理的安装失败测试。 diff --git a/tasks/reviews/UPD-1-r4.md b/tasks/reviews/UPD-1-r4.md new file mode 100644 index 0000000..ada7ca8 --- /dev/null +++ b/tasks/reviews/UPD-1-r4.md @@ -0,0 +1,3 @@ +FAIL + +crates/ely-ui/src/updates.rs:95 — [P2] 安装失败记录晚于应用重开,失败提示存在竞态。`ely_platform::install::finish` 在 `crates/ely-platform/src/install.rs:80` 先执行 `open`,返回后此处才写 `updates/failed.txt`;重开的应用只在启动时读取一次该文件(第 119–120 行)。真实 ElyTree 探针令 dmg 缺失,经透明包装调用真实 `/usr/bin/open` 并保持其参数和退出码,在启动请求之后暂停安装父进程;等重开窗口完成启动,再恢复安装进程。连续两次均出现:窗口启动时记录尚未写入,随后安装进程写入记录并退出,当前窗口始终没有失败提示,记录留待下次启动才读取。普通调度对照能生成失败提示,说明此次补建目录仍未保证提示交付。证据:`/tmp/upd1-r4-race-1.log`、`/tmp/upd1-r4-race-2.log`、`/tmp/upd1-r4-race-app.log`;复现步骤:`/tmp/upd1-r4-repro.log`。修复方向:失败记录完成写入及错误处理后再重开应用,并增加覆盖该先后顺序、断言本次重开即显示失败提示的回归测试。 diff --git a/tasks/reviews/UPD-1-r5.md b/tasks/reviews/UPD-1-r5.md new file mode 100644 index 0000000..0149bbc --- /dev/null +++ b/tasks/reviews/UPD-1-r5.md @@ -0,0 +1,5 @@ +FAIL + +crates/ely-ui/src/updates.rs:203 — [P2] 启动自动检查会静默丢弃新增 Gitea 查询路径的错误。`manifest(app)` 在第 188 行先执行,查询失败便跳过负责记录结果的 `update::check`;`asked == false` 时错误进入第 204 行的空分支。实测将临时 HOME 的 `check_updates` 设为 true,启动本轮构建并打包的 ElyTree:Gitea 请求完成并返回 HTTP 200,当前发布缺少 `ely-tree.json`,直接调用生产 `release::asset_url` 得到 `release v0.1.1 has no ely-tree.json`,启动日志却完全没有检查失败记录。API 断网、HTTP 错误和无效响应同样进入此分支,违反出错暴露和关键路径日志规则。证据:`/tmp/upd1-r5-gitea-debug.log`、`/tmp/upd1-r5-gitea-error.log`、`/tmp/upd1-r5-repro.log`。修复方向:对整个 Gitea 查询与清单检查的失败结果记录错误,并覆盖启动查询失败的日志断言。 + +tests/scripts/update.sh:115 — [P2] 新增顺序回归断言将记录修改时间与进程启动时间都截到整秒,同一秒内先重开、后写记录仍会通过。第 4 轮的生产代码顺序已修正,原版真实 ElyMark 更新脚本通过;随后在临时测试脚本的损坏发布阶段,通过 hdiutil 包装器执行真实 detach,再提前调用真实 `/usr/bin/open`,确认真实应用进程已出现后才让安装器继续写记录,保留全部原有断言。变异日志在 `1791577204130723000` 纳秒确认应用已重开且记录尚不存在,记录修改时间为 `1791577204133946137` 纳秒,完整脚本仍退出 0 并输出成功。独立真实进程探针连续三次也接受了启动后约 250 毫秒才写记录的顺序。证据:`/tmp/upd1-r5-mutation-order.log`、`/tmp/upd1-r5-mutation-test.log`、`/tmp/upd1-r5-time-assertion.log`;复现方法:`/tmp/upd1-r5-repro.log`。修复方向:通过重开入口的事件握手断言启动请求前记录已写好,并验证本次重开窗口显示失败提示,使提前重开的变异稳定失败。 diff --git a/tests/scripts/update.sh b/tests/scripts/update.sh new file mode 100755 index 0000000..3de4f71 --- /dev/null +++ b/tests/scripts/update.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# An installed ElyMark updates from a served release, asking first. +set -euo pipefail + +root=$(cd "$(dirname "$0")/../.." && pwd) +[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; } +work=$(mktemp -d) +cleanup() { + pkill -f "$work/Applications/ElyMark.app" 2>/dev/null || true + if [ -n "${server:-}" ]; then + kill "$server" 2>/dev/null || true + wait "$server" 2>/dev/null || true + fi + rm -rf "$work" +} +trap cleanup EXIT +fail() { echo "FAIL: $*" >&2; exit 1; } + +ELY_DIST="$work/dist" "$root/packaging/macos/bundle.sh" ely-mark debug >"$work/bundle" 2>&1 || fail "bundle: $(tail -5 "$work/bundle")" +swiftc -O -o "$work/ax-press" "$root/tests/scripts/ax-press.swift" +mkdir -p "$work/Applications" "$work/new" "$work/serve" +ditto "$work/dist/ElyMark.app" "$work/Applications/ElyMark.app" +# The new release: the same app, marked. +ditto "$work/dist/ElyMark.app" "$work/new/ElyMark.app" +touch "$work/new/ElyMark.app/Contents/Resources/ely-update-marker" +codesign --force --deep --sign - "$work/new/ElyMark.app" 2>/dev/null +ln -s /Applications "$work/new/Applications" +hdiutil create -quiet -volname ElyMark -srcfolder "$work/new" -format UDZO "$work/serve/ElyMark.dmg" +port=$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])') +sha=$(shasum -a 256 "$work/serve/ElyMark.dmg" | cut -d' ' -f1) +key="macos-$(uname -m | sed s/arm64/aarch64/)" +printf '{"version": "9.9.9", "assets": {"%s": {"url": "http://127.0.0.1:%s/ElyMark.dmg", "sha256": "%s"}}}\n' "$key" "$port" "$sha" >"$work/serve/ely-mark.json" +python3 -m http.server "$port" --bind 127.0.0.1 --directory "$work/serve" >"$work/served" 2>&1 & +server=$! +mkdir -p "$work/home" +# The installer's open: says whether the failure note came first. +mkdir -p "$work/bin" +printf '#!/bin/sh\n[ -e "%s" ] && echo before >>"%s" || echo after >>"%s"\nexec /usr/bin/open "$@"\n' \ + "$work/home/Library/Application Support/ElyOffice/ely-mark/updates/failed.txt" "$work/opened" "$work/opened" >"$work/bin/open" +chmod +x "$work/bin/open" +start() { + PATH="$work/bin:$PATH" HOME="$work/home" ELY_LOG=info ELY_LOCALE=en ELY_NO_DIALOG=1 ELY_UPDATE_MANIFEST="http://127.0.0.1:$port/ely-mark.json" \ + "$work/Applications/ElyMark.app/Contents/MacOS/ely-mark" >"$work/out" 2>&1 & + pid=$! +} +start +[ "$("$work/ax-press" "$pid" none)" != untrusted ] || fail "needs Accessibility permission" +# Off by default: nothing is asked at launch. +for _ in $(seq 60); do grep -q "no check at launch" "$work/out" && break; sleep 0.5; done +grep -q "no check at launch" "$work/out" || fail "the launch check ran: $(grep update "$work/out" | tail -3)" +for _ in $(seq 60); do grep -q 'mark: shown as' "$work/out" && break; sleep 0.5; done +# A new untitled page: it takes keys, autosave skips it. +osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $pid) to true" -e 'delay 1' \ + -e 'tell application "System Events" to keystroke "n" using command down' -e 'delay 2' \ + -e 'tell application "System Events" to keystroke "unsaved"' >/dev/null +sleep 3 +! grep -q GET "$work/served" || fail "asked without being told: $(cat "$work/served")" +press() { + for _ in $(seq 60); do + [ "$("$work/ax-press" "$pid" "$1")" = pressed ] && return 0 + sleep 0.5 + done + fail "could not press $1: $(grep update "$work/out" | tail -3)" +} +press "Check for Updates…" +for _ in $(seq 60); do grep -q "offers 9.9.9" "$work/out" && break; sleep 0.5; done +grep -q "offers 9.9.9" "$work/out" || fail "no offer: $(grep update "$work/out" | tail -3)" +press Download +press "Restart to update" +# Unsaved text: the question comes first; cancel installs nothing. +press Cancel +sleep 3 +kill -0 "$pid" 2>/dev/null || fail "a cancelled quit still quit" +[ ! -e "$work/Applications/ElyMark.app/Contents/Resources/ely-update-marker" ] || fail "installed before the question" +# A plain quit after the cancel installs nothing either. +osascript -e 'tell application "System Events" to keystroke "q" using command down' >/dev/null +press "Don't Save" +for _ in $(seq 60); do kill -0 "$pid" 2>/dev/null || break; sleep 0.5; done +! kill -0 "$pid" 2>/dev/null || fail "the app did not quit" +sleep 8 +[ ! -e "$work/Applications/ElyMark.app/Contents/Resources/ely-update-marker" ] || fail "a plain quit installed the update" +! pgrep -f "$(basename "$work")/Applications/ElyMark.app/Contents/MacOS/ely-mark" >/dev/null || fail "a plain quit reopened the app" +# Relaunched: check, download again, and Restart installs. +start +for _ in $(seq 60); do grep -q 'mark: shown as' "$work/out" && break; sleep 0.5; done +press "Check for Updates…" +press Download +press "Restart to update" +for _ in $(seq 60); do kill -0 "$pid" 2>/dev/null || break; sleep 0.5; done +! kill -0 "$pid" 2>/dev/null || fail "the old app did not quit" +# The installer copies once the old app has gone. +for _ in $(seq 120); do [ -e "$work/Applications/ElyMark.app/Contents/Resources/ely-update-marker" ] && break; sleep 0.5; done +[ -e "$work/Applications/ElyMark.app/Contents/Resources/ely-update-marker" ] || fail "the new app is not in place" +# The new app itself, by the path the system resolved. +binary="$(basename "$work")/Applications/ElyMark.app/Contents/MacOS/ely-mark" +for _ in $(seq 40); do pgrep -f "$binary" >/dev/null && break; sleep 0.5; done +fresh=$(pgrep -f "$binary" | head -n 1 || true) +[ -n "$fresh" ] && [ "$fresh" != "$pid" ] || fail "the new app did not open" +[ "$(ls -A "$work/Applications")" = ElyMark.app ] || fail "copies left: $(ls -A "$work/Applications")" +# A broken release: its note is written before the reopen. +pkill -f "$binary" || true +for _ in $(seq 20); do pgrep -f "$binary" >/dev/null || break; sleep 0.5; done +mkdir -p "$work/broken/Other.app" +hdiutil create -quiet -volname Broken -srcfolder "$work/broken" -format UDZO "$work/serve/Broken.dmg" +sha=$(shasum -a 256 "$work/serve/Broken.dmg" | cut -d' ' -f1) +rm -f "$work/opened" +printf '{"version": "9.9.10", "assets": {"%s": {"url": "http://127.0.0.1:%s/Broken.dmg", "sha256": "%s"}}}\n' "$key" "$port" "$sha" >"$work/serve/ely-mark.json" +start +for _ in $(seq 60); do grep -q 'mark: shown as' "$work/out" && break; sleep 0.5; done +press "Check for Updates…" +press Download +press "Restart to update" +note="$work/home/Library/Application Support/ElyOffice/ely-mark/updates/failed.txt" +# Not the old process, which this script started and reaps. +reopened() { pgrep -f "$binary" | grep -vx "$pid" | head -n 1 || true; } +for _ in $(seq 60); do [ -n "$(reopened)" ] && [ -e "$note" ] && break; sleep 0.5; done +grep -q "holds no ElyMark.app" "$note" || fail "no failure note: $(cat "$note" 2>/dev/null)" +[ -n "$(reopened)" ] || fail "the old app did not reopen" +[ "$(head -n 1 "$work/opened")" = before ] || fail "the app reopened before the note was written" +echo "ok: ElyMark downloads, installs and reopens its update"