diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -316,8 +316,10 @@ dependencies = [
  "dunce",
  "flate2",
  "getrandom 0.3.4",
+ "libc",
  "log",
  "plist",
+ "rustix 1.1.5",
  "serde",
  "serde_json",
  "sha2",
diff --git a/apps/artcraft-launcher/src/main.rs b/apps/artcraft-launcher/src/main.rs
--- a/apps/artcraft-launcher/src/main.rs
+++ b/apps/artcraft-launcher/src/main.rs
@@ -34,6 +34,17 @@ const SETTINGS_KEY: &str = "settings";
 type Wake = Arc<OnceLock<Box<dyn Fn() + Send + Sync>>>;
 
 fn main() -> ExitCode {
+    // An elevated helper for the system installer: no window, logging or single-instance claim,
+    // and it must stay ahead of everything else here. Linux starts it through polkit; Windows
+    // through the one UAC prompt.
+    #[cfg(target_os = "linux")]
+    if let Some(code) = artcraft_launcher_native::install::root_helper::run_if_requested() {
+        return code;
+    }
+    #[cfg(windows)]
+    if let Some(code) = artcraft_launcher_native::install::windows_helper::run_if_requested() {
+        return code;
+    }
     // First, so the panic report and every start-up warning are recorded (`logging`).
     let logs = logging::install();
     logging::install_panic_hook();
diff --git a/crates/native/Cargo.toml b/crates/native/Cargo.toml
--- a/crates/native/Cargo.toml
+++ b/crates/native/Cargo.toml
@@ -38,6 +38,12 @@ flate2 = "1"
 flate2 = "1"
 tar = { version = "0.4", default-features = false }
 
+[target.'cfg(target_os = "linux")'.dependencies]
+# Safe SO_PEERCRED, so the launcher can prove the helper is the process it started.
+rustix = { version = "1.1", features = ["net"] }
+# O_NOFOLLOW for the root-owned staging copy.
+libc = "0.2"
+
 [target.'cfg(target_os = "macos")'.dependencies]
 plist = "1"
 
@@ -47,6 +53,6 @@ winreg = "0.55"
 dunce = "1"
 zip = { version = "8", default-features = false, features = ["deflate"] }
 # Safe wrappers for waiting for another process and reading a file's VERSIONINFO.
-winsafe = { version = "0.0.29", features = ["kernel", "version"] }
+winsafe = { version = "0.0.29", features = ["kernel", "version", "shell", "advapi"] }
 # Authenticode checks through WinTrust: the update helper installs only files from the launcher's publisher.
 verifysign = "1"
diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs
--- a/crates/native/src/install.rs
+++ b/crates/native/src/install.rs
@@ -16,6 +16,11 @@
 //!   (`<data>/apps/<app>`) and run inside the same sandbox with `HOME` pointed at a `home/`
 //!   folder beside it, so the app's settings stay in the sandbox too.
 //!
+//! The first system install or removal asks for permission — the polkit password prompt on
+//! Linux, the one UAC prompt on Windows — and starts a short-lived helper as the privileged
+//! user for the rest of the run (see `root_helper` and `windows_helper`), so updating several
+//! apps at once asks only once. FreeBSD keeps one `pkexec` per command.
+//!
 //! Every download is checked against its published SHA-256 before anything is unpacked, a
 //! running app is never replaced, and a new copy is fully in place before the old one goes.
 
@@ -30,6 +35,13 @@ use crate::manifest::Record;
 use crate::paths::Paths;
 use crate::process;
 
+#[cfg(target_os = "linux")]
+pub mod root_helper;
+#[cfg(windows)]
+pub mod windows_helper;
+#[cfg(any(windows, test))]
+pub(crate) mod windows_wire;
+
 /// What an install needs from the backend.
 pub struct Job<'a> {
     pub http: &'a Http,
@@ -90,7 +102,12 @@ pub fn install_at(
         return Err(format!("Cannot update {} to {}: installed version {} is newer", app.name, release.version_label(), inst_v));
     }
     log::info!("{}: starting {} to {} ({})", app.name, if existing.is_some() { "update" } else { "install" }, release.version_label(), kind.label());
-    let file = download(release, asset, job)?;
+    #[allow(unused_variables)]
+    let (file, sha) = download(release, asset, job)?;
+    // Linux stages a root-owned copy and checks it against this verified checksum; Windows
+    // carries it to the elevated helper. Other platforms don't need it past the download.
+    #[cfg(target_os = "linux")]
+    let _expected = root_helper::expect_hash(&file, &sha);
     log::info!("{}: download ready ({}), running installer...", app.name, file.display());
     // Past this point the install can't be interrupted halfway.
     (job.phase)(JobPhase::Installing);
@@ -103,7 +120,7 @@ pub fn install_at(
                 .map(|path| Some(record(&version, kind, path, Vec::new(), false)))
         }
         #[cfg(windows)]
-        PackageKind::Msi => windows::run_msi(&file, destination.as_deref(), job.stop, &mut *job.phase).map(|_| None),
+        PackageKind::Msi => windows::run_msi(&file, destination.as_deref(), job.stop, &mut *job.phase, Some(sha.as_str())).map(|_| None),
         #[cfg(windows)]
         PackageKind::PortableZip => {
             crate::portable::install(app, &file, &version, previous, job.paths, destination.as_deref(), job.replace, &mut |record| {
@@ -372,7 +389,7 @@ pub fn available_kinds() -> Vec<PackageKind> {
 /// Fetches `asset` into the download folder, checked against its published SHA-256: the feed's
 /// or GitHub's per-file digest, else the release's `SHA256SUMS.txt`. A release with neither is
 /// refused before anything is fetched: a file nothing vouches for is never installed (#90).
-pub(crate) fn download(release: &Release, asset: &Asset, job: &mut Job<'_>) -> Result<PathBuf, String> {
+pub(crate) fn download(release: &Release, asset: &Asset, job: &mut Job<'_>) -> Result<(PathBuf, String), String> {
     if !release::valid_file_name(&asset.name) {
         return Err("the release has an unexpected file name".into());
     }
@@ -393,7 +410,7 @@ pub(crate) fn download(release: &Release, asset: &Asset, job: &mut Job<'_>) -> R
         Transfer::Reconnecting { done, total } => phase(JobPhase::Reconnecting { done, total }),
     })?;
     (job.phase)(JobPhase::Verifying);
-    Ok(dest)
+    Ok((dest, sha))
 }
 
 /// The file's line in the release's `SHA256SUMS.txt`. `Ok(None)` only when the release really
@@ -720,7 +737,16 @@ fn pkexec() -> Option<PathBuf> {
 }
 
 /// Runs a command as root through polkit (`pkexec`), which shows the desktop's password prompt.
-#[cfg(any(target_os = "linux", target_os = "freebsd"))]
+/// The first one also starts the helper that later commands share, so the prompt is shown once
+/// for a run of installs and removals (see `root_helper`).
+#[cfg(target_os = "linux")]
+fn run_as_root(args: &[&std::ffi::OsStr]) -> Result<(), String> {
+    root_helper::run(args)
+}
+
+/// FreeBSD has no shared helper: its two root commands (`cp` and `rm`) are not package-manager
+/// calls worth a session, so each uses its own `pkexec` prompt.
+#[cfg(target_os = "freebsd")]
 fn run_as_root(args: &[&std::ffi::OsStr]) -> Result<(), String> {
     let args = with_absolute_program(&SYSTEM_BIN_DIRS, args)?;
     let pkexec = pkexec().ok_or("installing system-wide needs polkit (pkexec), which isn't installed")?;
@@ -779,10 +805,6 @@ pub(crate) mod linux {
         cancelled, find_in, pkexec, process, run_as_root, swap_into_place, tree, turn,
     };
 
-    /// apt waits this long for another package tool (a software centre, automatic updates) to
-    /// let go of its lock, instead of failing at once.
-    const APT_WAITS: &str = "DPkg::Lock::Timeout=60";
-
     /// A package tool that root commands can run, from the same directories `run_as_root` uses.
     fn system_tool(name: &str) -> Option<PathBuf> {
         find_in(&SYSTEM_BIN_DIRS, OsStr::new(name))
@@ -824,13 +846,14 @@ pub(crate) mod linux {
 
     /// The command that installs (or upgrades) a downloaded system package.
     pub fn install_command(kind: PackageKind) -> Option<&'static [&'static str]> {
+        use super::root_helper::{APT_INSTALL, DNF_INSTALL, PACMAN_INSTALL, RPM_INSTALL, ZYPPER_INSTALL};
         match kind {
             // apt resolves dependencies and records the package like any other.
-            PackageKind::Deb => Some(&["apt-get", "-o", APT_WAITS, "install", "-y", "--allow-downgrades"]),
-            PackageKind::Rpm if system_tool("dnf").is_some() => Some(&["dnf", "install", "-y"]),
-            PackageKind::Rpm if system_tool("zypper").is_some() => Some(&["zypper", "--non-interactive", "install", "--allow-unsigned-rpm"]),
-            PackageKind::Rpm => Some(&["rpm", "-U", "--replacepkgs"]),
-            PackageKind::ArchPkg => Some(&["pacman", "-U", "--noconfirm"]),
+            PackageKind::Deb => Some(APT_INSTALL),
+            PackageKind::Rpm if system_tool("dnf").is_some() => Some(DNF_INSTALL),
+            PackageKind::Rpm if system_tool("zypper").is_some() => Some(ZYPPER_INSTALL),
+            PackageKind::Rpm => Some(RPM_INSTALL),
+            PackageKind::ArchPkg => Some(PACMAN_INSTALL),
             _ => None,
         }
     }
@@ -955,19 +978,20 @@ pub(crate) mod linux {
     }
 
     pub fn remove_package(app: &CraftApp, installed: &Installed, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {
+        use super::root_helper::{APT_REMOVE, DNF_REMOVE, DPKG_REMOVE, FLATPAK_SYSTEM_UNINSTALL, PACMAN_REMOVE, RPM_REMOVE};
         let mut remove = |command: &[&str], package: &str| as_root(&SYSTEM, command, OsStr::new(package), stop, phase, JobPhase::Removing);
         match installed.source {
-            InstallSource::Deb if system_tool("apt-get").is_some() => remove(&["apt-get", "-o", APT_WAITS, "remove", "-y"], app.id),
-            InstallSource::Deb => remove(&["dpkg", "-r"], app.id),
+            InstallSource::Deb if system_tool("apt-get").is_some() => remove(APT_REMOVE, app.id),
+            InstallSource::Deb => remove(DPKG_REMOVE, app.id),
             InstallSource::Rpm => {
                 if system_tool("dnf").is_some() {
-                    remove(&["dnf", "remove", "-y"], app.id)
+                    remove(DNF_REMOVE, app.id)
                 } else {
-                    remove(&["rpm", "-e"], app.id)
+                    remove(RPM_REMOVE, app.id)
                 }
             }
             // The package detected (photocraft, photocraft-bin or photocraft-git).
-            InstallSource::Pacman => remove(&["pacman", "-R", "--noconfirm"], installed.package.as_deref().unwrap_or(app.id)),
+            InstallSource::Pacman => remove(PACMAN_REMOVE, installed.package.as_deref().unwrap_or(app.id)),
             InstallSource::Flatpak => {
                 let id = app.bundle_id();
                 let user = process::flatpak_command().args(["uninstall", "--user", "-y", "--noninteractive", &id]).status();
@@ -981,7 +1005,7 @@ pub(crate) mod linux {
                     }
                     return Err(format!("couldn't uninstall {id}"));
                 }
-                remove(&["flatpak", "uninstall", "--system", "-y", "--noninteractive"], &id)
+                remove(FLATPAK_SYSTEM_UNINSTALL, &id)
             }
             other => Err(format!("{} isn't a system package", other.label())),
         }
@@ -1679,13 +1703,14 @@ pub(crate) fn msi_path(msi: &Path) -> Result<PathBuf, String> {
 pub mod windows {
     use artcraft_launcher_core::protocol::CANCELLED;
 
-    use super::{CraftApp, INSTALLER, JobPhase, Path, PathBuf, Stop, cancelled, msi_command, msi_folder_argument, msi_log, process, turn};
+    use super::{CraftApp, INSTALLER, JobPhase, Path, PathBuf, Stop, cancelled, msi_command, turn};
 
-    fn msiexec() -> PathBuf {
+    /// The Windows Installer, which the elevated helper runs once permission has been granted.
+    pub(super) fn msiexec() -> PathBuf {
         std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from).join(r"System32\msiexec.exe")
     }
 
-    fn explain(code: Option<i32>) -> Result<(), String> {
+    pub(super) fn explain(code: Option<i32>) -> Result<(), String> {
         match code {
             Some(0 | 3010 | 1641) => Ok(()),
             Some(1602) => Err("cancelled".into()),
@@ -1696,20 +1721,17 @@ pub mod windows {
         }
     }
 
-    /// Runs an MSI with a progress window and no restart. Windows asks for permission itself.
-    pub fn run_msi(msi: &Path, destination: Option<&Path>, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {
-        in_turn(stop, phase, JobPhase::Installing, || {
-            use std::os::windows::process::CommandExt;
-            let mut command = msi_command(&msiexec(), msi)?;
-            if let Some(dir) = destination {
-                command.raw_arg(msi_folder_argument(dir)?);
-            }
-            let status = command.status().map_err(|e| format!("couldn't start the Windows Installer ({e})"))?;
-            explain(status.code()).map_err(|e| match status.code() {
-                Some(1602) | None => e,
-                _ => format!("{e}. Its log is at {}", msi_log(msi).display()),
-            })
-        })
+    /// Runs an MSI with a progress window and no restart. The first one asks for administrator
+    /// permission once; the helper keeps that permission for the rest of the run (see
+    /// [`super::windows_helper`]). `expected` is the checksum the download was verified against.
+    pub fn run_msi(
+        msi: &Path,
+        destination: Option<&Path>,
+        stop: &Stop,
+        phase: &mut dyn FnMut(JobPhase),
+        expected: Option<&str>,
+    ) -> Result<(), String> {
+        in_turn(stop, phase, JobPhase::Installing, || super::windows_helper::install(msi, destination, expected))
     }
 
     /// Runs the launcher's own new MSI for a self-update, with the same progress window and no
@@ -1732,11 +1754,7 @@ pub mod windows {
         }
         in_turn(stop, phase, JobPhase::Removing, || {
             for m in installs {
-                let status = process::command(msiexec())
-                    .args(["/x", &m.product_code, "/passive", "/norestart"])
-                    .status()
-                    .map_err(|e| format!("couldn't start the Windows Installer ({e})"))?;
-                explain(status.code())?;
+                super::windows_helper::uninstall(&m.product_code)?;
             }
             Ok(())
         })
diff --git a/crates/native/src/install/root_helper.rs b/crates/native/src/install/root_helper.rs
new file mode 100644
--- /dev/null
+++ b/crates/native/src/install/root_helper.rs
@@ -0,0 +1,798 @@
+//! One password prompt for a run of system installs.
+//!
+//! `pkexec` authenticates every call on its own, so updating several apps at once asked for the
+//! password once per app. The first call instead starts this program again as root, through a
+//! single `pkexec`, and keeps it running as a helper. Later installs and removals go through
+//! that helper over a private Unix socket, until it has been idle for fifteen minutes or the
+//! launcher exits.
+//!
+//! The socket is inside a root-visible, user-private temporary folder. The launcher checks the
+//! connecting process's pid against the helper it started, so another process of the same user
+//! cannot use the helper without its own permission prompt.
+//!
+//! The helper runs only a fixed set of package-manager commands. Install targets are copied to a
+//! root-owned staging folder first and checked against the release's SHA-256, and removal names
+//! are limited to Craft app packages. A package can still run its own maintainer scripts as root
+//! while it is installed; that is the point of installing it, and the user approved the install.
+
+use std::collections::HashMap;
+use std::ffi::{OsStr, OsString};
+use std::fs::OpenOptions;
+use std::io::{Read, Write};
+use std::os::unix::ffi::{OsStrExt, OsStringExt};
+use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::path::{Path, PathBuf};
+use std::process::{Child, ExitCode, Stdio};
+use std::sync::{LazyLock, Mutex, MutexGuard, PoisonError};
+use std::time::{Duration, Instant};
+
+use artcraft_launcher_core::SUITE;
+use artcraft_launcher_core::protocol::CANCELLED;
+
+use super::{SYSTEM_BIN_DIRS, find_in, pkexec, process, with_absolute_program};
+
+/// The argument that turns this program into the privileged helper, with the socket path.
+pub const FLAG: &str = "--artcraft-root-helper=";
+
+/// How long the helper waits for another install before it exits. Every request restarts it.
+const IDLE: Duration = Duration::from_secs(15 * 60);
+
+/// How long the desktop may take to collect the password before the first call gives up.
+const START_LIMIT: Duration = Duration::from_secs(5 * 60);
+
+/// The helper's first line once it is running as root with the desktop's permission. The number
+/// is the protocol version, so a launcher never talks to a helper from another build.
+const READY: &str = "READY 1\n";
+
+/// The helper's executable was replaced (a package upgrade) after this process started; the
+/// privileged helper cannot be started from it, so the caller falls back to a direct prompt.
+const REPLACED: &str = "this program was replaced on disk";
+
+const SOCKET_NAME: &str = "helper.sock";
+const MAX_TARGETS: usize = 64;
+const MAX_TARGET: usize = 4096;
+const MAX_PAYLOAD: usize = 1 << 20;
+const MAX_MESSAGE_CHARS: usize = 1000;
+const HASH_LENGTH: usize = 32;
+
+// Every command the helper may run, with the fixed arguments before the targets. The
+// launcher's package commands are these same constants, so the two can't drift apart.
+// `DPkg::Lock::Timeout=60` makes apt wait for another package tool (a software centre,
+// automatic updates) to let go of its lock instead of failing at once.
+pub const APT_INSTALL: &[&str] = &["apt-get", "-o", "DPkg::Lock::Timeout=60", "install", "-y", "--allow-downgrades"];
+pub const APT_REMOVE: &[&str] = &["apt-get", "-o", "DPkg::Lock::Timeout=60", "remove", "-y"];
+pub const DPKG_REMOVE: &[&str] = &["dpkg", "-r"];
+pub const DNF_INSTALL: &[&str] = &["dnf", "install", "-y"];
+pub const DNF_REMOVE: &[&str] = &["dnf", "remove", "-y"];
+pub const ZYPPER_INSTALL: &[&str] = &["zypper", "--non-interactive", "install", "--allow-unsigned-rpm"];
+pub const RPM_INSTALL: &[&str] = &["rpm", "-U", "--replacepkgs"];
+pub const RPM_REMOVE: &[&str] = &["rpm", "-e"];
+pub const PACMAN_INSTALL: &[&str] = &["pacman", "-U", "--noconfirm"];
+pub const PACMAN_REMOVE: &[&str] = &["pacman", "-R", "--noconfirm"];
+pub const FLATPAK_SYSTEM_UNINSTALL: &[&str] = &["flatpak", "uninstall", "--system", "-y", "--noninteractive"];
+
+/// A command the helper may run.
+#[repr(u8)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum RootCommand {
+    AptInstall = 0,
+    AptRemove = 1,
+    DpkgRemove = 2,
+    DnfInstall = 3,
+    DnfRemove = 4,
+    ZypperInstall = 5,
+    RpmInstall = 6,
+    RpmRemove = 7,
+    PacmanInstall = 8,
+    PacmanRemove = 9,
+    FlatpakSystemUninstall = 10,
+}
+
+impl RootCommand {
+    const ALL: [Self; 11] = [
+        Self::AptInstall,
+        Self::AptRemove,
+        Self::DpkgRemove,
+        Self::DnfInstall,
+        Self::DnfRemove,
+        Self::ZypperInstall,
+        Self::RpmInstall,
+        Self::RpmRemove,
+        Self::PacmanInstall,
+        Self::PacmanRemove,
+        Self::FlatpakSystemUninstall,
+    ];
+
+    fn argv(self) -> &'static [&'static str] {
+        match self {
+            Self::AptInstall => APT_INSTALL,
+            Self::AptRemove => APT_REMOVE,
+            Self::DpkgRemove => DPKG_REMOVE,
+            Self::DnfInstall => DNF_INSTALL,
+            Self::DnfRemove => DNF_REMOVE,
+            Self::ZypperInstall => ZYPPER_INSTALL,
+            Self::RpmInstall => RPM_INSTALL,
+            Self::RpmRemove => RPM_REMOVE,
+            Self::PacmanInstall => PACMAN_INSTALL,
+            Self::PacmanRemove => PACMAN_REMOVE,
+            Self::FlatpakSystemUninstall => FLATPAK_SYSTEM_UNINSTALL,
+        }
+    }
+
+    fn targets(self) -> Targets {
+        match self {
+            Self::AptInstall | Self::DnfInstall | Self::ZypperInstall | Self::RpmInstall | Self::PacmanInstall => Targets::Files,
+            Self::AptRemove | Self::DpkgRemove | Self::DnfRemove | Self::RpmRemove | Self::PacmanRemove | Self::FlatpakSystemUninstall => {
+                Targets::Names
+            }
+        }
+    }
+
+    fn from_index(index: u8) -> Option<Self> {
+        Self::ALL.get(usize::from(index)).copied()
+    }
+
+    /// The command whose fixed arguments start `args`, with the rest as its targets. The
+    /// longest match wins, so no shorter command can claim a longer one's call.
+    fn of<'a>(args: &'a [&'a OsStr]) -> Option<(Self, Vec<&'a OsStr>)> {
+        let mut found: Option<(Self, Vec<&OsStr>)> = None;
+        for command in Self::ALL {
+            let argv = command.argv();
+            if args.len() <= argv.len() {
+                continue;
+            }
+            if argv.iter().zip(args).all(|(expected, actual)| actual.as_bytes() == expected.as_bytes())
+                && found.as_ref().is_none_or(|(best, _)| argv.len() > best.argv().len())
+            {
+                found = Some((command, args[argv.len()..].to_vec()));
+            }
+        }
+        found
+    }
+}
+
+/// What a command puts after its fixed arguments.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum Targets {
+    /// Downloaded package files.
+    Files,
+    /// Package names.
+    Names,
+}
+
+/// Runs the privileged helper when this program was started as one. The launcher calls this
+/// before it sets anything else up; every other start has no [`FLAG`] and returns `None`.
+pub fn run_if_requested() -> Option<ExitCode> {
+    let socket = std::env::args_os().find_map(|arg| arg.to_str().and_then(|text| text.strip_prefix(FLAG)).map(PathBuf::from))?;
+    Some(ExitCode::from(serve(&socket)))
+}
+
+/// The checksum the launcher verified `path` against before installing it. Dropping the guard
+/// forgets it again.
+pub(crate) struct ExpectedHash {
+    path: PathBuf,
+}
+
+static EXPECTED: LazyLock<Mutex<HashMap<PathBuf, String>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
+
+/// Remembers the release checksum for `path` until the returned guard is dropped.
+pub(crate) fn expect_hash(path: &Path, sha: &str) -> ExpectedHash {
+    if let Ok(mut expected) = EXPECTED.lock() {
+        expected.insert(path.to_path_buf(), sha.to_string());
+    }
+    ExpectedHash { path: path.to_path_buf() }
+}
+
+impl Drop for ExpectedHash {
+    fn drop(&mut self) {
+        if let Ok(mut expected) = EXPECTED.lock() {
+            expected.remove(&self.path);
+        }
+    }
+}
+
+fn expected_hash(target: &OsStr) -> Option<[u8; HASH_LENGTH]> {
+    let expected = EXPECTED.lock().ok()?;
+    parse_hash(expected.get(Path::new(target))?)
+}
+
+/// The helper's side: connect to the waiting launcher and serve requests until the launcher goes
+/// away or nothing has been asked for a while.
+fn serve(socket: &Path) -> u8 {
+    // `pkexec` sets this to the user who approved the prompt. Without it this wasn't started
+    // by a permission prompt, so it is not allowed to install anything.
+    let Some(uid) = std::env::var("PKEXEC_UID").ok().and_then(|uid| uid.parse::<u32>().ok()) else {
+        return 1;
+    };
+    let Ok(mut stream) = UnixStream::connect(socket) else { return 1 };
+    let _ = stream.set_write_timeout(Some(Duration::from_secs(60)));
+    if stream.write_all(READY.as_bytes()).and_then(|()| stream.flush()).is_err() {
+        return 1;
+    }
+    let _ = stream.set_read_timeout(Some(IDLE));
+    loop {
+        let request = match read_request(&mut stream) {
+            Ok(request) => request,
+            Err(()) => return 0,
+        };
+        let outcome = execute(request, uid);
+        if write_response(&mut stream, &outcome).is_err() {
+            return 1;
+        }
+    }
+}
+
+struct Request {
+    command: RootCommand,
+    targets: Vec<(OsString, Option<[u8; HASH_LENGTH]>)>,
+}
+
+fn read_request(input: &mut impl Read) -> Result<Request, ()> {
+    let mut header = [0u8; 4];
+    input.read_exact(&mut header).map_err(|_| ())?;
+    let length = u32::from_be_bytes(header) as usize;
+    if length == 0 || length > MAX_PAYLOAD {
+        return Err(());
+    }
+    let mut payload = vec![0u8; length];
+    input.read_exact(&mut payload).map_err(|_| ())?;
+    decode(&payload).map_err(|_| ())
+}
+
+fn decode(payload: &[u8]) -> Result<Request, String> {
+    if payload.len() > MAX_PAYLOAD {
+        return Err("the install request is too large".into());
+    }
+    let (&index, rest) = payload.split_first().ok_or("empty request")?;
+    let command = RootCommand::from_index(index).ok_or("unknown command")?;
+    let mut at = 0;
+    let count = take_u32(rest, &mut at)?;
+    if count == 0 || count > MAX_TARGETS as u32 {
+        return Err("wrong number of targets".into());
+    }
+    let mut targets = Vec::with_capacity(count as usize);
+    for _ in 0..count {
+        let length = take_u32(rest, &mut at)? as usize;
+        if length == 0 || length > MAX_TARGET {
+            return Err("invalid target".into());
+        }
+        let end = at.checked_add(length).ok_or("truncated request")?;
+        let bytes = rest.get(at..end).ok_or("truncated request")?;
+        at = end;
+        let flag = *rest.get(at).ok_or("truncated request")?;
+        at += 1;
+        let expected = match flag {
+            0 => None,
+            1 => {
+                let end = at.checked_add(HASH_LENGTH).ok_or("truncated checksum")?;
+                let bytes = rest.get(at..end).ok_or("truncated checksum")?;
+                at = end;
+                Some(<[u8; HASH_LENGTH]>::try_from(bytes).map_err(|_| "truncated checksum")?)
+            }
+            _ => return Err("unknown checksum flag".into()),
+        };
+        targets.push((OsString::from_vec(bytes.to_vec()), expected));
+    }
+    if at != rest.len() {
+        return Err("trailing request data".into());
+    }
+    Ok(Request { command, targets })
+}
+
+fn take_u32(bytes: &[u8], at: &mut usize) -> Result<u32, String> {
+    let end = at.checked_add(4).ok_or("truncated request")?;
+    let part = bytes.get(*at..end).ok_or("truncated request")?;
+    let value = <[u8; 4]>::try_from(part).map_err(|_| "truncated request")?;
+    *at = end;
+    Ok(u32::from_be_bytes(value))
+}
+
+fn execute(request: Request, uid: u32) -> Result<(), String> {
+    validate(request.command, &request.targets, Some(uid))?;
+    let argv = request.command.argv();
+    let program = find_in(&SYSTEM_BIN_DIRS, OsStr::new(argv[0])).ok_or_else(|| format!("{} isn't installed", argv[0]))?;
+    let (targets, _staging) = stage_targets(request.command, &request.targets, uid)?;
+    let status = process::command(program)
+        .args(&argv[1..])
+        .args(&targets)
+        .stdin(Stdio::null())
+        .stdout(Stdio::null())
+        .stderr(Stdio::null())
+        .status()
+        .map_err(|e| format!("couldn't start {} ({e})", argv[0]))?;
+    match status.code() {
+        Some(0) => Ok(()),
+        Some(code) => Err(format!("the system installer stopped with error {code}")),
+        None => Err("the system installer was interrupted".into()),
+    }
+}
+
+/// Copies the packages the installer is about to read into a root-owned folder, checking each
+/// copy against the release checksum the launcher verified. The package manager then reads a
+/// file no other process can change while it runs; names are passed through unchanged.
+fn stage_targets(
+    command: RootCommand,
+    targets: &[(OsString, Option<[u8; HASH_LENGTH]>)],
+    uid: u32,
+) -> Result<(Vec<OsString>, Option<tempfile::TempDir>), String> {
+    if command.targets() != Targets::Files {
+        return Ok((targets.iter().map(|(target, _)| target.clone()).collect(), None));
+    }
+    let staging = tempfile::Builder::new().prefix("artcraft-pkg-").tempdir().map_err(|error| format!("couldn't prepare the package ({error})"))?;
+    let mut copies = Vec::with_capacity(targets.len());
+    for (index, (target, expected)) in targets.iter().enumerate() {
+        let source_path = Path::new(target);
+        let mut source = OpenOptions::new()
+            .read(true)
+            .custom_flags(libc::O_NOFOLLOW)
+            .open(source_path)
+            .map_err(|error| format!("couldn't read {} ({error})", source_path.display()))?;
+        let metadata = source.metadata().map_err(|error| format!("couldn't read {} ({error})", source_path.display()))?;
+        if !metadata.file_type().is_file() || metadata.uid() != uid {
+            return Err(format!("{} isn't a downloaded package of the user who approved the prompt", source_path.display()));
+        }
+        let name = source_path.file_name().ok_or_else(|| format!("{} isn't a package file", source_path.display()))?;
+        let mut copy_name = OsString::from(format!("{index}-"));
+        copy_name.push(name);
+        let copy_path = staging.path().join(copy_name);
+        let mut copy = OpenOptions::new()
+            .write(true)
+            .create_new(true)
+            .open(&copy_path)
+            .map_err(|error| format!("couldn't prepare {} ({error})", copy_path.display()))?;
+        std::io::copy(&mut source, &mut copy).map_err(|error| format!("couldn't copy {} ({error})", source_path.display()))?;
+        copy.flush().map_err(|error| format!("couldn't save {} ({error})", copy_path.display()))?;
+        drop(copy);
+        if let Some(expected) = expected {
+            let actual = crate::http::sha256_file(&copy_path)?;
+            if actual != crate::http::hex(expected) {
+                return Err(format!("{} doesn't match the release checksum", source_path.display()));
+            }
+        }
+        copies.push(copy_path.into_os_string());
+    }
+    Ok((copies, Some(staging)))
+}
+
+fn write_response(output: &mut impl Write, outcome: &Result<(), String>) -> std::io::Result<()> {
+    let (status, message) = match outcome {
+        Ok(()) => (0u8, String::new()),
+        Err(error) => (1u8, error.chars().take(MAX_MESSAGE_CHARS).collect()),
+    };
+    let message = message.as_bytes();
+    let length = u32::try_from(message.len()).unwrap_or(u32::MAX).to_be_bytes();
+    output.write_all(&[status])?;
+    output.write_all(&length)?;
+    output.write_all(message)?;
+    output.flush()
+}
+
+/// Whether `targets` is something `command` may run, checked without root before a prompt is
+/// shown. The helper repeats the check with the file owner's user id.
+fn validate(command: RootCommand, targets: &[(OsString, Option<[u8; HASH_LENGTH]>)], uid: Option<u32>) -> Result<(), String> {
+    if targets.is_empty() || targets.len() > MAX_TARGETS {
+        return Err("the install request has the wrong number of files".into());
+    }
+    for (target, _) in targets {
+        match command.targets() {
+            Targets::Files => check_file(target, uid)?,
+            Targets::Names => check_name(target)?,
+        }
+    }
+    Ok(())
+}
+
+fn check_file(target: &OsStr, uid: Option<u32>) -> Result<(), String> {
+    let path = Path::new(target);
+    if !path.is_absolute() {
+        return Err("a package to install must be an absolute path".into());
+    }
+    let metadata = std::fs::symlink_metadata(path).map_err(|_| format!("{} can't be installed", path.display()))?;
+    if !metadata.file_type().is_file() || uid.is_some_and(|uid| metadata.uid() != uid) {
+        return Err(format!("{} isn't a downloaded package of the user who approved the prompt", path.display()));
+    }
+    Ok(())
+}
+
+fn check_name(target: &OsStr) -> Result<(), String> {
+    let Some(name) = target.to_str() else {
+        return Err("a package name must be text".into());
+    };
+    let known = SUITE.iter().any(|app| {
+        std::iter::once(app.id)
+            .chain(app.former_ids.iter().copied())
+            .any(|id| name == id || name == format!("{id}-bin") || name == format!("{id}-git") || name == format!("ai.storyteller.{id}"))
+    });
+    if known { Ok(()) } else { Err(format!("{name} isn't a Craft app package")) }
+}
+
+fn parse_hash(text: &str) -> Option<[u8; HASH_LENGTH]> {
+    if text.len() != HASH_LENGTH * 2 {
+        return None;
+    }
+    let mut hash = [0u8; HASH_LENGTH];
+    for (index, pair) in text.as_bytes().as_chunks::<2>().0.iter().enumerate() {
+        hash[index] = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?;
+    }
+    Some(hash)
+}
+
+/// The launcher's side of the helper.
+struct RootSession {
+    child: Child,
+    stream: UnixStream,
+    _socket_dir: tempfile::TempDir,
+}
+
+/// The helper of this launcher, when it has one.
+static SESSION: Mutex<Option<RootSession>> = Mutex::new(None);
+
+fn lock() -> MutexGuard<'static, Option<RootSession>> {
+    SESSION.lock().unwrap_or_else(PoisonError::into_inner)
+}
+
+/// Runs a command that needs root. A command the helper knows is sent to it, starting it (and
+/// with it the desktop's one password prompt) if needed; anything else goes through `pkexec`
+/// on its own, as before.
+pub fn run(args: &[&OsStr]) -> Result<(), String> {
+    let Some((command, targets)) = RootCommand::of(args) else {
+        return direct(args);
+    };
+    let hashes: Vec<Option<[u8; HASH_LENGTH]>> =
+        if command.targets() == Targets::Files { targets.iter().map(|target| expected_hash(target)).collect() } else { vec![None; targets.len()] };
+    let with_hashes: Vec<(OsString, Option<[u8; HASH_LENGTH]>)> =
+        targets.iter().map(|target| target.to_os_string()).zip(hashes.iter().copied()).collect();
+    validate(command, &with_hashes, None)?;
+    // An AppImage's own binary lives in a temporary mount, and inside a Flatpak sandbox root
+    // means the sandbox's own root: neither is a place to keep a privileged helper. Those keep
+    // the per-install prompt.
+    if std::env::var_os("APPIMAGE").is_some() || process::in_sandbox() {
+        return direct(args);
+    }
+    let mut session = lock();
+    if session.as_mut().is_some_and(|session| session.finished()) {
+        *session = None;
+    }
+    if session.is_none() {
+        match RootSession::start() {
+            Ok(started) => *session = Some(started),
+            Err(error) if error == REPLACED => return direct(args),
+            Err(error) => return Err(error),
+        }
+    }
+    let exchanged = match session.as_mut() {
+        Some(active) => active.exchange(command, &targets, &hashes),
+        None => Exchange::Broken("the elevated install session wasn't available; try again".into()),
+    };
+    match exchanged {
+        Exchange::Installed(outcome) => outcome,
+        Exchange::Broken(error) => {
+            *session = None;
+            Err(error)
+        }
+    }
+}
+
+/// What one exchange with the helper says.
+enum Exchange {
+    /// The helper answered: the command's own outcome. The helper is still usable.
+    Installed(Result<(), String>),
+    /// The helper could not be reached. It is dropped; the next call asks for permission again.
+    Broken(String),
+}
+
+impl RootSession {
+    /// Starts the helper through polkit. `Err("cancelled")` when the prompt was dismissed.
+    fn start() -> Result<Self, String> {
+        let executable = std::env::current_exe().map_err(|e| format!("couldn't find this program ({e})"))?;
+        if !executable.is_file() {
+            return Err(REPLACED.into());
+        }
+        let pkexec = pkexec().ok_or_else(|| "installing system-wide needs polkit (pkexec), which isn't installed".to_string())?;
+        let socket_dir =
+            tempfile::Builder::new().prefix("artcraft-helper-").tempdir().map_err(|e| format!("couldn't prepare the permission request ({e})"))?;
+        let socket = socket_dir.path().join(SOCKET_NAME);
+        let listener = UnixListener::bind(&socket).map_err(|e| format!("couldn't prepare the permission request ({e})"))?;
+        listener.set_nonblocking(true).map_err(|e| format!("couldn't prepare the permission request ({e})"))?;
+        let socket_text = socket.to_str().ok_or_else(|| "the permission socket path isn't text".to_string())?;
+        let mut child = process::command(pkexec)
+            .arg(&executable)
+            .arg(format!("{FLAG}{socket_text}"))
+            .stdin(Stdio::null())
+            .stdout(Stdio::null())
+            .stderr(Stdio::null())
+            .spawn()
+            .map_err(|e| format!("couldn't ask for permission ({e})"))?;
+        let deadline = Instant::now() + START_LIMIT;
+        loop {
+            if let Ok(Some(status)) = child.try_wait() {
+                return Err(start_failed_code(status.code()));
+            }
+            match listener.accept() {
+                Ok((mut stream, _)) => {
+                    let pid = i32::try_from(child.id()).ok();
+                    let peer = match rustix::net::sockopt::socket_peercred(&stream) {
+                        Ok(peer) => peer,
+                        Err(error) => {
+                            let reason = format!("couldn't verify the permission helper ({error})");
+                            return Err(end(&mut child, reason));
+                        }
+                    };
+                    if Some(peer.pid.as_raw_pid()) != pid {
+                        continue;
+                    }
+                    let _ = stream.set_nonblocking(false);
+                    let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
+                    let mut ready = Vec::new();
+                    if read_line(&mut stream, &mut ready).is_err() || ready != READY.trim_end().as_bytes() {
+                        return Err(end(&mut child, "the permission helper didn't start".into()));
+                    }
+                    let _ = stream.set_read_timeout(None);
+                    let _ = stream.set_write_timeout(Some(Duration::from_secs(300)));
+                    return Ok(Self { child, stream, _socket_dir: socket_dir });
+                }
+                Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
+                    if Instant::now() >= deadline {
+                        return Err(end(&mut child, "the permission prompt wasn't answered".into()));
+                    }
+                    std::thread::sleep(Duration::from_millis(50));
+                }
+                Err(error) => return Err(end(&mut child, format!("couldn't ask for permission ({error})"))),
+            }
+        }
+    }
+
+    fn finished(&mut self) -> bool {
+        !matches!(self.child.try_wait(), Ok(None))
+    }
+
+    fn exchange(&mut self, command: RootCommand, targets: &[&OsStr], hashes: &[Option<[u8; HASH_LENGTH]>]) -> Exchange {
+        let payload = match encode(command, targets, hashes) {
+            Ok(payload) => payload,
+            Err(()) => return Exchange::Broken("the install request is too large".into()),
+        };
+        let length = u32::try_from(payload.len()).unwrap_or(u32::MAX).to_be_bytes();
+        let written = self.stream.write_all(&length).and_then(|()| self.stream.write_all(&payload)).and_then(|()| self.stream.flush());
+        if let Err(e) = written {
+            return Exchange::Broken(gone(e));
+        }
+        let mut status = [0u8; 1];
+        if let Err(e) = self.stream.read_exact(&mut status) {
+            return Exchange::Broken(gone(e));
+        }
+        let mut length = [0u8; 4];
+        if let Err(e) = self.stream.read_exact(&mut length) {
+            return Exchange::Broken(gone(e));
+        }
+        let size = u32::from_be_bytes(length) as usize;
+        if size > MAX_PAYLOAD {
+            return Exchange::Broken("the elevated install session sent an invalid answer; try again".into());
+        }
+        let mut message = vec![0u8; size];
+        if let Err(e) = self.stream.read_exact(&mut message) {
+            return Exchange::Broken(gone(e));
+        }
+        match status[0] {
+            0 => Exchange::Installed(Ok(())),
+            1 => Exchange::Installed(Err(String::from_utf8_lossy(&message).into_owned())),
+            _ => Exchange::Broken("the elevated install session sent an invalid answer; try again".into()),
+        }
+    }
+}
+
+/// A session that stopped answering has no useful answers left; end it. A healthy helper is
+/// never dropped: it sees each command through and exits once its socket closes or it is idle.
+impl Drop for RootSession {
+    fn drop(&mut self) {
+        let _ = self.child.kill();
+        let _ = self.child.wait();
+    }
+}
+
+fn read_line(stream: &mut UnixStream, out: &mut Vec<u8>) -> std::io::Result<()> {
+    let mut byte = [0u8; 1];
+    while out.len() < 256 {
+        match stream.read(&mut byte) {
+            Ok(0) => return Ok(()),
+            Ok(_) => match byte[0] {
+                b'\n' => return Ok(()),
+                b'\r' => {}
+                other => out.push(other),
+            },
+            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
+            Err(error) => return Err(error),
+        }
+    }
+    Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "handshake line too long"))
+}
+
+fn gone(error: std::io::Error) -> String {
+    format!("the elevated install session ended ({error}); try again")
+}
+
+/// Stops a helper that will not be used and reports why.
+fn end(child: &mut Child, reason: String) -> String {
+    let _ = child.kill();
+    let _ = child.wait();
+    reason
+}
+
+fn start_failed_code(code: Option<i32>) -> String {
+    match code {
+        Some(126 | 127) => CANCELLED.to_string(),
+        Some(code) => format!("couldn't ask for permission (exit {code})"),
+        None => "couldn't ask for permission (interrupted)".into(),
+    }
+}
+
+/// The helper's request: the command number, then every target with an optional release hash.
+fn encode(command: RootCommand, targets: &[&OsStr], hashes: &[Option<[u8; HASH_LENGTH]>]) -> Result<Vec<u8>, ()> {
+    if targets.is_empty() || targets.len() > MAX_TARGETS || hashes.len() != targets.len() {
+        return Err(());
+    }
+    let count = u32::try_from(targets.len()).map_err(|_| ())?;
+    let mut payload = Vec::new();
+    payload.push(command as u8);
+    payload.extend_from_slice(&count.to_be_bytes());
+    for (target, expected) in targets.iter().zip(hashes) {
+        let bytes = target.as_bytes();
+        if bytes.is_empty() || bytes.len() > MAX_TARGET {
+            return Err(());
+        }
+        payload.extend_from_slice(&u32::try_from(bytes.len()).map_err(|_| ())?.to_be_bytes());
+        payload.extend_from_slice(bytes);
+        match expected {
+            Some(hash) => {
+                payload.push(1);
+                payload.extend_from_slice(hash);
+            }
+            None => payload.push(0),
+        }
+    }
+    if payload.len() > MAX_PAYLOAD { Err(()) } else { Ok(payload) }
+}
+
+/// A command that needs root, run on its own through `pkexec` (used where the helper can't).
+fn direct(args: &[&OsStr]) -> Result<(), String> {
+    let args = with_absolute_program(&SYSTEM_BIN_DIRS, args)?;
+    let pkexec = pkexec().ok_or("installing system-wide needs polkit (pkexec), which isn't installed")?;
+    let status = process::command(pkexec).args(&args).status().map_err(|e| format!("couldn't ask for permission ({e})"))?;
+    super::root_outcome(status.code())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn os(value: &str) -> OsString {
+        OsString::from(value)
+    }
+
+    #[test]
+    fn the_package_commands_are_the_ones_the_launcher_builds() {
+        use artcraft_launcher_core::PackageKind;
+        assert_eq!(crate::install::linux::install_command(PackageKind::Deb), Some(APT_INSTALL));
+        for command in RootCommand::ALL {
+            let argv = command.argv();
+            let mut args: Vec<&OsStr> = argv.iter().map(OsStr::new).collect();
+            args.push(OsStr::new("/downloads/app.deb"));
+            let (found, targets) = RootCommand::of(&args).expect("every command is recognized");
+            assert_eq!(found, command);
+            assert_eq!(targets, vec![OsStr::new("/downloads/app.deb")]);
+        }
+    }
+
+    #[test]
+    fn the_longest_command_claims_the_call() {
+        let mut args: Vec<&OsStr> = DNF_INSTALL.iter().map(OsStr::new).collect();
+        args.push(OsStr::new("/downloads/app.rpm"));
+        assert_eq!(RootCommand::of(&args).map(|(command, _)| command), Some(RootCommand::DnfInstall));
+    }
+
+    #[test]
+    fn a_call_without_targets_is_not_shared() {
+        let args: Vec<&OsStr> = APT_INSTALL.iter().map(OsStr::new).collect();
+        assert_eq!(RootCommand::of(&args), None);
+    }
+
+    #[test]
+    fn an_unknown_command_is_not_shared() {
+        let args = [OsStr::new("/bin/sh"), OsStr::new("-c"), OsStr::new("id")];
+        assert_eq!(RootCommand::of(&args), None);
+    }
+
+    #[test]
+    fn requests_round_trip() {
+        let targets = [os("/downloads/a.deb"), os("/downloads/b.deb")];
+        let borrowed: Vec<&OsStr> = targets.iter().map(OsString::as_os_str).collect();
+        let hashes = [Some([0x11; HASH_LENGTH]), None];
+        let payload = encode(RootCommand::AptInstall, &borrowed, &hashes).expect("a small request");
+        let request = decode(&payload).expect("the helper reads it back");
+        assert_eq!(request.command, RootCommand::AptInstall);
+        assert_eq!(request.targets, vec![(targets[0].clone(), hashes[0]), (targets[1].clone(), hashes[1])]);
+    }
+
+    #[test]
+    fn malformed_requests_are_refused() {
+        assert!(decode(&[]).is_err());
+        assert!(decode(&[99]).is_err());
+        assert!(decode(&[0, 0, 0, 0, 0]).is_err(), "no targets");
+        // A target that claims to be longer than the request.
+        assert!(decode(&[0, 0, 0, 0, 1, 0, 0, 0, 9, b'a']).is_err());
+        // Data after the last target.
+        let mut payload = encode(RootCommand::AptRemove, &[OsStr::new("photocraft")], &[None]).expect("a small request");
+        payload.push(0);
+        assert!(decode(&payload).is_err());
+        // An unknown checksum flag.
+        let mut payload = encode(RootCommand::AptInstall, &[OsStr::new("/tmp/a.deb")], &[None]).expect("a small request");
+        let last = payload.len() - 1;
+        payload[last] = 2;
+        assert!(decode(&payload).is_err());
+    }
+
+    #[test]
+    fn oversized_requests_are_refused() {
+        let big = "a".repeat(MAX_TARGET + 1);
+        assert!(encode(RootCommand::AptInstall, &[OsStr::new(&big)], &[None]).is_err());
+        assert!(encode(RootCommand::AptInstall, &[], &[]).is_err());
+        assert!(encode(RootCommand::AptInstall, &[OsStr::new("/tmp/a.deb")], &[]).is_err());
+    }
+
+    #[test]
+    fn package_files_must_be_absolute_and_exist() {
+        assert!(check_file(OsStr::new("relative.deb"), None).is_err());
+        assert!(check_file(OsStr::new("/no/such/package.deb"), None).is_err());
+        let dir = tempfile::tempdir().unwrap();
+        let file = dir.path().join("app.deb");
+        std::fs::write(&file, b"package").unwrap();
+        assert_eq!(check_file(file.as_os_str(), None), Ok(()));
+        // The helper only trusts the user who approved the prompt.
+        assert!(check_file(file.as_os_str(), Some(u32::MAX)).is_err());
+    }
+
+    #[test]
+    fn package_names_are_craft_packages() {
+        for name in ["photocraft", "photocraft-bin", "photocraft-git", "ai.storyteller.photocraft", "printcraft"] {
+            assert_eq!(check_name(OsStr::new(name)), Ok(()), "{name}");
+        }
+        for name in ["", "--option", "a/b", "a b", "a\nb", "gcc@12"] {
+            assert!(check_name(OsStr::new(name)).is_err(), "{name}");
+        }
+    }
+
+    #[test]
+    fn removal_commands_take_names_and_installs_take_files() {
+        assert_eq!(RootCommand::AptInstall.targets(), Targets::Files);
+        assert_eq!(RootCommand::PacmanInstall.targets(), Targets::Files);
+        assert_eq!(RootCommand::AptRemove.targets(), Targets::Names);
+        assert_eq!(RootCommand::FlatpakSystemUninstall.targets(), Targets::Names);
+    }
+
+    #[test]
+    fn checksums_are_read_from_hex() {
+        let text = "5a".repeat(HASH_LENGTH);
+        assert_eq!(parse_hash(&text), Some([0x5a; HASH_LENGTH]));
+        assert_eq!(parse_hash(&text[..text.len() - 1]), None);
+        assert_eq!(parse_hash(&"zz".repeat(HASH_LENGTH)), None);
+    }
+
+    #[test]
+    fn packages_are_copied_and_checked_before_install() {
+        use std::os::unix::fs::MetadataExt;
+        let dir = tempfile::tempdir().unwrap();
+        let file = dir.path().join("app.deb");
+        std::fs::write(&file, b"package").unwrap();
+        let uid = std::fs::metadata(&file).unwrap().uid();
+        let expected = crate::http::sha256_file(&file).unwrap();
+        let hash = parse_hash(&expected).unwrap();
+        let targets = vec![(file.as_os_str().to_os_string(), Some(hash))];
+        let (copies, staging) = stage_targets(RootCommand::AptInstall, &targets, uid).unwrap();
+        assert_eq!(copies.len(), 1);
+        assert_ne!(Path::new(&copies[0]).parent(), Some(dir.path()));
+        assert_eq!(std::fs::read(&copies[0]).unwrap(), b"package");
+        drop(staging);
+        assert!(!Path::new(&copies[0]).exists(), "the staging copy is removed with the staging folder");
+        assert!(stage_targets(RootCommand::AptInstall, &[(file.as_os_str().to_os_string(), Some([0; HASH_LENGTH]))], uid).is_err());
+    }
+}
diff --git a/crates/native/src/install/windows_helper.rs b/crates/native/src/install/windows_helper.rs
new file mode 100644
--- /dev/null
+++ b/crates/native/src/install/windows_helper.rs
@@ -0,0 +1,553 @@
+//! One UAC prompt for a run of Windows Installer packages.
+//!
+//! The Craft apps' MSIs are per-machine, so Windows asks for administrator permission for each
+//! one. The first install instead starts this program as an elevated helper through a single
+//! `runas` prompt, and later installs and removals are sent to it over a loopback connection.
+//! The helper stays alive until it has been idle for fifteen minutes or the launcher exits.
+//!
+//! The package bytes are streamed to the helper and checked against their SHA-256, so the
+//! elevated process never needs to read the launcher's cache, and the package cannot change
+//! between the launcher's download check and the installer. The helper accepts only installs of
+//! streamed MSIs and removals of product codes; nothing it receives can run another program.
+
+use std::io::{Read, Write};
+use std::net::{SocketAddr, TcpListener, TcpStream};
+use std::path::{Path, PathBuf};
+use std::process::ExitCode;
+use std::sync::{Mutex, MutexGuard, PoisonError};
+use std::time::{Duration, Instant};
+
+use artcraft_launcher_core::protocol::CANCELLED;
+use sha2::{Digest, Sha256};
+
+use super::windows_wire::{self, Request};
+
+/// The launcher passes this to the helper it starts elevated.
+const FLAG: &str = "--artcraft-elevated-helper=";
+
+/// How long the helper waits for another install before it exits.
+const IDLE: Duration = Duration::from_secs(15 * 60);
+
+/// How long the launcher waits for the permission helper to connect after UAC has been answered.
+const CONNECT_LIMIT: Duration = Duration::from_secs(60);
+
+const MAX_MESSAGE: usize = 4 << 10;
+const HASH_LENGTH: usize = 32;
+
+/// Runs the elevated helper when this program was started as one. The launcher calls this before
+/// it sets anything else up; every other start has no [`FLAG`] and returns `None`.
+pub fn run_if_requested() -> Option<ExitCode> {
+    let value = std::env::args_os().find_map(|arg| arg.to_str().and_then(|text| text.strip_prefix(FLAG)).map(str::to_string))?;
+    let Some((port, token)) = value.split_once(':') else { return Some(ExitCode::FAILURE) };
+    let Ok(port) = port.parse::<u16>() else { return Some(ExitCode::FAILURE) };
+    let token_ok = !token.is_empty() && token.len() <= 128 && token.bytes().all(|byte| byte.is_ascii_hexdigit());
+    if !token_ok {
+        return Some(ExitCode::FAILURE);
+    }
+    Some(ExitCode::from(serve(port, token.to_string())))
+}
+
+/// Installs `msi` as an administrator. The caller is already past the download check; this only
+/// fetches the install permission.
+pub fn install(msi: &Path, destination: Option<&Path>, expected: Option<&str>) -> Result<(), String> {
+    if !msi.is_file() {
+        return Err(format!("{} isn't a downloaded package", msi.display()));
+    }
+    let expected = match expected {
+        Some(text) => Some(windows_wire::parse_sha256(text).ok_or_else(|| "the release checksum isn't a SHA-256".to_string())?),
+        None => None,
+    };
+    with_session(|session| session.install(msi, destination, expected))
+}
+
+/// Removes one MSI product as an administrator.
+pub fn uninstall(product: &str) -> Result<(), String> {
+    windows_wire::validate_product(product)?;
+    with_session(|session| session.uninstall(product))
+}
+
+/// The helper's side: connect to the waiting launcher and serve requests until the launcher goes
+/// away or nothing has been asked for a while.
+fn serve(port: u16, token: String) -> u8 {
+    let Some(mut stream) = connect(port) else { return 1 };
+    let _ = stream.set_nodelay(true);
+    let _ = stream.set_write_timeout(Some(Duration::from_secs(60)));
+    if writeln!(stream, "HELLO {token}").and_then(|()| stream.flush()).is_err() {
+        return 1;
+    }
+    let _ = stream.set_read_timeout(Some(Duration::from_secs(60)));
+    let mut answer = Vec::new();
+    if read_line(&mut stream, &mut answer).is_err() || answer != b"OK" {
+        return 1;
+    }
+    let _ = stream.set_read_timeout(Some(IDLE));
+    let mut counter = 0u32;
+    loop {
+        let header = match read_frame(&mut stream) {
+            Ok(Some(header)) => header,
+            Ok(None) => return 0,
+            Err(_) => return 1,
+        };
+        let (outcome, intact) = match windows_wire::decode(&header) {
+            Ok(Request::Install { destination, length, expected }) => install_package(&mut stream, destination, length, expected, &token, counter),
+            Ok(Request::Uninstall { product }) => (uninstall_package(&product), true),
+            Err(_) => return 1,
+        };
+        if write_response(&mut stream, &outcome).is_err() || !intact {
+            return 1;
+        }
+        counter = counter.wrapping_add(1);
+    }
+}
+
+fn connect(port: u16) -> Option<TcpStream> {
+    let address = SocketAddr::from(([127, 0, 0, 1], port));
+    let deadline = Instant::now() + Duration::from_secs(10);
+    loop {
+        match TcpStream::connect_timeout(&address, Duration::from_secs(2)) {
+            Ok(stream) => return Some(stream),
+            Err(_) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(100)),
+            Err(_) => return None,
+        }
+    }
+}
+
+/// Reads one line, for the small plain-text handshake.
+fn read_line(stream: &mut TcpStream, out: &mut Vec<u8>) -> std::io::Result<()> {
+    let mut byte = [0u8; 1];
+    while out.len() < 256 {
+        match stream.read(&mut byte) {
+            Ok(0) => return Ok(()),
+            Ok(_) => match byte[0] {
+                b'\n' => return Ok(()),
+                b'\r' => {}
+                other => out.push(other),
+            },
+            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
+            Err(error) => return Err(error),
+        }
+    }
+    Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "handshake line too long"))
+}
+
+/// Reads a length-prefixed request header. `None` means the launcher is gone or idle.
+fn read_frame(stream: &mut TcpStream) -> Result<Option<Vec<u8>>, String> {
+    let mut length = [0u8; 4];
+    match stream.read_exact(&mut length) {
+        Ok(()) => {}
+        Err(error) if matches!(error.kind(), std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock) => {
+            return Ok(None);
+        }
+        Err(error) => return Err(format!("couldn't read the next request ({error})")),
+    }
+    let length = u32::from_be_bytes(length) as usize;
+    if length == 0 || length > windows_wire::MAX_HEADER {
+        return Err("the install request is too large".into());
+    }
+    let mut header = vec![0u8; length];
+    stream.read_exact(&mut header).map_err(|error| format!("couldn't read the next request ({error})"))?;
+    Ok(Some(header))
+}
+
+/// Receives one MSI into the elevated account's temp folder, checks it and runs it. The second
+/// value says whether the connection is still at a request boundary; a short or failed transfer
+/// leaves it out of step, so the helper then exits.
+fn install_package(
+    stream: &mut TcpStream,
+    destination: Option<String>,
+    length: u64,
+    expected: Option<[u8; HASH_LENGTH]>,
+    token: &str,
+    counter: u32,
+) -> (Result<(), String>, bool) {
+    let (temp, mut file) = match TempMsi::create(token, counter) {
+        Ok(pair) => pair,
+        Err(error) => return (Err(error), false),
+    };
+    let mut hasher = Sha256::new();
+    let mut buffer = [0u8; 64 << 10];
+    let mut remaining = length;
+    while remaining > 0 {
+        let want = remaining.min(buffer.len() as u64) as usize;
+        match stream.read(&mut buffer[..want]) {
+            Ok(0) => return (Err("the installer transfer ended early".into()), false),
+            Ok(read) => {
+                hasher.update(&buffer[..read]);
+                if let Err(error) = file.write_all(&buffer[..read]) {
+                    return (Err(format!("couldn't save the installer ({error})")), false);
+                }
+                remaining -= read as u64;
+            }
+            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
+            Err(error) => return (Err(format!("couldn't receive the installer ({error})")), false),
+        }
+    }
+    if let Err(error) = file.flush() {
+        return (Err(format!("couldn't save the installer ({error})")), false);
+    }
+    drop(file);
+    // Reopen read-only with `FILE_SHARE_READ` and keep that handle open while `msiexec` reads
+    // the package: no same-account process can replace it in the meantime.
+    let mut lock = match lock_msi(&temp.path) {
+        Ok(file) => file,
+        Err(error) => return (Err(error), false),
+    };
+    let stored = match hash_file(&mut lock) {
+        Ok(hash) => hash,
+        Err(error) => return (Err(error), false),
+    };
+    let mut digest = [0u8; HASH_LENGTH];
+    if let Err(error) = stream.read_exact(&mut digest) {
+        return (Err(format!("couldn't verify the installer ({error})")), false);
+    }
+    let received = hasher.finalize();
+    if received.as_slice() != &digest[..] {
+        return (Err("the installer changed on its way to the permission helper".into()), true);
+    }
+    if stored.as_slice() != received.as_slice() {
+        return (Err("the installer changed on disk before it could run".into()), true);
+    }
+    if let Some(expected) = expected
+        && stored != expected
+    {
+        return (Err("the installer doesn't match the release checksum".into()), true);
+    }
+    let destination = match destination {
+        Some(destination) => {
+            let path = PathBuf::from(destination);
+            if !path.is_absolute() {
+                return (Err("the install folder must be an absolute path".into()), true);
+            }
+            Some(path)
+        }
+        None => None,
+    };
+    let result = install_msi(&temp.path, destination.as_deref());
+    // A cancelled install must stay exactly `cancelled`, so the launcher can tell it from an
+    // error; everything else gets the end of the installer's log to explain it.
+    let result = result.map_err(|error| if error == CANCELLED { error } else { with_log_tail(error, &log_path(&temp.path)) });
+    (result, true)
+}
+
+/// `msi_command` canonicalizes the package path (junctions and symlinks, #338), so its log sits
+/// beside the canonical path rather than the path this module created.
+fn log_path(msi: &Path) -> PathBuf {
+    super::msi_path(msi).map_or_else(|_| super::msi_log(msi), |path| super::msi_log(path.as_path()))
+}
+
+/// Opens the staged MSI read-only and keeps other processes from writing or deleting it.
+fn lock_msi(path: &Path) -> Result<std::fs::File, String> {
+    use std::os::windows::fs::OpenOptionsExt;
+    const FILE_SHARE_READ: u32 = 0x1;
+    std::fs::OpenOptions::new().read(true).share_mode(FILE_SHARE_READ).open(path).map_err(|error| format!("couldn't lock the installer ({error})"))
+}
+
+fn hash_file(file: &mut std::fs::File) -> Result<[u8; HASH_LENGTH], String> {
+    use std::io::Seek;
+    file.seek(std::io::SeekFrom::Start(0)).map_err(|error| format!("couldn't verify the installer ({error})"))?;
+    let mut hasher = Sha256::new();
+    let mut buffer = [0u8; 64 << 10];
+    loop {
+        match file.read(&mut buffer) {
+            Ok(0) => break,
+            Ok(read) => hasher.update(&buffer[..read]),
+            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
+            Err(error) => return Err(format!("couldn't verify the installer ({error})")),
+        }
+    }
+    let mut hash = [0u8; HASH_LENGTH];
+    hash.copy_from_slice(hasher.finalize().as_slice());
+    Ok(hash)
+}
+
+fn install_msi(temp: &Path, destination: Option<&Path>) -> Result<(), String> {
+    use std::os::windows::process::CommandExt;
+    let mut command = super::msi_command(&super::windows::msiexec(), temp)?;
+    if let Some(destination) = destination {
+        command.raw_arg(super::msi_folder_argument(destination)?);
+    }
+    let status = command.status().map_err(|error| format!("couldn't start the Windows Installer ({error})"))?;
+    super::windows::explain(status.code())
+}
+
+fn uninstall_package(product: &str) -> Result<(), String> {
+    let status = crate::process::command(super::windows::msiexec())
+        .args(["/x", product, "/passive", "/norestart"])
+        .status()
+        .map_err(|error| format!("couldn't start the Windows Installer ({error})"))?;
+    super::windows::explain(status.code())
+}
+
+fn write_response(stream: &mut TcpStream, outcome: &Result<(), String>) -> std::io::Result<()> {
+    let (status, message) = match outcome {
+        Ok(()) => (0u8, String::new()),
+        Err(error) => (1u8, error.chars().take(1000).collect()),
+    };
+    let message = message.as_bytes();
+    let length = u32::try_from(message.len()).unwrap_or(u32::MAX).to_be_bytes();
+    stream.write_all(&[status])?;
+    stream.write_all(&length)?;
+    stream.write_all(message)?;
+    stream.flush()
+}
+
+/// The end of the installer's verbose log, for an error the user can act on.
+fn with_log_tail(error: String, log: &Path) -> String {
+    use std::io::{Seek, SeekFrom};
+    let Ok(metadata) = std::fs::metadata(log) else { return error };
+    let Ok(mut file) = std::fs::File::open(log) else { return error };
+    let start = metadata.len().saturating_sub(4096);
+    if file.seek(SeekFrom::Start(start)).is_err() {
+        return error;
+    }
+    let mut bytes = Vec::new();
+    if file.take(4096).read_to_end(&mut bytes).is_err() {
+        return error;
+    }
+    let text = String::from_utf8_lossy(&bytes);
+    let tail: String = text.lines().rev().find(|line| !line.trim().is_empty()).unwrap_or("").trim().chars().take(500).collect();
+    if tail.is_empty() { error } else { format!("{error}: {tail}") }
+}
+
+/// A downloaded MSI on the elevated account's temp folder, removed however its install ends.
+struct TempMsi {
+    path: PathBuf,
+}
+
+impl TempMsi {
+    fn create(token: &str, counter: u32) -> Result<(Self, std::fs::File), String> {
+        use std::os::windows::fs::OpenOptionsExt;
+        // Only readers may share this file while the helper holds it open: no writer or deleter
+        // can replace the package between the digest check and `msiexec`.
+        const FILE_SHARE_READ: u32 = 0x1;
+        let directory = std::env::temp_dir();
+        let prefix: String = token.chars().take(16).collect();
+        for attempt in 0..16u32 {
+            let path = directory.join(format!("artcraft-update-{prefix}-{counter}-{attempt}.msi"));
+            match std::fs::OpenOptions::new().write(true).create_new(true).share_mode(FILE_SHARE_READ).open(&path) {
+                Ok(file) => return Ok((Self { path }, file)),
+                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
+                Err(error) => return Err(format!("couldn't prepare the installer ({error})")),
+            }
+        }
+        Err("couldn't prepare the installer".into())
+    }
+}
+
+impl Drop for TempMsi {
+    fn drop(&mut self) {
+        // Resolve the log while the package still exists, so canonicalization can succeed.
+        let log = log_path(&self.path);
+        let _ = std::fs::remove_file(&self.path);
+        let _ = std::fs::remove_file(log);
+    }
+}
+
+/// The launcher's side of the helper.
+struct Session {
+    stream: TcpStream,
+}
+
+/// The helper of this launcher, when it has one.
+static SESSION: Mutex<Option<Session>> = Mutex::new(None);
+
+fn lock() -> MutexGuard<'static, Option<Session>> {
+    SESSION.lock().unwrap_or_else(PoisonError::into_inner)
+}
+
+/// What one exchange with the helper says.
+enum Reply {
+    /// The helper answered: the installer's own outcome. The helper is still usable.
+    Done(Result<(), String>),
+    /// The helper could not be reached. It is dropped; the next call asks for permission again.
+    Broken(String),
+}
+
+/// Runs `work` against the helper, starting it (and with it the one UAC prompt) if needed.
+fn with_session(work: impl FnOnce(&mut Session) -> Reply) -> Result<(), String> {
+    let mut slot = lock();
+    if slot.as_ref().is_some_and(|session| !session.alive()) {
+        *slot = None;
+    }
+    if slot.is_none() {
+        *slot = Some(Session::start()?);
+    }
+    let reply = match slot.as_mut() {
+        Some(session) => work(session),
+        None => Reply::Broken("the elevated install session wasn't available; try again".into()),
+    };
+    match reply {
+        Reply::Done(outcome) => outcome,
+        Reply::Broken(error) => {
+            *slot = None;
+            Err(error)
+        }
+    }
+}
+
+impl Session {
+    /// Starts the helper through UAC. `Err("cancelled")` when the prompt was dismissed.
+    fn start() -> Result<Self, String> {
+        let listener = TcpListener::bind(("127.0.0.1", 0)).map_err(|error| format!("couldn't prepare the permission request ({error})"))?;
+        listener.set_nonblocking(true).map_err(|error| format!("couldn't prepare the permission request ({error})"))?;
+        let port = listener.local_addr().map_err(|error| format!("couldn't prepare the permission request ({error})"))?.port();
+        let token = random_token()?;
+        let executable = std::env::current_exe().map_err(|error| format!("couldn't find this program ({error})"))?;
+        let executable = executable.to_str().ok_or_else(|| "this program's path isn't text".to_string())?;
+        let parameters = format!("{FLAG}{port}:{token}");
+        let info = winsafe::SHELLEXECUTEINFO {
+            mask: winsafe::co::SEE_MASK::NOASYNC | winsafe::co::SEE_MASK::FLAG_NO_UI,
+            verb: Some("runas"),
+            file: executable,
+            parameters: Some(&parameters),
+            show: winsafe::co::SW::HIDE,
+            ..Default::default()
+        };
+        winsafe::ShellExecuteEx(&info).map_err(|error| {
+            if error == winsafe::co::ERROR::CANCELLED { CANCELLED.to_string() } else { format!("couldn't ask for permission ({error})") }
+        })?;
+        let deadline = Instant::now() + CONNECT_LIMIT;
+        loop {
+            match listener.accept() {
+                Ok((mut stream, _)) => {
+                    let _ = stream.set_nonblocking(false);
+                    let _ = stream.set_nodelay(true);
+                    let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
+                    let mut hello = Vec::new();
+                    if read_line(&mut stream, &mut hello).is_err() || hello != format!("HELLO {token}").into_bytes() {
+                        continue;
+                    }
+                    if stream.write_all(b"OK\n").and_then(|()| stream.flush()).is_err() {
+                        continue;
+                    }
+                    let _ = stream.set_read_timeout(None);
+                    let _ = stream.set_write_timeout(Some(Duration::from_secs(300)));
+                    return Ok(Self { stream });
+                }
+                Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
+                    if Instant::now() >= deadline {
+                        return Err("the permission helper didn't start".into());
+                    }
+                    std::thread::sleep(Duration::from_millis(50));
+                }
+                Err(error) => return Err(format!("couldn't ask for permission ({error})")),
+            }
+        }
+    }
+
+    /// Whether the helper is still connected (it exits after being idle for `IDLE`).
+    fn alive(&self) -> bool {
+        if self.stream.set_nonblocking(true).is_err() {
+            return false;
+        }
+        let mut probe = [0u8; 1];
+        let alive = matches!(self.stream.peek(&mut probe), Err(ref error) if error.kind() == std::io::ErrorKind::WouldBlock);
+        let _ = self.stream.set_nonblocking(false);
+        alive
+    }
+
+    fn install(&mut self, msi: &Path, destination: Option<&Path>, expected: Option<[u8; HASH_LENGTH]>) -> Reply {
+        let destination = match destination {
+            Some(path) => match path.to_str() {
+                Some(text) => Some(text),
+                None => return Reply::Done(Err("the install folder isn't text".into())),
+            },
+            None => None,
+        };
+        let mut file = match std::fs::File::open(msi) {
+            Ok(file) => file,
+            Err(error) => return Reply::Done(Err(format!("couldn't read the installer ({error})"))),
+        };
+        let length = match file.metadata() {
+            Ok(metadata) => metadata.len(),
+            Err(error) => return Reply::Done(Err(format!("couldn't read the installer ({error})"))),
+        };
+        let header = match windows_wire::encode_install(destination, length, expected.as_ref()) {
+            Ok(header) => header,
+            Err(error) => return Reply::Done(Err(error)),
+        };
+        if let Err(error) = self.write_header(&header) {
+            return Reply::Broken(gone(error));
+        }
+        let mut hasher = Sha256::new();
+        let mut buffer = [0u8; 64 << 10];
+        let mut sent = 0u64;
+        loop {
+            match file.read(&mut buffer) {
+                Ok(0) => break,
+                Ok(read) => {
+                    if sent.saturating_add(read as u64) > length {
+                        return Reply::Broken("the installer changed while it was being sent; try again".into());
+                    }
+                    hasher.update(&buffer[..read]);
+                    if let Err(error) = self.stream.write_all(&buffer[..read]) {
+                        return Reply::Broken(gone(error));
+                    }
+                    sent += read as u64;
+                }
+                Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
+                Err(error) => return Reply::Broken(gone(error)),
+            }
+        }
+        if sent != length {
+            return Reply::Broken("the installer changed while it was being sent; try again".into());
+        }
+        let digest = hasher.finalize();
+        if let Err(error) = self.stream.write_all(&digest).and_then(|()| self.stream.flush()) {
+            return Reply::Broken(gone(error));
+        }
+        self.read_reply()
+    }
+
+    fn uninstall(&mut self, product: &str) -> Reply {
+        let header = match windows_wire::encode_uninstall(product) {
+            Ok(header) => header,
+            Err(error) => return Reply::Done(Err(error)),
+        };
+        if let Err(error) = self.write_header(&header) {
+            return Reply::Broken(gone(error));
+        }
+        self.read_reply()
+    }
+
+    fn write_header(&mut self, header: &[u8]) -> std::io::Result<()> {
+        let length = u32::try_from(header.len()).map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "header too large"))?;
+        self.stream.write_all(&length.to_be_bytes())?;
+        self.stream.write_all(header)?;
+        self.stream.flush()
+    }
+
+    fn read_reply(&mut self) -> Reply {
+        let mut status = [0u8; 1];
+        if let Err(error) = self.stream.read_exact(&mut status) {
+            return Reply::Broken(gone(error));
+        }
+        let mut length = [0u8; 4];
+        if let Err(error) = self.stream.read_exact(&mut length) {
+            return Reply::Broken(gone(error));
+        }
+        let length = u32::from_be_bytes(length) as usize;
+        if length > MAX_MESSAGE {
+            return Reply::Broken("the elevated install session sent an invalid answer; try again".into());
+        }
+        let mut message = vec![0u8; length];
+        if let Err(error) = self.stream.read_exact(&mut message) {
+            return Reply::Broken(gone(error));
+        }
+        match status[0] {
+            0 => Reply::Done(Ok(())),
+            1 => Reply::Done(Err(String::from_utf8_lossy(&message).into_owned())),
+            _ => Reply::Broken("the elevated install session sent an invalid answer; try again".into()),
+        }
+    }
+}
+
+fn random_token() -> Result<String, String> {
+    let mut bytes = [0u8; 32];
+    getrandom::fill(&mut bytes).map_err(|error| format!("couldn't prepare the permission request ({error})"))?;
+    Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
+}
+
+fn gone(error: std::io::Error) -> String {
+    format!("the elevated install session ended ({error}); try again")
+}
diff --git a/crates/native/src/install/windows_wire.rs b/crates/native/src/install/windows_wire.rs
new file mode 100644
--- /dev/null
+++ b/crates/native/src/install/windows_wire.rs
@@ -0,0 +1,239 @@
+//! The frames the launcher sends its elevated Windows helper.
+//!
+//! A request is a short header (`u32` length, then the header itself). An install follows the
+//! header with the package bytes and their SHA-256, so the helper can stream a large MSI to disk
+//! instead of holding it in memory. Keeping this code free of Windows APIs lets it be tested on
+//! every platform.
+
+pub(crate) const KIND_INSTALL: u8 = 1;
+pub(crate) const KIND_UNINSTALL: u8 = 2;
+
+pub(crate) const MAX_HEADER: usize = 64 << 10;
+pub(crate) const MAX_DESTINATION: usize = 32 << 10;
+pub(crate) const MAX_PRODUCT: usize = 64;
+pub(crate) const MAX_MSI: u64 = 2 << 30; // 2 GiB
+
+/// One thing the helper may be asked to do.
+#[derive(Debug, PartialEq, Eq)]
+pub(crate) enum Request {
+    Install { destination: Option<String>, length: u64, expected: Option<[u8; 32]> },
+    Uninstall { product: String },
+}
+
+/// An install header: the optional install folder, the package length, and the release checksum
+/// the launcher verified the download against.
+pub(crate) fn encode_install(destination: Option<&str>, length: u64, expected: Option<&[u8; 32]>) -> Result<Vec<u8>, String> {
+    if length == 0 || length > MAX_MSI {
+        return Err("the package is too large".into());
+    }
+    let mut out = vec![KIND_INSTALL];
+    match destination {
+        Some(destination) => {
+            let bytes = destination.as_bytes();
+            if bytes.is_empty() || bytes.len() > MAX_DESTINATION || destination.contains('\0') {
+                return Err("the install folder isn't usable".into());
+            }
+            out.push(1);
+            out.extend_from_slice(&u32::try_from(bytes.len()).map_err(|_| "the install folder is too long")?.to_be_bytes());
+            out.extend_from_slice(bytes);
+        }
+        None => out.push(0),
+    }
+    out.extend_from_slice(&length.to_be_bytes());
+    match expected {
+        Some(expected) => {
+            out.push(1);
+            out.extend_from_slice(expected);
+        }
+        None => out.push(0),
+    }
+    Ok(out)
+}
+
+/// An uninstall header: the MSI product code.
+pub(crate) fn encode_uninstall(product: &str) -> Result<Vec<u8>, String> {
+    validate_product(product)?;
+    let mut out = vec![KIND_UNINSTALL];
+    out.extend_from_slice(&u32::try_from(product.len()).map_err(|_| "the product code is too long")?.to_be_bytes());
+    out.extend_from_slice(product.as_bytes());
+    Ok(out)
+}
+
+/// Reads a header the helper was sent.
+pub(crate) fn decode(header: &[u8]) -> Result<Request, String> {
+    if header.len() > MAX_HEADER {
+        return Err("the install request is too large".into());
+    }
+    let mut reader = Reader { bytes: header, at: 0 };
+    let request = match reader.u8()? {
+        KIND_INSTALL => {
+            let destination = match reader.u8()? {
+                0 => None,
+                1 => {
+                    let length = reader.u32()? as usize;
+                    if length == 0 || length > MAX_DESTINATION {
+                        return Err("the install folder isn't usable".into());
+                    }
+                    let destination = std::str::from_utf8(reader.bytes(length)?).map_err(|_| "the install folder isn't text")?;
+                    if destination.contains('\0') {
+                        return Err("the install folder isn't usable".into());
+                    }
+                    Some(destination.to_string())
+                }
+                _ => return Err("unknown install header".into()),
+            };
+            let length = reader.u64()?;
+            if length == 0 || length > MAX_MSI {
+                return Err("the package is too large".into());
+            }
+            let expected = match reader.u8()? {
+                0 => None,
+                1 => {
+                    let bytes = reader.bytes(32)?;
+                    Some(<[u8; 32]>::try_from(bytes).map_err(|_| "truncated installer checksum")?)
+                }
+                _ => return Err("unknown installer checksum".into()),
+            };
+            Request::Install { destination, length, expected }
+        }
+        KIND_UNINSTALL => {
+            let length = reader.u32()? as usize;
+            if length == 0 || length > MAX_PRODUCT {
+                return Err("the product code isn't usable".into());
+            }
+            let product = std::str::from_utf8(reader.bytes(length)?).map_err(|_| "the product code isn't text")?.to_string();
+            validate_product(&product)?;
+            Request::Uninstall { product }
+        }
+        _ => return Err("unknown request".into()),
+    };
+    if reader.at != header.len() {
+        return Err("trailing request data".into());
+    }
+    Ok(request)
+}
+
+/// The release's SHA-256 as hex, for the header.
+pub(crate) fn parse_sha256(text: &str) -> Option<[u8; 32]> {
+    if text.len() != 64 {
+        return None;
+    }
+    let mut out = [0u8; 32];
+    for (index, chunk) in text.as_bytes().as_chunks::<2>().0.iter().enumerate() {
+        out[index] = u8::from_str_radix(std::str::from_utf8(chunk).ok()?, 16).ok()?;
+    }
+    Some(out)
+}
+
+/// Product codes are GUIDs from the Windows Installer registry; nothing else may reach `msiexec
+/// /x`.
+pub(crate) fn validate_product(product: &str) -> Result<(), String> {
+    let ok = !product.is_empty()
+        && product.len() <= MAX_PRODUCT
+        && !product.starts_with('-')
+        && product.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'{' | b'}' | b'-' | b'(' | b')'));
+    if ok { Ok(()) } else { Err(format!("{product} isn't a product code")) }
+}
+
+struct Reader<'a> {
+    bytes: &'a [u8],
+    at: usize,
+}
+
+impl<'a> Reader<'a> {
+    fn bytes(&mut self, length: usize) -> Result<&'a [u8], String> {
+        let end = self.at.checked_add(length).ok_or("truncated header")?;
+        let part = self.bytes.get(self.at..end).ok_or("truncated header")?;
+        self.at = end;
+        Ok(part)
+    }
+
+    fn u8(&mut self) -> Result<u8, String> {
+        self.bytes(1)?.first().copied().ok_or_else(|| "truncated header".to_string())
+    }
+
+    fn u32(&mut self) -> Result<u32, String> {
+        let bytes = self.bytes(4)?;
+        let value = <[u8; 4]>::try_from(bytes).map_err(|_| "truncated header")?;
+        Ok(u32::from_be_bytes(value))
+    }
+
+    fn u64(&mut self) -> Result<u64, String> {
+        let bytes = self.bytes(8)?;
+        let value = <[u8; 8]>::try_from(bytes).map_err(|_| "truncated header")?;
+        Ok(u64::from_be_bytes(value))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn installs_round_trip() {
+        let checksum = [0x5a; 32];
+        for destination in [None, Some(r"C:\Program Files\PhotoCraft")] {
+            for expected in [None, Some(&checksum)] {
+                let header = encode_install(destination, 12345, expected).expect("a small install request");
+                assert_eq!(
+                    decode(&header),
+                    Ok(Request::Install { destination: destination.map(str::to_string), length: 12345, expected: expected.copied() })
+                );
+            }
+        }
+    }
+
+    #[test]
+    fn checksums_are_read_from_hex() {
+        let text = "5a".repeat(32);
+        assert_eq!(parse_sha256(&text), Some([0x5a; 32]));
+        assert_eq!(parse_sha256(&text[..63]), None);
+        assert_eq!(parse_sha256(&"zz".repeat(32)), None);
+    }
+
+    #[test]
+    fn uninstalls_round_trip() {
+        let product = "{A6DE41B7-A80F-4C59-A17E-BB68065BF67E}";
+        let header = encode_uninstall(product).expect("a product code");
+        assert_eq!(decode(&header), Ok(Request::Uninstall { product: product.to_string() }));
+    }
+
+    #[test]
+    fn malformed_headers_are_refused() {
+        assert!(decode(&[]).is_err());
+        assert!(decode(&[99]).is_err());
+        assert!(decode(&[KIND_INSTALL, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]).is_err(), "unknown destination flag");
+        assert!(decode(&[KIND_UNINSTALL, 0, 0, 0, 1, b'-']).is_err(), "an option isn't a product code");
+        assert!(decode(&[KIND_UNINSTALL, 0, 0, 0, 1, b'a', 0]).is_err(), "trailing data");
+        let mut header = encode_install(None, 1, None).expect("a small request");
+        header.push(0);
+        assert!(decode(&header).is_err());
+        assert!(decode(&[KIND_UNINSTALL, 0, 0, 0, 0]).is_err(), "no product code");
+        // An install header that ends before its length.
+        assert!(decode(&[KIND_INSTALL, 0, 0, 0, 0, 0]).is_err());
+        // An unknown checksum flag, and a checksum that ends early.
+        assert!(decode(&[KIND_INSTALL, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2]).is_err());
+        assert!(decode(&[KIND_INSTALL, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 2]).is_err());
+    }
+
+    #[test]
+    fn oversized_requests_are_refused() {
+        assert!(encode_install(None, 0, None).is_err());
+        assert!(encode_install(None, MAX_MSI + 1, None).is_err());
+        let long = "a".repeat(MAX_DESTINATION + 1);
+        assert!(encode_install(Some(&long), 1, None).is_err());
+        assert!(encode_install(Some("bad\0folder"), 1, None).is_err());
+        let long = "a".repeat(MAX_PRODUCT + 1);
+        assert!(encode_uninstall(&long).is_err());
+    }
+
+    #[test]
+    fn product_codes_are_checked() {
+        for product in ["{A6DE41B7-A80F-4C59-A17E-BB68065BF67E}", "photocraft", "(A)"] {
+            assert!(validate_product(product).is_ok(), "{product}");
+        }
+        for product in ["", "-o", "a;b", "a b", "a\nb", "a/b"] {
+            assert!(validate_product(product).is_err(), "{product}");
+        }
+    }
+}
