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
@@ -384,6 +384,7 @@ fn claim_notification_identity() -> &'static Result<(), String> {
 struct DesktopHost {
     tray: bool,
     quit: bool,
+    browse_thread: Option<std::thread::JoinHandle<Option<std::path::PathBuf>>>,
 }
 
 impl Host for DesktopHost {
@@ -432,6 +433,20 @@ impl Host for DesktopHost {
     fn quit(&mut self) {
         self.quit = true;
     }
+
+    fn start_browse_folder(&mut self) {
+        if self.browse_thread.as_ref().is_some_and(|h| !h.is_finished()) {
+            return;
+        }
+        self.browse_thread = Some(std::thread::spawn(artcraft_launcher_native::install::windows::browse_for_folder));
+    }
+
+    fn poll_browse_folder(&mut self) -> Option<Option<std::path::PathBuf>> {
+        if !self.browse_thread.as_ref().is_some_and(|h| h.is_finished()) {
+            return None;
+        }
+        self.browse_thread.take().map(|h| h.join().ok().flatten())
+    }
 }
 
 /// Everything that outlives a window: the UI state and its backend, the tray icon, and the work
@@ -470,7 +485,7 @@ impl Launcher {
             system_theme: system_theme::Monitor::default(),
             ui: LauncherUi::new(&windowless, backend, Settings::default()),
             tray: None,
-            host: DesktopHost { tray: false, quit: false },
+            host: DesktopHost { tray: false, quit: false, browse_thread: None },
             starts,
             windowless,
             run_again,
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -16,6 +16,7 @@ pub mod release;
 pub mod status;
 pub mod time;
 pub mod version;
+pub mod zip_cd;
 
 pub use catalog::{AppGroup, CraftApp, SUITE};
 pub use platform::{Arch, Os, PackageKind, Platform};
diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs
--- a/crates/core/src/protocol.rs
+++ b/crates/core/src/protocol.rs
@@ -4,6 +4,8 @@
 //! The UI sends [`Command`]s and drains [`Event`]s once per frame, so backends do their work on
 //! threads (desktop) or futures (web) and never block a frame.
 
+use std::path::PathBuf;
+
 use serde::{Deserialize, Serialize};
 
 use crate::platform::{PackageKind, Platform};
@@ -60,6 +62,22 @@ pub struct BackendOptions {
     pub kinds: Vec<PackageKind>,
     /// Offer pre-releases (`0.6.0-rc.1`) as updates.
     pub prereleases: bool,
+    /// Where portable installs go. System packages keep their fixed locations.
+    #[serde(default)]
+    pub install_root: InstallRoot,
+}
+
+/// Where a portable install lives. Only portable installs honour this: an MSI goes where
+/// the Windows Installer puts it, whatever is chosen here.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub enum InstallRoot {
+    /// The platform convention (`%LOCALAPPDATA%\Programs` on Windows).
+    #[default]
+    Default,
+    /// Beside the launcher's own executable: a fully portable setup on any drive.
+    NextToLauncher,
+    /// Exactly this folder (must be absolute; created on demand at install time).
+    Custom(PathBuf),
 }
 
 /// The answer to "what's the latest release of this app?".
@@ -233,3 +251,21 @@ pub trait Backend {
     /// Unix seconds now.
     fn now(&self) -> i64;
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn install_root_defaults_and_round_trips() {
+        assert_eq!(InstallRoot::default(), InstallRoot::Default);
+        assert_eq!(BackendOptions::default().install_root, InstallRoot::Default);
+        for root in [InstallRoot::Default, InstallRoot::NextToLauncher, InstallRoot::Custom(PathBuf::from("F:/CraftApps"))] {
+            let back: InstallRoot = serde_json::from_str(&serde_json::to_string(&root).unwrap()).unwrap();
+            assert_eq!(back, root);
+        }
+        // Old senders omit the field: it reads as the default.
+        let opts: BackendOptions = serde_json::from_str(r#"{"kinds":[],"prereleases":false}"#).unwrap();
+        assert_eq!(opts.install_root, InstallRoot::Default);
+    }
+}
diff --git a/crates/core/src/zip_cd.rs b/crates/core/src/zip_cd.rs
new file mode 100644
--- /dev/null
+++ b/crates/core/src/zip_cd.rs
@@ -0,0 +1,428 @@
+//! ZIP central-directory deltas for Windows portable updates.
+//!
+//! A portable `.zip` carries everything a delta needs inside itself: the Central Directory at
+//! the end lists every entry (name, CRC32, sizes, offsets), and entries are compressed
+//! independently. Entries matching the retained previous installer by name, CRC32,
+//! compressed size and method are copied byte-exact from it; only the rest is range-fetched.
+//! Everything here is pure: fetching and assembly live in `native`, which SHA-256-verifies
+//! the result exactly like a full download (#90), so a wrong guess below can only fall back,
+//! never install wrong bytes. Compressed bytes are copied opaquely and never decompressed.
+
+use std::collections::HashMap;
+
+/// One Central Directory entry.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Entry {
+    pub name: String,
+    pub crc: u32,
+    pub comp_size: u64,
+    pub uncomp_size: u64,
+    pub local_offset: u64,
+    pub method: u16,
+    /// DOS/external file attributes (symlink detection lives in the Unix mode bits).
+    pub external_attrs: u32,
+}
+
+/// Compression methods a delta copies opaquely (stored, deflated).
+pub fn supported_method(method: u16) -> bool {
+    method == 0 || method == 8
+}
+
+/// CRC32 (zlib polynomial `0xedb88320`), vendored: the table is built at compile time, and the
+/// standard check value guards it.
+pub fn crc32(bytes: &[u8]) -> u32 {
+    const fn build_table() -> [u32; 256] {
+        let mut table = [0u32; 256];
+        let mut i = 0;
+        while i < 256 {
+            let mut c = i as u32;
+            let mut k = 0;
+            while k < 8 {
+                c = if c & 1 == 1 { 0xedb88320 ^ (c >> 1) } else { c >> 1 };
+                k += 1;
+            }
+            table[i] = c;
+            i += 1;
+        }
+        table
+    }
+    const TABLE: [u32; 256] = build_table();
+    let mut crc = 0xffff_ffffu32;
+    for b in bytes {
+        crc = TABLE[((crc ^ u32::from(*b)) & 0xff) as usize] ^ (crc >> 8);
+    }
+    crc ^ 0xffff_ffff
+}
+
+/// Where the Central Directory is.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct Eocd {
+    pub count: u64,
+    pub cd_size: u64,
+    pub cd_offset: u64,
+}
+
+fn le16(h: &[u8], off: usize) -> Option<u16> {
+    h.get(off..off + 2).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes)
+}
+
+fn le32(h: &[u8], off: usize) -> Option<u32> {
+    h.get(off..off + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes)
+}
+
+/// Finds the End-of-Central-Directory record in `tail` (a suffix of the file) given the file's
+/// total size. Candidates are tried from the end backwards; the first fully valid one wins.
+/// Anything off fails safe towards a full download.
+pub fn find_eocd(tail: &[u8], file_size: u64) -> Result<Eocd, String> {
+    if tail.len() as u64 > file_size {
+        return Err("tail longer than the file".into());
+    }
+    let end = tail.len();
+    let mut pos = end.saturating_sub(22);
+    loop {
+        let header = tail.get(pos..pos + 22).ok_or("no end-of-central-directory found")?;
+        if header[0..4] == [0x50, 0x4b, 0x05, 0x06] {
+            let comment = le16(header, 20).ok_or("no end-of-central-directory found")?;
+            if pos + 22 + comment as usize == end && le16(header, 4) == Some(0) && le16(header, 6) == Some(0) {
+                let count = le16(header, 10).ok_or("no end-of-central-directory found")? as u64;
+                let cd_size = le32(header, 12).ok_or("no end-of-central-directory found")? as u64;
+                let cd_offset = le32(header, 16).ok_or("no end-of-central-directory found")? as u64;
+                if count >= 1 && cd_size >= 46 * count && cd_size <= (64 << 20) && cd_offset.checked_add(cd_size).is_some_and(|e| e <= file_size) {
+                    return Ok(Eocd { count, cd_size, cd_offset });
+                }
+            }
+        }
+        if pos == 0 {
+            break;
+        }
+        pos -= 1;
+    }
+    Err("no end-of-central-directory found".into())
+}
+
+/// Reads exactly `count` Central Directory entries; the bytes must end where the last entry
+/// ends. Entry names must be UTF-8; anything else fails safe towards a full download.
+pub fn parse_cd(bytes: &[u8], expected: u64) -> Result<Vec<Entry>, String> {
+    let mut out = Vec::new();
+    let mut off = 0usize;
+    while off < bytes.len() {
+        let h = bytes.get(off..off + 46).ok_or("short central directory entry")?;
+        if h[0..4] != [0x50, 0x4b, 0x01, 0x02] {
+            return Err("bad central directory entry".into());
+        }
+        let name_len = le16(h, 28).ok_or("short central directory entry")? as usize;
+        let extra_len = le16(h, 30).ok_or("short central directory entry")? as usize;
+        let comment_len = le16(h, 32).ok_or("short central directory entry")? as usize;
+        let end = off
+            .checked_add(46)
+            .and_then(|e| e.checked_add(name_len))
+            .and_then(|e| e.checked_add(extra_len))
+            .and_then(|e| e.checked_add(comment_len))
+            .ok_or("bad central directory entry")?;
+        let name = bytes.get(off + 46..off + 46 + name_len).ok_or("short central directory entry")?;
+        let name = std::str::from_utf8(name).map_err(|_| "non-UTF-8 entry name")?.to_string();
+        out.push(Entry {
+            name,
+            crc: le32(h, 16).ok_or("short central directory entry")?,
+            comp_size: le32(h, 20).ok_or("short central directory entry")? as u64,
+            uncomp_size: le32(h, 24).ok_or("short central directory entry")? as u64,
+            local_offset: le32(h, 42).ok_or("short central directory entry")? as u64,
+            method: le16(h, 10).ok_or("short central directory entry")?,
+            external_attrs: le32(h, 38).ok_or("short central directory entry")?,
+        });
+        off = end;
+    }
+    if out.len() as u64 != expected {
+        return Err("central directory count mismatch".into());
+    }
+    Ok(out)
+}
+
+/// A local file header's layout: variable lengths plus the declared method, CRC32 and
+/// compressed size, to check against the Central Directory entry before trusting a range.
+/// `has_descriptor` (bit 3) means the CRC/size fields are zero here and live after the data
+/// instead, so they must not be compared.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct LocalHeader {
+    pub name_len: usize,
+    pub extra_len: usize,
+    pub method: u16,
+    pub crc: u32,
+    pub comp_size: u64,
+    pub has_descriptor: bool,
+}
+
+/// Reads a local file header's first 30 bytes.
+pub fn parse_local_header(bytes: &[u8]) -> Result<LocalHeader, String> {
+    let h = bytes.get(..30).ok_or("short local header")?;
+    if h[0..4] != [0x50, 0x4b, 0x03, 0x04] {
+        return Err("bad local header".into());
+    }
+    let flags = le16(h, 6).ok_or("short local header")?;
+    Ok(LocalHeader {
+        name_len: le16(h, 26).ok_or("short local header")? as usize,
+        extra_len: le16(h, 28).ok_or("short local header")? as usize,
+        method: le16(h, 8).ok_or("short local header")?,
+        crc: le32(h, 14).ok_or("short local header")?,
+        comp_size: le32(h, 18).ok_or("short local header")? as u64,
+        has_descriptor: flags & 0x08 != 0,
+    })
+}
+
+/// Safety caps for an installable archive: absurd counts or sizes fail fast instead of
+/// exhausting memory or disk.
+pub const MAX_ZIP_FILES: usize = 30_000;
+pub const MAX_ZIP_BYTES: u64 = 8 << 30;
+
+/// Rejects archives that must never touch disk: traversal/absolute paths, Windows reserved
+/// names, alternate streams, symlinks, unsupported methods, duplicates, and absurd sizes.
+/// Callers decide the meaning: the delta path falls back to a full download, the install
+/// path refuses with a clear error (a full download of the same bytes would fail alike).
+pub fn validate_entries(entries: &[Entry]) -> Result<(), String> {
+    if entries.len() > MAX_ZIP_FILES {
+        return Err("too many files in the archive".into());
+    }
+    let mut total: u64 = 0;
+    let mut seen = std::collections::HashSet::new();
+    for e in entries {
+        if !supported_method(e.method) {
+            return Err("unsupported compression method".into());
+        }
+        if !seen.insert(e.name.as_str()) {
+            return Err("duplicate entry name".into());
+        }
+        safe_name(&e.name)?;
+        if e.external_attrs >> 16 & 0o170000 == 0o120000 {
+            return Err("archive symlink rejected".into());
+        }
+        total = total.checked_add(e.uncomp_size).ok_or("archive size overflow")?;
+        if total > MAX_ZIP_BYTES {
+            return Err("archive too large".into());
+        }
+    }
+    Ok(())
+}
+
+/// A name that cannot escape its folder, collide with a device, or smuggle a stream.
+fn safe_name(name: &str) -> Result<(), String> {
+    let normalized = name.replace('\\', "/");
+    if normalized.contains(':') || normalized.starts_with('/') {
+        return Err("unsafe archive path".into());
+    }
+    let mut parts = 0;
+    for part in normalized.split('/') {
+        parts += 1;
+        if part.is_empty() || part == "." || part == ".." {
+            return Err("unsafe archive path".into());
+        }
+        if part.ends_with([' ', '.']) {
+            return Err("reserved Windows path".into());
+        }
+        let stem = part.split('.').next().unwrap_or("").to_ascii_uppercase();
+        if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
+            || (stem.len() == 4
+                && (stem.starts_with("COM") || stem.starts_with("LPT"))
+                && stem.ends_with(['1', '2', '3', '4', '5', '6', '7', '8', '9']))
+        {
+            return Err("reserved Windows path".into());
+        }
+    }
+    if parts == 0 {
+        return Err("unsafe archive path".into());
+    }
+    Ok(())
+}
+
+/// Where one new entry's bytes come from.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Need {
+    /// Copy the compressed bytes of the retained installer's entry.
+    Copy { old_index: usize },
+    /// Range-fetch from the new file.
+    Fetch,
+}
+
+/// The single shared top folder of every name, if there is exactly one: backslashes
+/// count as slashes, so `v1\a` and `v1/a` agree. The top folder itself (`v1`, `v1/`)
+/// is allowed through; anything else outside it (flat names, several tops) means names
+/// stand on their own and match exactly as before.
+pub fn common_top<'a, I>(names: I) -> Option<String>
+where
+    I: IntoIterator<Item = &'a str>,
+{
+    let all: Vec<String> = names.into_iter().map(|n| n.replace('\\', "/")).collect();
+    let candidate = all.iter().filter_map(|n| n.split_once('/').map(|(first, _)| first)).next()?;
+    if candidate.is_empty() {
+        return None;
+    }
+    let prefix = format!("{candidate}/");
+    for n in &all {
+        if *n != candidate && !n.starts_with(&prefix) {
+            return None;
+        }
+    }
+    Some(candidate.to_string())
+}
+
+/// The name `plan` matches on: the path inside the directory's single top folder when
+/// there is one, else the full name exactly as published (byte-identical to before).
+fn match_key(name: &str, top: Option<&str>) -> String {
+    let Some(t) = top else { return name.to_string() };
+    name.replace('\\', "/").strip_prefix(&format!("{t}/")).unwrap_or(name).to_string()
+}
+
+/// Matches the new directory against the retained one: same name, CRC32, compressed size and
+/// method means the compressed bytes are reused (the final SHA-256 still vouches for the
+/// assembly). When both directories sit under their own single top folder (the versioned
+/// folder publishers wrap releases in), names match by their path inside it, so a renamed
+/// top folder alone never forces a full download. Duplicate names or an unsupported method
+/// fail safe towards a full download.
+pub fn plan(old: &[Entry], new: &[Entry]) -> Result<Vec<Need>, String> {
+    let old_top = common_top(old.iter().map(|e| e.name.as_str()));
+    let new_top = common_top(new.iter().map(|e| e.name.as_str()));
+    let mut by_name: HashMap<String, usize> = HashMap::new();
+    for (i, e) in old.iter().enumerate() {
+        if by_name.insert(match_key(&e.name, old_top.as_deref()), i).is_some() {
+            return Err("duplicate entry name".into());
+        }
+    }
+    let mut seen = std::collections::HashSet::new();
+    for e in new {
+        if !seen.insert(match_key(&e.name, new_top.as_deref())) {
+            return Err("duplicate entry name".into());
+        }
+    }
+    new.iter()
+        .map(|n| {
+            if !supported_method(n.method) {
+                return Err("unsupported compression method".into());
+            }
+            match by_name.get(&match_key(&n.name, new_top.as_deref())) {
+                Some(&oi) if old.get(oi).is_some_and(|o| o.method == n.method && o.crc == n.crc && o.comp_size == n.comp_size) => {
+                    Ok(Need::Copy { old_index: oi })
+                }
+                _ => Ok(Need::Fetch),
+            }
+        })
+        .collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn crc32_matches_the_standard_check_value() {
+        assert_eq!(crc32(b"123456789"), 0xcbf43926);
+        assert_eq!(crc32(b""), 0);
+    }
+
+    /// The exact 22 bytes closing WordCraft v0.6.1's published windows-x64 portable zip
+    /// (95,828,700 bytes): the parser must read what real build tooling writes.
+    const REAL_EOCD: [u8; 22] =
+        [0x50, 0x4b, 0x05, 0x06, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x0a, 0x00, 0xf0, 0x03, 0x00, 0x00, 0xd6, 0x36, 0xb6, 0x05, 0x00, 0x00];
+
+    #[test]
+    fn finds_the_real_end_of_central_directory() {
+        let eocd = find_eocd(&REAL_EOCD, 95828700).unwrap();
+        assert_eq!(eocd, Eocd { count: 10, cd_size: 1008, cd_offset: 95827670 });
+        assert!(find_eocd(b"hello", 95828700).is_err());
+        assert!(find_eocd(&REAL_EOCD[..21], 95828700).is_err(), "truncated");
+        assert!(find_eocd(&REAL_EOCD, 100).is_err(), "tail longer than the file");
+    }
+
+    /// The exact first Central Directory entry of that same zip.
+    const REAL_ENTRY0: [u8; 97] = [
+        0x50, 0x4b, 0x01, 0x02, 0x14, 0x00, 0x14, 0x00, 0x00, 0x00, 0x08, 0x00, 0xfa, 0x7c, 0x4c, 0x5d, 0xc8, 0xc7, 0xa5, 0x9b, 0xe8, 0x0d, 0x00,
+        0x00, 0x6f, 0x28, 0x00, 0x00, 0x33, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        0x77, 0x6f, 0x72, 0x64, 0x63, 0x72, 0x61, 0x66, 0x74, 0x2d, 0x30, 0x2e, 0x36, 0x2e, 0x31, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x73,
+        0x2d, 0x78, 0x36, 0x34, 0x2d, 0x70, 0x6f, 0x72, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x2f, 0x4c, 0x49, 0x43, 0x45, 0x4e, 0x53, 0x45, 0x2d, 0x41,
+        0x50, 0x41, 0x43, 0x48, 0x45,
+    ];
+
+    #[test]
+    fn parses_the_real_directory_entry() {
+        let entries = parse_cd(&REAL_ENTRY0, 1).unwrap();
+        assert_eq!(entries.len(), 1);
+        let e = &entries[0];
+        assert_eq!(e.name, "wordcraft-0.6.1-windows-x64-portable/LICENSE-APACHE");
+        assert_eq!((e.method, e.crc, e.comp_size, e.uncomp_size, e.local_offset), (8, 0x9ba5c7c8, 3560, 10351, 0));
+        assert_eq!(e.external_attrs, 0);
+        assert!(parse_cd(&REAL_ENTRY0, 2).is_err(), "count mismatch");
+        assert!(parse_cd(&REAL_ENTRY0[..60], 1).is_err(), "truncated");
+    }
+
+    #[test]
+    fn local_headers_parse_and_reject() {
+        let mut h = [0u8; 30];
+        h[0..4].copy_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
+        h[8..10].copy_from_slice(&8u16.to_le_bytes());
+        h[14..18].copy_from_slice(&0xc7c85d4cu32.to_le_bytes());
+        h[18..22].copy_from_slice(&3560u32.to_le_bytes());
+        h[26..28].copy_from_slice(&51u16.to_le_bytes());
+        let lh = parse_local_header(&h).unwrap();
+        assert_eq!((lh.method, lh.crc, lh.comp_size, lh.name_len, lh.extra_len, lh.has_descriptor), (8, 0xc7c85d4c, 3560, 51, 0, false));
+        h[6] = 0x08;
+        assert!(parse_local_header(&h).unwrap().has_descriptor);
+        assert!(parse_local_header(&h[..29]).is_err());
+        assert!(parse_local_header(&[0u8; 30]).is_err());
+    }
+
+    fn entry(name: &str, crc: u32, comp: u64) -> Entry {
+        Entry { name: name.into(), crc, comp_size: comp, uncomp_size: comp, local_offset: 0, method: 8, external_attrs: 0 }
+    }
+
+    #[test]
+    fn plan_reuses_identical_entries_and_fetches_the_rest() {
+        let old = vec![entry("a", 1, 10), entry("b", 2, 20), entry("c", 3, 30)];
+        let new = vec![entry("a", 1, 10), entry("b", 9, 20), entry("d", 4, 40)];
+        assert_eq!(plan(&old, &new).unwrap(), [Need::Copy { old_index: 0 }, Need::Fetch, Need::Fetch]);
+        // Same content re-compressed to a different size is fetched, never trusted blindly.
+        assert_eq!(plan(&old, &[entry("a", 1, 11)]).unwrap(), [Need::Fetch]);
+        assert!(plan(&old, &[entry("x", 1, 10), entry("x", 1, 10)]).is_err(), "duplicate names");
+        assert!(plan(&[entry("a", 1, 10)], &[Entry { method: 99, ..entry("a", 1, 10) }]).is_err(), "bad method");
+    }
+
+    #[test]
+    fn validation_rejects_hostile_archives() {
+        assert!(validate_entries(&[entry("a", 1, 10)]).is_ok());
+        assert!(validate_entries(&[entry("../escape", 1, 10)]).is_err(), "traversal");
+        assert!(validate_entries(&[entry("/abs", 1, 10)]).is_err(), "absolute");
+        assert!(validate_entries(&[entry("C:\\win", 1, 10)]).is_err(), "drive letter");
+        assert!(validate_entries(&[entry("a:stream", 1, 10)]).is_err(), "alternate stream");
+        assert!(validate_entries(&[entry("CON", 1, 10)]).is_err(), "reserved name");
+        assert!(validate_entries(&[entry("dir/COM1.txt", 1, 10)]).is_err(), "reserved name in folder");
+        assert!(validate_entries(&[entry("x", 1, 10), entry("x", 1, 10)]).is_err(), "duplicate names");
+        assert!(validate_entries(&[Entry { method: 99, ..entry("a", 1, 10) }]).is_err(), "bad method");
+        assert!(validate_entries(&[Entry { external_attrs: 0o120000 << 16, ..entry("a", 1, 10) }]).is_err(), "symlink");
+        // A real published entry passes validation.
+        let real = parse_cd(&REAL_ENTRY0, 1).unwrap();
+        assert!(validate_entries(&real).is_ok());
+    }
+
+    #[test]
+    fn top_folders_match_by_relative_path() {
+        use std::iter::empty;
+        assert_eq!(common_top(["v1/a", "v1/b"].into_iter()), Some("v1".to_string()));
+        assert_eq!(common_top(["v1/", "v1/a"].into_iter()), Some("v1".to_string()));
+        assert_eq!(common_top(["v1\\a", "v1/b"].into_iter()), Some("v1".to_string()));
+        assert_eq!(common_top(["a", "b"].into_iter()), None, "flat names stand alone");
+        assert_eq!(common_top(["v1/a", "v2/a"].into_iter()), None, "several tops");
+        assert_eq!(common_top(["v1/a", "flat"].into_iter()), None, "a stray vetoes");
+        assert_eq!(common_top(["/a", "/b"].into_iter()), None, "empty top");
+        assert_eq!(common_top(empty::<&str>()), None);
+    }
+
+    #[test]
+    fn plan_matches_across_renamed_top_folders() {
+        fn e(top: &str, name: &str, crc: u32, comp: u64) -> Entry {
+            Entry { name: format!("{top}/{name}"), crc, comp_size: comp, uncomp_size: comp, local_offset: 0, method: 8, external_attrs: 0 }
+        }
+        let old = vec![e("v1", "keep.txt", 7, 70), e("v1", "new.bin", 8, 80)];
+        let new = vec![e("v2", "keep.txt", 7, 70), e("v2", "new.bin", 9, 80)];
+        assert_eq!(plan(&old, &new).unwrap(), [Need::Copy { old_index: 0 }, Need::Fetch]);
+        // Same top on both sides behaves exactly as before.
+        let same = vec![e("v1", "keep.txt", 7, 70)];
+        assert_eq!(plan(&same, &same).unwrap(), [Need::Copy { old_index: 0 }]);
+    }
+}
diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs
--- a/crates/native/src/backend.rs
+++ b/crates/native/src/backend.rs
@@ -478,7 +478,7 @@ fn run_install(
         last_emit = Instant::now();
         shared.emit(Event::Progress { app: app.id.to_string(), phase: p });
     };
