diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -311,6 +311,7 @@ name = "artcraft-launcher-native"
 version = "0.8.1"
 dependencies = [
  "artcraft-launcher-core",
+ "cidre",
  "craft-update",
  "dirs 6.0.0",
  "dunce",
@@ -829,6 +830,22 @@ dependencies = [
  "num-traits",
 ]
 
+[[package]]
+name = "cidre"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fde4542e9dfd21498f5366a5ad60963879fd1885743a18980f6dbb27f525895"
+dependencies = [
+ "cc",
+ "cidre-macros",
+]
+
+[[package]]
+name = "cidre-macros"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a85776177a885b3cd2cda74d634cabe7fa05fecfde8ded8779c0b34110a44dcb"
+
 [[package]]
 name = "clipboard-win"
 version = "5.4.1"
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -89,6 +89,10 @@ you when an update is out. It is a small native app that can sit in your menu ba
 
 The web version shows the same catalog in the browser, with the latest download for each app.
 
+On macOS, automatic app and launcher updates wait while Low Data Mode is on. Downloads already
+running automatically pause and resume when it is off. Manual updates and release checks remain
+available.
+
 ## Interface languages
 
 The interface is in English, Brazilian Portuguese (Português (Brasil)), Czech (Čeština), French
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
@@ -25,6 +25,10 @@ pub enum Command {
     Install {
         app: String,
     },
+    /// Automatically update an installed app or the launcher, respecting the network's data policy.
+    AutoUpdate {
+        app: String,
+    },
     /// `Install`, writing over what [`Event::Occupied`] reported.
     Replace {
         app: String,
@@ -175,6 +179,14 @@ impl Source {
 /// Something the backend wants the UI to know.
 #[derive(Clone, Debug, PartialEq)]
 pub enum Event {
+    /// An automatic update was deferred or paused by the network's data policy; retry when allowed.
+    AutomaticUpdateDeferred {
+        app: String,
+    },
+    /// An automatic update is waiting for another job for the same app to finish.
+    AutomaticUpdateWaiting {
+        app: String,
+    },
     Latest {
         app: String,
         latest: LatestRelease,
@@ -254,6 +266,10 @@ pub struct Capabilities {
 
 /// The UI's view of a backend.
 pub trait Backend {
+    /// Whether the network permits automatic downloads. Manual updates remain available.
+    fn automatic_updates_allowed(&self) -> bool {
+        true
+    }
     fn send(&mut self, command: Command);
     /// Events since the last call, oldest first.
     fn poll(&mut self) -> Vec<Event>;
diff --git a/crates/native/Cargo.toml b/crates/native/Cargo.toml
--- a/crates/native/Cargo.toml
+++ b/crates/native/Cargo.toml
@@ -40,6 +40,8 @@ tar = { version = "0.4", default-features = false }
 
 [target.'cfg(target_os = "macos")'.dependencies]
 plist = "1"
+# Safe Network.framework path monitoring for Low Data Mode; no direct FFI in this workspace.
+cidre = { version = "0.29", default-features = false, features = ["nw"] }
 
 [target.'cfg(target_os = "windows")'.dependencies]
 winreg = "0.55"
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
@@ -55,7 +55,8 @@ struct Shared {
     waker: Waker,
     latest: Mutex<HashMap<String, Release>>,
     installed: Mutex<HashMap<String, Installed>>,
-    jobs: Mutex<HashMap<String, Arc<Stop>>>,
+    jobs: Mutex<HashMap<String, RunningJob>>,
+    automatic_updates_allowed: AtomicBool,
     /// The launcher is replacing itself and will restart: no new job starts.
     restarting: AtomicBool,
     manifest: Mutex<Manifest>,
@@ -110,19 +111,61 @@ impl Drop for BusyGuard {
 struct JobGuard {
     shared: Arc<Shared>,
     app_id: String,
+    finished: bool,
+}
+
+#[derive(Clone)]
+struct RunningJob {
+    stop: Arc<Stop>,
+    automatic: bool,
+    deferred: bool,
+}
+
+impl JobGuard {
+    /// Remove the job before its terminal events are published. Returning the deferred flag lets
+    /// callers publish completion and deferral together after the old slot is released.
+    fn finish(&mut self) -> bool {
+        if self.finished {
+            return false;
+        }
+        self.finished = true;
+        let job = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).remove(&self.app_id);
+        job.is_some_and(|job| job.deferred && job.stop.check().is_err_and(|e| e == PAUSED))
+    }
 }
 
 impl Drop for JobGuard {
     fn drop(&mut self) {
-        self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).remove(&self.app_id);
+        if self.finish() {
+            self.shared.emit(Event::AutomaticUpdateDeferred { app: self.app_id.clone() });
+        }
     }
 }
 
 impl Shared {
+    fn network_changed(&self, allowed: bool) {
+        let mut jobs = self.jobs.lock().unwrap_or_else(PoisonError::into_inner);
+        self.automatic_updates_allowed.store(allowed, Ordering::SeqCst);
+        if !allowed {
+            for job in jobs.values_mut().filter(|job| job.automatic && job.stop.check().is_ok()) {
+                job.deferred = true;
+                job.stop.pause();
+            }
+        }
+        drop(jobs);
+        (self.waker)();
+    }
+
     fn emit(&self, event: Event) {
         self.events.lock().unwrap_or_else(PoisonError::into_inner).push(event);
         (self.waker)();
     }
+
+    /// Publish related terminal events under one queue lock, so the UI cannot retry between them.
+    fn emit_all(&self, events: impl IntoIterator<Item = Event>) {
+        self.events.lock().unwrap_or_else(PoisonError::into_inner).extend(events);
+        (self.waker)();
+    }
 }
 
 pub struct NativeBackend {
@@ -131,6 +174,8 @@ pub struct NativeBackend {
     http: Option<Http>,
     source: Option<Arc<dyn ReleaseSource>>,
     platform: Option<Platform>,
+    #[cfg(target_os = "macos")]
+    _network: Option<crate::network::Monitor>,
 }
 
 impl NativeBackend {
@@ -156,6 +201,7 @@ impl NativeBackend {
             latest: Mutex::new(HashMap::new()),
             installed: Mutex::new(HashMap::new()),
             jobs: Mutex::new(HashMap::new()),
+            automatic_updates_allowed: AtomicBool::new(false),
             restarting: AtomicBool::new(false),
             manifest: Mutex::new(Manifest::load(&paths.manifest())),
             options: Mutex::new(BackendOptions::default()),
@@ -172,7 +218,22 @@ impl NativeBackend {
             pictures_busy: Mutex::new(std::collections::HashSet::new()),
             last_check: Mutex::new(None),
         });
-        let backend = Self { shared, paths, http, source, platform: native_platform() };
+        #[cfg(not(target_os = "macos"))]
+        shared.network_changed(true);
+        #[cfg(target_os = "macos")]
+        let network = {
+            let shared = shared.clone();
+            crate::network::Monitor::new(move |allowed| shared.network_changed(allowed))
+        };
+        let backend = Self {
+            shared,
+            paths,
+            http,
+            source,
+            platform: native_platform(),
+            #[cfg(target_os = "macos")]
+            _network: Some(network),
+        };
         backend.replay_cache();
         backend.detect_all();
         backend
@@ -299,33 +360,47 @@ impl NativeBackend {
 
     fn start_job(&self, app_id: &str, work: Work) {
         let uninstall = work == Work::Uninstall;
+        let automatic = work == Work::AutoUpdate;
         let Some(app) = CraftApp::by_id(app_id) else { return };
         log::info!("{}: starting {} job", app.name, if uninstall { "uninstall" } else { "install/update" });
         let stop = Arc::new(Stop::default());
-        let restarting = {
+        let (restarting, rejected, busy) = {
             let mut jobs = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner);
             if jobs.contains_key(app.id) {
-                return;
-            }
-            let restarting = self.shared.restarting.load(Ordering::SeqCst);
-            if !restarting {
-                jobs.insert(app.id.to_string(), stop.clone());
+                (false, true, true)
+            } else if automatic && !self.automatic_updates_allowed() {
+                (false, true, false)
+            } else {
+                let restarting = self.shared.restarting.load(Ordering::SeqCst);
+                if !restarting {
+                    jobs.insert(app.id.to_string(), RunningJob { stop: stop.clone(), automatic, deferred: false });
+                }
+                (restarting, false, false)
             }
-            restarting
         };
+        if rejected {
+            if automatic {
+                self.shared.emit(if busy {
+                    Event::AutomaticUpdateWaiting { app: app.id.to_string() }
+                } else {
+                    Event::AutomaticUpdateDeferred { app: app.id.to_string() }
+                });
+            }
+            return;
+        }
         if restarting {
             let result = Err("ArtCraft Launcher is restarting to finish its update. Try again once it's back.".into());
             self.shared.emit(Event::JobFinished { app: app.id.to_string(), result });
             return;
         }
         let shared = self.shared.clone();
-        let job = JobGuard { shared: shared.clone(), app_id: app.id.to_string() };
+        let job = JobGuard { shared: shared.clone(), app_id: app.id.to_string(), finished: false };
         let paths = self.paths.clone();
         let http = self.http.clone();
         let platform = self.platform;
         let options = self.options();
         std::thread::spawn(move || {
-            let job = job;
+            let mut job = job;
             let existing = shared.installed.lock().unwrap_or_else(PoisonError::into_inner).get(app.id).cloned();
             let record = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).apps.get(app.id).cloned();
             let result = if uninstall {
@@ -362,7 +437,6 @@ impl NativeBackend {
                     other => other,
                 }
             };
-            report_partial(app, &shared, &paths, platform, &options);
             // Look again: the result is whatever is on disk now.
             let manifest = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).clone();
             let found = crate::detect::detect(app, &manifest, &crate::detect::Snapshot::take());
@@ -385,13 +459,20 @@ impl NativeBackend {
                 };
             }
             shared.emit(Event::Detected { app: app.id.to_string(), installed: found });
+            let deferred = job.finish();
+            let app_id = app.id.to_string();
+            let mut events = vec![Event::JobFinished { app: app_id.clone(), result }];
+            if deferred {
+                events.push(Event::AutomaticUpdateDeferred { app: app_id });
+            }
+            shared.emit_all(events);
+            report_partial(app, &shared, &paths, platform, &options);
             drop(job);
-            shared.emit(Event::JobFinished { app: app.id.to_string(), result });
         });
     }
 
     /// Downloads the newer launcher, puts it in place and asks the UI to quit so it can start.
-    fn update_launcher(&self) {
+    fn update_launcher(&self, automatic: bool) {
         let id = LAUNCHER_ID.to_string();
         let (Some(install), Some(platform)) = (self.shared.self_install.get().cloned().flatten(), self.platform) else {
             self.shared.emit(Event::JobFinished {
@@ -402,17 +483,27 @@ impl NativeBackend {
         };
         let Some(release) = self.shared.launcher_release.lock().unwrap_or_else(PoisonError::into_inner).clone() else { return };
         let stop = Arc::new(Stop::default());
-        {
+        let (rejected, busy) = {
             let mut jobs = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner);
             if jobs.contains_key(&id) {
-                return;
+                (true, true)
+            } else if automatic && !self.automatic_updates_allowed() {
+                (true, false)
+            } else {
+                jobs.insert(id.clone(), RunningJob { stop: stop.clone(), automatic, deferred: false });
+                (false, false)
+            }
+        };
+        if rejected {
+            if automatic {
+                self.shared.emit(if busy { Event::AutomaticUpdateWaiting { app: id } } else { Event::AutomaticUpdateDeferred { app: id } });
             }
-            jobs.insert(id.clone(), stop.clone());
+            return;
         }
-        let job = JobGuard { shared: self.shared.clone(), app_id: id.clone() };
+        let job = JobGuard { shared: self.shared.clone(), app_id: id.clone(), finished: false };
         let (shared, paths, http) = (self.shared.clone(), self.paths.clone(), self.http.clone());
         std::thread::spawn(move || {
-            let job = job;
+            let mut job = job;
             let mut last_emit = Instant::now() - Duration::from_secs(1);
             let mut phase = |p: JobPhase| {
                 if matches!(p, JobPhase::Downloading { done, total } if done != total && done != 0) && last_emit.elapsed() < Duration::from_millis(50)
@@ -430,14 +521,25 @@ impl NativeBackend {
                 Some(http) => crate::self_update::update(&install, &release, platform, http, &paths, &stop, &mut phase, &last),
                 None => Err("secure connections aren't available on this system".into()),
             };
-            drop(job);
+            let deferred = job.finish();
             match result {
-                Ok(()) => shared.emit(Event::LauncherRestarting),
+                Ok(()) => {
+                    let mut events = vec![Event::LauncherRestarting];
+                    if deferred {
+                        events.push(Event::AutomaticUpdateDeferred { app: LAUNCHER_ID.to_string() });
+                    }
+                    shared.emit_all(events);
+                }
                 Err(e) => {
                     shared.restarting.store(false, Ordering::SeqCst);
-                    shared.emit(Event::JobFinished { app: LAUNCHER_ID.to_string(), result: Err(e) });
+                    let mut events = vec![Event::JobFinished { app: LAUNCHER_ID.to_string(), result: Err(e) }];
+                    if deferred {
+                        events.push(Event::AutomaticUpdateDeferred { app: LAUNCHER_ID.to_string() });
+                    }
+                    shared.emit_all(events);
                 }
             }
+            drop(job);
         });
     }
 
@@ -449,6 +551,7 @@ impl NativeBackend {
 #[derive(Clone, Copy, PartialEq, Eq)]
 enum Work {
     Install,
+    AutoUpdate,
     Replace,
     Uninstall,
 }
@@ -554,7 +657,7 @@ fn landed(before: Option<&Installed>, after: Option<&Installed>, latest: &versio
 /// wait (`stop`); after `limit` it gives up and names the apps still busy, since one may be
 /// stuck behind a password prompt nobody has answered.
 fn claim_restart(
-    jobs: &Mutex<HashMap<String, Arc<Stop>>>,
+    jobs: &Mutex<HashMap<String, RunningJob>>,
     restarting: &AtomicBool,
     stop: &Stop,
     limit: Duration,
@@ -1035,22 +1138,29 @@ impl NativeBackend {
 }
 
 impl Backend for NativeBackend {
+    fn automatic_updates_allowed(&self) -> bool {
+        self.shared.automatic_updates_allowed.load(Ordering::SeqCst)
+    }
+
     fn send(&mut self, command: Command) {
         match command {
             Command::CheckForUpdates { force } => self.check(force),
             Command::Detect => self.detect_all(),
             Command::Install { app } => self.start_job(&app, Work::Install),
+            Command::AutoUpdate { app } if app == LAUNCHER_ID => self.update_launcher(true),
+            Command::AutoUpdate { app } => self.start_job(&app, Work::AutoUpdate),
             Command::Replace { app } => self.start_job(&app, Work::Replace),
             Command::Uninstall { app } => self.start_job(&app, Work::Uninstall),
             Command::Pause { app } => {
-                if let Some(stop) = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).get(&app) {
-                    stop.pause();
+                if let Some(job) = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).get_mut(&app) {
+                    job.deferred = false;
+                    job.stop.pause();
                 }
             }
             Command::Cancel { app } => {
                 let running = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).get(&app).cloned();
                 match running {
-                    Some(stop) => stop.cancel(),
+                    Some(job) => job.stop.cancel(),
                     // A paused download, or one kept after a failed install: throw the file away.
                     None => {
                         if let Some(def) = CraftApp::by_id(&app)
@@ -1082,7 +1192,7 @@ impl Backend for NativeBackend {
                     }
                 }
             }
-            Command::UpdateLauncher => self.update_launcher(),
+            Command::UpdateLauncher => self.update_launcher(false),
             Command::FetchScreenshots { app } => self.fetch_screenshots(&app),
             Command::Configure(options) => {
                 let changed_pre = {
@@ -1172,6 +1282,7 @@ mod tests {
             latest: Mutex::new(HashMap::new()),
             installed: Mutex::new(HashMap::new()),
             jobs: Mutex::new(HashMap::new()),
+            automatic_updates_allowed: AtomicBool::new(true),
             restarting: AtomicBool::new(false),
             manifest: Mutex::new(Manifest::default()),
             options: Mutex::new(BackendOptions::default()),
@@ -1190,6 +1301,147 @@ mod tests {
         }
     }
 
+    #[test]
+    fn low_data_mode_pauses_automatic_app_and_launcher_downloads_only() {
+        let shared = Arc::new(shared());
+        let ids = ["photocraft", LAUNCHER_ID, "wordcraft", "filmcraft", "deckcraft"];
+        for id in ids {
+            shared
+                .jobs
+                .lock()
+                .unwrap()
+                .insert(id.into(), RunningJob { stop: Arc::new(Stop::default()), automatic: id != "wordcraft", deferred: false });
+        }
+        // User pauses and cancellations must not become automatic retries.
+        {
+            let jobs = shared.jobs.lock().unwrap();
+            jobs.get("filmcraft").unwrap().stop.pause();
+            jobs.get("deckcraft").unwrap().stop.cancel();
+        }
+        shared.network_changed(false);
+        assert!(!shared.automatic_updates_allowed.load(Ordering::SeqCst));
+        {
+            let jobs = shared.jobs.lock().unwrap();
+            for id in ["photocraft", LAUNCHER_ID] {
+                let job = jobs.get(id).unwrap();
+                assert_eq!(job.stop.check(), Err(PAUSED.into()));
+                assert!(job.deferred);
+            }
+            assert!(jobs.get("wordcraft").unwrap().stop.check().is_ok());
+            assert!(!jobs.get("filmcraft").unwrap().deferred);
+            assert!(!jobs.get("deckcraft").unwrap().deferred);
+        }
+        // A quick off/on change must still remember which jobs need to resume.
+        shared.network_changed(true);
+        for id in ids {
+            drop(JobGuard { shared: shared.clone(), app_id: id.into(), finished: false });
+        }
+        assert_eq!(
+            *shared.events.lock().unwrap(),
+            vec![Event::AutomaticUpdateDeferred { app: "photocraft".into() }, Event::AutomaticUpdateDeferred { app: LAUNCHER_ID.into() },]
+        );
+    }
+
+    #[test]
+    fn terminal_events_are_queued_in_order_as_one_batch() {
+        let shared = shared();
+        shared.emit_all([Event::JobFinished { app: APP.into(), result: Err(PAUSED.into()) }, Event::AutomaticUpdateDeferred { app: APP.into() }]);
+        assert_eq!(
+            std::mem::take(&mut *shared.events.lock().unwrap()),
+            vec![Event::JobFinished { app: APP.into(), result: Err(PAUSED.into()) }, Event::AutomaticUpdateDeferred { app: APP.into() }]
+        );
+    }
+
+    #[test]
+    fn a_finished_guard_cannot_remove_a_replacement_job() {
+        let shared = Arc::new(shared());
+        shared.jobs.lock().unwrap().insert(APP.into(), RunningJob { stop: Arc::new(Stop::default()), automatic: false, deferred: false });
+        let mut guard = JobGuard { shared: shared.clone(), app_id: APP.into(), finished: false };
+        assert!(!guard.finish());
+        shared.jobs.lock().unwrap().insert(APP.into(), RunningJob { stop: Arc::new(Stop::default()), automatic: true, deferred: false });
+        drop(guard);
+        assert!(shared.jobs.lock().unwrap().contains_key(APP));
+    }
+
+    #[test]
+    fn low_data_mode_defers_automatic_work_but_manual_updates_still_start() {
+        let Some(platform) = native_platform() else { return };
+        let dir = tempfile::tempdir().unwrap();
+        let mut backend = backend(dir.path(), platform, None);
+        backend.shared.network_changed(false);
+        backend.send(Command::AutoUpdate { app: APP.into() });
+        assert!(backend.shared.jobs.lock().unwrap().is_empty());
+        assert_eq!(backend.poll(), vec![Event::AutomaticUpdateDeferred { app: APP.into() }]);
+
+        // This test backend has no HTTP client, so a manual job ends with an actionable error.
+        backend.send(Command::Install { app: APP.into() });
+        wait_until(|| backend.shared.events.lock().unwrap().iter().any(|event| matches!(event, Event::JobFinished { app, .. } if app == APP)));
+        let events = backend.poll();
+        assert!(events.iter().any(|event| matches!(event, Event::JobFinished { app, .. } if app == APP)));
+        assert!(!events.iter().any(|event| matches!(event, Event::AutomaticUpdateDeferred { .. })));
+    }
+
+    #[test]
+    fn an_automatic_update_waits_for_an_active_job() {
+        let Some(platform) = native_platform() else { return };
+        let dir = tempfile::tempdir().unwrap();
+        let mut backend = backend(dir.path(), platform, None);
+        backend.shared.jobs.lock().unwrap().insert(APP.into(), RunningJob { stop: Arc::new(Stop::default()), automatic: false, deferred: false });
+        backend.send(Command::AutoUpdate { app: APP.into() });
+        assert_eq!(backend.poll(), vec![Event::AutomaticUpdateWaiting { app: APP.into() }]);
+    }
+
+    /// Start on an unrestricted connection, then turn Low Data Mode on and off in System Settings.
+    #[cfg(target_os = "macos")]
+    #[test]
+    #[ignore = "requires toggling macOS Low Data Mode on the active network"]
+    fn live_macos_low_data_mode_cycle() {
+        let shared = Arc::new(shared());
+        shared.automatic_updates_allowed.store(false, Ordering::SeqCst);
+        let (sent, received) = mpsc::channel();
+        let watched = shared.clone();
+        let _monitor = crate::network::Monitor::new(move |allowed| {
+            watched.network_changed(allowed);
+            let _ = sent.send(allowed);
+        });
+        let wait_for = |expected| {
+            let deadline = Instant::now() + Duration::from_secs(180);
+            loop {
+                let remaining = deadline.saturating_duration_since(Instant::now());
+                let allowed = received.recv_timeout(remaining).expect("toggle Low Data Mode before the timeout");
+                eprintln!("Network.framework: automatic updates allowed = {allowed}");
+                if allowed == expected {
+                    break;
+                }
+            }
+        };
+        wait_for(true);
+        let app_stop = Arc::new(Stop::default());
+        let launcher_stop = Arc::new(Stop::default());
+        let manual_stop = Arc::new(Stop::default());
+        for (id, stop, automatic) in
+            [("photocraft", app_stop.clone(), true), (LAUNCHER_ID, launcher_stop.clone(), true), ("wordcraft", manual_stop.clone(), false)]
+        {
+            shared.jobs.lock().unwrap().insert(id.into(), RunningJob { stop, automatic, deferred: false });
+        }
+        eprintln!("READY: turn Low Data Mode ON");
+        wait_for(false);
+        assert!(!shared.automatic_updates_allowed.load(Ordering::SeqCst));
+        assert_eq!(app_stop.check(), Err(PAUSED.into()));
+        assert_eq!(launcher_stop.check(), Err(PAUSED.into()));
+        assert!(manual_stop.check().is_ok());
+        for id in ["photocraft", LAUNCHER_ID] {
+            drop(JobGuard { shared: shared.clone(), app_id: id.into(), finished: false });
+        }
+        assert_eq!(shared.events.lock().unwrap().len(), 2, "both automatic updates must be eligible for retry");
+        eprintln!("PASS: automatic app and launcher jobs paused; manual job unaffected");
+        eprintln!("READY: turn Low Data Mode OFF");
+        wait_for(true);
+        assert!(shared.automatic_updates_allowed.load(Ordering::SeqCst));
+        assert!(manual_stop.check().is_ok());
+        eprintln!("PASS: automatic updates allowed again after Low Data Mode was turned off");
+    }
+
     /// A local server answering every request with `status` (and `Retry-After: 120` on a 429),
     /// counting the requests.
     fn serve(status: &'static str) -> (String, Arc<AtomicUsize>) {
@@ -1498,7 +1750,15 @@ mod tests {
         let shared = shared();
         shared.latest.lock().unwrap().insert(APP.to_string(), release("0.5.0"));
         let shared = Arc::new(shared);
-        NativeBackend { shared, paths: Paths::under(root), http: None, source, platform: Some(platform) }
+        NativeBackend {
+            shared,
+            paths: Paths::under(root),
+            http: None,
+            source,
+            platform: Some(platform),
+            #[cfg(target_os = "macos")]
+            _network: None,
+        }
     }
 
     /// Reports again, and returns what the UI was last told about a kept download.
@@ -1709,7 +1969,7 @@ mod tests {
     fn the_launcher_gives_up_waiting_and_names_the_busy_apps() {
         let jobs = Mutex::new(HashMap::new());
         for id in [LAUNCHER_ID, "photocraft"] {
-            jobs.lock().unwrap().insert(id.to_string(), Arc::new(Stop::default()));
+            jobs.lock().unwrap().insert(id.to_string(), RunningJob { stop: Arc::new(Stop::default()), automatic: false, deferred: false });
         }
         let restarting = AtomicBool::new(false);
         let error = claim_restart(&jobs, &restarting, &Stop::default(), Duration::from_millis(300), &mut || {}).unwrap_err();
@@ -1722,7 +1982,7 @@ mod tests {
     fn the_launcher_restarts_only_once_every_other_job_has_ended() {
         let jobs = Mutex::new(HashMap::new());
         for id in [LAUNCHER_ID, "photocraft", "filmcraft"] {
-            jobs.lock().unwrap().insert(id.to_string(), Arc::new(Stop::default()));
+            jobs.lock().unwrap().insert(id.to_string(), RunningJob { stop: Arc::new(Stop::default()), automatic: false, deferred: false });
         }
         let restarting = AtomicBool::new(false);
         let stop = Stop::default();
@@ -1749,8 +2009,10 @@ mod tests {
 
     #[test]
     fn a_cancelled_launcher_update_stops_waiting_and_claims_nothing() {
-        let jobs =
-            Mutex::new(HashMap::from([(LAUNCHER_ID.to_string(), Arc::new(Stop::default())), ("photocraft".to_string(), Arc::new(Stop::default()))]));
+        let jobs = Mutex::new(HashMap::from([
+            (LAUNCHER_ID.to_string(), RunningJob { stop: Arc::new(Stop::default()), automatic: false, deferred: false }),
+            ("photocraft".to_string(), RunningJob { stop: Arc::new(Stop::default()), automatic: false, deferred: false }),
+        ]));
         let restarting = AtomicBool::new(false);
         let stop = Stop::default();
         let (waiting, waits) = mpsc::channel();
diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs
--- a/crates/native/src/install.rs
+++ b/crates/native/src/install.rs
@@ -1222,7 +1222,7 @@ pub mod macos {
 
     use artcraft_launcher_core::version::Version;
 
-    use super::{CraftApp, InstallSource, Installed, Path, PathBuf, Paths, Release, process, staging_dir, swap_into_place};
+    use super::{CraftApp, InstallSource, Installed, Path, PathBuf, Paths, Release, process, swap_into_place};
 
     /// `/Applications` when we can write there, else `~/Applications`.
     fn applications_dir(existing: Option<&Installed>, destination: Option<&Path>) -> Result<PathBuf, String> {
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
@@ -15,6 +15,8 @@ pub mod http;
 pub mod install;
 pub mod login;
 pub mod manifest;
+#[cfg(target_os = "macos")]
+mod network;
 pub mod packages;
 pub mod paths;
 #[cfg(windows)]
diff --git a/crates/native/src/network.rs b/crates/native/src/network.rs
new file mode 100644
--- /dev/null
+++ b/crates/native/src/network.rs
@@ -0,0 +1,36 @@
+//! macOS Low Data Mode, through safe Network.framework bindings.
+
+use cidre::{arc, dispatch, nw};
+
+pub struct Monitor(arc::R<nw::PathMonitor>);
+
+impl Monitor {
+    pub fn new(mut changed: impl FnMut(bool) + Sync + 'static) -> Self {
+        let queue = dispatch::Queue::new();
+        let mut monitor = nw::PathMonitor::new();
+        monitor.set_queue(&queue);
+        monitor.set_update_handler(move |path| {
+            changed(path.status() == nw::PathStatus::Satisfied && !path.is_constrained());
+        });
+        monitor.start();
+        Self(monitor)
+    }
+}
+
+impl Drop for Monitor {
+    fn drop(&mut self) {
+        self.0.cancel();
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    #[test]
+    fn macos_reports_an_initial_path_without_a_network_request() {
+        let (sent, received) = std::sync::mpsc::channel();
+        let _monitor = super::Monitor::new(move |allowed| {
+            let _ = sent.send(allowed);
+        });
+        assert!(received.recv_timeout(std::time::Duration::from_secs(5)).is_ok(), "Network.framework should report the current path");
+    }
+}
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
@@ -100,6 +100,13 @@ pub(crate) enum UiAction {
     },
 }
 
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum UpdateAttempt {
+    Automatic,
+    Manual,
+    Deferred,
+}
+
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub(crate) enum Dialog {
     Notes(String),
@@ -177,9 +184,10 @@ pub struct LauncherUi {
     last_detect: i64,
     /// Whether the window had focus last frame, to re-check when the user comes back to it.
     was_focused: bool,
-    /// Versions of apps or the launcher already queued for auto-update during this session,
-    /// preventing repeated install loops if an update fails or is in progress.
-    pub(crate) auto_update_attempted: std::collections::BTreeSet<(String, String)>,
+    /// Per-version backoff and manual intent; late network events cannot override a user's action.
+    auto_update_attempted: std::collections::BTreeMap<(String, String), UpdateAttempt>,
+    /// Automatic updates waiting for an active job to finish before retrying.
+    auto_update_waiting: std::collections::BTreeSet<String>,
     /// The URL or deep link to open when Discord is clicked.
     pub(crate) discord_url: &'static str,
     /// UI actions drawn in this pass, not sent yet.
@@ -264,7 +272,8 @@ impl LauncherUi {
             sent_options: None,
             last_detect: 0,
             was_focused: true,
-            auto_update_attempted: std::collections::BTreeSet::new(),
+            auto_update_attempted: std::collections::BTreeMap::new(),
+            auto_update_waiting: std::collections::BTreeSet::new(),
             discord_url: artcraft_launcher_core::catalog::DISCORD_URL,
             pending: Vec::new(),
             frame: None,
@@ -313,6 +322,7 @@ impl LauncherUi {
     /// Looks for updates now (the tray's "Check for Updates").
     pub fn check_now(&mut self) {
         self.auto_update_attempted.clear();
+        self.auto_update_waiting.clear();
         self.backend.send(Command::CheckForUpdates { force: true });
         self.backend.send(Command::Detect);
         self.last_detect = self.backend.now();
@@ -366,7 +376,25 @@ impl LauncherUi {
                 continue;
             }
             match action {
-                UiAction::Command(command) => self.backend.send(command.clone()),
+                UiAction::Command(command) => {
+                    let manual = match command {
+                        Command::Install { app } | Command::Replace { app } | Command::Pause { app } | Command::Cancel { app } => Some(app.as_str()),
+                        Command::UpdateLauncher => Some(artcraft_launcher_core::catalog::LAUNCHER_ID),
+                        _ => None,
+                    };
+                    if let Some(id) = manual {
+                        self.auto_update_waiting.remove(id);
+                        let version = if id == artcraft_launcher_core::catalog::LAUNCHER_ID {
+                            self.launcher_update.as_ref().map(Release::version_label)
+                        } else {
+                            self.entry(id).and_then(AppEntry::latest_label)
+                        };
+                        if let Some(version) = version {
+                            self.auto_update_attempted.insert((id.to_string(), version), UpdateAttempt::Manual);
+                        }
+                    }
+                    self.backend.send(command.clone());
+                }
                 UiAction::Launch(id) => {
                     let opened = self.settings.launches.entry(id.clone()).or_insert(0);
                     *opened = opened.saturating_add(1);
@@ -374,6 +402,7 @@ impl LauncherUi {
                 }
                 UiAction::CheckNow => {
                     self.auto_update_attempted.clear();
+                    self.auto_update_waiting.clear();
                     self.backend.send(Command::CheckForUpdates { force: true });
                     self.backend.send(Command::Detect);
                     self.last_detect = self.backend.now();
@@ -481,9 +510,9 @@ impl LauncherUi {
         }
         if changed {
             self.announce_updates(host);
-            if self.settings.auto_update {
-                self.trigger_auto_updates();
-            }
+        }
+        if self.settings.auto_update {
+            self.trigger_auto_updates();
         }
         if self.restart_requested {
             // The helper is waiting for this process to exit before it starts the new launcher.
@@ -497,6 +526,23 @@ impl LauncherUi {
     /// Returns whether app state changed.
     fn handle(&mut self, event: Event) -> bool {
         match event {
+            Event::AutomaticUpdateDeferred { app } => {
+                for ((id, _), attempt) in &mut self.auto_update_attempted {
+                    if *id == app && *attempt == UpdateAttempt::Automatic {
+                        *attempt = UpdateAttempt::Deferred;
+                    }
+                }
+                false
+            }
+            Event::AutomaticUpdateWaiting { app } => {
+                self.auto_update_waiting.insert(app.clone());
+                for ((id, _), attempt) in &mut self.auto_update_attempted {
+                    if *id == app && *attempt == UpdateAttempt::Automatic {
+                        *attempt = UpdateAttempt::Deferred;
+                    }
+                }
+                false
+            }
             Event::Latest { app, latest, from } => {
                 self.on_latest(app, latest, from);
                 true
@@ -542,6 +588,7 @@ impl LauncherUi {
                 false
             }
             Event::JobFinished { app, result } => {
+                self.auto_update_waiting.remove(&app);
                 self.on_job_finished(app, result);
                 true
             }
@@ -674,22 +721,24 @@ impl LauncherUi {
 
     /// When auto-updates are enabled, automatically downloads and installs pending updates.
     pub(crate) fn trigger_auto_updates(&mut self) {
-        if !self.caps.manage_apps || !self.settings.auto_update {
+        if !self.caps.manage_apps || !self.settings.auto_update || !self.backend.automatic_updates_allowed() {
             return;
         }
+        let waiting = &self.auto_update_waiting;
+        self.auto_update_attempted.retain(|(id, _), attempt| *attempt != UpdateAttempt::Deferred || waiting.contains(id));
         let to_update: Vec<(String, String)> = self
             .entries
             .iter()
             .filter(|e| self.in_filter(e, Filter::Updates) && e.job.is_none() && !e.ready)
             .filter_map(|e| {
                 let v = e.latest_label()?;
-                if self.auto_update_attempted.contains(&(e.app.id.to_string(), v.clone())) { None } else { Some((e.app.id.to_string(), v)) }
+                if self.auto_update_attempted.contains_key(&(e.app.id.to_string(), v.clone())) { None } else { Some((e.app.id.to_string(), v)) }
             })
             .collect();
         for (id, version) in to_update {
             log::info!("auto-update: queuing update for {id} to {version}");
-            self.auto_update_attempted.insert((id.clone(), version));
-            self.send(Command::Install { app: id });
+            self.auto_update_attempted.insert((id.clone(), version), UpdateAttempt::Automatic);
+            self.send(Command::AutoUpdate { app: id });
         }
         if self.caps.self_update
             && let Some(r) = &self.launcher_update
@@ -697,10 +746,10 @@ impl LauncherUi {
         {
             let id = artcraft_launcher_core::catalog::LAUNCHER_ID.to_string();
             let v = r.version_label();
-            if !self.auto_update_attempted.contains(&(id.clone(), v.clone())) {
+            if !self.auto_update_attempted.contains_key(&(id.clone(), v.clone())) {
                 log::info!("auto-update: queuing launcher self-update to {v}");
-                self.auto_update_attempted.insert((id, v));
-                self.send(Command::UpdateLauncher);
+                self.auto_update_attempted.insert((id.clone(), v), UpdateAttempt::Automatic);
+                self.send(Command::AutoUpdate { app: id });
             }
         }
     }
@@ -1054,6 +1103,68 @@ mod tests {
         sent.borrow().iter().filter(|command| matches!(command, Command::Install { .. })).count()
     }
 
+    #[test]
+    fn a_manual_cancel_keeps_a_deferred_update_from_resuming_automatically() {
+        for deferred_before_cancel in [true, false] {
+            let (mut launcher, sent, _ctx) = launcher();
+            launcher.settings.auto_update = true;
+            launcher.caps.self_update = true;
+            launcher.caps.manage_apps = true;
+            let id = artcraft_launcher_core::catalog::LAUNCHER_ID;
+            let release = Release { tag: "v0.9.0".into(), version: artcraft_launcher_core::version::parse("0.9.0"), ..Release::default() };
+            launcher.auto_update_attempted.insert((id.into(), release.version_label()), UpdateAttempt::Automatic);
+            launcher.launcher_update = Some(release);
+            if deferred_before_cancel {
+                launcher.handle(Event::AutomaticUpdateDeferred { app: id.into() });
+            }
+            launcher.act_command(Command::Cancel { app: id.into() });
+            launcher.flush_actions();
+            if !deferred_before_cancel {
+                launcher.handle(Event::AutomaticUpdateDeferred { app: id.into() });
+            }
+            launcher.handle(Event::JobFinished { app: id.into(), result: Err(PAUSED.into()) });
+            launcher.trigger_auto_updates();
+            assert_eq!(*sent.borrow(), vec![Command::Cancel { app: id.into() }], "deferred before cancel: {deferred_before_cancel}");
+        }
+    }
+
+    #[test]
+    fn manual_updates_are_not_retried_before_any_automatic_attempt() {
+        for id in [artcraft_launcher_core::catalog::LAUNCHER_ID, "photocraft"] {
+            let (mut launcher, sent, _ctx) = launcher();
+            launcher.settings.auto_update = true;
+            launcher.caps.self_update = true;
+            launcher.caps.manage_apps = true;
+            launcher.platform = Some(Platform { os: artcraft_launcher_core::Os::MacOs, arch: artcraft_launcher_core::Arch::Arm64 });
+            let release = Release {
+                tag: "v0.9.0".into(),
+                version: artcraft_launcher_core::version::parse("0.9.0"),
+                assets: vec![artcraft_launcher_core::release::Asset { name: format!("{id}-0.9.0-macos-universal.dmg"), ..Default::default() }],
+                ..Release::default()
+            };
+            let manual = if id == artcraft_launcher_core::catalog::LAUNCHER_ID {
+                launcher.launcher_update = Some(release);
+                Command::UpdateLauncher
+            } else {
+                let entry = launcher.entry_mut(id).unwrap();
+                entry.detected = true;
+                entry.latest = Some(LatestRelease::Found(release));
+                entry.installed = Some(artcraft_launcher_core::Installed {
+                    version: artcraft_launcher_core::version::parse("0.8.0"),
+                    source: artcraft_launcher_core::InstallSource::MacApp,
+                    location: "/Applications/PhotoCraft.app".into(),
+                    package: None,
+                });
+                Command::Install { app: id.into() }
+            };
+            launcher.act_command(manual.clone());
+            launcher.act_command(Command::Pause { app: id.into() });
+            launcher.flush_actions();
+            launcher.trigger_auto_updates();
+            assert_eq!(*sent.borrow(), vec![manual, Command::Pause { app: id.into() }]);
+        }
+    }
+
     /// A CJK preference is drawable only while that language's own face is installed: having the
     /// system font cached, or another script's face installed, is not enough.
     #[test]
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
@@ -74,6 +74,7 @@
 	ArtCraft Launcher {version}	ArtCraft Launcher {version}
 	Only when I ask	Pouze na vyžádání
 	Automatically download and install updates	Automaticky stahovat a instalovat aktualizace
+	Automatic updates wait or pause while Low Data Mode is on and resume when it’s off. Clicking Install, Update, or Resume still downloads over the current network.	Automatické aktualizace se při zapnutém režimu malého objemu dat odloží nebo pozastaví a po jeho vypnutí se obnoví. Kliknutím na „Nainstalovat“, „Aktualizovat“ nebo „Pokračovat“ můžete i nadále stahovat přes aktuální síť.
 	Notify me when an installed app has an update	Upozornit, když má nainstalovaná aplikace aktualizaci
 	Include pre-releases (betas and release candidates)	Zahrnout předběžná vydání (beta verze a kandidáty na vydání)
 	Installing on Linux	Instalace v Linuxu
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
@@ -87,6 +87,7 @@
 	ArtCraft Launcher {version}	ArtCraft Launcher {version}
 	Only when I ask	Nur manuell
 	Automatically download and install updates	Updates automatisch herunterladen und installieren
+	Automatic updates wait or pause while Low Data Mode is on and resume when it’s off. Clicking Install, Update, or Resume still downloads over the current network.	Automatische Updates warten oder pausieren bei aktiviertem Datensparmodus und werden fortgesetzt, sobald er ausgeschaltet ist. Mit „Installieren“, „Aktualisieren“ oder „Fortsetzen“ werden Downloads weiterhin über das aktuelle Netzwerk ausgeführt.
 	Notify me when an installed app has an update	Benachrichtigen, wenn es für eine installierte App ein Update gibt
 	Include pre-releases (betas and release candidates)	Vorabversionen einbeziehen (Betas und Release Candidates)
 	Installing on Linux	Installation unter Linux
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
@@ -87,6 +87,7 @@
 	ArtCraft Launcher {version}	ArtCraft Launcher {version}
 	Only when I ask	Solo cuando lo pida
 	Automatically download and install updates	Descargar e instalar actualizaciones automáticamente
+	Automatic updates wait or pause while Low Data Mode is on and resume when it’s off. Clicking Install, Update, or Resume still downloads over the current network.	Las actualizaciones automáticas esperan o se pausan mientras el modo de datos reducidos está activado y se reanudan al desactivarlo. Al hacer clic en Instalar, Actualizar o Reanudar, las descargas siguen usando la red actual.
 	Notify me when an installed app has an update	Avisarme cuando una aplicación instalada tenga una actualización
 	Include pre-releases (betas and release candidates)	Incluir versiones preliminares (betas y versiones candidatas)
 	Installing on Linux	Instalación en Linux
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
@@ -85,6 +85,7 @@
 	ArtCraft Launcher {version}	ArtCraft Launcher {version}
 	Only when I ask	Seulement à ma demande
 	Automatically download and install updates	Télécharger et installer les mises à jour automatiquement
+	Automatic updates wait or pause while Low Data Mode is on and resume when it’s off. Clicking Install, Update, or Resume still downloads over the current network.	Les mises à jour automatiques sont différées ou mises en pause lorsque le mode Faibles données est activé, puis reprennent lorsqu’il est désactivé. Cliquer sur Installer, Mettre à jour ou Reprendre permet toujours de télécharger via le réseau actuel.
 	Notify me when an installed app has an update	Me prévenir quand une application installée a une mise à jour
 	Include pre-releases (betas and release candidates)	Inclure les préversions (bêtas et versions candidates)
 	Installing on Linux	Installation sous Linux
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
@@ -87,6 +87,7 @@
 	ArtCraft Launcher {version}	ArtCraft Launcher {version}
 	Only when I ask	Csak kézi kereséskor
 	Automatically download and install updates	Frissítések automatikus letöltése és telepítése
+	Automatic updates wait or pause while Low Data Mode is on and resume when it’s off. Clicking Install, Update, or Resume still downloads over the current network.	Az automatikus frissítések várakoznak vagy szünetelnek, amíg az Alacsony adatforgalmú mód be van kapcsolva, és a kikapcsolása után folytatódnak. A Telepítés, Frissítés vagy Folytatás gombra kattintva továbbra is letölthet az aktuális hálózaton.
 	Notify me when an installed app has an update	Értesítés a telepített alkalmazásokhoz elérhető frissítésekről
 	Include pre-releases (betas and release candidates)	Előzetes kiadások engedélyezése (béta- és kiadásra jelölt verziók)
 	Installing on Linux	Telepítés Linuxon
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
@@ -89,6 +89,7 @@
 	ArtCraft Launcher {version}	ArtCraft Launcher {version}
 	Only when I ask	手動で確認したときのみ
 	Automatically download and install updates	更新を自動でダウンロードしてインストール
+	Automatic updates wait or pause while Low Data Mode is on and resume when it’s off. Clicking Install, Update, or Resume still downloads over the current network.	省データモードがオンの間、自動更新は待機または一時停止し、オフになると再開します。「インストール」「更新」「再開」をクリックすると、現在のネットワーク経由で引き続きダウンロードできます。
 	Notify me when an installed app has an update	インストール済みアプリに更新があるときに通知する
 	Include pre-releases (betas and release candidates)	プレリリース（ベータ版とリリース候補）を含める
 	Installing on Linux	Linux へのインストール
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
@@ -88,6 +88,7 @@
 	ArtCraft Launcher {version}	ArtCraft Launcher {version}
 	Only when I ask	Tylko kiedy zapytam
 	Automatically download and install updates	Automatycznie pobieraj i instaluj aktualizacje
+	Automatic updates wait or pause while Low Data Mode is on and resume when it’s off. Clicking Install, Update, or Resume still downloads over the current network.	Automatyczne aktualizacje są odkładane lub wstrzymywane, gdy tryb niskiego transferu danych jest włączony, i wznawiane po jego wyłączeniu. Kliknięcie „Instaluj”, „Aktualizacja” lub „Wznów” nadal pozwala pobierać przez bieżącą sieć.
 	Notify me when an installed app has an update	Powiadom mnie o aktualizacjach dla zainstalowanych aplikacji
 	Include pre-releases (betas and release candidates)	Uwzględnij wersje przedpremierowe (beta i wersje kandydujące)
 	Installing on Linux	Instalowanie w systemie Linux
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
@@ -75,6 +75,7 @@
 	ArtCraft Launcher {version}	ArtCraft Launcher {version}
 	Only when I ask	Apenas quando eu pedir
 	Automatically download and install updates	Baixar e instalar atualizações automaticamente
+	Automatic updates wait or pause while Low Data Mode is on and resume when it’s off. Clicking Install, Update, or Resume still downloads over the current network.	As atualizações automáticas aguardam ou são pausadas enquanto o Modo de Economia de Dados está ativado e são retomadas quando ele é desativado. Clicar em Instalar, Atualizar ou Retomar ainda permite baixar pela rede atual.
 	Notify me when an installed app has an update	Notificar quando um aplicativo instalado tiver atualização
 	Include pre-releases (betas and release candidates)	Incluir versões de pré-lançamento (betas e RC)
 	Installing on Linux	Instalação no Linux
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
@@ -85,6 +85,7 @@
 	ArtCraft Launcher {version}	ArtCraft Launcher {version}
 	Only when I ask	Только по запросу
 	Automatically download and install updates	Автоматически скачивать и устанавливать обновления
+	Automatic updates wait or pause while Low Data Mode is on and resume when it’s off. Clicking Install, Update, or Resume still downloads over the current network.	Автоматические обновления откладываются или приостанавливаются при включённом режиме экономии данных и возобновляются после его отключения. Нажатие «Установить», «Обновить» или «Продолжить» по-прежнему позволяет скачивать через текущую сеть.
 	Notify me when an installed app has an update	Уведомлять, когда у установленного приложения есть обновление
 	Include pre-releases (betas and release candidates)	Включать предварительные релизы (бета и RC)
 	Installing on Linux	Установка в Linux
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
@@ -69,6 +69,7 @@
 	ArtCraft Launcher {version}	ArtCraft Launcher {version}
 	Only when I ask	仅手动检查
 	Automatically download and install updates	自动下载并安装更新
+	Automatic updates wait or pause while Low Data Mode is on and resume when it’s off. Clicking Install, Update, or Resume still downloads over the current network.	开启低数据模式时，自动更新会等待或暂停，关闭后会自动恢复。点击“安装”“更新”或“继续”仍会通过当前网络下载。
 	Notify me when an installed app has an update	已安装应用有更新时通知我
 	Include pre-releases (betas and release candidates)	包含预发布版本（测试版和候选版）
 	Installing on Linux	Linux 安装设置
diff --git a/crates/ui-egui/src/settings_dialog.rs b/crates/ui-egui/src/settings_dialog.rs
--- a/crates/ui-egui/src/settings_dialog.rs
+++ b/crates/ui-egui/src/settings_dialog.rs
@@ -184,11 +184,15 @@ impl LauncherUi {
                     .labelled_by(label);
             });
             self.settings.check_every = every;
-            if self.caps.manage_apps
-                && ui.checkbox(&mut self.settings.auto_update, tl!("Automatically download and install updates")).changed()
-                && self.settings.auto_update
-            {
-                self.trigger_auto_updates();
+            if self.caps.manage_apps {
+                let auto_update = ui.checkbox(&mut self.settings.auto_update, tl!("Automatically download and install updates"));
+                #[cfg(target_os = "macos")]
+                let auto_update = auto_update.on_hover_text(tl!(
+                    "Automatic updates wait or pause while Low Data Mode is on and resume when it’s off. Clicking Install, Update, or Resume still downloads over the current network."
+                ));
+                if auto_update.changed() && self.settings.auto_update {
+                    self.trigger_auto_updates();
+                }
             }
             ui.checkbox(&mut self.settings.notify, tl!("Notify me when an installed app has an update"));
             ui.checkbox(&mut self.settings.prereleases, tl!("Include pre-releases (betas and release candidates)"));
diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs
--- a/crates/ui-egui/tests/ui.rs
+++ b/crates/ui-egui/tests/ui.rs
@@ -1554,8 +1554,7 @@ fn automatic_checks_run_every_five_minutes() {
 }
 
 #[test]
-/// With auto-update on, outdated apps are queued for Install and the launcher for
-/// `UpdateLauncher`.
+/// Automatic app and launcher updates are distinguished from manual installs.
 fn automatic_updates_queue_installs_for_outdated_apps_and_launcher() {
     let mut ev = events();
     ev.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW)));
@@ -1579,9 +1578,9 @@ fn automatic_updates_queue_installs_for_outdated_apps_and_launcher() {
     );
     h.run_steps(4);
     let commands = sent.borrow().clone();
-    assert!(commands.contains(&Command::Install { app: "photocraft".into() }), "outdated photocraft should be queued for install: {commands:?}");
-    assert!(commands.contains(&Command::Install { app: "effectcraft".into() }), "outdated effectcraft should be queued for install: {commands:?}");
-    assert!(commands.contains(&Command::UpdateLauncher), "launcher update should be queued: {commands:?}");
+    assert!(commands.contains(&Command::AutoUpdate { app: "photocraft".into() }), "outdated photocraft should be queued for install: {commands:?}");
+    assert!(commands.contains(&Command::AutoUpdate { app: "effectcraft".into() }), "outdated effectcraft should be queued for install: {commands:?}");
+    assert!(commands.contains(&Command::AutoUpdate { app: "artcraft-launcher".into() }), "launcher update should be queued: {commands:?}");
 }
 
 #[test]
@@ -1609,10 +1608,89 @@ fn automatic_updates_are_disabled_by_default() {
     );
     h.run_steps(4);
     let commands = sent.borrow().clone();
+    assert!(!commands.iter().any(|command| matches!(command, Command::AutoUpdate { .. })));
     assert!(!commands.contains(&Command::Install { app: "photocraft".into() }), "photocraft should not be auto-installed when auto_update is false");
     assert!(!commands.contains(&Command::UpdateLauncher), "launcher should not be auto-updated when auto_update is false");
 }
 
+#[test]
+fn low_data_mode_defers_and_resumes_automatic_updates_without_another_release_event() {
+    struct NetworkFake {
+        inner: Fake,
+        allowed: Rc<Cell<bool>>,
+        incoming: Rc<RefCell<Vec<Event>>>,
+    }
+    impl Backend for NetworkFake {
+        fn automatic_updates_allowed(&self) -> bool {
+            self.allowed.get()
+        }
+        fn send(&mut self, command: Command) {
+            self.inner.send(command);
+        }
+        fn poll(&mut self) -> Vec<Event> {
+            let mut events = self.inner.poll();
+            events.extend(std::mem::take(&mut *self.incoming.borrow_mut()));
+            events
+        }
+        fn capabilities(&self) -> Capabilities {
+            self.inner.capabilities()
+        }
+        fn platform(&self) -> Option<Platform> {
+            self.inner.platform()
+        }
+        fn now(&self) -> i64 {
+            self.inner.now()
+        }
+    }
+    let allowed = Rc::new(Cell::new(false));
+    let incoming = Rc::new(RefCell::new(Vec::new()));
+    let sent = Rc::new(RefCell::new(Vec::new()));
+    let mut ev = events();
+    ev.push(Event::LauncherUpdate(release("artcraft-launcher", "0.9.0", NOW)));
+    let backend = NetworkFake {
+        inner: Fake { events: ev, sent: sent.clone(), manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) },
+        allowed: allowed.clone(),
+        incoming: incoming.clone(),
+    };
+    let ctx = egui::Context::default();
+    let mut launcher = LauncherUi::new(&ctx, Box::new(backend), Settings { auto_update: true, notify: false, ..Settings::default() });
+    launcher.logic(&ctx, &mut NoHost);
+    assert!(!sent.borrow().iter().any(|command| matches!(command, Command::AutoUpdate { .. })));
+    assert!(sent.borrow().iter().any(|command| matches!(command, Command::CheckForUpdates { .. })), "release checks remain available");
+
+    allowed.set(true);
+    launcher.logic(&ctx, &mut NoHost);
+    let automatic: Vec<_> = sent.borrow().iter().filter(|command| matches!(command, Command::AutoUpdate { .. })).cloned().collect();
+    for id in ["photocraft", "effectcraft", "artcraft-launcher"] {
+        assert!(automatic.contains(&Command::AutoUpdate { app: id.into() }));
+    }
+    assert_eq!(automatic.len(), 3);
+    sent.borrow_mut().clear();
+    launcher.logic(&ctx, &mut NoHost);
+    assert!(sent.borrow().is_empty(), "an unchanged path must not repeat automatic updates");
+
+    allowed.set(false);
+    for id in ["photocraft", "artcraft-launcher"] {
+        incoming.borrow_mut().extend([
+            Event::AutomaticUpdateDeferred { app: id.into() },
+            Event::JobFinished { app: id.into(), result: Err(artcraft_launcher_core::protocol::PAUSED.into()) },
+        ]);
+    }
+    launcher.logic(&ctx, &mut NoHost);
+    assert!(sent.borrow().is_empty());
+    allowed.set(true);
+    launcher.logic(&ctx, &mut NoHost);
+    assert_eq!(*sent.borrow(), vec![Command::AutoUpdate { app: "photocraft".into() }, Command::AutoUpdate { app: "artcraft-launcher".into() },]);
+
+    sent.borrow_mut().clear();
+    incoming.borrow_mut().push(Event::AutomaticUpdateWaiting { app: "photocraft".into() });
+    launcher.logic(&ctx, &mut NoHost);
+    assert!(sent.borrow().is_empty(), "an active job must hold the automatic retry");
+    incoming.borrow_mut().push(Event::JobFinished { app: "photocraft".into(), result: Err(artcraft_launcher_core::protocol::PAUSED.into()) });
+    launcher.logic(&ctx, &mut NoHost);
+    assert_eq!(*sent.borrow(), vec![Command::AutoUpdate { app: "photocraft".into() }]);
+}
+
 #[test]
 /// The footer names the release origins actually in use, adding GitHub only when some release
 /// came from there.
