collective

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

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@@ -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,diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs--- a/crates/native/src/backend.rs+++ b/crates/native/src/backend.rs@@ -84,6 +84,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)));+    } }diff --git a/crates/native/src/lib.rs b/crates/native/src/lib.rs--- a/crates/native/src/lib.rs+++ b/crates/native/src/lib.rs@@ -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;diff --git a/crates/native/src/size.rs b/crates/native/src/size.rsnew file mode 100644--- /dev/null+++ b/crates/native/src/size.rs@@ -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")));+    }+}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@@ -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))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@@ -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 {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@@ -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	Verzediff --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@@ -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	Versiondiff --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@@ -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óndiff --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@@ -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	Versiondiff --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@@ -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ódiff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv--- a/crates/ui-egui/src/i18n/ja.tsv+++ b/crates/ui-egui/src/i18n/ja.tsv@@ -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	バージョン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@@ -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	Wersjadiff --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@@ -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ãodiff --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@@ -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	Версия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@@ -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	版本diff --git a/crates/ui-egui/src/model.rs b/crates/ui-egui/src/model.rs--- a/crates/ui-egui/src/model.rs+++ b/crates/ui-egui/src/model.rs@@ -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> {diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs--- a/crates/ui-egui/src/settings.rs+++ b/crates/ui-egui/src/settings.rs@@ -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)     } } diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs--- a/crates/ui-egui/src/views.rs+++ b/crates/ui-egui/src/views.rs@@ -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);