-    let mut job = Job { http, paths, stop, phase: &mut phase };
+    let mut job = Job { http, paths, stop, phase: &mut phase, install_root: options.install_root.clone() };
     let new_record = install::install(app, &release, &asset, kind, existing, record, &mut job)?;
     {
         let mut m = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner);
diff --git a/crates/native/src/detect.rs b/crates/native/src/detect.rs
--- a/crates/native/src/detect.rs
+++ b/crates/native/src/detect.rs
@@ -111,7 +111,7 @@ fn exe_candidates(app: &CraftApp, location: Option<&Path>, program_files: &[Path
 
 /// Version from `<app> --version` output. Binaries print the machine id (`photocraft 0.7.0`),
 /// the display name (`SoundCraft 0.5.0`), or a former id (`printcraft 0.2.1` for PdfCraft).
-#[cfg_attr(not(any(target_os = "linux", target_os = "freebsd")), allow(dead_code))]
+#[cfg_attr(not(any(target_os = "linux", target_os = "freebsd", windows)), allow(dead_code))]
 fn parse_cli_version(line: &str, app: &CraftApp) -> Option<version::Version> {
     let line = line.trim();
     for prefix in app.stems().chain(std::iter::once(app.name)) {
@@ -243,13 +243,25 @@ pub mod windows {
     }
 
     pub fn detect(app: &CraftApp) -> Option<Installed> {
-        let m = msi_installs(app).into_iter().next()?;
-        Some(Installed {
-            version: m.version,
-            source: InstallSource::Msi,
-            location: m.exe.map_or_else(|| app.name.to_string(), |p| p.display().to_string()),
-            package: Some(m.product_code),
-        })
+        if let Some(m) = msi_installs(app).into_iter().next() {
+            return Some(Installed {
+                version: m.version,
+                source: InstallSource::Msi,
+                location: m.exe.map_or_else(|| app.name.to_string(), |p| p.display().to_string()),
+                package: Some(m.product_code),
+            });
+        }
+        // A portable copy the launcher unpacked into its own per-user folder.
+        if let Some(dir) = crate::install::windows::portable_dir(app, &artcraft_launcher_core::protocol::InstallRoot::Default)
+            && let Some(exe) = crate::install::windows::portable_exe(app, &dir)
+            && let Some(exe_str) = exe.to_str()
+        {
+            let version = crate::process::capture(exe_str, &["--version"], std::time::Duration::from_secs(5))
+                .and_then(|out| out.lines().next().map(|l| parse_cli_version(l, app)))
+                .flatten();
+            return Some(Installed { version, source: InstallSource::Launcher, location: exe.display().to_string(), package: None });
+        }
+        None
     }
 }
 
diff --git a/crates/native/src/http.rs b/crates/native/src/http.rs
--- a/crates/native/src/http.rs
+++ b/crates/native/src/http.rs
@@ -12,6 +12,8 @@ use sha2::{Digest, Sha256};
 const USER_AGENT: &str = concat!("ArtCraftLauncher/", env!("CARGO_PKG_VERSION"));
 /// Largest JSON or text answer we read.
 const MAX_SMALL: u64 = 4 << 20;
+/// Largest single HTTP range fetch (delta updates split anything bigger into chunks).
+const MAX_RANGE: u64 = 64 << 20;
 
 /// What a GET of a small resource produced.
 #[derive(Debug, PartialEq, Eq)]
@@ -181,6 +183,40 @@ impl Http {
         }
     }
 
+    /// GET `bytes=start-end` (following redirects) for delta updates. `Ok` only for a 206 whose
+    /// body is exactly the asked range; a 200 that ignored the range, a short or long body, or
+    /// anything else is an error, so the caller falls back to a full download instead of
+    /// trusting a wrong slice.
+    pub fn get_range(&self, url: &str, start: u64, end: u64) -> Result<Vec<u8>, String> {
+        let want = end.checked_sub(start).and_then(|n| n.checked_add(1)).ok_or("bad byte range")?;
+        if want == 0 || want > MAX_RANGE {
+            return Err("bad byte range".into());
+        }
+        let mut resp = self
+            .download
+            .get(url)
+            .header("Accept-Encoding", "identity")
+            .header("Range", &format!("bytes={start}-{end}"))
+            .call()
+            .map_err(|e| format!("couldn't reach {} ({e})", host(url)))?;
+        match resp.status().as_u16() {
+            206 => {
+                match resp.headers().get("content-range").and_then(|v| v.to_str().ok()).and_then(parse_content_range) {
+                    Some((s, _)) if s == start => {}
+                    _ => return Err("the server sent an unreadable partial response".into()),
+                }
+                let body = resp.body_mut().with_config().limit(want + 1).read_to_vec().map_err(|e| format!("unreadable answer ({e})"))?;
+                if body.len() as u64 != want {
+                    return Err(format!("the server sent {} bytes for a {want}-byte range", body.len()));
+                }
+                Ok(body)
+            }
+            404 | 410 => Err("the file is no longer on the release page".into()),
+            200 => Err("the server ignored the range request".into()),
+            s => Err(format!("{} answered HTTP {s}", host(url))),
+        }
+    }
+
     /// Where `url` redirects to, without following it.
     pub fn redirect_target(&self, url: &str) -> Result<Option<String>, String> {
         let resp = self.no_redirect.head(url).call().map_err(|e| format!("couldn't reach {} ({e})", host(url)))?;
@@ -646,7 +682,7 @@ pub(crate) mod tests {
                         break;
                     }
                     if let Some(v) = line.to_ascii_lowercase().strip_prefix("range: bytes=") {
-                        range = v.trim().trim_end_matches('-').parse::<usize>().ok();
+                        range = v.trim().split(['-', ' ']).next().and_then(|s| s.parse::<usize>().ok());
                     }
                 }
                 seen.lock().unwrap().push(range.map(|r| r.to_string()));
@@ -741,6 +777,19 @@ pub(crate) mod tests {
         assert_eq!(result.unwrap(), body().0);
     }
 
+    #[test]
+    fn range_requests_return_exactly_the_asked_slice() {
+        let (body, _) = body();
+        let http = Http::new().unwrap();
+        let (url, ranges) = serve(body.clone(), vec![Reply::Ranged]);
+        assert_eq!(http.get_range(&url, 100, body.len() as u64 - 1).unwrap(), body[100..].to_vec());
+        assert_eq!(ranges.lock().unwrap().as_slice(), [Some("100".to_string())]);
+        // A 200 that ignored the range is an error, never a trusted slice.
+        let (url, _) = serve(body, vec![Reply::Full]);
+        assert!(http.get_range(&url, 0, 10).unwrap_err().contains("ignored"));
+        assert!(http.get_range(&url, 10, 9).unwrap_err().contains("bad byte range"));
+    }
+
     #[test]
     fn corrupt_data_is_fetched_again_and_never_kept() {
         let (result, _) = run(vec![Reply::Corrupt, Reply::Full]);
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
@@ -21,7 +21,8 @@
 
 use std::path::{Path, PathBuf};
 
-use artcraft_launcher_core::protocol::{CANCELLED, INSTALL_CANCELLED, JobPhase};
+use artcraft_launcher_core::protocol::InstallRoot;
+use artcraft_launcher_core::protocol::{CANCELLED, INSTALL_CANCELLED, JobPhase, PAUSED};
 use artcraft_launcher_core::release::{self, Asset, Release};
 use artcraft_launcher_core::{CraftApp, InstallSource, Installed, PackageKind};
 
@@ -29,6 +30,7 @@ use crate::http::{Http, Stop, Transfer};
 use crate::manifest::Record;
 use crate::paths::Paths;
 use crate::process;
+use crate::zip_fetch::{self, ZipJob};
 
 /// What an install needs from the backend.
 pub struct Job<'a> {
@@ -36,6 +38,7 @@ pub struct Job<'a> {
     pub paths: &'a Paths,
     pub stop: &'a Stop,
     pub phase: &'a mut dyn FnMut(JobPhase),
+    pub install_root: InstallRoot,
 }
 
 /// Downloads and installs `asset` of `release`. Returns a record when the launcher owns the
@@ -50,7 +53,6 @@ pub fn install(
     previous: Option<&Record>,
     job: &mut Job<'_>,
 ) -> Result<Option<Record>, String> {
-    let _ = previous;
     if let Some(inst) = existing
         && inst.source.launcher_can_update()
         && process::is_running(Path::new(&inst.location))
@@ -58,7 +60,7 @@ pub fn install(
         return Err(format!("{} is running. Quit it, then try again.", app.name));
     }
     log::info!("{}: starting {} to {} ({})", app.name, if existing.is_some() { "update" } else { "install" }, release.version_label(), kind.label());
-    let file = download(release, asset, job)?;
+    let file = download(release, asset, kind, app.id, job)?;
     log::info!("{}: download ready ({}), running installer...", app.name, file.display());
     // Past this point the install can't be interrupted halfway.
     (job.phase)(JobPhase::Installing);
@@ -69,6 +71,16 @@ pub fn install(
         PackageKind::Dmg => macos::install_dmg(app, &file, existing, job.paths, &macos::Expect::release(release)).map(|_| None),
         #[cfg(windows)]
         PackageKind::Msi => windows::run_msi(&file, job.stop, &mut *job.phase).map(|_| None),
+        #[cfg(windows)]
+        PackageKind::PortableZip => windows::install_portable(app, &file, &job.install_root, previous, job.stop).map(|exe| {
+            let dir = match exe.parent() {
+                Some(p) => p.to_path_buf(),
+                None => PathBuf::new(),
+            };
+            let mut rec = record(&version, kind, exe, windows::walk_files(&dir), false);
+            rec.root = Some(dir);
+            Some(rec)
+        }),
         #[cfg(target_os = "linux")]
         PackageKind::Deb | PackageKind::Rpm | PackageKind::Flatpak => linux::install_package(kind, &file, job.stop, &mut *job.phase).map(|_| None),
         #[cfg(target_os = "linux")]
@@ -88,9 +100,14 @@ pub fn install(
         Err(e) => log::error!("{}: installation failed: {e}", app.name),
     }
     // A failed install keeps the download: the next try checks it against its published checksum
-    // again and skips the transfer.
+    // again and skips the transfer. A successful portable install keeps it instead as the next
+    // delta's base (one per app, beside the downloads); every other format deletes it.
     if result.is_ok() {
-        let _ = std::fs::remove_file(&file);
+        if kind == PackageKind::PortableZip {
+            zip_fetch::retain_installer(job.paths, app.id, &file);
+        } else {
+            let _ = std::fs::remove_file(&file);
+        }
     }
     // The system's own prompt was dismissed: the download itself wasn't cancelled.
     result.map_err(|e| if e == CANCELLED { INSTALL_CANCELLED.to_string() } else { e })
@@ -103,7 +120,7 @@ pub fn download_path(paths: &Paths, asset: &Asset) -> PathBuf {
 
 #[allow(dead_code)]
 fn record(version: &str, kind: PackageKind, path: PathBuf, files: Vec<PathBuf>, needs_root: bool) -> Record {
-    Record { version: version.to_string(), kind, path, installed_at: crate::now(), files, needs_root }
+    Record { version: version.to_string(), kind, path, installed_at: crate::now(), files, needs_root, root: None }
 }
 
 /// The package format to install `app` with: the format of the copy that's there (an update
@@ -137,12 +154,13 @@ pub fn choose_kinds(existing: Option<&Installed>, record: Option<&Record>, prefe
     kinds
 }
 
-/// Package formats this machine can install, best first.
+/// Package formats this machine can install, best first. Windows offers the system
+/// installer and, for users who prefer no admin prompts, the portable zip.
 pub fn available_kinds() -> Vec<PackageKind> {
     #[cfg(target_os = "macos")]
     return vec![PackageKind::Dmg];
     #[cfg(windows)]
-    return vec![PackageKind::Msi];
+    return vec![PackageKind::Msi, PackageKind::PortableZip];
     #[cfg(target_os = "linux")]
     return linux::available_kinds();
     #[cfg(target_os = "freebsd")]
@@ -154,7 +172,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, kind: PackageKind, app_id: &str, job: &mut Job<'_>) -> Result<PathBuf, String> {
     if !release::valid_file_name(&asset.name) {
         return Err("the release has an unexpected file name".into());
     }
@@ -168,6 +186,10 @@ pub(crate) fn download(release: &Release, asset: &Asset, job: &mut Job<'_>) -> R
             asset.name
         ));
     };
+    if let Some(ready) = zip_delta_update(asset, kind, app_id, &sha, job)? {
+        (job.phase)(JobPhase::Verifying);
+        return Ok(ready);
+    }
     let dest = download_path(job.paths, asset);
     let phase = &mut *job.phase;
     job.http.download(&asset.url, &dest, asset.size, &sha, job.stop, &mut |t| match t {
@@ -178,6 +200,50 @@ pub(crate) fn download(release: &Release, asset: &Asset, job: &mut Job<'_>) -> R
     Ok(dest)
 }
 
+/// Tries a portable-zip delta of the retained previous installer: `Ok(Some(path))` when
+/// only the changed entries travelled and the assembly verified; `Ok(None)` when a delta
+/// isn't applicable (other formats, no retained copy, unknown size), so the caller does the
+/// full download instead. Pause and cancel propagate as `Err` unchanged.
+fn zip_delta_update(asset: &Asset, kind: PackageKind, app_id: &str, sha: &str, job: &mut Job<'_>) -> Result<Option<PathBuf>, String> {
+    if kind != PackageKind::PortableZip || asset.size == 0 {
+        return Ok(None);
+    }
+    let Some(kept) = zip_fetch::find_kept(job.paths, app_id) else { return Ok(None) };
+    let tmp = job.paths.downloads().join(format!(".{}.delta.tmp", asset.name));
+    let result = zip_fetch::fetch_zip_delta(ZipJob {
+        http: job.http,
+        base_url: &asset.url,
+        file_size: asset.size,
+        old_path: &kept,
+        tmp_path: &tmp,
+        expected_sha256: sha,
+        stop: job.stop,
+        progress: &mut |done, total| (job.phase)(JobPhase::Downloading { done, total }),
+    });
+    match result {
+        Ok(()) => {
+            let dest = download_path(job.paths, asset);
+            match std::fs::rename(&tmp, &dest) {
+                Ok(()) => {
+                    log::info!("delta update assembled {}", asset.name);
+                    Ok(Some(dest))
+                }
+                Err(e) => {
+                    log::info!("delta update couldn't be put in place ({e}); falling back to a full download");
+                    let _ = std::fs::remove_file(&tmp);
+                    Ok(None)
+                }
+            }
+        }
+        Err(e) if e == PAUSED || e == CANCELLED => Err(e),
+        Err(e) => {
+            log::info!("delta update failed ({e}); falling back to a full download");
+            let _ = std::fs::remove_file(&tmp);
+            Ok(None)
+        }
+    }
+}
+
 /// The file's line in the release's `SHA256SUMS.txt`. `Ok(None)` only when the release really
 /// publishes no checksum for it; a list that exists but can't be fetched is an error, so a
 /// network hiccup never downgrades the check.
@@ -232,6 +298,11 @@ pub fn uninstall(
     match installed.source {
         #[cfg(windows)]
         InstallSource::Msi => windows::uninstall_msi(app, stop, phase),
+        #[cfg(windows)]
+        InstallSource::Launcher => match record {
+            Some(r) if r.kind == PackageKind::PortableZip => windows::remove_portable(app, r),
+            _ => Err(format!("{} can't be removed from here", installed.source.label())),
+        },
         #[cfg(target_os = "macos")]
         InstallSource::MacApp | InstallSource::Launcher => macos::move_to_trash(&path),
         #[cfg(target_os = "linux")]
@@ -1211,6 +1282,233 @@ pub mod windows {
             Ok(())
         })
     }
+
+    /// Portable installs live here, per user with no admin rights (where the per-user MSI
+    /// puts the launcher itself): `%LOCALAPPDATA%\Programs`.
+    #[cfg_attr(not(windows), allow(dead_code))]
+    pub fn programs_dir() -> Option<PathBuf> {
+        let root = std::env::var_os("LOCALAPPDATA").map(PathBuf::from).or_else(dirs::data_local_dir)?;
+        Some(root.join("Programs"))
+    }
+
+    /// Resolves an install root choice to a folder. `None` (no folder configured, a
+    /// relative custom path, no launcher exe to sit beside) means the install fails with a
+    /// clear message: an explicit location choice is never silently replaced.
+    #[cfg_attr(not(windows), allow(dead_code))]
+    pub fn resolve_root(root: &InstallRoot) -> Option<PathBuf> {
+        match root {
+            InstallRoot::Default => programs_dir(),
+            InstallRoot::NextToLauncher => std::env::current_exe().ok()?.parent().map(Path::to_path_buf),
+            InstallRoot::Custom(dir) => (!dir.as_os_str().is_empty() && dir.is_absolute()).then(|| dir.clone()),
+        }
+    }
+
+    /// Where `app`'s portable copy lives. Stable across versions (the zip's own top folder
+    /// is versioned and is flattened away on install).
+    #[cfg_attr(not(windows), allow(dead_code))]
+    pub fn portable_dir(app: &CraftApp, root: &InstallRoot) -> Option<PathBuf> {
+        resolve_root(root).map(|base| base.join(app.name))
+    }
+
+    /// The app's exe inside its portable folder (`<stem>.exe`, current stem first).
+    #[cfg_attr(not(windows), allow(dead_code))]
+    pub fn portable_exe(app: &CraftApp, dir: &Path) -> Option<PathBuf> {
+        app.stems().map(|s| dir.join(format!("{s}.exe"))).find(|p| p.is_file())
+    }
+
+    /// The Start Menu shortcut for a portable install.
+    #[cfg_attr(not(windows), allow(dead_code))]
+    pub fn shortcut_path(app: &CraftApp) -> Option<PathBuf> {
+        std::env::var_os("APPDATA").map(|root| PathBuf::from(root).join(r"Microsoft\Windows\Start Menu\Programs").join(format!("{}.lnk", app.name)))
+    }
+
+    /// Folder-picker script: the modern file-manager dialog in folder mode, owned by an
+    /// invisible topmost window (no extra window, no taskbar entry), so it can never hide
+    /// behind other apps and strand the user. Prints the chosen folder, or nothing when
+    /// cancelled. No new dependency.
+    #[cfg_attr(not(windows), allow(dead_code))]
+    pub(crate) fn browse_script() -> String {
+        "Add-Type -AssemblyName System.Windows.Forms; $f = New-Object System.Windows.Forms.Form; $f.TopMost = $true; $f.ShowInTaskbar = $false; $f.Opacity = 0; $f.Show(); $d = New-Object System.Windows.Forms.OpenFileDialog; $d.ValidateNames = $false; $d.CheckFileExists = $false; $d.CheckPathExists = $true; $d.FileName = 'Folder Selection.'; if ($d.ShowDialog($f) -eq 'OK') { Split-Path $d.FileName }; $f.Close()".to_string()
+    }
+
+    /// Keeps a picker answer only when it names a real folder.
+    #[cfg_attr(not(windows), allow(dead_code))]
+    pub(crate) fn parse_browse_output(out: &str) -> Option<PathBuf> {
+        let path = PathBuf::from(out.trim());
+        (!out.trim().is_empty() && path.is_dir()).then_some(path)
+    }
+
+    /// Asks Windows for a folder. Blocks while the picker is open (like the MSI installer
+    /// does); `None` anywhere else or on cancel.
+    pub fn browse_for_folder() -> Option<PathBuf> {
+        #[cfg(windows)]
+        {
+            crate::process::capture(
+                "powershell.exe",
+                &["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", &browse_script()],
+                std::time::Duration::from_secs(600),
+            )
+            .and_then(|out| parse_browse_output(&out))
+        }
+        #[cfg(not(windows))]
+        {
+            None
+        }
+    }
+
+    /// A PowerShell single-quoted literal.
+    #[cfg_attr(not(windows), allow(dead_code))]
+    pub(crate) fn ps_quote(path: &Path) -> String {
+        format!("'{}'", path.display().to_string().replace('\'', "''"))
+    }
+
+    #[cfg_attr(not(windows), allow(dead_code))]
+    pub(crate) fn powershell(script: &str) -> Result<std::process::ExitStatus, String> {
+        process::command("powershell.exe")
+            .args(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script])
+            .status()
+            .map_err(|e| format!("couldn't run PowerShell ({e})"))
+    }
+
+    /// `Expand-Archive` script: unpacks `zip` into a fresh `dest`.
+    #[cfg_attr(not(windows), allow(dead_code))]
+    pub(crate) fn expand_script(zip: &Path, dest: &Path) -> String {
+        format!(
+            "Remove-Item -LiteralPath {} -Recurse -Force -ErrorAction SilentlyContinue; New-Item -ItemType Directory -Path {} -Force | Out-Null; Expand-Archive -LiteralPath {} -DestinationPath {} -Force",
+            ps_quote(dest),
+            ps_quote(dest),
+            ps_quote(zip),
+            ps_quote(dest)
+        )
+    }
+
+    /// Shortcut-writing script (WScript.Shell, built into Windows: no new dependency).
+    #[cfg_attr(not(windows), allow(dead_code))]
+    pub(crate) fn shortcut_script(lnk: &Path, exe: &Path, dir: &Path) -> String {
+        format!(
+            "$ws = New-Object -ComObject WScript.Shell; $sc = $ws.CreateShortcut({lnk}); $sc.TargetPath = {exe}; $sc.WorkingDirectory = {dir}; $sc.Save()",
+            lnk = ps_quote(lnk),
+            exe = ps_quote(exe),
+            dir = ps_quote(dir)
+        )
+    }
+
+    /// Every file under `root`, relative to it (mirrors `tree::files`, which lives in the
+    /// unix-gated `tree` module).
+    #[cfg_attr(not(windows), allow(dead_code))]
+    pub(crate) fn walk_files(root: &Path) -> Vec<PathBuf> {
+        let mut out = Vec::new();
+        let mut stack = vec![root.to_path_buf()];
+        while let Some(dir) = stack.pop() {
+            for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
+                let p = entry.path();
+                if p.is_dir() {
+                    stack.push(p);
+                } else if let Ok(rel) = p.strip_prefix(root) {
+                    out.push(rel.to_path_buf());
+                }
+            }
+        }
+        out.sort();
+        out
+    }
+
+    /// The single top-level folder of an unpacked portable zip, if it has exactly one.
+    #[cfg_attr(not(windows), allow(dead_code))]
+    pub(crate) fn single_child_dir(dir: &Path) -> Option<PathBuf> {
+        let mut dirs: Vec<PathBuf> = std::fs::read_dir(dir).ok()?.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.is_dir()).collect();
+        if dirs.len() == 1 { dirs.pop() } else { None }
+    }
+
+    /// A destination the launcher didn't install into is never overwritten: an update
+    /// proceeds only when `dest` is missing, empty, or exactly the previous install's root,
+    /// so switching folders can't wipe an MSI-owned copy or user files (the swap below
+    /// deletes whatever the old folder holds).
+    #[cfg_attr(not(windows), allow(dead_code))]
+    pub(crate) fn dest_is_ours(dest: &Path, previous_root: Option<&Path>) -> bool {
+        if previous_root == Some(dest) {
+            return true;
+        }
+        std::fs::read_dir(dest).map(|mut d| d.next().is_none()).unwrap_or(true)
+    }
+
+    /// The published zips ship a `portable.txt` marker for manual installs; the launcher's
+    /// own record tracks the install instead, so a stale marker is never left to confuse
+    /// detection. Only that exact top-level name goes: anything else stays untouched.
+    #[cfg_attr(not(windows), allow(dead_code))]
+    pub(crate) fn drop_portable_marker(staging: &Path) {
+        let marker = staging.join("portable.txt");
+        if let Err(e) = std::fs::remove_file(&marker)
+            && e.kind() != std::io::ErrorKind::NotFound
+        {
+            log::warn!("couldn't remove {} ({e}); leaving it in place", marker.display());
+        }
+    }
+
+    /// Installs a portable zip. New install path (MSI stays the default): the zip's
+    /// directory is validated before anything is unpacked, then it unpacks to staging,
+    /// flattens the zip's single versioned top folder away, swaps the result into
+    /// `%LOCALAPPDATA%\Programs\<App>` (the new copy fully in place before the old one goes),
+    /// and writes a Start Menu shortcut (a shortcut failure only warns: the app runs fine
+    /// without it). A folder holding files the launcher didn't install is refused, unless
+    /// it is the previous install's own root. Returns the installed exe.
+    #[cfg_attr(not(windows), allow(dead_code))]
+    pub fn install_portable(app: &CraftApp, file: &Path, root: &InstallRoot, previous: Option<&Record>, stop: &Stop) -> Result<PathBuf, String> {
+        let dest = portable_dir(app, root).ok_or("can't use that folder for installs: pick an absolute path that exists or can be created")?;
+        if !dest_is_ours(&dest, previous.and_then(|r| r.root.as_deref())) {
+            return Err(format!("{} already has files the launcher didn't install; pick an empty folder", dest.display()));
+        }
+        // The checksum already passed: this rejects a hostile or broken zip before it can
+        // write outside its folder (a delta-assembled file is checked here too).
+        zip_fetch::read_local_cd(file).map_err(|e| format!("the download isn't a usable portable zip ({e})"))?;
+        let staging = dest.parent().map(|p| p.join(format!("{}.new", app.name))).ok_or("no local app-data folder")?;
+        let _ = std::fs::remove_dir_all(&staging);
+        powershell(&expand_script(file, &staging)).map_err(|e| format!("couldn't unpack the download ({e})"))?;
+        stop.check()?;
+        // The zip holds one versioned top folder; the install dir stays stable across updates.
+        if let Some(top) = single_child_dir(&staging) {
+            for child in std::fs::read_dir(&top).map_err(|e| format!("couldn't read the unpacked download ({e})"))?.flatten() {
+                let to = staging.join(child.file_name());
+                std::fs::rename(child.path(), &to).map_err(|e| format!("couldn't arrange the unpacked download ({e})"))?;
+            }
+            std::fs::remove_dir_all(&top).map_err(|e| format!("couldn't arrange the unpacked download ({e})"))?;
+        }
+        drop_portable_marker(&staging);
+        let staged_exe = portable_exe(app, &staging).ok_or_else(|| format!("the download has no {} executable", app.name))?;
+        let rel = staged_exe.strip_prefix(&staging).map_err(|_| "the download escaped its folder".to_string())?.to_path_buf();
+        stop.check()?;
+        super::swap_into_place(&staging, &dest)?;
+        let exe = dest.join(&rel);
+        if let Some(lnk) = shortcut_path(app) {
+            let dir = exe.parent().map(Path::to_path_buf).unwrap_or_else(|| dest.clone());
+            if powershell(&shortcut_script(&lnk, &exe, &dir)).is_err() {
+                log::warn!("couldn't write the Start Menu shortcut; {} still runs from {}", app.name, exe.display());
+            }
+        }
+        Ok(exe)
+    }
+
+    /// Removes a portable install the launcher owns: the Start Menu shortcut and the install
+    /// folder. Only the folder the install record pins (or, for records from before the
+    /// install location was choosable, the default one): anything else is left alone, so a
+    /// stray record can never delete user files.
+    #[cfg_attr(not(windows), allow(dead_code))]
+    pub fn remove_portable(app: &CraftApp, record: &Record) -> Result<(), String> {
+        if record.kind != PackageKind::PortableZip {
+            return Err(format!("{} isn't a portable install", app.name));
+        }
+        let allowed = record.root.clone().or_else(|| portable_dir(app, &InstallRoot::Default));
+        let exe = PathBuf::from(&record.path);
+        if allowed.as_deref() != exe.parent() || !allowed.as_deref().is_some_and(|d| d.is_dir()) {
+            return Err(format!("{} isn't where its portable install should be; it was left alone", app.name));
+        }
+        if let Some(lnk) = shortcut_path(app) {
+            let _ = std::fs::remove_file(&lnk);
+        }
+        let dir = allowed.ok_or_else(|| format!("{} isn't where its portable install should be; it was left alone", app.name))?;
+        std::fs::remove_dir_all(&dir).map_err(|e| format!("couldn't remove {} ({e})", dir.display()))?;
+        Ok(())
+    }
 }
 
 #[cfg(test)]
@@ -1250,7 +1548,7 @@ mod tests {
         let mut phases = Vec::new();
         for _ in 0..2 {
             let mut phase = |p: JobPhase| phases.push(p);
-            let mut job = Job { http: &http, paths: &paths, stop: &Stop::default(), phase: &mut phase };
+            let mut job = Job { http: &http, paths: &paths, stop: &Stop::default(), phase: &mut phase, install_root: InstallRoot::default() };
             let err = install(app, &release, &asset, kind, None, None, &mut job).unwrap_err();
             assert!(err.contains("isn't supported"), "{err}");
             assert_eq!(std::fs::read(download_path(&paths, &asset)).unwrap(), body);
@@ -1274,7 +1572,7 @@ mod tests {
         let kind = if cfg!(windows) { PackageKind::Dmg } else { PackageKind::Msi };
         let mut phases = Vec::new();
         let mut phase = |p: JobPhase| phases.push(p);
-        let mut job = Job { http: &http, paths: &paths, stop: &Stop::default(), phase: &mut phase };
+        let mut job = Job { http: &http, paths: &paths, stop: &Stop::default(), phase: &mut phase, install_root: InstallRoot::default() };
         let err = install(app, &release, &asset, kind, None, None, &mut job).unwrap_err();
         assert!(err.contains("publishes no checksum"), "{err}");
         assert!(!download_path(&paths, &asset).exists());
@@ -1569,6 +1867,142 @@ mod tests {
         let _ = inst;
     }
 
+    #[cfg(windows)]
+    #[test]
+    fn windows_offers_the_installer_first_and_portable_second() {
+        let kinds = available_kinds();
+        assert_eq!(kinds.first(), Some(&PackageKind::Msi));
+        assert!(kinds.contains(&PackageKind::PortableZip));
+    }
+
+    #[test]
+    fn portable_updates_stay_portable() {
+        use crate::manifest::Record;
+        let inst = Installed { version: None, source: InstallSource::Launcher, location: String::new(), package: None };
+        let record = Record {
+            version: "0.5.0".into(),
+            kind: PackageKind::PortableZip,
+            path: "x".into(),
+            installed_at: 1,
+            files: vec![],
+            needs_root: false,
+            root: None,
+        };
+        let kinds = choose_kinds(Some(&inst), Some(&record), &[]);
+        if available_kinds().contains(&PackageKind::PortableZip) {
+            assert_eq!(kinds, [PackageKind::PortableZip]);
+        }
+    }
+
+    #[test]
+    fn powershell_quoting_survives_apostrophes() {
+        let quoted = windows::ps_quote(Path::new(r"C:\Users\O'Brien\app.exe"));
+        assert_eq!(quoted, "'C:\\Users\\O''Brien\\app.exe'");
+    }
+
+    #[test]
+    fn shortcut_script_points_at_the_exe() {
+        let script = windows::shortcut_script(Path::new("C:\\s\\A.lnk"), Path::new("C:\\p\\a.exe"), Path::new("C:\\p"));
+        assert!(script.contains("'C:\\s\\A.lnk'") && script.contains("'C:\\p\\a.exe'"), "{script}");
+    }
+
+    #[test]
+    fn folder_picker_answers_only_real_folders() {
+        assert!(windows::browse_script().contains("OpenFileDialog"));
+        assert!(windows::browse_script().contains("ShowDialog($f)"));
+        assert!(!windows::browse_script().contains("FolderBrowserDialog"));
+        let dir = tempfile::tempdir().unwrap();
+        assert_eq!(windows::parse_browse_output(&format!("{}\r\n", dir.path().display())), Some(dir.path().to_path_buf()));
+        assert_eq!(windows::parse_browse_output(""), None);
+        assert_eq!(windows::parse_browse_output("   \r\n"), None);
+        assert_eq!(windows::parse_browse_output("C:\\no\\such\\dir"), None);
+    }
+
+    #[test]
+    fn single_child_dir_needs_exactly_one_folder() {
+        let dir = tempfile::tempdir().unwrap();
+        assert_eq!(windows::single_child_dir(dir.path()), None);
+        std::fs::create_dir(dir.path().join("v1")).unwrap();
+        std::fs::write(dir.path().join("note.txt"), "x").unwrap();
+        assert_eq!(windows::single_child_dir(dir.path()), Some(dir.path().join("v1")));
+        std::fs::create_dir(dir.path().join("v2")).unwrap();
+        assert_eq!(windows::single_child_dir(dir.path()), None);
+    }
+
+    #[test]
+    fn install_roots_resolve_safely() {
+        use artcraft_launcher_core::protocol::InstallRoot;
+        let def = windows::resolve_root(&InstallRoot::Default).unwrap();
+        assert!(def.ends_with("Programs"), "{}", def.display());
+        let exe_dir = windows::resolve_root(&InstallRoot::NextToLauncher).unwrap();
+        assert!(exe_dir.is_dir());
+        let custom = std::env::temp_dir().join("craft-root-test");
+        assert_eq!(windows::resolve_root(&InstallRoot::Custom(custom.clone())), Some(custom));
+        assert_eq!(windows::resolve_root(&InstallRoot::Custom(PathBuf::from("relative/dir"))), None);
+        assert_eq!(windows::resolve_root(&InstallRoot::Custom(PathBuf::new())), None);
+    }
+
+    #[test]
+    fn portable_removal_stays_inside_its_record() {
+        use crate::manifest::Record;
+        let app = CraftApp::by_id("wordcraft").unwrap();
+        let dir = tempfile::tempdir().unwrap();
+        let home = dir.path().join("WordCraft");
+        std::fs::create_dir(&home).unwrap();
+        std::fs::write(home.join("wordcraft.exe"), "x").unwrap();
+        let record = Record {
+            version: "0.5.0".into(),
+            kind: PackageKind::PortableZip,
+            path: home.join("wordcraft.exe"),
+            installed_at: 1,
+            files: vec![],
+            needs_root: false,
+            root: Some(home.clone()),
+        };
+        windows::remove_portable(app, &record).unwrap();
+        assert!(!home.exists());
+        // A record pointing elsewhere never deletes user files.
+        let elsewhere = dir.path().join("elsewhere");
+        std::fs::create_dir(&elsewhere).unwrap();
+        std::fs::write(elsewhere.join("wordcraft.exe"), "x").unwrap();
+        let stray = Record { path: elsewhere.join("wordcraft.exe"), ..record.clone() };
+        assert!(windows::remove_portable(app, &stray).is_err());
+        assert!(elsewhere.exists());
+    }
+
+    #[test]
+    fn portable_installs_refuse_unmanaged_folders() {
+        let dir = tempfile::tempdir().unwrap();
+        let missing = dir.path().join("missing");
+        assert!(windows::dest_is_ours(&missing, None), "fresh folder");
+        let empty = dir.path().join("empty");
+        std::fs::create_dir(&empty).unwrap();
+        assert!(windows::dest_is_ours(&empty, None), "empty folder");
+        // A folder with files the launcher didn't install is refused...
+        let lived_in = dir.path().join("lived-in");
+        std::fs::create_dir(&lived_in).unwrap();
+        std::fs::write(lived_in.join("mine.txt"), "x").unwrap();
+        assert!(!windows::dest_is_ours(&lived_in, None));
+        // ...unless it is the previous install's own root being updated.
+        assert!(windows::dest_is_ours(&lived_in, Some(&lived_in)));
+        let other = dir.path().join("other");
+        assert!(!windows::dest_is_ours(&lived_in, Some(&other)));
+    }
+
+    #[test]
+    fn portable_marker_drop_keeps_everything_else() {
+        let dir = tempfile::tempdir().unwrap();
+        let staging = dir.path().join("staging");
+        std::fs::create_dir(&staging).unwrap();
+        // Missing marker is fine: nothing to drop.
+        windows::drop_portable_marker(&staging);
+        std::fs::write(staging.join("portable.txt"), "x").unwrap();
+        std::fs::write(staging.join("app.exe"), "x").unwrap();
+        windows::drop_portable_marker(&staging);
+        assert!(!staging.join("portable.txt").exists());
+        assert!(staging.join("app.exe").exists(), "only the marker goes");
+    }
+
     #[cfg(target_os = "linux")]
     #[test]
     fn package_kinds_order_matches_launcher_format() {
diff --git a/crates/native/src/lib.rs b/crates/native/src/lib.rs
--- a/crates/native/src/lib.rs
+++ b/crates/native/src/lib.rs
@@ -24,6 +24,7 @@ pub mod screenshots;
 pub mod self_update;
 pub mod single_instance;
 pub mod source;
+pub mod zip_fetch;
 
 pub use backend::NativeBackend;
 
diff --git a/crates/native/src/manifest.rs b/crates/native/src/manifest.rs
--- a/crates/native/src/manifest.rs
+++ b/crates/native/src/manifest.rs
@@ -18,6 +18,10 @@ pub struct Record {
     /// Every file installed, for tree installs (FreeBSD), so removal takes exactly those.
     #[serde(default)]
     pub files: Vec<PathBuf>,
+    /// The folder a portable install went into, so removal takes exactly it even after the
+    /// install location changes. Older records leave this empty and fall back to the default.
+    #[serde(default)]
+    pub root: Option<PathBuf>,
     /// The files are in a system prefix and removing them needs root.
     #[serde(default)]
     pub needs_root: bool,
@@ -51,7 +55,15 @@ mod tests {
         let mut m = Manifest::default();
         m.apps.insert(
             "photocraft".into(),
-            Record { version: "0.5.0".into(), kind: PackageKind::AppImage, path: "/x/y".into(), installed_at: 1, files: vec![], needs_root: false },
+            Record {
+                version: "0.5.0".into(),
+                kind: PackageKind::AppImage,
+                path: "/x/y".into(),
+                installed_at: 1,
+                files: vec![],
+                needs_root: false,
+                root: None,
+            },
         );
         m.save(&path).unwrap();
         assert_eq!(Manifest::load(&path), m);
diff --git a/crates/native/src/self_update.rs b/crates/native/src/self_update.rs
--- a/crates/native/src/self_update.rs
+++ b/crates/native/src/self_update.rs
@@ -168,8 +168,10 @@ pub fn update(
     let (asset, _) = pick_asset(release, &[LAUNCHER.id], platform, &[install.kind()])
         .ok_or_else(|| format!("ArtCraft Launcher {} has no {} for {}", release.version_label(), install.kind().label(), platform.label()))?;
     let asset = asset.clone();
-    let mut job = Job { http, paths, stop, phase };
-    let file = install::download(release, &asset, &mut job)?;
+    let mut job = Job { http, paths, stop, phase, install_root: artcraft_launcher_core::protocol::InstallRoot::default() };
+    // Self-updates stay full downloads (the restart helper replaces a running binary; the
+    // portable delta is for apps).
+    let file = install::download(release, &asset, install.kind(), LAUNCHER.id, &mut job)?;
     // The restart ends this process: nothing else may be under way.
     last()?;
     phase(JobPhase::Installing);
diff --git a/crates/native/src/zip_fetch.rs b/crates/native/src/zip_fetch.rs
new file mode 100644
--- /dev/null
+++ b/crates/native/src/zip_fetch.rs
@@ -0,0 +1,330 @@
+//! Portable-zip deltas: fetch only the entries that changed.
+//!
+//! A portable `.zip` carries everything a delta needs inside itself: the Central Directory at
+//! the end lists every entry (name, CRC32, sizes, offsets), and entries are compressed
+//! independently. Entries matching the retained previous installer by name, CRC32,
+//! compressed size and method are copied byte-exact from it; only the rest (plus every tiny
+//! local header) travels over HTTP range requests. Compressed bytes are copied opaquely and
+//! never decompressed. Like every other fetch here, the assembly is SHA-256-verified against
+//! the release's published checksum exactly like a full download (#90), and any failure
+//! means the caller falls back to that full download.
+//!
+//! Retention (keeping one previous installer per portable install) lives here too: it is the
+//! launcher's own decision in its own download folder, needing nothing published.
+
+use std::fs::File;
+use std::io::{Read, Seek, SeekFrom, Write};
+use std::path::{Path, PathBuf};
+
+use crate::http::{Http, Stop};
+use crate::paths::Paths;
+use artcraft_launcher_core::zip_cd;
+
+/// How much of the file's tail is fetched to find the End-of-Central-Directory record.
+const TAIL_MAX: u64 = 128 << 10;
+/// Largest single local read (old installer chunks are small; the cap is only a backstop).
+const MAX_READ: u64 = 1 << 30;
+/// Extra bytes fetched past a local header's declared name, covering its extra field: the
+/// response is trimmed to the header's true length, so this only ever wastes a kilobyte.
+const HEADER_SLACK: u64 = 1024;
+
+pub struct ZipJob<'a> {
+    pub http: &'a Http,
+    /// The new asset's download URL.
+    pub base_url: &'a str,
+    /// The new asset's size (0 when unknown: no tail math is possible).
+    pub file_size: u64,
+    /// The retained previous installer to copy unchanged entries from.
+    pub old_path: &'a Path,
+    pub tmp_path: &'a Path,
+    pub expected_sha256: &'a str,
+    pub stop: &'a Stop,
+    pub progress: &'a mut dyn FnMut(u64, u64),
+}
+
+/// Three attempts, reporting the first error: a flaky chunk is retried, a deterministic
+/// refusal (range ignored, file gone) keeps its meaning.
+fn retrying(stop: &Stop, mut work: impl FnMut() -> Result<Vec<u8>, String>) -> Result<Vec<u8>, String> {
+    let mut first_err: Option<String> = None;
+    for _ in 0..3 {
+        match work() {
+            Ok(b) => return Ok(b),
+            Err(err) => {
+                stop.check()?;
+                if first_err.is_none() {
+                    first_err = Some(err);
+                }
+            }
+        }
+    }
+    match first_err {
+        Some(err) => Err(err),
+        None => Err("delta fetch failed".into()),
+    }
+}
+
+fn read_at(path: &Path, off: u64, len: u64) -> Result<Vec<u8>, String> {
+    if len > MAX_READ {
+        return Err("range too large".into());
+    }
+    let mut file = File::open(path).map_err(|e| format!("couldn't read {} ({e})", path.display()))?;
+    file.seek(SeekFrom::Start(off)).map_err(|e| format!("couldn't read {} ({e})", path.display()))?;
+    let mut buf = Vec::new();
+    file.take(len).read_to_end(&mut buf).map_err(|e| format!("couldn't read {} ({e})", path.display()))?;
+    if buf.len() as u64 != len {
+        return Err(format!("short read of {}", path.display()));
+    }
+    Ok(buf)
+}
+
+/// A zip's validated Central Directory, read from disk: every entry is name-safe (no
+/// traversal, absolute paths, streams, reserved names, duplicates, symlinks or bad
+/// methods) before anything is unpacked, so a hostile or broken zip fails before it can
+/// write outside its folder.
+pub(crate) fn read_local_cd(path: &Path) -> Result<Vec<zip_cd::Entry>, String> {
+    let len = std::fs::metadata(path).map_err(|e| format!("couldn't read {} ({e})", path.display()))?.len();
+    let tail = read_at(path, len.saturating_sub(TAIL_MAX), len.min(TAIL_MAX))?;
+    let eocd = zip_cd::find_eocd(&tail, len)?;
+    let cd_bytes = read_at(path, eocd.cd_offset, eocd.cd_size)?;
+    let entries = zip_cd::parse_cd(&cd_bytes, eocd.count)?;
+    zip_cd::validate_entries(&entries)?;
+    Ok(entries)
+}
+
+/// Where `old` keeps the previous installer for deltas: a subfolder of the downloads folder.
+/// `clean_downloads` only touches files directly inside downloads, so this folder is left alone.
+pub fn kept_dir(paths: &Paths, app_id: &str) -> PathBuf {
+    paths.downloads().join(".kept").join(app_id)
+}
+
+/// The retained installer, when exactly one is kept. Anything else (none, several, a
+/// directory) fails safe towards a full download.
+pub fn find_kept(paths: &Paths, app_id: &str) -> Option<PathBuf> {
+    let mut files: Vec<PathBuf> = std::fs::read_dir(kept_dir(paths, app_id)).ok()?.flatten().map(|e| e.path()).filter(|p| p.is_file()).collect();
+    (files.len() == 1).then(|| files.pop()).flatten()
+}
+
+/// Keeps `file` as the next delta base, pruning anything previously kept. Best-effort: a
+/// retention failure must never fail the install that just succeeded.
+pub fn retain_installer(paths: &Paths, app_id: &str, file: &Path) {
+    let dir = kept_dir(paths, app_id);
+    if std::fs::create_dir_all(&dir).is_err() {
+        return;
+    }
+    let name = match file.file_name() {
+        Some(n) => n.to_os_string(),
+        None => return,
+    };
+    if std::fs::rename(file, dir.join(&name)).is_err() {
+        return;
+    }
+    if let Ok(rd) = std::fs::read_dir(&dir) {
+        for e in rd.flatten() {
+            if e.path().file_name() != Some(name.as_os_str()) {
+                let _ = std::fs::remove_file(e.path());
+            }
+        }
+    }
+}
+
+/// Delta-updates the retained installer to `tmp_path`: the new directory is range-fetched,
+/// unchanged entries are copied from the retained file, the rest is range-fetched, and the
+/// assembly (local blocks in order, directory tail verbatim) is SHA-256-verified. Progress
+/// hears `(done, total)` bytes. Any error means the caller falls back to a full download,
+/// except pause and cancel, which propagate unchanged.
+pub fn fetch_zip_delta(job: ZipJob<'_>) -> Result<(), String> {
+    let ZipJob { http, base_url, file_size, old_path, tmp_path, expected_sha256, stop, progress } = job;
+    stop.check()?;
+    if file_size == 0 {
+        return Err("unknown file size".into());
+    }
+    // The new directory: tail, then the directory itself.
+    let tail_len = file_size.min(TAIL_MAX);
+    let tail = retrying(stop, || http.get_range(base_url, file_size - tail_len, file_size - 1))?;
+    let eocd = zip_cd::find_eocd(&tail, file_size)?;
+    let cd_bytes = retrying(stop, || http.get_range(base_url, eocd.cd_offset, eocd.cd_offset + eocd.cd_size - 1))?;
+    let new_cd = zip_cd::parse_cd(&cd_bytes, eocd.count)?;
+    // Fail fast before fetching entry data: a hostile directory never becomes ranges.
+    zip_cd::validate_entries(&new_cd)?;
+    // The old directory, entirely from disk.
+    let old_len = std::fs::metadata(old_path).map_err(|e| format!("couldn't read {} ({e})", old_path.display()))?.len();
+    let old_tail = read_at(old_path, old_len.saturating_sub(TAIL_MAX), old_len.min(TAIL_MAX))?;
+    let old_eocd = zip_cd::find_eocd(&old_tail, old_len)?;
+    let old_cd_bytes = read_at(old_path, old_eocd.cd_offset, old_eocd.cd_size)?;
+    let old_cd = zip_cd::parse_cd(&old_cd_bytes, old_eocd.count)?;
+    let plan = zip_cd::plan(&old_cd, &new_cd)?;
+    if let (Some(o), Some(n)) =
+        (zip_cd::common_top(old_cd.iter().map(|e| e.name.as_str())), zip_cd::common_top(new_cd.iter().map(|e| e.name.as_str())))
+        && o != n
+    {
+        log::info!("matching entries by relative paths under '{o}' vs '{n}'");
+    }
+    if !plan.iter().any(|p| matches!(p, zip_cd::Need::Copy { .. })) {
+        return Err("nothing reusable; a full download is cheaper".into());
+    }
+    let mut out = File::create(tmp_path).map_err(|e| format!("couldn't save the download ({e})"))?;
+    let mut written: u64 = 0;
+    progress(0, file_size);
+    for (entry, need) in new_cd.iter().zip(plan.iter()) {
+        stop.check()?;
+        // Every entry's local header travels (it is tiny): its true length trims the slack,
+        // and its declared values must match the directory (unless a data descriptor holds
+        // the real CRC/size after the data instead).
+        let name_len = entry.name.len() as u64;
+        let head_end = (entry.local_offset + 30 + name_len + HEADER_SLACK).min(file_size.saturating_sub(1));
+        let head = retrying(stop, || http.get_range(base_url, entry.local_offset, head_end))?;
+        let lh = zip_cd::parse_local_header(&head)?;
+        if lh.name_len as u64 != name_len || lh.method != entry.method {
+            return Err("local header doesn't match the directory".into());
+        }
+        if !lh.has_descriptor && (lh.crc != entry.crc || lh.comp_size != entry.comp_size) {
+            return Err("local header doesn't match the directory".into());
+        }
+        let hlen = 30u64 + lh.name_len as u64 + lh.extra_len as u64;
+        let header = head.get(..hlen as usize).ok_or("local header longer than fetched")?;
+        let data = match need {
+            zip_cd::Need::Copy { old_index } => {
+                let old_entry = old_cd.get(*old_index).ok_or("plan doesn't match the old directory")?;
+                let old_head = read_at(old_path, old_entry.local_offset, 30)?;
+                let old_lh = zip_cd::parse_local_header(&old_head)?;
+                read_at(old_path, old_entry.local_offset + 30 + old_lh.name_len as u64 + old_lh.extra_len as u64, entry.comp_size)?
+            }
+            zip_cd::Need::Fetch => {
+                if entry.comp_size == 0 {
+                    Vec::new()
+                } else {
+                    let data_start = entry.local_offset + hlen;
+                    retrying(stop, || http.get_range(base_url, data_start, data_start + entry.comp_size - 1))?
+                }
+            }
+        };
+        out.write_all(header).map_err(|e| format!("couldn't save the download ({e})"))?;
+        out.write_all(&data).map_err(|e| format!("couldn't save the download ({e})"))?;
+        written += hlen + data.len() as u64;
+        progress(written, file_size);
+    }
+    // The directory tail verbatim: central directory plus end record.
+    let cd_tail = retrying(stop, || http.get_range(base_url, eocd.cd_offset, file_size - 1))?;
+    out.write_all(&cd_tail).map_err(|e| format!("couldn't save the download ({e})"))?;
+    written += cd_tail.len() as u64;
+    out.sync_all().map_err(|e| format!("couldn't save the download ({e})"))?;
+    drop(out);
+    progress(written, file_size);
+    if written != file_size {
+        let _ = std::fs::remove_file(tmp_path);
+        return Err("assembled size doesn't match".into());
+    }
+    stop.check()?;
+    if !crate::http::sha256_file(tmp_path).is_ok_and(|h| h.eq_ignore_ascii_case(expected_sha256)) {
+        let _ = std::fs::remove_file(tmp_path);
+        return Err("the delta update doesn't match its published checksum, so it wasn't installed".into());
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn kept_files_round_trip_and_prune() {
+        let dir = tempfile::tempdir().unwrap();
+        let paths = Paths::under(dir.path());
+        assert_eq!(find_kept(&paths, "wordcraft"), None);
+        let src = dir.path().join("dl").join("wordcraft-0.6.1.zip");
+        std::fs::create_dir_all(src.parent().unwrap()).unwrap();
+        std::fs::write(&src, b"v1").unwrap();
+        retain_installer(&paths, "wordcraft", &src);
+        assert!(!src.exists(), "retained, not copied");
+        assert_eq!(find_kept(&paths, "wordcraft").map(|p| std::fs::read(&p).unwrap()), Some(b"v1".to_vec()));
+        // A newer keep replaces the older one: at most one base is ever stored.
+        let src2 = dir.path().join("dl").join("wordcraft-0.6.2.zip");
+        std::fs::write(&src2, b"v2").unwrap();
+        retain_installer(&paths, "wordcraft", &src2);
+        assert_eq!(find_kept(&paths, "wordcraft").map(|p| std::fs::read(&p).unwrap()), Some(b"v2".to_vec()));
+        // A stray second file disables the delta base rather than confusing it.
+        std::fs::write(kept_dir(&paths, "wordcraft").join("stray.zip"), b"x").unwrap();
+        assert_eq!(find_kept(&paths, "wordcraft"), None);
+    }
+
+    #[test]
+    fn short_reads_fail_safe() {
+        let dir = tempfile::tempdir().unwrap();
+        let f = dir.path().join("f.bin");
+        std::fs::write(&f, b"12345").unwrap();
+        assert_eq!(read_at(&f, 1, 3).unwrap(), b"234");
+        assert!(read_at(&f, 4, 3).is_err(), "past the end");
+        assert!(read_at(&f, 0, MAX_READ + 1).is_err(), "capped");
+        assert!(read_at(&dir.path().join("missing"), 0, 1).is_err());
+    }
+
+    /// A minimal stored-entry zip, built by hand: local file, central directory, end record.
+    fn stored_zip(name: &str, data: &[u8]) -> Vec<u8> {
+        fn u16le(v: u16, out: &mut Vec<u8>) {
+            out.extend_from_slice(&v.to_le_bytes());
+        }
+        fn u32le(v: u32, out: &mut Vec<u8>) {
+            out.extend_from_slice(&v.to_le_bytes());
+        }
+        let mut zip = vec![];
+        zip.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
+        u16le(20, &mut zip);
+        u16le(0, &mut zip);
+        u16le(0, &mut zip);
+        u16le(0, &mut zip);
+        u16le(0, &mut zip);
+        u32le(0, &mut zip);
+        u32le(data.len() as u32, &mut zip);
+        u32le(data.len() as u32, &mut zip);
+        u16le(name.len() as u16, &mut zip);
+        u16le(0, &mut zip);
+        zip.extend_from_slice(name.as_bytes());
+        let local_len = zip.len() as u64;
+        zip.extend_from_slice(data);
+        let cd_off = zip.len() as u64;
+        zip.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02]);
+        u16le(20, &mut zip);
+        u16le(20, &mut zip);
+        u16le(0, &mut zip);
+        u16le(0, &mut zip);
+        u16le(0, &mut zip);
+        u16le(0, &mut zip);
+        u32le(0, &mut zip);
+        u32le(data.len() as u32, &mut zip);
+        u32le(data.len() as u32, &mut zip);
+        u16le(name.len() as u16, &mut zip);
+        for _ in 0..4 {
+            u16le(0, &mut zip);
+        }
+        u32le(0, &mut zip);
+        u32le(0, &mut zip);
+        zip.extend_from_slice(name.as_bytes());
+        let cd_len = zip.len() as u64 - cd_off;
+        zip.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]);
+        for _ in 0..2 {
+            u16le(0, &mut zip);
+        }
+        u16le(1, &mut zip);
+        u16le(1, &mut zip);
+        u32le(cd_len as u32, &mut zip);
+        u32le(cd_off as u32, &mut zip);
+        u16le(0, &mut zip);
+        assert_eq!(local_len, 30 + name.len() as u64, "local header math");
+        zip
+    }
+
+    #[test]
+    fn local_directories_validate_before_unpack() {
+        let dir = tempfile::tempdir().unwrap();
+        let good = dir.path().join("good.zip");
+        std::fs::write(&good, stored_zip("app/file.txt", b"hi")).unwrap();
+        let entries = read_local_cd(&good).unwrap();
+        assert_eq!(entries.len(), 1);
+        assert_eq!(entries[0].name, "app/file.txt");
+        // A traversal entry fails before anything is unpacked, not after.
+        let evil = dir.path().join("evil.zip");
+        std::fs::write(&evil, stored_zip("../evil.txt", b"hi")).unwrap();
+        assert!(read_local_cd(&evil).is_err(), "traversal");
+        assert!(read_local_cd(&dir.path().join("missing.zip")).is_err());
+    }
+}
diff --git a/crates/native/tests/zip_delta_update.rs b/crates/native/tests/zip_delta_update.rs
new file mode 100644
--- /dev/null
+++ b/crates/native/tests/zip_delta_update.rs
@@ -0,0 +1,260 @@
+//! Portable-zip deltas end to end, without the real network: a local HTTP server plays
+//! the release host serving a synthetic new zip, the retained old zip sits on disk, and
+//! `fetch_zip_delta` must rebuild the new bytes while asking only for the directory and the
+//! changed entries. Stored (uncompressed) entries keep the fixture builder free of any
+//! compression library; the parser under test never decompresses.
+
+use std::io::{BufRead, BufReader, Write};
+use std::net::TcpListener;
+use std::sync::{Arc, Mutex};
+
+use artcraft_launcher_core::zip_cd;
+use artcraft_launcher_native::http::{Http, Stop};
+use artcraft_launcher_native::zip_fetch::{self, ZipJob};
+use sha2::{Digest as _, Sha256};
+
+/// Builds a minimal stored-method zip: local headers + central directory + end record.
+fn build_zip(files: &[(&str, &[u8])]) -> Vec<u8> {
+    let mut out = Vec::new();
+    let mut cd = Vec::new();
+    let mut off: u32 = 0;
+    for (name, data) in files {
+        let crc = zip_cd::crc32(data);
+        let len = data.len() as u32;
+        let name_len = name.len() as u16;
+        out.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
+        out.extend_from_slice(&20u16.to_le_bytes());
+        out.extend_from_slice(&0u16.to_le_bytes());
+        out.extend_from_slice(&0u16.to_le_bytes());
+        out.extend_from_slice(&0u16.to_le_bytes());
+        out.extend_from_slice(&0u16.to_le_bytes());
+        out.extend_from_slice(&crc.to_le_bytes());
+        out.extend_from_slice(&len.to_le_bytes());
+        out.extend_from_slice(&len.to_le_bytes());
+        out.extend_from_slice(&name_len.to_le_bytes());
+        out.extend_from_slice(&0u16.to_le_bytes());
+        out.extend_from_slice(name.as_bytes());
+        out.extend_from_slice(data);
+        cd.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02]);
+        cd.extend_from_slice(&20u16.to_le_bytes());
+        cd.extend_from_slice(&20u16.to_le_bytes());
+        cd.extend_from_slice(&0u16.to_le_bytes());
+        cd.extend_from_slice(&0u16.to_le_bytes());
+        cd.extend_from_slice(&0u16.to_le_bytes());
+        cd.extend_from_slice(&0u16.to_le_bytes());
+        cd.extend_from_slice(&crc.to_le_bytes());
+        cd.extend_from_slice(&len.to_le_bytes());
+        cd.extend_from_slice(&len.to_le_bytes());
+        cd.extend_from_slice(&name_len.to_le_bytes());
+        cd.extend_from_slice(&0u16.to_le_bytes());
+        cd.extend_from_slice(&0u16.to_le_bytes());
+        cd.extend_from_slice(&0u16.to_le_bytes());
+        cd.extend_from_slice(&0u16.to_le_bytes());
+        cd.extend_from_slice(&[0u8; 4]);
+        cd.extend_from_slice(&off.to_le_bytes());
+        cd.extend_from_slice(name.as_bytes());
+        off += 30 + name.len() as u32 + len;
+    }
+    let cd_off = off;
+    out.extend_from_slice(&cd);
+    out.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0]);
+    out.extend_from_slice(&(files.len() as u16).to_le_bytes());
+    out.extend_from_slice(&(files.len() as u16).to_le_bytes());
+    out.extend_from_slice(&(cd.len() as u32).to_le_bytes());
+    out.extend_from_slice(&cd_off.to_le_bytes());
+    out.extend_from_slice(&0u16.to_le_bytes());
+    out
+}
+
+/// Serves `body`: exact byte ranges with 206s (`ranged`), or always the whole body with a 200
+/// (`!ranged`, proving a range-ignoring server fails safe). Records every `Range` it saw.
+fn serve(body: Vec<u8>, ranged: bool, expect: usize) -> (String, Arc<Mutex<Vec<String>>>) {
+    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+    let url = format!("http://{}/wordcraft-0.6.2-windows-x64-portable.zip", listener.local_addr().unwrap());
+    let hits = Arc::new(Mutex::new(Vec::new()));
+    let seen = hits.clone();
+    std::thread::spawn(move || {
+        for _ in 0..expect {
+            let Ok((mut stream, _)) = listener.accept() else { return };
+            let mut range: Option<(u64, u64)> = None;
+            let mut reader = BufReader::new(stream.try_clone().unwrap());
+            loop {
+                let mut line = String::new();
+                if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
+                    break;
+                }
+                if let Some(v) = line.to_ascii_lowercase().strip_prefix("range: bytes=") {
+                    let mut parts = v.trim().split('-');
+                    if let (Some(Ok(s)), Some(Ok(e))) = (parts.next().map(str::parse), parts.next().map(str::parse)) {
+                        range = Some((s, e));
+                    }
+                }
+            }
+            if ranged
+                && let Some((s, e)) = range
+                && let Some(slice) = body.get(s as usize..=e as usize)
+            {
+                seen.lock().unwrap().push(format!("{s}-{e}"));
+                let _ = write!(
+                    stream,
+                    "HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {s}-{}/{}\r\nConnection: close\r\n\r\n",
+                    slice.len(),
+                    e,
+                    body.len()
+                )
+                .and_then(|()| stream.write_all(slice));
+            } else {
+                let _ = write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len())
+                    .and_then(|()| stream.write_all(&body));
+            }
+        }
+    });
+    (url, hits)
+}
+
+fn sha256_hex(bytes: &[u8]) -> String {
+    format!("{:x}", Sha256::digest(bytes))
+}
+
+fn old_zip() -> Vec<u8> {
+    // Bulk stays identical so the tail request cannot cover it: the delta must skip it.
+    build_zip(&[("app/a.txt", &[1u8; 400000]), ("app/b.txt", &[2u8; 200]), ("app/c.txt", &[3u8; 400000])])
+}
+
+fn new_zip() -> Vec<u8> {
+    build_zip(&[("app/a.txt", &[1u8; 400000]), ("app/b.txt", &[9u8; 200]), ("app/c.txt", &[3u8; 400000]), ("app/d.txt", &[4u8; 300])])
+}
+
+fn travelled(hits: &Arc<Mutex<Vec<String>>>) -> u64 {
+    hits.lock().unwrap().iter().map(|h| h.split_once('-').map_or(0, |(s, e)| e.parse::<u64>().unwrap_or(0) - s.parse::<u64>().unwrap_or(0) + 1)).sum()
+}
+
+#[test]
+fn delta_rebuilds_the_new_zip_from_changed_entries_only() {
+    let old = old_zip();
+    let new = new_zip();
+    // Requests: tail + directory + one header per new entry (4) + changed data (2) + tail copy (1).
+    let (url, hits) = serve(new.clone(), true, 9);
+    let dir = tempfile::tempdir().unwrap();
+    let tmp = dir.path().join("new.zip.delta.tmp");
+    let old_path = dir.path().join("old.zip");
+    std::fs::write(&old_path, &old).unwrap();
+    let mut progress = Vec::new();
+    zip_fetch::fetch_zip_delta(ZipJob {
+        http: &Http::new().unwrap(),
+        base_url: &url,
+        file_size: new.len() as u64,
+        old_path: &old_path,
+        tmp_path: &tmp,
+        expected_sha256: &sha256_hex(&new),
+        stop: &Stop::default(),
+        progress: &mut |done, total| progress.push((done, total)),
+    })
+    .unwrap();
+    assert_eq!(std::fs::read(&tmp).unwrap(), new);
+    assert_eq!(progress.last(), Some(&(new.len() as u64, new.len() as u64)));
+    // Two entries' data plus small headers travelled: far less than the whole zip.
+    assert!(travelled(&hits) < new.len() as u64 / 2, "{} of {} bytes", travelled(&hits), new.len());
+    assert_eq!(hits.lock().unwrap().len(), 9);
+}
+
+#[test]
+fn delta_refuses_a_wrong_checksum_and_leaves_no_file() {
+    let old = old_zip();
+    let new = new_zip();
+    let (url, _) = serve(new.clone(), true, 9);
+    let dir = tempfile::tempdir().unwrap();
+    let tmp = dir.path().join("new.zip.delta.tmp");
+    let old_path = dir.path().join("old.zip");
+    std::fs::write(&old_path, &old).unwrap();
+    let err = zip_fetch::fetch_zip_delta(ZipJob {
+        http: &Http::new().unwrap(),
+        base_url: &url,
+        file_size: new.len() as u64,
+        old_path: &old_path,
+        tmp_path: &tmp,
+        expected_sha256: &"0".repeat(64),
+        stop: &Stop::default(),
+        progress: &mut |_, _| {},
+    })
+    .unwrap_err();
+    assert!(err.contains("checksum"), "{err}");
+    assert!(!tmp.exists());
+}
+
+#[test]
+fn delta_fails_safe_when_the_server_ignores_ranges() {
+    let old = old_zip();
+    let new = new_zip();
+    // Three attempts at the tail before giving up with the first refusal.
+    let (full_url, _) = serve(new.clone(), false, 3);
+    let dir = tempfile::tempdir().unwrap();
+    let tmp = dir.path().join("new.zip.delta.tmp");
+    let old_path = dir.path().join("old.zip");
+    std::fs::write(&old_path, &old).unwrap();
+    let err = zip_fetch::fetch_zip_delta(ZipJob {
+        http: &Http::new().unwrap(),
+        base_url: &full_url,
+        file_size: new.len() as u64,
+        old_path: &old_path,
+        tmp_path: &tmp,
+        expected_sha256: &sha256_hex(&new),
+        stop: &Stop::default(),
+        progress: &mut |_, _| {},
+    })
+    .unwrap_err();
+    assert!(err.contains("ignored"), "{err}");
+    assert!(!tmp.exists());
+}
+
+#[test]
+fn nothing_reusable_skips_the_delta() {
+    let new = new_zip();
+    // Tail + directory, then the plan finds no shared entry and stops before entry fetches.
+    let (url, hits) = serve(new.clone(), true, 2);
+    let dir = tempfile::tempdir().unwrap();
+    let tmp = dir.path().join("new.zip.delta.tmp");
+    let old_path = dir.path().join("old.zip");
+    std::fs::write(&old_path, build_zip(&[("other/z.txt", &[7u8; 60])])).unwrap();
+    let err = zip_fetch::fetch_zip_delta(ZipJob {
+        http: &Http::new().unwrap(),
+        base_url: &url,
+        file_size: new.len() as u64,
+        old_path: &old_path,
+        tmp_path: &tmp,
+        expected_sha256: &sha256_hex(&new),
+        stop: &Stop::default(),
+        progress: &mut |_, _| {},
+    })
+    .unwrap_err();
+    assert!(err.contains("nothing reusable"), "{err}");
+    assert_eq!(hits.lock().unwrap().len(), 2);
+    assert!(!tmp.exists());
+}
+
+#[test]
+fn delta_reuses_entries_across_renamed_top_folders() {
+    // Publishers wrap each release in a versioned top folder: only the top differs,
+    // so the identical entry must copy from the base while the changed one fetches.
+    let old = build_zip(&[("v1/a.txt", &[1u8; 400000]), ("v1/b.txt", &[2u8; 200])]);
+    let new = build_zip(&[("v2/a.txt", &[1u8; 400000]), ("v2/b.txt", &[9u8; 200])]);
+    // Tail + directory + one header per entry (2) + changed data (1) + tail copy (1).
+    let (url, hits) = serve(new.clone(), true, 6);
+    let dir = tempfile::tempdir().unwrap();
+    let tmp = dir.path().join("new.zip.delta.tmp");
+    let old_path = dir.path().join("old.zip");
+    std::fs::write(&old_path, &old).unwrap();
+    zip_fetch::fetch_zip_delta(ZipJob {
+        http: &Http::new().unwrap(),
+        base_url: &url,
+        file_size: new.len() as u64,
+        old_path: &old_path,
+        tmp_path: &tmp,
+        expected_sha256: &sha256_hex(&new),
+        stop: &Stop::default(),
+        progress: &mut |_, _| {},
+    })
+    .unwrap();
+    assert_eq!(std::fs::read(&tmp).unwrap(), new);
+    assert!(travelled(&hits) < new.len() as u64 / 2, "{} of {} bytes", travelled(&hits), new.len());
+}
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -60,6 +60,15 @@ pub trait Host {
     }
     /// Quit the launcher for real (not just hide to the tray).
     fn quit(&mut self) {}
