mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-31 07:39:23 +00:00
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:
1 parent
b9c1a1105e
commit
ffe7f8fce8
10 files changed
+1287
-10
No files matched your search
Generated
+382
@@ -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",
|
||||
]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user