From ffe7f8fce82dc6b2325a83d2e8a64f0452ba2573 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 08:13:09 -0600 Subject: [PATCH] Share Linux and BSD desktop appearance observation (#225) * platform: share desktop appearance observation * Align appearance target guards and document watcher contracts * Replace appearance wildcard imports with explicit dependencies --------- Co-authored-by: skylartaylor --- Cargo.lock | 382 +++++++++++ NOTICE | 18 +- README.md | 2 +- crates/platform/Cargo.toml | 8 + crates/platform/NOTICE | 18 +- crates/platform/README.md | 30 +- crates/platform/src/appearance.rs | 635 ++++++++++++++++++ .../appearance/portal/appearance_bus_tests.rs | 147 ++++ crates/platform/src/lib.rs | 4 + crates/platform/src/process.rs | 53 ++ 10 files changed, 1287 insertions(+), 10 deletions(-) create mode 100644 crates/platform/src/appearance.rs create mode 100644 crates/platform/src/appearance/portal/appearance_bus_tests.rs create mode 100644 crates/platform/src/process.rs diff --git a/Cargo.lock b/Cargo.lock index fc190e00..50ffab2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,6 +135,126 @@ dependencies = [ "libloading", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.5", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.92" @@ -273,6 +393,19 @@ dependencies = [ "objc2 0.6.5", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borsh" version = "1.8.1" @@ -690,7 +823,12 @@ dependencies = [ name = "craft-platform" version = "0.1.0" dependencies = [ + "async-channel", + "async-io", + "async-process", "egui", + "futures-lite", + "zbus", ] [[package]] @@ -1120,6 +1258,12 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum-map" version = "2.7.3" @@ -1140,6 +1284,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "epaint" version = "0.36.2" @@ -1202,6 +1367,26 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1387,6 +1572,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-sink" version = "0.3.34" @@ -2306,6 +2504,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -2855,6 +3062,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -3012,6 +3235,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -3817,6 +4051,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "serde_spanned" version = "1.1.2" @@ -3866,6 +4111,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -4724,6 +4979,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unarray" version = "0.1.4" @@ -4868,6 +5134,11 @@ name = "uuid" version = "1.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] [[package]] name = "vello_common" @@ -5932,6 +6203,76 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.5", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.62" @@ -6053,3 +6394,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow", +] diff --git a/NOTICE b/NOTICE index 49a34553..8703347b 100644 --- a/NOTICE +++ b/NOTICE @@ -107,9 +107,19 @@ Their licences and per-file hashes are retained in vendor/hayro-interpret/assets licenses/pdf-backend and ATTRIBUTION.md. These are existing reader dependency assets; applications retain their own document-font selection and licensing policy. -Platform dialog lifecycle provenance +Platform lifecycle source provenance -The dialog outcomes and lifecycle adapt preserved local platform candidate -9b135779a1e7136438a217c18afe1693ea23f1ab. -Copyright (c) 2026 ArtCraft Team and the craft-libs contributors. +The optional crates/platform services adapt the preserved local platform candidate +9b135779a1e7136438a217c18afe1693ea23f1ab (dialog outcomes and lifecycle), +and current application implementations: +- PdfCraft crates/platform/src/desktop_theme.rs, a8cd797a917b1007544575c7280c9a0ce6a17e8f. +- CADCraft apps/cadcraft/src/desktop_theme.rs, 0cc66ad3066e71f2c3d4cacb7de905e79ba1c95b. +- ArtCraft Launcher apps/artcraft-launcher/src/system_theme.rs, f1676dfb41c226b5be490721f486ee6427419d80. +- Earlier PhotoCraft, FilmCraft and EffectCraft appearance services at revisions + 0c72d95425dece90ef9a1cceb49e3315c96e22d5, 794a25fc9ed2e3f6fab51a254625bc9b99a88015 + and 1b4d47f34f2b1c1b4a89f4b7348889def8fd648b respectively. +Sources: https://github.com/storytold/pdfcraft, https://github.com/storytold/cadcraft, +https://github.com/storytold/craft-launcher, https://github.com/storytold/photocraft, +https://github.com/storytold/filmcraft, https://github.com/storytold/effectcraft. +Copyright (c) 2026 ArtCraft Team and the respective application and craft-libs contributors. All adapted source is licensed MIT OR Apache-2.0. diff --git a/README.md b/README.md index 4629d9a5..7de7d010 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = ", +} +impl Shared { + fn get(&self) -> Option { + match self.value.load(Ordering::Acquire) { + 1 => Some(Theme::Dark), + 2 => Some(Theme::Light), + _ => None, + } + } + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + fn publish(&self, theme: Option) { + let code = match theme { + Some(Theme::Dark) => 1, + Some(Theme::Light) => 2, + None => 0, + }; + if self.value.swap(code, Ordering::AcqRel) != code && self.active.load(Ordering::Acquire) { + (self.wake)(); + } + } +} +/// An owned subscription. Dropping cancels connection setup, reads, backoff and helper capture, +/// and waits at most 100 ms for acknowledgement. Only a finished worker is joined: an OS call +/// or caller wake callback can block synchronously, so an exceptional unfinished worker is +/// detached and retains its resources until it returns (possibly until process exit). +/// Cache reads never block the UI; callers should keep wake callbacks nonblocking. +pub struct Watcher { + shared: Arc, + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + stop: async_channel::Sender<()>, + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + worker: Option>, + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + finished: std::sync::mpsc::Receiver<()>, +} +impl Watcher { + /// Start an active subscription, or return `None` on unsupported targets or worker failure. + /// `wake` runs on the worker and must not block. + pub fn start(wake: impl Fn() + Send + Sync + 'static) -> Option { + Self::start_active(true, wake) + } + /// Start observing with explicit control over wake notifications. + /// An inactive subscription still updates its cache. Unsupported targets and worker + /// startup failure return `None`; browser targets never start a desktop watcher. + pub fn start_active(active: bool, wake: impl Fn() + Send + Sync + 'static) -> Option { + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + { + Self::spawn(active, wake, portal::watch) + } + #[cfg(not(all(unix, not(target_os = "macos"), not(target_arch = "wasm32"))))] + { + let _ = (active, wake); + None + } + } + /// Read the cached preference without blocking; `None` means no known preference. + pub fn current(&self) -> Option { + self.shared.get() + } + /// Enable or suppress future change notifications without stopping observation. + /// Changing this flag does not itself wake the host or clear the cached preference. + pub fn set_active(&self, active: bool) { + self.shared.active.store(active, Ordering::Release); + } + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + fn spawn + 'static>( + active: bool, + wake: impl Fn() + Send + Sync + 'static, + source: impl FnOnce(Arc) -> F + Send + 'static, + ) -> Option { + let shared = Arc::new(Shared { value: AtomicU8::new(0), active: AtomicBool::new(active), wake: Box::new(wake) }); + let worker_shared = Arc::clone(&shared); + let (stop, stopped) = async_channel::bounded::<()>(1); + let (done, finished) = std::sync::mpsc::channel(); + let worker = std::thread::Builder::new() + .name("desktop-appearance".into()) + .spawn(move || { + futures_lite::future::block_on(futures_lite::future::race( + async { + let _ = stopped.recv().await; + }, + source(worker_shared), + )); + let _ = done.send(()); + }) + .ok()?; + Some(Self { shared, stop, worker: Some(worker), finished }) + } +} +impl Drop for Watcher { + fn drop(&mut self) { + self.set_active(false); + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + { + self.stop.close(); + if let Some(worker) = self.worker.take() { + let _ = self.finished.recv_timeout(std::time::Duration::from_millis(100)); + // Acknowledgement precedes thread-local destructors. They can block too, so + // never join solely on the acknowledgement (or channel disconnection). + if worker.is_finished() { + let _ = worker.join(); + } + } + } + } +} + +#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +mod portal { + use super::{Shared, Theme}; + use futures_lite::StreamExt; + use std::{sync::Arc, time::Duration}; + use zbus::zvariant::{OwnedValue, Value}; + const DEST: &str = "org.freedesktop.portal.Desktop"; + const PATH: &str = "/org/freedesktop/portal/desktop"; + const IFACE: &str = "org.freedesktop.portal.Settings"; + const STANDARD: &str = "org.freedesktop.appearance"; + const GNOME: &str = "org.gnome.desktop.interface"; + const KEY: &str = "color-scheme"; + const TIMEOUT: Duration = Duration::from_secs(2); + + // Outer None means unknown/malformed; Some(None) is an explicit no-preference answer. + fn decode(namespace: &str, value: &Value<'_>) -> Option> { + match (namespace, value) { + (STANDARD, Value::U32(n)) => Some(match n { + 1 => Some(Theme::Dark), + 2 => Some(Theme::Light), + _ => None, + }), + (GNOME, Value::Str(s)) => Some(match s.as_str() { + "prefer-dark" => Some(Theme::Dark), + "prefer-light" => Some(Theme::Light), + _ => None, + }), + _ => None, + } + } + fn reply(namespace: &str, value: &Value<'_>, legacy: bool) -> Option> { + if legacy { + let Value::Value(inner) = value else { return None }; + decode(namespace, inner) + } else { + decode(namespace, value) + } + } + fn combined(standard: Option>, gnome: Option>) -> Option> { + if standard.is_none() && gnome.is_none() { None } else { Some(standard.flatten().or(gnome.flatten())) } + } + async fn bounded(future: impl std::future::Future) -> Option { + futures_lite::future::race(async { Some(future.await) }, async { + async_io::Timer::after(TIMEOUT).await; + None + }) + .await + } + async fn read(connection: &zbus::Connection, namespace: &str) -> Option> { + for (method, legacy) in [("ReadOne", false), ("Read", true)] { + if let Some(Ok(message)) = bounded(connection.call_method(Some(DEST), PATH, Some(IFACE), method, &(namespace, KEY))).await + && let Ok(value) = message.body().deserialize::() + && let Some(answer) = reply(namespace, &value, legacy) + { + return Some(answer); + } + } + None + } + fn rule() -> zbus::Result> { + Ok(zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .sender(DEST)? + .path(PATH)? + .interface(IFACE)? + .member("SettingChanged")? + .arg(1, KEY)? + .build()) + } + fn owner_rule() -> zbus::Result> { + Ok(zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .sender("org.freedesktop.DBus")? + .interface("org.freedesktop.DBus")? + .member("NameOwnerChanged")? + .arg(0, DEST)? + .build()) + } + pub(super) async fn watch(shared: Arc) { + let mut retry = Duration::from_secs(5); + let mut fallback_used = false; + loop { + let mut connected = false; + let _ = session(&shared, &mut fallback_used, &mut connected).await; + if connected { + retry = Duration::from_secs(5); + } + // Keep the last valid answer during disconnect; a new explicit no-preference clears it. + async_io::Timer::after(retry).await; + retry = (retry * 2).min(Duration::from_secs(300)); + } + } + // Keep Nix/profile PATH entries, but never execute a helper from a relative search folder. + fn gsettings_exe(path: Option<&std::ffi::OsStr>) -> Option { + let dirs = path.map(std::env::split_paths).into_iter().flatten().filter(|p| p.is_absolute()); + dirs.chain(["/usr/bin", "/bin", "/usr/local/bin"].map(std::path::PathBuf::from)).map(|p| p.join("gsettings")).find(|p| p.is_file()) + } + async fn fallback(shared: &Shared, used: &mut bool) { + if *used { + return; + } + *used = true; + if let Some(exe) = gsettings_exe(std::env::var_os("PATH").as_deref()) + && let Some(text) = crate::process::capture(&exe, &["get", GNOME, KEY], TIMEOUT).await + { + let text = text.trim().trim_matches('\''); + if let Some(answer) = decode(GNOME, &Value::from(text)) { + shared.publish(answer); + } + } + } + async fn session(shared: &Shared, fallback_used: &mut bool, connected: &mut bool) -> Option<()> { + let connection = match zbus::connection::Builder::session() { + Ok(builder) => bounded(builder.method_timeout(TIMEOUT).build()).await.and_then(Result::ok), + Err(_) => None, + }; + let Some(connection) = connection else { + fallback(shared, fallback_used).await; + return None; + }; + watch_connection(shared, fallback_used, connected, connection).await + } + async fn watch_connection(shared: &Shared, fallback_used: &mut bool, connected: &mut bool, connection: zbus::Connection) -> Option<()> { + // MatchRule installs before activation: unlike a proxy subscription it needs no current owner. + let mut changes = bounded(zbus::MessageStream::for_match_rule(rule().ok()?, &connection, Some(64))).await?.ok()?; + let mut owners = bounded(zbus::MessageStream::for_match_rule(owner_rule().ok()?, &connection, Some(8))).await?.ok()?; + let mut standard = read(&connection, STANDARD).await; + let mut gnome = read(&connection, GNOME).await; + match combined(standard, gnome) { + Some(answer) => { + *connected = true; + shared.publish(answer); + } + None => { + fallback(shared, fallback_used).await; + return None; + } + } + loop { + let message = + futures_lite::future::race(async { changes.next().await.map(|m| (false, m)) }, async { owners.next().await.map(|m| (true, m)) }) + .await?; + let (owner, message) = message; + let message = message.ok()?; + if owner { + // Activation is already covered by the initial read. Loss/replacement reconnects. + if let Ok((_, old, _)) = message.body().deserialize::<(String, String, String)>() + && !old.is_empty() + { + return None; + } + continue; + } + let Ok((namespace, key, value)) = message.body().deserialize::<(String, String, OwnedValue)>() else { continue }; + if key != KEY { + continue; + } + let Some(answer) = decode(&namespace, &value) else { continue }; + match namespace.as_str() { + STANDARD => standard = Some(answer), + GNOME => gnome = Some(answer), + _ => continue, + } + if let Some(answer) = combined(standard, gnome) { + shared.publish(answer); + } + } + } + #[cfg(test)] + #[path = "appearance_bus_tests.rs"] + mod bus_tests; + #[cfg(test)] + mod tests { + #[test] + fn gsettings_respects_absolute_nix_style_path_without_using_relative_folders() -> Result<(), Box> { + struct Fixture(std::path::PathBuf); + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_nanos(); + let directory = std::env::temp_dir().join(format!("craft-platform-gsettings-{}-{nonce}", std::process::id())); + std::fs::create_dir(&directory)?; + let fixture = Fixture(directory); + let helper = fixture.0.join("gsettings"); + std::fs::write(&helper, b"fixture")?; + let path = std::env::join_paths([std::path::Path::new("relative-untrusted"), fixture.0.as_path()])?; + assert_eq!(super::gsettings_exe(Some(path.as_os_str())), Some(helper)); + assert!(super::gsettings_exe(Some(std::ffi::OsStr::new("relative-untrusted"))).is_none_or(|p| p.is_absolute())); + Ok(()) + } + + use super::{GNOME, STANDARD, combined, decode, reply}; + use crate::appearance::{Theme, Watcher}; + use std::{ + sync::{Arc, atomic::Ordering}, + time::Duration, + }; + use zbus::zvariant::Value; + #[test] + fn no_preference_is_an_answer_and_gnome_only_fills_missing_preference() { + assert_eq!(combined(None, None), None); + assert_eq!(combined(Some(None), None), Some(None)); + assert_eq!(combined(Some(None), Some(Some(Theme::Dark))), Some(Some(Theme::Dark))); + assert_eq!(combined(Some(Some(Theme::Light)), Some(Some(Theme::Dark))), Some(Some(Theme::Light))); + assert_eq!(combined(None, Some(None)), Some(None)); + } + #[test] + fn malformed_is_distinct_from_no_preference_and_legacy_is_exact() { + assert_eq!(decode(STANDARD, &Value::U32(0)), Some(None)); + assert_eq!(decode(STANDARD, &Value::U32(77)), Some(None)); + assert_eq!(decode(STANDARD, &Value::from("dark")), None); + assert_eq!(reply(STANDARD, &Value::Value(Box::new(Value::U32(1))), true), Some(Some(Theme::Dark))); + assert_eq!(reply(STANDARD, &Value::U32(1), true), None); + assert_eq!(reply(STANDARD, &Value::Value(Box::new(Value::Value(Box::new(Value::U32(1))))), true), None); + assert_eq!(decode(STANDARD, &Value::Value(Box::new(Value::U32(1)))), None); + assert_eq!(decode(STANDARD, &Value::I32(1)), None); + assert_eq!(decode(GNOME, &Value::from("default")), Some(None)); + assert_eq!(decode(GNOME, &Value::U32(1)), None); + + assert_eq!(reply(GNOME, &Value::from("prefer-light"), false), Some(Some(Theme::Light))); + } + #[test] + fn manual_cache_changes_do_not_wake_and_drop_cancels_pending_source() -> Result<(), Box> { + let (tx, rx) = std::sync::mpsc::channel(); + let (cancelled, cancellation) = std::sync::mpsc::channel(); + struct Cancelled(std::sync::mpsc::Sender<()>); + impl Drop for Cancelled { + fn drop(&mut self) { + let _ = self.0.send(()); + } + } + let wakes = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed = Arc::clone(&wakes); + let watcher = Watcher::spawn( + false, + move || { + observed.fetch_add(1, Ordering::Relaxed); + }, + move |shared| async move { + let _cancelled = Cancelled(cancelled); + shared.publish(Some(Theme::Dark)); + let _ = tx.send(()); + futures_lite::future::pending::<()>().await; + }, + ) + .ok_or("worker creation failed")?; + rx.recv_timeout(Duration::from_secs(2))?; + assert_eq!(watcher.current(), Some(Theme::Dark)); + assert_eq!(wakes.load(Ordering::Relaxed), 0); + watcher.set_active(true); + watcher.shared.publish(Some(Theme::Dark)); + assert_eq!(wakes.load(Ordering::Relaxed), 0); + watcher.shared.publish(Some(Theme::Light)); + assert_eq!(watcher.current(), Some(Theme::Light)); + watcher.shared.publish(Some(Theme::Dark)); + assert_eq!(watcher.current(), Some(Theme::Dark)); + watcher.shared.publish(None); + assert_eq!(watcher.current(), None); + assert_eq!(wakes.load(Ordering::Relaxed), 3); + drop(watcher); + cancellation.recv_timeout(Duration::from_secs(2))?; + Ok(()) + } + #[test] + fn drop_does_not_wait_for_a_synchronously_blocked_source() -> Result<(), Box> { + let (started, ready) = std::sync::mpsc::channel(); + let (release, blocked) = std::sync::mpsc::channel(); + let (exited, exit) = std::sync::mpsc::channel(); + struct Release(Option>); + impl Drop for Release { + fn drop(&mut self) { + if let Some(release) = self.0.take() { + let _ = release.send(()); + } + } + } + // Declared before the watcher: every error/unwind releases the synchronous poll. + let release = Release(Some(release)); + let watcher = Watcher::spawn( + false, + || {}, + move |_| async move { + let _ = started.send(()); + let _ = blocked.recv(); + let _ = exited.send(()); + }, + ) + .ok_or("worker creation failed")?; + ready.recv_timeout(Duration::from_secs(2))?; + let (dropped, drop_result) = std::sync::mpsc::channel(); + let dropper = std::thread::spawn(move || { + drop(watcher); + let _ = dropped.send(()); + }); + let bounded = drop_result.recv_timeout(Duration::from_secs(1)).is_ok(); + // Release before asserting, including when an unbounded implementation regresses. + drop(release); + exit.recv_timeout(Duration::from_secs(2))?; + assert!(dropper.join().is_ok(), "watcher drop panicked"); + assert!(bounded, "watcher drop waited for a blocked synchronous poll"); + Ok(()) + } + } +} +/// Owned callback that supplies an observed theme and updates egui native-window hints. +#[cfg(feature = "egui")] +pub type SystemThemeFn = Box Option>; +/// Create an egui theme callback that owns the subscription and wakes its first context. +/// Return `None` when no desktop watcher can start; the host retains its fallback policy. +#[cfg(feature = "egui")] +pub fn egui_service() -> Option { + let context = Arc::new(std::sync::OnceLock::::new()); + let worker_context = Arc::clone(&context); + let watcher = Watcher::start(move || { + if let Some(ctx) = worker_context.get() { + ctx.request_repaint(); + } + })?; + Some(Box::new(move |ctx| { + let _ = context.set(ctx.clone()); + sync_native_window_theme(ctx, watcher.current()); + watcher.current().map(|t| match t { + Theme::Dark => egui::Theme::Dark, + Theme::Light => egui::Theme::Light, + }) + })) +} + +/// Supply the portal answer for native window decorations without changing egui's theme choice +/// or either palette. A missing portal answer falls back to the current toolkit system theme; +/// when both are absent the native pin is released. Linux/BSD window backends cannot reliably +/// resolve `SystemDefault` themselves. +/// The output hook runs after egui's own window-theme synchronization, so it cannot be overwritten +/// by a later automatic `SetTheme(SystemDefault)` in the same pass. +#[cfg(feature = "egui")] +pub fn sync_native_window_theme(ctx: &egui::Context, observed: Option) { + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + { + ctx.add_plugin(NativeWindowTheme::default()); + ctx.with_plugin(|plugin: &mut NativeWindowTheme| plugin.observed = observed); + } + #[cfg(not(all(unix, not(target_os = "macos"), not(target_arch = "wasm32"))))] + let _ = (ctx, observed); +} + +#[cfg(all(feature = "egui", unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +#[derive(Default)] +struct NativeWindowTheme { + observed: Option, + sent: std::collections::HashMap, +} +#[cfg(all(feature = "egui", unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +impl egui::Plugin for NativeWindowTheme { + fn debug_name(&self) -> &'static str { + "desktop window appearance" + } + fn output_hook(&mut self, ctx: &egui::Context, output: &mut egui::FullOutput) { + if !ctx.options(|o| o.sync_window_theme) { + // Another owner may alter the native theme while synchronization is disabled. + self.sent.clear(); + return; + } + let desired = match ctx.options(|o| o.theme_preference) { + egui::ThemePreference::Dark => egui::SystemTheme::Dark, + egui::ThemePreference::Light => egui::SystemTheme::Light, + egui::ThemePreference::System => match self.observed.or_else(|| { + ctx.system_theme().map(|theme| match theme { + egui::Theme::Dark => Theme::Dark, + egui::Theme::Light => Theme::Light, + }) + }) { + Some(Theme::Dark) => egui::SystemTheme::Dark, + Some(Theme::Light) => egui::SystemTheme::Light, + None => egui::SystemTheme::SystemDefault, + }, + }; + // FullOutput can carry child-window commands too. Rewrite every viewport so a deferred + // or immediate child cannot reintroduce egui's inherited hint after its own UI pass. + for (id, viewport) in &mut output.viewport_output { + let had_sync = viewport.commands.iter().any(|command| matches!(command, egui::ViewportCommand::SetTheme(_))); + viewport.commands.retain(|command| !matches!(command, egui::ViewportCommand::SetTheme(_))); + if had_sync || self.sent.get(id) != Some(&desired) { + viewport.commands.push(egui::ViewportCommand::SetTheme(desired)); + self.sent.insert(*id, desired); + } + } + // Forget destroyed native windows: a reused viewport identifier must receive its hint. + self.sent.retain(|id, _| output.viewport_output.contains_key(id)); + } +} + +#[cfg(all(test, feature = "egui", unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +mod native_window_tests { + use super::{Theme, sync_native_window_theme}; + fn pass(ctx: &egui::Context, observed: Option, preference: egui::ThemePreference) -> Vec { + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + sync_native_window_theme(ui.ctx(), observed); + // Deliberately apply the host's choice AFTER the observer, as real palette adapters do. + ui.ctx().set_theme(preference); + }); + output.textures_delta.clear(); // Headless policy test; no renderer consumes uploads. + output + .viewport_output + .values() + .flat_map(|v| &v.commands) + .filter_map(|command| match command { + egui::ViewportCommand::SetTheme(theme) => Some(*theme), + _ => None, + }) + .collect() + } + #[test] + fn actual_end_pass_maps_system_manual_and_reset_without_overwriting_palettes() { + use egui::{SystemTheme as Native, ThemePreference as Choice}; + let ctx = egui::Context::default(); + let marker = egui::Color32::from_rgb(12, 34, 56); + ctx.all_styles_mut(|s| s.visuals.panel_fill = marker); + assert_eq!(pass(&ctx, Some(Theme::Light), Choice::System), vec![Native::Light]); + assert_eq!(ctx.options(|o| o.theme_preference), Choice::System); + assert!(pass(&ctx, Some(Theme::Light), Choice::System).is_empty()); + assert_eq!(pass(&ctx, Some(Theme::Dark), Choice::System), vec![Native::Dark]); + // A portal change cannot override a concrete manual native preference. + assert_eq!(pass(&ctx, Some(Theme::Light), Choice::Dark), vec![Native::Dark]); + assert_eq!(pass(&ctx, Some(Theme::Dark), Choice::Light), vec![Native::Light]); + assert_eq!(pass(&ctx, Some(Theme::Dark), Choice::System), vec![Native::Dark]); + assert_eq!(pass(&ctx, None, Choice::System), vec![Native::SystemDefault]); + assert!(pass(&ctx, None, Choice::System).is_empty()); + assert_eq!(ctx.style_of(egui::Theme::Dark).visuals.panel_fill, marker); + assert_eq!(ctx.style_of(egui::Theme::Light).visuals.panel_fill, marker); + } + #[test] + fn native_system_hint_uses_current_toolkit_fallback_without_retaining_stale_input() { + let ctx = egui::Context::default(); + for (raw_theme, observed, expected) in [ + (Some(egui::Theme::Light), None, egui::SystemTheme::Light), + (Some(egui::Theme::Dark), None, egui::SystemTheme::Dark), + (Some(egui::Theme::Dark), Some(Theme::Light), egui::SystemTheme::Light), + (None, None, egui::SystemTheme::SystemDefault), + ] { + let mut output = ctx.run_ui(egui::RawInput { system_theme: raw_theme, ..Default::default() }, |ui| { + sync_native_window_theme(ui.ctx(), observed); + ui.ctx().set_theme(egui::ThemePreference::System); + }); + let hints: Vec<_> = output + .viewport_output + .values() + .flat_map(|v| &v.commands) + .filter_map(|command| match command { + egui::ViewportCommand::SetTheme(theme) => Some(*theme), + _ => None, + }) + .collect(); + output.textures_delta.clear(); + assert_eq!(hints, vec![expected]); + } + } + #[test] + fn deferred_windows_receive_the_same_hint_and_reset() { + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + let child = egui::ViewportId::from_hash_of("appearance-child"); + for observed in [Some(Theme::Light), Some(Theme::Dark), None] { + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + sync_native_window_theme(ui.ctx(), observed); + ui.ctx().set_theme(egui::ThemePreference::System); + ui.ctx().show_viewport_deferred(child, egui::ViewportBuilder::default(), |_, _| {}); + }); + output.textures_delta.clear(); + let expected = match observed { + Some(Theme::Light) => egui::SystemTheme::Light, + Some(Theme::Dark) => egui::SystemTheme::Dark, + None => egui::SystemTheme::SystemDefault, + }; + for id in [egui::ViewportId::ROOT, child] { + let commands: Vec<_> = output + .viewport_output + .get(&id) + .into_iter() + .flat_map(|v| &v.commands) + .filter_map(|c| match c { + egui::ViewportCommand::SetTheme(t) => Some(*t), + _ => None, + }) + .collect(); + assert_eq!(commands, vec![expected]); + } + } + } + #[test] + fn custom_palette_system_policy_and_explicit_sync_opt_out_are_preserved() { + use egui::{SystemTheme as Native, ThemePreference as Choice}; + let ctx = egui::Context::default(); + // Photo/PDF/Sound keep System native policy even when their custom palette is manual. + ctx.all_styles_mut(|s| s.visuals = egui::Visuals::dark()); + assert_eq!(pass(&ctx, Some(Theme::Light), Choice::System), vec![Native::Light]); + assert!(ctx.style_of(egui::Theme::Light).visuals.dark_mode); + ctx.options_mut(|o| o.sync_window_theme = false); + assert!(pass(&ctx, Some(Theme::Dark), Choice::System).is_empty()); + ctx.options_mut(|o| o.sync_window_theme = true); + assert_eq!(pass(&ctx, Some(Theme::Dark), Choice::System), vec![Native::Dark]); + } +} diff --git a/crates/platform/src/appearance/portal/appearance_bus_tests.rs b/crates/platform/src/appearance/portal/appearance_bus_tests.rs new file mode 100644 index 00000000..d5923bc2 --- /dev/null +++ b/crates/platform/src/appearance/portal/appearance_bus_tests.rs @@ -0,0 +1,147 @@ +//! Private-bus regression based on PdfCraft's queued-update test (storytold/pdfcraft#881). +use super::{DEST, GNOME, IFACE, KEY, PATH, STANDARD, watch_connection}; +use crate::appearance::{Theme, Watcher}; +use std::time::Duration; +use zbus::zvariant::{OwnedValue, Value}; + +#[test] +#[ignore = "requires dbus-daemon; private session bus only"] +fn legacy_read_keeps_queued_signals_and_drop_cancels_idle_subscription() -> Result<(), Box> { + use std::io::BufRead; + use std::process::{Command, Stdio}; + struct Daemon(std::process::Child); + impl Drop for Daemon { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + struct Settings { + captured: std::sync::mpsc::Sender<()>, + released: async_channel::Receiver<()>, + } + #[zbus::interface(name = "org.freedesktop.portal.Settings")] + impl Settings { + async fn read(&self, namespace: &str, _key: &str) -> zbus::fdo::Result { + if namespace != STANDARD { + return Err(zbus::fdo::Error::InvalidArgs("unknown namespace".into())); + } + self.captured.send(()).map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; + self.released.recv().await.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; + OwnedValue::try_from(Value::Value(Box::new(Value::U32(2)))).map_err(|e| zbus::fdo::Error::Failed(e.to_string())) + } + } + let mut daemon = Daemon( + Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address=1"]) + .arg(format!("--address=unix:abstract=craft-appearance-test-{}", std::process::id())) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?, + ); + let mut address = String::new(); + std::io::BufReader::new(daemon.0.stdout.take().ok_or("no daemon stdout")?).read_line(&mut address)?; + let (captured, seen) = std::sync::mpsc::channel(); + let (release, released) = async_channel::bounded(1); + let server = futures_lite::future::block_on( + zbus::connection::Builder::address(address.trim())?.name(DEST)?.serve_at(PATH, Settings { captured, released })?.build(), + )?; + let client = futures_lite::future::block_on(zbus::connection::Builder::address(address.trim())?.build())?; + let (woke, wakes) = std::sync::mpsc::channel(); + let tracker = Watcher::spawn( + true, + move || { + let _ = woke.send(()); + }, + move |shared| async move { + let _ = watch_connection(&shared, &mut true, &mut false, client).await; + }, + ) + .ok_or("could not start observer")?; + seen.recv_timeout(Duration::from_secs(2))?; + futures_lite::future::block_on(server.emit_signal(None::<&str>, PATH, IFACE, "SettingChanged", &(STANDARD, KEY, OwnedValue::from(1u32))))?; + release.send_blocking(())?; + wakes.recv_timeout(Duration::from_secs(2))?; + wakes.recv_timeout(Duration::from_secs(2))?; + assert_eq!(tracker.current(), Some(Theme::Dark)); + // A malformed update must not clear the valid value or cause a wake. + futures_lite::future::block_on(server.emit_signal(None::<&str>, PATH, IFACE, "SettingChanged", &(STANDARD, KEY, OwnedValue::from(true))))?; + assert!(wakes.recv_timeout(Duration::from_millis(50)).is_err()); + assert_eq!(tracker.current(), Some(Theme::Dark)); + let before = std::time::Instant::now(); + drop(tracker); + assert!(before.elapsed() < Duration::from_secs(1)); + // Drop also cancels a backend that has entered Read and never replies. + let client = futures_lite::future::block_on(zbus::connection::Builder::address(address.trim())?.build())?; + let pending = Watcher::spawn( + true, + || {}, + move |shared| async move { + let _ = watch_connection(&shared, &mut true, &mut false, client).await; + }, + ) + .ok_or("could not start pending-read observer")?; + seen.recv_timeout(Duration::from_secs(2))?; + let before = std::time::Instant::now(); + drop(pending); + assert!(before.elapsed() < Duration::from_secs(1)); + // A newly available portal uses ReadOne, and a replacement owner must trigger a reread. + futures_lite::future::block_on(server.release_name(DEST))?; + struct ModernSettings(u32); + #[zbus::interface(name = "org.freedesktop.portal.Settings")] + impl ModernSettings { + fn read_one(&self, namespace: &str, _key: &str) -> zbus::fdo::Result { + if namespace == STANDARD { Ok(OwnedValue::from(self.0)) } else { Err(zbus::fdo::Error::InvalidArgs("unknown namespace".into())) } + } + } + let client = futures_lite::future::block_on(zbus::connection::Builder::address(address.trim())?.build())?; + let (woke, wakes) = std::sync::mpsc::channel(); + let (attempted, attempts) = std::sync::mpsc::channel(); + let tracker = Watcher::spawn( + true, + move || { + let _ = woke.send(()); + }, + move |shared| async move { + // The production observer repeats this session after its bounded reconnect backoff. + loop { + let _ = watch_connection(&shared, &mut true, &mut false, client.clone()).await; + let _ = attempted.send(()); + async_io::Timer::after(Duration::from_millis(10)).await; + } + }, + ) + .ok_or("could not start modern observer")?; + attempts.recv_timeout(Duration::from_secs(2))?; + let modern = + futures_lite::future::block_on(zbus::connection::Builder::address(address.trim())?.name(DEST)?.serve_at(PATH, ModernSettings(2))?.build())?; + + wakes.recv_timeout(Duration::from_secs(2))?; + assert_eq!(tracker.current(), Some(Theme::Light)); + futures_lite::future::block_on(modern.release_name(DEST))?; + let replacement = + futures_lite::future::block_on(zbus::connection::Builder::address(address.trim())?.name(DEST)?.serve_at(PATH, ModernSettings(1))?.build())?; + wakes.recv_timeout(Duration::from_secs(2))?; + assert_eq!(tracker.current(), Some(Theme::Dark)); + futures_lite::future::block_on(replacement.emit_signal(None::<&str>, PATH, IFACE, "SettingChanged", &(STANDARD, KEY, OwnedValue::from(0u32))))?; + wakes.recv_timeout(Duration::from_secs(2))?; + assert_eq!(tracker.current(), None); + // GNOME's string schema fills the empty standard preference. Manual mode still caches it, + // and opting back into System sees that latest cache without a redundant wake. + tracker.set_active(false); + futures_lite::future::block_on(replacement.emit_signal(None::<&str>, PATH, IFACE, "SettingChanged", &(GNOME, KEY, Value::from("prefer-dark"))))?; + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while tracker.current() != Some(Theme::Dark) { + assert!(std::time::Instant::now() < deadline, "GNOME change was not cached"); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(matches!(wakes.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty))); + tracker.set_active(true); + assert_eq!(tracker.current(), Some(Theme::Dark)); + assert!(matches!(wakes.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty))); + futures_lite::future::block_on(replacement.emit_signal(None::<&str>, PATH, IFACE, "SettingChanged", &(GNOME, KEY, Value::from("default"))))?; + wakes.recv_timeout(Duration::from_secs(2))?; + assert_eq!(tracker.current(), None); + drop(tracker); + Ok(()) +} diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index ba43fbfb..2335afc6 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -1,5 +1,9 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] //! Shared lifecycle mechanics; application state, theme choices and file policy stay in hosts. +#[cfg(feature = "appearance")] +pub mod appearance; pub mod dialog_outcome; #[cfg(all(feature = "dialogs", not(target_arch = "wasm32")))] pub mod dialogs; +#[cfg(all(feature = "appearance", unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +mod process; diff --git a/crates/platform/src/process.rs b/crates/platform/src/process.rs new file mode 100644 index 00000000..24c4f7f4 --- /dev/null +++ b/crates/platform/src/process.rs @@ -0,0 +1,53 @@ +//! Bounded, cancellable output capture for the desktop's one-shot settings fallback. + +/// Reads at most 8193 bytes from a pipe, without a detached reader or disk spool. +/// Cancellation/timeout drops the reader and kills the owned child; async-process reaps it. +pub(crate) async fn capture(program: &std::path::Path, args: &[&str], timeout: std::time::Duration) -> Option { + use futures_lite::io::AsyncReadExt; + use std::process::Stdio; + let mut child = async_process::Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .reap_on_drop(true) + .spawn() + .ok()?; + let stdout = child.stdout.take()?; + futures_lite::future::race( + async { + let mut bytes = Vec::new(); + stdout.take(8193).read_to_end(&mut bytes).await.ok()?; + if bytes.len() > 8192 { + return None; + } + if !child.status().await.ok()?.success() { + return None; + } + String::from_utf8(bytes).ok() + }, + async { + async_io::Timer::after(timeout).await; + None + }, + ) + .await +} +#[cfg(all(test, feature = "appearance", unix, not(target_os = "macos")))] +mod tests { + use super::capture; + #[test] + fn capture_checks_exit_status_size_and_deadline() { + use std::time::{Duration, Instant}; + futures_lite::future::block_on(async { + let shell = std::path::Path::new("/bin/sh"); + assert_eq!(capture(shell, &["-c", "printf hello"], Duration::from_secs(1)).await.as_deref(), Some("hello")); + assert_eq!(capture(shell, &["-c", "printf failure; exit 7"], Duration::from_secs(1)).await, None); + let before = Instant::now(); + assert_eq!(capture(shell, &["-c", "exec sleep 5"], Duration::from_millis(30)).await, None); + assert!(before.elapsed() < Duration::from_secs(1)); + assert_eq!(capture(shell, &["-c", "head -c 10000 /dev/zero"], Duration::from_secs(1)).await, None); + }); + } +}