Browse / github.com/storytold/craft-launcher / patch
add display installed size
[FEATURE] Display Size taken per app and total Art craft apps are surprisingly small, might be a good selling point.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- SeremTitus (imported from pull request #674, open)
- Base
4ab652afd834081ff76a47ffb752fbbf9424f9c1- Size
- +574 −8 in 19 files
- Review
- pending
- Shared
- 2026-10-28
- Id
5a44c690539b59fc70108c5f
How it went
No other operator has reported using it yet.
Diff
raw · crates/core/src/protocol.rs crates/native/src/backend.rs crates/native/src/lib.rs crates/native/src/size.rs crates/ui-egui/src/app.rs crates/ui-egui/src/details.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/src/model.rs crates/ui-egui/src/settings.rs crates/ui-egui/src/views.rs
@@ -194,6 +194,13 @@ pub enum Event { app: String, installed: Option<Installed>, },+ /// How much disk space an installed copy takes, measured on its own thread. `None` when the+ /// app isn't installed, or what's installed has no folder to measure (a package a system+ /// package manager owns, a bare binary).+ InstallSize {+ app: String,+ bytes: Option<u64>,+ }, Progress { app: String, phase: JobPhase,@@ -84,6 +84,47 @@ 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>>,+ /// What is known about how much disk space the installed apps take.+ sizes: Mutex<Sizes>,+ /// A size pass is running; another request waits for it.+ measuring: AtomicBool,+ /// A size pass was asked for: the one that is running measures again once it ends.+ sizes_dirty: AtomicBool,+}++/// What is known about how much disk space the installed apps take.+#[derive(Default)]+struct Sizes {+ /// The answer for each app: where it was measured and what that came to.+ measured: HashMap<String, Measured>,+ /// A number per app that goes up every time the app's install changes. What was measured+ /// before a change is out of date, even when the folder it was measured at is the same.+ generations: HashMap<String, u64>,+}++/// One answer about an app's disk space: the folder (or file) it was measured at, and what that+/// came to (`None` when there was nothing whose size could be measured).+struct Measured {+ /// Where the answer belongs to: the path and the version that was measured. A new one means+ /// the app must be measured again.+ key: String,+ bytes: Option<u64>,+ /// The app's generation when it was measured: an answer from an older one is out of date.+ generation: u64,+}++impl Sizes {+ /// How many times the app's install has changed.+ fn generation(&self, app: &str) -> u64 {+ self.generations.get(app).copied().unwrap_or(0)+ }++ /// Says the app's install changed: what was measured for it is out of date.+ fn change(&mut self, app: &str) {+ let generation = self.generations.entry(app.to_string()).or_insert(0);+ *generation += 1;+ self.measured.remove(app);+ } } #[derive(Clone, Copy)]@@ -125,6 +166,128 @@ impl Shared { } } +/// Measures how much disk space every installed app takes, on its own thread: a folder walk can+/// take a while, and each answer arrives as one [`Event::InstallSize`]. A pass already running+/// does another one when the disk changed under it.+fn measure_sizes(shared: &Arc<Shared>) {+ measure_sizes_with(shared, measure_all_sizes);+}++/// Starts a size pass, or asks for one to run: `pass` does the measuring.+fn measure_sizes_with(shared: &Arc<Shared>, pass: impl Fn(&Shared) + Send + 'static) {+ // Ask first, then take the flag: a pass that is giving the flag up has already seen the+ // request, whichever of the two threads gets there first. Asking the other way round loses a+ // request that arrives between the pass's own check and the flag being given up.+ shared.sizes_dirty.store(true, Ordering::SeqCst);+ if shared.measuring.swap(true, Ordering::SeqCst) {+ return;+ }+ let shared = shared.clone();+ std::thread::spawn(move || {+ loop {+ shared.sizes_dirty.store(false, Ordering::SeqCst);+ pass(&shared);+ // A request came in while the pass was running: measure again.+ if shared.sizes_dirty.swap(false, Ordering::SeqCst) {+ continue;+ }+ // The pass is over, unless a request came in while it was giving up its flag.+ if end_pass(&shared) {+ break;+ }+ }+ });+}++/// Ends a size pass: gives up the flag, so that a request can start a new pass, and takes the+/// flag back when a request came in while it was being given up. Returns whether the pass is+/// over.+fn end_pass(shared: &Shared) -> bool {+ shared.measuring.store(false, Ordering::SeqCst);+ // A request may have arrived between the pass's own check and the flag being given up: it+ // found the pass still running, so it only set the dirty flag. Take the flag back, or the+ // request is lost and the sizes stay as they were until the next detection pass.+ !shared.sizes_dirty.load(Ordering::SeqCst) || shared.measuring.swap(true, Ordering::SeqCst)+}++/// One look at every app: what it takes, from the last answer where that install hasn't changed+/// since. Apps that aren't installed anymore (or never were) say so.+fn measure_all_sizes(shared: &Shared) {+ for app in &SUITE {+ // The record and its generation are taken together, under the lock order every other+ // site uses (installed, then sizes): reading them apart lets a change between the two+ // hand an old record to a new generation, which would keep a size the app no longer has.+ let (found, generation) = {+ let installed = shared.installed.lock().unwrap_or_else(PoisonError::into_inner);+ let found = installed.get(app.id).cloned();+ let sizes = shared.sizes.lock().unwrap_or_else(PoisonError::into_inner);+ (found, sizes.generation(app.id))+ };+ let bytes = match found {+ None => {+ shared.sizes.lock().unwrap_or_else(PoisonError::into_inner).measured.remove(app.id);+ None+ }+ Some(inst) => {+ // Where the app's files are: the folder (or file) to measure. A Flatpak's deploy+ // folder is new after every update, so the answer is measured again when it is.+ let target = crate::size::target(&inst);+ let path = target.as_ref().map_or_else(|| inst.location.clone(), |p| p.display().to_string());+ // The copy is part of the key: an update that leaves the path alone (an MSI or a+ // macOS bundle replaced in place) is another copy, and is measured again.+ let key = match &inst.version {+ Some(v) => format!("{path}@{v}"),+ None => path,+ };+ // Only an answer from the generation just read is current: one from before the+ // change belongs to a copy the app no longer has.+ let cached = {+ let sizes = shared.sizes.lock().unwrap_or_else(PoisonError::into_inner);+ sizes.measured.get(app.id).filter(|m| m.key == key && m.generation == generation).map(|m| m.bytes)+ };+ match cached {+ Some(bytes) => bytes,+ None => {+ let bytes = target.map(|p| crate::size::path_size(&p));+ // The app may have changed while its folder was being walked: then this+ // answer is out of date, and the next pass measures the app again.+ let answer = Measured { key, bytes, generation };+ if !keep_measured(shared, app.id, answer) {+ log::debug!("{}: the app changed while it was being measured", app.id);+ }+ bytes+ }+ }+ }+ };+ emit_size_if_current(shared, app.id, generation, bytes);+ }+}++/// Says how much room an app takes, unless it changed while its size was being worked out: the+/// answer belongs to a generation the app has already left, and the pass that change asked for+/// says what it takes now.+fn emit_size_if_current(shared: &Shared, app: &str, generation: u64, bytes: Option<u64>) {+ // Held across the emit, so a change cannot slip in between the check and the publication.+ let sizes = shared.sizes.lock().unwrap_or_else(PoisonError::into_inner);+ if sizes.generation(app) != generation {+ return;+ }+ shared.emit(Event::InstallSize { app: app.to_string(), bytes });+}++/// Keeps what was measured for an app, unless the app changed while it was being measured: an+/// out-of-date answer would be found again by the next pass, and the app's size would stay+/// wrong.+fn keep_measured(shared: &Shared, app: &str, answer: Measured) -> bool {+ let mut sizes = shared.sizes.lock().unwrap_or_else(PoisonError::into_inner);+ if sizes.generation(app) != answer.generation {+ return false;+ }+ sizes.measured.insert(app.to_string(), answer);+ true+}+ pub struct NativeBackend { shared: Arc<Shared>, paths: Paths,@@ -170,6 +333,9 @@ 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()),+ sizes: Mutex::new(Sizes::default()),+ measuring: AtomicBool::new(false),+ sizes_dirty: AtomicBool::new(false), last_check: Mutex::new(None), }); let backend = Self { shared, paths, http, source, platform: native_platform() };@@ -237,6 +403,8 @@ impl NativeBackend { for app in &SUITE { report_partial(app, &shared, &paths, platform, &options); }+ // Now that the apps are known, so is how much space they take.+ measure_sizes(&shared); drop(busy); }); }@@ -378,15 +546,22 @@ impl NativeBackend { (result, _) => result, }; {+ // Both change together, in the order used everywhere else (installed, then+ // sizes), so a measurement pass can never pair the new record with the old+ // generation — and the generation is up before anything is said about the app. let mut map = shared.installed.lock().unwrap_or_else(PoisonError::into_inner);+ let mut sizes = shared.sizes.lock().unwrap_or_else(PoisonError::into_inner); match &found { Some(i) => map.insert(app.id.to_string(), i.clone()), None => map.remove(app.id), };+ // The install changed the disk: what was measured for the app is out of date.+ sizes.change(app.id); } shared.emit(Event::Detected { app: app.id.to_string(), installed: found }); drop(job); shared.emit(Event::JobFinished { app: app.id.to_string(), result });+ measure_sizes(&shared); }); } @@ -1187,6 +1362,9 @@ mod tests { feed_ok_at: Mutex::new(None), feed_throttle: Mutex::new(FeedThrottle::new()), pictures_busy: Mutex::new(std::collections::HashSet::new()),+ sizes: Mutex::new(Sizes::default()),+ measuring: AtomicBool::new(false),+ sizes_dirty: AtomicBool::new(false), } } @@ -1763,4 +1941,164 @@ mod tests { }); assert!(!restarting.load(Ordering::SeqCst)); }++ #[test]+ fn a_request_that_comes_while_a_pass_gives_up_its_flag_is_not_lost() {+ let shared = shared();+ // The pass is running and has just found no request.+ shared.measuring.store(true, Ordering::SeqCst);+ // A request comes in: it finds the pass still running, so it only sets the flag.+ shared.sizes_dirty.store(true, Ordering::SeqCst);+ // The pass gives up its flag and sees the request: it takes the flag back and goes on.+ assert!(!end_pass(&shared));+ assert!(shared.measuring.load(Ordering::SeqCst));+ assert!(shared.sizes_dirty.load(Ordering::SeqCst));+ }++ #[test]+ fn a_pass_without_a_request_ends_and_frees_the_flag() {+ let shared = shared();+ shared.measuring.store(true, Ordering::SeqCst);+ assert!(end_pass(&shared));+ assert!(!shared.measuring.load(Ordering::SeqCst));+ }++ #[test]+ fn a_request_during_a_pass_measures_again() {+ let shared = Arc::new(shared());+ let (started_tx, started_rx) = mpsc::channel::<()>();+ let (go_tx, wait_rx) = mpsc::channel::<()>();+ let passes = Arc::new(AtomicUsize::new(0));+ let count = passes.clone();+ let pass = move |_: &Shared| {+ count.fetch_add(1, Ordering::SeqCst);+ let _ = started_tx.send(());+ let _ = wait_rx.recv();+ };+ measure_sizes_with(&shared, pass);+ started_rx.recv().unwrap();+ // A request comes in while the pass is running: it only sets the flag.+ measure_sizes_with(&shared, |_| {});+ go_tx.send(()).unwrap();+ // The pass ends and the request is answered by another pass.+ started_rx.recv().unwrap();+ assert_eq!(passes.load(Ordering::SeqCst), 2);+ go_tx.send(()).unwrap();+ }++ #[test]+ fn a_request_asks_before_it_looks_at_the_flag() {+ // The window between asking and looking is one instruction wide, so no run can be caught+ // by a test: what matters is the order the two steps are written in.+ let source = include_str!("backend.rs");+ let start = source.find("fn measure_sizes_with").expect("the function is there");+ let body = &source[start..];+ let body = &body[..body.find("\nfn ").unwrap_or(body.len())];+ let ask = body.find("sizes_dirty.store(true").expect("the request is recorded");+ let look = body.find("measuring.swap(true").expect("the flag is looked at");+ assert!(ask < look, "the request must record itself before it looks at the flag");+ }++ #[test]+ fn a_measurement_from_before_a_change_is_not_kept() {+ let shared = shared();+ let generation = shared.sizes.lock().unwrap().generation("photocraft");+ let answer = || Measured { key: "C:/apps/photocraft".into(), bytes: Some(42), generation };+ assert!(keep_measured(&shared, "photocraft", answer()));+ // The app changes while its folder is being walked: the answer is out of date.+ shared.sizes.lock().unwrap().change("photocraft");+ assert!(!keep_measured(&shared, "photocraft", answer()));+ // Nothing is kept: the next pass measures the app again.+ assert!(!shared.sizes.lock().unwrap().measured.contains_key("photocraft"));+ }++ #[test]+ fn a_size_the_app_no_longer_has_is_not_published() {+ let shared = shared();+ let stale = shared.sizes.lock().unwrap().generation("photocraft");+ // The app changes while its size is being worked out, so the answer belongs to a+ // generation the app has left: the UI must not hear about it.+ shared.sizes.lock().unwrap().change("photocraft");+ emit_size_if_current(&shared, "photocraft", stale, Some(100));+ assert!(!shared.events.lock().unwrap().iter().any(|e| matches!(e, Event::InstallSize { .. })));+ // The generation that is current now is published.+ let now = shared.sizes.lock().unwrap().generation("photocraft");+ emit_size_if_current(&shared, "photocraft", now, Some(200));+ assert!(shared.events.lock().unwrap().iter().any(|e| matches!(e, Event::InstallSize { app, bytes: Some(200) } if app == "photocraft")));+ }++ #[test]+ fn the_record_and_its_generation_are_read_together() {+ // Read apart, a change between the two hands an old record to a new generation, which+ // the pass then keeps as if it were current. The window is one statement wide, so no run+ // can be caught by a test: what matters is that both are read from one block, before the+ // folder is walked.+ let source = include_str!("backend.rs");+ let start = source.find("fn measure_all_sizes").expect("the function is there");+ let body = &source[start..];+ let body = &body[..body.find("\nfn ").unwrap_or(body.len())];+ let pair = body.find("let (found, generation) = {").expect("both are taken in one block");+ let record = body[pair..].find("installed.get(app.id)").expect("the record is read");+ let generation = body[pair..].find("sizes.generation(app.id)").expect("the generation is read");+ let walk = body[pair..].find("crate::size::path_size").expect("the folder is walked");+ assert!(record < generation && generation < walk, "both are read before anything is measured");+ }++ #[test]+ fn the_generation_changes_before_the_app_is_published() {+ // A pass started by the Detected or JobFinished event must read the record with the+ // generation that goes with it, so the change is written ahead of both. The two are a+ // statement apart, which no run can be paused between: the order is what is checked.+ let source = include_str!("backend.rs");+ let start = source.find("fn start_job").expect("the function is there");+ let job = &source[start..];+ let job = &job[..job.find("\n fn update_launcher").unwrap_or(job.len())];+ let at = job.find("let mut map = shared.installed.lock()").expect("the job puts the record away");+ let job = &job[at..];+ let change = job.find("sizes.change(app.id)").expect("the install changes the generation");+ let detected = job.find("Event::Detected").expect("the app is published");+ let finished = job.find("Event::JobFinished").expect("the job is published");+ assert!(change < detected && detected < finished, "what was measured must be out of date before either is said");+ }++ #[test]+ fn an_app_that_changed_is_measured_again() {+ let shared = shared();+ let dir = tempfile::tempdir().unwrap();+ let app = SUITE.iter().find(|a| a.id == "photocraft").unwrap();+ std::fs::write(dir.path().join("app.bin"), vec![0u8; 100]).unwrap();+ let inst = Installed { version: None, source: InstallSource::Launcher, location: dir.path().display().to_string(), package: None };+ shared.installed.lock().unwrap().insert(app.id.to_string(), inst);+ measure_all_sizes(&shared);+ assert_eq!(shared.sizes.lock().unwrap().measured.get(app.id).map(|m| m.bytes), Some(Some(100)));+ // The app changes: what was measured is out of date, and the app is measured again.+ std::fs::write(dir.path().join("app.bin"), vec![0u8; 200]).unwrap();+ shared.sizes.lock().unwrap().change(app.id);+ measure_all_sizes(&shared);+ assert_eq!(shared.sizes.lock().unwrap().measured.get(app.id).map(|m| m.bytes), Some(Some(200)));+ }++ #[test]+ fn a_new_version_at_the_same_path_is_measured_again() {+ let shared = shared();+ let dir = tempfile::tempdir().unwrap();+ let app = SUITE.iter().find(|a| a.id == "photocraft").unwrap();+ std::fs::write(dir.path().join("app.bin"), vec![0u8; 100]).unwrap();+ let inst = Installed {+ version: version::parse("1.0.0"),+ source: InstallSource::Launcher,+ location: dir.path().display().to_string(),+ package: None,+ };+ shared.installed.lock().unwrap().insert(app.id.to_string(), inst.clone());+ measure_all_sizes(&shared);+ assert_eq!(shared.sizes.lock().unwrap().measured.get(app.id).map(|m| m.bytes), Some(Some(100)));+ // The app is updated in place: the path is the same, the version is not.+ std::fs::write(dir.path().join("app.bin"), vec![0u8; 200]).unwrap();+ let mut updated = inst;+ updated.version = version::parse("2.0.0");+ shared.installed.lock().unwrap().insert(app.id.to_string(), updated);+ measure_all_sizes(&shared);+ assert_eq!(shared.sizes.lock().unwrap().measured.get(app.id).map(|m| m.bytes), Some(Some(200)));+ } }@@ -25,6 +25,7 @@ pub mod queue; pub mod screenshots; pub mod self_update; pub mod single_instance;+pub mod size; pub mod source; pub use backend::NativeBackend;new file mode 100644@@ -0,0 +1,122 @@+//! How much disk space an installed copy takes.+//!+//! Only copies whose recorded location is a path on disk can be measured: a launcher install's+//! folder (a Windows launcher install records its exe, and the folder around it is the install),+//! an MSI install's folder (the folder its exe sits in), a macOS `.app` bundle, an+//! AppImage file, and a Flatpak app's deploy folder (asked of `flatpak info`). Packages a system+//! package manager owns and bare system binaries report nothing: the launcher doesn't own them,+//! and one binary says nothing about the app's real footprint.++use std::path::{Path, PathBuf};+use std::time::Duration;++use artcraft_launcher_core::{InstallSource, Installed};++/// The folder or file whose size says how much space `installed` takes; `None` when there is+/// nothing whose size would be honest.+pub fn target(installed: &Installed) -> Option<PathBuf> {+ match installed.source {+ // The location is the app's bundle id; where it really lives is Flatpak's own answer.+ InstallSource::Flatpak => {+ let id = installed.location.trim();+ (!id.is_empty()).then_some(id).and_then(flatpak_folder)+ }+ InstallSource::Deb | InstallSource::Rpm | InstallSource::Pacman | InstallSource::Binary => None,+ // An MSI records an exe, not its folder; the folder is where the exe is.+ InstallSource::Msi => {+ let exe = Path::new(&installed.location);+ if exe.is_file() { folder_of(exe) } else { None }+ }+ // Everything else records the path itself: a folder, a bundle, or a single file.+ _ => {+ let path = Path::new(&installed.location);+ if path.is_dir() {+ return Some(path.to_path_buf());+ }+ if !path.is_file() {+ return None;+ }+ // A Windows launcher install records its exe, not its folder: the install is the+ // folder the exe sits in, with the rest of the app's files beside it. Elsewhere a+ // recorded file is the app itself (an AppImage), and is measured as it is.+ if cfg!(windows) { folder_of(path) } else { Some(path.to_path_buf()) }+ }+ }+}++/// The folder a file sits in. A drive root is never an app's folder: measuring it would count+/// the whole disk.+fn folder_of(file: &Path) -> Option<PathBuf> {+ file.parent().filter(|folder| folder.file_name().is_some()).map(Path::to_path_buf)+}++/// Where Flatpak keeps an app's files; `None` when Flatpak doesn't know the app (or isn't here).+fn flatpak_folder(id: &str) -> Option<PathBuf> {+ let shown = crate::process::capture_flatpak(&["info", "--show-location", id], Duration::from_secs(5))?;+ let path = PathBuf::from(shown.trim());+ path.is_dir().then_some(path)+}++/// The size of a file, or of every file under a folder. Symlinks are counted as the link itself+/// and never followed. Anything unreadable is skipped: the answer is an estimate, never an error.+pub fn path_size(path: &Path) -> u64 {+ let Ok(meta) = std::fs::symlink_metadata(path) else { return 0 };+ if !meta.is_dir() {+ return meta.len();+ }+ let mut total = 0u64;+ let mut stack = vec![path.to_path_buf()];+ while let Some(dir) = stack.pop() {+ let Ok(entries) = std::fs::read_dir(&dir) else { continue };+ for entry in entries.flatten() {+ match entry.metadata() {+ // `DirEntry::metadata` doesn't traverse symlinks, so this can't loop.+ Ok(m) if m.is_dir() => stack.push(entry.path()),+ Ok(m) => total = total.saturating_add(m.len()),+ Err(_) => {}+ }+ }+ }+ total+}++#[cfg(test)]+mod tests {+ use super::*;++ fn launcher(location: &Path) -> Installed {+ Installed { version: None, source: InstallSource::Launcher, location: location.display().to_string(), package: None }+ }++ #[test]+ fn a_launcher_folder_is_measured_where_it_is() {+ let dir = tempfile::tempdir().unwrap();+ assert_eq!(target(&launcher(dir.path())).as_deref(), Some(dir.path()));+ }++ #[test]+ fn a_location_that_is_not_on_disk_is_not_measured() {+ let dir = tempfile::tempdir().unwrap();+ let missing = dir.path().join("photocraft");+ assert_eq!(target(&launcher(&missing)), None);+ }++ #[cfg(windows)]+ #[test]+ fn a_windows_launcher_exe_is_measured_by_the_folder_around_it() {+ let dir = tempfile::tempdir().unwrap();+ let exe = dir.path().join("photocraft.exe");+ std::fs::write(&exe, b"program").unwrap();+ std::fs::write(dir.path().join("runtime.dll"), b"library").unwrap();+ assert_eq!(target(&launcher(&exe)).as_deref(), Some(dir.path()));+ // The whole install is measured, not just the exe.+ assert_eq!(path_size(&target(&launcher(&exe)).unwrap()), 14);+ }++ #[cfg(windows)]+ #[test]+ fn a_drive_root_is_never_an_apps_folder() {+ assert_eq!(folder_of(Path::new(r"D:\photocraft.exe")), None);+ assert_eq!(folder_of(Path::new(r"D:\Apps\PhotoCraft\photocraft.exe")), Some(PathBuf::from(r"D:\Apps\PhotoCraft")));+ }+}@@ -512,6 +512,12 @@ impl LauncherUi { } true }+ Event::InstallSize { app, bytes } => {+ if let Some(e) = self.entry_mut(&app) {+ e.size = bytes;+ }+ false+ } Event::Progress { app, phase } => { self.on_progress(app, phase); false@@ -871,6 +877,10 @@ impl LauncherUi { let (x, y) = (at(a), at(b)); opt(x.zip(y).map(|(x, y)| x.cmp(&y)), x.is_some(), y.is_some()) }+ SortKey::Size => {+ let (x, y) = (a.size, b.size);+ opt(x.zip(y).map(|(x, y)| x.cmp(&y)), x.is_some(), y.is_some())+ } }; // Ties keep suite order. ord.then(ia.cmp(ib))@@ -365,10 +365,15 @@ impl LauncherUi { ui.spacing_mut().item_spacing.y = 5.0; widgets::wordmark(ui, app, theme::PAGE_TITLE); ui.add(egui::Label::new(RichText::new(details.headline).size(15.0).color(t.ink)).wrap());- // "Version 0.5.0 · Oct 8, 2026", or the installed-versus-latest line.+ // "Version 0.5.0 · Oct 8, 2026", or the installed-versus-latest line, and how+ // much room the installed copy takes. let version = crate::views::version_line(entry, status);- if !version.trim().is_empty() {- ui.label(RichText::new(version).size(12.5).color(t.muted));+ let line = match entry.size {+ Some(bytes) if !version.trim().is_empty() => format!("{version} · {}", widgets::human_bytes(bytes)),+ _ => version,+ };+ if !line.trim().is_empty() {+ ui.label(RichText::new(line).size(12.5).color(t.muted)); } }); if wide {@@ -242,6 +242,7 @@ column App Aplikace column Installed Nainstalováno column Latest Nejnovější column Released Vydáno+column Size Velikost filter Installed Nainstalováno App details Podrobnosti o aplikaci More actions for {name} Další akce pro {name}@@ -348,4 +349,7 @@ filter Installed Nainstalováno Previous app Předchozí aplikace Show {n} more changes Zobrazit dalších změn: {n} Last updated: {date} Naposledy aktualizováno: {date}+ Size on disk Velikost na disku+ Total: {size} Celkem: {size}+ Disk space used by the installed apps Místo na disku zabrané nainstalovanými aplikacemi column Version Verze@@ -263,6 +263,7 @@ column App App column Installed Lokal column Latest Neueste column Released Datum+column Size Größe # Contextual: the filter tab names a group (the status pill says "Installed") filter Installed Installiert@@ -380,4 +381,7 @@ filter Installed Installiert Previous app Vorherige App Show {n} more changes {n} weitere Änderungen anzeigen Last updated: {date} Zuletzt aktualisiert: {date}+ Size on disk Belegter Speicherplatz+ Total: {size} Gesamt: {size}+ Disk space used by the installed apps Von den installierten Apps belegter Speicherplatz column Version Version@@ -255,6 +255,7 @@ column App Aplicación column Installed Instalada column Latest Última column Released Publicada+column Size Tamaño filter Installed Instaladas App details Detalles de la app More actions for {name} Más acciones para {name}@@ -361,4 +362,7 @@ filter Installed Instaladas Previous app Aplicación anterior Show {n} more changes Mostrar {n} cambios más Last updated: {date} Última actualización: {date}+ Size on disk Tamaño en disco+ Total: {size} Total: {size}+ Disk space used by the installed apps Espacio en disco ocupado por las aplicaciones instaladas column Version Versión@@ -261,6 +261,7 @@ column App Application column Installed Installée column Latest Dernière column Released Date de publication+column Size Taille # Contextual: the filter tab names a group (the status pill says "Installed") filter Installed Installées@@ -378,4 +379,7 @@ filter Installed Installées Previous app Application précédente Show {n} more changes Afficher {n} modifications de plus Last updated: {date} Dernière mise à jour : {date}+ Size on disk Taille sur le disque+ Total: {size} Total : {size}+ Disk space used by the installed apps Espace disque utilisé par les applications installées column Version Version@@ -264,6 +264,7 @@ column App Alkalmazás column Installed Telepített column Latest Legújabb column Released Dátum+column Size Méret # Contextual: the filter tab names a group (the status pill says "Installed") filter Installed Telepítve@@ -381,4 +382,7 @@ filter Installed Telepítve Previous app Előző alkalmazás Show {n} more changes {n} további változás megjelenítése Last updated: {date} Utoljára frissítve: {date}+ Size on disk Lemezfoglalás+ Total: {size} Összesen: {size}+ Disk space used by the installed apps A telepített alkalmazások lemezfoglalása column Version Verzió@@ -266,6 +266,7 @@ column App アプリ column Installed インストール済みバージョン column Latest 最新バージョン column Released 公開日+column Size サイズ # Contextual: the filter tab names a group (the status pill says "Installed") filter Installed インストール済み@@ -383,4 +384,7 @@ filter Installed インストール済み Previous app 前のアプリ Show {n} more changes さらに{n}件の変更を表示 Last updated: {date} 最終更新: {date}+ Size on disk ディスク使用量+ Total: {size} 合計: {size}+ Disk space used by the installed apps インストール済みアプリが使用するディスク容量 column Version バージョン@@ -265,6 +265,7 @@ column App Aplikacja column Installed Zainstalowana column Latest Najnowsza column Released Data wydania+column Size Rozmiar # Contextual: the filter tab names a group (the status pill says "Installed") filter Installed Zainstalowane@@ -382,4 +383,7 @@ filter Installed Zainstalowane Previous app Poprzednia aplikacja Show {n} more changes Pokaż {n} więcej zmian Last updated: {date} Ostatnia aktualizacja: {date}+ Size on disk Rozmiar na dysku+ Total: {size} Razem: {size}+ Disk space used by the installed apps Miejsce na dysku zajmowane przez zainstalowane aplikacje column Version Wersja@@ -251,6 +251,7 @@ column App Aplicativo column Installed Instalada column Latest Última column Released Lançamento+column Size Tamanho # Contextual: the filter tab names a group (the status pill says "Installed") filter Installed Instalados@@ -368,4 +369,7 @@ filter Installed Instalados Previous app Aplicativo anterior Show {n} more changes Mostrar mais {n} alterações Last updated: {date} Última atualização: {date}+ Size on disk Tamanho em disco+ Total: {size} Total: {size}+ Disk space used by the installed apps Espaço em disco usado pelos aplicativos instalados column Version Versão@@ -260,6 +260,7 @@ column App Приложение column Installed Текущая column Latest Последняя column Released Дата релиза+column Size Размер # Contextual: the filter tab names a group (the status pill says "Installed") filter Installed Установлены@@ -376,4 +377,7 @@ filter Installed Установлены Previous app Предыдущее приложение Show {n} more changes Показать ещё изменений: {n} Last updated: {date} Последнее обновление: {date}+ Size on disk Размер на диске+ Total: {size} Всего: {size}+ Disk space used by the installed apps Место на диске, занятое установленными приложениями column Version Версия@@ -177,6 +177,7 @@ column App 应用 column Installed 已安装版本 column Latest 最新版本 column Released 发布日期+column Size 大小 filter Installed 已安装 {name} details {name} 详情 @plural {n} min ago|{n} min ago {n} 分钟前@@ -348,4 +349,7 @@ figcraft Interface design and prototyping 界面设计与原型制作 Previous app 上一个应用 Show {n} more changes 显示另外 {n} 项更改 Last updated: {date} 最后更新:{date}+ Size on disk 磁盘占用+ Total: {size} 总计:{size}+ Disk space used by the installed apps 已安装应用占用的磁盘空间 column Version 版本@@ -22,11 +22,14 @@ pub struct AppEntry { pub paused: Option<PausedDownload>, /// A finished download waiting for another try at installing it. pub ready: bool,+ /// Disk space this app takes, once the backend has measured it (`None` while unknown, or+ /// when what's installed has nothing on disk to measure).+ pub size: Option<u64>, } impl AppEntry { pub fn new(app: &'static CraftApp) -> Self {- Self { app, latest: None, installed: None, detected: false, job: None, origin: None, paused: None, ready: false }+ Self { app, latest: None, installed: None, detected: false, job: None, origin: None, paused: None, ready: false, size: None } } pub fn release(&self) -> Option<&Release> {@@ -43,13 +43,15 @@ pub enum SortKey { Released, /// How often the app has been opened from here, most first. Frequent,+ /// Disk space the installed copy takes.+ Size, } impl SortKey { /// The direction a first click sorts in: newest and highest first for dates and versions, /// A to Z for text. pub fn starts_descending(self) -> bool {- matches!(self, Self::Installed | Self::Latest | Self::Released | Self::Frequent)+ matches!(self, Self::Installed | Self::Latest | Self::Released | Self::Frequent | Self::Size) } } @@ -227,6 +227,13 @@ impl LauncherUi { widgets::hud(ui, sort, t.faint); } }+ // What every installed app takes together: before the sort in the grid, beside+ // the search in the table, where there is no sort control.+ if let Some(total) = self.total_size() {+ ui.add_space(12.0);+ widgets::hud(ui, &fmt(tl!("Total: {size}"), &[("size", &widgets::human_bytes(total))]), t.faint)+ .on_hover_text(tl!("Disk space used by the installed apps"));+ } if !compact && self.caps.manage_apps && updates > 0 { self.update_all_button(ui, updates); }@@ -252,6 +259,12 @@ impl LauncherUi { }); } + /// The disk space every installed app takes, once the backend has measured any of them.+ fn total_size(&self) -> Option<u64> {+ let total = self.entries.iter().filter_map(|e| e.size).sum::<u64>();+ (total > 0).then_some(total)+ }+ /// The toolbar's check button. It queues a forced check through the UI action boundary and is /// disabled while `self.checking`; the backend receives the check only when this pass flushes. fn refresh_button(&mut self, ui: &mut Ui) {@@ -475,14 +488,22 @@ impl LauncherUi { let mut strip_ui = ui.new_child( egui::UiBuilder::new().max_rect(strip.shrink2(vec2(CARD_PAD, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center)), );+ // What the app takes on disk, if it is installed: the strip says so beside its version.+ let size = entry.size.map(widgets::human_bytes); if !version.is_empty() {- let github_w = widgets::hud_galley(&strip_ui, &format!("{} ↗", tl!("GitHub")), t.muted).size().x;- let room = (strip_ui.available_width() - github_w - 16.0).max(0.0);+ // Room for what the right end of the strip already holds: the size, then GitHub.+ let github_w = widgets::hud_galley(&strip_ui, &format!("{} ↗", tl!("GitHub")), t.muted).size().x + 16.0;+ let size_w = size.as_deref().map_or(0.0, |s| widgets::hud_galley(&strip_ui, s, t.faint).size().x + 16.0);+ let room = (strip_ui.available_width() - github_w - size_w - 16.0).max(0.0); widgets::hud_elided(&mut strip_ui, &version, if in_colour { t.app_ink(app) } else { t.muted }, room); } strip_ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.spacing_mut().item_spacing.x = 16.0; widgets::hud_link(ui, tl!("GitHub"), &app.repo_url());+ // How much disk space the installed copy takes; only an installed app has one.+ if let Some(size) = size.as_ref() {+ widgets::hud(ui, size, t.faint).on_hover_text(tl!("Size on disk"));+ } }); } @@ -573,6 +594,7 @@ impl LauncherUi { .column(Column::remainder().at_least(220.0).clip(true)) .column(Column::exact(176.0)) .column(Column::exact(150.0))+ .column(Column::exact(96.0)) .column(Column::exact(104.0)) .column(Column::exact(206.0)) .header(42.0, |header| self.table_header(header, &mut sort_clicked, &t, span, clip))@@ -655,6 +677,7 @@ impl LauncherUi { (Some(SortKey::Name), tl!("App")), (Some(SortKey::Status), tl!("Status")), (None, tr_ctx(current(), "column", "Version")),+ (Some(SortKey::Size), tr_ctx(current(), "column", "Size")), (Some(SortKey::Released), tr_ctx(current(), "column", "Released")), (None, ""), ] {@@ -670,7 +693,7 @@ impl LauncherUi { } /// The table's one row per app: the app (click to open its page), its status, the version,- /// when it was released, and its actions.+ /// how much disk space it takes, when it was released, and its actions. fn table_rows( &mut self, mut body: egui_extras::TableBody<'_>,@@ -688,6 +711,7 @@ impl LauncherUi { row.col(|ui| self.table_app_cell(ui, &entry, id, i < last, t, (fonts.0, fonts.1, rule.0, rule.1))); row.col(|ui| self.table_status_cell(ui, &entry, &status, t)); row.col(|ui| self.table_version_cell(ui, &entry, &status, t));+ row.col(|ui| self.table_size_cell(ui, &entry, t)); row.col(|ui| self.table_released_cell(ui, &entry, now, t)); row.col(|ui| self.actions(ui, &entry, &status, 204.0)); });@@ -772,6 +796,20 @@ impl LauncherUi { } } + /// The size column: how much disk space the installed copy takes; an app without a+ /// measurable install (a system package) says nothing.+ fn table_size_cell(&self, ui: &mut Ui, entry: &AppEntry, t: &Tokens) {+ let mut mono = |text: String, color| ui.label(RichText::new(text).family(egui::FontFamily::Monospace).color(color));+ match entry.size {+ Some(bytes) => {+ mono(widgets::human_bytes(bytes), t.ink);+ }+ None => {+ mono("—".into(), t.faint);+ }+ }+ }+ /// The released column: the release date, or an em dash, with "how long ago" in its tooltip. fn table_released_cell(&self, ui: &mut Ui, entry: &AppEntry, now: i64, t: &Tokens) { let when = entry.release().and_then(|r| r.published_at);