Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ patois = "0.2.2"
pulldown-cmark = { version = "0.13.4", default-features = false }
serde = { version = "1.0.229", default-features = false, features = ["derive"] }
ureq = { version = "3.4.2", default-features = false, features = ["json", "rustls"] }
wx-utils = "0.4.2"
# default-features = false so this does not decide for the application
# which parts of wxWidgets get built; nothing here needs any of them.
wxdragon = { version = "0.9.22", default-features = false }
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,13 @@ that identity, fixtures are ad-hoc signed and demonstrate the manual fallback in
trusts only its generated local fixture and keeps the production signer checks. It is a repository
test harness, excluded from the published crate package.

The download gauge stays below 100 until verification and preparation finish. Native progress
updates can yield to the event loop, so completion waits for the active Update/Pulse call to
return before destroying the dialog or calling the shutdown handler. Regression tests cover this
ordering without requiring a graphical session.
The download window comes from the `progress` module of
[wx-utils](https://github.com/trypsynth/wx-utils). Its gauge stays below 100 until verification
and preparation finish. Native progress updates can yield to the event loop, so completion waits
for the active Update/Pulse call to return before destroying the dialog or calling the shutdown
handler. wx-utils' regression tests cover this ordering without requiring a graphical session.
wx-utils shows one progress window at a time, so a download that starts while the application
shows another one waits for it.

Run `cargo test` on macOS for coverage of cancellation, helper timeouts, replacement and rollback,
failed restore and launch, competing destination changes, protected locations, bundle selection,
Expand Down
155 changes: 47 additions & 108 deletions src/ui.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
use std::{
path::PathBuf,
process, ptr,
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
atomic::{AtomicBool, Ordering},
},
thread,
time::Duration,
};

use patois::t;
use wx_utils::progress::{Ended, Progress, run_with_progress};
use wxdragon::{ffi, prelude::*, window::WxWidget};

pub use self::markdown::markdown_to_text;
Expand All @@ -19,15 +20,6 @@ use crate::{
};

mod markdown;
mod progress;

use progress::{ProgressLifecycle, download_percent};

const PROGRESS_INTERVAL: Duration = Duration::from_millis(200);

thread_local! {
static ACTIVE_PROGRESS: ProgressLifecycle<ProgressDialog> = const { ProgressLifecycle::new() };
}

/// Guards against a second update-check flow (silent startup check, manual "Check for
/// Updates", or an impatient double-click while a download is stuck) from starting while
Expand Down Expand Up @@ -78,16 +70,6 @@ pub enum CheckTrigger {
Manual,
}

#[derive(Default)]
struct DownloadProgress {
downloaded: AtomicU64,
total: AtomicU64,
finished: AtomicBool,
#[cfg(target_os = "macos")]
preparing: AtomicBool,
cancelled: AtomicBool,
}