+    /// Opens an OS folder picker. Returns immediately: the picker runs off the UI thread so a
+    /// hidden dialog can never hang the app; [`Host::poll_browse_folder`] collects the answer.
+    /// Does nothing where unsupported. A second call while one runs is ignored.
+    fn start_browse_folder(&mut self) {}
+    /// A finished picker, if any: `Some(dir)` chosen, `Some(None)` cancelled or failed, `None`
+    /// when nothing is pending. Each answer is reported once.
+    fn poll_browse_folder(&mut self) -> Option<Option<std::path::PathBuf>> {
+        None
+    }
 }
 
 pub(crate) struct Toast {
@@ -266,7 +275,11 @@ impl LauncherUi {
     /// Non-drawing work: runs every frame and also while the window is hidden in the tray.
     pub fn logic(&mut self, ctx: &egui::Context, host: &mut dyn Host) {
         set_current(crate::i18n::Lang::from_pref(&self.settings.language));
-        let options = BackendOptions { kinds: self.settings.preferred_kinds.clone(), prereleases: self.settings.prereleases };
+        let options = BackendOptions {
+            kinds: self.settings.preferred_kinds.clone(),
+            prereleases: self.settings.prereleases,
+            install_root: self.settings.install_root.clone(),
+        };
         if self.sent_options.as_ref() != Some(&options) {
             self.backend.send(Command::Configure(options.clone()));
             self.sent_options = Some(options);
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -1,5 +1,7 @@
 //! Modal dialogs: release notes, uninstall confirmation, settings.
 
+use std::path::PathBuf;
+
 use crate::i18n::fmt;
 use artcraft_launcher_core::protocol::Command;
 use artcraft_launcher_core::{Os, PackageKind};
@@ -318,6 +320,81 @@ impl LauncherUi {
             ui.label(RichText::new(tl!("Updates always use the format an app was installed with.")).size(12.0).color(t.muted));
         }
 
+        if self.caps.manage_apps && self.platform.is_some_and(|p| p.os == Os::Windows) {
+            section(ui, tl!("Installing on Windows"));
+            let mut choice = self.settings.preferred_kinds.first().copied();
+            row(ui, tl!("Install new apps as"), 210.0, &mut |ui| {
+                let label = choice.map_or(tl!("Installer (recommended)"), |k| crate::i18n::t(k.label()));
+                egui::ComboBox::from_id_salt("kind").selected_text(label).width(210.0).show_ui(ui, |ui| {
+                    ui.selectable_value(&mut choice, None, tl!("Installer (recommended)"));
+                    for k in [PackageKind::Msi, PackageKind::PortableZip] {
+                        ui.selectable_value(&mut choice, Some(k), crate::i18n::t(k.label()));
+                    }
+                });
+            });
+            self.settings.preferred_kinds = choice.into_iter().collect();
+            ui.label(RichText::new(tl!("Updates always use the format an app was installed with.")).size(12.0).color(t.muted));
+            // The location only matters for portable installs: an MSI goes where the
+            // Windows Installer puts it, whatever is chosen here.
+            if choice == Some(PackageKind::PortableZip) {
+                use artcraft_launcher_core::protocol::InstallRoot;
+                let mut tag = match self.settings.install_root {
+                    InstallRoot::Default => 0,
+                    InstallRoot::NextToLauncher => 1,
+                    InstallRoot::Custom(_) => 2,
+                };
+                let mut custom = match &self.settings.install_root {
+                    InstallRoot::Custom(p) => p.display().to_string(),
+                    _ => String::new(),
+                };
+                row(ui, tl!("Install location"), 210.0, &mut |ui| {
+                    egui::ComboBox::from_id_salt("install-root")
+                        .selected_text(match tag {
+                            1 => tl!("Next to the launcher"),
+                            2 => tl!("Custom folder"),
+                            _ => tl!("Default location"),
+                        })
+                        .width(210.0)
+                        .show_ui(ui, |ui| {
+                            ui.selectable_value(&mut tag, 0, tl!("Default location"));
+                            ui.selectable_value(&mut tag, 1, tl!("Next to the launcher"));
+                            ui.selectable_value(&mut tag, 2, tl!("Custom folder"));
+                        });
+                });
+                if tag == 2 {
+                    // A finished picker applies once; it ran off the UI thread, so even a
+                    // hidden dialog can never hang the app.
+                    if let Some(picked) = host.poll_browse_folder()
+                        && let Some(dir) = picked
+                    {
+                        custom = dir.display().to_string();
+                        self.settings.install_root = InstallRoot::Custom(dir);
+                    }
+                    ui.horizontal(|ui| {
+                        let browse = widgets::button_width(ui, tl!("Browse"), 0.0);
+                        let field = (ui.available_width() - browse - ui.spacing().item_spacing.x).max(60.0);
+                        ui.add(
+                            egui::TextEdit::singleline(&mut custom)
+                                .hint_text(r"F:\CraftApps")
+                                .desired_width(field)
+                                .min_size(egui::vec2(0.0, theme::CONTROL_H))
+                                .vertical_align(egui::Align::Center)
+                                .margin(egui::vec2(10.0, 0.0)),
+                        );
+                        #[cfg(windows)]
+                        if widgets::button(ui, tl!("Browse"), Kind::Primary, 0.0).clicked() {
+                            host.start_browse_folder();
+                        }
+                    });
+                }
+                self.settings.install_root = match tag {
+                    1 => InstallRoot::NextToLauncher,
+                    2 => InstallRoot::Custom(PathBuf::from(custom)),
+                    _ => InstallRoot::Default,
+                };
+            }
+        }
+
         if self.caps.manage_apps {
             section(ui, tl!("Running in the background"));
             if host.has_tray() {
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -312,3 +312,10 @@ filter	Installed	Nainstalováno
 	ArtCraft on GitHub	ArtCraft na GitHubu
 	Join the ArtCraft Discord	Připojit se na ArtCraft Discord
 @plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · {n} dostupná aktualizace|ArtCraft Launcher · {n} dostupné aktualizace|ArtCraft Launcher · {n} dostupných aktualizací
+	Installing on Windows	Installing on Windows
+	Installer (recommended)	Installer (recommended)
+	Install location	Install location
+	Default location	Default location
+	Next to the launcher	Next to the launcher
+	Custom folder	Custom folder
+	Browse	Browse
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -344,3 +344,10 @@ filter	Installed	Installiert
 	Loading pictures…	Bilder werden geladen…
 	Pictures couldn't be loaded. Reopen this page to try again.	Die Bilder konnten nicht geladen werden. Beim erneuten Öffnen der Seite wird es noch einmal versucht.
 @plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · {n} Update verfügbar|ArtCraft Launcher · {n} Updates verfügbar
+	Installing on Windows	Installing on Windows
+	Installer (recommended)	Installer (recommended)
+	Install location	Install location
+	Default location	Default location
+	Next to the launcher	Next to the launcher
+	Custom folder	Custom folder
+	Browse	Browse
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -325,3 +325,10 @@ filter	Installed	Instaladas
 	ArtCraft on GitHub	ArtCraft en GitHub
 	Join the ArtCraft Discord	Únete al Discord de ArtCraft
 @plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · {n} actualización disponible|ArtCraft Launcher · {n} actualizaciones disponibles
+	Installing on Windows	Installing on Windows
+	Installer (recommended)	Installer (recommended)
+	Install location	Install location
+	Default location	Default location
+	Next to the launcher	Next to the launcher
+	Custom folder	Custom folder
+	Browse	Browse
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -342,3 +342,10 @@ filter	Installed	Installées
 	Loading pictures…	Chargement des images…
 	Pictures couldn't be loaded. Reopen this page to try again.	Impossible de charger les images. Rouvrir la page permet de réessayer.
 @plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · {n} mise à jour disponible|ArtCraft Launcher · {n} mises à jour disponibles
+	Installing on Windows	Installing on Windows
+	Installer (recommended)	Installer (recommended)
+	Install location	Install location
+	Default location	Default location
+	Next to the launcher	Next to the launcher
+	Custom folder	Custom folder
+	Browse	Browse
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -345,3 +345,10 @@ filter	Installed	Telepítve
 	ArtCraft on GitHub	ArtCraft a GitHubon
 	Join the ArtCraft Discord	Csatlakozás az ArtCraft Discord-közösségéhez
 @plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · {n} frissítés elérhető|ArtCraft Launcher · {n} frissítés elérhető
+	Installing on Windows	Installing on Windows
+	Installer (recommended)	Installer (recommended)
+	Install location	Install location
+	Default location	Default location
+	Next to the launcher	Next to the launcher
+	Custom folder	Custom folder
+	Browse	Browse
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -347,3 +347,10 @@ filter	Installed	インストール済み
 	ArtCraft on GitHub	GitHub の ArtCraft
 	Join the ArtCraft Discord	ArtCraft Discord に参加
 @plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · {n} 件の更新があります
+	Installing on Windows	Installing on Windows
+	Installer (recommended)	Installer (recommended)
+	Install location	Install location
+	Default location	Default location
+	Next to the launcher	Next to the launcher
+	Custom folder	Custom folder
+	Browse	Browse
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -346,3 +346,10 @@ filter	Installed	Zainstalowane
 	ArtCraft on GitHub	ArtCraft na GitHubie
 	Join the ArtCraft Discord	Dołącz do Discorda ArtCraft
 @plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · {n} dostępna aktualizacja|ArtCraft Launcher · {n} dostępne aktualizacje|ArtCraft Launcher · {n} dostępnych aktualizacji
+	Installing on Windows	Installing on Windows
+	Installer (recommended)	Installer (recommended)
+	Install location	Install location
+	Default location	Default location
+	Next to the launcher	Next to the launcher
+	Custom folder	Custom folder
+	Browse	Browse
diff --git a/crates/ui-egui/src/i18n/pt-BR.tsv b/crates/ui-egui/src/i18n/pt-BR.tsv
--- a/crates/ui-egui/src/i18n/pt-BR.tsv
+++ b/crates/ui-egui/src/i18n/pt-BR.tsv
@@ -332,3 +332,10 @@ filter	Installed	Instalados
 	Loading pictures…	Carregando imagens…
 	Pictures couldn't be loaded. Reopen this page to try again.	Não foi possível carregar as imagens. Abra a página novamente para tentar de novo.
 @plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · {n} atualização disponível|ArtCraft Launcher · {n} atualizações disponíveis
+	Installing on Windows	Installing on Windows
+	Installer (recommended)	Installer (recommended)
+	Install location	Install location
+	Default location	Default location
+	Next to the launcher	Next to the launcher
+	Custom folder	Custom folder
+	Browse	Browse
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -340,3 +340,10 @@ filter	Installed	Установлены
 	Loading pictures…	Загрузка изображений…
 	Pictures couldn't be loaded. Reopen this page to try again.	Не удалось загрузить изображения. Откройте страницу снова, чтобы повторить попытку.
 @plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · {n} обновление|ArtCraft Launcher · {n} обновления|ArtCraft Launcher · {n} обновлений
+	Installing on Windows	Installing on Windows
+	Installer (recommended)	Installer (recommended)
+	Install location	Install location
+	Default location	Default location
+	Next to the launcher	Next to the launcher
+	Custom folder	Custom folder
+	Browse	Browse
diff --git a/crates/ui-egui/src/i18n/zh-CN.tsv b/crates/ui-egui/src/i18n/zh-CN.tsv
--- a/crates/ui-egui/src/i18n/zh-CN.tsv
+++ b/crates/ui-egui/src/i18n/zh-CN.tsv
@@ -312,3 +312,10 @@ figcraft	Interface design and prototyping	界面设计与原型制作
 @msg	{name} installed: {version} (this release publishes no checksum, so only its size was checked)	{name} 已安装：{version}（此版本未发布校验和，因此只检查了文件大小）
 @msg	{name} was removed	{name} 已卸载
 @msg	{name} is running. Quit it, then try again.	{name} 正在运行。请先退出，然后重试。
+	Installing on Windows	Installing on Windows
+	Installer (recommended)	Installer (recommended)
+	Install location	Install location
+	Default location	Default location
+	Next to the launcher	Next to the launcher
+	Custom folder	Custom folder
+	Browse	Browse
diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs
--- a/crates/ui-egui/src/settings.rs
+++ b/crates/ui-egui/src/settings.rs
@@ -3,6 +3,7 @@
 use std::collections::BTreeMap;
 
 use artcraft_launcher_core::PackageKind;
+use artcraft_launcher_core::protocol::InstallRoot;
 use serde::{Deserialize, Serialize};
 
 use crate::i18n::t;
@@ -106,6 +107,8 @@ pub struct Settings {
     pub prereleases: bool,
     /// Linux: the package format to prefer for new installs (empty: the system's own).
     pub preferred_kinds: Vec<PackageKind>,
+    /// Windows: where portable installs go (MSI keeps its fixed location).
+    pub install_root: InstallRoot,
     /// Announcements and alerts from the release feed the user dismissed (by notice id).
     pub dismissed_notices: std::collections::BTreeSet<String>,
     /// Updates already announced, so each version is announced once: app id → version.
@@ -182,6 +185,7 @@ impl Default for Settings {
             notify: true,
             prereleases: false,
             preferred_kinds: default_preferred_kinds(),
+            install_root: InstallRoot::default(),
             dismissed_notices: std::collections::BTreeSet::new(),
             notified: BTreeMap::new(),
             last_check: None,
