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
@@ -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"@@ -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() }@@ -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();@@ -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@@ -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()), } } @@ -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"));@@ -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();@@ -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;@@ -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();@@ -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(@@ -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@@ -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@@ -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@@ -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@@ -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@@ -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 置き換える@@ -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@@ -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@@ -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 Заменить@@ -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 替换@@ -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); }