Updates: opt-in launch check, Check for Updates…, install after a normal quit, from the Gitea release; dmgs carry an Applications link (UPD-1, REL-1)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-09 16:34:32 -04:00
1 parent fa273aff13
commit b03c9f2853
28 files changed
+896 -16

No files matched your search

+6
View File
@@ -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<Self>) {
ely_ui::updates::quit_stopped(cx);
self.close_cancelled(cx);
}
fn no_document(&self, cx: &mut Context<Self>) {
let text = text(cx, "command.blocked.no_document", &[]);
self.toast(text, Severity::Warning, cx);
+3 -3
View File
@@ -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)
},
+2 -2
View File
@@ -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);
}
}
}
+160
View File
@@ -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<Option<PathBuf>> {
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<PathBuf> {
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");
}
}
+2
View File
@@ -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;
+46
View File
@@ -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<Asset>,
}
#[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<String> {
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<String> {
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");
}
}
+1 -1
View File
@@ -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;
@@ -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,
+3 -2
View File
@@ -63,14 +63,15 @@ fn shared_fields_type_strictly_and_default_when_absent() {
let fields = |text: &str| serde_json::from_str::<serde_json::Map<_, _>>(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}");
}
}
+2 -2
View File
@@ -305,7 +305,7 @@ fn secure_host(url: &str) -> Result<String> {
}
/// GETs `url`, following redirects only to places `secure_host` accepts.
fn fetch(agent: &Agent, url: &str) -> Result<Response<Body>> {
pub(crate) fn fetch(agent: &Agent, url: &str) -> Result<Response<Body>> {
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<Response<Body>> {
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)
+1
View File
@@ -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
+6 -1
View File
@@ -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::<QuitRoute>().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![
+6
View File
@@ -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}"),
+10
View File
@@ -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", "各应用下次启动时生效"),
+1
View File
@@ -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;
+6
View File
@@ -15,6 +15,10 @@ pub fn run<V: Render>(
app: AppId,
root: impl FnOnce(AppId, &mut Window, &mut App) -> Entity<V> + '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<V: Render>(
}
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));
+10 -2
View File
@@ -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)
+1
View File
@@ -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.
+27
View File
@@ -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", &note)]);
cx.defer_in(window, move |root: &mut Root, _, cx| {
root.toast(text, Severity::Danger, cx)
});
}
}
Root {
app,
focus,
+428
View File
@@ -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<PathBuf>,
/// Why the last install failed, for the window to say.
pub note: Option<String>,
}
/// What an update says to the window.
pub enum UpdateNews {
Failed(String),
/// A check the user asked for found nothing newer.
Latest,
}
impl EventEmitter<UpdateNews> for Updates {}
struct Updater(Entity<Updates>);
impl Global for Updater {}
/// The app's updater, if it runs from an installed bundle.
pub fn updater(cx: &App) -> Option<Entity<Updates>> {
cx.try_global::<Updater>().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<i32> {
let args: Vec<String> = 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<String> {
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<Self>) {
// 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(&current, &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>) {
self.dismissed = true;
cx.notify();
}
fn download(&mut self, cx: &mut Context<Self>) {
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<u64>| {
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::<PathBuf, String>(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<Self>) {
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<PathBuf, String>,
cx: &mut Context<Self>,
) {
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<Self>) {
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<Self>) -> 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")))
);
});
}
}
+13 -1
View File
@@ -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"
+2 -2
View File
@@ -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,官网指向新发布页
+9
View File
@@ -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` 失败变异稳定触发测试失败。
+7
View File
@@ -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`。修复方向:将横幅放在主题化外壳的标题栏下方,使用主题前景色并保留原生窗口按钮区域;验证深浅主题下文案可读且无重叠。
+7
View File
@@ -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`。修复方向:确保故障记录目录存在,处理记录写入错误,并保证持久化失败时重开的应用仍能展示安装失败;覆盖下载目录被清理的安装失败测试。
+3
View File
@@ -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`。修复方向:失败记录完成写入及错误处理后再重开应用,并增加覆盖该先后顺序、断言本次重开即显示失败提示的回归测试。
+5
View File
@@ -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`。修复方向:通过重开入口的事件握手断言启动请求前记录已写好,并验证本次重开窗口显示失败提示,使提前重开的变异稳定失败。
+120
View File
@@ -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"