diff --git a/crates/update/src/install.rs b/crates/update/src/install.rs
--- a/crates/update/src/install.rs
+++ b/crates/update/src/install.rs
@@ -1424,7 +1424,7 @@ pub mod macos {
 /// out the same way first.
 #[cfg(unix)]
 #[allow(dead_code)]
-mod tree {
+pub(crate) mod tree {
     use super::{App, Path, PathBuf, single_top_folder};
 
     /// Unpacks `file` and returns a staging folder laid out like `/usr/local`.
diff --git a/crates/update/src/self_update.rs b/crates/update/src/self_update.rs
--- a/crates/update/src/self_update.rs
+++ b/crates/update/src/self_update.rs
@@ -368,7 +368,7 @@ pub fn update(
             }
             #[cfg(unix)]
             Install::Standalone(exe) => {
-                let result = replace_standalone(app, &file, exe);
+                let result = replace_standalone(app, paths, &file, exe);
                 let _ = std::fs::remove_file(&file);
                 result?;
                 relaunch_after_exit(pid, &[exe.as_os_str()])
@@ -415,42 +415,94 @@ fn replace_appimage(new: &Path, path: &Path) -> Result<(), String> {
     })
 }
 
-/// Extracts the app's binary from a release tarball and atomically renames it over the running
-/// standalone executable.
+/// Replaces a standalone copy from a release tarball, along with what the tarball install put
+/// beside it: the release's other programs that are already in the executable's folder, and,
+/// when that folder is a prefix's `bin` with the app's `share/<id>` folder next to it, everything
+/// under the tarball's `share` (data, desktop entry, icons). A copy on its own in some folder gets
+/// only its executable. Each file is staged next to its destination and renamed over it; the
+/// executable goes last, so a failure before it leaves the running version in charge.
 #[cfg(unix)]
-fn replace_standalone(app: &App, tarball: &Path, exe: &Path) -> Result<(), String> {
-    use std::os::unix::fs::PermissionsExt;
-    let dir = exe.parent().ok_or_else(|| "executable isn't in a folder".to_string())?;
-    let f = std::fs::File::open(tarball).map_err(|e| format!("could not open {}: {e}", tarball.display()))?;
-    let gz = flate2::read::GzDecoder::new(f);
-    let mut archive = tar::Archive::new(gz);
-    let staged = dir.join(format!(".{}.update", exe.file_name().and_then(|n| n.to_str()).unwrap_or("app")));
-    let bin_suffix = format!("/{}", app.id);
-    let bin_rel = format!("bin/{}", app.id);
-    let mut found = false;
-    for entry in archive.entries().map_err(|e| format!("could not read archive entries: {e}"))? {
-        let mut entry = entry.map_err(|e| format!("archive entry error: {e}"))?;
-        if !entry.header().entry_type().is_file() {
-            continue;
+fn replace_standalone(app: &App, paths: &Paths, tarball: &Path, exe: &Path) -> Result<(), String> {
+    let staging = paths.cache.join(format!("{}.standalone", app.id));
+    let result = install::tree::unpack(app, tarball, &staging).and_then(|tree| replace_from_tree(app, &tree, exe));
+    let _ = std::fs::remove_dir_all(&staging);
+    result
+}
+
+#[cfg(unix)]
+fn replace_from_tree(app: &App, tree: &Path, exe: &Path) -> Result<(), String> {
+    let new_exe = tree.join("bin").join(app.id);
+    if !is_plain_file(&new_exe) {
+        return Err(format!("release archive did not contain binary for {}", app.id));
+    }
+    let bin = exe.parent().ok_or_else(|| "executable isn't in a folder".to_string())?;
+    let mut moves = Vec::new();
+    for entry in std::fs::read_dir(tree.join("bin")).map_err(|e| format!("couldn't read the release's programs ({e})"))?.flatten() {
+        let name = entry.file_name();
+        if name != app.id && is_plain_file(&entry.path()) && bin.join(&name).is_file() {
+            moves.push((entry.path(), bin.join(&name)));
         }
-        let path = entry.path().map_err(|e| format!("archive path error: {e}"))?;
-        let path_str = path.to_string_lossy();
-        if path_str == bin_rel || path_str.ends_with(&bin_suffix) || path.file_name().is_some_and(|n| n == app.id) {
-            let mut out = std::fs::File::create(&staged).map_err(|e| format!("could not create {}: {e}", staged.display()))?;
-            std::io::copy(&mut entry, &mut out).map_err(|e| format!("could not extract binary: {e}"))?;
-            found = true;
-            break;
+    }
+    let prefix = bin.parent().filter(|_| bin.file_name().is_some_and(|n| n == "bin"));
+    if let Some(prefix) = prefix
+        && prefix.join("share").join(app.id).is_dir()
+    {
+        for file in plain_files(&tree.join("share"))? {
+            let relative = file.strip_prefix(tree).map_err(|_| "staged file escapes the release".to_string())?;
+            moves.push((file.clone(), prefix.join(relative)));
         }
     }
-    if !found {
-        let _ = std::fs::remove_file(&staged);
-        return Err(format!("release archive did not contain binary for {}", app.id));
+    for (from, to) in &moves {
+        place(from, to, false)?;
     }
-    std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string())?;
-    std::fs::rename(&staged, exe).map_err(|e| {
+    place(&new_exe, exe, true)
+}
+
+/// Copies `from` next to `to` and renames it over `to`. Group and world write and the set-id
+/// bits are never carried over from the archive; the executable is always 0755.
+#[cfg(unix)]
+fn place(from: &Path, to: &Path, executable: bool) -> Result<(), String> {
+    use std::os::unix::fs::PermissionsExt;
+    let dir = to.parent().ok_or_else(|| format!("{} isn't in a folder", to.display()))?;
+    std::fs::create_dir_all(dir).map_err(|e| format!("couldn't create {} ({e})", dir.display()))?;
+    let name = to.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
+    let staged = dir.join(format!(".{name}.update"));
+    let done = std::fs::copy(from, &staged).map_err(|e| format!("couldn't write next to {} ({e})", to.display())).and_then(|_| {
+        let mode = std::fs::metadata(from).map(|m| m.permissions().mode()).unwrap_or(0o644);
+        let mode = if executable { 0o755 } else { mode & 0o755 };
+        std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(mode)).map_err(|e| e.to_string())?;
+        std::fs::rename(&staged, to).map_err(|e| format!("couldn't replace {} ({e})", to.display()))
+    });
+    if done.is_err() {
         let _ = std::fs::remove_file(&staged);
-        format!("could not replace {}: {e}", exe.display())
-    })
+    }
+    done
+}
+
+/// A regular file, not a link to one.
+#[cfg(unix)]
+fn is_plain_file(path: &Path) -> bool {
+    std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_file())
+}
+
+/// The regular files under `dir`, without following links; none when `dir` doesn't exist.
+#[cfg(unix)]
+fn plain_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
+    let mut files = Vec::new();
+    let mut pending = vec![dir.to_path_buf()];
+    while let Some(dir) = pending.pop() {
+        let Ok(entries) = std::fs::read_dir(&dir) else { continue };
+        for entry in entries {
+            let entry = entry.map_err(|e| format!("couldn't read {} ({e})", dir.display()))?;
+            let kind = entry.file_type().map_err(|e| e.to_string())?;
+            if kind.is_dir() {
+                pending.push(entry.path());
+            } else if kind.is_file() {
+                files.push(entry.path());
+            }
+        }
+    }
+    Ok(files)
 }
 
 /// Starts `sh`, which waits for `pid` to exit and then runs `command`. Detached, so it outlives us.
@@ -1778,4 +1830,104 @@ mod tests {
         assert!(is_bundle_origin(&crate::suite::PHOTOCRAFT, "photocraft-origin"));
         assert!(!is_bundle_origin(&crate::suite::LAUNCHER, ""));
     }
+
+    /// A release tarball like `package.sh` makes: everything under one `<app>-<ver>-<platform>/`.
+    #[cfg(unix)]
+    fn release_tarball(path: &Path, files: &[(&str, &[u8])]) {
+        let f = std::fs::File::create(path).unwrap();
+        let mut b = tar::Builder::new(flate2::write::GzEncoder::new(f, flate2::Compression::fast()));
+        for (name, bytes) in files {
+            let mut h = tar::Header::new_gnu();
+            h.set_size(bytes.len() as u64);
+            h.set_mode(0o755);
+            h.set_cksum();
+            b.append_data(&mut h, format!("pdfcraft-9.9.9-linux-x86_64/{name}"), *bytes).unwrap();
+        }
+        b.into_inner().unwrap().finish().unwrap();
+    }
+
+    #[cfg(unix)]
+    #[test]
+    fn a_standalone_update_in_a_prefix_replaces_the_cli_and_shared_files_too() {
+        let dir = tempfile::tempdir().unwrap();
+        let prefix = dir.path().join("local");
+        std::fs::create_dir_all(prefix.join("bin")).unwrap();
+        std::fs::create_dir_all(prefix.join("share/pdfcraft/models")).unwrap();
+        let exe = prefix.join("bin/pdfcraft");
+        std::fs::write(&exe, b"old app").unwrap();
+        std::fs::write(prefix.join("bin/pdfcraft-cli"), b"old cli").unwrap();
+        std::fs::write(prefix.join("bin/unrelated"), b"mine").unwrap();
+        std::fs::write(prefix.join("share/pdfcraft/models/a.rten"), b"old model").unwrap();
+        let tarball = dir.path().join("new.tar.gz");
+        release_tarball(
+            &tarball,
+            &[
+                ("bin/pdfcraft", b"new app"),
+                ("bin/pdfcraft-cli", b"new cli"),
+                ("share/pdfcraft/models/a.rten", b"new model"),
+                ("share/pdfcraft/models/b.rten", b"added model"),
+                ("share/applications/ai.storyteller.pdfcraft.desktop", b"desktop"),
+            ],
+        );
+        let paths = Paths::under(&dir.path().join("data"));
+        replace_standalone(&crate::suite::PDFCRAFT, &paths, &tarball, &exe).unwrap();
+        let read = |p: &str| std::fs::read(prefix.join(p)).unwrap();
+        assert_eq!(read("bin/pdfcraft"), b"new app");
+        assert_eq!(read("bin/pdfcraft-cli"), b"new cli");
+        assert_eq!(read("bin/unrelated"), b"mine", "files the release doesn't ship are left alone");
+        assert_eq!(read("share/pdfcraft/models/a.rten"), b"new model");
+        assert_eq!(read("share/pdfcraft/models/b.rten"), b"added model");
+        assert_eq!(read("share/applications/ai.storyteller.pdfcraft.desktop"), b"desktop");
+        let leftovers: Vec<_> = walk(&prefix).into_iter().filter(|p| p.to_string_lossy().contains(".update")).collect();
+        assert!(leftovers.is_empty(), "no staged files left behind: {leftovers:?}");
+    }
+
+    #[cfg(unix)]
+    #[test]
+    fn a_lone_standalone_binary_gets_only_what_sits_beside_it() {
+        let dir = tempfile::tempdir().unwrap();
+        let tools = dir.path().join("tools");
+        std::fs::create_dir_all(&tools).unwrap();
+        let exe = tools.join("pdfcraft");
+        std::fs::write(&exe, b"old app").unwrap();
+        let tarball = dir.path().join("new.tar.gz");
+        release_tarball(&tarball, &[("bin/pdfcraft", b"new app"), ("bin/pdfcraft-cli", b"new cli"), ("share/pdfcraft/models/a.rten", b"model")]);
+        let paths = Paths::under(&dir.path().join("data"));
+        replace_standalone(&crate::suite::PDFCRAFT, &paths, &tarball, &exe).unwrap();
+        assert_eq!(std::fs::read(&exe).unwrap(), b"new app");
+        assert!(!tools.join("pdfcraft-cli").exists(), "a program that wasn't installed isn't added");
+        assert!(!dir.path().join("share").exists(), "no prefix, so no shared files");
+    }
+
+    #[cfg(unix)]
+    #[test]
+    fn a_standalone_update_without_the_app_binary_changes_nothing() {
+        let dir = tempfile::tempdir().unwrap();
+        let prefix = dir.path().join("local");
+        std::fs::create_dir_all(prefix.join("bin")).unwrap();
+        std::fs::create_dir_all(prefix.join("share/pdfcraft")).unwrap();
+        let exe = prefix.join("bin/pdfcraft");
+        std::fs::write(&exe, b"old app").unwrap();
+        std::fs::write(prefix.join("bin/pdfcraft-cli"), b"old cli").unwrap();
+        let tarball = dir.path().join("new.tar.gz");
+        release_tarball(&tarball, &[("bin/pdfcraft-cli", b"new cli")]);
+        let paths = Paths::under(&dir.path().join("data"));
+        assert!(replace_standalone(&crate::suite::PDFCRAFT, &paths, &tarball, &exe).is_err());
+        assert_eq!(std::fs::read(&exe).unwrap(), b"old app");
+        assert_eq!(std::fs::read(prefix.join("bin/pdfcraft-cli")).unwrap(), b"old cli");
+    }
+
+    #[cfg(unix)]
+    fn walk(dir: &Path) -> Vec<PathBuf> {
+        let mut out = Vec::new();
+        for e in std::fs::read_dir(dir).unwrap().flatten() {
+            let p = e.path();
+            if p.is_dir() {
+                out.extend(walk(&p));
+            } else {
+                out.push(p);
+            }
+        }
+        out
+    }
 }
