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
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ship-shape

Auto-updater library for wxDragon desktop apps, supporting Windows and macOS.
Auto-updater library for wxDragon desktop apps, supporting Windows, macOS, and Linux.

## Features

Expand All @@ -11,11 +11,16 @@ Auto-updater library for wxDragon desktop apps, supporting Windows and macOS.
- Windows: PowerShell install/extract scripts that relaunch the app afterward
- macOS: downloads and mounts a signed, notarized `.dmg` for the user to drag into
Applications; the app must be quit and relaunched manually
- Linux: a shell script that extracts the tarball or replaces the running `.AppImage`, then
relaunches the app, the same self-updating flow as Windows
- Other platforms: downloads the file and tells the user where it is

On macOS the expected release asset is `{app_name}.dmg` (`install_kind` is ignored there,
since there's only one asset kind). Windows keeps the `{app_name}.zip` (`InstallKind::Portable`) /
`{app_name}_setup.exe` (`InstallKind::Installer`) distinction.
since there's only one asset kind). Windows and Linux keep the same `InstallKind` distinction:
`{app_name}.zip` / `{app_name}.tar.gz` for `InstallKind::Portable`, and `{app_name}_setup.exe` /
`{app_name}.AppImage` for `InstallKind::Installer`. The Linux installer flow requires the running
process to be inside an AppImage (it reads the `APPIMAGE` environment variable the AppImage
runtime sets); there's no separate install step to run, so the update just replaces that file.

Each platform's asset names, download folder, and install flow live in one file under
`src/platform/`. To add a platform, add a file there and select it in `src/platform.rs`.
Expand Down
3 changes: 2 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ pub struct UpdaterConfig {
pub github_repo: String,
/// App name used to derive asset file names.
/// On Windows a zip asset is expected to be `{app_name}.zip` and an installer
/// `{app_name}_setup.exe`. On macOS the expected asset is a disk image,
/// `{app_name}_setup.exe`. On Linux the equivalents are `{app_name}.tar.gz` and
/// `{app_name}.AppImage`. On macOS the expected asset is a disk image,
/// `{app_name}.dmg`; `install_kind` is ignored there since there is only one asset kind.
pub app_name: String,
/// Human-readable app name used in dialog titles and messages (e.g. `"Paperback"`).
Expand Down
5 changes: 3 additions & 2 deletions src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ impl Drop for TempDownload {
/// already dismissed the progress dialog.
///
/// The destination folder depends on the platform and [`UpdaterConfig::install_kind`]. A portable
/// Windows zip lands next to the current executable so that the extraction script can overwrite
/// in place. Everything else lands in the system temp directory.
/// Windows zip or Linux tar.gz lands next to the current executable so that the extraction script
/// can overwrite in place. A Linux `AppImage` lands in a per-user cache directory. Everything else
/// lands in the system temp directory.
///
/// # Errors
///
Expand Down
8 changes: 6 additions & 2 deletions src/platform.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
//! Everything that differs between operating systems. Each platform module provides the same
//! three functions: `asset_name_parts`, `download_dir`, and `install`.

#[cfg(any(target_os = "linux", test))]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
mod other;
#[cfg(any(target_os = "windows", test))]
mod windows;

#[cfg(target_os = "linux")]
pub use linux::{asset_name_parts, download_dir, install};
#[cfg(target_os = "macos")]
pub use macos::{asset_name_parts, download_dir, install};
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
pub use other::{asset_name_parts, download_dir, install};
#[cfg(target_os = "windows")]
pub use windows::{asset_name_parts, download_dir, install};
Expand Down
201 changes: 201 additions & 0 deletions src/platform/linux.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
#[cfg(target_os = "linux")]
use std::{
env, fs,
path::{Path, PathBuf},
process::{self, Command},
};

#[cfg(target_os = "linux")]
use patois::t;

#[cfg(target_os = "linux")]
use super::InstallOutcome;
use crate::InstallKind;
#[cfg(target_os = "linux")]
use crate::{UpdateError, UpdaterConfig};

/// Portable is a `.tar.gz` extracted over the app folder, the same shape as the Windows zip.
/// The installer is a standalone `.AppImage` that replaces itself in place.
pub const fn asset_name_parts(install_kind: InstallKind) -> (&'static str, &'static str) {
match install_kind {
InstallKind::Installer => ("", "AppImage"),
InstallKind::Portable => ("", "tar.gz"),
}
}

/// The tar.gz lands next to the current executable so the extraction script can overwrite it in
/// place. The `AppImage` lands in a per-user cache directory rather than the shared system temp
/// directory, since a predictable path under `/tmp` could be squatted by another local user and
/// break the update for everyone else; it only replaces the running `AppImage` once its signature
/// is verified.
///
/// For [`InstallKind::Installer`] this also checks that the app is actually running from an
/// `AppImage`, so a misconfigured build fails before spending a download on an update it can
/// never apply, rather than after.
#[cfg(target_os = "linux")]
pub fn download_dir(config: &UpdaterConfig) -> Result<PathBuf, UpdateError> {
match config.install_kind {
InstallKind::Installer => {
env::var("APPIMAGE")
.map_err(|_| UpdateError::Io("Not running from an AppImage; cannot self-update.".to_string()))?;
cache_dir(config)
}
InstallKind::Portable => env::current_exe()
.map_err(|e| UpdateError::Io(format!("Failed to determine exe path: {e}")))?
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| UpdateError::Io("Failed to get exe directory".to_string())),
}
}

/// A per-user cache directory (`$XDG_CACHE_HOME/{app_name}`, falling back to
/// `$HOME/.cache/{app_name}`) for the downloaded `AppImage`, created if it doesn't exist yet.
#[cfg(target_os = "linux")]
fn cache_dir(config: &UpdaterConfig) -> Result<PathBuf, UpdateError> {
let base = env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".cache")))
.ok_or_else(|| {
UpdateError::Io("Could not determine a cache directory: neither XDG_CACHE_HOME nor HOME is set".to_string())
})?;
let dir = base.join(&config.app_name);
fs::create_dir_all(&dir).map_err(|e| UpdateError::Io(format!("Failed to create cache directory: {e}")))?;
Ok(dir)
}

