diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -316,6 +316,7 @@ dependencies = [
  "dirs 6.0.0",
  "dunce",
  "flate2",
+ "fs4",
  "log",
  "plist",
  "serde",
@@ -1633,6 +1634,16 @@ dependencies = [
  "percent-encoding",
 ]
 
+[[package]]
+name = "fs4"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5"
+dependencies = [
+ "rustix 1.1.5",
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "futures-channel"
 version = "0.3.34"
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
@@ -441,7 +441,6 @@ impl Host for DesktopHost {
         }
         dialog.pick_folder().and_then(|p| p.to_str().map(str::to_string))
     }
-
     fn reduce_motion(&self) -> bool {
         motion::reduced()
     }
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,7 +4,7 @@
 //! 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::collections::BTreeMap;
+use std::collections::{BTreeMap, BTreeSet};
 
 use serde::{Deserialize, Serialize};
 
@@ -55,6 +55,11 @@ pub enum Command {
     FetchScreenshots {
         app: String,
     },
+    /// How much room the drive for an install into `folder` (`None`: the system's usual place)
+    /// has, answered with [`Event::FreeSpace`]. A backend that can't tell doesn't answer.
+    FreeSpace {
+        folder: Option<String>,
+    },
     /// Change how the backend behaves.
     Configure(BackendOptions),
 }
@@ -65,10 +70,15 @@ pub enum Command {
 pub struct InstallLocations {
     pub root: Option<String>,
     pub per_app: BTreeMap<String, String>,
+    /// Apps that go in the system's usual place even when `root` is set.
+    pub system: BTreeSet<String>,
 }
 
 impl InstallLocations {
     pub fn folder(&self, app: &str) -> Option<&str> {
+        if self.system.contains(app) {
+            return None;
+        }
         self.per_app.get(app).map(String::as_str).or(self.root.as_deref())
     }
 }
@@ -228,6 +238,11 @@ pub enum Event {
         key: String,
         result: Result<Vec<u8>, String>,
     },
+    /// The answer to [`Command::FreeSpace`] for `folder`: free bytes, or `None` when unknown.
+    FreeSpace {
+        folder: Option<String>,
+        bytes: Option<u64>,
+    },
     CheckStarted,
     CheckFinished {
         /// Unix seconds.
@@ -280,6 +295,16 @@ mod tests {
         assert_eq!(locations.folder("photocraft"), Some("/Volumes/Craft"));
     }
 
+    #[test]
+    fn an_app_can_keep_the_system_install_location_when_a_default_is_set() {
+        let mut locations = InstallLocations { root: Some("/Volumes/Craft".into()), ..Default::default() };
+        locations.system.insert("photocraft".into());
+        assert_eq!(locations.folder("photocraft"), None);
+        assert_eq!(locations.folder("filmcraft"), Some("/Volumes/Craft"));
+        let old: InstallLocations = serde_json::from_str(r#"{"root":"/Volumes/Craft","per_app":{}}"#).unwrap();
+        assert!(old.system.is_empty());
+    }
+
     #[test]
     fn old_backend_options_keep_system_install_locations() {
         let options: BackendOptions = serde_json::from_str(r#"{"kinds":[],"prereleases":true}"#).unwrap();
diff --git a/crates/native/Cargo.toml b/crates/native/Cargo.toml
--- a/crates/native/Cargo.toml
+++ b/crates/native/Cargo.toml
@@ -22,6 +22,8 @@ serde = { workspace = true }
 serde_json = { workspace = true }
 log = { workspace = true }
 dirs = "6"
+# Free space on the drive an app would install to, without unsafe code of our own.
+fs4 = "1.1"
 tempfile = "3"
 sha2 = "0.10"
 # HTTPS over rustls, with the OS verifying certificates (rustls-platform-verifier). No bundled
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
@@ -84,6 +84,33 @@ struct Shared {
     feed_throttle: Mutex<FeedThrottle>,
     /// Apps whose details pictures are on their way (a second request meanwhile does nothing).
     pictures_busy: Mutex<std::collections::HashSet<&'static str>>,
+    /// Free-space requests: one measured at a time, so a drive that hangs holds up one thread.
+    free_space: Mutex<FreeSpaceQueue>,
+}
+
+/// Free-space requests waiting for the one worker. Only the newest waiting folder is kept: the
+/// UI shows the answer for the folder chosen now, so an older one would be wasted work.
+#[derive(Default)]
+struct FreeSpaceQueue {
+    /// A worker thread is measuring.
+    running: bool,
+    /// The next folder to measure (`Some(None)`: the system's usual place).
+    next: Option<Option<String>>,
+}
+
+impl FreeSpaceQueue {
+    /// Queues `folder` in place of any waiting one. True when a worker should be started.
+    fn push(&mut self, folder: Option<String>) -> bool {
+        self.next = Some(folder);
+        !std::mem::replace(&mut self.running, true)
+    }
+
+    /// The worker's next folder, or `None` when there is none: the worker then ends.
+    fn take(&mut self) -> Option<Option<String>> {
+        let next = self.next.take();
+        self.running = next.is_some();
+        next
+    }
 }
 
 impl Shared {
@@ -138,6 +165,7 @@ impl NativeBackend {
             feed_ok_at: Mutex::new(None),
             feed_throttle: Mutex::new(FeedThrottle::load(&paths.feed_throttle(), crate::now())),
             pictures_busy: Mutex::new(std::collections::HashSet::new()),
+            free_space: Mutex::new(FreeSpaceQueue::default()),
             last_check: Mutex::new(None),
         });
         let backend = Self { shared, paths, http, source, platform: native_platform() };
@@ -931,6 +959,24 @@ impl Drop for PicturesBusy {
 }
 
 impl NativeBackend {
+    /// Measures the free space for `folder` off the UI thread (a network drive can take seconds),
+    /// with at most one measurement running; requests that arrive meanwhile are coalesced.
+    fn free_space(&self, folder: Option<String>) {
+        if !self.shared.free_space.lock().unwrap_or_else(PoisonError::into_inner).push(folder) {
+            return;
+        }
+        let shared = self.shared.clone();
+        std::thread::spawn(move || {
+            loop {
+                // The lock is let go before measuring, so a new request never waits on the drive.
+                let next = shared.free_space.lock().unwrap_or_else(PoisonError::into_inner).take();
+                let Some(folder) = next else { break };
+                let bytes = crate::paths::free_space(folder.as_deref());
+                shared.emit(Event::FreeSpace { folder, bytes });
+            }
+        });
+    }
+
     /// Starts fetching the pictures of an app's details page, unless they are already on their way.
     /// An app without a page, or a name that isn't an app, is ignored.
     fn fetch_screenshots(&self, app: &str) {
@@ -998,6 +1044,7 @@ impl Backend for NativeBackend {
             }
             Command::UpdateLauncher => self.update_launcher(),
             Command::FetchScreenshots { app } => self.fetch_screenshots(&app),
+            Command::FreeSpace { folder } => self.free_space(folder),
             Command::Configure(options) => {
                 let changed_pre = {
                     let mut o = self.shared.options.lock().unwrap_or_else(PoisonError::into_inner);
@@ -1051,6 +1098,20 @@ mod tests {
     use super::*;
     use crate::source::Answer;
 
+    #[test]
+    fn free_space_requests_share_one_worker_and_keep_only_the_newest() {
+        let mut queue = FreeSpaceQueue::default();
+        assert!(queue.push(Some("/a".into())), "the first request starts the worker");
+        assert_eq!(queue.take(), Some(Some("/a".into())));
+        // While /a is measured (on a drive that may hang), requests pile into one slot.
+        assert!(!queue.push(Some("/b".into())));
+        assert!(!queue.push(None));
+        assert!(!queue.push(Some("/c".into())));
+        assert_eq!(queue.take(), Some(Some("/c".into())));
+        assert_eq!(queue.take(), None, "nothing waiting: the worker ends");
+        assert!(queue.push(None), "the next request starts a new worker");
+    }
+
     /// GitHub, counting how often it's asked.
     #[derive(Default)]
     struct CountingGitHub(AtomicUsize);
@@ -1091,6 +1152,7 @@ mod tests {
             feed_ok_at: Mutex::new(None),
             feed_throttle: Mutex::new(FeedThrottle::new()),
             pictures_busy: Mutex::new(std::collections::HashSet::new()),
+            free_space: Mutex::new(FreeSpaceQueue::default()),
         }
     }
 
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
@@ -201,14 +201,19 @@ fn destination(
     Ok(Some(if kind == PackageKind::Msi { parent.join(app.name) } else { parent }))
 }
 
-fn selected_folder(folder: &str, writable: bool) -> Result<PathBuf, String> {
-    let path = if folder == "~" {
+/// `folder` with a leading `~` or `~/` read as the home folder.
+pub(crate) fn expand_home(folder: &str) -> Result<PathBuf, String> {
+    Ok(if folder == "~" {
         dirs::home_dir().ok_or("no home folder")?
     } else if let Some(relative) = folder.strip_prefix("~/") {
         dirs::home_dir().ok_or("no home folder")?.join(relative)
     } else {
         PathBuf::from(folder)
-    };
+    })
+}
+
+fn selected_folder(folder: &str, writable: bool) -> Result<PathBuf, String> {
+    let path = expand_home(folder)?;
     let invalid = |reason: &str| format!("couldn't use install folder {}: {reason}", path.display());
     if folder.chars().any(|c| c.is_control() || (cfg!(windows) && c == '"')) {
         return Err(invalid("the path contains invalid characters"));
diff --git a/crates/native/src/paths.rs b/crates/native/src/paths.rs
--- a/crates/native/src/paths.rs
+++ b/crates/native/src/paths.rs
@@ -95,10 +95,49 @@ pub fn portable_root() -> Option<PathBuf> {
     dir.join("portable.txt").is_file().then(|| dir.join("ArtCraftLauncherData"))
 }
 
+/// Free space, in bytes, on the drive that would hold an install into `folder`, or into the
+/// system's usual place when `folder` is `None`. A folder that doesn't exist yet is measured at
+/// its nearest existing parent. `None` when it can't be found out.
+pub fn free_space(folder: Option<&str>) -> Option<u64> {
+    let folder = match folder.map(str::trim).filter(|f| !f.is_empty()) {
+        // The installer turns down a relative path, so there's nothing to measure.
+        Some(folder) => crate::install::expand_home(folder).ok().filter(|p| p.is_absolute())?,
+        None => system_install_folder()?,
+    };
+    let existing = folder.ancestors().find(|p| !p.as_os_str().is_empty() && p.is_dir())?;
+    fs4::available_space(existing).ok()
+}
+
+/// Where new installs go when no folder is chosen: near enough to measure its drive.
+fn system_install_folder() -> Option<PathBuf> {
+    if cfg!(target_os = "macos") {
+        // `/Applications`, or `~/Applications` when that isn't writable: the same volume.
+        Some(PathBuf::from("/Applications"))
+    } else if cfg!(windows) {
+        // App MSIs go into `C:\Program Files\<App>` (the 64-bit one, even for a 32-bit launcher).
+        ["ProgramW6432", "ProgramFiles"].into_iter().find_map(std::env::var_os).map(PathBuf::from)
+    } else {
+        dirs::home_dir()
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
 
+    #[test]
+    fn free_space_is_measured_at_the_nearest_existing_folder() {
+        let dir = tempfile::tempdir().unwrap();
+        let missing = dir.path().join("not").join("made").join("yet");
+        let here = free_space(dir.path().to_str());
+        assert!(here.is_some_and(|bytes| bytes > 0));
+        assert!(free_space(missing.to_str()).is_some());
+        assert!(free_space(None).is_some());
+        // Like the installer, `~/` is the home folder.
+        assert!(free_space(Some("~/not-made-yet")).is_some());
+        assert!(free_space(Some("relative/folder")).is_none());
+    }
+
     #[test]
     fn atomic_write_does_not_overwrite_an_unrelated_temporary_file() {
         let dir = tempfile::tempdir().unwrap();
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
@@ -79,12 +79,22 @@ pub(crate) struct Toast {
 pub(crate) enum Dialog {
     Notes(String),
     ConfirmUninstall(String),
-    Install { app: String, folder: String },
+    Install { app: String, choice: FolderChoice, folder: String, make_default: bool },
     Replace { app: String, path: String },
     Settings,
     LauncherUpdate(artcraft_launcher_core::Release),
 }
 
+/// Where the Install dialog puts a new app.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum FolderChoice {
+    /// The default folder from Settings (the system's place when none is set).
+    AppDefault,
+    /// The system's usual place, even when Settings has a default folder.
+    System,
+    Custom,
+}
+
 impl Dialog {
     /// What sets this dialog apart from the others, for egui's memory: each one keeps its own
     /// size there, so a small prompt never opens at the height of the last dialog shown (#88).
@@ -131,6 +141,11 @@ pub struct LauncherUi {
     pub(crate) detail_shot: usize,
     /// The screenshot is enlarged over the whole window.
     pub(crate) detail_zoom: bool,
+    /// The Install dialog's last free-space request: the folder asked about (`None`: the system's
+    /// place) and when.
+    pub(crate) free_space_asked: Option<(Option<String>, f64)>,
+    /// The backend's latest free-space answer: the folder it is for, and the free bytes.
+    pub(crate) free_space: Option<(Option<String>, Option<u64>)>,
     /// The details pictures the backend has sent (or been asked for), by app and picture key.
     pub(crate) pictures: std::collections::HashMap<(String, String), crate::details::Picture>,
     started: bool,
@@ -209,6 +224,8 @@ impl LauncherUi {
             detail_shown: None,
             detail_shot: 0,
             detail_zoom: false,
+            free_space_asked: None,
+            free_space: None,
             pictures: std::collections::HashMap::new(),
             started: false,
             applied_theme: None,
@@ -311,8 +328,14 @@ impl LauncherUi {
             self.send(Command::Install { app: id.to_string() });
             return;
         }
-        self.dialog =
-            Some(Dialog::Install { app: id.to_string(), folder: self.settings.install_locations.per_app.get(id).cloned().unwrap_or_default() });
+        let locations = &self.settings.install_locations;
+        let (choice, folder) = match locations.per_app.get(id) {
+            _ if locations.system.contains(id) => (FolderChoice::System, String::new()),
+            Some(folder) => (FolderChoice::Custom, folder.clone()),
+            // Nothing chosen for this app yet: it follows the default, whatever that becomes.
+            None => (FolderChoice::AppDefault, String::new()),
+        };
+        self.dialog = Some(Dialog::Install { app: id.to_string(), choice, folder, make_default: false });
     }
 
     /// Non-drawing work: runs every frame and also while the window is hidden in the tray.
@@ -377,6 +400,10 @@ impl LauncherUi {
                 self.pictures.insert((app, key), crate::details::Picture::from_result(result));
                 true
             }
+            Event::FreeSpace { folder, bytes } => {
+                self.free_space = Some((folder, bytes));
+                true
+            }
             Event::Detected { app, installed } => {
                 if let Some(e) = self.entry_mut(&app) {
                     e.installed = installed;
diff --git a/crates/ui-egui/src/details.rs b/crates/ui-egui/src/details.rs
--- a/crates/ui-egui/src/details.rs
+++ b/crates/ui-egui/src/details.rs
@@ -346,7 +346,8 @@ impl LauncherUi {
     }
 
     /// The facts of the latest release in cells, then its notes.
-    fn release_section(&self, ui: &mut Ui, entry: &AppEntry, details: &AppDetails, t: &Tokens, per_row: usize) {
+    fn release_section(&mut self, ui: &mut Ui, entry: &AppEntry, details: &AppDetails, t: &Tokens, per_row: usize) {
+        self.install_location(ui, entry, t);
         widgets::eyebrow(ui, "02", tl!("Latest release"));
         ui.add_space(4.0);
         let Some(r) = entry.release() else {
@@ -382,6 +383,22 @@ impl LauncherUi {
         crate::notes::show(ui, &r.notes);
     }
 
+    /// Where an installed app lives, with a button to show it in the file manager.
+    fn install_location(&mut self, ui: &mut Ui, entry: &AppEntry, t: &Tokens) {
+        let Some(installed) = entry.installed.as_ref().filter(|_| self.caps.manage_apps) else { return };
+        egui::Frame::new().stroke(t.hairline()).inner_margin(theme::BOX_MARGIN).show(ui, |ui| {
+            ui.set_width(ui.available_width());
+            ui.spacing_mut().item_spacing.y = 6.0;
+            widgets::hud(ui, tl!("Installed at"), t.faint);
+            ui.add(egui::Label::new(RichText::new(&installed.location).size(14.0).color(t.ink_strong)).wrap().selectable(true));
+            let reveal = crate::i18n::t(self.caps.reveal_label);
+            if widgets::button(ui, reveal, Kind::Secondary, 120.0).clicked() {
+                self.send(Command::Reveal { app: entry.app.id.to_string() });
+            }
+        });
+        ui.add_space(10.0);
+    }
+
     /// The current screenshot, large, over the whole window; ‹ › flip through them, a click or Esc closes it.
     fn lightbox(&mut self, ctx: &egui::Context, details: &AppDetails, t: &Tokens) {
         let count = details.shots.len();
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
@@ -2,12 +2,13 @@
 
 use crate::i18n::fmt;
 use artcraft_launcher_core::Os;
+use artcraft_launcher_core::platform::pick_asset;
 use artcraft_launcher_core::protocol::Command;
 use egui::{RichText, vec2};
 
-use crate::app::{Dialog, Host, LauncherUi};
+use crate::app::{Dialog, FolderChoice, Host, LauncherUi};
 use crate::theme::{self, Tokens};
-use crate::widgets::{self, Kind};
+use crate::widgets::{self, Cell, Kind};
 
 impl LauncherUi {
     pub(crate) fn dialogs(&mut self, ctx: &egui::Context, host: &mut dyn Host) {
@@ -29,7 +30,9 @@ impl LauncherUi {
         let response = match &mut dialog {
             Dialog::Notes(id) => modal.show(ctx, |ui| close = self.notes_dialog(ui, id)),
             Dialog::ConfirmUninstall(id) => modal.show(ctx, |ui| close = self.uninstall_dialog(ui, id)),
-            Dialog::Install { app, folder } => modal.show(ctx, |ui| close = self.install_dialog(ui, app, folder, host)),
+            Dialog::Install { app, choice, folder, make_default } => {
+                modal.show(ctx, |ui| close = self.install_dialog(ui, app, choice, folder, make_default, host))
+            }
             Dialog::Replace { app, path } => modal.show(ctx, |ui| close = self.replace_dialog(ui, app, path)),
             Dialog::Settings => modal.show(ctx, |ui| close = self.settings_dialog(ui, host)),
             Dialog::LauncherUpdate(r) => modal.show(ctx, |ui| close = self.launcher_update_dialog(ui, r)),
@@ -228,23 +231,97 @@ impl LauncherUi {
         close
     }
 
-    fn install_dialog(&mut self, ui: &mut egui::Ui, id: &str, folder: &mut String, host: &mut dyn Host) -> bool {
+    fn install_dialog(
+        &mut self,
+        ui: &mut egui::Ui,
+        id: &str,
+        choice: &mut FolderChoice,
+        folder: &mut String,
+        make_default: &mut bool,
+        host: &mut dyn Host,
+    ) -> bool {
         let Some(app) = self.entry(id).map(|e| e.app) else { return true };
         ui.set_width(440.0f32.min(ui.ctx().content_rect().width() - 80.0));
         Self::title(ui, None, app.name, tl!("Install folder"));
-        let default = self.settings.install_locations.root.as_deref().unwrap_or(tl!("System default"));
-        Self::folder_field(ui, folder, Some(default), host);
-        if self.platform.is_some_and(|p| p.os == Os::Linux) {
+        let root = self.settings.install_locations.root.clone();
+        let options = [
+            (FolderChoice::AppDefault, Cell { label: tl!("App default"), tip: tl!("The default folder set in Settings."), count: None }),
+            (FolderChoice::System, Cell { label: tl!("System default"), tip: tl!("The system's usual place for apps."), count: None }),
+            (FolderChoice::Custom, Cell { label: tl!("Custom folder"), tip: tl!("A folder for this app only."), count: None }),
+        ];
+        let ink = Tokens::get(ui.ctx()).ink;
+        let row_width: f32 = options.iter().map(|(_, cell)| widgets::hud_galley(ui, cell.label, ink).size().x + 24.0).sum();
+        if row_width <= ui.available_width() {
+            ui.horizontal(|ui| widgets::cells(ui, choice, &options));
+        } else {
+            // Longer translations don't fit in one row: stack the options, a box each.
+            widgets::cells_apart(ui, choice, &options, 4.0);
+        }
+        ui.add_space(8.0);
+        match *choice {
+            FolderChoice::AppDefault => match &root {
+                Some(root) => {
+                    ui.label(RichText::new(root.as_str()).strong());
+                    ui.label(tl!("The default folder set in Settings."));
+                }
+                None => {
+                    ui.label(tl!("No default folder is set, so this is the same as the system default."));
+                }
+            },
+            FolderChoice::System => {
+                ui.label(tl!("The system's usual place for apps."));
+            }
+            FolderChoice::Custom => {
+                let label = ui.label(tl!("Install folder"));
+                ui.add(egui::TextEdit::singleline(folder).id_salt("install_folder").desired_width(f32::INFINITY)).labelled_by(label.id);
+                ui.label(tl!("Apps are placed inside this folder."));
+                if widgets::button(ui, tl!("Browse…"), Kind::Secondary, 100.0).clicked()
+                    && let Some(selected) = host.pick_folder(if folder.is_empty() { root.as_deref() } else { Some(folder.as_str()) })
+                {
+                    *folder = selected;
+                }
+            }
+        }
+        let custom = (*choice == FolderChoice::Custom).then(|| folder.trim()).filter(|f| !f.is_empty()).map(str::to_string);
+        let target = match *choice {
+            FolderChoice::AppDefault => root.clone(),
+            FolderChoice::System => None,
+            FolderChoice::Custom => custom.clone().or_else(|| root.clone()),
+        };
+        self.free_space_note(ui, id, target);
+        // Only a folder that isn't already the default can become it.
+        let can_make_default = custom.as_ref().is_some_and(|f| root.as_ref() != Some(f));
+        if can_make_default {
+            ui.checkbox(make_default, tl!("Make this my default install folder"));
+            ui.label(tl!("You can change the default anytime in Settings."));
+        }
+        let goes_to_custom_folder = match *choice {
+            FolderChoice::AppDefault => root.is_some(),
+            FolderChoice::System => false,
+            FolderChoice::Custom => custom.is_some() || root.is_some(),
+        };
+        if goes_to_custom_folder && self.platform.is_some_and(|p| p.os == Os::Linux) {
             ui.label(tl!("Custom folders use AppImage on Linux."));
         }
         ui.add_space(14.0);
         let mut close = false;
         ui.horizontal(|ui| {
             if widgets::button(ui, tl!("Install"), Kind::Primary, 100.0).clicked() {
-                if folder.is_empty() {
-                    self.settings.install_locations.per_app.remove(id);
-                } else {
-                    self.settings.install_locations.per_app.insert(id.to_string(), folder.clone());
+                let locations = &mut self.settings.install_locations;
+                locations.per_app.remove(id);
+                locations.system.remove(id);
+                match (*choice, custom) {
+                    // Kept even with no default folder yet, so setting one later doesn't move it.
+                    (FolderChoice::System, _) => {
+                        locations.system.insert(id.to_string());
+                    }
+                    // The app follows the new default rather than keeping its own copy of it.
+                    (FolderChoice::Custom, Some(folder)) if can_make_default && *make_default => locations.root = Some(folder),
+                    (FolderChoice::Custom, Some(folder)) => {
+                        locations.per_app.insert(id.to_string(), folder);
+                    }
+                    // The app default, or a custom folder left empty.
+                    _ => {}
                 }
                 self.configure();
                 self.send(Command::Install { app: id.to_string() });
@@ -255,6 +332,31 @@ impl LauncherUi {
         close
     }
 
+    /// How much room the drive under `target` has, and a warning when the app may not fit.
+    fn free_space_note(&mut self, ui: &mut egui::Ui, id: &str, target: Option<String>) {
+        // The backend measures off the UI thread: asked again when the folder changes, and every
+        // few seconds while the dialog is open.
+        let now = ui.input(|i| i.time);
+        if self.free_space_asked.as_ref().is_none_or(|(asked, at)| *asked != target || now - at > 5.0) {
+            self.send(Command::FreeSpace { folder: target.clone() });
+            self.free_space_asked = Some((target.clone(), now));
+        }
+        // A late answer for a folder no longer chosen isn't shown.
+        let Some(free) = self.free_space.as_ref().filter(|(folder, _)| *folder == target).and_then(|(_, bytes)| *bytes) else { return };
+        let download = self.platform.zip(self.entry(id)).and_then(|(p, entry)| {
+            let stems: Vec<&str> = entry.app.stems().collect();
+            pick_asset(entry.release()?, &stems, p, p.default_kinds()).map(|(asset, _)| asset.size)
+        });
+        let t = Tokens::get(ui.ctx());
+        ui.label(RichText::new(fmt(tl!("{size} free on this drive"), &[("size", &widgets::human_bytes(free))])).color(t.muted));
+        // Unpacking needs about as much room again as the download itself.
+        if let Some(download) = download.filter(|d| free < d.saturating_mul(2)) {
+            let warning =
+                fmt(tl!("This drive may not have enough space. The download alone is {size}."), &[("size", &widgets::human_bytes(download))]);
+            ui.label(RichText::new(warning).color(t.bad));
+        }
+    }
+
     pub(crate) fn folder_field(ui: &mut egui::Ui, folder: &mut String, default: Option<&str>, host: &mut dyn Host) {
         let label = ui.label(tl!("Install folder"));
         ui.add(
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
@@ -332,6 +332,17 @@ filter	Installed	Nainstalováno
 @msg	{path} already exists and wasn't installed by ArtCraft Launcher	{path} již existuje a nebylo nainstalováno aplikací ArtCraft Launcher
 @msg	couldn't move {path} to Trash ({reason}). Remove it in Finder instead.	{path} nelze přesunout do koše ({reason}). Odstraňte položku ve Finderu.
 	Apps are placed inside this folder.	Aplikace se umístí do této složky.
+	Make this my default install folder	Nastavit tuto složku jako výchozí složku pro instalaci
+	You can change the default anytime in Settings.	Výchozí složku můžete kdykoli změnit v Nastavení.
+	App default	Výchozí v aplikaci
+	Custom folder	Vlastní složka
+	The default folder set in Settings.	Výchozí složka nastavená v Nastavení.
+	The system's usual place for apps.	Obvyklé umístění aplikací v systému.
+	A folder for this app only.	Složka jen pro tuto aplikaci.
+	No default folder is set, so this is the same as the system default.	Výchozí složka není nastavena, takže je to stejné jako výchozí nastavení systému.
+	{size} free on this drive	{size} volného místa na tomto disku
+	This drive may not have enough space. The download alone is {size}.	Na tomto disku nemusí být dost místa. Samotné stažení má {size}.
+	Installed at	Nainstalováno v
 	Replace what's there?	Nahradit, co tam je?
 	{path} already exists. Installing {name} will overwrite it.	{path} už existuje. Instalace aplikace {name} ho přepíše.
 	Replace	Nahradit
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
@@ -364,6 +364,17 @@ filter	Installed	Installiert
 @msg	{path} already exists and wasn't installed by ArtCraft Launcher	{path} existiert bereits und wurde nicht von ArtCraft Launcher installiert
 @msg	couldn't move {path} to Trash ({reason}). Remove it in Finder instead.	{path} konnte nicht in den Papierkorb verschoben werden ({reason}). Entfernen Sie es stattdessen im Finder.
 	Apps are placed inside this folder.	Apps werden in diesem Ordner abgelegt.
+	Make this my default install folder	Diesen Ordner als Standard-Installationsordner verwenden
+	You can change the default anytime in Settings.	Den Standardordner kannst du jederzeit in den Einstellungen ändern.
+	App default	App-Standard
+	Custom folder	Eigener Ordner
+	The default folder set in Settings.	Der in den Einstellungen festgelegte Standardordner.
+	The system's usual place for apps.	Der übliche Ort des Systems für Apps.
+	A folder for this app only.	Ein Ordner nur für diese App.
+	No default folder is set, so this is the same as the system default.	Es ist kein Standardordner festgelegt, daher entspricht dies dem Systemstandard.
+	{size} free on this drive	{size} frei auf diesem Laufwerk
+	This drive may not have enough space. The download alone is {size}.	Auf diesem Laufwerk ist möglicherweise nicht genug Platz. Allein der Download ist {size} groß.
+	Installed at	Installiert in
 	Replace what's there?	Vorhandenes ersetzen?
 	{path} already exists. Installing {name} will overwrite it.	{path} existiert bereits. Die Installation von {name} überschreibt es.
 	Replace	Ersetzen
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
@@ -345,6 +345,17 @@ filter	Installed	Instaladas
 @msg	{path} already exists and wasn't installed by ArtCraft Launcher	{path} ya existe y no fue instalado por ArtCraft Launcher
 @msg	couldn't move {path} to Trash ({reason}). Remove it in Finder instead.	no se pudo mover {path} a la Papelera ({reason}). Elimínalo desde Finder.
 	Apps are placed inside this folder.	Las aplicaciones se guardan dentro de esta carpeta.
+	Make this my default install folder	Usar esta carpeta como carpeta de instalación predeterminada
+	You can change the default anytime in Settings.	Puedes cambiar la carpeta predeterminada cuando quieras en Ajustes.
+	App default	Predeterminada de la aplicación
+	Custom folder	Carpeta personalizada
+	The default folder set in Settings.	La carpeta predeterminada definida en Ajustes.
+	The system's usual place for apps.	La ubicación habitual del sistema para las aplicaciones.
+	A folder for this app only.	Una carpeta solo para esta aplicación.
+	No default folder is set, so this is the same as the system default.	No hay ninguna carpeta predeterminada, así que equivale a la predeterminada del sistema.
+	{size} free on this drive	{size} libres en esta unidad
+	This drive may not have enough space. The download alone is {size}.	Puede que esta unidad no tenga espacio suficiente. Solo la descarga ocupa {size}.
+	Installed at	Instalada en
 	Replace what's there?	¿Reemplazar lo que hay?
 	{path} already exists. Installing {name} will overwrite it.	{path} ya existe. Instalar {name} lo sobrescribirá.
 	Replace	Reemplazar
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
@@ -362,6 +362,17 @@ filter	Installed	Installées
 @msg	{path} already exists and wasn't installed by ArtCraft Launcher	{path} existe déjà et n’a pas été installé par ArtCraft Launcher
 @msg	couldn't move {path} to Trash ({reason}). Remove it in Finder instead.	impossible de déplacer {path} vers la Corbeille ({reason}). Supprimez-le dans le Finder.
 	Apps are placed inside this folder.	Les applications sont placées dans ce dossier.
+	Make this my default install folder	Utiliser ce dossier comme dossier d’installation par défaut
+	You can change the default anytime in Settings.	Vous pouvez modifier le dossier par défaut à tout moment dans les Réglages.
+	App default	Par défaut de l'application
+	Custom folder	Dossier personnalisé
+	The default folder set in Settings.	Le dossier par défaut défini dans les Réglages.
+	The system's usual place for apps.	L'emplacement habituel du système pour les applications.
+	A folder for this app only.	Un dossier pour cette application uniquement.
+	No default folder is set, so this is the same as the system default.	Aucun dossier par défaut n'est défini : c'est donc l'emplacement par défaut du système.
+	{size} free on this drive	{size} libres sur ce disque
+	This drive may not have enough space. The download alone is {size}.	Ce disque n'a peut-être pas assez d'espace. Le téléchargement seul fait {size}.
+	Installed at	Installée dans
 	Replace what's there?	Remplacer l’existant ?
 	{path} already exists. Installing {name} will overwrite it.	{path} existe déjà. L’installation de {name} l’écrasera.
 	Replace	Remplacer
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
@@ -365,6 +365,17 @@ filter	Installed	Telepítve
 @msg	{path} already exists and wasn't installed by ArtCraft Launcher	a(z) {path} már létezik, és nem az ArtCraft Launcher telepítette
 @msg	couldn't move {path} to Trash ({reason}). Remove it in Finder instead.	a(z) {path} nem helyezhető a Kukába ({reason}). Távolítsa el a Finderben.
 	Apps are placed inside this folder.	Az alkalmazások ebbe a mappába kerülnek.
+	Make this my default install folder	Legyen ez az alapértelmezett telepítési mappa
+	You can change the default anytime in Settings.	Az alapértelmezett mappát bármikor módosíthatod a Beállításokban.
+	App default	Alkalmazás alapértelmezése
+	Custom folder	Egyéni mappa
+	The default folder set in Settings.	A Beállításokban megadott alapértelmezett mappa.
+	The system's usual place for apps.	A rendszer szokásos helye az alkalmazásoknak.
+	A folder for this app only.	Csak ennek az alkalmazásnak szánt mappa.
+	No default folder is set, so this is the same as the system default.	Nincs alapértelmezett mappa megadva, ezért ez megegyezik a rendszer alapértelmezésével.
+	{size} free on this drive	{size} szabad ezen a meghajtón
+	This drive may not have enough space. The download alone is {size}.	Lehet, hogy ezen a meghajtón nincs elég hely. Már a letöltés is {size}.
+	Installed at	Telepítés helye
 	Replace what's there?	Lecseréli, ami ott van?
 	{path} already exists. Installing {name} will overwrite it.	{path} már létezik. A(z) {name} telepítése felülírja.
 	Replace	Csere
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
@@ -367,6 +367,17 @@ filter	Installed	インストール済み
 @msg	{path} already exists and wasn't installed by ArtCraft Launcher	{path} は既に存在し、ArtCraft Launcherでインストールされたものではありません
 @msg	couldn't move {path} to Trash ({reason}). Remove it in Finder instead.	{path} をゴミ箱に移動できませんでした（{reason}）。Finderで削除してください。
 	Apps are placed inside this folder.	アプリはこのフォルダー内に配置されます。
+	Make this my default install folder	このフォルダーを既定のインストール先にする
+	You can change the default anytime in Settings.	既定のフォルダーは設定からいつでも変更できます。
+	App default	アプリの既定
+	Custom folder	任意のフォルダー
+	The default folder set in Settings.	設定で指定した既定のフォルダーです。
+	The system's usual place for apps.	システムでアプリを置く通常の場所です。
+	A folder for this app only.	このアプリ専用のフォルダーです。
+	No default folder is set, so this is the same as the system default.	既定のフォルダーが設定されていないため、システムの既定と同じです。
+	{size} free on this drive	このドライブの空き容量: {size}
+	This drive may not have enough space. The download alone is {size}.	このドライブの空き容量が足りない可能性があります。ダウンロードだけで {size} あります。
+	Installed at	インストール先
 	Replace what's there?	既存の項目を置き換えますか？
 	{path} already exists. Installing {name} will overwrite it.	{path} は既に存在します。{name} をインストールすると上書きされます。
 	Replace	置き換える
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
@@ -366,6 +366,17 @@ filter	Installed	Zainstalowane
 @msg	{path} already exists and wasn't installed by ArtCraft Launcher	{path} już istnieje i nie został zainstalowany przez ArtCraft Launcher
 @msg	couldn't move {path} to Trash ({reason}). Remove it in Finder instead.	nie można przenieść {path} do Kosza ({reason}). Usuń go w Finderze.
 	Apps are placed inside this folder.	Aplikacje są umieszczane w tym folderze.
+	Make this my default install folder	Ustaw ten folder jako domyślny folder instalacji
+	You can change the default anytime in Settings.	Domyślny folder możesz zmienić w dowolnej chwili w Ustawieniach.
+	App default	Domyślny aplikacji
+	Custom folder	Własny folder
+	The default folder set in Settings.	Domyślny folder ustawiony w Ustawieniach.
+	The system's usual place for apps.	Zwykłe miejsce na aplikacje w systemie.
+	A folder for this app only.	Folder tylko dla tej aplikacji.
+	No default folder is set, so this is the same as the system default.	Nie ustawiono domyślnego folderu, więc to to samo co domyślny systemowy.
+	{size} free on this drive	{size} wolnego miejsca na tym dysku
+	This drive may not have enough space. The download alone is {size}.	Na tym dysku może zabraknąć miejsca. Sam plik do pobrania ma {size}.
+	Installed at	Zainstalowano w
 	Replace what's there?	Zastąpić to, co tam jest?
 	{path} already exists. Installing {name} will overwrite it.	{path} już istnieje. Instalacja {name} go nadpisze.
 	Replace	Zastąp
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
@@ -352,6 +352,17 @@ filter	Installed	Instalados
 @msg	{path} already exists and wasn't installed by ArtCraft Launcher	{path} já existe e não foi instalado pelo ArtCraft Launcher
 @msg	couldn't move {path} to Trash ({reason}). Remove it in Finder instead.	não foi possível mover {path} para o Lixo ({reason}). Remova-o pelo Finder.
 	Apps are placed inside this folder.	Os aplicativos são colocados dentro desta pasta.
+	Make this my default install folder	Usar esta pasta como pasta de instalação padrão
+	You can change the default anytime in Settings.	Você pode alterar a pasta padrão a qualquer momento nas Configurações.
+	App default	Padrão do aplicativo
+	Custom folder	Pasta personalizada
+	The default folder set in Settings.	A pasta padrão definida em Configurações.
+	The system's usual place for apps.	O local habitual do sistema para aplicativos.
+	A folder for this app only.	Uma pasta só para este aplicativo.
+	No default folder is set, so this is the same as the system default.	Nenhuma pasta padrão foi definida, então é o mesmo que o padrão do sistema.
+	{size} free on this drive	{size} livres nesta unidade
+	This drive may not have enough space. The download alone is {size}.	Esta unidade pode não ter espaço suficiente. Só o download tem {size}.
+	Installed at	Instalado em
 	Replace what's there?	Substituir o que já existe?
 	{path} already exists. Installing {name} will overwrite it.	{path} já existe. Instalar o {name} vai substituí-lo.
 	Replace	Substituir
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
@@ -360,6 +360,17 @@ filter	Installed	Установлены
 @msg	{path} already exists and wasn't installed by ArtCraft Launcher	{path} уже существует и не был установлен через ArtCraft Launcher
 @msg	couldn't move {path} to Trash ({reason}). Remove it in Finder instead.	не удалось переместить {path} в Корзину ({reason}). Удалите его через Finder.
 	Apps are placed inside this folder.	Приложения размещаются внутри этой папки.
+	Make this my default install folder	Сделать эту папку папкой установки по умолчанию
+	You can change the default anytime in Settings.	Папку по умолчанию можно изменить в любой момент в настройках.
+	App default	По умолчанию в приложении
+	Custom folder	Своя папка
+	The default folder set in Settings.	Папка по умолчанию, заданная в настройках.
+	The system's usual place for apps.	Обычное место для приложений в системе.
+	A folder for this app only.	Папка только для этого приложения.
+	No default folder is set, so this is the same as the system default.	Папка по умолчанию не задана, поэтому это то же самое, что и системное значение по умолчанию.
+	{size} free on this drive	Свободно на этом диске: {size}
+	This drive may not have enough space. The download alone is {size}.	На этом диске может не хватить места. Одна только загрузка занимает {size}.
+	Installed at	Установлено в
 	Replace what's there?	Заменить существующее?
 	{path} already exists. Installing {name} will overwrite it.	{path} уже существует. Установка {name} перезапишет его.
 	Replace	Заменить
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
@@ -332,6 +332,17 @@ figcraft	Interface design and prototyping	界面设计与原型制作
 @msg	{path} already exists and wasn't installed by ArtCraft Launcher	{path} 已存在，且并非由 ArtCraft Launcher 安装
 @msg	couldn't move {path} to Trash ({reason}). Remove it in Finder instead.	无法将 {path} 移到废纸篓（{reason}）。请在 Finder 中移除。
 	Apps are placed inside this folder.	应用会放置在此文件夹内。
+	Make this my default install folder	将此文件夹设为默认安装文件夹
+	You can change the default anytime in Settings.	你可以随时在设置中更改默认文件夹。
+	App default	应用默认
+	Custom folder	自定义文件夹
+	The default folder set in Settings.	在设置中指定的默认文件夹。
+	The system's usual place for apps.	系统存放应用的常用位置。
+	A folder for this app only.	仅用于此应用的文件夹。
+	No default folder is set, so this is the same as the system default.	尚未设置默认文件夹，因此与系统默认相同。
+	{size} free on this drive	此驱动器可用空间：{size}
+	This drive may not have enough space. The download alone is {size}.	此驱动器的空间可能不足。仅下载文件就有 {size}。
+	Installed at	安装位置
 	Replace what's there?	替换现有内容？
 	{path} already exists. Installing {name} will overwrite it.	{path} 已存在。安装 {name} 将覆盖它。
 	Replace	替换
diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs
--- a/crates/ui-egui/tests/ui.rs
+++ b/crates/ui-egui/tests/ui.rs
@@ -298,6 +298,54 @@ fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {
 }
 
 fn folder_harness(picked: Rc<RefCell<Option<String>>>) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
+    folder_harness_with_space(picked, FreeSpace::default())
+}
+
+/// How the fake backend answers free-space requests in `folder_harness_with_space`.
+#[derive(Clone, Default)]
+struct FreeSpace {
+    /// The folders asked about, in order.
+    asked: Rc<RefCell<Vec<Option<String>>>>,
+    /// Answered at once with this many free bytes; `None` leaves the answers to `replies`.
+    free: Option<u64>,
+    /// Events the test hands the UI on the next poll.
+    replies: Rc<RefCell<Vec<Event>>>,
+}
+
+/// Answers `Command::FreeSpace` as a `FreeSpace` says, and passes everything else on.
+struct SpaceFake {
+    inner: Fake,
+    space: FreeSpace,
+}
+impl Backend for SpaceFake {
+    fn send(&mut self, command: Command) {
+        let Command::FreeSpace { folder } = command else { return self.inner.send(command) };
+        self.space.asked.borrow_mut().push(folder.clone());
+        if let Some(bytes) = self.space.free {
+            self.space.replies.borrow_mut().push(Event::FreeSpace { folder, bytes: Some(bytes) });
+        }
+    }
+    fn poll(&mut self) -> Vec<Event> {
+        let mut events = self.inner.poll();
+        events.append(&mut self.space.replies.borrow_mut());
+        events
+    }
+    fn capabilities(&self) -> Capabilities {
+        self.inner.capabilities()
+    }
+    fn platform(&self) -> Option<Platform> {
+        self.inner.platform()
+    }
+    fn now(&self) -> i64 {
+        self.inner.now()
+    }
+}
+
+/// Like `folder_harness`, with free-space requests answered as `space` says.
+fn folder_harness_with_space(
+    picked: Rc<RefCell<Option<String>>>,
+    space: FreeSpace,
+) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
     struct Picker(Rc<RefCell<Option<String>>>);
     impl Host for Picker {
         fn pick_folder(&mut self, _initial: Option<&str>) -> Option<String> {
@@ -306,7 +354,7 @@ fn folder_harness(picked: Rc<RefCell<Option<String>>>) -> (Harness<'static, Opti
     }
     artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
     let sent = Rc::new(RefCell::new(Vec::new()));
-    let mut backend = Some(Fake {
+    let fake = Fake {
         events: vec![
             Event::Latest { app: "photocraft".into(), latest: LatestRelease::Found(release("photocraft", "0.6.0", NOW)), from: Origin::GitHub },
             Event::Detected { app: "photocraft".into(), installed: None },
@@ -316,7 +364,8 @@ fn folder_harness(picked: Rc<RefCell<Option<String>>>) -> (Harness<'static, Opti
         pictures: Rc::default(),
         asked: vec![],
         clock: Rc::new(Cell::new(NOW)),
-    });
+    };
+    let mut backend = Some(SpaceFake { inner: fake, space });
     let mut host = Picker(picked);
     let harness = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
         move |ui, state: &mut Option<LauncherUi>| {
@@ -334,7 +383,7 @@ fn folder_harness(picked: Rc<RefCell<Option<String>>>) -> (Harness<'static, Opti
 }
 
 fn install_commands(sent: &RefCell<Vec<Command>>) -> Vec<Command> {
-    sent.borrow().iter().filter(|c| !matches!(c, Command::CheckForUpdates { .. })).cloned().collect()
+    sent.borrow().iter().filter(|c| !matches!(c, Command::CheckForUpdates { .. } | Command::FreeSpace { .. })).cloned().collect()
 }
 
 #[test]
@@ -358,6 +407,10 @@ fn a_fresh_install_confirms_the_folder_and_configures_before_installing() {
     h.get_by_label("Install").click();
     h.run_steps(4);
     assert!(!sent.borrow().iter().any(|c| matches!(c, Command::Install { .. })));
+    // With a default folder set, the dialog starts on it.
+    h.get_by_label("/Volumes/Craft Apps");
+    h.get_by_label("Custom folder").click();
+    h.run_steps(3);
     h.get_by_label("Apps are placed inside this folder.");
     snapshot(&mut h, "install-folder-confirmation");
     *picked.borrow_mut() = Some("/Volumes/Photo".into());
@@ -371,6 +424,8 @@ fn a_fresh_install_confirms_the_folder_and_configures_before_installing() {
     h.run_steps(3);
     h.get_by_label("Install").click();
     h.run_steps(3);
+    h.get_by_label("Custom folder").click();
+    h.run_steps(3);
     h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Install folder").click();
     h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Install folder").type_text("/Volumes/Typed Photo");
     h.run_steps(3);
@@ -388,7 +443,7 @@ fn a_fresh_install_confirms_the_folder_and_configures_before_installing() {
 }
 
 #[test]
-fn use_default_removes_an_app_override_and_settings_can_reset_the_root() {
+fn app_default_removes_an_app_override_and_settings_can_reset_the_root() {
     let picked = Rc::new(RefCell::new(Some("/Volumes/Photo".into())));
     let (mut h, sent) = folder_harness(picked);
     h.run_steps(8);
@@ -399,7 +454,9 @@ fn use_default_removes_an_app_override_and_settings_can_reset_the_root() {
     h.run_steps(3);
     h.get_by_label("Install").click();
     h.run_steps(3);
-    h.get_by_label("Use default").click();
+    // The app's own folder is remembered.
+    assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Install folder").value().as_deref(), Some("/Volumes/Old Photo"));
+    h.get_by_label("App default").click();
     h.run_steps(2);
     sent.borrow_mut().clear();
     h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().click();
@@ -417,6 +474,224 @@ fn use_default_removes_an_app_override_and_settings_can_reset_the_root() {
     assert_eq!(h.state().as_ref().unwrap().settings.install_locations.root, None);
 }
 
+#[test]
+fn the_install_dialog_can_make_its_folder_the_default_which_settings_can_still_change() {
+    let picked = Rc::new(RefCell::new(None));
+    let (mut h, sent) = folder_harness(picked.clone());
+    h.run_steps(8);
+    h.get_by_label("Install").scroll_to_me();
+    h.run_steps(3);
+    h.get_by_label("Install").click();
+    h.run_steps(3);
+    // Nothing chosen yet, so the dialog follows the default, which is still the system's place.
+    h.get_by_label("No default folder is set, so this is the same as the system default.");
+    h.get_by_label("Custom folder").click();
+    h.run_steps(3);
+    // No folder typed yet: the system default is already the default.
+    assert!(h.query_by_label("Make this my default install folder").is_none());
+    *picked.borrow_mut() = Some("/Volumes/Craft Apps".into());
+    h.get_by_label("Browse…").click();
+    h.run_steps(3);
+    h.get_by_label("You can change the default anytime in Settings.");
+    h.get_by_label("Make this my default install folder").click();
+    h.run_steps(2);
+    sent.borrow_mut().clear();
+    h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().click();
+    h.run_steps(2);
+    let locations = h.state().as_ref().unwrap().settings.install_locations.clone();
+    assert_eq!(locations.root.as_deref(), Some("/Volumes/Craft Apps"));
+    assert!(locations.per_app.is_empty());
+    let commands = install_commands(&sent);
+    assert!(
+        matches!(commands.first(), Some(Command::Configure(options)) if options.install_locations.root.as_deref() == Some("/Volumes/Craft Apps"))
+    );
+    assert_eq!(commands.get(1), Some(&Command::Install { app: "photocraft".into() }));
+
+    h.get_by_label("Settings").click();
+    h.run_steps(4);
+    h.get_by_label("Browse…").scroll_to_me();
+    h.run_steps(4);
+    *picked.borrow_mut() = Some("/Volumes/Elsewhere".into());
+    h.get_by_label("Browse…").click();
+    h.run_steps(3);
+    assert_eq!(h.state().as_ref().unwrap().settings.install_locations.root.as_deref(), Some("/Volumes/Elsewhere"));
+}
+
+#[test]
+fn an_unticked_install_dialog_keeps_the_folder_for_that_app_only() {
+    let picked = Rc::new(RefCell::new(Some("/Volumes/Photo".into())));
+    let (mut h, sent) = folder_harness(picked);
+    h.run_steps(8);
+    h.state_mut().as_mut().unwrap().settings.install_locations.root = Some("/Volumes/Craft Apps".into());
+    h.run_steps(2);
+    h.get_by_label("Install").scroll_to_me();
+    h.run_steps(3);
+    h.get_by_label("Install").click();
+    h.run_steps(3);
+    h.get_by_label("Custom folder").click();
+    h.run_steps(3);
+    h.get_by_label("Browse…").click();
+    h.run_steps(3);
+    sent.borrow_mut().clear();
+    h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().click();
+    h.run_steps(2);
+    let locations = h.state().as_ref().unwrap().settings.install_locations.clone();
+    assert_eq!(locations.root.as_deref(), Some("/Volumes/Craft Apps"));
+    assert_eq!(locations.folder("photocraft"), Some("/Volumes/Photo"));
+    assert!(
+        matches!(install_commands(&sent).first(), Some(Command::Configure(options)) if options.install_locations.folder("photocraft") == Some("/Volumes/Photo"))
+    );
+}
+
+#[test]
+fn the_install_dialog_can_pick_the_system_default_over_the_app_default() {
+    let picked = Rc::new(RefCell::new(None));
+    let (mut h, sent) = folder_harness(picked);
+    h.run_steps(8);
+    h.state_mut().as_mut().unwrap().settings.install_locations.root = Some("/Volumes/Craft Apps".into());
+    h.run_steps(2);
+    h.get_by_label("Install").scroll_to_me();
+    h.run_steps(3);
+    h.get_by_label("Install").click();
+    h.run_steps(3);
+    h.get_by_label("System default").click();
+    h.run_steps(3);
+    assert!(h.query_by_label("Make this my default install folder").is_none());
+    sent.borrow_mut().clear();
+    h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().click();
+    h.run_steps(2);
+    let locations = h.state().as_ref().unwrap().settings.install_locations.clone();
+    assert_eq!(locations.root.as_deref(), Some("/Volumes/Craft Apps"));
+    assert_eq!(locations.folder("photocraft"), None);
+    assert!(matches!(install_commands(&sent).first(), Some(Command::Configure(options)) if options.install_locations.folder("photocraft").is_none()));
+
+    // Opened again, it remembers the choice, and the app default undoes it.
+    h.get_by_label("Install").scroll_to_me();
+    h.run_steps(3);
+    h.get_by_label("Install").click();
+    h.run_steps(3);
+    h.get_by_label("The system's usual place for apps.");
+    h.get_by_label("App default").click();
+    h.run_steps(3);
+    h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().click();
+    h.run_steps(2);
+    assert_eq!(h.state().as_ref().unwrap().settings.install_locations.folder("photocraft"), Some("/Volumes/Craft Apps"));
+}
+
+#[test]
+fn the_system_default_is_kept_when_a_default_folder_is_set_later() {
+    let (mut h, _sent) = folder_harness(Rc::new(RefCell::new(None)));
+    h.run_steps(8);
+    h.get_by_label("Install").scroll_to_me();
+    h.run_steps(3);
+    h.get_by_label("Install").click();
+    h.run_steps(3);
+    h.get_by_label("System default").click();
+    h.run_steps(3);
+    h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().click();
+    h.run_steps(2);
+    h.state_mut().as_mut().unwrap().settings.install_locations.root = Some("/Volumes/Craft Apps".into());
+    h.run_steps(2);
+    assert_eq!(h.state().as_ref().unwrap().settings.install_locations.folder("photocraft"), None);
+}
+
+#[test]
+fn the_install_dialog_shows_free_space_for_the_chosen_folder() {
+    let space = FreeSpace { free: Some(64 << 30), ..Default::default() };
+    let asked = space.asked.clone();
+    let (mut h, _sent) = folder_harness_with_space(Rc::new(RefCell::new(None)), space);
+    h.run_steps(8);
+    h.state_mut().as_mut().unwrap().settings.install_locations.root = Some("/Volumes/Craft Apps".into());
+    h.run_steps(2);
+    h.get_by_label("Install").scroll_to_me();
+    h.run_steps(3);
+    h.get_by_label("Install").click();
+    h.run_steps(3);
+    h.get_by_label_contains("free on this drive");
+    assert!(h.query_by_label_contains("may not have enough space").is_none());
+    assert_eq!(asked.borrow().last(), Some(&Some("/Volumes/Craft Apps".to_string())));
+    // Each choice measures its own drive: the system's place is asked about as `None`.
+    h.get_by_label("System default").click();
+    h.run_steps(3);
+    assert_eq!(asked.borrow().last(), Some(&None));
+}
+
+#[test]
+fn the_install_dialog_warns_when_the_drive_is_nearly_full() {
+    // The download is 150 MB; unpacking needs about as much again.
+    let (mut h, sent) = folder_harness_with_space(Rc::new(RefCell::new(None)), FreeSpace { free: Some(200 << 20), ..Default::default() });
+    h.run_steps(8);
+    h.get_by_label("Install").scroll_to_me();
+    h.run_steps(3);
+    h.get_by_label("Install").click();
+    h.run_steps(3);
+    h.get_by_label_contains("may not have enough space");
+    snapshot(&mut h, "install-folder-low-space");
+    // A warning, not a block: the user may know better (or free some space first).
+    sent.borrow_mut().clear();
+    h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().click();
+    h.run_steps(2);
+    assert!(install_commands(&sent).contains(&Command::Install { app: "photocraft".into() }));
+}
+
+#[test]
+fn an_unknown_amount_of_free_space_shows_nothing() {
+    let (mut h, _sent) = folder_harness(Rc::new(RefCell::new(None)));
+    h.run_steps(8);
+    h.get_by_label("Install").scroll_to_me();
+    h.run_steps(3);
+    h.get_by_label("Install").click();
+    h.run_steps(3);
+    assert!(h.query_by_label_contains("free on this drive").is_none());
+    assert!(h.query_by_label_contains("may not have enough space").is_none());
+}
+
+#[test]
+fn a_late_free_space_answer_for_another_folder_is_not_shown() {
+    let space = FreeSpace::default();
+    let replies = space.replies.clone();
+    let (mut h, _sent) = folder_harness_with_space(Rc::new(RefCell::new(None)), space);
+    h.run_steps(8);
+    h.state_mut().as_mut().unwrap().settings.install_locations.root = Some("/Volumes/Craft Apps".into());
+    h.run_steps(2);
+    h.get_by_label("Install").scroll_to_me();
+    h.run_steps(3);
+    h.get_by_label("Install").click();
+    h.run_steps(3);
+    h.get_by_label("System default").click();
+    h.run_steps(3);
+    // The answer for the app default folder arrives after the system default was chosen.
+    replies.borrow_mut().push(Event::FreeSpace { folder: Some("/Volumes/Craft Apps".into()), bytes: Some(64 << 30) });
+    h.run_steps(3);
+    assert!(h.query_by_label_contains("free on this drive").is_none());
+    replies.borrow_mut().push(Event::FreeSpace { folder: None, bytes: Some(64 << 30) });
+    h.run_steps(3);
+    h.get_by_label_contains("free on this drive");
+}
+
+#[test]
+fn an_installed_apps_details_show_where_it_lives() {
+    let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
+    h.run_steps(8);
+    h.get_by_label("PhotoCraft details").click();
+    h.run_steps(10);
+    h.get_by_label("Installed at");
+    h.get_by_label("/Applications/PhotoCraft.app");
+    sent.borrow_mut().clear();
+    h.get_by_label("Show in Finder").click();
+    h.run_steps(2);
+    assert!(sent.borrow().contains(&Command::Reveal { app: "photocraft".into() }));
+}
+
+#[test]
+fn an_app_that_isnt_installed_has_no_location_in_its_details() {
+    let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
+    h.run_steps(8);
+    h.get_by_label("VectorCraft details").click();
+    h.run_steps(10);
+    assert!(h.query_by_label("Installed at").is_none());
+}
+
 #[cfg(windows)]
 #[test]
 fn windows_package_choice_can_select_portable_and_configures_the_backend() {
@@ -561,10 +836,10 @@ fn a_kept_download_offers_retry_install_and_download_again() {
     let row = retry.rect();
     retry.click();
     h.run_steps(3);
-    assert!(sent.borrow().is_empty());
+    assert!(install_commands(&sent).is_empty());
     h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().click();
     h.run_steps(2);
-    assert_eq!(*sent.borrow(), [Command::Install { app: "cadcraft".into() }]);
+    assert_eq!(install_commands(&sent), [Command::Install { app: "cadcraft".into() }]);
     sent.borrow_mut().clear();
     // The … menu beside it.
     let beside = |r: egui::Rect| row.y_range().contains(r.center().y) && (row.right()..row.right() + 60.0).contains(&r.left());
@@ -573,10 +848,10 @@ fn a_kept_download_offers_retry_install_and_download_again() {
     snapshot(&mut h, "grid-retry-install-menu-dark");
     h.get_by_label("Download again").click();
     h.run_steps(3);
-    assert_eq!(*sent.borrow(), [Command::Cancel { app: "cadcraft".into() }]);
+    assert_eq!(install_commands(&sent), [Command::Cancel { app: "cadcraft".into() }]);
     h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().click();
     h.run_steps(2);
-    assert_eq!(*sent.borrow(), [Command::Cancel { app: "cadcraft".into() }, Command::Install { app: "cadcraft".into() }]);
+    assert_eq!(install_commands(&sent), [Command::Cancel { app: "cadcraft".into() }, Command::Install { app: "cadcraft".into() }]);
     // Apps without a kept download don't offer it.
     assert_eq!(h.query_all_by_label("Retry install").count(), 1);
 }
