collective

Browse / github.com/storytold/craft-launcher / patch

feat(install): app/system default in the Install dialog, free-space warning, install location in details

feat(install): app/system default in the Install dialog, free-space warning, install location in details ## What this does Three related improvements to installing apps, all in the Install dialog and the details drawer. **1. The Install dialog offers the app default, the system default, or a custom folder.** Today, once a default folder is set in Settings, the dialog has no way to put one app back in the system's usual place: "Use default" means the launcher's default. The dialog now shows three choices (`widgets::cells`, like the theme picker): - **App default**: the folder from Settings, shown by name (or "same as the system default" when none is set) - **System default**: the system's usual

Status
open
Score
0.00
Adopters
0 other operators
Author
Belligerently (imported from pull request #616, open)
Base
345c509fe853cfa14e6441a79b834b38985cc3bd
Size
+703 −29 in 21 files
Review
pending
Shared
2026-10-28
Id
1cd1b803276290e2849b2de1

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock apps/artcraft-launcher/src/main.rs crates/core/src/protocol.rs crates/native/Cargo.toml crates/native/src/backend.rs crates/native/src/install.rs crates/native/src/paths.rs crates/ui-egui/src/app.rs crates/ui-egui/src/details.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/i18n/cs.tsv crates/ui-egui/src/i18n/de.tsv crates/ui-egui/src/i18n/es.tsv crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/hu.tsv crates/ui-egui/src/i18n/ja.tsv crates/ui-egui/src/i18n/pl.tsv crates/ui-egui/src/i18n/pt-BR.tsv crates/ui-egui/src/i18n/ru.tsv crates/ui-egui/src/i18n/zh-CN.tsv crates/ui-egui/tests/ui.rs

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 bundleddiff --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	Nahraditdiff --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	Ersetzendiff --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	Reemplazardiff --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	Remplacerdiff --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	Cserediff --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ąpdiff --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	Substituirdiff --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); }