/// Start a detached shell script that waits for this process to exit, applies the update, and
/// relaunches the app.
#[cfg(target_os = "linux")]
pub fn install(config: &UpdaterConfig, path: &Path) -> Result<InstallOutcome, String> {
let update = path.display().to_string();
let script = match config.install_kind {
InstallKind::Installer => {
// Set by the AppImage runtime to the path of the `.AppImage` file itself; unlike on
// Windows there's no separate installer step, so the update just overwrites that
// file. `current_exe` would instead resolve to the temporary FUSE mount the runtime
// extracts itself to, which disappears when the AppImage exits.
let current_appimage =
env::var("APPIMAGE").map_err(|_| t("Not running from an AppImage; cannot self-update."))?;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This APPIMAGE check only runs in install(), which is after the full download. If the app is set to InstallKind::Installer but isn't running as an AppImage (launched from an extracted folder, or during development), the user waits for the whole download and only then gets the error. The downloaded file is also past its temp guard by then, so it stays in /tmp forever.

Could this check happen before the download starts, for example in download_dir?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 30f2724: the APPIMAGE check now happens in download_dir, which runs before the download starts, so a non-AppImage build configured with InstallKind::Installer fails immediately instead of after downloading (and the file no longer gets orphaned in /tmp past the temp guard).

installer_script(process::id(), &update, &current_appimage)
}
InstallKind::Portable => {
let current_exe =
env::current_exe().map_err(|e| format!("{}: {e}", t("Failed to get current exe path")))?;
let exe_dir = current_exe.parent().unwrap_or(&current_exe);
let exe = current_exe.display().to_string();
targz_update_script(process::id(), &update, &exe_dir.display().to_string(), &exe)
}
};
Command::new("sh")
.arg("-c")
.arg(&script)
.spawn()
.map_err(|e| format!("{}: {e}", t("Failed to launch update script")))?;
Ok(InstallOutcome::Exit)
}

fn sh_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}

fn wait_clause(pid: u32) -> String {
format!("sleep 1; while kill -0 {pid} 2>/dev/null; do sleep 0.2; done")
}