/// Show the "update available" dialog and return `true` if the user accepted.
///
/// `app_display_name` appears in the body label (e.g. `"A new version of My App is available."`).
Expand Down Expand Up @@ -203,99 +185,56 @@ fn start_download(
on_exit: Box<dyn FnOnce() + Send>,
) {
let title = t("%s Update").replace("%s", &config.app_display_name);
let progress_dialog = ProgressDialog::builder(&parent, &title, &t("Downloading update..."), 100)
.with_style(
ProgressDialogStyle::AutoHide
| ProgressDialogStyle::AppModal
| ProgressDialogStyle::RemainingTime
| ProgressDialogStyle::CanAbort,
)
.build();
ACTIVE_PROGRESS.with(|p| p.set(progress_dialog));
let progress = Arc::new(DownloadProgress::default());
spawn_progress_heartbeat(Arc::clone(&progress));
thread::spawn(move || {
let download =
download_update_file(&config, &result.download_url, &result.signature_url, &progress.cancelled, |d, t| {
progress.downloaded.store(d, Ordering::Relaxed);
progress.total.store(t, Ordering::Relaxed);
});
#[cfg(target_os = "macos")]
let download = if progress.cancelled.load(Ordering::Relaxed) {
if let Ok(ref path) = download {
platform::remove_private_download(path);
}
Err(t("Update cancelled."))
} else {
download.map_err(|e| format!("{}: {e}", t("Update failed"))).and_then(|path| {
progress.preparing.store(true, Ordering::Relaxed);
let result = platform::install_with_cancel(&config, &path, &progress.cancelled);
// A manual fallback still needs its DMG. Automatic preparation has copied the
// bundle, so its private verified download can be removed.
if !matches!(result, Ok(InstallOutcome::ManualStep(_))) {
platform::remove_private_download(&path);
}
result
})
};
progress.finished.store(true, Ordering::Relaxed);
wxdragon::call_after(Box::new(move || {
ACTIVE_PROGRESS.with(|p| {
p.finish(Box::new(move || {
ACTIVE_PROGRESS.with(ProgressLifecycle::clear);
if !progress.cancelled.load(Ordering::Relaxed) {
#[cfg(not(target_os = "macos"))]
let download = download
.map_err(|e| format!("{}: {e}", t("Update failed")))
.and_then(|path| platform::install(&config, &path));
install_update(parent, download, on_exit);
}
drop(check);
}));
});
}));
wxdragon::wake_up_idle();
#[cfg(not(target_os = "macos"))]
let install_config = Arc::clone(&config);
#[cfg(not(target_os = "macos"))]
let work = move |progress: &Progress| fetch(&config, &result, progress);
#[cfg(target_os = "macos")]
let work = move |progress: &Progress| prepare(&config, fetch(&config, &result, progress), progress);
run_with_progress(&parent, &title, &t("Downloading update..."), work, move |download, ended| {
if ended == Ended::Completed {
#[cfg(not(target_os = "macos"))]
let download = download
.map_err(|e| format!("{}: {e}", t("Update failed")))
.and_then(|path| platform::install(&install_config, &path));
install_update(parent, download, on_exit);
}
drop(check);
});
}

/// Updates the progress dialog from the main thread every [`PROGRESS_INTERVAL`] until the
/// download finishes or is cancelled.
fn spawn_progress_heartbeat(progress: Arc<DownloadProgress>) {
thread::spawn(move || {
while !progress.finished.load(Ordering::Relaxed) && !progress.cancelled.load(Ordering::Relaxed) {
let downloaded = progress.downloaded.load(Ordering::Relaxed);
let total = progress.total.load(Ordering::Relaxed);
let progress = Arc::clone(&progress);
wxdragon::call_after(Box::new(move || update_progress_dialog(&progress, downloaded, total)));
wxdragon::wake_up_idle();
thread::sleep(PROGRESS_INTERVAL);
}
});
/// Downloads and verifies the update, moving the download window's gauge as it goes.
fn fetch(config: &UpdaterConfig, result: &UpdateAvailableResult, progress: &Progress) -> Result<PathBuf, UpdateError> {
download_update_file(config, &result.download_url, &result.signature_url, progress.cancel_flag(), |done, total| {
progress.set(done, Some(total));
})
}

fn update_progress_dialog(progress: &DownloadProgress, downloaded: u64, total: u64) {
ACTIVE_PROGRESS.with(|p| {
let Some(update) = p.begin_update() else {
return;
};
let dialog = update.dialog();
#[cfg(target_os = "macos")]
let preparing = progress.preparing.load(Ordering::Relaxed);
#[cfg(not(target_os = "macos"))]
let preparing = false;
let keep_going = if preparing {
dialog.pulse(Some(&t("Preparing update...")))
} else {
download_percent(downloaded, total)
.map_or_else(|| dialog.pulse(None), |percent| dialog.update(percent, None))
};
if !keep_going {
progress.cancelled.store(true, Ordering::Relaxed);
p.clear();
/// Prepares the downloaded update for installation, while the download window pulses.
#[cfg(target_os = "macos")]
fn prepare(
config: &UpdaterConfig,
download: Result<PathBuf, UpdateError>,
progress: &Progress,
) -> Result<InstallOutcome, String> {
if progress.is_cancelled() {
if let Ok(ref path) = download {
platform::remove_private_download(path);
}
// Dropping `update` invokes any completion queued during the native event-loop yield,
// after Update/Pulse returns and its reference to the dialog is released.
});
Err(t("Update cancelled."))
} else {
download.map_err(|e| format!("{}: {e}", t("Update failed"))).and_then(|path| {
progress.set_message(&t("Preparing update..."));
progress.set(0, None);
let result = platform::install_with_cancel(config, &path, progress.cancel_flag());
// A manual fallback still needs its DMG. Automatic preparation has copied the
// bundle, so its private verified download can be removed.
if !matches!(result, Ok(InstallOutcome::ManualStep(_))) {
platform::remove_private_download(&path);
}
result
})
}
}

fn install_update(parent: ParentWindow, outcome: Result<InstallOutcome, String>, on_exit: Box<dyn FnOnce() + Send>) {
Expand Down
127 changes: 0 additions & 127 deletions src/ui/progress.rs

This file was deleted.

Loading