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 <skylartaylor@users.noreply.github.com>
This commit is contained in:
skylar taylor-barrickandskylartaylor authored and GitHub committed 2026-10-22 10:13:09 -04:00
1 parent b9c1a1105e
commit ffe7f8fce8
10 files changed
+1287 -10

No files matched your search

Generated
+382
View File
@@ -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",
]
+14 -4
View File
@@ -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.
+1 -1
View File
@@ -25,7 +25,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
## Libraries
- [`craft-platform`](crates/platform/README.md): optional native dialog lifecycle; application policy stays in the apps.
- [`craft-platform`](crates/platform/README.md): optional native dialog lifecycle and Linux/BSD desktop appearance observation; application policy stays in the apps.
- [`craft-ai-core`](crates/ai-core/README.md): the data model and contract for AI in the Craft Apps (connections, models, tools, requests, jobs, consent, history, route spec). Pure, no I/O, wasm-safe. Contract in [docs/ai-shared-layer.md](docs/ai-shared-layer.md).
- [`craft-pdf`](crates/pdf/README.md): independently optional PDF parsing, interpretation, rasterization and guarded authoring. Apps retain layout, fonts, editing and export policy; writer-only consumers do not enable the shared reader.
+8
View File
@@ -13,7 +13,15 @@ workspace = true
[features]
default = []
dialogs = []
appearance = ["dep:zbus", "dep:futures-lite", "dep:async-channel", "dep:async-io", "dep:async-process"]
egui = ["dep:egui"]
[dependencies]
egui = { workspace = true, optional = true }
[target.'cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))'.dependencies]
zbus = { version = "5", default-features = false, features = ["async-io"], optional = true }
futures-lite = { version = "2", optional = true }
async-channel = { version = "2", optional = true }
async-io = { version = "2", optional = true }
async-process = { version = "2", optional = true }
+14 -4
View File
@@ -1,6 +1,16 @@
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.
+29 -1
View File
@@ -8,7 +8,8 @@ third-party dependencies. Enable only the services a host uses.
| --- | --- |
| default | Typed dialog outcomes; no backend or toolkit. |
| `dialogs` | Reserve waiting workers before eager UI-thread native picker creation; serial requests, failure delivery and completion wakeups. The backend factory belongs to the app. |
| `egui` | Combine with `dialogs` for the deferred-close guard. |
| `egui` | Combine with `dialogs` for the deferred-close guard or with `appearance` for native theme mapping. |
| `appearance` | Linux/BSD XDG desktop preference observation, owned cancellable subscriptions, bounded reads/helper capture, legacy compatibility and reconnect. |
## Native dialogs
@@ -35,6 +36,33 @@ unconsumed application continuation. `DeferredClose::update` cancels close durin
returns a request to retry the application's normal close policy afterward. It never grants
permission to discard work. Run those guards before `craft_quit::on_close_request` in `App::logic`.
## Desktop appearance
`appearance::Watcher` caches the desktop preference and wakes only on changes. Subscription precedes
the first read. ReadOne/legacy Read, the standard namespace and GNOME fallback are handled together;
malformed values retain the last valid answer, and explicit no-preference clears it. Disconnection
retains the last valid value until a fresh answer. A later service owner triggers a reread; session
reconnection has bounded backoff. Dropping cancels asynchronous work and waits at most 100 ms
for acknowledgement, joining only an already-finished worker. A synchronously blocked OS call or caller wake callback can outlive
the watcher: that exceptional worker is detached and retains its resources until it returns,
possibly until process exit. Wake callbacks must be nonblocking. No per-helper reader threads
are created.
App-selected Light/Dark/System, custom palettes, window decorations and unknown-theme fallback remain
application policy. `set_active(false)` suppresses wake notifications while continuing to cache.
Four existing custom-palette service hooks retain their current signatures and receive change-only
notifications even in manual mode; they do not change the chosen palette. Never infer Auto from the
egui theme preference when an app implements Auto by explicitly applying a Light/Dark palette.
`sync_native_window_theme(ctx, observed)` forwards the cached Linux/BSD desktop answer to native
decorations when egui's window policy is System, falling back to the current toolkit answer.
Its output hook runs after egui's automatic
window synchronization, including child-window output. Concrete Light/Dark preferences retain
precedence, absence of both answers releases the native pin, and `sync_window_theme = false` opts out.
It does not change styles or theme preference. Hosts whose custom palette is manual but whose
native window follows System keep observation active for the native window. Native decoration
rendering still depends on the window manager and requires visible acceptance testing.
## Integration
Enable only the services a host uses. Applications retain palettes, settings, document identity,
+635
View File
@@ -0,0 +1,635 @@
//! Desktop colour preference through the XDG Settings portal.
//!
//! Derived from PdfCraft's activation-safe subscription and GNOME schema fallback,
//! CADCraft's BSD support, and ArtCraft Launcher's ReadOne/Read compatibility.
//! Palettes, manual choices and the fallback when no preference exists belong to the host.
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicU8, Ordering},
};
/// Desktop color preference observed independently of the application palette.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Theme {
/// The desktop requests a dark appearance.
Dark,
/// The desktop requests a light appearance.
Light,
}
struct Shared {
value: AtomicU8,
active: AtomicBool,
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
wake: Box<dyn Fn() + Send + Sync>,
}
impl Shared {
fn get(&self) -> Option<Theme> {
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<Theme>) {
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<Shared>,
#[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<std::thread::JoinHandle<()>>,
#[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> {
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<Self> {
#[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<Theme> {
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<F: std::future::Future<Output = ()> + 'static>(
active: bool,
wake: impl Fn() + Send + Sync + 'static,
source: impl FnOnce(Arc<Shared>) -> F + Send + 'static,
) -> Option<Self> {
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<Option<Theme>> {
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<Option<Theme>> {
if legacy {
let Value::Value(inner) = value else { return None };
decode(namespace, inner)
} else {
decode(namespace, value)
}
}
fn combined(standard: Option<Option<Theme>>, gnome: Option<Option<Theme>>) -> Option<Option<Theme>> {
if standard.is_none() && gnome.is_none() { None } else { Some(standard.flatten().or(gnome.flatten())) }
}
async fn bounded<T>(future: impl std::future::Future<Output = T>) -> Option<T> {
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<Option<Theme>> {
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::<OwnedValue>()
&& let Some(answer) = reply(namespace, &value, legacy)
{
return Some(answer);
}
}
None
}
fn rule() -> zbus::Result<zbus::MatchRule<'static>> {
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<zbus::MatchRule<'static>> {
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<Shared>) {
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<std::path::PathBuf> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
let (started, ready) = std::sync::mpsc::channel();
let (release, blocked) = std::sync::mpsc::channel();
let (exited, exit) = std::sync::mpsc::channel();
struct Release(Option<std::sync::mpsc::Sender<()>>);
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<dyn Fn(&egui::Context) -> Option<egui::Theme>>;
/// 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<SystemThemeFn> {
let context = Arc::new(std::sync::OnceLock::<egui::Context>::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<Theme>) {
#[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<Theme>,
sent: std::collections::HashMap<egui::ViewportId, egui::SystemTheme>,
}
#[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<Theme>, preference: egui::ThemePreference) -> Vec<egui::SystemTheme> {
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]);
}
}
@@ -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<dyn std::error::Error>> {
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<OwnedValue> {
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<OwnedValue> {
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(())
}
+4
View File
@@ -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;
+53
View File
@@ -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<String> {
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);
});
}
}