fn installer_script(pid: u32, new_appimage: &str, current_appimage: &str) -> String {
// `;` rather than `&&` between steps, like the Windows scripts: if `chmod`/`mv` fails (for
// example the AppImage lives somewhere the user can't write), the old AppImage is still
// there under `current_appimage` and still gets relaunched, instead of leaving the app
// closed with no indication why.
format!(
"{wait}; chmod +x {new_q}; mv -f {new_q} {cur_q}; nohup {cur_q} >/dev/null 2>&1 &",
wait = wait_clause(pid),
new_q = sh_quote(new_appimage),
cur_q = sh_quote(current_appimage),
)
}

fn targz_update_script(pid: u32, targz: &str, dest_dir: &str, current_exe: &str) -> String {
// See `installer_script`: `;` so a failed extraction still relaunches the existing exe
// rather than leaving the app closed.
format!(
"{wait}; tar -xzf {targz_q} -C {dest_q}; rm -f {targz_q}; nohup {exe_q} >/dev/null 2>&1 &",
wait = wait_clause(pid),
targz_q = sh_quote(targz),
dest_q = sh_quote(dest_dir),
exe_q = sh_quote(current_exe),
)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn asset_name_depends_on_install_kind() {
assert_eq!(asset_name_parts(InstallKind::Installer), ("", "AppImage"));
assert_eq!(asset_name_parts(InstallKind::Portable), ("", "tar.gz"));
}

#[cfg(target_os = "linux")]
#[test]
fn cache_dir_prefers_xdg_cache_home_and_creates_it() {
let temp = env::temp_dir().join("ship-shape-cache-dir-test");
// SAFETY: no other test in this crate reads or writes XDG_CACHE_HOME.
unsafe { env::set_var("XDG_CACHE_HOME", &temp) };
let config = UpdaterConfig::new("o/r", "myapp", "My App", "key", "1.0.0");
let dir = cache_dir(&config).unwrap();
assert_eq!(dir, temp.join("myapp"));
assert!(dir.is_dir());
// SAFETY: see above.
unsafe { env::remove_var("XDG_CACHE_HOME") };
let _ = fs::remove_dir_all(&temp);
}

#[test]
fn sh_quote_wraps_in_single_quotes() {
assert_eq!(sh_quote("/tmp/app.tar.gz"), "'/tmp/app.tar.gz'");
}

#[test]
fn sh_quote_escapes_embedded_single_quotes() {
assert_eq!(sh_quote("/home/o'brien/app.AppImage"), r"'/home/o'\''brien/app.AppImage'");
}

#[test]
fn installer_script_waits_replaces_and_relaunches() {
let script = installer_script(42, "/tmp/app.AppImage", "/home/user/App.AppImage");
assert_eq!(
script,
"sleep 1; while kill -0 42 2>/dev/null; do sleep 0.2; done; chmod +x '/tmp/app.AppImage'; mv -f '/tmp/app.AppImage' '/home/user/App.AppImage'; nohup '/home/user/App.AppImage' >/dev/null 2>&1 &"
);
}

#[test]
fn installer_script_uses_semicolons_so_a_failed_step_still_relaunches() {
let script = installer_script(1, "/tmp/app.AppImage", "/opt/App.AppImage");
assert!(!script.contains("&&"));
}

#[test]
fn targz_script_extracts_cleans_up_and_relaunches() {
let script = targz_update_script(7, "/tmp/app.tar.gz", "/opt/app", "/opt/app/app");
assert_eq!(
script,
"sleep 1; while kill -0 7 2>/dev/null; do sleep 0.2; done; tar -xzf '/tmp/app.tar.gz' -C '/opt/app'; rm -f '/tmp/app.tar.gz'; nohup '/opt/app/app' >/dev/null 2>&1 &"
);
}

#[test]
fn targz_script_uses_semicolons_so_a_failed_step_still_relaunches() {
let script = targz_update_script(1, "/tmp/app.tar.gz", "/opt/app", "/opt/app/app");
assert!(!script.contains("&&"));
}

#[test]
fn scripts_escape_quotes_in_paths() {
let script = installer_script(7, "/tmp/o'brien.AppImage", "/home/o'brien/App.AppImage");
assert!(script.contains(r"'/tmp/o'\''brien.AppImage'"));
assert!(script.contains(r"'/home/o'\''brien/App.AppImage'"));
}
}