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.rs
new 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	Verze
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -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
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -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
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -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
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -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	Wersja
diff --git a/crates/ui-egui/src/i18n/pt-BR.tsv b/crates/ui-egui/src/i18n/pt-BR.tsv
--- a/crates/ui-egui/src/i18n/pt-BR.tsv
+++ b/crates/ui-egui/src/i18n/pt-BR.tsv
@@ -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
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -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);
