Merge pull request #6038 from dotCooCoo/chore/craft-update-updater-fixes

Updates: the updater reports installing, ready, installed and failed, and installs at exit
This commit is contained in:
Dylan M. Taylor
2026-11-08 22:12:29 -05:00
committed by GitHub
4 changed files with 119 additions and 12 deletions
Generated
+3 -4
View File
@@ -1081,7 +1081,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/dotCooCoo/craft-libs?rev=c7c84230631cbc01ee928f0a0e0d838b1ed612ae#c7c84230631cbc01ee928f0a0e0d838b1ed612ae"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -1135,7 +1135,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/dotCooCoo/craft-libs?rev=c7c84230631cbc01ee928f0a0e0d838b1ed612ae#c7c84230631cbc01ee928f0a0e0d838b1ed612ae"
dependencies = [
"chrono",
"dunce",
@@ -2981,7 +2981,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/dotCooCoo/craft-libs?rev=c7c84230631cbc01ee928f0a0e0d838b1ed612ae#c7c84230631cbc01ee928f0a0e0d838b1ed612ae"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -7562,7 +7562,6 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
+3 -3
View File
@@ -67,9 +67,9 @@ wuff = "0.2.9"
brotli-decompressor = "5"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write"] }
craft-pdf = { git = "https://github.com/dotCooCoo/craft-libs", rev = "c7c84230631cbc01ee928f0a0e0d838b1ed612ae", version = "=0.1.0", default-features = false, features = ["write"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
craft-update = { git = "https://github.com/dotCooCoo/craft-libs", version = "=0.1.0", rev = "c7c84230631cbc01ee928f0a0e0d838b1ed612ae", features = ["egui"] }
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
@@ -134,7 +134,7 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
krilla = { git = "https://github.com/dotCooCoo/craft-libs", rev = "c7c84230631cbc01ee928f0a0e0d838b1ed612ae" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+3
View File
@@ -133,6 +133,7 @@ impl eframe::App for App {
if let Some(key_file) = &self.2 {
key_file.remove();
}
self.0.start_pending_update();
}
}
@@ -192,6 +193,8 @@ fn schedule_macos_terminate(app: &mut WordApp, key_file: Option<&control_server:
if let Some(key_file) = key_file {
key_file.remove();
}
// `terminate:` ends the process without dropping the app, so a ready update's helper starts here.
app.start_pending_update();
dispatch2::DispatchQueue::main().exec_async(|| {
if let Some(mtm) = objc2::MainThreadMarker::new() {
objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None);
+110 -5
View File
@@ -29,9 +29,9 @@ impl Default for Runtime {
fn default() -> Self {
Self {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")),
updater: Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).with_translator(translate),
#[cfg(not(target_arch = "wasm32"))]
result_dialog: CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")),
result_dialog: CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).with_translator(translate),
#[cfg(not(target_arch = "wasm32"))]
startup_check: false,
#[cfg(not(target_arch = "wasm32"))]
@@ -45,6 +45,13 @@ impl Default for Runtime {
}
}
/// The update prompt's and the result window's words in the interface language: craft-update
/// passes its English text and the values of its `{name}` placeholders each time it draws.
#[cfg(not(target_arch = "wasm32"))]
fn translate(text: &str, args: &[(&str, &str)]) -> String {
crate::i18n::fmt(crate::i18n::t(text), args)
}
impl WordApp {
/// Whether the app asks for a newer release when it starts (off until the desktop app turns it on).
#[cfg(not(target_arch = "wasm32"))]
@@ -59,7 +66,15 @@ impl WordApp {
pub fn disable_self_update(&mut self) {
let rt = &mut self.updates_rt;
rt.startup_check = false;
rt.updater = Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).with_source(refused_check());
rt.updater = Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).with_translator(translate).with_source(refused_check());
}
/// Starts the helper of an update that is ready, so that it installs once the process has
/// ended. Call it once the exit is certain, before a path that ends the process without
/// dropping the app. Later calls do nothing.
#[cfg(not(target_arch = "wasm32"))]
pub fn start_pending_update(&mut self) {
self.updates_rt.updater.start_pending_update();
}
}
@@ -107,15 +122,23 @@ pub(crate) fn tick(app: &mut WordApp, ctx: &egui::Context) {
pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
// An update starts, and a ready one asks the app to close, only while no work is unsaved.
let clean = !(app.session.dirty || app.save_unconfirmed() || app.printing.busy() || crate::panes::has_comment_drafts(app));
match app.updates_rt.updater.show_if(ctx, clean) {
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
// The whole app quits, every window included, and first asks about each document with
// unsaved changes.
Action::Close => {
let _ = app.ask_before_quit();
}
Action::None => {}
}
// The dialog answers Help ▸ Check for Updates; a found update leaves the status idle and the prompt above takes over.
// It also reports what the last update came to, which the check at start leaves as the status.
let rt = &mut app.updates_rt;
if !matches!(rt.updater.status(), Status::Idle) {
rt.manual_window_open = true;
}
if rt.manual_window_open {
match rt.result_dialog.show(ctx, rt.updater.status()) {
CheckResultResponse::None => {}
@@ -124,6 +147,13 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) {
rt.updater.dismiss_status();
}
CheckResultResponse::CheckAgain => rt.updater.check_now(ctx),
// The window closes, and the next frame's `show_if` asks the app to quit once the
// shown document has no unsaved work.
CheckResultResponse::RestartToUpdate => {
rt.updater.restart_to_update();
rt.manual_window_open = false;
rt.updater.dismiss_status();
}
}
// An update was found: the prompt replaces the dialog.
if matches!(rt.updater.status(), Status::Idle) {
@@ -178,4 +208,79 @@ mod tests {
assert!(matches!(status, Status::Failed(why) if why.contains("another program's AppImage")), "{status:?}");
assert!(!app.updates_rt.updater.prompt_open(), "no update offered");
}
/// Help ▸ Check for Updates runs while the ribbon draws, after the frame's `tick`: the result
/// window shows the check from the next frame on.
#[test]
fn a_check_asked_for_while_drawing_opens_the_result_window() {
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
let pins = std::env::temp_dir().join(format!("wordcraft-no-pins-{}.toml", std::process::id()));
app.updates_rt.updater = craft_update::updater::Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION"))
.with_source(std::sync::Arc::new(|| Err("offline".into())))
.with_pins_path(pins);
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
super::tick(&mut app, ui.ctx());
super::check(&mut app);
super::show(&mut app, ui.ctx());
});
out.textures_delta.clear();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
super::tick(&mut app, ui.ctx());
super::show(&mut app, ui.ctx());
});
out.textures_delta.clear();
let status = app.updates_rt.updater.status();
assert!(matches!(status, Status::Checking | Status::Failed(_)), "{status:?}");
assert!(app.updates_rt.manual_window_open, "the result window shows the check ({status:?})");
}
/// The first start after an update reports what it came to without a menu click: the check at
/// start reads the record, the result window opens, and Esc closes it and clears the status.
#[test]
fn the_last_update_opens_the_result_window_until_it_is_closed() {
let version = env!("CARGO_PKG_VERSION");
// The files craft-update writes for an installed update and for a failed one.
let cases: [(&str, &str, fn(&Status) -> bool); 2] = [
("data/self-update-done.txt", version, |s| matches!(s, Status::Updated(v) if v == env!("CARGO_PKG_VERSION"))),
("data/logs/self-update-error.txt", "disk full", |s| matches!(s, Status::InstallFailed(why) if why == "disk full")),
];
for (n, (file, text, expected)) in cases.into_iter().enumerate() {
let root = std::env::temp_dir().join(format!("wordcraft-last-update-{n}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let record = root.join(file);
std::fs::create_dir_all(record.parent().unwrap()).unwrap();
std::fs::write(&record, text).unwrap();
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
app.updates_rt.updater = craft_update::updater::Updater::new(&crate::UPDATE_APP, version)
.with_source(std::sync::Arc::new(|| Ok(None)))
.with_paths(craft_update::Paths::under(&root))
.with_pins_path(root.join("no-pins.toml"));
app.set_startup_update_check(true);
let ctx = egui::Context::default();
let frame = |app: &mut crate::WordApp, events: Vec<egui::Event>| {
let input = egui::RawInput { events, ..Default::default() };
let mut out = ctx.run_ui(input, |ui| {
super::tick(app, ui.ctx());
super::show(app, ui.ctx());
});
out.textures_delta.clear();
};
frame(&mut app, Vec::new());
let status = app.updates_rt.updater.status();
assert!(expected(status), "{file}: {status:?}");
assert!(app.updates_rt.manual_window_open, "{file}: the result window opens by itself");
assert!(!record.exists(), "{file}: the record is read once");
frame(&mut app, Vec::new());
assert!(app.updates_rt.manual_window_open, "{file}: the window stays open");
let escape =
egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
frame(&mut app, vec![escape]);
assert!(!app.updates_rt.manual_window_open, "{file}: Esc closes the window");
assert_eq!(app.updates_rt.updater.status(), &Status::Idle, "{file}: closing clears the status");
frame(&mut app, Vec::new());
assert!(!app.updates_rt.manual_window_open, "{file}: the window stays closed");
let _ = std::fs::remove_dir_all(&root);
}
}
}