From 6017d9d3af2cdad8c7aad7c896afc402f305ac9a Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 2 Sep 2026 12:55:52 +0200 Subject: [PATCH 01/38] refactor: become a workspace, so a GUI can be a second crate The architecture reserved crates/raven-gui behind a trigger that is now met. Nothing about the raven crate changes: the same sources, the same tests, the same seven suites passing. The workspace root carries the shared version and the release profile so the two crates cannot drift apart. --- Cargo.lock | 4 ++++ Cargo.toml | 23 +++++++------------ crates/raven-gui/Cargo.toml | 10 ++++++++ crates/raven-gui/src/main.rs | 1 + crates/raven/Cargo.toml | 20 ++++++++++++++++ {src => crates/raven/src}/attach.rs | 0 {src => crates/raven/src}/base.rs | 0 {src => crates/raven/src}/d3d.rs | 0 {src => crates/raven/src}/env.rs | 0 {src => crates/raven/src}/launch.rs | 0 {src => crates/raven/src}/layer.rs | 0 {src => crates/raven/src}/lib.rs | 0 {src => crates/raven/src}/main.rs | 0 {src => crates/raven/src}/mount/mod.rs | 0 {src => crates/raven/src}/mount/userns.rs | 0 {src => crates/raven/src}/paths.rs | 0 {src => crates/raven/src}/prefix.rs | 0 {src => crates/raven/src}/registry/emit.rs | 0 {src => crates/raven/src}/registry/hive.rs | 0 {src => crates/raven/src}/registry/mod.rs | 0 {src => crates/raven/src}/registry/rules.rs | 0 {src => crates/raven/src}/registry/text.rs | 0 {src => crates/raven/src}/session.rs | 0 .../raven/tests}/base_is_immutable.rs | 0 .../raven/tests}/device_attach.rs | 0 .../raven/tests}/environment_recovery.rs | 0 .../raven/tests}/registry_projection.rs | 0 27 files changed, 43 insertions(+), 15 deletions(-) create mode 100644 crates/raven-gui/Cargo.toml create mode 100644 crates/raven-gui/src/main.rs create mode 100644 crates/raven/Cargo.toml rename {src => crates/raven/src}/attach.rs (100%) rename {src => crates/raven/src}/base.rs (100%) rename {src => crates/raven/src}/d3d.rs (100%) rename {src => crates/raven/src}/env.rs (100%) rename {src => crates/raven/src}/launch.rs (100%) rename {src => crates/raven/src}/layer.rs (100%) rename {src => crates/raven/src}/lib.rs (100%) rename {src => crates/raven/src}/main.rs (100%) rename {src => crates/raven/src}/mount/mod.rs (100%) rename {src => crates/raven/src}/mount/userns.rs (100%) rename {src => crates/raven/src}/paths.rs (100%) rename {src => crates/raven/src}/prefix.rs (100%) rename {src => crates/raven/src}/registry/emit.rs (100%) rename {src => crates/raven/src}/registry/hive.rs (100%) rename {src => crates/raven/src}/registry/mod.rs (100%) rename {src => crates/raven/src}/registry/rules.rs (100%) rename {src => crates/raven/src}/registry/text.rs (100%) rename {src => crates/raven/src}/session.rs (100%) rename {tests => crates/raven/tests}/base_is_immutable.rs (100%) rename {tests => crates/raven/tests}/device_attach.rs (100%) rename {tests => crates/raven/tests}/environment_recovery.rs (100%) rename {tests => crates/raven/tests}/registry_projection.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 31082ef..28c7f78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,6 +400,10 @@ dependencies = [ "toml", ] +[[package]] +name = "raven-gui" +version = "0.2.0" + [[package]] name = "regf" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 4cbb9fb..86d8a10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,24 +1,17 @@ -[package] -name = "raven" -description = "Run Windows programs on Linux against a real Windows installation mounted as C:" +[workspace] +resolver = "3" +members = ["crates/raven", "crates/raven-gui"] + +# Shared by both crates so they cannot drift apart: the GUI is unusable +# against a library of a different age, and release-please gives them one +# version. +[workspace.package] version = "0.2.0" # x-release-please-version license = "GPL-3.0-or-later" repository = "https://github.com/Project-Colony/Raven" edition = "2024" rust-version = "1.85" -[dependencies] -anyhow = "1.0.104" -clap = { version = "4.6.6", features = ["derive"] } -nt-hive = "0.3.0" -rustix = { version = "1.1.4", features = ["mount", "thread", "process", "fs"] } -serde = { version = "1.0.229", features = ["derive"] } -thiserror = "2.0.20" -toml = "1.1.4" - -[dev-dependencies] -regf = "0.1.0" - # raven runs once per .exe launch, so what matters is start-up and size, both # measured before and after (numbers in the commit that added this). # `panic = "abort"` is deliberately not set: it trades backtraces away for diff --git a/crates/raven-gui/Cargo.toml b/crates/raven-gui/Cargo.toml new file mode 100644 index 0000000..d038a8a --- /dev/null +++ b/crates/raven-gui/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "raven-gui" +description = "Raven's administration window" +version.workspace = true +license.workspace = true +repository.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] diff --git a/crates/raven-gui/src/main.rs b/crates/raven-gui/src/main.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/crates/raven-gui/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/crates/raven/Cargo.toml b/crates/raven/Cargo.toml new file mode 100644 index 0000000..b976314 --- /dev/null +++ b/crates/raven/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "raven" +description = "Run Windows programs on Linux against a real Windows installation mounted as C:" +version.workspace = true +license.workspace = true +repository.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +anyhow = "1.0.104" +clap = { version = "4.6.6", features = ["derive"] } +nt-hive = "0.3.0" +rustix = { version = "1.1.4", features = ["mount", "thread", "process", "fs"] } +serde = { version = "1.0.229", features = ["derive"] } +thiserror = "2.0.20" +toml = "1.1.4" + +[dev-dependencies] +regf = "0.1.0" diff --git a/src/attach.rs b/crates/raven/src/attach.rs similarity index 100% rename from src/attach.rs rename to crates/raven/src/attach.rs diff --git a/src/base.rs b/crates/raven/src/base.rs similarity index 100% rename from src/base.rs rename to crates/raven/src/base.rs diff --git a/src/d3d.rs b/crates/raven/src/d3d.rs similarity index 100% rename from src/d3d.rs rename to crates/raven/src/d3d.rs diff --git a/src/env.rs b/crates/raven/src/env.rs similarity index 100% rename from src/env.rs rename to crates/raven/src/env.rs diff --git a/src/launch.rs b/crates/raven/src/launch.rs similarity index 100% rename from src/launch.rs rename to crates/raven/src/launch.rs diff --git a/src/layer.rs b/crates/raven/src/layer.rs similarity index 100% rename from src/layer.rs rename to crates/raven/src/layer.rs diff --git a/src/lib.rs b/crates/raven/src/lib.rs similarity index 100% rename from src/lib.rs rename to crates/raven/src/lib.rs diff --git a/src/main.rs b/crates/raven/src/main.rs similarity index 100% rename from src/main.rs rename to crates/raven/src/main.rs diff --git a/src/mount/mod.rs b/crates/raven/src/mount/mod.rs similarity index 100% rename from src/mount/mod.rs rename to crates/raven/src/mount/mod.rs diff --git a/src/mount/userns.rs b/crates/raven/src/mount/userns.rs similarity index 100% rename from src/mount/userns.rs rename to crates/raven/src/mount/userns.rs diff --git a/src/paths.rs b/crates/raven/src/paths.rs similarity index 100% rename from src/paths.rs rename to crates/raven/src/paths.rs diff --git a/src/prefix.rs b/crates/raven/src/prefix.rs similarity index 100% rename from src/prefix.rs rename to crates/raven/src/prefix.rs diff --git a/src/registry/emit.rs b/crates/raven/src/registry/emit.rs similarity index 100% rename from src/registry/emit.rs rename to crates/raven/src/registry/emit.rs diff --git a/src/registry/hive.rs b/crates/raven/src/registry/hive.rs similarity index 100% rename from src/registry/hive.rs rename to crates/raven/src/registry/hive.rs diff --git a/src/registry/mod.rs b/crates/raven/src/registry/mod.rs similarity index 100% rename from src/registry/mod.rs rename to crates/raven/src/registry/mod.rs diff --git a/src/registry/rules.rs b/crates/raven/src/registry/rules.rs similarity index 100% rename from src/registry/rules.rs rename to crates/raven/src/registry/rules.rs diff --git a/src/registry/text.rs b/crates/raven/src/registry/text.rs similarity index 100% rename from src/registry/text.rs rename to crates/raven/src/registry/text.rs diff --git a/src/session.rs b/crates/raven/src/session.rs similarity index 100% rename from src/session.rs rename to crates/raven/src/session.rs diff --git a/tests/base_is_immutable.rs b/crates/raven/tests/base_is_immutable.rs similarity index 100% rename from tests/base_is_immutable.rs rename to crates/raven/tests/base_is_immutable.rs diff --git a/tests/device_attach.rs b/crates/raven/tests/device_attach.rs similarity index 100% rename from tests/device_attach.rs rename to crates/raven/tests/device_attach.rs diff --git a/tests/environment_recovery.rs b/crates/raven/tests/environment_recovery.rs similarity index 100% rename from tests/environment_recovery.rs rename to crates/raven/tests/environment_recovery.rs diff --git a/tests/registry_projection.rs b/crates/raven/tests/registry_projection.rs similarity index 100% rename from tests/registry_projection.rs rename to crates/raven/tests/registry_projection.rs From fd3fb56b624ef57318bde32ad365843d66f12130 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 2 Sep 2026 13:03:45 +0200 Subject: [PATCH 02/38] feat(gui): a themed window, drawing nothing but its own name colony-ui supplies the palette and the type scale. Every size goes through Typography::sz from the first line, because the layout has to survive the 0.7225x-1.68x the org's two font preferences produce when multiplied, and retrofitting that later means touching every call site. No assets/fonts/ directory exists yet, so the window relies on JetBrainsMono Nerd Font being installed system-wide rather than bundling it; a later task can add the bundled font deliberately. Two deviations from the task brief's code, both required to compile/pass lint under iced 0.14 and this repo's -D warnings clippy config: - Column::spacing needs impl Into, which is not implemented for u16, only f32/u32 - pass t.sz(8) directly instead of casting to u16. - theme.rs's doc comment used a Markdown-list-shaped '- X - Y' construction that clippy::doc_lazy_continuation flags as a malformed list; reworded without changing its meaning. --- Cargo.lock | 4110 ++++++++++++++++++++++++++++++--- crates/raven-gui/Cargo.toml | 3 + crates/raven-gui/src/main.rs | 63 +- crates/raven-gui/src/theme.rs | 31 + 4 files changed, 3944 insertions(+), 263 deletions(-) create mode 100644 crates/raven-gui/src/theme.rs diff --git a/Cargo.lock b/Cargo.lock index 28c7f78..290d7c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,69 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.20", +] + +[[package]] +name = "android-build" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fc9904ad2ad097c3c1cfe2eacaaf0fc24710936fa9ed941cb310b7c6ed2ab7" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -47,7 +110,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,7 +121,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -67,30 +130,329 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +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.4", + "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.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.4", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[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 = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + [[package]] name = "cc" version = "1.4.4" @@ -98,6 +460,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -107,6 +471,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chrono" version = "0.4.45" @@ -161,394 +531,486 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] -name = "colorchoice" -version = "1.0.5" +name = "clipboard-win" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "clipboard_macos" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f" +dependencies = [ + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] [[package]] -name = "encoding_rs" -version = "0.8.35" +name = "clipboard_wayland" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "003f886bc4e2987729d10c1db3424e7f80809f3fc22dbc16c685738887cb37b8" dependencies = [ - "cfg-if", + "smithay-clipboard", ] [[package]] -name = "enumn" -version = "0.1.14" +name = "clipboard_x11" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +checksum = "bd63e33452ffdafd39924c4f05a5dd1e94db646c779c6bd59148a3d95fff5ad4" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "thiserror 2.0.20", + "x11rb", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "codespan-reporting" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] [[package]] -name = "errno" -version = "0.3.14" +name = "colony-ui" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "9005873dd9a2d7b63d226eeaa7191d595e42e312caba03d193b3d473a25ccea8" dependencies = [ - "libc", - "windows-sys", + "dirs", + "iced", + "serde_json", ] [[package]] -name = "find-msvc-tools" -version = "0.1.11" +name = "colorchoice" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "futures-core" -version = "0.3.34" +name = "combine" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] [[package]] -name = "futures-task" -version = "0.3.34" +name = "concurrent-queue" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] [[package]] -name = "futures-util" -version = "0.3.34" +name = "core-foundation" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", + "core-foundation-sys", + "libc", ] [[package]] -name = "hashbrown" -version = "0.17.1" +name = "core-foundation" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] [[package]] -name = "heck" -version = "0.5.0" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "iana-time-zone" -version = "0.1.65" +name = "core-graphics" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", ] [[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "core-graphics-types" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ - "cc", + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", ] [[package]] -name = "indexmap" -version = "2.14.1" +name = "core-graphics-types" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "equivalent", - "hashbrown", + "bitflags 2.13.1", + "core-foundation 0.10.1", + "libc", ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "core_maths" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] [[package]] -name = "js-sys" -version = "0.3.104" +name = "cosmic-text" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "173852283a9a57a3cbe365d86e74dc428a09c50421477d5ad6fe9d9509e37737" dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", + "bitflags 2.13.1", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 1.1.0", + "self_cell", + "skrifa 0.37.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", ] [[package]] -name = "libc" -version = "0.2.189" +name = "crossbeam-utils" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "crunchy" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] -name = "log" -version = "0.4.34" +name = "cryoglyph" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "08bc795bdbccdbd461736fb163930a009da6597b226d6f6fce33e7a8eb6ec519" +dependencies = [ + "cosmic-text", + "etagere", + "lru", + "rustc-hash 2.1.3", + "wgpu", +] [[package]] -name = "memoffset" -version = "0.9.1" +name = "ctor" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" dependencies = [ - "autocfg", + "dtor", ] [[package]] -name = "nt-hive" -version = "0.3.0" +name = "cursor-icon" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39f2d1dac9c1dd47ef51de60ff6794e2e163272124489bc93ec7b6a63348bbe9" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "bitflags", - "enumn", - "memoffset", - "thiserror 2.0.20", - "zerocopy", + "dirs-sys", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "dirs-sys" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ - "autocfg", + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "dispatch" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "dispatch2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", +] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "dlib" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "document-features" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ - "unicode-ident", + "litrs", ] [[package]] -name = "quote" -version = "1.0.47" +name = "downcast-rs" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "proc-macro2", + "cfg-if", ] [[package]] -name = "raven" -version = "0.2.0" +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ - "anyhow", - "clap", - "nt-hive", - "regf", - "rustix", + "enumflags2_derive", "serde", - "thiserror 2.0.20", - "toml", ] [[package]] -name = "raven-gui" -version = "0.2.0" +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 = "regf" -version = "0.1.0" +name = "enumn" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c58f645b9512345197fd9509bdd4a0c0843eeb8e2980e70a828f018f52bc7c4" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" dependencies = [ - "bitflags", - "byteorder", - "chrono", - "encoding_rs", - "thiserror 1.0.69", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "rustix" -version = "1.1.4" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "bitflags", - "errno", "libc", - "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] -name = "rustversion" -version = "1.0.23" +name = "error-code" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" [[package]] -name = "serde" -version = "1.0.229" +name = "etagere" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" dependencies = [ - "serde_core", - "serde_derive", + "euclid", + "svg_fmt", ] [[package]] -name = "serde_core" -version = "1.0.229" +name = "euclid" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" dependencies = [ - "serde_derive", + "num-traits", ] [[package]] -name = "serde_derive" -version = "1.0.229" +name = "event-listener" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", + "parking", + "pin-project-lite", ] [[package]] -name = "serde_spanned" -version = "1.1.1" +name = "event-listener-strategy" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "serde_core", + "event-listener", + "pin-project-lite", ] [[package]] -name = "shlex" -version = "2.0.1" +name = "fastrand" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] -name = "slab" -version = "0.4.12" +name = "find-msvc-tools" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] -name = "strsim" -version = "0.11.1" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "syn" -version = "2.0.119" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "bytemuck", ] [[package]] -name = "syn" -version = "3.0.4" +name = "font-types" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "bytemuck", ] [[package]] -name = "thiserror" -version = "1.0.69" +name = "fontconfig-parser" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "thiserror-impl 1.0.69", + "roxmltree", ] [[package]] -name = "thiserror" -version = "2.0.20" +name = "fontdb" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" dependencies = [ - "thiserror-impl 2.0.20", + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", ] [[package]] -name = "thiserror-impl" -version = "1.0.69" +name = "foreign-types" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "foreign-types-macros", + "foreign-types-shared", ] [[package]] -name = "thiserror-impl" -version = "2.0.20" +name = "foreign-types-macros" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", @@ -556,99 +1018,2818 @@ dependencies = [ ] [[package]] -name = "toml" -version = "1.1.4+spec-1.1.0" +name = "foreign-types-shared" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", -] +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" [[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +name = "futures" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ - "serde_core", + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", ] [[package]] -name = "toml_parser" -version = "1.1.3+spec-1.1.0" +name = "futures-channel" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ - "winnow", + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +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-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "read-fonts 0.35.0", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "iced" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000e01026c93ba643f8357a3db3ada0e6555265a377f6f9291c472f6dd701fb3" +dependencies = [ + "iced_core", + "iced_debug", + "iced_futures", + "iced_renderer", + "iced_runtime", + "iced_widget", + "iced_winit", + "thiserror 2.0.20", +] + +[[package]] +name = "iced_core" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ab1937d699403e7e69252ae743a902bcee9f4ab2052cc4c9a46fcf34729d85" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "glam", + "lilt", + "log", + "num-traits", + "rustc-hash 2.1.3", + "smol_str", + "thiserror 2.0.20", + "web-time", +] + +[[package]] +name = "iced_debug" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25035ab0215a620e53f4103e36fc4e59a1fb2817e4bfc38a30ad27b4202ea0be" +dependencies = [ + "iced_core", + "iced_futures", + "log", +] + +[[package]] +name = "iced_futures" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c0c85ccad42dfbec7293c36c018af0ea0dbcc52d137a4a9a0b0f6822a3fdf0a" +dependencies = [ + "futures", + "iced_core", + "log", + "rustc-hash 2.1.3", + "tokio", + "wasm-bindgen-futures", + "wasmtimer", +] + +[[package]] +name = "iced_graphics" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234ca1c2cec4155055f68fa5fad1b5242c496ac8238d80a259bca382fb44a102" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "cosmic-text", + "half", + "iced_core", + "iced_futures", + "log", + "raw-window-handle", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "unicode-segmentation", +] + +[[package]] +name = "iced_program" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dfafec2947cda688d8eb00dac337ba11aa60f9ef6335aed343e189d26e4a673" +dependencies = [ + "iced_graphics", + "iced_runtime", +] + +[[package]] +name = "iced_renderer" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250cc0802408e8c077986ec56c7d07c65f423ee658a4b9fd795a1f2aae5dac05" +dependencies = [ + "iced_graphics", + "iced_tiny_skia", + "iced_wgpu", + "log", + "thiserror 2.0.20", +] + +[[package]] +name = "iced_runtime" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1889b819ce4c06674183242e336c8d49465665441396914dc07cc86f44fa8d4" +dependencies = [ + "bytes", + "iced_core", + "iced_futures", + "raw-window-handle", + "thiserror 2.0.20", +] + +[[package]] +name = "iced_tiny_skia" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe0acf8b75a3bc914aff5f2329fdffc1b36eeaea29dda0e4bd232f1c62e9cc3d" +dependencies = [ + "bytemuck", + "cosmic-text", + "iced_debug", + "iced_graphics", + "kurbo", + "log", + "rustc-hash 2.1.3", + "softbuffer", + "tiny-skia", +] + +[[package]] +name = "iced_wgpu" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff144a999b0ca0f8a10257934500060240825c42e950ec0ebee9c8ae30561c13" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "cryoglyph", + "futures", + "glam", + "guillotiere", + "iced_debug", + "iced_graphics", + "log", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "wgpu", +] + +[[package]] +name = "iced_widget" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab" +dependencies = [ + "iced_renderer", + "log", + "num-traits", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "unicode-segmentation", +] + +[[package]] +name = "iced_winit" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b7dbedc47562d1de3b9707d939f678b88c382004b7ab5a18f7a7dd723162d75" +dependencies = [ + "iced_debug", + "iced_program", + "log", + "mundy", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "tracing", + "wasm-bindgen-futures", + "web-sys", + "window_clipboard", + "winit", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +dependencies = [ + "arrayvec", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.3", +] + +[[package]] +name = "lilt" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d4c256f7d9f2dbd633891d48ace853efa5b1554122d9220b172ad9c03c3a9" +dependencies = [ + "web-time", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +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 = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "mundy" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f32eb0db40f2df2bcfb05c93b8f73938d4c26ce9ac8881f1df0c8d3296921a73" +dependencies = [ + "android-build", + "async-io", + "cfg-if", + "dispatch", + "futures-channel", + "futures-lite", + "jni", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "pin-project-lite", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.62.2", + "zbus", +] + +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.20", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nt-hive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39f2d1dac9c1dd47ef51de60ff6794e2e163272124489bc93ec7b6a63348bbe9" +dependencies = [ + "bitflags 2.13.1", + "enumn", + "memoffset", + "thiserror 2.0.20", + "zerocopy", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c860fd3227ca4ac3cc032e2cd20cba3f02ccdf4b610538f8ee6584d56bb62e96" +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 = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + +[[package]] +name = "raven" +version = "0.2.0" +dependencies = [ + "anyhow", + "clap", + "nt-hive", + "regf", + "rustix 1.1.4", + "serde", + "thiserror 2.0.20", + "toml", +] + +[[package]] +name = "raven-gui" +version = "0.2.0" +dependencies = [ + "colony-ui", + "iced", + "raven", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.4", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "regf" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c58f645b9512345197fd9509bdd4a0c0843eeb8e2980e70a828f018f52bc7c4" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "chrono", + "encoding_rs", + "thiserror 1.0.69", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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.4", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +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 = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts 0.35.0", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.20", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "fastrand", + "js-sys", + "memmap2", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 1.1.4", + "tiny-xlib", + "tracing", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +dependencies = [ + "as-raw-xcb-connection", + "ctor", + "libloading", + "pkg-config", + "tracing", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[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 = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.1", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.20", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.1", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types 0.2.0", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.20", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", ] [[package]] -name = "toml_writer" -version = "1.1.2+spec-1.1.0" +name = "wgpu-types" +version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.20", + "web-sys", +] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "winapi-util" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] [[package]] -name = "utf8parse" -version = "0.2.2" +name = "window_clipboard" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +checksum = "d5654226305eaf2dde8853fb482861d28e5dcecbbd40cb88e8393d94bb80d733" +dependencies = [ + "clipboard-win", + "clipboard_macos", + "clipboard_wayland", + "clipboard_x11", + "raw-window-handle", + "thiserror 2.0.20", +] [[package]] -name = "wasm-bindgen" -version = "0.2.127" +name = "windows" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", + "windows-core 0.58.0", + "windows-targets", ] [[package]] -name = "wasm-bindgen-macro" -version = "0.2.127" +name = "windows" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "quote", - "wasm-bindgen-macro-support", + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", ] [[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.127" +name = "windows-collections" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", + "windows-core 0.62.2", ] [[package]] -name = "wasm-bindgen-shared" -version = "0.2.127" +name = "windows-core" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" dependencies = [ - "unicode-ident", + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets", ] [[package]] @@ -657,11 +3838,33 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", "windows-link", - "windows-result", - "windows-strings", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -675,6 +3878,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -692,6 +3906,25 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -701,6 +3934,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets", +] + [[package]] name = "windows-strings" version = "0.5.1" @@ -710,6 +3953,24 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -719,11 +3980,290 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "zbus" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" +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.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" @@ -744,3 +4284,49 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "winnow 0.7.15", +] diff --git a/crates/raven-gui/Cargo.toml b/crates/raven-gui/Cargo.toml index d038a8a..ffad440 100644 --- a/crates/raven-gui/Cargo.toml +++ b/crates/raven-gui/Cargo.toml @@ -8,3 +8,6 @@ edition.workspace = true rust-version.workspace = true [dependencies] +raven = { path = "../raven" } +colony-ui = "0.1.4" +iced = { version = "0.14.0", features = ["tokio", "advanced"] } diff --git a/crates/raven-gui/src/main.rs b/crates/raven-gui/src/main.rs index f328e4d..60e5122 100644 --- a/crates/raven-gui/src/main.rs +++ b/crates/raven-gui/src/main.rs @@ -1 +1,62 @@ -fn main() {} +//! Raven's administration window. +//! +//! A second caller of the same library the command line uses - see +//! docs/superpowers/specs/2026-09-01-raven-gui-design.md. Nothing here decides +//! anything about environments; it draws what the library reports and asks the +//! library to act. + +mod theme; + +use iced::widget::{column, container, text}; +use iced::{Element, Length, Task}; + +/// Which screen is showing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Screen { + #[default] + Environments, + Bases, + Doctor, +} + +#[derive(Debug, Clone)] +pub enum Message { + Go(Screen), +} + +#[derive(Default)] +pub struct App { + screen: Screen, +} + +impl App { + fn update(&mut self, message: Message) -> Task { + match message { + Message::Go(screen) => { + self.screen = screen; + Task::none() + } + } + } + + fn view(&self) -> Element<'_, Message> { + let t = theme::typography(); + let p = theme::palette(); + container(column![text("Raven").size(t.sz(30)).color(p.text_primary)].spacing(t.sz(8))) + .width(Length::Fill) + .height(Length::Fill) + .padding(t.sz(16) as u16) + .style(move |_| container::Style { + background: Some(p.bg_primary.into()), + ..Default::default() + }) + .into() + } +} + +fn main() -> iced::Result { + iced::application(App::default, App::update, App::view) + .title("Raven") + .default_font(theme::APP_FONT) + .run() +} diff --git a/crates/raven-gui/src/theme.rs b/crates/raven-gui/src/theme.rs new file mode 100644 index 0000000..e4c633b --- /dev/null +++ b/crates/raven-gui/src/theme.rs @@ -0,0 +1,31 @@ +//! What colony-ui needs to know about this program, and what it gives back. +//! +//! Every colour and every font size in the window comes through here. The org +//! rule is that a size is never a raw number: two independent user +//! preferences, Appearance and Accessibility, multiply into one scale, so the +//! layout has to survive 0.7225x to 1.68x, and `sz` is the only thing that +//! knows it. + +use colony_ui::{ThemePalette, Typography}; +use iced::Font; + +/// The org's application font. JetBrainsMono Nerd Font per +/// Project-Colony-Resources/design/typography.md. +pub const APP_FONT: Font = Font::with_name("JetBrainsMono Nerd Font"); + +/// The current palette, whichever theme the user picked. +pub fn palette() -> ThemePalette { + colony_ui::active_palette() +} + +/// The type scale. `scale` is 1.0 until the settings screen exists to change +/// it; the accessor is used everywhere from the start so adding that screen +/// later changes one function rather than every call site. +pub fn typography() -> Typography { + Typography { + scale: 1.0, + regular: APP_FONT, + medium: APP_FONT, + bold: APP_FONT, + } +} From 73d5c1a3328c03dc113a027cd3e29953fe0ac65b Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 2 Sep 2026 13:09:55 +0200 Subject: [PATCH 03/38] feat(gui): the view model, read off the interface thread Reading one environment opens /proc for every process on the machine, so no library call runs where it could stutter the window. The model is plain data with its own tests - status_line included, because '1 processes' is the kind of thing a widget tree test would never catch. --- Cargo.lock | 1 + crates/raven-gui/Cargo.toml | 1 + crates/raven-gui/src/load.rs | 32 +++++++++++ crates/raven-gui/src/main.rs | 30 ++++++++-- crates/raven-gui/src/model.rs | 105 ++++++++++++++++++++++++++++++++++ 5 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 crates/raven-gui/src/load.rs create mode 100644 crates/raven-gui/src/model.rs diff --git a/Cargo.lock b/Cargo.lock index 290d7c7..d7b3f39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2572,6 +2572,7 @@ dependencies = [ "colony-ui", "iced", "raven", + "tokio", ] [[package]] diff --git a/crates/raven-gui/Cargo.toml b/crates/raven-gui/Cargo.toml index ffad440..830edea 100644 --- a/crates/raven-gui/Cargo.toml +++ b/crates/raven-gui/Cargo.toml @@ -11,3 +11,4 @@ rust-version.workspace = true raven = { path = "../raven" } colony-ui = "0.1.4" iced = { version = "0.14.0", features = ["tokio", "advanced"] } +tokio = { version = "1", features = ["rt"] } diff --git a/crates/raven-gui/src/load.rs b/crates/raven-gui/src/load.rs new file mode 100644 index 0000000..cec6221 --- /dev/null +++ b/crates/raven-gui/src/load.rs @@ -0,0 +1,32 @@ +//! Calling the library without freezing the window. +//! +//! Reading one environment's state opens `/proc` for every process on the +//! machine. Doing that on the interface thread stutters the window, so every +//! library call in the program goes through this module. + +use iced::Task; + +use crate::Message; +use crate::model::{self, EnvRow}; + +/// Loads every environment's state on a blocking worker. +pub fn environments() -> Task { + Task::perform( + async { + tokio::task::spawn_blocking(|| { + raven::env::Environment::list() + .map(model::env_rows) + .map_err(|e| e.to_string()) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())) + }, + Message::Environments, + ) +} + +/// The shape every loader returns: the data, or a message already fit to show. +pub type Loaded = Result; + +/// Named so the signature above reads: `Task` carrying `Loaded>`. +pub type EnvRows = Loaded>; diff --git a/crates/raven-gui/src/main.rs b/crates/raven-gui/src/main.rs index 60e5122..61d8f5f 100644 --- a/crates/raven-gui/src/main.rs +++ b/crates/raven-gui/src/main.rs @@ -5,11 +5,15 @@ //! anything about environments; it draws what the library reports and asks the //! library to act. +mod load; +mod model; mod theme; use iced::widget::{column, container, text}; use iced::{Element, Length, Task}; +use model::EnvRow; + /// Which screen is showing. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Screen { @@ -22,11 +26,15 @@ pub enum Screen { #[derive(Debug, Clone)] pub enum Message { Go(Screen), + Environments(load::EnvRows), + Refresh, } #[derive(Default)] pub struct App { screen: Screen, + envs: Vec, + error: Option, } impl App { @@ -36,6 +44,16 @@ impl App { self.screen = screen; Task::none() } + Message::Environments(Ok(rows)) => { + self.envs = rows; + self.error = None; + Task::none() + } + Message::Environments(Err(e)) => { + self.error = Some(e); + Task::none() + } + Message::Refresh => load::environments(), } } @@ -55,8 +73,12 @@ impl App { } fn main() -> iced::Result { - iced::application(App::default, App::update, App::view) - .title("Raven") - .default_font(theme::APP_FONT) - .run() + iced::application( + || (App::default(), load::environments()), + App::update, + App::view, + ) + .title("Raven") + .default_font(theme::APP_FONT) + .run() } diff --git a/crates/raven-gui/src/model.rs b/crates/raven-gui/src/model.rs new file mode 100644 index 0000000..d7dd337 --- /dev/null +++ b/crates/raven-gui/src/model.rs @@ -0,0 +1,105 @@ +//! Plain data the screens draw. +//! +//! Kept apart from the drawing so it can be tested: an iced widget tree cannot +//! be meaningfully asserted on, and pretending otherwise produces tests that +//! pass whatever the window looks like. + +use std::path::PathBuf; + +use raven::d3d::{DXVK, VKD3D}; +use raven::env::Environment; + +/// One environment, as a card draws it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnvRow { + pub name: String, + pub base: String, + /// The session anchor's pid, when one is live. + pub session: Option, + /// How many processes hold the mount, the anchor included. + pub holders: usize, + /// The installed build, as the release named itself. + pub dxvk: Option, + pub vkd3d: Option, + /// Drive letter and the device behind it. + pub attachments: Vec<(char, PathBuf)>, +} + +impl EnvRow { + pub fn is_running(&self) -> bool { + self.session.is_some() + } + + /// The line under the environment's name. Written here rather than in the + /// view so it can be tested, and because "1 process" is not "1 processes". + pub fn status_line(&self) -> String { + if !self.is_running() { + return "not running".into(); + } + let plural = if self.holders == 1 { + "process" + } else { + "processes" + }; + format!("running - {} {plural}", self.holders) + } +} + +/// Reads every environment's state. Touches `/proc` for each process on the +/// machine and stats dozens of files, so this must not run on the interface +/// thread - `load::environments` is the only intended caller. +pub fn env_rows(envs: Vec) -> Vec { + envs.into_iter() + .map(|e| EnvRow { + name: e.name.clone(), + base: e.manifest.base.clone(), + session: e.session(), + holders: e.holders().len(), + dxvk: e.d3d_build(&DXVK), + vkd3d: e.d3d_build(&VKD3D), + attachments: e + .attachments() + .into_iter() + .map(|a| (a.letter, a.device)) + .collect(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_environment_with_a_session_is_marked_running() { + let row = EnvRow { + name: "games".into(), + base: "win11-26200-pro".into(), + session: Some(4242), + holders: 9, + dxvk: Some("dxvk-3.1".into()), + vkd3d: None, + attachments: vec![], + }; + assert!(row.is_running()); + assert_eq!(row.status_line(), "running - 9 processes"); + } + + #[test] + fn an_environment_without_one_says_so_in_the_singular_when_it_should() { + let mut row = EnvRow { + name: "games".into(), + base: "b".into(), + session: None, + holders: 0, + dxvk: None, + vkd3d: None, + attachments: vec![], + }; + assert!(!row.is_running()); + assert_eq!(row.status_line(), "not running"); + row.session = Some(1); + row.holders = 1; + assert_eq!(row.status_line(), "running - 1 process"); + } +} From b1779f6153b4ff0000c729e809af9d8ff634c524 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 2 Sep 2026 13:24:21 +0200 Subject: [PATCH 04/38] feat(gui): the environments screen, with start and stop on the card Starting is the most frequent action and env start is what makes launches instant, so it does not hide behind a detail view. --- crates/raven-gui/src/main.rs | 52 +++++++++---- crates/raven-gui/src/view/environments.rs | 91 +++++++++++++++++++++++ crates/raven-gui/src/view/mod.rs | 76 +++++++++++++++++++ 3 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 crates/raven-gui/src/view/environments.rs create mode 100644 crates/raven-gui/src/view/mod.rs diff --git a/crates/raven-gui/src/main.rs b/crates/raven-gui/src/main.rs index 61d8f5f..e897271 100644 --- a/crates/raven-gui/src/main.rs +++ b/crates/raven-gui/src/main.rs @@ -8,9 +8,9 @@ mod load; mod model; mod theme; +mod view; -use iced::widget::{column, container, text}; -use iced::{Element, Length, Task}; +use iced::{Element, Task}; use model::EnvRow; @@ -28,13 +28,16 @@ pub enum Message { Go(Screen), Environments(load::EnvRows), Refresh, + Start(String), + Stop(String), + Acted(Result<(), String>), } #[derive(Default)] pub struct App { - screen: Screen, - envs: Vec, - error: Option, + pub(crate) screen: Screen, + pub(crate) envs: Vec, + pub(crate) error: Option, } impl App { @@ -54,21 +57,38 @@ impl App { Task::none() } Message::Refresh => load::environments(), + Message::Start(name) => Task::perform( + async move { + tokio::task::spawn_blocking(move || { + let e = raven::env::Environment::open(&name).map_err(|e| e.to_string())?; + e.ensure_session().map(|_| ()).map_err(|e| e.to_string()) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())) + }, + Message::Acted, + ), + Message::Stop(name) => Task::perform( + async move { + tokio::task::spawn_blocking(move || { + let e = raven::env::Environment::open(&name).map_err(|e| e.to_string())?; + e.stop().map(|_| ()).map_err(|e| e.to_string()) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())) + }, + Message::Acted, + ), + Message::Acted(Ok(())) => load::environments(), + Message::Acted(Err(e)) => { + self.error = Some(e); + load::environments() + } } } fn view(&self) -> Element<'_, Message> { - let t = theme::typography(); - let p = theme::palette(); - container(column![text("Raven").size(t.sz(30)).color(p.text_primary)].spacing(t.sz(8))) - .width(Length::Fill) - .height(Length::Fill) - .padding(t.sz(16) as u16) - .style(move |_| container::Style { - background: Some(p.bg_primary.into()), - ..Default::default() - }) - .into() + view::shell(self) } } diff --git a/crates/raven-gui/src/view/environments.rs b/crates/raven-gui/src/view/environments.rs new file mode 100644 index 0000000..994dfb2 --- /dev/null +++ b/crates/raven-gui/src/view/environments.rs @@ -0,0 +1,91 @@ +//! One card per environment. Start and Stop sit on the card because starting +//! is the most frequent action and `env start` is what makes launches instant. + +use iced::widget::{Space, button, column, container, row, scrollable, text}; +use iced::{Element, Length}; + +use crate::Message; +use crate::model::EnvRow; +use crate::theme; + +pub fn screen(envs: &[EnvRow]) -> Element<'_, Message> { + let t = theme::typography(); + let p = theme::palette(); + + if envs.is_empty() { + return column![ + text("No environments yet") + .size(t.sz(22)) + .color(p.text_primary), + Space::new().height(t.sz(8)), + text("Create one with: raven env create --base ") + .size(t.sz(13)) + .color(p.text_muted), + ] + .into(); + } + + let cards = envs + .iter() + .fold(column![].spacing(t.sz(10)), |acc, e| acc.push(card(e))); + + scrollable(cards).height(Length::Fill).into() +} + +fn card(e: &EnvRow) -> Element<'_, Message> { + let t = theme::typography(); + let p = theme::palette(); + + let action = if e.is_running() { + button(text("Stop").size(t.sz(13))).on_press(Message::Stop(e.name.clone())) + } else { + button(text("Start").size(t.sz(13))).on_press(Message::Start(e.name.clone())) + }; + + let mut runtimes = row![].spacing(t.sz(8)); + if let Some(v) = &e.dxvk { + runtimes = runtimes.push(text(v.clone()).size(t.sz(11)).color(p.text_muted)); + } + if let Some(v) = &e.vkd3d { + runtimes = runtimes.push(text(v.clone()).size(t.sz(11)).color(p.text_muted)); + } + for (letter, device) in &e.attachments { + runtimes = runtimes.push( + text(format!("{letter}: {}", device.display())) + .size(t.sz(11)) + .color(p.text_muted), + ); + } + + container( + row![ + column![ + text(e.name.clone()).size(t.sz(16)).color(p.text_primary), + text(e.base.clone()).size(t.sz(11)).color(p.text_muted), + text(e.status_line()) + .size(t.sz(12)) + .color(if e.is_running() { + p.success + } else { + p.text_muted + }), + runtimes, + ] + .spacing(t.sz(3)) + .width(Length::Fill), + action, + ] + .align_y(iced::Alignment::Center), + ) + .padding(t.sz(12) as u16) + .style(move |_| container::Style { + background: Some(p.bg_card.into()), + border: iced::Border { + color: p.border_subtle, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }) + .into() +} diff --git a/crates/raven-gui/src/view/mod.rs b/crates/raven-gui/src/view/mod.rs new file mode 100644 index 0000000..8d224bf --- /dev/null +++ b/crates/raven-gui/src/view/mod.rs @@ -0,0 +1,76 @@ +//! Drawing. Nothing in here decides anything - `update` does that. + +pub mod environments; + +use iced::widget::{Space, button, column, container, row, text}; +use iced::{Element, Length}; + +use crate::theme; +use crate::{App, Message, Screen}; + +/// The sidebar and whichever screen is selected. +pub fn shell(app: &App) -> Element<'_, Message> { + let t = theme::typography(); + let p = theme::palette(); + + let item = |label: &'static str, screen: Screen, current: Screen| { + button(text(label).size(t.sz(13))) + .width(Length::Fill) + .style(move |_, _| button::Style { + background: Some( + if screen == current { + p.bg_selected + } else { + p.bg_sidebar + } + .into(), + ), + text_color: if screen == current { + p.accent_blue + } else { + p.text_secondary + }, + ..Default::default() + }) + .on_press(Message::Go(screen)) + }; + + let sidebar = container( + column![ + text("Raven").size(t.sz(20)).color(p.text_primary), + Space::new().height(t.sz(12)), + item("Environments", Screen::Environments, app.screen), + item("Bases", Screen::Bases, app.screen), + item("Diagnostics", Screen::Doctor, app.screen), + ] + .spacing(t.sz(4)), + ) + .width(Length::Fixed(t.sz(180))) + .height(Length::Fill) + .padding(t.sz(12) as u16) + .style(move |_| container::Style { + background: Some(p.bg_sidebar.into()), + ..Default::default() + }); + + let body = match app.screen { + Screen::Environments => environments::screen(&app.envs), + Screen::Bases => text("Bases").size(t.sz(22)).color(p.text_primary).into(), + Screen::Doctor => text("Diagnostics") + .size(t.sz(22)) + .color(p.text_primary) + .into(), + }; + + container(row![ + sidebar, + container(body).padding(t.sz(16) as u16).width(Length::Fill) + ]) + .width(Length::Fill) + .height(Length::Fill) + .style(move |_| container::Style { + background: Some(p.bg_primary.into()), + ..Default::default() + }) + .into() +} From b9bb4848f1726dbd98f1851069cc05f50685e607 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 2 Sep 2026 13:57:54 +0200 Subject: [PATCH 05/38] feat(gui): errors offer the action instead of naming a command Raven's messages are written for a terminal and several end with a command to run. In a window that is an admission of failure, so the ones with an obvious action become a button. The rest keep their own words - the text is good, and a paraphrase would lose the part that explains why. --- crates/raven-gui/src/errors.rs | 66 ++++++++++++++++++++++++++++++++ crates/raven-gui/src/main.rs | 32 ++++++++++------ crates/raven-gui/src/view/mod.rs | 27 ++++++++++++- 3 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 crates/raven-gui/src/errors.rs diff --git a/crates/raven-gui/src/errors.rs b/crates/raven-gui/src/errors.rs new file mode 100644 index 0000000..5435f28 --- /dev/null +++ b/crates/raven-gui/src/errors.rs @@ -0,0 +1,66 @@ +//! Turning a library error into something a window can offer. +//! +//! Raven's messages are written for a terminal and several carry a command to +//! run. Telling someone using a window to open a terminal is an admission of +//! failure, so the errors with an obvious action become that action. The rest +//! keep their own words: the text is good, and a GUI-flavoured paraphrase +//! would lose the part that explains *why*. + +use raven::Error; + +/// What the window offers to do about an error. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + Stop(String), +} + +/// A message to show, and optionally a button to show beside it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Offer { + pub message: String, + pub action: Option, +} + +pub fn explain(error: &Error) -> Offer { + match error { + Error::SessionHolds(name) => Offer { + message: format!( + "A session is holding {name}'s C:, which is what makes launches fast." + ), + action: Some(Action::Stop(name.clone())), + }, + Error::EnvironmentBusy { name, holders } => Offer { + message: format!("{name} is still in use by {holders}."), + action: Some(Action::Stop(name.clone())), + }, + other => Offer { + message: other.to_string(), + action: None, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use raven::Error; + + #[test] + fn a_session_holding_an_environment_offers_to_stop_it() { + let offer = explain(&Error::SessionHolds("games".into())); + assert_eq!(offer.action, Some(Action::Stop("games".into()))); + assert!( + !offer.message.contains("raven env stop"), + "the button replaces the command; repeating it is noise: {}", + offer.message + ); + } + + #[test] + fn an_error_with_no_obvious_action_keeps_its_own_words() { + let e = Error::NoWine; + let offer = explain(&e); + assert_eq!(offer.action, None); + assert_eq!(offer.message, e.to_string()); + } +} diff --git a/crates/raven-gui/src/main.rs b/crates/raven-gui/src/main.rs index e897271..2dd4e2f 100644 --- a/crates/raven-gui/src/main.rs +++ b/crates/raven-gui/src/main.rs @@ -5,6 +5,7 @@ //! anything about environments; it draws what the library reports and asks the //! library to act. +mod errors; mod load; mod model; mod theme; @@ -30,14 +31,18 @@ pub enum Message { Refresh, Start(String), Stop(String), - Acted(Result<(), String>), + // `raven::Error` holds `std::io::Error` in some variants, so it cannot be + // `Clone` - and iced's widgets (`Button::on_press` among them) require + // `Message: Clone`. `Arc` is `Clone` regardless of what it wraps, so it + // carries the error across that boundary without the library changing. + Acted(Result<(), std::sync::Arc>), } #[derive(Default)] pub struct App { pub(crate) screen: Screen, pub(crate) envs: Vec, - pub(crate) error: Option, + pub(crate) offer: Option, } impl App { @@ -49,39 +54,44 @@ impl App { } Message::Environments(Ok(rows)) => { self.envs = rows; - self.error = None; + self.offer = None; Task::none() } Message::Environments(Err(e)) => { - self.error = Some(e); + self.offer = Some(errors::Offer { + message: e, + action: None, + }); Task::none() } Message::Refresh => load::environments(), Message::Start(name) => Task::perform( async move { tokio::task::spawn_blocking(move || { - let e = raven::env::Environment::open(&name).map_err(|e| e.to_string())?; - e.ensure_session().map(|_| ()).map_err(|e| e.to_string()) + let e = raven::env::Environment::open(&name)?; + e.ensure_session().map(|_| ()) }) .await - .unwrap_or_else(|e| Err(e.to_string())) + .expect("the blocking task panicked") + .map_err(std::sync::Arc::new) }, Message::Acted, ), Message::Stop(name) => Task::perform( async move { tokio::task::spawn_blocking(move || { - let e = raven::env::Environment::open(&name).map_err(|e| e.to_string())?; - e.stop().map(|_| ()).map_err(|e| e.to_string()) + let e = raven::env::Environment::open(&name)?; + e.stop().map(|_| ()) }) .await - .unwrap_or_else(|e| Err(e.to_string())) + .expect("the blocking task panicked") + .map_err(std::sync::Arc::new) }, Message::Acted, ), Message::Acted(Ok(())) => load::environments(), Message::Acted(Err(e)) => { - self.error = Some(e); + self.offer = Some(errors::explain(&e)); load::environments() } } diff --git a/crates/raven-gui/src/view/mod.rs b/crates/raven-gui/src/view/mod.rs index 8d224bf..60278dd 100644 --- a/crates/raven-gui/src/view/mod.rs +++ b/crates/raven-gui/src/view/mod.rs @@ -62,9 +62,34 @@ pub fn shell(app: &App) -> Element<'_, Message> { .into(), }; + let banner: Element<'_, Message> = match &app.offer { + None => Space::new().height(0).into(), + Some(offer) => { + let mut r = row![text(offer.message.clone()).size(t.sz(13)).color(p.error)] + .spacing(t.sz(8)) + .align_y(iced::Alignment::Center); + if let Some(crate::errors::Action::Stop(name)) = &offer.action { + r = r.push( + button(text("Stop the session").size(t.sz(12))) + .on_press(Message::Stop(name.clone())), + ); + } + container(r) + .padding(t.sz(10) as u16) + .width(Length::Fill) + .style(move |_| container::Style { + background: Some(p.error_bg.into()), + ..Default::default() + }) + .into() + } + }; + container(row![ sidebar, - container(body).padding(t.sz(16) as u16).width(Length::Fill) + container(column![banner, body].spacing(t.sz(8))) + .padding(t.sz(16) as u16) + .width(Length::Fill) ]) .width(Length::Fill) .height(Length::Fill) From 71b9d38e2df1c84d2d3e3c6a37ea04d68b88ecfa Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 2 Sep 2026 14:09:29 +0200 Subject: [PATCH 06/38] feat(gui): the environment detail - session, Direct3D, devices, registry Install needs a file and this batch has no file dialog, so the button says which command takes one rather than pretending. That is a dependency decision worth making deliberately, not while building a screen. --- crates/raven-gui/src/main.rs | 63 +++++++++++- crates/raven-gui/src/view/detail.rs | 116 ++++++++++++++++++++++ crates/raven-gui/src/view/environments.rs | 7 +- crates/raven-gui/src/view/mod.rs | 26 +++-- 4 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 crates/raven-gui/src/view/detail.rs diff --git a/crates/raven-gui/src/main.rs b/crates/raven-gui/src/main.rs index 2dd4e2f..3fd9ca4 100644 --- a/crates/raven-gui/src/main.rs +++ b/crates/raven-gui/src/main.rs @@ -16,10 +16,11 @@ use iced::{Element, Task}; use model::EnvRow; /// Which screen is showing. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum Screen { #[default] Environments, + Detail(String), Bases, Doctor, } @@ -36,6 +37,11 @@ pub enum Message { // `Message: Clone`. `Arc` is `Clone` regardless of what it wraps, so it // carries the error across that boundary without the library changing. Acted(Result<(), std::sync::Arc>), + Open(String), + InstallD3d { env: String, vkd3d: bool }, + RemoveD3d { env: String, vkd3d: bool }, + Detach { env: String, letter: char }, + Reproject(String), } #[derive(Default)] @@ -52,6 +58,10 @@ impl App { self.screen = screen; Task::none() } + Message::Open(name) => { + self.screen = Screen::Detail(name); + Task::none() + } Message::Environments(Ok(rows)) => { self.envs = rows; self.offer = None; @@ -94,6 +104,57 @@ impl App { self.offer = Some(errors::explain(&e)); load::environments() } + Message::InstallD3d { env, vkd3d } => { + let which = if vkd3d { "vkd3d" } else { "dxvk" }; + self.offer = Some(errors::Offer { + message: format!( + "Install it from a build you already have: raven env {which} {env} --from " + ), + action: None, + }); + Task::none() + } + Message::RemoveD3d { env, vkd3d } => Task::perform( + async move { + tokio::task::spawn_blocking(move || { + let rt = if vkd3d { + &raven::d3d::VKD3D + } else { + &raven::d3d::DXVK + }; + let e = raven::env::Environment::open(&env)?; + e.remove_d3d(rt).map(|_| ()) + }) + .await + .expect("the blocking task panicked") + .map_err(std::sync::Arc::new) + }, + Message::Acted, + ), + Message::Detach { env, letter } => Task::perform( + async move { + tokio::task::spawn_blocking(move || { + raven::env::Environment::open(&env)?.detach(letter) + }) + .await + .expect("the blocking task panicked") + .map_err(std::sync::Arc::new) + }, + Message::Acted, + ), + Message::Reproject(env) => Task::perform( + async move { + tokio::task::spawn_blocking(move || { + raven::env::Environment::open(&env)? + .project_registry() + .map(|_| ()) + }) + .await + .expect("the blocking task panicked") + .map_err(std::sync::Arc::new) + }, + Message::Acted, + ), } } diff --git a/crates/raven-gui/src/view/detail.rs b/crates/raven-gui/src/view/detail.rs new file mode 100644 index 0000000..11d36c8 --- /dev/null +++ b/crates/raven-gui/src/view/detail.rs @@ -0,0 +1,116 @@ +//! One environment, in full: its session, its Direct3D runtimes, its devices +//! and its registry rules. + +use iced::widget::{Space, button, column, container, row, text}; +use iced::{Element, Length}; + +use crate::Message; +use crate::model::EnvRow; +use crate::theme; + +pub fn screen(e: &EnvRow) -> Element<'_, Message> { + let t = theme::typography(); + let p = theme::palette(); + + let heading = |s: &'static str| text(s).size(t.sz(14)).color(p.text_secondary); + + let session = column![ + heading("Session"), + text(e.status_line()).size(t.sz(13)).color(p.text_primary), + if e.is_running() { + button(text("Stop").size(t.sz(12))).on_press(Message::Stop(e.name.clone())) + } else { + button(text("Start").size(t.sz(12))).on_press(Message::Start(e.name.clone())) + }, + ] + .spacing(t.sz(6)); + + let runtime = |label: &'static str, build: &Option, vkd3d: bool| { + let name = e.name.clone(); + match build { + Some(v) => row![ + text(format!("{label}: {v}")) + .size(t.sz(13)) + .color(p.text_primary), + button(text("Remove").size(t.sz(12))) + .on_press(Message::RemoveD3d { env: name, vkd3d }), + ], + None => row![ + text(format!("{label}: not installed")) + .size(t.sz(13)) + .color(p.text_muted), + button(text("Install…").size(t.sz(12))) + .on_press(Message::InstallD3d { env: name, vkd3d }), + ], + } + .spacing(t.sz(8)) + .align_y(iced::Alignment::Center) + }; + + let d3d = column![ + heading("Direct3D"), + runtime("DXVK (D3D 8-11)", &e.dxvk, false), + runtime("vkd3d-proton (D3D 12)", &e.vkd3d, true), + text("They are separate runtimes, not versions of one.") + .size(t.sz(11)) + .color(p.text_muted), + ] + .spacing(t.sz(6)); + + let mut devices = column![heading("Devices")].spacing(t.sz(6)); + if e.attachments.is_empty() { + devices = devices.push( + text("None attached. Attach one with: raven env attach /dev/sdX") + .size(t.sz(12)) + .color(p.text_muted), + ); + } else { + for (letter, device) in &e.attachments { + devices = devices.push( + row![ + text(format!("{letter}: {}", device.display())) + .size(t.sz(13)) + .color(p.text_primary), + button(text("Detach").size(t.sz(12))).on_press(Message::Detach { + env: e.name.clone(), + letter: *letter, + }), + ] + .spacing(t.sz(8)) + .align_y(iced::Alignment::Center), + ); + } + } + + let registry = column![ + heading("Registry"), + text("Re-run the projection after editing registry-rules.toml.") + .size(t.sz(12)) + .color(p.text_muted), + button(text("Reproject").size(t.sz(12))).on_press(Message::Reproject(e.name.clone())), + ] + .spacing(t.sz(6)); + + container( + column![ + row![ + button(text("← Environments").size(t.sz(12))) + .on_press(Message::Go(crate::Screen::Environments)), + Space::new().width(Length::Fill), + ], + text(e.name.clone()).size(t.sz(22)).color(p.text_primary), + text(e.base.clone()).size(t.sz(12)).color(p.text_muted), + Space::new().height(t.sz(12)), + session, + Space::new().height(t.sz(12)), + d3d, + Space::new().height(t.sz(12)), + devices, + Space::new().height(t.sz(12)), + registry, + ] + .spacing(t.sz(4)), + ) + .width(Length::Fill) + .into() +} diff --git a/crates/raven-gui/src/view/environments.rs b/crates/raven-gui/src/view/environments.rs index 994dfb2..a6746bc 100644 --- a/crates/raven-gui/src/view/environments.rs +++ b/crates/raven-gui/src/view/environments.rs @@ -57,10 +57,15 @@ fn card(e: &EnvRow) -> Element<'_, Message> { ); } + let name = button(text(e.name.clone()).size(t.sz(16)).color(p.text_primary)) + .style(|_, _| button::Style::default()) + .padding(0.0) + .on_press(Message::Open(e.name.clone())); + container( row![ column![ - text(e.name.clone()).size(t.sz(16)).color(p.text_primary), + name, text(e.base.clone()).size(t.sz(11)).color(p.text_muted), text(e.status_line()) .size(t.sz(12)) diff --git a/crates/raven-gui/src/view/mod.rs b/crates/raven-gui/src/view/mod.rs index 60278dd..7bec1f2 100644 --- a/crates/raven-gui/src/view/mod.rs +++ b/crates/raven-gui/src/view/mod.rs @@ -1,5 +1,6 @@ //! Drawing. Nothing in here decides anything - `update` does that. +pub mod detail; pub mod environments; use iced::widget::{Space, button, column, container, row, text}; @@ -13,19 +14,24 @@ pub fn shell(app: &App) -> Element<'_, Message> { let t = theme::typography(); let p = theme::palette(); - let item = |label: &'static str, screen: Screen, current: Screen| { + // `Screen::Detail` carries a `String`, so `Screen` is no longer `Copy`; + // `current` is taken by reference so the same `&app.screen` can feed all + // three calls below, and `selected` is settled before the `move` closure + // so `screen` stays available afterward for `on_press`. + let item = |label: &'static str, screen: Screen, current: &Screen| { + let selected = screen == *current; button(text(label).size(t.sz(13))) .width(Length::Fill) .style(move |_, _| button::Style { background: Some( - if screen == current { + if selected { p.bg_selected } else { p.bg_sidebar } .into(), ), - text_color: if screen == current { + text_color: if selected { p.accent_blue } else { p.text_secondary @@ -39,9 +45,9 @@ pub fn shell(app: &App) -> Element<'_, Message> { column![ text("Raven").size(t.sz(20)).color(p.text_primary), Space::new().height(t.sz(12)), - item("Environments", Screen::Environments, app.screen), - item("Bases", Screen::Bases, app.screen), - item("Diagnostics", Screen::Doctor, app.screen), + item("Environments", Screen::Environments, &app.screen), + item("Bases", Screen::Bases, &app.screen), + item("Diagnostics", Screen::Doctor, &app.screen), ] .spacing(t.sz(4)), ) @@ -53,8 +59,14 @@ pub fn shell(app: &App) -> Element<'_, Message> { ..Default::default() }); - let body = match app.screen { + let body = match &app.screen { Screen::Environments => environments::screen(&app.envs), + Screen::Detail(name) => match app.envs.iter().find(|e| &e.name == name) { + Some(e) => detail::screen(e), + // The environment was destroyed from elsewhere between the click and + // the draw. Falling back is better than an empty page. + None => environments::screen(&app.envs), + }, Screen::Bases => text("Bases").size(t.sz(22)).color(p.text_primary).into(), Screen::Doctor => text("Diagnostics") .size(t.sz(22)) From ebe296dc20f244415b0206feaac33510267e2a68 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 2 Sep 2026 14:26:46 +0200 Subject: [PATCH 07/38] feat(gui): bases, and a deployment progress bar that reports real progress The only long operation in Raven, and minutes of silence in the CLI. The GUI spawns the CLI and parses wimlib's own output - no library change was needed, because .status() inherits the parent's stdio and wimlib does not suppress progress when piped. Both format strings are pinned by a test against output captured from a real apply. --- crates/raven-gui/src/deploy.rs | 183 +++++++++++++++++++++++++++++ crates/raven-gui/src/load.rs | 23 +++- crates/raven-gui/src/main.rs | 122 ++++++++++++++++++- crates/raven-gui/src/model.rs | 18 +++ crates/raven-gui/src/view/bases.rs | 179 ++++++++++++++++++++++++++++ crates/raven-gui/src/view/mod.rs | 3 +- 6 files changed, 523 insertions(+), 5 deletions(-) create mode 100644 crates/raven-gui/src/deploy.rs create mode 100644 crates/raven-gui/src/view/bases.rs diff --git a/crates/raven-gui/src/deploy.rs b/crates/raven-gui/src/deploy.rs new file mode 100644 index 0000000..5f30792 --- /dev/null +++ b/crates/raven-gui/src/deploy.rs @@ -0,0 +1,183 @@ +//! Deploying a base, and reading how far it has got. +//! +//! `base deploy` is the only long operation in Raven - minutes over 143 886 +//! files - so the GUI spawns `raven base deploy` rather than calling the +//! library, and reads wimlib's progress out of the child's output. +//! +//! No library change was needed for this. `.status()` inherits the parent's +//! stdio, so a piped stdout reaches `wimlib-imagex` unchanged, and wimlib does +//! not suppress progress when its output is not a terminal - verified before +//! this was written. +//! +//! The two shapes below are wimlib's own format strings, read from the +//! `wimlib-imagex` binary: +//! +//! ```text +//! Creating files: %lu of %lu (%u%%) done +//! Extracting file data: %lu %s of %lu %s (%u%%) done +//! ``` + +use std::io::Read; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use iced::futures::Stream; +use iced::futures::channel::mpsc; + +use crate::Message; + +/// How far a deployment has got. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Progress { + /// 0 to 100. + pub percent: u8, + /// Which phase wimlib is in, as it names it. + pub what: String, +} + +/// Reads one line of wimlib output. `None` for anything that is not progress. +/// +/// Matched by shape rather than by regex: the phase name, then a bracketed +/// percentage, then the word `done`. A stray percentage elsewhere in a line is +/// not progress and must not be read as any. +pub fn parse_progress(line: &str) -> Option { + let line = line.trim(); + let rest = line.strip_suffix(" done")?; + let (head, pct) = rest.rsplit_once(" (")?; + let percent: u8 = pct.strip_suffix("%)")?.parse().ok()?; + let what = head.split_once(':')?.0; + if what.is_empty() { + return None; + } + Some(Progress { + percent: percent.min(100), + what: what.to_string(), + }) +} + +/// What `raven base deploy` needs, gathered from the bases screen's three +/// text fields since this batch has no file dialog. +/// +/// Doubles as the identity `Subscription::run_with` hashes on: while the same +/// deployment is in flight, the stream survives repeated `view` calls rather +/// than being torn down and restarted. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Args { + pub image: PathBuf, + pub edition: u32, + pub name: String, +} + +/// Spawns `raven base deploy` and turns its stdout into progress messages. +/// +/// The intended target of `Subscription::run_with(args, deploy::run)`. Runs +/// the child on a blocking worker - spawning, reading a pipe and waiting are +/// all blocking calls - and forwards what it reads back through `output`. +/// +/// `+ use<>` tells the 2024 capture rules the returned stream does not borrow +/// `args` - it is cloned on the next line - so the opaque type stays a plain +/// `S`, which is what `Subscription::run_with`'s `fn(&D) -> S` needs; without +/// it the type is `for<'a> fn(&'a Args) -> S<'a>`, which cannot coerce there. +pub fn run(args: &Args) -> impl Stream + use<> { + let args = args.clone(); + iced::stream::channel(16, async move |output| { + tokio::task::spawn_blocking(move || pump(&args, output)) + .await + .expect("the blocking task panicked"); + }) +} + +/// Runs the child to completion, reporting wimlib's progress as it goes. +/// +/// wimlib redraws its progress line in place with `\r` rather than emitting +/// one `\n`-terminated line per update, so each chunk read off the pipe is +/// split on both and only the last complete reading is reported - the ones +/// before it were already stale by the time the read returned. +fn pump(args: &Args, mut output: mpsc::Sender) { + let child = Command::new("raven") + .arg("base") + .arg("deploy") + .arg("--image") + .arg(&args.image) + .arg("--edition") + .arg(args.edition.to_string()) + .arg("--name") + .arg(&args.name) + .stdout(Stdio::piped()) + .spawn(); + + let mut child = match child { + Ok(child) => child, + Err(_) => { + let _ = output.try_send(Message::DeployDone(false)); + return; + } + }; + + let mut stdout = child.stdout.take().expect("stdout was requested piped"); + let mut buf = [0u8; 4096]; + loop { + let n = match stdout.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => n, + }; + let chunk = String::from_utf8_lossy(&buf[..n]); + if let Some(progress) = chunk + .split(['\r', '\n']) + .filter_map(parse_progress) + .next_back() + { + let _ = output.try_send(Message::DeployProgress(progress)); + } + } + + let success = child.wait().map(|status| status.success()).unwrap_or(false); + let _ = output.try_send(Message::DeployDone(success)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_two_shapes_wimlib_emits_are_both_understood() { + // Captured from `wimlib-imagex apply` with its output piped, which is + // how the GUI will always see it. + let extracting = parse_progress("Extracting file data: 3886 KiB of 3906 KiB (99%) done"); + assert_eq!( + extracting, + Some(Progress { + percent: 99, + what: "Extracting file data".into() + }) + ); + let creating = parse_progress("Creating files: 12345 of 143886 (8%) done"); + assert_eq!( + creating, + Some(Progress { + percent: 8, + what: "Creating files".into() + }) + ); + } + + #[test] + fn anything_else_is_not_progress() { + assert_eq!(parse_progress(""), None); + assert_eq!(parse_progress("Applying image 1 to /home/x/base"), None); + // A percentage that is not wimlib's shape must not be mistaken for one. + assert_eq!(parse_progress("almost (50%) there"), None); + } + + // The brief's exact `.last()` triggers clippy::double_ended_iterator_last + // (it would be `.next_back()` in code that ships); kept verbatim since the + // brief's test text is authoritative, and allowed rather than edited. + #[allow(clippy::double_ended_iterator_last)] + #[test] + fn a_carriage_return_stream_yields_the_last_complete_line() { + // wimlib redraws in place with \r rather than emitting new lines. + let chunk = "Extracting file data: 1 KiB of 10 KiB (10%) done\rExtracting file data: 5 KiB of 10 KiB (50%) done\r"; + let last = chunk.split('\r').filter_map(parse_progress).last(); + assert_eq!(last.map(|p| p.percent), Some(50)); + } +} diff --git a/crates/raven-gui/src/load.rs b/crates/raven-gui/src/load.rs index cec6221..c946418 100644 --- a/crates/raven-gui/src/load.rs +++ b/crates/raven-gui/src/load.rs @@ -7,7 +7,7 @@ use iced::Task; use crate::Message; -use crate::model::{self, EnvRow}; +use crate::model::{self, BaseRow, EnvRow}; /// Loads every environment's state on a blocking worker. pub fn environments() -> Task { @@ -25,8 +25,29 @@ pub fn environments() -> Task { ) } +/// Loads every base and, so each one's count is right, every environment. +/// Touches the same `/proc` state `environments` does, so this must not run +/// on the interface thread either. +pub fn bases() -> Task { + Task::perform( + async { + tokio::task::spawn_blocking(|| -> Loaded> { + let bases = raven::base::Base::list().map_err(|e| e.to_string())?; + let envs = raven::env::Environment::list().map_err(|e| e.to_string())?; + Ok(model::base_rows(bases, &model::env_rows(envs))) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())) + }, + Message::Bases, + ) +} + /// The shape every loader returns: the data, or a message already fit to show. pub type Loaded = Result; /// Named so the signature above reads: `Task` carrying `Loaded>`. pub type EnvRows = Loaded>; + +/// Named so the signature above reads: `Task` carrying `Loaded>`. +pub type BaseRows = Loaded>; diff --git a/crates/raven-gui/src/main.rs b/crates/raven-gui/src/main.rs index 3fd9ca4..ce528b5 100644 --- a/crates/raven-gui/src/main.rs +++ b/crates/raven-gui/src/main.rs @@ -5,15 +5,18 @@ //! anything about environments; it draws what the library reports and asks the //! library to act. +mod deploy; mod errors; mod load; mod model; mod theme; mod view; -use iced::{Element, Task}; +use std::path::PathBuf; -use model::EnvRow; +use iced::{Element, Subscription, Task}; + +use model::{BaseRow, EnvRow}; /// Which screen is showing. #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -25,10 +28,20 @@ pub enum Screen { Doctor, } +/// The bases screen's three text fields, since this batch has no file dialog: +/// an image path typed or pasted, an edition index, and a name. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct DeployForm { + pub image: String, + pub edition: String, + pub name: String, +} + #[derive(Debug, Clone)] pub enum Message { Go(Screen), Environments(load::EnvRows), + Bases(load::BaseRows), Refresh, Start(String), Stop(String), @@ -42,21 +55,42 @@ pub enum Message { RemoveD3d { env: String, vkd3d: bool }, Detach { env: String, letter: char }, Reproject(String), + DeployImageChanged(String), + DeployEditionChanged(String), + DeployNameChanged(String), + DeployStart, + DeployProgress(deploy::Progress), + DeployDone(bool), } #[derive(Default)] pub struct App { pub(crate) screen: Screen, pub(crate) envs: Vec, + pub(crate) bases: Vec, pub(crate) offer: Option, + pub(crate) deploy_form: DeployForm, + /// The bar the bases screen draws, when a deployment is running. + pub(crate) deploying: Option, + /// What the running deployment was started with - kept so `subscription` + /// can keep pointing `Subscription::run_with` at the same job, and `None` + /// once it's over. Distinct from `deploying`: that one is the drawn + /// state, cleared to show the bar at all; this one is the stream's + /// identity, and would restart the job if it changed mid-flight. + deploy_job: Option, } impl App { fn update(&mut self, message: Message) -> Task { match message { Message::Go(screen) => { + let task = if screen == Screen::Bases { + load::bases() + } else { + Task::none() + }; self.screen = screen; - Task::none() + task } Message::Open(name) => { self.screen = Screen::Detail(name); @@ -74,6 +108,18 @@ impl App { }); Task::none() } + Message::Bases(Ok(rows)) => { + self.bases = rows; + self.offer = None; + Task::none() + } + Message::Bases(Err(e)) => { + self.offer = Some(errors::Offer { + message: e, + action: None, + }); + Task::none() + } Message::Refresh => load::environments(), Message::Start(name) => Task::perform( async move { @@ -155,6 +201,75 @@ impl App { }, Message::Acted, ), + Message::DeployImageChanged(image) => { + self.deploy_form.image = image; + Task::none() + } + Message::DeployEditionChanged(edition) => { + self.deploy_form.edition = edition; + Task::none() + } + Message::DeployNameChanged(name) => { + self.deploy_form.name = name; + Task::none() + } + Message::DeployStart => { + let form = &self.deploy_form; + match form.edition.trim().parse::() { + Ok(edition) + if !form.image.trim().is_empty() && !form.name.trim().is_empty() => + { + self.deploy_job = Some(deploy::Args { + image: PathBuf::from(form.image.trim()), + edition, + name: form.name.trim().to_owned(), + }); + self.deploying = Some(deploy::Progress { + percent: 0, + what: "Starting".into(), + }); + self.offer = None; + } + _ => { + self.offer = Some(errors::Offer { + message: "Fill in the image path, a numeric edition, and a name." + .into(), + action: None, + }); + } + } + Task::none() + } + Message::DeployProgress(progress) => { + self.deploying = Some(progress); + Task::none() + } + Message::DeployDone(success) => { + self.deploy_job = None; + self.deploying = None; + if success { + self.deploy_form = DeployForm::default(); + load::bases() + } else { + self.offer = Some(errors::Offer { + message: "Deployment failed. Run the same `raven base deploy` from a terminal to see why." + .into(), + action: None, + }); + Task::none() + } + } + } + } + + /// While a deployment is running, keeps its stream alive; otherwise + /// subscribes to nothing. `deploy_job` - not `deploying` - is the + /// identity `Subscription::run_with` hashes on, so editing the progress + /// text alone can never be mistaken for a new job. + fn subscription(&self) -> Subscription { + match &self.deploy_job { + Some(args) => Subscription::run_with(args.clone(), deploy::run), + None => Subscription::none(), } } @@ -169,6 +284,7 @@ fn main() -> iced::Result { App::update, App::view, ) + .subscription(App::subscription) .title("Raven") .default_font(theme::APP_FONT) .run() diff --git a/crates/raven-gui/src/model.rs b/crates/raven-gui/src/model.rs index d7dd337..51835a4 100644 --- a/crates/raven-gui/src/model.rs +++ b/crates/raven-gui/src/model.rs @@ -66,6 +66,24 @@ pub fn env_rows(envs: Vec) -> Vec { .collect() } +/// One deployed Windows, as the bases screen draws it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BaseRow { + pub id: String, + /// How many environments run against it, so destroying one is informed. + pub environments: usize, +} + +pub fn base_rows(bases: Vec, envs: &[EnvRow]) -> Vec { + bases + .into_iter() + .map(|b| BaseRow { + environments: envs.iter().filter(|e| e.base == b.id).count(), + id: b.id, + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/raven-gui/src/view/bases.rs b/crates/raven-gui/src/view/bases.rs new file mode 100644 index 0000000..846fcb2 --- /dev/null +++ b/crates/raven-gui/src/view/bases.rs @@ -0,0 +1,179 @@ +//! Deployed bases, and the form and progress bar for deploying one more. +//! +//! Deploying is the only long operation in Raven - minutes of silence over +//! 143 886 files in the CLI - so this is the one screen with a +//! `progress_bar`: real feedback for the one wait a user cannot otherwise +//! tell from a hang. + +use iced::widget::{ + Space, button, column, container, progress_bar, row, scrollable, text, text_input, +}; +use iced::{Element, Length}; + +use crate::deploy::Progress; +use crate::model::BaseRow; +use crate::theme; +use crate::{DeployForm, Message}; + +pub fn screen<'a>( + bases: &'a [BaseRow], + deploying: Option<&'a Progress>, + form: &'a DeployForm, +) -> Element<'a, Message> { + let t = theme::typography(); + let p = theme::palette(); + + let list: Element<'_, Message> = if bases.is_empty() { + text("No bases yet") + .size(t.sz(13)) + .color(p.text_muted) + .into() + } else { + let cards = bases + .iter() + .fold(column![].spacing(t.sz(10)), |acc, b| acc.push(card(b))); + scrollable(cards).height(Length::Fill).into() + }; + + container( + column![ + text("Bases").size(t.sz(22)).color(p.text_primary), + Space::new().height(t.sz(8)), + list, + Space::new().height(t.sz(16)), + deploy_form(deploying, form), + ] + .spacing(t.sz(4)), + ) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn card(b: &BaseRow) -> Element<'_, Message> { + let t = theme::typography(); + let p = theme::palette(); + + let plural = if b.environments == 1 { + "environment" + } else { + "environments" + }; + + container( + column![ + text(b.id.clone()).size(t.sz(16)).color(p.text_primary), + text(format!("{} {plural}", b.environments)) + .size(t.sz(12)) + .color(p.text_muted), + ] + .spacing(t.sz(3)), + ) + .padding(t.sz(12) as u16) + .width(Length::Fill) + .style(move |_| container::Style { + background: Some(p.bg_card.into()), + border: iced::Border { + color: p.border_subtle, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }) + .into() +} + +/// The three fields a deployment needs, and the Deploy button. Fields are +/// disabled while a deployment is running: `Subscription::run_with` keys on +/// them, so editing one mid-flight would restart the job rather than change +/// it. +fn deploy_form<'a>(deploying: Option<&'a Progress>, form: &'a DeployForm) -> Element<'a, Message> { + let t = theme::typography(); + let p = theme::palette(); + let busy = deploying.is_some(); + + let field = + move |placeholder: &'static str, value: &'a str, on_input: fn(String) -> Message| { + text_input(placeholder, value) + .size(t.sz(13)) + .padding(t.sz(8) as u16) + .on_input_maybe((!busy).then_some(on_input)) + .style(move |_, _| text_input::Style { + background: p.bg_input.into(), + border: iced::Border { + color: p.border_subtle, + width: 1.0, + radius: 4.0.into(), + }, + icon: p.text_muted, + placeholder: p.text_placeholder, + value: p.text_primary, + selection: p.accent_blue, + }) + }; + + let inputs = row![ + field( + "Path to install.wim", + &form.image, + Message::DeployImageChanged + ) + .width(Length::FillPortion(3)), + field( + "Edition index", + &form.edition, + Message::DeployEditionChanged + ) + .width(Length::FillPortion(1)), + field("Name", &form.name, Message::DeployNameChanged).width(Length::FillPortion(1)), + ] + .spacing(t.sz(8)); + + let deploy_button = button(text("Deploy").size(t.sz(13))); + let deploy_button = if busy { + deploy_button + } else { + deploy_button.on_press(Message::DeployStart) + }; + + let mut section = column![ + text("Deploy a base").size(t.sz(14)).color(p.text_secondary), + inputs, + deploy_button, + ] + .spacing(t.sz(8)); + + if let Some(progress) = deploying { + section = section.push( + row![ + progress_bar(0.0..=100.0, progress.percent as f32) + .girth(t.sz(10)) + .length(Length::FillPortion(4)) + .style(move |_| progress_bar::Style { + background: p.bg_progress.into(), + bar: p.accent_progress.into(), + border: iced::Border::default(), + }), + text(format!("{} - {}%", progress.what, progress.percent)) + .size(t.sz(12)) + .color(p.text_muted), + ] + .spacing(t.sz(8)) + .align_y(iced::Alignment::Center), + ); + } + + container(section) + .padding(t.sz(12) as u16) + .width(Length::Fill) + .style(move |_| container::Style { + background: Some(p.bg_card.into()), + border: iced::Border { + color: p.border_subtle, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }) + .into() +} diff --git a/crates/raven-gui/src/view/mod.rs b/crates/raven-gui/src/view/mod.rs index 7bec1f2..0f7f579 100644 --- a/crates/raven-gui/src/view/mod.rs +++ b/crates/raven-gui/src/view/mod.rs @@ -1,5 +1,6 @@ //! Drawing. Nothing in here decides anything - `update` does that. +pub mod bases; pub mod detail; pub mod environments; @@ -67,7 +68,7 @@ pub fn shell(app: &App) -> Element<'_, Message> { // the draw. Falling back is better than an empty page. None => environments::screen(&app.envs), }, - Screen::Bases => text("Bases").size(t.sz(22)).color(p.text_primary).into(), + Screen::Bases => bases::screen(&app.bases, app.deploying.as_ref(), &app.deploy_form), Screen::Doctor => text("Diagnostics") .size(t.sz(22)) .color(p.text_primary) From b8dc2ebfd40465f4bca1ca15e09d481f95f73424 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 2 Sep 2026 14:31:36 +0200 Subject: [PATCH 08/38] docs(gui): explain why base deploy resolves raven through PATH A review of the deployment progress bar flagged Command::new("raven") as undocumented: under cargo run it silently runs the system's installed CLI rather than the workspace build, with no hint that happened. PATH is still the right choice - the package installs the GUI and CLI together at the same version - so this documents the reasoning on pump() rather than changing the resolution strategy. --- crates/raven-gui/src/deploy.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/raven-gui/src/deploy.rs b/crates/raven-gui/src/deploy.rs index 5f30792..14af158 100644 --- a/crates/raven-gui/src/deploy.rs +++ b/crates/raven-gui/src/deploy.rs @@ -93,6 +93,16 @@ pub fn run(args: &Args) -> impl Stream + use<> { /// one `\n`-terminated line per update, so each chunk read off the pipe is /// split on both and only the last complete reading is reported - the ones /// before it were already stale by the time the read returned. +/// +/// `Command::new("raven")` resolves through `$PATH`, not to this workspace's +/// own build. That's the right binary to run in the packaged case the GUI +/// ships for: the package installs the GUI and the CLI together at the same +/// version (see `packaging/PKGBUILD`), so whichever `raven` `$PATH` finds is +/// guaranteed to match. It is the wrong binary under `cargo run -p +/// raven-gui`: PATH still resolves to the system's installed `/usr/bin/raven` +/// rather than `target/debug/raven`, so a developer testing a CLI change +/// against a freshly built GUI silently deploys with the old, already- +/// installed CLI and sees no sign that happened. fn pump(args: &Args, mut output: mpsc::Sender) { let child = Command::new("raven") .arg("base") From 5684ef793a26e6eb073bca04c08c136c9d6a6b5d Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 2 Sep 2026 14:37:18 +0200 Subject: [PATCH 09/38] feat(gui): diagnostics, keeping the consequences rather than lights raven doctor says 'absent - Wine falls back to wineserver' rather than 'no', and a green tick would throw away the only useful half of that. --- crates/raven-gui/src/load.rs | 21 +++++++- crates/raven-gui/src/main.rs | 16 ++++-- crates/raven-gui/src/model.rs | 76 +++++++++++++++++++++++++++++ crates/raven-gui/src/view/doctor.rs | 68 ++++++++++++++++++++++++++ crates/raven-gui/src/view/mod.rs | 6 +-- 5 files changed, 177 insertions(+), 10 deletions(-) create mode 100644 crates/raven-gui/src/view/doctor.rs diff --git a/crates/raven-gui/src/load.rs b/crates/raven-gui/src/load.rs index c946418..a8360f3 100644 --- a/crates/raven-gui/src/load.rs +++ b/crates/raven-gui/src/load.rs @@ -7,7 +7,7 @@ use iced::Task; use crate::Message; -use crate::model::{self, BaseRow, EnvRow}; +use crate::model::{self, BaseRow, Check, EnvRow}; /// Loads every environment's state on a blocking worker. pub fn environments() -> Task { @@ -43,6 +43,22 @@ pub fn bases() -> Task { ) } +/// Loads every diagnostic on a blocking worker. `checks` shells out to `wine +/// --version`, which is enough of a subprocess call that it must not run on +/// the interface thread either. Unlike `environments` and `bases`, `checks` +/// cannot fail - it turns absence into a judgement rather than an error - so +/// there is no `Err` case to carry. +pub fn doctor() -> Task { + Task::perform( + async { + tokio::task::spawn_blocking(model::checks) + .await + .unwrap_or_default() + }, + Message::Doctor, + ) +} + /// The shape every loader returns: the data, or a message already fit to show. pub type Loaded = Result; @@ -51,3 +67,6 @@ pub type EnvRows = Loaded>; /// Named so the signature above reads: `Task` carrying `Loaded>`. pub type BaseRows = Loaded>; + +/// Named so the signature above reads: `Task` carrying `Vec`. +pub type Checks = Vec; diff --git a/crates/raven-gui/src/main.rs b/crates/raven-gui/src/main.rs index ce528b5..a06e313 100644 --- a/crates/raven-gui/src/main.rs +++ b/crates/raven-gui/src/main.rs @@ -16,7 +16,7 @@ use std::path::PathBuf; use iced::{Element, Subscription, Task}; -use model::{BaseRow, EnvRow}; +use model::{BaseRow, Check, EnvRow}; /// Which screen is showing. #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -42,6 +42,7 @@ pub enum Message { Go(Screen), Environments(load::EnvRows), Bases(load::BaseRows), + Doctor(load::Checks), Refresh, Start(String), Stop(String), @@ -68,6 +69,7 @@ pub struct App { pub(crate) screen: Screen, pub(crate) envs: Vec, pub(crate) bases: Vec, + pub(crate) checks: Vec, pub(crate) offer: Option, pub(crate) deploy_form: DeployForm, /// The bar the bases screen draws, when a deployment is running. @@ -84,10 +86,10 @@ impl App { fn update(&mut self, message: Message) -> Task { match message { Message::Go(screen) => { - let task = if screen == Screen::Bases { - load::bases() - } else { - Task::none() + let task = match screen { + Screen::Bases => load::bases(), + Screen::Doctor => load::doctor(), + _ => Task::none(), }; self.screen = screen; task @@ -120,6 +122,10 @@ impl App { }); Task::none() } + Message::Doctor(rows) => { + self.checks = rows; + Task::none() + } Message::Refresh => load::environments(), Message::Start(name) => Task::perform( async move { diff --git a/crates/raven-gui/src/model.rs b/crates/raven-gui/src/model.rs index 51835a4..8dc9c9b 100644 --- a/crates/raven-gui/src/model.rs +++ b/crates/raven-gui/src/model.rs @@ -84,6 +84,68 @@ pub fn base_rows(bases: Vec, envs: &[EnvRow]) -> Vec .collect() } +/// One line of the diagnostics screen. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Check { + pub label: String, + pub ok: bool, + /// What it means, not just whether it passed. `raven doctor` reports + /// "absent - Wine falls back to wineserver for NT synchronization" rather + /// than "no", and the window keeps that. + pub detail: String, +} + +/// The same four judgements `raven doctor` prints, from the same functions - +/// a difference between the two would be a bug here, not a second opinion. +pub fn checks() -> Vec { + use raven::mount::MountBackend as _; + let userns = raven::mount::UserNsOverlay::is_available(); + let wine = raven::prefix::wine_available(); + let ntsync = std::path::Path::new("/dev/ntsync").exists(); + let media = raven::prefix::media_decoders(); + + vec![ + Check { + label: "Unprivileged user namespaces".into(), + ok: userns, + detail: if userns { + "available - Raven can mount without root".into() + } else { + "this kernel restricts them, and Raven's only mount backend needs them".into() + }, + }, + Check { + label: "Wine".into(), + ok: wine, + detail: if wine { + "found".into() + } else { + "missing - Raven cannot run anything without it".into() + }, + }, + Check { + label: "ntsync".into(), + ok: ntsync, + detail: if ntsync { + "present".into() + } else { + "absent - Wine falls back to wineserver for NT synchronization".into() + }, + }, + Check { + label: "Media playback".into(), + ok: media.is_none(), + detail: match media { + None => "GStreamer decoders present".into(), + Some(missing) => format!( + "incomplete: {}. Games will run with their cutscenes and music silently absent.", + missing.join("; ") + ), + }, + }, + ] +} + #[cfg(test)] mod tests { use super::*; @@ -120,4 +182,18 @@ mod tests { row.holders = 1; assert_eq!(row.status_line(), "running - 1 process"); } + + #[test] + fn a_failing_check_carries_its_consequence_and_not_just_a_no() { + let c = Check { + label: "ntsync".into(), + ok: false, + detail: "absent - Wine falls back to wineserver for NT synchronization".into(), + }; + assert!(!c.ok); + assert!( + c.detail.len() > "no".len(), + "the CLI explains what a missing check costs, and the window keeps that" + ); + } } diff --git a/crates/raven-gui/src/view/doctor.rs b/crates/raven-gui/src/view/doctor.rs new file mode 100644 index 0000000..d60dd53 --- /dev/null +++ b/crates/raven-gui/src/view/doctor.rs @@ -0,0 +1,68 @@ +//! Diagnostics: the same four judgements `raven doctor` prints, from the same +//! functions. A green tick and a red cross would throw away the only useful +//! half of what the CLI says - "absent - Wine falls back to wineserver for NT +//! synchronization" rather than "no" - so every row, passing or not, keeps +//! its consequence in the detail line. + +use iced::widget::{Space, column, container, scrollable, text}; +use iced::{Element, Length}; + +use crate::Message; +use crate::model::Check; +use crate::theme; + +pub fn screen(checks: &[Check]) -> Element<'_, Message> { + let t = theme::typography(); + let p = theme::palette(); + + let list: Element<'_, Message> = if checks.is_empty() { + text("No diagnostics yet") + .size(t.sz(13)) + .color(p.text_muted) + .into() + } else { + let cards = checks + .iter() + .fold(column![].spacing(t.sz(10)), |acc, c| acc.push(card(c))); + scrollable(cards).height(Length::Fill).into() + }; + + container( + column![ + text("Diagnostics").size(t.sz(22)).color(p.text_primary), + Space::new().height(t.sz(8)), + list, + ] + .spacing(t.sz(4)), + ) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn card(c: &Check) -> Element<'_, Message> { + let t = theme::typography(); + let p = theme::palette(); + + container( + column![ + text(c.label.clone()).size(t.sz(16)).color(p.text_primary), + text(c.detail.clone()) + .size(t.sz(12)) + .color(if c.ok { p.success } else { p.error }), + ] + .spacing(t.sz(3)), + ) + .padding(t.sz(12) as u16) + .width(Length::Fill) + .style(move |_| container::Style { + background: Some(p.bg_card.into()), + border: iced::Border { + color: p.border_subtle, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }) + .into() +} diff --git a/crates/raven-gui/src/view/mod.rs b/crates/raven-gui/src/view/mod.rs index 0f7f579..1032e2d 100644 --- a/crates/raven-gui/src/view/mod.rs +++ b/crates/raven-gui/src/view/mod.rs @@ -2,6 +2,7 @@ pub mod bases; pub mod detail; +pub mod doctor; pub mod environments; use iced::widget::{Space, button, column, container, row, text}; @@ -69,10 +70,7 @@ pub fn shell(app: &App) -> Element<'_, Message> { None => environments::screen(&app.envs), }, Screen::Bases => bases::screen(&app.bases, app.deploying.as_ref(), &app.deploy_form), - Screen::Doctor => text("Diagnostics") - .size(t.sz(22)) - .color(p.text_primary) - .into(), + Screen::Doctor => doctor::screen(&app.checks), }; let banner: Element<'_, Message> = match &app.offer { From 054f9eedd6cc0922fcfaf0a865ab0622e88104f1 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 2 Sep 2026 14:51:43 +0200 Subject: [PATCH 10/38] build: package and sign raven-gui beside the CLI Two binaries, two desktop entries, one version. They are separate entries on purpose: raven.desktop carries the .exe association, and a double-clicked program must run rather than open the manager. --- .github/workflows/release.yml | 12 ++++++++---- docs/internals/packaging.md | 32 ++++++++++++++++++++++++++++++++ packaging/PKGBUILD | 2 ++ packaging/raven-gui.desktop | 9 +++++++++ 4 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 packaging/raven-gui.desktop diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe52e90..f756369 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,14 +47,18 @@ jobs: - name: Build run: cargo build --release --locked --target x86_64-unknown-linux-gnu - - name: Stage binary - run: cp target/x86_64-unknown-linux-gnu/release/raven raven-linux + - name: Stage binaries + run: | + cp target/x86_64-unknown-linux-gnu/release/raven raven-linux + cp target/x86_64-unknown-linux-gnu/release/raven-gui raven-gui-linux - - name: Upload binary to GitHub Release + - name: Upload binaries to GitHub Release uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: tag_name: ${{ needs.release-please.outputs.tag_name }} - files: raven-linux + files: | + raven-linux + raven-gui-linux # Sign every release asset with the Project-Colony org key (ed25519). # Colony >= 0.8.0 verifies these signatures on install and REFUSES a diff --git a/docs/internals/packaging.md b/docs/internals/packaging.md index 036336e..49801a2 100644 --- a/docs/internals/packaging.md +++ b/docs/internals/packaging.md @@ -36,6 +36,38 @@ binary would keep working on the kernel's open handle until the next reboot (the `F` flag) and then make every `.exe` on the machine fail in a way nobody would connect to Raven; `raven doctor` detects and reports both states. +## Two binaries, two desktop entries, one version + +The package installs `raven-gui` beside `raven`, and `raven-gui.desktop` +beside `raven.desktop`. They stay separate rather than merging into one +launcher, because the two desktop entries mean different things to a file +manager: + +`raven.desktop` claims the `.exe` MIME types (`application/x-msdownload` and +friends) and carries `NoDisplay=true`. It is the handler a file manager +reaches for when someone double-clicks a Windows program, launching +`raven launch %f` against that one file — it is not meant to appear in an +application menu on its own. + +`raven-gui.desktop` claims no MIME type and has no `NoDisplay`. It appears in +the menu under Raven's `System;Settings;` categories and launches `raven-gui` +with no arguments, opening the administration window for managing bases and +environments. + +If the GUI launcher also claimed the `.exe` association, double-clicking a +Windows program would open the manager instead of running the program — the +opposite of what a double-click means. Keeping the association on +`raven.desktop` alone, and never adding it to `raven-gui.desktop`, is what +keeps that from happening. Both entries reuse the single `Icon=raven` already +installed into the `hicolor` theme; there is no second icon to keep in sync. + +`build()` needed no change to produce the second binary: `cargo build +--release` at the workspace root already builds every workspace member, so +`raven-gui` comes out of the same command as `raven`. Only `package()` gained +two more `install` lines, and the release workflow gained a second staged +asset — the signing job already signs every file it finds under `dist/`, so +it needed no change at all. + ## `ntsync` is not Raven's business Arch's `wine` package depends on `ntsync-autoload`, whose entire content is a diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 3e75730..4cff1ce 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -41,6 +41,7 @@ package() { cd Raven install -Dm755 target/release/raven "$pkgdir/usr/bin/raven" ln -s raven "$pkgdir/usr/bin/rvn" + install -Dm755 target/release/raven-gui "$pkgdir/usr/bin/raven-gui" # The registration: the pacman hook applies it in the same transaction. install -Dm644 packaging/raven.conf "$pkgdir/usr/lib/binfmt.d/raven.conf" @@ -51,6 +52,7 @@ package() { install -Dm644 packaging/wine-mask.conf "$pkgdir/etc/binfmt.d/wine.conf" install -Dm644 packaging/raven.desktop "$pkgdir/usr/share/applications/raven.desktop" + install -Dm644 packaging/raven-gui.desktop "$pkgdir/usr/share/applications/raven-gui.desktop" # Icon=raven in the desktop entry resolves through the icon theme, so the # basename here, the desktop file name and Icon= must all agree or a panel diff --git a/packaging/raven-gui.desktop b/packaging/raven-gui.desktop new file mode 100644 index 0000000..691ed5f --- /dev/null +++ b/packaging/raven-gui.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Raven +GenericName=Windows environment manager +Comment=Manage the Windows installations and environments Raven runs programs against +Exec=raven-gui +Icon=raven +Terminal=false +Categories=System;Settings; From 73464378319c4d8e0d8e0d156950292a3834ad96 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 2 Sep 2026 15:21:10 +0200 Subject: [PATCH 11/38] fix(gui): every button takes its colours from the palette Eleven of the thirteen buttons carried no `.style()`, which does not mean "no style" - it means iced's built-in theme, so they rendered iced's blue on colony-ui's ground. `btn_default`, `btn_hover` and `btn_pressed` were unused in the whole crate: the palette carried three button colours that nothing asked for. One helper in `theme.rs` rather than a copy per call site, so the next button added cannot quietly opt out of the palette by saying nothing. It covers `Disabled` too - the Deploy button spends minutes in that state during a deployment, and the built-in theme's disabled fill was as foreign as its active one. The environment name on a card keeps its own style: it is the card's heading and only behaves like a button. Its colour is now stated rather than left to `button::Style::default()`, whose text is black. --- crates/raven-gui/src/theme.rs | 33 +++++++++++++++++++++++ crates/raven-gui/src/view/bases.rs | 2 +- crates/raven-gui/src/view/detail.rs | 25 ++++++++++++----- crates/raven-gui/src/view/environments.rs | 17 +++++++++--- crates/raven-gui/src/view/mod.rs | 1 + 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/crates/raven-gui/src/theme.rs b/crates/raven-gui/src/theme.rs index e4c633b..14aaa26 100644 --- a/crates/raven-gui/src/theme.rs +++ b/crates/raven-gui/src/theme.rs @@ -8,6 +8,7 @@ use colony_ui::{ThemePalette, Typography}; use iced::Font; +use iced::widget::button; /// The org's application font. JetBrainsMono Nerd Font per /// Project-Colony-Resources/design/typography.md. @@ -18,6 +19,38 @@ pub fn palette() -> ThemePalette { colony_ui::active_palette() } +/// The look of an ordinary button, for every ordinary button in the window. +/// +/// A button left without `.style()` does not fall back to nothing - it falls +/// back to iced's own theme, which paints its blue on colony-ui's ground. One +/// helper rather than a copy per call site, so the three button colours the +/// palette carries are used everywhere or nowhere. +/// +/// `Disabled` keeps the resting fill and dims the label: the palette has no +/// disabled token, and a button that vanishes when it cannot be pressed is +/// harder to find again than one that greys out. +pub fn button_style(p: ThemePalette) -> impl Fn(&iced::Theme, button::Status) -> button::Style { + move |_, status| button::Style { + background: Some( + match status { + button::Status::Hovered => p.btn_hover, + button::Status::Pressed => p.btn_pressed, + button::Status::Active | button::Status::Disabled => p.btn_default, + } + .into(), + ), + text_color: match status { + button::Status::Disabled => p.text_muted, + _ => p.text_primary, + }, + border: iced::Border { + radius: 4.0.into(), + ..Default::default() + }, + ..Default::default() + } +} + /// The type scale. `scale` is 1.0 until the settings screen exists to change /// it; the accessor is used everywhere from the start so adding that screen /// later changes one function rather than every call site. diff --git a/crates/raven-gui/src/view/bases.rs b/crates/raven-gui/src/view/bases.rs index 846fcb2..947b65e 100644 --- a/crates/raven-gui/src/view/bases.rs +++ b/crates/raven-gui/src/view/bases.rs @@ -129,7 +129,7 @@ fn deploy_form<'a>(deploying: Option<&'a Progress>, form: &'a DeployForm) -> Ele ] .spacing(t.sz(8)); - let deploy_button = button(text("Deploy").size(t.sz(13))); + let deploy_button = button(text("Deploy").size(t.sz(13))).style(theme::button_style(p)); let deploy_button = if busy { deploy_button } else { diff --git a/crates/raven-gui/src/view/detail.rs b/crates/raven-gui/src/view/detail.rs index 11d36c8..b946abf 100644 --- a/crates/raven-gui/src/view/detail.rs +++ b/crates/raven-gui/src/view/detail.rs @@ -18,9 +18,13 @@ pub fn screen(e: &EnvRow) -> Element<'_, Message> { heading("Session"), text(e.status_line()).size(t.sz(13)).color(p.text_primary), if e.is_running() { - button(text("Stop").size(t.sz(12))).on_press(Message::Stop(e.name.clone())) + button(text("Stop").size(t.sz(12))) + .style(theme::button_style(p)) + .on_press(Message::Stop(e.name.clone())) } else { - button(text("Start").size(t.sz(12))).on_press(Message::Start(e.name.clone())) + button(text("Start").size(t.sz(12))) + .style(theme::button_style(p)) + .on_press(Message::Start(e.name.clone())) }, ] .spacing(t.sz(6)); @@ -33,6 +37,7 @@ pub fn screen(e: &EnvRow) -> Element<'_, Message> { .size(t.sz(13)) .color(p.text_primary), button(text("Remove").size(t.sz(12))) + .style(theme::button_style(p)) .on_press(Message::RemoveD3d { env: name, vkd3d }), ], None => row![ @@ -40,6 +45,7 @@ pub fn screen(e: &EnvRow) -> Element<'_, Message> { .size(t.sz(13)) .color(p.text_muted), button(text("Install…").size(t.sz(12))) + .style(theme::button_style(p)) .on_press(Message::InstallD3d { env: name, vkd3d }), ], } @@ -71,10 +77,12 @@ pub fn screen(e: &EnvRow) -> Element<'_, Message> { text(format!("{letter}: {}", device.display())) .size(t.sz(13)) .color(p.text_primary), - button(text("Detach").size(t.sz(12))).on_press(Message::Detach { - env: e.name.clone(), - letter: *letter, - }), + button(text("Detach").size(t.sz(12))) + .style(theme::button_style(p)) + .on_press(Message::Detach { + env: e.name.clone(), + letter: *letter, + }), ] .spacing(t.sz(8)) .align_y(iced::Alignment::Center), @@ -87,7 +95,9 @@ pub fn screen(e: &EnvRow) -> Element<'_, Message> { text("Re-run the projection after editing registry-rules.toml.") .size(t.sz(12)) .color(p.text_muted), - button(text("Reproject").size(t.sz(12))).on_press(Message::Reproject(e.name.clone())), + button(text("Reproject").size(t.sz(12))) + .style(theme::button_style(p)) + .on_press(Message::Reproject(e.name.clone())), ] .spacing(t.sz(6)); @@ -95,6 +105,7 @@ pub fn screen(e: &EnvRow) -> Element<'_, Message> { column![ row![ button(text("← Environments").size(t.sz(12))) + .style(theme::button_style(p)) .on_press(Message::Go(crate::Screen::Environments)), Space::new().width(Length::Fill), ], diff --git a/crates/raven-gui/src/view/environments.rs b/crates/raven-gui/src/view/environments.rs index a6746bc..7a0f5af 100644 --- a/crates/raven-gui/src/view/environments.rs +++ b/crates/raven-gui/src/view/environments.rs @@ -37,9 +37,13 @@ fn card(e: &EnvRow) -> Element<'_, Message> { let p = theme::palette(); let action = if e.is_running() { - button(text("Stop").size(t.sz(13))).on_press(Message::Stop(e.name.clone())) + button(text("Stop").size(t.sz(13))) + .style(theme::button_style(p)) + .on_press(Message::Stop(e.name.clone())) } else { - button(text("Start").size(t.sz(13))).on_press(Message::Start(e.name.clone())) + button(text("Start").size(t.sz(13))) + .style(theme::button_style(p)) + .on_press(Message::Start(e.name.clone())) }; let mut runtimes = row![].spacing(t.sz(8)); @@ -57,8 +61,15 @@ fn card(e: &EnvRow) -> Element<'_, Message> { ); } + // The name is the card's own heading, not a control beside it, so it is + // drawn as text and only behaves like a button. No fill, and its colour is + // stated rather than left to `Style::default()`, which is black. let name = button(text(e.name.clone()).size(t.sz(16)).color(p.text_primary)) - .style(|_, _| button::Style::default()) + .style(move |_, _| button::Style { + background: None, + text_color: p.text_primary, + ..Default::default() + }) .padding(0.0) .on_press(Message::Open(e.name.clone())); diff --git a/crates/raven-gui/src/view/mod.rs b/crates/raven-gui/src/view/mod.rs index 1032e2d..13f7f05 100644 --- a/crates/raven-gui/src/view/mod.rs +++ b/crates/raven-gui/src/view/mod.rs @@ -82,6 +82,7 @@ pub fn shell(app: &App) -> Element<'_, Message> { if let Some(crate::errors::Action::Stop(name)) = &offer.action { r = r.push( button(text("Stop the session").size(t.sz(12))) + .style(theme::button_style(p)) .on_press(Message::Stop(name.clone())), ); } From 8c54b3333286a035c7a67d238157340f7ab5f4ac Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 2 Sep 2026 15:21:33 +0200 Subject: [PATCH 12/38] fix(gui): an offer outlives the refresh, and the screen refreshes at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the same seam, written by different tasks and reviewed apart. `Acted(Err)` set the offer and then returned `load::environments()`, and `Environments(Ok)` cleared the offer. So the flagship behaviour the design asks for - an error that arrives with a *Stop the session* button - appeared for the length of a `/proc` scan and then erased itself. The clear is now narrowed to what a load raised: a successful read says the read works again, and says nothing about the action that failed. `Offer` carries a `Kind` to tell the two apart, which is also what lets guidance stop being painted as a failure - pressing "Install…" was rendering instructions in `error` ink on `error_bg`. And the design's Data flow section asks for a refresh on actions plus a two-second poll while the environments screen is open. None of it was built: `Message::Refresh` was declared and never constructed, and `Go(Environments)` returned no task, so the home screen showed whatever was true at launch until an action happened to reload it. The poll is `iced::time::every`, subscribed only while that screen or a detail is showing - reading every process on the machine for a screen nobody is looking at is the waste the design refuses - and `loading_envs` drops a tick that arrives while the last scan is still running rather than queueing a second one behind it. Navigating now clears the banner too. An error raised on one screen said nothing about another and made it look broken. --- crates/raven-gui/src/errors.rs | 63 +++++++++++- crates/raven-gui/src/main.rs | 170 +++++++++++++++++++++---------- crates/raven-gui/src/view/mod.rs | 11 +- 3 files changed, 187 insertions(+), 57 deletions(-) diff --git a/crates/raven-gui/src/errors.rs b/crates/raven-gui/src/errors.rs index 5435f28..01fe7d1 100644 --- a/crates/raven-gui/src/errors.rs +++ b/crates/raven-gui/src/errors.rs @@ -14,29 +14,72 @@ pub enum Action { Stop(String), } +/// What raised the banner, which decides both how it is painted and what is +/// allowed to take it away. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + /// Reading the machine's state failed. The next successful read means the + /// condition is gone, so that read clears it. + LoadError, + /// An action failed. A read must never clear this: the environments screen + /// reloads every two seconds, and an error that survives for one poll + /// interval is an error nobody gets to act on. + ActionError, + /// Guidance, not a failure - what to type for something the window cannot + /// do yet. Painted neutrally, because `error` ink on `error_bg` would tell + /// the user something went wrong when nothing did. + Notice, +} + /// A message to show, and optionally a button to show beside it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Offer { + pub kind: Kind, pub message: String, pub action: Option, } +impl Offer { + pub fn load_error(message: String) -> Self { + Self { + kind: Kind::LoadError, + message, + action: None, + } + } + + pub fn action_error(message: String) -> Self { + Self { + kind: Kind::ActionError, + message, + action: None, + } + } + + pub fn notice(message: String) -> Self { + Self { + kind: Kind::Notice, + message, + action: None, + } + } +} + pub fn explain(error: &Error) -> Offer { match error { Error::SessionHolds(name) => Offer { + kind: Kind::ActionError, message: format!( "A session is holding {name}'s C:, which is what makes launches fast." ), action: Some(Action::Stop(name.clone())), }, Error::EnvironmentBusy { name, holders } => Offer { + kind: Kind::ActionError, message: format!("{name} is still in use by {holders}."), action: Some(Action::Stop(name.clone())), }, - other => Offer { - message: other.to_string(), - action: None, - }, + other => Offer::action_error(other.to_string()), } } @@ -56,6 +99,18 @@ mod tests { ); } + #[test] + fn what_an_action_raises_is_marked_as_such_so_a_refresh_cannot_erase_it() { + // The environments screen reloads every two seconds. If these came back + // as `LoadError`, the next poll would take the message and its button + // away before anyone had read either. + assert_eq!( + explain(&Error::SessionHolds("games".into())).kind, + Kind::ActionError + ); + assert_eq!(explain(&Error::NoWine).kind, Kind::ActionError); + } + #[test] fn an_error_with_no_obvious_action_keeps_its_own_words() { let e = Error::NoWine; diff --git a/crates/raven-gui/src/main.rs b/crates/raven-gui/src/main.rs index a06e313..f3c77f4 100644 --- a/crates/raven-gui/src/main.rs +++ b/crates/raven-gui/src/main.rs @@ -13,11 +13,18 @@ mod theme; mod view; use std::path::PathBuf; +use std::time::Duration; use iced::{Element, Subscription, Task}; +use errors::Offer; use model::{BaseRow, Check, EnvRow}; +/// How often the environments screen asks who holds a session, per the design's +/// Data flow section. The one clock in the program, and it runs only while that +/// screen is showing. +const POLL: Duration = Duration::from_secs(2); + /// Which screen is showing. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum Screen { @@ -70,7 +77,11 @@ pub struct App { pub(crate) envs: Vec, pub(crate) bases: Vec, pub(crate) checks: Vec, - pub(crate) offer: Option, + pub(crate) offer: Option, + /// Whether an environments load is in flight. The poll fires on a clock + /// that knows nothing about how long `/proc` takes, so without this a slow + /// scan would have a second one queued behind it every two seconds. + loading_envs: bool, pub(crate) deploy_form: DeployForm, /// The bar the bases screen draws, when a deployment is running. pub(crate) deploying: Option, @@ -83,50 +94,97 @@ pub struct App { } impl App { + /// The window and the first load. `reload_environments` rather than + /// `load::environments` so `loading_envs` is true from the first frame: + /// the boot load and the first poll must not both be in flight. + fn boot() -> (Self, Task) { + let mut app = Self::default(); + let task = app.reload_environments(); + (app, task) + } + + /// Reads every environment's state, and records that it is being read. + fn reload_environments(&mut self) -> Task { + self.loading_envs = true; + load::environments() + } + + /// Forgets a banner the user has navigated away from. An error raised on + /// one screen means nothing above another, and leaving it there makes the + /// new screen look broken. + fn dismiss_offer(&mut self) { + self.offer = None; + } + + /// Forgets a banner a *load* raised, and only that. A load succeeding says + /// the read works again; it says nothing about the action that failed, and + /// taking that message and its button away is what a two-second poll would + /// otherwise do to every error in the program. + fn dismiss_stale_load_error(&mut self) { + if matches!( + self.offer, + Some(Offer { + kind: errors::Kind::LoadError, + .. + }) + ) { + self.offer = None; + } + } + fn update(&mut self, message: Message) -> Task { match message { Message::Go(screen) => { - let task = match screen { + self.dismiss_offer(); + // The design asks for a reload on arriving at the environments + // screen: the poll only runs while it is showing, so without + // this the first two seconds show whatever was true when the + // user last left it. + let task = match &screen { + Screen::Environments | Screen::Detail(_) => self.reload_environments(), Screen::Bases => load::bases(), Screen::Doctor => load::doctor(), - _ => Task::none(), }; self.screen = screen; task } - Message::Open(name) => { - self.screen = Screen::Detail(name); - Task::none() - } + // Opening a card is navigating to its detail, and gets the same + // treatment rather than a second, quietly different one. + Message::Open(name) => self.update(Message::Go(Screen::Detail(name))), Message::Environments(Ok(rows)) => { self.envs = rows; - self.offer = None; + self.loading_envs = false; + self.dismiss_stale_load_error(); Task::none() } Message::Environments(Err(e)) => { - self.offer = Some(errors::Offer { - message: e, - action: None, - }); + self.loading_envs = false; + self.offer = Some(Offer::load_error(e)); Task::none() } Message::Bases(Ok(rows)) => { self.bases = rows; - self.offer = None; + self.dismiss_stale_load_error(); Task::none() } Message::Bases(Err(e)) => { - self.offer = Some(errors::Offer { - message: e, - action: None, - }); + self.offer = Some(Offer::load_error(e)); Task::none() } Message::Doctor(rows) => { self.checks = rows; Task::none() } - Message::Refresh => load::environments(), + // A poll that arrives while the last one is still reading `/proc` + // is dropped rather than queued: the answer it would fetch is the + // answer already on its way. + Message::Refresh => { + if self.loading_envs { + Task::none() + } else { + self.reload_environments() + } + } Message::Start(name) => Task::perform( async move { tokio::task::spawn_blocking(move || { @@ -151,19 +209,22 @@ impl App { }, Message::Acted, ), - Message::Acted(Ok(())) => load::environments(), + Message::Acted(Ok(())) => { + self.dismiss_offer(); + self.reload_environments() + } + // The reload still happens: whatever the action did before it + // failed is part of the state. It no longer takes the banner with + // it - that is what `dismiss_stale_load_error` is careful about. Message::Acted(Err(e)) => { self.offer = Some(errors::explain(&e)); - load::environments() + self.reload_environments() } Message::InstallD3d { env, vkd3d } => { let which = if vkd3d { "vkd3d" } else { "dxvk" }; - self.offer = Some(errors::Offer { - message: format!( - "Install it from a build you already have: raven env {which} {env} --from " - ), - action: None, - }); + self.offer = Some(Offer::notice(format!( + "Install it from a build you already have: raven env {which} {env} --from " + ))); Task::none() } Message::RemoveD3d { env, vkd3d } => Task::perform( @@ -234,14 +295,12 @@ impl App { percent: 0, what: "Starting".into(), }); - self.offer = None; + self.dismiss_offer(); } _ => { - self.offer = Some(errors::Offer { - message: "Fill in the image path, a numeric edition, and a name." - .into(), - action: None, - }); + self.offer = Some(Offer::action_error( + "Fill in the image path, a numeric edition, and a name.".into(), + )); } } Task::none() @@ -257,26 +316,39 @@ impl App { self.deploy_form = DeployForm::default(); load::bases() } else { - self.offer = Some(errors::Offer { - message: "Deployment failed. Run the same `raven base deploy` from a terminal to see why." + self.offer = Some(Offer::action_error( + "Deployment failed. Run the same `raven base deploy` from a terminal to see why." .into(), - action: None, - }); + )); Task::none() } } } } - /// While a deployment is running, keeps its stream alive; otherwise - /// subscribes to nothing. `deploy_job` - not `deploying` - is the - /// identity `Subscription::run_with` hashes on, so editing the progress - /// text alone can never be mistaken for a new job. + /// The two things that run without being asked. + /// + /// A deployment's stream, while one is running: `deploy_job` - not + /// `deploying` - is the identity `Subscription::run_with` hashes on, so + /// editing the progress text alone can never be mistaken for a new job. + /// + /// And the design's one clock. Who holds a session changes without the + /// window doing anything, so the environments screen and its details poll + /// for it - and nothing else does, because reading `/proc` for every + /// process on the machine on behalf of a screen nobody is looking at is + /// exactly the waste the design refuses. fn subscription(&self) -> Subscription { - match &self.deploy_job { + let deploying = match &self.deploy_job { Some(args) => Subscription::run_with(args.clone(), deploy::run), None => Subscription::none(), - } + }; + let polling = match self.screen { + Screen::Environments | Screen::Detail(_) => { + iced::time::every(POLL).map(|_| Message::Refresh) + } + Screen::Bases | Screen::Doctor => Subscription::none(), + }; + Subscription::batch([deploying, polling]) } fn view(&self) -> Element<'_, Message> { @@ -285,13 +357,9 @@ impl App { } fn main() -> iced::Result { - iced::application( - || (App::default(), load::environments()), - App::update, - App::view, - ) - .subscription(App::subscription) - .title("Raven") - .default_font(theme::APP_FONT) - .run() + iced::application(App::boot, App::update, App::view) + .subscription(App::subscription) + .title("Raven") + .default_font(theme::APP_FONT) + .run() } diff --git a/crates/raven-gui/src/view/mod.rs b/crates/raven-gui/src/view/mod.rs index 13f7f05..50b7a03 100644 --- a/crates/raven-gui/src/view/mod.rs +++ b/crates/raven-gui/src/view/mod.rs @@ -76,7 +76,14 @@ pub fn shell(app: &App) -> Element<'_, Message> { let banner: Element<'_, Message> = match &app.offer { None => Space::new().height(0).into(), Some(offer) => { - let mut r = row![text(offer.message.clone()).size(t.sz(13)).color(p.error)] + // A notice is guidance the window cannot carry out itself, not a + // failure, and painting it in the error colours would tell the user + // something went wrong when nothing did. + let (ink, ground) = match offer.kind { + crate::errors::Kind::Notice => (p.text_primary, p.bg_modal_section), + _ => (p.error, p.error_bg), + }; + let mut r = row![text(offer.message.clone()).size(t.sz(13)).color(ink)] .spacing(t.sz(8)) .align_y(iced::Alignment::Center); if let Some(crate::errors::Action::Stop(name)) = &offer.action { @@ -90,7 +97,7 @@ pub fn shell(app: &App) -> Element<'_, Message> { .padding(t.sz(10) as u16) .width(Length::Fill) .style(move |_| container::Style { - background: Some(p.error_bg.into()), + background: Some(ground.into()), ..Default::default() }) .into() From e1525252680153b848fcd1d5309ab43f442cabb1 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 2 Sep 2026 15:21:47 +0200 Subject: [PATCH 13/38] fix(gui): the window reports an application id, so its desktop entry binds iced defaults `application_id` to the empty string. A Wayland compositor has no other way to tell which desktop file a running window came from - it matches that id against a desktop file's basename - so nothing would ever match `raven-gui.desktop`, and the `Icon=raven` that docs/internals/packaging.md promises would never reach a taskbar. The menu entry would carry the mark and the window it launched would show a blank fallback, with nothing to say the two were one program. assets/brand/README.md already writes down the agreement the icon needs; this is a fourth term in it, so it is recorded there rather than left to be rediscovered. --- assets/brand/README.md | 12 ++++++++++-- crates/raven-gui/src/main.rs | 12 ++++++++++++ docs/internals/packaging.md | 6 ++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/assets/brand/README.md b/assets/brand/README.md index 901fd3d..7ddacb4 100644 --- a/assets/brand/README.md +++ b/assets/brand/README.md @@ -51,14 +51,22 @@ Three things have to agree or the icon silently does not appear: | | | |---|---| -| `Icon=` in `packaging/raven.desktop` | `raven` | -| the installed desktop file | `raven.desktop` | +| `Icon=` in both desktop entries | `raven` | +| the installed desktop files | `raven.desktop` and `raven-gui.desktop` | | the installed icon | `hicolor//apps/raven.png` | `packaging/PKGBUILD` installs all three. Before that it installed only the first two, which is why the desktop entry has always asked for an icon that was not there. +A *window* needs a fourth. A compositor is not told which desktop file a running +window came from - it matches the application id the window reports against a +desktop file's basename - so `raven-gui` sets its `application_id` to +`raven-gui`, the basename of `raven-gui.desktop`, in +`crates/raven-gui/src/main.rs`. iced leaves that empty by default, which matches +nothing: the menu entry would keep its icon while the window it launched showed +the compositor's blank fallback, with nothing to say the two were one program. + ## Colours | role | dark | light | diff --git a/crates/raven-gui/src/main.rs b/crates/raven-gui/src/main.rs index f3c77f4..0b7c7e5 100644 --- a/crates/raven-gui/src/main.rs +++ b/crates/raven-gui/src/main.rs @@ -361,5 +361,17 @@ fn main() -> iced::Result { .subscription(App::subscription) .title("Raven") .default_font(theme::APP_FONT) + // A Wayland compositor identifies a window by the application id it + // reports and looks for a desktop file with that basename; iced leaves + // it empty, so nothing would ever match `raven-gui.desktop` and the + // `Icon=raven` it carries would never reach a taskbar. The three-way + // agreement this belongs to is written down in assets/brand/README.md. + .window(iced::window::Settings { + platform_specific: iced::window::settings::PlatformSpecific { + application_id: "raven-gui".to_owned(), + ..Default::default() + }, + ..Default::default() + }) .run() } diff --git a/docs/internals/packaging.md b/docs/internals/packaging.md index 49801a2..0d6a783 100644 --- a/docs/internals/packaging.md +++ b/docs/internals/packaging.md @@ -61,6 +61,12 @@ opposite of what a double-click means. Keeping the association on keeps that from happening. Both entries reuse the single `Icon=raven` already installed into the `hicolor` theme; there is no second icon to keep in sync. +The window itself has to opt into that entry. A compositor matches a window's +application id against a desktop file's basename, so `raven-gui` sets its +`application_id` to `raven-gui` — without it the entry and the running window +are unrelated as far as a taskbar is concerned, and the `Icon=raven` above +never reaches one. `assets/brand/README.md` records that agreement in full. + `build()` needed no change to produce the second binary: `cargo build --release` at the workspace root already builds every workspace member, so `raven-gui` comes out of the same command as `raven`. Only `package()` gained From f6b926e669d33aaa564973c1dfbe1bc858496838 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 2 Sep 2026 15:22:04 +0200 Subject: [PATCH 14/38] fix(gui): a failed deployment says why, and its last word cannot be dropped Two faults in the same 40 lines, and both only bite on the one operation that costs minutes. stderr was inherited. A GUI started from an application menu has nowhere to inherit it to, so the reason a deployment failed went to no terminal at all and the window could only say "run the same command from a terminal to see why" - the admission of failure the design's error section forbids. It is piped now, drained on its own thread so a full stderr pipe cannot deadlock a reader parked on stdout, and the last line that says anything is what the banner shows. The child failing to spawn at all now carries its reason too, instead of being a bare `false`. And `DeployDone` was sent with `try_send` into a sixteen-slot channel, like the progress updates around it. Losing a progress update is right - they arrive faster than the window redraws and the next one is truer. Losing the terminal message left `deploying` and `deploy_job` `Some` for ever: a bar frozen at whatever percentage it reached, the fields and the Deploy button disabled, and no cancel. It leaves the blocking worker as a return value now and is awaited on the async side, so it waits for room rather than giving up. The progress updates stay lossy on purpose. --- crates/raven-gui/src/deploy.rs | 90 +++++++++++++++++++++++++++++----- crates/raven-gui/src/main.rs | 26 +++++----- 2 files changed, 93 insertions(+), 23 deletions(-) diff --git a/crates/raven-gui/src/deploy.rs b/crates/raven-gui/src/deploy.rs index 14af158..08c43b3 100644 --- a/crates/raven-gui/src/deploy.rs +++ b/crates/raven-gui/src/deploy.rs @@ -21,8 +21,8 @@ use std::io::Read; use std::path::PathBuf; use std::process::{Command, Stdio}; -use iced::futures::Stream; use iced::futures::channel::mpsc; +use iced::futures::{SinkExt, Stream}; use crate::Message; @@ -55,6 +55,20 @@ pub fn parse_progress(line: &str) -> Option { }) } +/// The one line of a failed child's error output worth putting in a banner. +/// +/// The last line is the one that says why - both Raven and `wimlib-imagex` +/// end with the sentence that matters. What follows it is usually blank, or a +/// remnant of a progress line wimlib redrew with `\r`, so empty pieces are +/// skipped rather than shown as an empty message. +pub fn last_meaningful_line(stderr: &str) -> Option { + stderr + .split(['\r', '\n']) + .map(str::trim) + .rfind(|line| !line.is_empty()) + .map(str::to_owned) +} + /// What `raven base deploy` needs, gathered from the bases screen's three /// text fields since this batch has no file dialog. /// @@ -80,20 +94,37 @@ pub struct Args { /// it the type is `for<'a> fn(&'a Args) -> S<'a>`, which cannot coerce there. pub fn run(args: &Args) -> impl Stream + use<> { let args = args.clone(); - iced::stream::channel(16, async move |output| { - tokio::task::spawn_blocking(move || pump(&args, output)) - .await - .expect("the blocking task panicked"); + iced::stream::channel(16, async move |mut output| { + let outcome = { + let output = output.clone(); + tokio::task::spawn_blocking(move || pump(&args, output)) + .await + .expect("the blocking task panicked") + }; + // The outcome comes back as a value rather than being pushed down the + // channel with the progress updates, so that it can be `await`ed here. + // Dropping a progress update is right - they arrive faster than anyone + // reads them. Dropping this one is not: `deploying` and `deploy_job` + // would stay `Some` for ever, freezing the bar at whatever percentage + // it had reached with the fields and the Deploy button disabled and no + // way out of it. Awaiting waits for room instead of giving up. + let _ = output.send(Message::DeployDone(outcome)).await; }) } -/// Runs the child to completion, reporting wimlib's progress as it goes. +/// Runs the child to completion, reporting wimlib's progress as it goes, and +/// returns why it failed if it did. /// /// wimlib redraws its progress line in place with `\r` rather than emitting /// one `\n`-terminated line per update, so each chunk read off the pipe is /// split on both and only the last complete reading is reported - the ones /// before it were already stale by the time the read returned. /// +/// stderr is piped rather than inherited. A GUI started from an application +/// menu has nowhere to inherit it *to*, so the reason a deployment failed - +/// the one operation here that costs minutes - would go to no terminal at all +/// and the window would have nothing to say but "try it again by hand". +/// /// `Command::new("raven")` resolves through `$PATH`, not to this workspace's /// own build. That's the right binary to run in the packaged case the GUI /// ships for: the package installs the GUI and the CLI together at the same @@ -103,7 +134,7 @@ pub fn run(args: &Args) -> impl Stream + use<> { /// rather than `target/debug/raven`, so a developer testing a CLI change /// against a freshly built GUI silently deploys with the old, already- /// installed CLI and sees no sign that happened. -fn pump(args: &Args, mut output: mpsc::Sender) { +fn pump(args: &Args, mut output: mpsc::Sender) -> Result<(), String> { let child = Command::new("raven") .arg("base") .arg("deploy") @@ -114,16 +145,24 @@ fn pump(args: &Args, mut output: mpsc::Sender) { .arg("--name") .arg(&args.name) .stdout(Stdio::piped()) + .stderr(Stdio::piped()) .spawn(); let mut child = match child { Ok(child) => child, - Err(_) => { - let _ = output.try_send(Message::DeployDone(false)); - return; - } + Err(e) => return Err(format!("`raven base deploy` would not start: {e}")), }; + // stderr is drained by its own thread. The child writes to both pipes, and + // a single reader parked on stdout would deadlock the moment stderr's pipe + // filled - which is exactly what a failing deployment does. + let mut stderr = child.stderr.take().expect("stderr was requested piped"); + let errors = std::thread::spawn(move || { + let mut said = String::new(); + let _ = stderr.read_to_string(&mut said); + said + }); + let mut stdout = child.stdout.take().expect("stdout was requested piped"); let mut buf = [0u8; 4096]; loop { @@ -137,12 +176,20 @@ fn pump(args: &Args, mut output: mpsc::Sender) { .filter_map(parse_progress) .next_back() { + // Losing one of these is fine and intended: they arrive faster than + // the window redraws, and the next one is already truer. let _ = output.try_send(Message::DeployProgress(progress)); } } let success = child.wait().map(|status| status.success()).unwrap_or(false); - let _ = output.try_send(Message::DeployDone(success)); + let said = errors.join().unwrap_or_default(); + if success { + Ok(()) + } else { + Err(last_meaningful_line(&said) + .unwrap_or_else(|| "It gave no reason before it stopped.".to_owned())) + } } #[cfg(test)] @@ -179,6 +226,25 @@ mod tests { assert_eq!(parse_progress("almost (50%) there"), None); } + #[test] + fn a_failure_is_reported_with_the_line_that_says_why() { + // Raven's own wording, as `base deploy` writes it to stderr, followed + // by the blank line a terminating newline leaves behind. + let said = "Applying image 1 to /home/x/.local/share/raven/bases/win11\n\ + error: no space left on device while writing Windows/System32\n"; + assert_eq!( + last_meaningful_line(said).as_deref(), + Some("error: no space left on device while writing Windows/System32") + ); + } + + #[test] + fn a_child_that_said_nothing_yields_nothing_to_show() { + assert_eq!(last_meaningful_line(""), None); + // wimlib redraws with \r, so a killed child can leave only whitespace. + assert_eq!(last_meaningful_line("\r\n \r\n"), None); + } + // The brief's exact `.last()` triggers clippy::double_ended_iterator_last // (it would be `.next_back()` in code that ships); kept verbatim since the // brief's test text is authoritative, and allowed rather than edited. diff --git a/crates/raven-gui/src/main.rs b/crates/raven-gui/src/main.rs index 0b7c7e5..4cc3d3f 100644 --- a/crates/raven-gui/src/main.rs +++ b/crates/raven-gui/src/main.rs @@ -68,7 +68,10 @@ pub enum Message { DeployNameChanged(String), DeployStart, DeployProgress(deploy::Progress), - DeployDone(bool), + // `Err` carries the last thing the child said before it gave up, so a + // failure that took minutes does not have to be reproduced in a terminal + // to be understood. + DeployDone(Result<(), String>), } #[derive(Default)] @@ -309,18 +312,19 @@ impl App { self.deploying = Some(progress); Task::none() } - Message::DeployDone(success) => { + Message::DeployDone(outcome) => { self.deploy_job = None; self.deploying = None; - if success { - self.deploy_form = DeployForm::default(); - load::bases() - } else { - self.offer = Some(Offer::action_error( - "Deployment failed. Run the same `raven base deploy` from a terminal to see why." - .into(), - )); - Task::none() + match outcome { + Ok(()) => { + self.deploy_form = DeployForm::default(); + load::bases() + } + Err(reason) => { + self.offer = + Some(Offer::action_error(format!("Deployment failed. {reason}"))); + Task::none() + } } } } From 311c74438666cffebf6880f79c0f69d517aeb10d Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 2 Sep 2026 15:22:17 +0200 Subject: [PATCH 15/38] test(gui): point the diagnostics test at checks(), and two stale words The diagnostics test built a `Check` literal and then asserted against the string it had just typed. No production code ran, so it would have passed with `checks()` deleted. It calls `checks()` now: which judgements pass differs between machines and cannot be asserted on, but the shape can - four of them, each with a label, none reduced to the bare verdict this screen exists to avoid. The release workflow's build job was still called "Build raven-linux" though it stages two binaries. And `[profile.release]`'s comment still justified `lto = "fat"` with "raven runs once per .exe launch". That is still true of `raven` and now says nothing about `raven-gui`, which the same profile also builds. The reasoning is rewritten to say what is actually true - the settings are kept for the launcher and paid for in the window's compile time - as the design asked. The values are unchanged. --- .github/workflows/release.yml | 2 +- Cargo.toml | 11 +++++++++-- crates/raven-gui/src/model.rs | 32 ++++++++++++++++++++++---------- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f756369..f11764e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: # Raven is Linux-only: overlayfs and binfmt_misc do not exist elsewhere, # so the asset matrix is one row, not four. build: - name: Build raven-linux + name: Build raven and raven-gui needs: release-please if: ${{ needs.release-please.outputs.release_created }} runs-on: ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index 86d8a10..075b519 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,15 @@ repository = "https://github.com/Project-Colony/Raven" edition = "2024" rust-version = "1.85" -# raven runs once per .exe launch, so what matters is start-up and size, both -# measured before and after (numbers in the commit that added this). +# Two binaries share this profile now, and they are paid for by different +# people. `raven` runs once per .exe launch, so start-up and size are all that +# matter, and both were measured before and after (numbers in the commit that +# added this). `raven-gui` gains nothing measurable from either setting - it is +# opened by hand, once, and then stays open - and pays for them in compile +# time, since it links wgpu and iced under one codegen unit. The settings are +# kept anyway: they are here for the launcher, which is the binary on the hot +# path, and a second profile for the window would buy a faster build at the +# cost of two release binaries built two different ways. # `panic = "abort"` is deliberately not set: it trades backtraces away for # little, and that decision is recorded in docs/project/consolidation.md. [profile.release] diff --git a/crates/raven-gui/src/model.rs b/crates/raven-gui/src/model.rs index 8dc9c9b..24b8db9 100644 --- a/crates/raven-gui/src/model.rs +++ b/crates/raven-gui/src/model.rs @@ -184,16 +184,28 @@ mod tests { } #[test] - fn a_failing_check_carries_its_consequence_and_not_just_a_no() { - let c = Check { - label: "ntsync".into(), - ok: false, - detail: "absent - Wine falls back to wineserver for NT synchronization".into(), - }; - assert!(!c.ok); - assert!( - c.detail.len() > "no".len(), - "the CLI explains what a missing check costs, and the window keeps that" + fn every_check_carries_its_consequence_and_not_just_a_no() { + // `checks()` reads the machine, so which of them pass differs between + // machines and cannot be asserted on. What holds everywhere is the + // shape: four judgements, each one saying what it costs. Building a + // `Check` here and asserting on the string just typed would run none + // of that. + let checks = checks(); + assert_eq!( + checks.len(), + 4, + "raven doctor prints four judgements and the window shows the same four" ); + for c in &checks { + assert!(!c.label.is_empty()); + // A bare verdict is what `raven doctor` refuses to print, and the + // window shows whatever this returns, so it must refuse it too. + assert!( + !matches!(c.detail.as_str(), "" | "yes" | "no" | "ok"), + "{} said only {:?}, which is the green tick this screen exists to avoid", + c.label, + c.detail + ); + } } } From 15d750346533034532038e4ae86530544d5f7674 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 2 Sep 2026 15:51:33 +0200 Subject: [PATCH 16/38] fix: release-please could not open a pull request against a virtual manifest The workspace conversion moved the version into [workspace.package] and left no [package] at the root. release-please's `rust` strategy assumes a Cargo package lives at the package path and throws when it does not - so no release pull request would have been opened at all, silently, and 0.2.0 would have been the last version this project ever cut. Found by the final whole-branch review, whose premise was that the strategy would no-op; the fix investigation disproved that and found it throws, which is worse and quieter. The fix is not invented here. Eidos is the same shape - a virtual workspace with a GUI crate, one inherited version, CI building with --locked - and solved this already: `release-type: simple` naming the version line through extra-files, plus a step that runs `cargo update --workspace` on the release branch so the lock does not lag the manifest and break the --locked build on merge. Both carry Eidos's comments, including the mistake it already made: conditioning that step on `prs_created` silently skips it, and a step that skips looks exactly like a step that had nothing to do. One deliberate difference. Eidos names the release branch outright; this matches by prefix, because the branch carried `--components--raven` under the `rust` strategy and `simple` has no package name to derive a component from. Guessing that suffix wrong would make the step a no-op in the one case it exists for. More than one open release branch fails loudly rather than picking one. --- .github/workflows/release.yml | 58 ++++++++++++++++++++++++++++++++++- release-please-config.json | 7 +++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f11764e..2df198c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,10 +21,66 @@ jobs: - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 id: release with: - release-type: rust + # The workspace is a virtual manifest: one `[workspace.package]` + # version that both crates inherit, and no `[package]` at the root at + # all. That is why the config uses `release-type: simple` and names + # the version line outright - the `rust` type assumes a Cargo package + # lives here and throws on the manifest that does not have one, so no + # release pull request is opened at all. The type is set in the config + # file rather than here, so there is one place to read it. config-file: release-please-config.json manifest-file: .release-please-manifest.json + # `release-type: simple` rewrites Cargo.toml and nothing else, but + # Cargo.lock pins every workspace member by version and this workflow + # builds with `--locked`. A release pull request that bumped the manifest + # alone would fail the moment it merged, so the lock is brought along. + # + # The condition is the branch existing, not an output of the action above: + # conditioning on `prs_created` silently skips this, and a step that skips + # looks exactly like a step that had nothing to do. That lesson is Eidos's, + # whose release pipeline this mirrors. + - name: Keep Cargo.lock in step with the version + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + url="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + # Matched by prefix rather than named outright. Under `release-type: + # rust` the branch carried a component read from the Cargo package + # name - `…--components--raven` - and `simple` has no package to read + # one from, so the suffix is not ours to predict. One open release + # branch is the only case there is; more than one would be a + # release-please misconfiguration worth failing on. + branch=$(git ls-remote --heads "$url" \ + "release-please--branches--${GITHUB_REF_NAME}*" \ + | sed 's#.*refs/heads/##') + if [ -z "$branch" ]; then + echo "no release branch open - nothing to sync" + exit 0 + fi + if [ "$(printf '%s\n' "$branch" | wc -l)" -gt 1 ]; then + echo "more than one release branch open, refusing to guess:" + printf ' %s\n' $branch + exit 1 + fi + git clone --branch "$branch" --depth 1 "$url" pr + cd pr + # Rewrites the workspace members' versions in the lock and nothing + # else: no external dependency is touched and nothing is compiled. + # `cargo metadata --no-deps` does NOT do this - skipping resolution is + # the whole point of that flag, so the lock is never rewritten. + cargo update --workspace + if git diff --quiet Cargo.lock; then + echo "Cargo.lock already agrees with Cargo.toml" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "chore: sync Cargo.lock with the release version" Cargo.lock + git push + echo "Cargo.lock synced onto $branch" + # Raven is Linux-only: overlayfs and binfmt_misc do not exist elsewhere, # so the asset matrix is one row, not four. build: diff --git a/release-please-config.json b/release-please-config.json index 698349a..233cf82 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -2,12 +2,15 @@ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", "packages": { ".": { - "release-type": "rust", + "release-type": "simple", "changelog-path": "CHANGELOG.md", "bump-minor-pre-major": false, "bump-patch-for-minor-pre-major": false, "draft": false, - "prerelease": false + "prerelease": false, + "extra-files": [ + "Cargo.toml" + ] } } } From d88aeeffb73e3639ac7e940659872f46be810b02 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sat, 5 Sep 2026 01:01:14 +0200 Subject: [PATCH 17/38] fix(gui): the button that ends the user's programs says so `Environment::stop()` signals every process holding the mount, SIGTERM then SIGKILL, and does not ask who they are. The banner for `EnvironmentBusy` - raised precisely when something other than Raven's own anchor holds the environment, a game typically - offered that under the label "Stop the session", in a window whose own vocabulary uses "session" for the anchor. One click on a harmless-looking button ended the game. `Action` now tells the two apart: `StopSession` for the anchor, `StopHolders` for everything else, each with its own words, and the `EnvironmentBusy` message says that stopping ends the programs it lists. Both still stop the environment - that is the remedy in both cases - only the label's honesty changed. The labels live on `Action` rather than in the view so the test that pins the distinction can read them. The card's own Stop is unchanged: a count cannot tell a game from Wine's services, and listing the holders there is the spec's own item for the Session section, left for after the visual check. --- crates/raven-gui/src/errors.rs | 61 +++++++++++++++++++++++++++++--- crates/raven-gui/src/view/mod.rs | 8 +++-- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/crates/raven-gui/src/errors.rs b/crates/raven-gui/src/errors.rs index 01fe7d1..7685ed3 100644 --- a/crates/raven-gui/src/errors.rs +++ b/crates/raven-gui/src/errors.rs @@ -11,7 +11,33 @@ use raven::Error; /// What the window offers to do about an error. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Action { - Stop(String), + /// Release Raven's own session anchor. Nothing of the user's is running: + /// the library raises `SessionHolds` only when every holder is Raven. + StopSession(String), + /// Terminate every process holding the environment, the user's programs + /// included - `stop()` signals holders without asking who they are. Kept + /// apart from `StopSession` because the button has to say so: the window + /// uses "session" for the anchor, and "Stop the session" over a running + /// game would read as harmless. + StopHolders(String), +} + +impl Action { + /// The button's words. Here rather than in the view so the test that + /// pins the distinction can read them. + pub fn label(&self) -> &'static str { + match self { + Action::StopSession(_) => "Stop the session", + Action::StopHolders(_) => "Stop everything using it", + } + } + + /// The environment the action is about. + pub fn env(&self) -> &str { + match self { + Action::StopSession(name) | Action::StopHolders(name) => name, + } + } } /// What raised the banner, which decides both how it is painted and what is @@ -72,12 +98,12 @@ pub fn explain(error: &Error) -> Offer { message: format!( "A session is holding {name}'s C:, which is what makes launches fast." ), - action: Some(Action::Stop(name.clone())), + action: Some(Action::StopSession(name.clone())), }, Error::EnvironmentBusy { name, holders } => Offer { kind: Kind::ActionError, - message: format!("{name} is still in use by {holders}."), - action: Some(Action::Stop(name.clone())), + message: format!("{name} is still in use by {holders}. Stopping it ends them."), + action: Some(Action::StopHolders(name.clone())), }, other => Offer::action_error(other.to_string()), } @@ -91,7 +117,7 @@ mod tests { #[test] fn a_session_holding_an_environment_offers_to_stop_it() { let offer = explain(&Error::SessionHolds("games".into())); - assert_eq!(offer.action, Some(Action::Stop("games".into()))); + assert_eq!(offer.action, Some(Action::StopSession("games".into()))); assert!( !offer.message.contains("raven env stop"), "the button replaces the command; repeating it is noise: {}", @@ -99,6 +125,31 @@ mod tests { ); } + #[test] + fn an_environment_in_use_by_other_programs_says_that_stopping_ends_them() { + // The library raises this when something other than Raven's own + // anchor holds the mount - a game, typically - and `stop()` will + // SIGTERM and then SIGKILL all of it. The window's own vocabulary + // uses "session" for the anchor, so a button called "Stop the + // session" here would read as harmless while ending the game. + let offer = explain(&Error::EnvironmentBusy { + name: "games".into(), + holders: "1234 (ShineHill.exe)".into(), + }); + let action = offer.action.clone().expect("stopping is the remedy"); + assert_eq!(action, Action::StopHolders("games".into())); + assert_ne!( + action.label(), + Action::StopSession("games".into()).label(), + "ending the user's programs must not wear the anchor's label" + ); + assert!( + offer.message.contains("1234 (ShineHill.exe)") && offer.message.contains("ends"), + "must say what it will end: {}", + offer.message + ); + } + #[test] fn what_an_action_raises_is_marked_as_such_so_a_refresh_cannot_erase_it() { // The environments screen reloads every two seconds. If these came back diff --git a/crates/raven-gui/src/view/mod.rs b/crates/raven-gui/src/view/mod.rs index 50b7a03..e44ba2b 100644 --- a/crates/raven-gui/src/view/mod.rs +++ b/crates/raven-gui/src/view/mod.rs @@ -86,11 +86,13 @@ pub fn shell(app: &App) -> Element<'_, Message> { let mut r = row![text(offer.message.clone()).size(t.sz(13)).color(ink)] .spacing(t.sz(8)) .align_y(iced::Alignment::Center); - if let Some(crate::errors::Action::Stop(name)) = &offer.action { + // Both actions stop the environment; only the words differ, and + // they are the action's own so the view cannot mislabel one. + if let Some(action) = &offer.action { r = r.push( - button(text("Stop the session").size(t.sz(12))) + button(text(action.label()).size(t.sz(12))) .style(theme::button_style(p)) - .on_press(Message::Stop(name.clone())), + .on_press(Message::Stop(action.env().to_owned())), ); } container(r) From f599ff6e807a203f34b45d0031da4717a2c15194 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sat, 5 Sep 2026 01:01:14 +0200 Subject: [PATCH 18/38] fix(gui): Environments stays lit in the sidebar while a detail shows The sidebar highlighted an entry only when the screen equalled it, and `Screen::Detail` equals none of the three, so opening a card - the most common navigation in the window - turned the whole sidebar dark. Everywhere else the detail is already Environments one level down: `Open` is `Go(Detail)`, the back button returns there, the poll runs for both. The sidebar now agrees. --- crates/raven-gui/src/view/mod.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/raven-gui/src/view/mod.rs b/crates/raven-gui/src/view/mod.rs index e44ba2b..74f1649 100644 --- a/crates/raven-gui/src/view/mod.rs +++ b/crates/raven-gui/src/view/mod.rs @@ -21,7 +21,14 @@ pub fn shell(app: &App) -> Element<'_, Message> { // three calls below, and `selected` is settled before the `move` closure // so `screen` stays available afterward for `on_press`. let item = |label: &'static str, screen: Screen, current: &Screen| { - let selected = screen == *current; + // An environment's detail is the Environments screen one level down: + // `Open` is `Go(Detail)` and its back button returns here. So that + // entry stays lit while a detail shows, rather than the whole sidebar + // going dark on the most common navigation in the window. + let selected = match (&screen, current) { + (Screen::Environments, Screen::Detail(_)) => true, + _ => screen == *current, + }; button(text(label).size(t.sz(13))) .width(Length::Fill) .style(move |_, _| button::Style { From 2b3b33dc34fed63d91a14c29ddd77a6553686405 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sat, 5 Sep 2026 01:01:14 +0200 Subject: [PATCH 19/38] perf(gui): the bases screen counts environments without reading them To count environments per base, `load::bases` built the full environment rows, and each of those reads the session, the holders, the Direct3D manifests and the attachments - where reading the holders walks `/proc//mountinfo` for every process on the machine, and the session read does it a second time. Measured at 60 to 70 ms per environment on a 435-process machine, all of it for a comparison of base ids the manifests already hold. `base_rows` now takes those ids and nothing else. `load::bases` no longer touches `/proc`, and the note in `model.rs` that `load::environments` is `env_rows`' only caller has become true. --- crates/raven-gui/src/load.rs | 15 ++++++++++----- crates/raven-gui/src/model.rs | 27 +++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/crates/raven-gui/src/load.rs b/crates/raven-gui/src/load.rs index a8360f3..ed12929 100644 --- a/crates/raven-gui/src/load.rs +++ b/crates/raven-gui/src/load.rs @@ -25,16 +25,21 @@ pub fn environments() -> Task { ) } -/// Loads every base and, so each one's count is right, every environment. -/// Touches the same `/proc` state `environments` does, so this must not run -/// on the interface thread either. +/// Loads every base, and the base id of every environment so each count is +/// right. The ids come from the manifests alone, so unlike `environments` +/// this never touches `/proc`; it stays off the interface thread only +/// because it reads the disk. pub fn bases() -> Task { Task::perform( async { tokio::task::spawn_blocking(|| -> Loaded> { let bases = raven::base::Base::list().map_err(|e| e.to_string())?; - let envs = raven::env::Environment::list().map_err(|e| e.to_string())?; - Ok(model::base_rows(bases, &model::env_rows(envs))) + let env_bases: Vec = raven::env::Environment::list() + .map_err(|e| e.to_string())? + .into_iter() + .map(|e| e.manifest.base) + .collect(); + Ok(model::base_rows(bases, &env_bases)) }) .await .unwrap_or_else(|e| Err(e.to_string())) diff --git a/crates/raven-gui/src/model.rs b/crates/raven-gui/src/model.rs index 24b8db9..289679d 100644 --- a/crates/raven-gui/src/model.rs +++ b/crates/raven-gui/src/model.rs @@ -74,11 +74,14 @@ pub struct BaseRow { pub environments: usize, } -pub fn base_rows(bases: Vec, envs: &[EnvRow]) -> Vec { +/// `env_bases` is one base id per environment, straight from the manifests. +/// A count needs nothing more, and reading an environment's state to get it +/// would walk `/proc` for every process on the machine, twice over. +pub fn base_rows(bases: Vec, env_bases: &[String]) -> Vec { bases .into_iter() .map(|b| BaseRow { - environments: envs.iter().filter(|e| e.base == b.id).count(), + environments: env_bases.iter().filter(|base| **base == b.id).count(), id: b.id, }) .collect() @@ -183,6 +186,26 @@ mod tests { assert_eq!(row.status_line(), "running - 1 process"); } + #[test] + fn a_base_counts_the_environments_built_on_it_from_their_ids_alone() { + // Counting must not cost a read of each environment's state: that + // walks /proc for every process on the machine, and the id is in the + // manifest already. + let bases = vec![ + raven::base::Base { + id: "win11".into(), + path: PathBuf::new(), + }, + raven::base::Base { + id: "win10".into(), + path: PathBuf::new(), + }, + ]; + let rows = base_rows(bases, &["win11".into(), "win11".into()]); + assert_eq!(rows[0].environments, 2); + assert_eq!(rows[1].environments, 0); + } + #[test] fn every_check_carries_its_consequence_and_not_just_a_no() { // `checks()` reads the machine, so which of them pass differs between From 1b10d45a3e3980b3a81426ca859f14e21dd91e19 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sat, 5 Sep 2026 01:01:14 +0200 Subject: [PATCH 20/38] refactor(gui): one helper runs every action the window asks for Start, Stop, Remove, Detach and Reproject each spelled out the same wrapper - open the environment on a blocking thread, one library call, the `Arc` bridge for an error that cannot be `Clone` - five times over: 65 lines for five calls. `act` says it once, so a sixth action cannot quietly do any part of it differently. No behaviour changes; the five closures keep exactly the calls the arms made. `load`'s module doc claimed every library call went through it, which was already untrue of those five arms. It now says what is true: every read does, and actions take the same route through `act`. --- crates/raven-gui/src/load.rs | 3 +- crates/raven-gui/src/main.rs | 100 ++++++++++++----------------------- 2 files changed, 37 insertions(+), 66 deletions(-) diff --git a/crates/raven-gui/src/load.rs b/crates/raven-gui/src/load.rs index ed12929..27d65af 100644 --- a/crates/raven-gui/src/load.rs +++ b/crates/raven-gui/src/load.rs @@ -2,7 +2,8 @@ //! //! Reading one environment's state opens `/proc` for every process on the //! machine. Doing that on the interface thread stutters the window, so every -//! library call in the program goes through this module. +//! read goes through this module, and the actions take the same blocking +//! route through `act` in `main.rs`. use iced::Task; diff --git a/crates/raven-gui/src/main.rs b/crates/raven-gui/src/main.rs index 4cc3d3f..aef533c 100644 --- a/crates/raven-gui/src/main.rs +++ b/crates/raven-gui/src/main.rs @@ -188,30 +188,8 @@ impl App { self.reload_environments() } } - Message::Start(name) => Task::perform( - async move { - tokio::task::spawn_blocking(move || { - let e = raven::env::Environment::open(&name)?; - e.ensure_session().map(|_| ()) - }) - .await - .expect("the blocking task panicked") - .map_err(std::sync::Arc::new) - }, - Message::Acted, - ), - Message::Stop(name) => Task::perform( - async move { - tokio::task::spawn_blocking(move || { - let e = raven::env::Environment::open(&name)?; - e.stop().map(|_| ()) - }) - .await - .expect("the blocking task panicked") - .map_err(std::sync::Arc::new) - }, - Message::Acted, - ), + Message::Start(name) => act(name, |e| e.ensure_session().map(|_| ())), + Message::Stop(name) => act(name, |e| e.stop().map(|_| ())), Message::Acted(Ok(())) => { self.dismiss_offer(); self.reload_environments() @@ -230,47 +208,16 @@ impl App { ))); Task::none() } - Message::RemoveD3d { env, vkd3d } => Task::perform( - async move { - tokio::task::spawn_blocking(move || { - let rt = if vkd3d { - &raven::d3d::VKD3D - } else { - &raven::d3d::DXVK - }; - let e = raven::env::Environment::open(&env)?; - e.remove_d3d(rt).map(|_| ()) - }) - .await - .expect("the blocking task panicked") - .map_err(std::sync::Arc::new) - }, - Message::Acted, - ), - Message::Detach { env, letter } => Task::perform( - async move { - tokio::task::spawn_blocking(move || { - raven::env::Environment::open(&env)?.detach(letter) - }) - .await - .expect("the blocking task panicked") - .map_err(std::sync::Arc::new) - }, - Message::Acted, - ), - Message::Reproject(env) => Task::perform( - async move { - tokio::task::spawn_blocking(move || { - raven::env::Environment::open(&env)? - .project_registry() - .map(|_| ()) - }) - .await - .expect("the blocking task panicked") - .map_err(std::sync::Arc::new) - }, - Message::Acted, - ), + Message::RemoveD3d { env, vkd3d } => act(env, move |e| { + let rt = if vkd3d { + &raven::d3d::VKD3D + } else { + &raven::d3d::DXVK + }; + e.remove_d3d(rt).map(|_| ()) + }), + Message::Detach { env, letter } => act(env, move |e| e.detach(letter)), + Message::Reproject(env) => act(env, |e| e.project_registry().map(|_| ())), Message::DeployImageChanged(image) => { self.deploy_form.image = image; Task::none() @@ -360,6 +307,29 @@ impl App { } } +/// Opens the environment on a blocking thread, runs one action against it, +/// and reports the outcome as `Message::Acted`. +/// +/// Every action in the window has this shape - open, one library call, the +/// `Arc` bridge for the error - and writing it once means a sixth action +/// cannot quietly do any of it differently. Off the interface thread for the +/// reason `load` gives: the calls behind these read `/proc` and start +/// processes. +fn act( + env: String, + f: impl FnOnce(&raven::env::Environment) -> Result<(), raven::Error> + Send + 'static, +) -> Task { + Task::perform( + async move { + tokio::task::spawn_blocking(move || f(&raven::env::Environment::open(&env)?)) + .await + .expect("the blocking task panicked") + .map_err(std::sync::Arc::new) + }, + Message::Acted, + ) +} + fn main() -> iced::Result { iced::application(App::boot, App::update, App::view) .subscription(App::subscription) From 3601a7c057a637d4adf4eb7f528c02180fb5fada Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sat, 5 Sep 2026 01:01:14 +0200 Subject: [PATCH 21/38] test(gui): the carriage-return test reads like the code it tests The test kept a `.last()` under a clippy allow, with a comment citing "the brief" as the reason - a build-time document no reader of the repository can find. The code under test already uses `.next_back()`, the same operation on a double-ended iterator. The allow was the only one in the repository, and it guarded nothing worth keeping. --- crates/raven-gui/src/deploy.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/raven-gui/src/deploy.rs b/crates/raven-gui/src/deploy.rs index 08c43b3..baf67ef 100644 --- a/crates/raven-gui/src/deploy.rs +++ b/crates/raven-gui/src/deploy.rs @@ -245,15 +245,11 @@ mod tests { assert_eq!(last_meaningful_line("\r\n \r\n"), None); } - // The brief's exact `.last()` triggers clippy::double_ended_iterator_last - // (it would be `.next_back()` in code that ships); kept verbatim since the - // brief's test text is authoritative, and allowed rather than edited. - #[allow(clippy::double_ended_iterator_last)] #[test] fn a_carriage_return_stream_yields_the_last_complete_line() { // wimlib redraws in place with \r rather than emitting new lines. let chunk = "Extracting file data: 1 KiB of 10 KiB (10%) done\rExtracting file data: 5 KiB of 10 KiB (50%) done\r"; - let last = chunk.split('\r').filter_map(parse_progress).last(); + let last = chunk.split('\r').filter_map(parse_progress).next_back(); assert_eq!(last.map(|p| p.percent), Some(50)); } } From 644765df88099daa547e15c86eb51e6480d9a038 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sat, 5 Sep 2026 01:04:25 +0200 Subject: [PATCH 22/38] fix(gui): Start works, because the window can be its own session anchor `Environment::ensure_session` starts the anchor as `current_exe() session-anchor ` - whichever binary asked. Only the launcher answered to that argument, so Start from the window spawned a second `raven-gui`, which opened a window, said nothing on the pipe, and was killed thirty seconds later with a banner blaming the kernel. The GUI's headline action could never have succeeded, and the visual check would have met an unexpected window first. The anchor routine moves from the launcher into the library, beside the code that spawns it, and both binaries call it. The window checks its arguments before iced or tokio exist: the anchor's `unshare(CLONE_NEWUSER)` is refused to a process that already has threads. One behaviour of the routine changed in the move - a failure to record the session file now leaves through stdout like every other failure, instead of through an `anyhow` context on a stderr the anchor does not have. The test runs the window binary with its environment cleared, so no display exists for it to open anything on, and expects the anchor protocol's answer on stdout. Before the fix it printed nothing and died trying to create an event loop. --- crates/raven-gui/src/main.rs | 15 +++++ crates/raven-gui/tests/session_anchor.rs | 39 +++++++++++++ crates/raven/src/main.rs | 64 +-------------------- crates/raven/src/session.rs | 71 ++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 63 deletions(-) create mode 100644 crates/raven-gui/tests/session_anchor.rs diff --git a/crates/raven-gui/src/main.rs b/crates/raven-gui/src/main.rs index aef533c..866e055 100644 --- a/crates/raven-gui/src/main.rs +++ b/crates/raven-gui/src/main.rs @@ -331,6 +331,21 @@ fn act( } fn main() -> iced::Result { + // `ensure_session` starts the anchor as `current_exe() session-anchor + // ` - whichever binary asked. From the window, that is this one, + // so it answers here, before iced and tokio exist: the anchor's + // `unshare(CLONE_NEWUSER)` is refused to a process that has threads. + let mut args = std::env::args().skip(1); + if args.next().as_deref() == Some("session-anchor") { + match args.next() { + Some(name) => raven::session::anchor(&name), + None => { + // The launcher reads stdout and nothing else. + println!("session-anchor needs an environment name"); + std::process::exit(1); + } + } + } iced::application(App::boot, App::update, App::view) .subscription(App::subscription) .title("Raven") diff --git a/crates/raven-gui/tests/session_anchor.rs b/crates/raven-gui/tests/session_anchor.rs new file mode 100644 index 0000000..363989b --- /dev/null +++ b/crates/raven-gui/tests/session_anchor.rs @@ -0,0 +1,39 @@ +//! The window can be its own session anchor. +//! +//! `Environment::ensure_session` starts the anchor as `current_exe() +//! session-anchor `, so whichever binary asked for a session has to +//! answer to that argument - the window included, since Start asks. +//! +//! The environment is cleared so that `DISPLAY`, `WAYLAND_DISPLAY` and +//! `WAYLAND_SOCKET` are all unset: whatever this binary does with the +//! argument, it cannot open a window on the machine running the tests. + +use std::process::{Command, Stdio}; + +#[test] +fn session_anchor_is_answered_before_any_window_is_attempted() { + let home = std::env::temp_dir().join(format!("raven-gui-anchor-{}", std::process::id())); + std::fs::create_dir_all(&home).unwrap(); + let out = Command::new(env!("CARGO_BIN_EXE_raven-gui")) + .args(["session-anchor", "nope"]) + .env_clear() + .env("HOME", &home) + .env("PATH", "/usr/bin:/bin") + .stdin(Stdio::null()) + .output() + .expect("raven-gui starts"); + let _ = std::fs::remove_dir_all(&home); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + // The anchor protocol: every failure leaves through stdout with status + // 1, and an environment that does not exist is the first it can hit. + assert_eq!( + out.status.code(), + Some(1), + "stdout: {stdout:?}\nstderr: {stderr:?}" + ); + assert!( + stdout.contains("no environment called \"nope\""), + "the anchor protocol was not answered on stdout.\nstdout: {stdout:?}\nstderr: {stderr:?}" + ); +} diff --git a/crates/raven/src/main.rs b/crates/raven/src/main.rs index 40246cf..88c625c 100644 --- a/crates/raven/src/main.rs +++ b/crates/raven/src/main.rs @@ -238,7 +238,7 @@ fn main() -> Result<()> { run(&e.name, argv, cwd) } Commands::Binfmt => binfmt(), - Commands::SessionAnchor { name } => session_anchor(&name), + Commands::SessionAnchor { name } => raven::session::anchor(&name), Commands::Exec { lower, upper, @@ -699,68 +699,6 @@ fn d3d_cmd( Ok(()) } -/// Holds a namespace open so later launches can join it. -/// -/// Mounts, reports itself, then does nothing for as long as it is wanted. It -/// must stay single-threaded until the mount is done - the kernel refuses -/// `CLONE_NEWUSER` to a threaded process - which is why the readiness line is -/// written only afterwards. -fn session_anchor(name: &str) -> Result<()> { - use std::io::Write as _; - // Every failure below has to leave through stdout: the launcher reads that - // pipe and nothing else, and the anchor's stderr goes to /dev/null because - // it outlives the terminal that started it. A `?` here would print to a - // stderr nobody is holding and the launcher would report only silence. - let report = |what: std::fmt::Arguments<'_>| -> ! { - println!("{what}"); - let _ = std::io::stdout().flush(); - std::process::exit(1); - }; - let e = match env::Environment::open(name) { - Ok(e) => e, - Err(err) => report(format_args!("{err}")), - }; - let spec = match e.spec() { - Ok(s) => s, - Err(err) => report(format_args!("{err}")), - }; - if let Err(err) = std::fs::create_dir_all(&spec.target) { - report(format_args!( - "could not create the mount point {}: {err}", - spec.target.display() - )); - } - // Bring the layer's opaque markers in line with the current shadow set - // before mounting. An environment created when Windows/Fonts was masked - // would otherwise stay masked for ever, and a user should not have to - // rebuild to receive a fix. - if let Err(err) = raven::layer::reconcile(&e.layer()) { - report(format_args!("could not reconcile the layer: {err}")); - } - if !UserNsOverlay::is_available() { - report(format_args!( - "this kernel restricts unprivileged user namespaces; run `raven doctor`" - )); - } - if let Err(err) = UserNsOverlay.mount(&spec) { - report(format_args!("could not mount the overlay: {err}")); - } - let pid = std::process::id(); - // Written only after the mount exists, so a reader of this file never sees - // a session that cannot be joined. - std::fs::write(e.session_file(), format!("{pid}\n")).with_context(|| { - format!( - "could not record the session at {}", - e.session_file().display() - ) - })?; - println!("ready {pid}"); - let _ = std::io::stdout().flush(); - loop { - std::thread::sleep(std::time::Duration::from_secs(3600)); - } -} - fn exec( spec: OverlaySpec, argv: Vec, diff --git a/crates/raven/src/session.rs b/crates/raven/src/session.rs index 2847a55..e898d49 100644 --- a/crates/raven/src/session.rs +++ b/crates/raven/src/session.rs @@ -255,6 +255,77 @@ fn read_line_before(pipe: &std::process::ChildStdout, limit: std::time::Duration out } +/// Holds a namespace open so later launches can join it. +/// +/// Mounts, reports itself on stdout, then does nothing for as long as it is +/// wanted. It must stay single-threaded until the mount is done - the kernel +/// refuses `CLONE_NEWUSER` to a threaded process - which is why the readiness +/// line is written only afterwards, and why a binary has to reach this before +/// starting any runtime of its own. +/// +/// In the library rather than the launcher because `start_session` runs +/// `current_exe()` as the anchor: whichever binary asked for the session is +/// the one that must be able to hold it. The window asks too, and until it +/// could answer, its Start started a second window instead, said nothing on +/// the pipe, and was killed thirty seconds later with a message blaming the +/// kernel. +pub fn anchor(name: &str) -> ! { + use crate::mount::MountBackend as _; + use std::io::Write as _; + // Every failure has to leave through stdout: the launcher reads that pipe + // and nothing else, and the anchor's stderr goes to /dev/null because it + // outlives the terminal that started it. An error on stderr would reach + // nobody, and the launcher would report only silence. + let report = |what: std::fmt::Arguments<'_>| -> ! { + println!("{what}"); + let _ = std::io::stdout().flush(); + std::process::exit(1); + }; + let e = match crate::env::Environment::open(name) { + Ok(e) => e, + Err(err) => report(format_args!("{err}")), + }; + let spec = match e.spec() { + Ok(s) => s, + Err(err) => report(format_args!("{err}")), + }; + if let Err(err) = std::fs::create_dir_all(&spec.target) { + report(format_args!( + "could not create the mount point {}: {err}", + spec.target.display() + )); + } + // Bring the layer's opaque markers in line with the current shadow set + // before mounting. An environment created when Windows/Fonts was masked + // would otherwise stay masked for ever, and a user should not have to + // rebuild to receive a fix. + if let Err(err) = crate::layer::reconcile(&e.layer()) { + report(format_args!("could not reconcile the layer: {err}")); + } + if !crate::mount::UserNsOverlay::is_available() { + report(format_args!( + "this kernel restricts unprivileged user namespaces; run `raven doctor`" + )); + } + if let Err(err) = crate::mount::UserNsOverlay.mount(&spec) { + report(format_args!("could not mount the overlay: {err}")); + } + let pid = std::process::id(); + // Written only after the mount exists, so a reader of this file never + // sees a session that cannot be joined. + if let Err(err) = std::fs::write(e.session_file(), format!("{pid}\n")) { + report(format_args!( + "could not record the session at {}: {err}", + e.session_file().display() + )); + } + println!("ready {pid}"); + let _ = std::io::stdout().flush(); + loop { + std::thread::sleep(std::time::Duration::from_secs(3600)); + } +} + #[cfg(test)] mod tests { use crate::env::{Environment, Manifest}; From 37c15f26422da1a9adf1ce9d01da98c7655fe644 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sat, 5 Sep 2026 01:11:35 +0200 Subject: [PATCH 23/38] fix: a session anchor is recognised by what it runs, not by its name Two places decided whether an environment was held by Raven's own session or by something else with `comm == "raven"`. That held while only the launcher could start a session. The window can now, and its anchor is the window's binary - `current_exe()` - so it is called `raven-gui`, and both tests classified the window's own session as a foreign program: "still in use by 1234 (raven-gui)", with the button that ends the user's programs offered to end it. A holder is now an anchor when its `/proc//cmdline` says so - the first argument is `session-anchor`, whichever binary it is - and `env status` names it among the holders. The rule lives once, on `Holder`, and a test pins it to both binary names and to the things that are not it. --- crates/raven/src/env.rs | 44 +++++++++++++++++++++++++++++++++++++--- crates/raven/src/main.rs | 3 ++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/raven/src/env.rs b/crates/raven/src/env.rs index bd39172..262c61f 100644 --- a/crates/raven/src/env.rs +++ b/crates/raven/src/env.rs @@ -133,7 +133,7 @@ impl Environment { // ensure_not_running, because the environment may not open, and // that is exactly how it came to be the one command still saying // "still running - held by raven". - if holders.iter().all(|h| h.comm == "raven") { + if holders.iter().all(|h| h.anchor) { return Err(Error::SessionHolds(name.to_owned())); } return Err(Error::EnvironmentBusy { @@ -168,7 +168,7 @@ impl Environment { // A session anchor holding it is the ordinary state after a launch, // not a stuck process, and saying "still running - held by raven" // reads like a bug in Raven rather than the thing the user asked for. - if holders.iter().all(|h| h.comm == "raven") { + if holders.iter().all(|h| h.anchor) { return Err(Error::SessionHolds(self.name.clone())); } Err(Error::EnvironmentBusy { @@ -212,6 +212,12 @@ impl Environment { pub struct Holder { pub pid: u32, pub comm: String, + /// Whether this is Raven's own session anchor, recognised by what it is + /// running - ` session-anchor ` - and not by its name. + /// The anchor is whichever binary asked for the session, so the name is + /// `raven` from the launcher and `raven-gui` from the window, and a + /// test on the name called the window's own session a foreign program. + pub anchor: bool, } fn describe(holders: &[Holder]) -> String { @@ -260,12 +266,21 @@ fn holders_of(upper: &std::path::Path) -> Vec { let comm = std::fs::read_to_string(entry.path().join("comm")) .map(|s| s.trim().to_owned()) .unwrap_or_else(|_| "?".to_owned()); - held.push(Holder { pid, comm }); + let anchor = std::fs::read(entry.path().join("cmdline")) + .map(|c| is_anchor_cmdline(&c)) + .unwrap_or(false); + held.push(Holder { pid, comm, anchor }); } } held } +/// Whether a `/proc//cmdline` - NUL-separated argv - is a session +/// anchor's: its first argument is `session-anchor`, whatever the binary. +fn is_anchor_cmdline(cmdline: &[u8]) -> bool { + cmdline.split(|b| *b == 0).nth(1) == Some(b"session-anchor".as_slice()) +} + /// What this upper directory looks like inside `/proc//mountinfo`. /// /// Two escapings stack, and both matter. The option string handed to @@ -507,3 +522,26 @@ mod tests { assert!(env.layer().ends_with("layer")); } } + +#[cfg(test)] +mod anchor_tests { + use super::is_anchor_cmdline; + + #[test] + fn the_anchor_is_recognised_by_what_it_runs_not_by_its_name() { + // Whichever binary asked for the session becomes its anchor, so the + // name is `raven` from the launcher and `raven-gui` from the window. + assert!(is_anchor_cmdline( + b"/usr/bin/raven\0session-anchor\0games\0" + )); + assert!(is_anchor_cmdline( + b"/usr/bin/raven-gui\0session-anchor\0games\0" + )); + // A launcher joining the session, and the programs inside it, are not. + assert!(!is_anchor_cmdline( + b"/usr/bin/raven\0run\0games\0--\0wine\0a.exe\0" + )); + assert!(!is_anchor_cmdline(b"wineserver\0")); + assert!(!is_anchor_cmdline(b"")); + } +} diff --git a/crates/raven/src/main.rs b/crates/raven/src/main.rs index 88c625c..1518f86 100644 --- a/crates/raven/src/main.rs +++ b/crates/raven/src/main.rs @@ -496,7 +496,8 @@ fn env_cmd(cmd: EnvCmd) -> Result<()> { let (n, s) = plural(holders.len()); out!("{name}: running - {n} process{s} holding its C:"); for h in &holders { - out!(" {:>7} {}", h.pid, h.comm); + let role = if h.anchor { " (session anchor)" } else { "" }; + out!(" {:>7} {}{role}", h.pid, h.comm); } out!("Release it: raven env stop {name}"); } From 053319e588d1005ec0fcce5251cba25edb2f1fd0 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sun, 6 Sep 2026 14:23:10 +0200 Subject: [PATCH 24/38] fix: the window can no longer be asked to do what only the launcher can The library re-runs a Raven binary twice: `session-anchor` to hold a session open, and `exec` to mount for a registry import. Both ran `current_exe()`, which is right for the launcher and wrong for the window. The anchor was taught to the window last time; `exec` was not, so the Reproject button launched `raven-gui exec ...`, which fell through to iced and opened a second, unexplained window on the user's screen. Closing that window exits zero, so the import would then report success having projected nothing. Both re-invocations now run the `raven` beside us when there is one - the package installs both binaries into /usr/bin and a cargo build puts both in target/, so there always is - and the window answers a command line instead of opening for it. An argument it does not know is refused on stdout, the stream the library reads, so a fallback can only ever produce a loud failure rather than a window and a false success. The tests run the window binary with its environment cleared, so no display exists to open anything on. Before this, both of them reached iced. --- crates/raven-gui/src/main.rs | 45 ++++++++++++--- crates/raven-gui/tests/helper_verbs.rs | 78 ++++++++++++++++++++++++++ crates/raven/src/registry/mod.rs | 3 +- crates/raven/src/session.rs | 67 +++++++++++++++++++++- 4 files changed, 180 insertions(+), 13 deletions(-) create mode 100644 crates/raven-gui/tests/helper_verbs.rs diff --git a/crates/raven-gui/src/main.rs b/crates/raven-gui/src/main.rs index 866e055..37d1068 100644 --- a/crates/raven-gui/src/main.rs +++ b/crates/raven-gui/src/main.rs @@ -330,21 +330,48 @@ fn act( ) } -fn main() -> iced::Result { - // `ensure_session` starts the anchor as `current_exe() session-anchor - // ` - whichever binary asked. From the window, that is this one, - // so it answers here, before iced and tokio exist: the anchor's - // `unshare(CLONE_NEWUSER)` is refused to a process that has threads. - let mut args = std::env::args().skip(1); - if args.next().as_deref() == Some("session-anchor") { - match args.next() { +/// Answers a command line rather than opening a window, and never returns. +/// +/// The library re-runs a Raven binary for the two things it cannot do in the +/// calling process - holding a session open, and mounting for a registry +/// import - and it prefers the `raven` beside us for both. Should it ever +/// fall back to this binary, falling through to iced would put a second, +/// unexplained window on the user's screen and, worse, hand the caller the +/// exit status of a window they closed: a registry import that projected +/// nothing would report success. So anything that is not a window is +/// answered here, before iced and tokio exist - which `session-anchor` needs +/// anyway, since its `unshare(CLONE_NEWUSER)` is refused to a process that +/// already has threads. +/// +/// Everything is said on stdout: that is the stream the library reads. +fn answer(verb: &str) -> ! { + match verb { + "session-anchor" => match std::env::args().nth(2) { Some(name) => raven::session::anchor(&name), None => { - // The launcher reads stdout and nothing else. println!("session-anchor needs an environment name"); std::process::exit(1); } + }, + "--help" | "-h" => { + println!("raven-gui - Raven's administration window. It takes no arguments."); + println!("The command line is `raven`; run `raven --help` for it."); + std::process::exit(0); + } + "--version" | "-V" => { + println!("raven-gui {}", env!("CARGO_PKG_VERSION")); + std::process::exit(0); } + other => { + println!("raven-gui does not answer {other:?}; run `raven {other}` instead."); + std::process::exit(1); + } + } +} + +fn main() -> iced::Result { + if let Some(verb) = std::env::args().nth(1) { + answer(&verb); } iced::application(App::boot, App::update, App::view) .subscription(App::subscription) diff --git a/crates/raven-gui/tests/helper_verbs.rs b/crates/raven-gui/tests/helper_verbs.rs new file mode 100644 index 0000000..2847d5e --- /dev/null +++ b/crates/raven-gui/tests/helper_verbs.rs @@ -0,0 +1,78 @@ +//! The window must never open a window when it was not asked for one. +//! +//! The library re-runs its own binary for two things - `session-anchor` and +//! the `exec` behind a registry import - so whichever binary asked has to +//! answer, or answer plainly that it cannot. Falling through to iced would +//! put a second, unexplained Raven window on the user's screen and hand the +//! caller a success it did not earn. +//! +//! Every case runs with the environment cleared, so no display exists for a +//! window to open on even if one were attempted. + +use std::process::{Command, Stdio}; + +fn run(args: &[&str]) -> (Option, String, String) { + let home = std::env::temp_dir().join(format!( + "raven-gui-verbs-{}-{}", + std::process::id(), + args.join("_").replace('/', "_") + )); + std::fs::create_dir_all(&home).unwrap(); + let out = Command::new(env!("CARGO_BIN_EXE_raven-gui")) + .args(args) + .env_clear() + .env("HOME", &home) + .env("PATH", "/usr/bin:/bin") + .stdin(Stdio::null()) + .output() + .expect("raven-gui starts"); + let _ = std::fs::remove_dir_all(&home); + ( + out.status.code(), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +#[test] +fn the_exec_verb_is_refused_in_words_rather_than_with_a_window() { + // `registry::import` re-runs `current_exe() exec --lower ... -- `. + // The window cannot mount and exec - it would have to replace itself - + // so it must say so on stdout, which is the only stream the caller reads. + let (code, stdout, stderr) = run(&[ + "exec", + "--lower", + "/nonexistent/lower", + "--upper", + "/nonexistent/upper", + "--work", + "/nonexistent/work", + "--target", + "/nonexistent/target", + "--", + "/bin/true", + ]); + assert_eq!( + code, + Some(1), + "must fail, and visibly.\nstdout: {stdout:?}\nstderr: {stderr:?}" + ); + assert!( + !stderr.contains("Create event loop"), + "a window was attempted for a verb that is not about windows: {stderr:?}" + ); + assert!( + stdout.contains("raven"), + "the caller reads stdout and must be told which binary to run: {stdout:?}" + ); +} + +#[test] +fn an_argument_the_window_does_not_know_never_reaches_iced() { + let (code, stdout, stderr) = run(&["--frobnicate"]); + assert_eq!(code, Some(1), "stdout: {stdout:?}\nstderr: {stderr:?}"); + assert!( + !stderr.contains("Create event loop"), + "an unknown argument must not open a window: {stderr:?}" + ); +} diff --git a/crates/raven/src/registry/mod.rs b/crates/raven/src/registry/mod.rs index 058eb25..558e376 100644 --- a/crates/raven/src/registry/mod.rs +++ b/crates/raven/src/registry/mod.rs @@ -66,8 +66,7 @@ pub fn import( ) -> Result<(), Error> { std::fs::create_dir_all(&spec.target).map_err(|e| Error::Layer(spec.target.clone(), e))?; - let me = std::env::current_exe().map_err(|e| Error::Tool("raven", e))?; - let mut cmd = Command::new(me); + let mut cmd = Command::new(crate::session::helper()?); cmd.arg("exec"); for l in &spec.lower { cmd.arg("--lower").arg(l); diff --git a/crates/raven/src/session.rs b/crates/raven/src/session.rs index e898d49..2b1b56e 100644 --- a/crates/raven/src/session.rs +++ b/crates/raven/src/session.rs @@ -117,8 +117,7 @@ impl Environment { /// Starts an anchor and waits for it to report that the mount is up. fn start_session(&self) -> Result { - let exe = std::env::current_exe().map_err(|e| Error::Tool("raven", e))?; - let mut cmd = Command::new(exe); + let mut cmd = Command::new(helper()?); cmd.arg("session-anchor") .arg(&self.name) .stdin(Stdio::null()) @@ -209,6 +208,35 @@ impl Environment { } } +/// The binary to re-run for the work the library cannot do in this process. +/// +/// Two things are done by re-running Raven: holding a session open, and +/// mounting for a registry import. Both need a process that will be replaced +/// or will sleep for ever, so they cannot happen here - and both used to run +/// `current_exe()`, which is right for the launcher and wrong for the window. +/// The window can answer `session-anchor`, but `exec` would have it mount and +/// exec over itself, so it refuses; running the CLI beside it instead means +/// neither verb depends on which binary the user happened to start. +pub(crate) fn helper() -> Result { + let exe = std::env::current_exe().map_err(|e| Error::Tool("raven", e))?; + Ok(helper_binary(&exe)) +} + +/// `raven` next to `exe` if there is one, else `exe` itself. +/// +/// The package installs both binaries into `/usr/bin` and a cargo build puts +/// both in `target/`, so the sibling exists in every layout Raven +/// ships or is developed in. Where it does not, re-running ourselves is the +/// old behaviour and still correct for the launcher. +fn helper_binary(exe: &std::path::Path) -> PathBuf { + let sibling = exe.with_file_name("raven"); + if sibling.is_file() { + sibling + } else { + exe.to_path_buf() + } +} + /// Removes the start lock however the attempt ends. struct LockGuard(PathBuf); impl Drop for LockGuard { @@ -326,6 +354,41 @@ pub fn anchor(name: &str) -> ! { } } +#[cfg(test)] +mod helper_tests { + use super::helper_binary; + + #[test] + fn the_helper_is_the_cli_beside_us_when_there_is_one() { + // The library re-runs its own binary for `session-anchor` and for the + // `exec` behind a registry import. From the window that would be the + // window - which answers neither well - so a `raven` next to it is + // preferred. The package installs both into /usr/bin and a cargo + // build puts both in target/, so the sibling is there in + // every layout Raven ships or is developed in. + let dir = std::env::temp_dir().join(format!("raven-helper-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let gui = dir.join("raven-gui"); + std::fs::write(&gui, b"").unwrap(); + + // No sibling yet: the only thing to re-run is ourselves. + assert_eq!(helper_binary(&gui), gui); + + let cli = dir.join("raven"); + std::fs::write(&cli, b"").unwrap(); + assert_eq!( + helper_binary(&gui), + cli, + "the CLI beside us answers both verbs" + ); + // The CLI itself is its own helper, not a self-reference through a + // path that happens to have the same name. + assert_eq!(helper_binary(&cli), cli); + let _ = std::fs::remove_dir_all(&dir); + } +} + #[cfg(test)] mod tests { use crate::env::{Environment, Manifest}; From 838bf920f68ae74c6d30d6dcda8b020ab0d379c0 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sun, 6 Sep 2026 14:24:11 +0200 Subject: [PATCH 25/38] perf: a warm launch no longer scans every process to find one of them `session` answers "is the anchor I recorded still holding this environment?" - a question about one pid - and answered it by reading `/proc//mountinfo` for every process on the machine. That scan was measured at 37 ms on a 454-process machine, out of a 32 ms warm launch: Raven's whole cost on the path every double-clicked program takes was the scan, and `run` pays it before it can join anything. Reading the one process's `mountinfo` gives the same answer - the scan's only filter is that needle - so `holds` asks about one pid and `session` uses it. Measured on the same machine and environment, `raven run games -- /bin/true` with a session up: before 33 35 32 32 32 33 30 ms after 2 2 2 2 2 2 2 ms The check that made a recorded pid trustworthy is unchanged, and the integration test now pins both halves of it against a real mount: the cheap question must agree with the scan that found the holder, and a recorded pid that holds nothing must still be refused. --- crates/raven/src/env.rs | 14 +++++++++++ crates/raven/src/session.rs | 2 +- crates/raven/tests/environment_recovery.rs | 28 ++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/raven/src/env.rs b/crates/raven/src/env.rs index 262c61f..325eaa9 100644 --- a/crates/raven/src/env.rs +++ b/crates/raven/src/env.rs @@ -155,6 +155,20 @@ impl Environment { holders_of(&self.upper()) } + /// Whether one particular process is holding this environment's C:. + /// + /// `holders` asks that of every process on the machine; this asks it of + /// one. The difference is the launch path's whole cost: validating the + /// recorded anchor is a question about a single pid, and answering it by + /// scanning `/proc` was measured at 37 ms of a 32 ms warm launch - the + /// scan *was* the launch. Reading one `mountinfo` gives the same answer. + pub fn holds(&self, pid: u32) -> bool { + let needle = mountinfo_needle(&self.upper()); + std::fs::read_to_string(format!("/proc/{pid}/mountinfo")) + .map(|mi| mi.contains(&needle)) + .unwrap_or(false) + } + /// Refuses while the environment is held by live processes. /// /// overlayfs will not mount the same upper layer twice, so a second diff --git a/crates/raven/src/session.rs b/crates/raven/src/session.rs index 2b1b56e..d98e6f6 100644 --- a/crates/raven/src/session.rs +++ b/crates/raven/src/session.rs @@ -53,7 +53,7 @@ impl Environment { .trim() .parse() .ok()?; - self.holders().iter().any(|h| h.pid == pid).then_some(pid) + self.holds(pid).then_some(pid) } /// The pid of a session to join, starting one if none is running. diff --git a/crates/raven/tests/environment_recovery.rs b/crates/raven/tests/environment_recovery.rs index 1a7ea22..ae0dcf0 100644 --- a/crates/raven/tests/environment_recovery.rs +++ b/crates/raven/tests/environment_recovery.rs @@ -82,6 +82,34 @@ fn a_process_left_in_the_namespace_is_found_and_stopped() { "the process inside the namespace must be visible from outside; saw {holders:?}" ); + // The launch path does not ask who holds the environment - it asks + // whether the anchor it recorded still does, which is one process, not + // every process on the machine. The cheap question must give the same + // answer as the expensive one, or a warm launch either misses a live + // session or joins a dead one. + assert!( + env.holds(child.id()), + "the single-process check must agree with the scan that found it" + ); + assert!( + !env.holds(1), + "a live process that holds nothing of ours is not a session" + ); + // And the recorded pid is believed only when it is really a holder. + fs::write(env.session_file(), "1\n").unwrap(); + assert_eq!( + env.session(), + None, + "a stale pid must not pass for a session" + ); + fs::write(env.session_file(), format!("{}\n", child.id())).unwrap(); + assert_eq!( + env.session(), + Some(child.id()), + "a real holder is the session" + ); + let _ = fs::remove_file(env.session_file()); + let stopped = env.stop().expect("stop must release the environment"); assert!( stopped.iter().any(|h| h.pid == child.id()), From bebcaf7c8cd1237e29d39b0c878d8c67ad4ee61d Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sun, 6 Sep 2026 14:25:40 +0200 Subject: [PATCH 26/38] fix: a program given by a relative path is found where the user meant it `run` joins the session's mount namespace before it execs, and `setns(CLONE_NEWNS)` resets the process's working directory to that namespace's root. Everything still relative by then was therefore resolved against `/`: `raven launch demo/game.exe` had wine look under `/demo`, and a bare `game.exe` - whose directory was deliberately left unset - under `/`. Only an absolute path worked, which is what a file manager passes and why this survived a corpus. `launch::target` settles both the program and its directory while the caller's own directory is still ours. It is lexical rather than `canonicalize`, because a program inside a live environment lives under a mount point that exists only in the anchor's namespace - resolving through the filesystem would fail for exactly the paths that do work. A working directory that is not there is now skipped instead of fatal, and tested where the answer is true - inside the namespace. Setting it anyway made `exec` fail with an ENOENT that the message rendered as "could not run wine", which sent the reader after a Wine that was installed and fine. --- crates/raven/src/launch.rs | 81 ++++++++++++++++++++++++++++++++++++++ crates/raven/src/main.rs | 16 +++++--- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/crates/raven/src/launch.rs b/crates/raven/src/launch.rs index 97ca061..d3042c5 100644 --- a/crates/raven/src/launch.rs +++ b/crates/raven/src/launch.rs @@ -170,6 +170,39 @@ pub fn resolve(exe: &Path) -> Result { } } +/// The program to run and the directory to run it from, both absolute. +/// +/// `run` joins the session's mount namespace before it execs, and +/// `setns(CLONE_NEWNS)` resets the process's working directory to that +/// namespace's root. So every relative path is resolved against `/` by the +/// time wine sees it - `demo/game.exe` is looked for at `/demo/game.exe`, +/// and a bare `game.exe`, whose directory used to be left unset, at +/// `/game.exe`. Settling both here, while the caller's directory is still +/// ours, is what makes a relative path mean what the user meant. +/// +/// Lexical, not `canonicalize`: a program inside a live environment is under +/// a mount point that exists only in the anchor's namespace, so resolving +/// through the filesystem would fail for exactly the paths that work. +pub fn target(exe: &Path) -> Result<(PathBuf, PathBuf), Error> { + let here = std::env::current_dir().map_err(|e| Error::Tool("raven", e))?; + Ok(resolve_against(&here, exe)) +} + +fn resolve_against(here: &Path, exe: &Path) -> (PathBuf, PathBuf) { + let mut full = PathBuf::new(); + // `Component::CurDir` is dropped by pushing components; `..` is kept as + // written rather than collapsed, since collapsing it would change which + // directory a symlink means. + for c in here.join(exe).components() { + match c { + std::path::Component::CurDir => {} + other => full.push(other), + } + } + let dir = full.parent().unwrap_or(here).to_path_buf(); + (full, dir) +} + /// The environment used for programs that are not inside one. pub fn default_environment() -> Result, Error> { let file = paths::config_dir()?.join("default-environment"); @@ -194,6 +227,54 @@ pub fn conf_path() -> PathBuf { PathBuf::from("/etc/binfmt.d/raven.conf") } +#[cfg(test)] +mod target_tests { + use super::resolve_against; + use std::path::Path; + + #[test] + fn a_relative_program_is_resolved_before_the_namespace_moves_underneath_it() { + // `run` joins the session's mount namespace before it execs, and + // `setns(CLONE_NEWNS)` resets the working directory to that + // namespace's root. Anything still relative by then is looked for + // under `/`, so every form has to be settled here, against the + // directory the user actually typed it in. + let here = Path::new("/home/u/games"); + assert_eq!( + resolve_against(here, Path::new("demo/game.exe")), + ( + Path::new("/home/u/games/demo/game.exe").to_path_buf(), + Path::new("/home/u/games/demo").to_path_buf() + ) + ); + // A bare name used to leave the directory unset, which after the + // join means `/` - the program's own files would be looked for at + // the root of the mount. + assert_eq!( + resolve_against(here, Path::new("game.exe")), + ( + Path::new("/home/u/games/game.exe").to_path_buf(), + here.to_path_buf() + ) + ); + assert_eq!( + resolve_against(here, Path::new("./game.exe")), + ( + Path::new("/home/u/games/game.exe").to_path_buf(), + here.to_path_buf() + ) + ); + // An absolute path is already what it means. + assert_eq!( + resolve_against(here, Path::new("/opt/g/game.exe")), + ( + Path::new("/opt/g/game.exe").to_path_buf(), + Path::new("/opt/g").to_path_buf() + ) + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/raven/src/main.rs b/crates/raven/src/main.rs index 1518f86..a7e4b05 100644 --- a/crates/raven/src/main.rs +++ b/crates/raven/src/main.rs @@ -215,6 +215,10 @@ fn main() -> Result<()> { Commands::Env(c) => env_cmd(c), Commands::Run { name, argv } => run(&name, argv, None), Commands::Launch { exe, args } => { + // Both absolute before anything joins a namespace: `setns` resets + // the working directory, so a relative path settled later is + // resolved against the mount's root instead of the user's. + let (exe, cwd) = launch::target(&exe)?; let e = launch::resolve(&exe)?; // The kernel invokes this with no terminal of its own, so when a // double-clicked program misbehaves there is nothing to look at. @@ -231,11 +235,7 @@ fn main() -> Result<()> { let mut argv = vec!["wine".to_string(), exe.display().to_string()]; argv.extend(args); // The program's own directory, as Windows would give it. - let cwd = exe - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .map(PathBuf::from); - run(&e.name, argv, cwd) + run(&e.name, argv, Some(cwd)) } Commands::Binfmt => binfmt(), Commands::SessionAnchor { name } => raven::session::anchor(&name), @@ -634,7 +634,11 @@ fn run(name: &str, argv: Vec, cwd: Option) -> Result<()> { let mut cmd = Command::new(&argv[0]); cmd.args(&argv[1..]); cmd.env("WINEPREFIX", e.prefix()); - if let Some(d) = cwd { + // Tested inside the namespace, which is the only place the answer is + // true, and skipped rather than fatal: a directory that is not there is + // no reason to refuse to start the program. Setting it anyway makes + // `exec` fail with an ENOENT that reads as "wine is missing". + if let Some(d) = cwd.filter(|d| d.is_dir()) { cmd.current_dir(d); } Err(cmd.exec()).with_context(|| format!("could not run {}", argv[0])) From cc577453a240cb0913b0f44451ecdfb5fe7fd452 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sun, 6 Sep 2026 14:26:39 +0200 Subject: [PATCH 27/38] fix: detach touches only the drives Raven itself attached Two ways the same asymmetry showed. `attach` refuses a letter that already carries any mapping, on the grounds that a `"d:"="cdrom"` made in winecfg is not Raven's to overwrite - but `detach` accepted any entry at all and deleted it, so `raven env detach d` silently destroyed a mapping Raven never made. It now recognises its own entries by the form it writes, and says the letter is not attached otherwise. And the renumbering counted the wrong set. mountmgr numbers a drive by its rank in the Drives section whether or not anything is wired to it, which is what `attach` and `attachments` both count; `detach` renumbered by rank among the entries that had a device behind them. With any floppy entry that has no raw link - a half-finished attach, or one made by hand - every survivor was then wired to a `physicaldriveN` naming a different disk from the one `attachments` reports. --- crates/raven/src/attach.rs | 23 +++++--- crates/raven/tests/device_attach.rs | 83 +++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/crates/raven/src/attach.rs b/crates/raven/src/attach.rs index 3602d61..7702c0a 100644 --- a/crates/raven/src/attach.rs +++ b/crates/raven/src/attach.rs @@ -145,8 +145,13 @@ impl Environment { // Either half alone still counts: a detach that failed midway must // be re-runnable until nothing is left. + // Only entries Raven wrote are Raven's to remove. `attach` refuses a + // letter that already carries any mapping, so a `"d:"="cdrom"` the + // user made in winecfg was never ours - and deleting it here would + // be the destruction `attach` went out of its way to avoid. + // `disk_letters` matches exactly the form `attach` writes. let target = std::fs::read_link(&raw).ok(); - if target.is_none() && !has_drive_entry(&text, letter) { + if target.is_none() && !disk_letters(&text).contains(&letter) { return Err(Error::NotAttached(letter)); } let updated = edit_drives_section(&text, letter, None); @@ -161,11 +166,17 @@ impl Environment { // therefore here. First drop every link that belongs to an // attachment (or to the device just detached), then re-wire. let mut owned: Vec = target.into_iter().collect(); - let survivors: Vec<(char, PathBuf)> = disk_letters(&updated) + // Rank among *every* remaining disk, not among the ones with a device + // behind them: mountmgr counts an entry whether or not anything is + // wired to it, which is what `attach` and `attachments` count too. A + // rank taken from the linked subset would wire a survivor to a number + // naming a different disk. + let survivors: Vec<(usize, PathBuf)> = disk_letters(&updated) .iter() - .filter_map(|&l| { + .enumerate() + .filter_map(|(rank, &l)| { let t = std::fs::read_link(dos.join(format!("{l}::"))).ok()?; - Some((l, t)) + Some((rank + 1, t)) }) .collect(); owned.extend(survivors.iter().map(|(_, t)| t.clone())); @@ -181,8 +192,8 @@ impl Environment { } } } - for (rank, (_, t)) in survivors.iter().enumerate() { - let phys = dos.join(format!("physicaldrive{}", rank + 1)); + for (number, t) in &survivors { + let phys = dos.join(format!("physicaldrive{number}")); std::os::unix::fs::symlink(t, &phys).map_err(|e| Error::Layer(phys, e))?; } diff --git a/crates/raven/tests/device_attach.rs b/crates/raven/tests/device_attach.rs index c055ce1..fce07ab 100644 --- a/crates/raven/tests/device_attach.rs +++ b/crates/raven/tests/device_attach.rs @@ -198,6 +198,89 @@ fn detaching_renumbers_the_disks_that_remain() { let _ = fs::remove_dir_all(&env.root); } +#[test] +fn detach_leaves_alone_a_drive_letter_raven_did_not_create() { + // winecfg's own mappings live in the same Drives section. `attach` + // refuses a letter that carries one; `detach` must not delete it either, + // and there is nothing of Raven's on that letter to report detaching. + let env = fake_env("foreign"); + let reg = env.prefix().join("system.reg"); + let text = fs::read_to_string(®).unwrap(); + fs::write( + ®, + format!("{text}\n[Software\\\\Wine\\\\Drives] 1700000000\n\"d:\"=\"cdrom\"\n"), + ) + .unwrap(); + + assert!( + matches!(env.detach('d'), Err(raven::Error::NotAttached('d'))), + "a letter Raven never attached is not Raven's to detach" + ); + assert!( + fs::read_to_string(®) + .unwrap() + .contains("\"d:\"=\"cdrom\""), + "the user's own mapping must survive" + ); + + let _ = fs::remove_dir_all(&env.root); +} + +#[test] +fn renumbering_counts_the_drives_mountmgr_counts() { + // mountmgr numbers by rank in the Drives section, and a floppy-typed + // entry with no raw link behind it - a half-finished attach, or one made + // by hand - still takes a number. `attach` counts it; `detach` has to + // count it too, or every later drive is wired to a number that names a + // different disk. + let devs = some_block_devices(); + if devs.len() < 2 { + eprintln!("skipped: needs two block devices, found {}", devs.len()); + return; + } + let env = fake_env("rank"); + let dos = env.prefix().join("dosdevices"); + let reg = env.prefix().join("system.reg"); + + let d = env.attach(&devs[0], 'd').unwrap(); + let f = env.attach(&devs[1], 'f').unwrap(); + assert_eq!((d.number, f.number), (1, 2)); + + // A floppy entry on e: with nothing behind it, sitting between them. + let text = fs::read_to_string(®).unwrap(); + fs::write( + ®, + text.replace("\"f:\"=\"floppy\"", "\"e:\"=\"floppy\"\n\"f:\"=\"floppy\""), + ) + .unwrap(); + assert_eq!( + env.attachments() + .iter() + .find(|a| a.letter == 'f') + .map(|a| a.number), + Some(3), + "f: is now the third disk, because e: takes a number" + ); + + env.detach('d').unwrap(); + // d: is gone, so e: is first and f: second - and f: is the only one of + // them with a device behind it. + assert_eq!( + env.attachments() + .iter() + .find(|a| a.letter == 'f') + .map(|a| a.number), + Some(2) + ); + assert_eq!( + fs::read_link(dos.join("physicaldrive2")).unwrap(), + devs[1], + "the link must carry the number attachments() reports" + ); + + let _ = fs::remove_dir_all(&env.root); +} + #[test] fn attach_refuses_what_would_eat_a_disk() { let env = fake_env("refuse"); From 1b26eb47543802000dd1de1aa3639be07eb5f563 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sun, 6 Sep 2026 14:27:56 +0200 Subject: [PATCH 28/38] fix: an interrupted deploy no longer leaves a base nothing can finish `base deploy` applied a Windows straight into the directory that names it, and cleaned up only when `wimlib-imagex` exited non-zero. That is the one failure it cannot count on: ten minutes over 143 886 files is long enough to be interrupted, and an interruption usually kills Raven too, so the cleanup never runs. What was left was half a Windows wearing a finished base's name - listed by `base list`, offered to `env create`, refused by a second `deploy` as already existing, and removable only by hand, since there is no command that deletes a base. It now applies into a hidden partial directory and renames only once the image is whole and its junctions repointed. A rename within one directory is atomic, so the name appears on a finished base or not at all, and a leftover partial is neither listed nor in the way: the next deploy clears it. --- crates/raven/src/base.rs | 59 ++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/crates/raven/src/base.rs b/crates/raven/src/base.rs index 5f2febe..442221a 100644 --- a/crates/raven/src/base.rs +++ b/crates/raven/src/base.rs @@ -39,6 +39,7 @@ impl Base { .map_err(|e| Error::Layer(dir.clone(), e))? .filter_map(Result::ok) .filter(|e| e.path().is_dir()) + .filter(|e| is_base_dir(&e.file_name().to_string_lossy())) .map(|e| Base { id: e.file_name().to_string_lossy().into_owned(), path: e.path(), @@ -129,35 +130,58 @@ fn parse_editions(text: &str) -> Vec { /// Refuses to touch a base that already exists: bases are immutable, and /// "deploy over the top" is how an immutable thing quietly stops being one. pub fn deploy(image: &Path, index: u32, id: &str) -> Result { - let dir = paths::bases_dir()?.join(paths::check_name(id)?); + let bases = paths::bases_dir()?; + let dir = bases.join(paths::check_name(id)?); if dir.exists() { return Err(Error::BaseExists(id.to_owned())); } - std::fs::create_dir_all(&dir).map_err(|e| Error::Layer(dir.clone(), e))?; + // Applied beside the real name and renamed only once it is whole. Ten + // minutes over 143 886 files is long enough to be interrupted, and the + // cleanup below cannot run when the interruption is the process dying: + // what was left then was a directory holding half a Windows under the + // name of a finished one, which `deploy` refused to overwrite and no + // command could remove. + let partial = bases.join(partial_name(id)); + let _ = std::fs::remove_dir_all(&partial); + std::fs::create_dir_all(&partial).map_err(|e| Error::Layer(partial.clone(), e))?; let status = Command::new("wimlib-imagex") .arg("apply") .arg(image) .arg(index.to_string()) - .arg(&dir) + .arg(&partial) .status() .map_err(|e| Error::Tool("wimlib-imagex", e))?; if !status.success() { - // A half-applied base is worse than none: it looks deployable. - let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&partial); return Err(Error::ToolFailed( "wimlib-imagex apply", format!("exited with {status}"), )); } - let base = Base { + // Before the rename, so the name only ever appears on a base whose + // junctions already resolve inside it. + repoint_absolute_symlinks(&partial)?; + std::fs::rename(&partial, &dir).map_err(|e| Error::Layer(dir.clone(), e))?; + Ok(Base { id: id.to_owned(), path: dir, - }; - repoint_absolute_symlinks(&base.path)?; - Ok(base) + }) +} + +/// Where a deploy applies before it has earned the real name. +/// +/// Hidden, so `list` passes over it: a base is a finished Windows, and +/// anything else under that directory is Raven's own working state. +fn partial_name(id: &str) -> String { + format!(".partial-{id}") +} + +/// Whether a directory in the bases folder is a deployed Windows. +fn is_base_dir(name: &str) -> bool { + !name.starts_with('.') } /// Rewrites the reparse points a WIM leaves behind as absolute symlinks. @@ -217,6 +241,23 @@ fn relative_to(target: &Path, from: &Path) -> Option { (!rel.as_os_str().is_empty()).then_some(rel) } +#[cfg(test)] +mod partial_tests { + use super::{is_base_dir, partial_name}; + + #[test] + fn a_half_applied_base_is_never_mistaken_for_a_finished_one() { + // A deploy applies into the partial name and renames only when it + // has finished, so an interrupted one leaves that directory behind. + // It must not be listed, offered to `env create`, or block a retry. + assert_eq!(partial_name("win11-26200-pro"), ".partial-win11-26200-pro"); + assert!(!is_base_dir(&partial_name("win11-26200-pro"))); + assert!(is_base_dir("win11-26200-pro")); + // Nothing hidden is a base; that is what makes the partial name safe. + assert!(!is_base_dir(".anything")); + } +} + #[cfg(test)] mod tests { use super::*; From 315d224dfa9f07c792fc517e77b3a68eb36560d4 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sun, 6 Sep 2026 14:30:30 +0200 Subject: [PATCH 29/38] fix: a Direct3D install that fails part way can still be undone Installing a runtime copies libraries into the upper layer, edits the prefix's registry, and only then records what it did. Everything between the first copy and that record could fail - and did, in a full disk or a read-only prefix - leaving libraries Raven had put there and did not list. `dxvk` reported nothing installed, `--remove` removed nothing while saying the real Windows was uncovered, and the next install refused each of those files as somebody else's, telling the user to move aside what Raven had written itself. The record now goes down as soon as the files exist and describes a superset of them until the install narrows it at the end. `d3d` already skips entries that are not files, so a superset costs nothing and always leaves something that `--remove` can act on. The rollback also stopped short of the previous install. An upgrade copies over the libraries already there, and undoing it deleted them too, so a failed upgrade uninstalled the build that was working. It now removes only the files this call created. Both are pinned by tests that fail without the change: one makes the registry unwritable after the copies, the other fails an upgrade half way and requires every library of the working build to survive it. --- crates/raven/src/d3d.rs | 50 +++++++++--- crates/raven/tests/d3d_install.rs | 128 ++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 crates/raven/tests/d3d_install.rs diff --git a/crates/raven/src/d3d.rs b/crates/raven/src/d3d.rs index 88fd428..6393a77 100644 --- a/crates/raven/src/d3d.rs +++ b/crates/raven/src/d3d.rs @@ -132,8 +132,14 @@ impl Environment { } let mut written: Vec = Vec::new(); + let mut created: Vec = Vec::new(); let mut done = Vec::new(); for (from, to, rel, dll, arch) in &plan { + // Whether this call is the reason the file is there. An upgrade + // copies over the previous install's libraries, and undoing that + // by deleting them would turn a failed upgrade into an uninstall + // of the build that was working. + let is_new = !to.exists(); let copy = (|| { if let Some(parent) = to.parent() { std::fs::create_dir_all(parent)?; @@ -143,11 +149,14 @@ impl Environment { if let Err(e) = copy { // Undo the half-install rather than leave the environment in a // state neither `dxvk` nor `--remove` can describe. - for r in &written { + for r in &created { let _ = std::fs::remove_file(self.upper().join(r)); } return Err(Error::Layer(to.clone(), e)); } + if is_new { + created.push(rel.clone()); + } written.push(rel.clone()); done.push(Shadow { dll: (*dll).to_string(), @@ -159,6 +168,24 @@ impl Environment { written.sort(); written.dedup(); + // The record goes down as soon as the files exist, and describes a + // superset of them from here on. Everything below can fail - the + // superseded sweep, reading and rewriting user.reg - and until this + // was written first, a failure there left libraries in the upper + // layer that `dxvk` could not see, `--remove` could not remove, and + // the next install refused as somebody else's, telling the user to + // move aside files Raven had put there itself. `d3d` already skips + // manifest entries that are not files, so a superset is harmless. + let version = root + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "unknown".into()); + let m = self.d3d_manifest_path(rt); + let mut described: Vec = written.iter().chain(ours.iter()).cloned().collect(); + described.sort(); + described.dedup(); + write_manifest(&m, &version, &described)?; + // Installing over an older build is the normal way to update, and // upstream drops modules between versions - d3d10.dll went that way. // Anything the previous install left that this one does not replace is @@ -192,16 +219,9 @@ impl Environment { } text::write_atomic(®, &text)?; - // The build's own directory name is the only version DXVK ships in a - // release, and "which DXVK do I have" is the first question after "is - // it installed" - so it is recorded rather than left to be guessed. - let version = root - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "unknown".into()); - let m = self.d3d_manifest_path(rt); - let body = format!("#build {version}\n{}\n", written.join("\n")); - std::fs::write(&m, body).map_err(|e| Error::Layer(m, e))?; + // Narrowed to exactly what is installed now that the superseded + // files are gone and the overrides agree with them. + write_manifest(&m, &version, &written)?; Ok(done) } @@ -313,6 +333,14 @@ fn unique_dlls(done: &[Shadow]) -> Vec { /// A directory holding the DXVK build, plus a guard that deletes it again if we /// created it by extracting an archive. +/// Records which libraries Raven has put in the environment, and which build +/// they came from - "which DXVK do I have" being the first question after +/// "is one installed". +fn write_manifest(path: &std::path::Path, version: &str, files: &[String]) -> Result<(), Error> { + let body = format!("#build {version}\n{}\n", files.join("\n")); + std::fs::write(path, body).map_err(|e| Error::Layer(path.to_path_buf(), e)) +} + fn unpack(source: &Path) -> Result<(PathBuf, Option), Error> { if source.is_dir() { return Ok((source.to_path_buf(), None)); diff --git a/crates/raven/tests/d3d_install.rs b/crates/raven/tests/d3d_install.rs new file mode 100644 index 0000000..92f7506 --- /dev/null +++ b/crates/raven/tests/d3d_install.rs @@ -0,0 +1,128 @@ +//! What an interrupted Direct3D install leaves behind. +//! +//! Installing a runtime copies libraries into the upper layer, then edits +//! the prefix's registry, then records what it did. Everything between the +//! first copy and that record can fail, and what the record says afterwards +//! decides whether the environment can be repaired: a library Raven put +//! there but does not list is one `dxvk` cannot see, `--remove` cannot +//! remove, and the next install refuses as somebody else's. + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; + +use raven::d3d::DXVK; +use raven::env::{Environment, Manifest}; + +fn fake_env(name: &str) -> Environment { + let root = std::env::temp_dir().join(format!("raven-d3d-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("prefix/dosdevices")).unwrap(); + fs::create_dir_all(root.join("upper/Windows/System32")).unwrap(); + fs::create_dir_all(root.join("upper/Windows/SysWOW64")).unwrap(); + fs::write( + root.join("prefix/user.reg"), + "WINE REGISTRY Version 2\n\n[Software\\\\Fluff] 100\n\"x\"=\"y\"\n", + ) + .unwrap(); + Environment { + name: name.into(), + manifest: Manifest { + base: "none".into(), + }, + root, + } +} + +/// A directory shaped like a DXVK release, with the libraries it names. +fn fake_build(root: &PathBuf, version: &str) -> PathBuf { + let build = root.join(version); + for arch in ["x64", "x32"] { + let dir = build.join(arch); + fs::create_dir_all(&dir).unwrap(); + for dll in ["d3d11", "dxgi"] { + fs::write(dir.join(format!("{dll}.dll")), version.as_bytes()).unwrap(); + } + } + build +} + +#[test] +fn a_failure_after_the_copies_leaves_libraries_raven_can_still_account_for() { + let env = fake_env("interrupted"); + let build = fake_build(&env.root, "dxvk-2.7"); + + // The registry is what fails: read it, then refuse the rewrite. The + // prefix is made unwritable, which is exactly what a full disk or a + // read-only home does to the same step. + let prefix = env.prefix(); + let before = fs::metadata(&prefix).unwrap().permissions(); + fs::set_permissions(&prefix, fs::Permissions::from_mode(0o500)).unwrap(); + let outcome = env.install_d3d(&DXVK, &build); + fs::set_permissions(&prefix, before).unwrap(); + + assert!(outcome.is_err(), "the registry could not be written"); + + // The libraries are on disk. What matters is that Raven says so, rather + // than reporting an empty install and then refusing to touch its own + // files as though a stranger had put them there. + let listed = env.d3d(&DXVK); + assert!( + !listed.is_empty(), + "the copies happened, so they must be accounted for" + ); + for shadow in &listed { + assert!( + shadow.path.is_file(), + "{} is listed and must be on disk", + shadow.path.display() + ); + } + let removed = env.remove_d3d(&DXVK).expect("removal must be possible"); + assert_eq!(removed, listed.len(), "everything listed comes back out"); + assert!( + env.d3d(&DXVK).is_empty(), + "and nothing of Raven's is left behind" + ); + + let _ = fs::remove_dir_all(&env.root); +} + +#[test] +fn a_failed_upgrade_does_not_take_the_working_build_with_it() { + let env = fake_env("upgrade"); + let old = fake_build(&env.root, "dxvk-2.4"); + env.install_d3d(&DXVK, &old).expect("the first install works"); + let installed: Vec = env.d3d(&DXVK).into_iter().map(|s| s.path).collect(); + assert_eq!(installed.len(), 4, "two libraries, two architectures"); + + // An upgrade whose second architecture cannot be read: x32 is there in + // the plan and unreadable by the time it is copied. + let new = fake_build(&env.root, "dxvk-2.7"); + fs::set_permissions( + new.join("x32/dxgi.dll"), + fs::Permissions::from_mode(0o000), + ) + .unwrap(); + let outcome = env.install_d3d(&DXVK, &new); + fs::set_permissions(new.join("x32/dxgi.dll"), fs::Permissions::from_mode(0o644)).unwrap(); + assert!(outcome.is_err(), "the upgrade could not be completed"); + + // Whatever the mixture of versions now on disk, every library the + // environment had is still there and still described. The rollback must + // not delete files that were a working install before this call. + for path in &installed { + assert!( + path.is_file(), + "{} was working before the upgrade and must survive it", + path.display() + ); + } + assert_eq!( + env.d3d(&DXVK).len(), + installed.len(), + "and all of them are still accounted for" + ); + + let _ = fs::remove_dir_all(&env.root); +} From 4981d813c8f103e42d64f6debc7a5388667155a4 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sun, 6 Sep 2026 14:31:22 +0200 Subject: [PATCH 30/38] fix: two silent failures in creating and reading an environment A rules file that exists and cannot be read was treated as one that is not there, and "not there" means the defaults. The defaults are the *wider* set, so a file someone had narrowed by hand - saved by an editor in Latin-1 with one accented character, or left unreadable - was replaced by rules that project more of the base's registry than its author allowed, without a word. Only a genuinely absent file falls back now; anything else is reported with the path. And `create`'s rollback used `remove_dir_all`, which this module documents as unable to delete an environment that has ever been mounted: overlayfs leaves a `work/work` with no permissions and the removal stops there, having already deleted the upper layer. Creating an environment mounts it, so the rollback was exactly the case that cannot work; it now uses the same `remove_tree` that `destroy` does. --- crates/raven/src/env.rs | 50 +++++++++++++++++++++++++++++-- crates/raven/tests/d3d_install.rs | 11 +++---- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/crates/raven/src/env.rs b/crates/raven/src/env.rs index 325eaa9..c39c3af 100644 --- a/crates/raven/src/env.rs +++ b/crates/raven/src/env.rs @@ -49,11 +49,17 @@ impl Environment { self.root.join("registry-rules.toml") } + /// An environment created before the file existed has none, and the + /// defaults are the right answer for it. Any other failure to read is + /// reported: the defaults are the *wider* set, so quietly substituting + /// them for a file someone narrowed by hand projects more of the base's + /// registry than its author allowed - and says nothing. pub fn rules(&self) -> Result { match std::fs::read_to_string(self.rules_file()) { Ok(text) => registry::Rules::parse(&text) .map_err(|e| Error::Manifest(self.rules_file(), e.to_string())), - Err(_) => Ok(registry::Rules::default()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(registry::Rules::default()), + Err(e) => Err(Error::Layer(self.rules_file(), e)), } } @@ -422,11 +428,51 @@ pub fn create(name: &str, base_id: &str) -> Result { })(); if built.is_err() { - let _ = std::fs::remove_dir_all(&root); + // `remove_tree`, not `remove_dir_all`: creating an environment mounts + // it - `project_registry` does - and overlayfs leaves a `work/work` + // with no permissions at all, which stops a plain removal after it + // has already deleted the upper layer. The wreck that leaves is + // refused by a second `create` and by every command that opens it. + let _ = remove_tree(&root); } built } +#[cfg(test)] +mod rules_tests { + use super::{Environment, Manifest}; + + #[test] + fn a_rules_file_that_cannot_be_read_is_not_silently_replaced() { + // The default rules are the *wider* set, so falling back to them + // when a hand-narrowed file cannot be read projects more of the + // base's registry than its author allowed - the opposite of the + // conservative direction, and invisible. + let root = std::env::temp_dir().join(format!("raven-rules-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let env = Environment { + name: "rules-test".into(), + manifest: Manifest { + base: "none".into(), + }, + root: root.clone(), + }; + + // Absent is the one case that legitimately means "the defaults". + assert!(env.rules().is_ok(), "no file means the default rules"); + + // Not valid UTF-8: an editor saving a comment in Latin-1 is enough. + std::fs::write(env.rules_file(), [0xff, 0xfe, 0x41]).unwrap(); + assert!( + env.rules().is_err(), + "a rules file that exists and cannot be read must be reported" + ); + + let _ = std::fs::remove_dir_all(&root); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/raven/tests/d3d_install.rs b/crates/raven/tests/d3d_install.rs index 92f7506..6b2b24f 100644 --- a/crates/raven/tests/d3d_install.rs +++ b/crates/raven/tests/d3d_install.rs @@ -35,7 +35,7 @@ fn fake_env(name: &str) -> Environment { } /// A directory shaped like a DXVK release, with the libraries it names. -fn fake_build(root: &PathBuf, version: &str) -> PathBuf { +fn fake_build(root: &std::path::Path, version: &str) -> PathBuf { let build = root.join(version); for arch in ["x64", "x32"] { let dir = build.join(arch); @@ -92,18 +92,15 @@ fn a_failure_after_the_copies_leaves_libraries_raven_can_still_account_for() { fn a_failed_upgrade_does_not_take_the_working_build_with_it() { let env = fake_env("upgrade"); let old = fake_build(&env.root, "dxvk-2.4"); - env.install_d3d(&DXVK, &old).expect("the first install works"); + env.install_d3d(&DXVK, &old) + .expect("the first install works"); let installed: Vec = env.d3d(&DXVK).into_iter().map(|s| s.path).collect(); assert_eq!(installed.len(), 4, "two libraries, two architectures"); // An upgrade whose second architecture cannot be read: x32 is there in // the plan and unreadable by the time it is copied. let new = fake_build(&env.root, "dxvk-2.7"); - fs::set_permissions( - new.join("x32/dxgi.dll"), - fs::Permissions::from_mode(0o000), - ) - .unwrap(); + fs::set_permissions(new.join("x32/dxgi.dll"), fs::Permissions::from_mode(0o000)).unwrap(); let outcome = env.install_d3d(&DXVK, &new); fs::set_permissions(new.join("x32/dxgi.dll"), fs::Permissions::from_mode(0o644)).unwrap(); assert!(outcome.is_err(), "the upgrade could not be completed"); From a7617cc2ad651a5ac84a2c7c047253f908630b5b Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sun, 6 Sep 2026 14:33:28 +0200 Subject: [PATCH 31/38] fix: the window reports the check that matters most, and says why things failed Three things the window got wrong about telling the truth. Its Diagnostics screen showed four judgements and left out the one `raven doctor` leads with: who the kernel hands a double-clicked `.exe` to. Wine registers a handler for the same magic, the kernel silently picks the most recently registered, and losing that race looks exactly like Raven losing its prefix - an hour, once. Someone using a window rather than a terminal is precisely who would never find that out, so the screen now carries it. A failed deployment showed the last line of the child's stderr, which for Raven's own failures is the errno anyhow prints under the sentence: "No such file or directory", true of a thousand failures and a description of none. The sentence wins when there is one, and wimlib's own last-line diagnostics still work as before. And the banner's "Stop the session" could outlive the judgement behind its words. It is raised when only Raven's anchor holds the environment and is deliberately exempt from the poll that clears other banners, so a game started in between would have been killed by a button that promised it would not be. It asks again before acting, and the banner relabels itself to "Stop everything using it" if the answer has changed. `raven doctor`'s media playback line also lines up with the others now. --- crates/raven-gui/src/deploy.rs | 44 +++++++++++++++++++----- crates/raven-gui/src/main.rs | 15 ++++++++- crates/raven-gui/src/model.rs | 61 ++++++++++++++++++++++++++++++---- crates/raven/src/main.rs | 4 +-- 4 files changed, 106 insertions(+), 18 deletions(-) diff --git a/crates/raven-gui/src/deploy.rs b/crates/raven-gui/src/deploy.rs index baf67ef..a21141c 100644 --- a/crates/raven-gui/src/deploy.rs +++ b/crates/raven-gui/src/deploy.rs @@ -57,16 +57,29 @@ pub fn parse_progress(line: &str) -> Option { /// The one line of a failed child's error output worth putting in a banner. /// -/// The last line is the one that says why - both Raven and `wimlib-imagex` -/// end with the sentence that matters. What follows it is usually blank, or a -/// remnant of a progress line wimlib redrew with `\r`, so empty pieces are -/// skipped rather than shown as an empty message. +/// Raven's own failures reach stderr through anyhow, which prints the +/// sentence first and its cause underneath: +/// +/// ```text +/// Error: could not read the image at /mnt/win.wim +/// +/// Caused by: +/// No such file or directory (os error 2) +/// ``` +/// +/// The last line there is the errno, which says nothing about which file or +/// what Raven was doing - so the `Error:` line wins when there is one. Only +/// when there is not, as for `wimlib-imagex`'s own diagnostics, is the last +/// line the one that says why. Empty pieces are skipped either way: wimlib +/// redraws progress with `\r`, so a killed child can leave only whitespace. pub fn last_meaningful_line(stderr: &str) -> Option { - stderr - .split(['\r', '\n']) - .map(str::trim) - .rfind(|line| !line.is_empty()) - .map(str::to_owned) + let lines = || stderr.split(['\r', '\n']).map(str::trim); + if let Some(sentence) = lines().find_map(|l| l.strip_prefix("Error: ")) { + if !sentence.is_empty() { + return Some(sentence.to_owned()); + } + } + lines().rfind(|line| !line.is_empty()).map(str::to_owned) } /// What `raven base deploy` needs, gathered from the bases screen's three @@ -238,6 +251,19 @@ mod tests { ); } + #[test] + fn ravens_own_failure_is_reported_by_its_sentence_and_not_its_errno() { + // anyhow prints the context first and the cause underneath, so the + // last line is "No such file or directory" - true of a thousand + // failures and a description of none of them. + let said = "Error: could not read the image at /mnt/win.wim\n\n\ + Caused by:\n No such file or directory (os error 2)\n"; + assert_eq!( + last_meaningful_line(said).as_deref(), + Some("could not read the image at /mnt/win.wim") + ); + } + #[test] fn a_child_that_said_nothing_yields_nothing_to_show() { assert_eq!(last_meaningful_line(""), None); diff --git a/crates/raven-gui/src/main.rs b/crates/raven-gui/src/main.rs index 37d1068..bb22994 100644 --- a/crates/raven-gui/src/main.rs +++ b/crates/raven-gui/src/main.rs @@ -189,7 +189,20 @@ impl App { } } Message::Start(name) => act(name, |e| e.ensure_session().map(|_| ())), - Message::Stop(name) => act(name, |e| e.stop().map(|_| ())), + Message::Stop(name) => act(name, |e| { + // The banner that offers this says "Stop the session", on the + // library's judgement that only Raven's own anchor holds the + // environment - and that banner is deliberately long-lived, + // exempt from the poll that clears the others. A game started + // in between would be killed by a button whose words promised + // it would not be. Asking again costs one read and lets the + // banner relabel itself instead. + match e.ensure_not_running() { + // Nothing there, or nothing but our own anchor. + Ok(()) | Err(raven::Error::SessionHolds(_)) => e.stop().map(|_| ()), + Err(other) => Err(other), + } + }), Message::Acted(Ok(())) => { self.dismiss_offer(); self.reload_environments() diff --git a/crates/raven-gui/src/model.rs b/crates/raven-gui/src/model.rs index 289679d..50d5cbb 100644 --- a/crates/raven-gui/src/model.rs +++ b/crates/raven-gui/src/model.rs @@ -98,8 +98,8 @@ pub struct Check { pub detail: String, } -/// The same four judgements `raven doctor` prints, from the same functions - -/// a difference between the two would be a bug here, not a second opinion. +/// The same judgements `raven doctor` prints, from the same functions - a +/// difference between the two would be a bug here, not a second opinion. pub fn checks() -> Vec { use raven::mount::MountBackend as _; let userns = raven::mount::UserNsOverlay::is_available(); @@ -108,6 +108,7 @@ pub fn checks() -> Vec { let media = raven::prefix::media_decoders(); vec![ + exe_handler(), Check { label: "Unprivileged user namespaces".into(), ok: userns, @@ -149,6 +150,51 @@ pub fn checks() -> Vec { ] } +/// Who the kernel hands a double-clicked `.exe` to. +/// +/// The check `raven doctor` leads with, and the one the window most needs: +/// Wine registers a handler for the same `MZ` magic, the kernel silently +/// picks the most recently registered, and losing that race looks exactly +/// like Raven losing its prefix. Someone using the window rather than a +/// terminal is precisely who would never find that out. +fn exe_handler() -> Check { + let label = "Double-clicked .exe".to_string(); + if !std::path::Path::new("/proc/sys/fs/binfmt_misc").exists() { + return Check { + label, + ok: false, + detail: "binfmt_misc is not mounted - a double-clicked .exe cannot run".into(), + }; + } + let handlers = raven::launch::exe_handlers(); + match handlers.iter().find(|h| h.enabled) { + None if handlers.is_empty() => Check { + label, + ok: false, + detail: "nothing claims .exe files - run `raven binfmt` to see what to register".into(), + }, + None => Check { + label, + ok: false, + detail: "every handler is disabled - a double-clicked .exe will not run".into(), + }, + Some(w) if w.name == raven::launch::BINFMT_NAME => Check { + label, + ok: true, + detail: format!("handled by {} -> {}", w.name, w.interpreter.display()), + }, + Some(w) => Check { + label, + ok: false, + detail: format!( + "{} claims it first, not Raven: a double-clicked .exe runs against {}", + w.name, + w.interpreter.display() + ), + }, + } +} + #[cfg(test)] mod tests { use super::*; @@ -214,10 +260,13 @@ mod tests { // `Check` here and asserting on the string just typed would run none // of that. let checks = checks(); - assert_eq!( - checks.len(), - 4, - "raven doctor prints four judgements and the window shows the same four" + assert!( + checks.len() >= 5, + "the window shows what raven doctor judges, the .exe handler included" + ); + assert!( + checks.iter().any(|c| c.label.contains(".exe")), + "the handler race is the failure that looks like Raven losing its prefix" ); for c in &checks { assert!(!c.label.is_empty()); diff --git a/crates/raven/src/main.rs b/crates/raven/src/main.rs index a7e4b05..666700f 100644 --- a/crates/raven/src/main.rs +++ b/crates/raven/src/main.rs @@ -289,9 +289,9 @@ fn doctor() -> Result<()> { } ); match raven::prefix::media_decoders() { - None => out!("media playback : GStreamer decoders present"), + None => out!("media playback : GStreamer decoders present"), Some(missing) => { - out!("media playback : INCOMPLETE"); + out!("media playback : INCOMPLETE"); for m in missing { out!(" missing: {m}"); } From afb5b27e1d8e1b44cdc79e5ffdd4ebb40a026e1f Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sun, 6 Sep 2026 14:33:28 +0200 Subject: [PATCH 32/38] fix: the projection example belongs to a crate again Becoming a workspace left `examples/` at the repository root, where it is part of no package: `cargo run --example project_real_base` answered "no example target named project_real_base in default-run packages" and the one tool the project has for checking a projection against a real Windows had been unbuildable since. It moves in beside the library it exercises, and runs: 1894 keys in 82 ms against the deployed base, with all six of its refusals holding. --- {examples => crates/raven/examples}/project_real_base.rs | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {examples => crates/raven/examples}/project_real_base.rs (100%) diff --git a/examples/project_real_base.rs b/crates/raven/examples/project_real_base.rs similarity index 100% rename from examples/project_real_base.rs rename to crates/raven/examples/project_real_base.rs From b57e549dab920d6b112f971cfaf8f991b4e83d83 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sun, 6 Sep 2026 14:35:36 +0200 Subject: [PATCH 33/38] build: the package names its own version, and CI builds the way a release does `pkgver()` printed a literal 0.1.0 that stayed put through two releases, so a package built from a 0.3.0 tree called itself 0.1.0 and pacman read an upgrade as a downgrade. It reads the workspace version now, from the line release-please writes and both crates inherit. CI ran `cargo test` and nothing locked, while the release workflow and the PKGBUILD both build with `--locked`. A Cargo.lock that no longer agreed with Cargo.toml therefore passed every check and failed at the release, which is the expensive and public place to discover it. CI does that build too. And `conf_path` was labelled "where the packaged registration belongs" while naming `/etc/binfmt.d`, which is where an *admin* file goes and which takes precedence over the `/usr/lib` file the package installs - the same mechanism the Wine mask relies on. The path is right for what it is used for, `raven binfmt` printing what to register when nothing has; only its name for itself was wrong, in the source and in the shipped conf file's own header. --- .github/workflows/ci.yml | 7 +++++++ crates/raven/src/launch.rs | 11 ++++++++++- packaging/PKGBUILD | 9 ++++++++- packaging/raven.conf | 9 ++++++--- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1f18b1..4614a09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,13 @@ jobs: - name: Test run: cargo test --verbose + # `--locked` here and nowhere else was the gap: the release workflow and + # the PKGBUILD both build locked, so a Cargo.lock that no longer agrees + # with Cargo.toml passed every check and then failed the release, where + # it is expensive and public. This is the cheap place to find that out. + - name: Build the way a release and a package do + run: cargo build --release --locked + # The org rule is that every dependency sits on its latest release. This makes # drift visible in a diff rather than leaving it to be discovered later. outdated: diff --git a/crates/raven/src/launch.rs b/crates/raven/src/launch.rs index d3042c5..27f2e19 100644 --- a/crates/raven/src/launch.rs +++ b/crates/raven/src/launch.rs @@ -222,7 +222,16 @@ pub fn set_default_environment(name: &str) -> Result<(), Error> { std::fs::write(&file, name).map_err(|e| Error::Layer(file, e)) } -/// Where the packaged registration file belongs. +/// Where a hand-written registration belongs, for a build that no package +/// installed. +/// +/// Not where the *package* puts its own: that goes to +/// `/usr/lib/binfmt.d/raven.conf`, the directory for files a package owns, +/// and `/etc` deliberately takes precedence over it - the same mechanism +/// `packaging/wine-mask.conf` uses to shadow Wine's registration. So this +/// path is right for `raven binfmt`, which prints what to install when +/// nothing has installed it, and would silently override the package if it +/// were used on a system that has one. pub fn conf_path() -> PathBuf { PathBuf::from("/etc/binfmt.d/raven.conf") } diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 4cff1ce..3f10932 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -22,7 +22,14 @@ sha256sums=('SKIP') pkgver() { cd Raven - printf "0.1.0.r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" + # Read rather than repeated: the literal was left at 0.1.0 through two + # releases, so every package built from a 0.3.0 tree called itself 0.1.0 and + # pacman saw an upgrade as a downgrade. `[workspace.package]` is where + # release-please writes it, and both crates inherit it from there. + local v + v=$(sed -n '/^\[workspace\.package\]/,/^\[/ s/^version = "\([^"]*\)".*/\1/p' Cargo.toml) + printf "%s.r%s.%s" "${v:?could not read the workspace version}" \ + "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" } build() { diff --git a/packaging/raven.conf b/packaging/raven.conf index 320b427..0f3fcaa 100644 --- a/packaging/raven.conf +++ b/packaging/raven.conf @@ -1,8 +1,11 @@ # Makes the kernel recognise Windows executables and hand them to Raven. # -# Installed to /etc/binfmt.d/ and applied by systemd-binfmt at boot. This is the -# one step that needs root, and it happens once at install time rather than from -# a service that keeps privilege while Raven runs. +# Installed to /usr/lib/binfmt.d/ - the directory for files a package owns - and +# applied by systemd-binfmt at boot. This is the one step that needs root, and it +# happens once at install time rather than from a service that keeps privilege +# while Raven runs. A file of the same name in /etc/binfmt.d/ would take +# precedence over this one, which is what `raven binfmt` prints for a build that +# no package installed. # # Fields: name, type (Magic), offset, magic, mask, interpreter, flags. # From c2b728c4b482eef3949674338f51f3540d9b69f3 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sun, 6 Sep 2026 15:22:00 +0200 Subject: [PATCH 34/38] fix: the window's Start really starts, and three failures say more The card's Start mounted the environment and stopped there. Mounting is the cheap half - hundredths of a second - and the seconds a first launch pays are Wine's services coming up, which the CLI's `env start` deliberately waits for and the window did not. So the card flipped to "running", read as warm, and the next double-clicked program still paid the two seconds the button was pressed to avoid. The warm-up moves into the library and both callers use it. A launcher that lost the start race waited the full thirty seconds even when the winner had already given up: it watched only for a session, never for the lock going away. The lock disappearing is the holder saying it is no longer starting one - and it is the same signal that clears a lock left behind by a launcher that was killed, which cost the next launch the same thirty seconds. An anchor's failure reached the user with its reason removed: `Error::Layer` reads "preparing the layer failed at " and keeps the errno as a source that anyhow prints for the CLI and this path drops. The anchor's one line is all a launcher ever shows, and "permission denied" is the difference between a bug and a chmod. And the commonest `--from` mistake said "does not look like a build", having been given an empty runtime name to print. It names the runtime it wanted. --- crates/raven-gui/src/main.rs | 13 ++++++++++++- crates/raven/src/d3d.rs | 15 +++++++++------ crates/raven/src/main.rs | 15 +++++---------- crates/raven/src/session.rs | 25 +++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 17 deletions(-) diff --git a/crates/raven-gui/src/main.rs b/crates/raven-gui/src/main.rs index bb22994..5fa1eed 100644 --- a/crates/raven-gui/src/main.rs +++ b/crates/raven-gui/src/main.rs @@ -188,7 +188,18 @@ impl App { self.reload_environments() } } - Message::Start(name) => act(name, |e| e.ensure_session().map(|_| ())), + Message::Start(name) => act(name, |e| { + // Mounting is the cheap half. The card's Start exists because + // `env start` is what makes launches instant, and what makes + // them instant is Wine's services already standing - so this + // waits for them too, rather than flipping the card to + // "running" and leaving the first launch to pay the seconds + // the button was pressed to avoid. Their failure is not this + // action's failure: the session is up either way. + e.ensure_session()?; + e.warm_up(); + Ok(()) + }), Message::Stop(name) => act(name, |e| { // The banner that offers this says "Stop the session", on the // library's judgement that only Raven's own anchor holds the diff --git a/crates/raven/src/d3d.rs b/crates/raven/src/d3d.rs index 6393a77..b6199ed 100644 --- a/crates/raven/src/d3d.rs +++ b/crates/raven/src/d3d.rs @@ -96,7 +96,7 @@ impl Environment { pub fn install_d3d(&self, rt: &Runtime, source: &Path) -> Result, Error> { self.ensure_not_running()?; let (dir, _keep) = unpack(source)?; - let root = build_root(&dir)?; + let root = build_root(&dir, rt.key)?; // Plan every copy before performing any of it. Checking as we went // meant a refusal left the copies already made behind, and those then @@ -375,7 +375,7 @@ fn unpack(source: &Path) -> Result<(PathBuf, Option), Error> { /// Finds the directory that actually holds `x64/`, so both a release tarball /// (which nests everything under `dxvk-/`) and an already-extracted /// build work without the caller having to know which they have. -fn build_root(dir: &Path) -> Result { +fn build_root(dir: &Path, key: &'static str) -> Result { if dir.join("x64").is_dir() { return Ok(dir.to_path_buf()); } @@ -387,7 +387,7 @@ fn build_root(dir: &Path) -> Result { } } } - Err(Error::NotAD3dBuild("", dir.to_path_buf())) + Err(Error::NotAD3dBuild(key, dir.to_path_buf())) } struct TempDir(PathBuf); @@ -407,10 +407,10 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); // A release tarball extracts to dxvk-2.7/x64, not to x64. std::fs::create_dir_all(dir.join("dxvk-2.7/x64")).unwrap(); - assert_eq!(build_root(&dir).unwrap(), dir.join("dxvk-2.7")); + assert_eq!(build_root(&dir, "dxvk").unwrap(), dir.join("dxvk-2.7")); // An already-extracted build works too. assert_eq!( - build_root(&dir.join("dxvk-2.7")).unwrap(), + build_root(&dir.join("dxvk-2.7"), "dxvk").unwrap(), dir.join("dxvk-2.7") ); let _ = std::fs::remove_dir_all(&dir); @@ -421,7 +421,10 @@ mod tests { let dir = std::env::temp_dir().join(format!("raven-dxvkbad-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(dir.join("lib")).unwrap(); - assert!(matches!(build_root(&dir), Err(Error::NotAD3dBuild(_, _)))); + assert!(matches!( + build_root(&dir, "dxvk"), + Err(Error::NotAD3dBuild("dxvk", _)) + )); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/raven/src/main.rs b/crates/raven/src/main.rs index 666700f..558b44f 100644 --- a/crates/raven/src/main.rs +++ b/crates/raven/src/main.rs @@ -556,20 +556,15 @@ fn env_cmd(cmd: EnvCmd) -> Result<()> { // services.exe and the rest are standing when the user arrives. out!("Starting Wine's services so the first launch does not wait..."); let started = std::time::Instant::now(); - match std::process::Command::new(std::env::current_exe()?) - .args(["run", &name, "--", "wine", "cmd", "/c", "exit"]) - .env("WINEDEBUG", "-all") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - { - Ok(s) if s.success() => out!( + if e.warm_up() { + out!( "{name} is ready in {:.1}s. Launches will be immediate.", started.elapsed().as_secs_f32() - ), + ); + } else { // The mount is up either way, so this is a warning and not a // failure: the next launch simply pays what this would have. - _ => out!("{name} is mounted, but Wine did not start; the first launch will."), + out!("{name} is mounted, but Wine did not start; the first launch will."); } Ok(()) } diff --git a/crates/raven/src/session.rs b/crates/raven/src/session.rs index d98e6f6..7a2c85e 100644 --- a/crates/raven/src/session.rs +++ b/crates/raven/src/session.rs @@ -202,6 +202,31 @@ impl Environment { Ok(()) } + /// Brings Wine's services up, so the next launch does not pay for them. + /// + /// Mounting is the cheap half - hundredths of a second. The seconds a + /// first launch pays are `wineserver`, `services.exe` and the rest + /// starting, and they only start when something runs. So the smallest + /// possible program is run: what matters is that they are standing when + /// the user arrives. + /// + /// Reports whether they came up. A failure is not the caller's problem: + /// the mount is up either way and the next launch simply pays what this + /// would have. + pub fn warm_up(&self) -> bool { + Command::new(match helper() { + Ok(exe) => exe, + Err(_) => return false, + }) + .args(["run", &self.name, "--", "wine", "cmd", "/c", "exit"]) + .env("WINEDEBUG", "-all") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) + } + /// Forgets a session's record. The processes are `stop`'s business. pub fn clear_session(&self) { let _ = std::fs::remove_file(self.session_file()); From dfccc901611218aa096be91d55985545140f9e47 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sun, 6 Sep 2026 15:26:39 +0200 Subject: [PATCH 35/38] docs: the documentation says what the code does Fifteen files carried claims that had stopped being true. The audit that found them checked each against the source; each correction below was re-checked before it was written. The shadow set says one entry, not two: `Windows/Fonts` was withdrawn on correctness grounds and `reconcile` un-masks it at every session mount, so every measurement taken under that mask - the 135 ms spawn, the 1.19x ratio, the ~20 ms launch overhead - is labelled as history rather than quoted as current. Sessions exist, so the pages that said concurrent launches into one environment are unsupported, refuse, or are an open item now say they join, and the lifecycle table describes an anchor rather than a mount that dies with the program. The repository is a workspace, so the trees and paths name crates/raven/src and crates/raven-gui/src, and the architecture page records that the first of its four planned splits has happened. Two claims were wrong in a way worth naming. The projection page said COM registrations "genuinely carry over" while `HKLM\Software\Classes` is on the deny list with a comment explaining why - the opposite of what the page said, and the corpus's own `com` category depends on the refusal. And it listed four tests, two of which do not exist; the list is now the eight that do. The `\Device\` rewrite it promised is stated as known and unhandled, because it is. The packaging page said the registration is installed to /etc/binfmt.d; it goes to /usr/lib/binfmt.d, and /etc is what a hand-registration uses to override it. It also said both binary names report as `raven` - `rvn --help` prints `Usage: rvn`, checked. The README, the status page and the corpus record what the project has gained: a second game rendering through DXVK on the GPU, vkd3d-proton installed and still unproven, twenty leaf commands counted from --help, and the window - named, with its screens listed and nothing said about how it looks, since it has never been opened. Today's measurement is in performance.md: Raven's own share of a warm launch, 32 ms down to 2. --- README.md | 32 ++++++++------ docs/guide/troubleshooting.md | 15 ++++--- docs/guide/usage.md | 10 +++-- docs/internals/architecture.md | 43 +++++++++++++----- docs/internals/contributing.md | 9 ++-- docs/internals/mount-stack.md | 26 ++++++----- docs/internals/packaging.md | 18 +++++--- docs/internals/performance.md | 64 ++++++++++++++++++--------- docs/internals/registry-projection.md | 36 ++++++++++----- docs/internals/shadow-set.md | 19 ++++---- docs/project/consolidation.md | 58 +++++++++++++++--------- docs/project/corpus.md | 7 +-- docs/project/landscape.md | 2 +- docs/project/status.md | 52 +++++++++++++--------- packaging/README.md | 3 +- 15 files changed, 249 insertions(+), 145 deletions(-) diff --git a/README.md b/README.md index 39b330d..e0f05a0 100644 --- a/README.md +++ b/README.md @@ -28,16 +28,18 @@ that physically cannot be. > an official ISO, mounts as C:, a real installer wrote 256 MB into an > environment without touching a byte of the base — and the game it installs > runs from a double-click in a file manager to its title screen. The registry -> projection carries 1 894 keys from the real hives, process spawn costs 1.19× -> plain Wine (135 ms against 113, down from 2× after the fonts discovery), and -> the shadow set has two entries, each backed by a measurement. +> projection carries 1 894 keys from the real hives, a launch into an +> already-running session costs about 2 ms of Raven's own overhead, and the +> shadow set is down to a single entry — the fonts mask that bought the older +> spawn figures was withdrawn, because a Windows declaring 961 fonts and having +> none is not the real thing. > -> What that does *not* mean: one game, 2D and software-rendered, which turned out to -> render through GDI and never touch Direct3D at all; DXVK now installs and has -> been shown to initialise and enumerate the GPU, but no game has rendered a -> frame through it; one installer framework exercised; programs that keep -> their strings in `.mui` files run mute. The honest ledger, including five -> performance theories that measurement destroyed, is in +> What that does *not* mean: two games, the first 2D and software-rendered, +> which turned out to render through GDI and never touch Direct3D at all, the +> second drawing on the GPU through DXVK and Direct3D 11; vkd3d-proton installs, +> but no Direct3D 12 title has been tried; one installer framework exercised; +> programs that keep their strings in `.mui` files run mute. The honest ledger, +> including five performance theories that measurement destroyed, is in > [docs/project/status.md](docs/project/status.md) and > [docs/internals/performance.md](docs/internals/performance.md). @@ -93,10 +95,10 @@ Raven's position is the one nobody occupies: ## Installation -On Arch, the package in [packaging/](packaging/) installs the binary, registers -`.exe` files with the kernel, and masks Wine's competing registration — be -aware that **installing changes what every `.exe` on the machine does**, and -uninstalling reverses it: +On Arch, the package in [packaging/](packaging/) installs both binaries, +registers `.exe` files with the kernel, and masks Wine's competing +registration — be aware that **installing changes what every `.exe` on the +machine does**, and uninstalling reverses it: ```bash git clone https://github.com/Project-Colony/Raven @@ -104,6 +106,10 @@ cd Raven/packaging makepkg -si ``` +There are two binaries because there are two front ends. `raven` is the command +line and the primary interface; `raven-gui` is a window over the same library — +environments, bases and diagnostics — and nothing here requires it. + Everywhere else, build from source — short, but the `.exe` registration is then yours to install (`raven binfmt` prints it): diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index 5853561..1a3b146 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -53,21 +53,26 @@ pick, and prints the masking fix when it is not Raven's. ## A second launch fails, or a program will not start again -Closing a program's window can leave `wineserver` and a handful of Wine -services alive inside the mount namespace, holding the environment busy. +A launch into an environment that already has a session joins it rather than +refusing — that is what a session is for — so this is no longer what an +ordinary second launch does. What still holds an environment busy is the +wreckage of a session: when the anchor dies, `wineserver` and a handful of Wine +services stay alive inside the mount namespace for a few seconds afterwards, +and overlayfs will not mount the same upper layer twice while they are there. ```bash raven env status games ``` -names the processes holding it, and +names the processes holding it, marking the anchor of a live session as such, +and ```bash raven env stop games ``` -terminates them and releases the environment. Launches into a held environment -refuse with exactly these two commands rather than a bare +terminates them and releases the environment. A launch that cannot mount +refuses with exactly these two commands rather than a bare `Device or resource busy`. ## Launching feels slower than Proton diff --git a/docs/guide/usage.md b/docs/guide/usage.md index a892eb1..f526440 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -115,8 +115,8 @@ Microsoft's. Raven refuses to overwrite a library it did not install itself, so a DLL some installer left in the environment is safe. DXVK has been shown to initialise against a real Windows and reach the GPU - -Microsoft's own `dxdiag.exe` drove it and DXVK enumerated the card. No game has -rendered a frame through it yet, and nothing is benchmarked; see +Microsoft's own `dxdiag.exe` drove it and DXVK enumerated the card, and +*ShineHill*, a Direct3D 11 game from Steam, then ran and drew through it; see [../project/status.md](../project/status.md) for exactly how far that goes. ### Direct3D 12 @@ -129,7 +129,9 @@ raven env vkd3d games --from ~/Downloads/vkd3d-proton-3.0.1.tar.zst ``` A game wanting D3D11 and a game wanting D3D12 are different games, and one -environment serves both. Removing either leaves the other alone. +environment serves both. Removing either leaves the other alone. No D3D12 title +has been run through it yet, though - vkd3d-proton is installed and unproven +where DXVK is not. Neither command has an opinion about whose build you use, and that is not politeness: CachyOS's Proton and Valve's both carry these two projects as @@ -141,7 +143,7 @@ There is no distinct "CachyOS DXVK" to prefer. The first program you run in an environment starts a **session**: Raven mounts C: once and keeps it, so every later launch joins what is already there instead of building a world of its own. The first launch of the day costs about two -seconds; the ones after it cost about a sixth of one. +seconds; the ones after it cost about a quarter of one. You can also bring an environment up before you need it, so nothing waits at all: diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md index a664a34..3c74c06 100644 --- a/docs/internals/architecture.md +++ b/docs/internals/architecture.md @@ -103,8 +103,10 @@ read-only Wine layer can shadow a file or directory outright where the override is not enough. Today the shadow set is a short, measurement-justified constant in -`src/layer.rs` — two directory masks, `Windows/WinSxS` and `Windows/Fonts`; -the per-library table the corpus will produce is planned as a data file keyed +`crates/raven/src/layer.rs` — one directory mask, `Windows/WinSxS`. +`Windows/Fonts` was masked there too and has been withdrawn: the spawn time it +bought was not worth a Windows that declared 961 fonts and had none. The +per-library table the corpus will produce is planned as a data file keyed by Windows build. It is the thing the project exists to shrink. See [shadow-set.md](shadow-set.md). @@ -139,9 +141,9 @@ works without special handling, and the mount dies with the process tree that owns it. `binfmt_misc` is the exception, and it is a **packaging** concern rather than a -runtime one: a file in `/etc/binfmt.d/`, applied by `systemd-binfmt` at boot, -installed by the package manager that already holds root legitimately. Nothing -needs to hold privilege while Raven runs. +runtime one: a file in `/usr/lib/binfmt.d/`, applied by `systemd-binfmt` at +boot, installed by the package manager that already holds root legitimately. +Nothing needs to hold privilege while Raven runs. ### Where this breaks, and the seam that anticipates it @@ -179,27 +181,45 @@ error. A bare `mount: operation not permitted` is not. ## Crate layout -Phase 1 is **one crate**, not a workspace. +Raven is a workspace of **two crates**: the library with its command line, and +the window. [The org rule](https://github.com/Project-Colony/Project-Colony-Resources/blob/main/design/repository-layout.md) is that a workspace is for a real boundary — a separate process, a different build target, a library something else genuinely consumes — and that splitting by layer buys nothing but a dependency graph. Once the privileged daemon disappeared, Raven became one program in one process, which is the case the rule -answers with a single crate and subsystems as directories under `src/`. +answers with a single crate and subsystems as directories under `src/`, and that +is what it stayed until the window arrived. A second binary, with its own +dependency tree, is a real boundary rather than a layer. The root manifest is +virtual — nothing of its own but the member list and the one version both crates +inherit, because a window and a library of different ages are not shippable +together. ``` -src/ +crates/raven/src/ ├── main.rs the CLI; a thin shell over the library below ├── lib.rs the library root and error type ├── paths.rs the Colony filesystem layout for Raven ├── base.rs deploying and describing a Windows base ├── env.rs the environment model: create, run, recover, destroy +├── session.rs the anchor holding a namespace open, and joining it +├── attach.rs the real block devices an environment may see +├── d3d.rs installing DXVK and vkd3d-proton into a prefix ├── launch.rs binfmt registration and environment resolution ├── layer.rs case normalisation, and the shadow masks ├── prefix.rs the Wine prefix pieces an environment keeps ├── mount/ the mount backends, behind one interface └── registry/ registry hive reading and Wine .reg projection + +crates/raven-gui/src/ +├── main.rs the window, and the actions it asks the library for +├── model.rs the plain data the screens draw, kept testable +├── load.rs reading that model without freezing the window +├── deploy.rs base deployment, and the progress read from wimlib +├── errors.rs library errors turned into something a window can offer +├── theme.rs colony-ui's palette and scale, applied to iced +└── view/ one module per screen ``` A directory earns its existence by holding more than one file, which is why @@ -213,17 +233,18 @@ operations, and if the logic ends up inside argument handlers, adding one means rewriting it. The cost of the rule now is a few function signatures; the cost of skipping it is the GUI. -### When this becomes a workspace +### What else splits out | Split out | When | |---|---| -| `raven-gui` | there is a model worth showing — it consumes `colony-ui`, so it is cheap once the model exists | +| `raven-gui` | done — there was a model worth showing, and it consumes `colony-ui`, which is what made it cheap | | `raven-daemon`, `raven-proto` | a privileged helper is needed for systems without unprivileged namespaces | | `raven-launch` | measurement shows CLI start-up cost matters on the `binfmt` path | | `raven-hive` | the hive corpus and its tests outgrow living alongside the binary | Each has a trigger, so the split is a decision rather than a drift. None of them -is speculative — they are the four things already known to be coming. +is speculative — they are the four things already known to be coming, and the +first of them has arrived. ## Language diff --git a/docs/internals/contributing.md b/docs/internals/contributing.md index 026ed8e..9fdb2e8 100644 --- a/docs/internals/contributing.md +++ b/docs/internals/contributing.md @@ -48,8 +48,9 @@ root. ### The library is the API, the CLI is a shell over it A GUI is a second caller of the same operations. Logic that ends up inside -argument handlers has to be rewritten to add one. The rule costs a few function -signatures now and saves the GUI later. +argument handlers has to be rewritten to add one. The rule cost a few function +signatures and has already paid for itself: `raven-gui` is that second caller, +and adding it left the CLI untouched. ### Measurements come with their configuration @@ -72,11 +73,11 @@ cargo build --release cargo test ``` -82 tests, no root required, no Windows base required. +142 tests, no root required, no Windows base required. Two things that are true, and stay true: -- `cargo test` at the crate root must work on a plain developer machine, +- `cargo test` at the workspace root must work on a plain developer machine, without root and without a Windows base present. Tests that need either are gated behind a feature or a fixture, because a test command people learn to avoid is a test suite that stops running. diff --git a/docs/internals/mount-stack.md b/docs/internals/mount-stack.md index 711e5f8..ec27dcb 100644 --- a/docs/internals/mount-stack.md +++ b/docs/internals/mount-stack.md @@ -113,7 +113,8 @@ assumption to build on. The overlay is mounted **without root**, inside a user namespace: ``` -unshare -Urm → mount -t overlay → exec wine +unshare -Urm → mount -t overlay → hold the anchor +setns → exec wine every launch ``` This was measured rather than assumed, and measured end to end with Wine in the @@ -136,15 +137,16 @@ Nothing has to hold privilege while Raven runs. Two consequences worth stating: -- **Concurrent independent launches into one environment are not supported at - first.** `overlayfs` does not support two live mounts sharing an `upperdir`, - and the simple path — mount, exec, exit — gives one mount per process tree. - If launching two unrelated programs into the same environment turns out to - matter, the fix is a keeper process holding the namespace with others joining - by `nsenter`, which is proven to work. It is not built until it is needed. -- **`binfmt_misc` still needs root, once.** It is a file in `/etc/binfmt.d/` - applied by `systemd-binfmt` at boot — a packaging concern, handled by the - package manager, not a service that runs. +- **Concurrent independent launches into one environment join it.** + `overlayfs` does not support two live mounts sharing an `upperdir`, and the + simple path — mount, exec, exit — gave one mount per process tree, so a + second independent launch failed. The keeper process that fixes it is built: + an *anchor* creates the namespace, mounts the overlay and stays alive, and + every launch `setns`es into it. It was built for speed rather than for + concurrency — see [performance.md](performance.md) — and got both. +- **`binfmt_misc` still needs root, once.** It is a file in + `/usr/lib/binfmt.d/` applied by `systemd-binfmt` at boot — a packaging + concern, handled by the package manager, not a service that runs. Where unprivileged namespaces are unavailable — `linux-hardened`, Ubuntu's AppArmor policy, some enterprise configurations — the mount goes through a @@ -158,8 +160,8 @@ Four conceptual verbs, and how they map onto the real commands: | | | | |---|---|---| | **create** | `raven env create` | allocate `upper/` and `work/`, build the Wine prefix, normalise the layer's casing, set the shadow masks, project the registry | -| **activate** | implicit in `run` / `launch` | the overlay mounts when a program starts, in that program's namespace | -| **deactivate** | the program exiting — or `raven env stop` | the mount dies with the process tree; `env status` names anything still holding it | +| **activate** | implicit in `run` / `launch`, or `raven env start` | the first launch starts an anchor that mounts the overlay and holds it; every later one joins that namespace | +| **deactivate** | `raven env stop` | the mount dies with the anchor's process tree, so it outlives any one program; `env status` names everything holding it and marks the anchor | | **destroy** | `raven env destroy` | refuse if held, then delete the environment directory; the base is untouched | Nothing in that list writes to a base. That is checkable, and it should be diff --git a/docs/internals/packaging.md b/docs/internals/packaging.md index 0d6a783..445bcfd 100644 --- a/docs/internals/packaging.md +++ b/docs/internals/packaging.md @@ -17,19 +17,23 @@ because it is typed in front of every program launch, and three characters against five adds up over a day. `rvn` is a link rather than a second binary, so there is one thing to build, one -to sign and one to update. Help output and diagnostics say `raven` under both -names — the short name is a convenience, not a second identity. +to sign and one to update. `--version` and the diagnostics say `raven` under +both names, and only the usage line echoes the name it was invoked as — the +short name is a convenience, not a second identity. ## `binfmt_misc` registration Making `./program.exe` run like any other binary means registering the PE magic with the kernel, and writing to `/proc/sys/fs/binfmt_misc/register` needs root. -It is registered **once, at install time**, through `/etc/binfmt.d/raven.conf` -applied by `systemd-binfmt` — the same mechanism the `wine` package uses. The -package manager already holds root legitimately; Raven does not need to, and -adding a privileged service to do at runtime what a config file does at boot -would be trading a file for an attack surface. +It is registered **once, at install time**, through +`/usr/lib/binfmt.d/raven.conf` applied by `systemd-binfmt` — the same mechanism +the `wine` package uses. That is the directory for files a package owns; a file +of the same name in `/etc/binfmt.d/` takes precedence over it, which is why +`raven binfmt` prints an `/etc` path for a hand-registration on a build no +package installed. The package manager already holds root legitimately; Raven +does not need to, and adding a privileged service to do at runtime what a config +file does at boot would be trading a file for an attack surface. Uninstalling removes the file. A registration left behind pointing at a deleted binary would keep working on the kernel's open handle until the next reboot diff --git a/docs/internals/performance.md b/docs/internals/performance.md index 2342b75..4653207 100644 --- a/docs/internals/performance.md +++ b/docs/internals/performance.md @@ -1,9 +1,9 @@ # Performance What running against a real Windows costs, what it does not cost, what has -been ruled out — and what has been fixed. The spawn overhead is attributed and -mostly removed (fonts, masked); what stays deferred is the ~20 ms residual and -an in-game frame-time measurement. +been ruled out — and what has been fixed. The spawn overhead is attributed +(fonts), though the mask that removed it has since been withdrawn on +correctness grounds; what stays deferred is an in-game frame-time measurement. ## The wall-clock benchmark @@ -24,8 +24,10 @@ the fonts mask; the fix it motivated is further down): About **+115 ms per process** at the time, consistent with the +95 ms measured earlier by a cruder method — twenty seconds of pure overhead for an installer -spawning two hundred processes. With `Windows/Fonts` masked it is **~135 ms -(1.19×), about +22 ms per process** — some four seconds for the same installer. +spawning two hundred processes. With `Windows/Fonts` masked it fell to **~135 +ms (1.19×), about +22 ms per process** — some four seconds for the same +installer — but that mask has since been withdrawn on correctness grounds, so +the table above is what a spawn costs today. **Directory enumeration** (30 × `dir C:\windows\system32` inside one `cmd`, three samples each): @@ -131,11 +133,14 @@ fontconfig), so plain Wine never pays this. One opaque-overlay mask on |---|---|---|---| | spawn, warm server | ~113 ms | ~227 ms | **~135 ms** | -`Windows/Fonts` is now the shadow set's second measured entry (`layer.rs`), -and the remaining ~20 ms is the overlay plus the rest of the real tree. Fonts -still render — through fontconfig, exactly as under plain Wine — and the game -still reaches its title screen with the mask on. The cost: programs that want -Microsoft's font *files*, not just the faces, will not see them; the corpus +`Windows/Fonts` became the shadow set's second measured entry for a while +(`crates/raven/src/layer.rs`), leaving ~20 ms of overlay and the rest of the +real tree. Fonts still rendered — through fontconfig, exactly as under plain +Wine — and the game still reached its title screen with the mask on. The mask +has since been withdrawn on correctness grounds, so the 227 ms column is what +a spawn costs today; the reasoning is in [shadow-set.md](shadow-set.md). The +cost it carried while it stood: programs that want Microsoft's font *files*, +not just the faces, would not see them; the corpus will say whether such a program exists. The casefold side-benefit worth keeping: on a casefolding filesystem the @@ -200,9 +205,22 @@ namespace is what admits it to the mount namespace. **Eight times faster after the first launch of the day**, which is the launch nobody counts. (0.16 s of that measurement was taken while `Windows/Fonts` was masked; the mask has since been removed on correctness grounds and the figure -is 0.26 s - see [shadow-set.md](shadow-set.md).) The remaining 0.04 s over plain Wine's warm figure is -`setns` plus the real Windows being larger than a Wine prefix, and is not worth -chasing. +is 0.26 s - see [shadow-set.md](shadow-set.md).) What remains over plain Wine's +warm figure is `setns` plus the real Windows being larger than a Wine prefix. + +Raven's own share of that was measured directly afterwards, by running a +trivial *native* program through the same path so Wine's start-up is excluded: + +| `raven run -- /bin/true`, session up | | +|---|---| +| validating the anchor by scanning `/proc` | 33 35 32 32 32 33 30 ms | +| validating it by reading one `mountinfo` | 2 2 2 2 2 2 2 ms | + +The question `session` asks is whether the *one* pid it recorded still holds +the environment, and it answered it by reading `/proc//mountinfo` for +every process on the machine - 37 ms of a 32 ms launch, on 454 processes. The +scan was the launch. Reading the one process's `mountinfo` gives the same +answer, and what is left of Raven on a warm launch is about 2 ms. The crash property that motivated the old design is intact: the mount is still owned by the anchor's process tree, and killing the anchor was verified to take @@ -289,9 +307,10 @@ it fails. A guarantee whose test cannot fail is not a guarantee. ## Where to start when this is picked up -1. **The spawn cost is attributed and mostly fixed** — fonts, masked, 227 → 135 - ms. The residual ~20 ms over plain Wine has no owner yet; profile it only if - an installer measurement says it matters. +1. **The spawn cost is attributed, and the fix was given back** — fonts, + masked, 227 → 135 ms, then unmasked because a Windows declaring 961 fonts + and having none is not the real thing. It is the largest known cost and it + has no owner; profile it only if an installer measurement says it matters. 2. **`casefold` is closed.** Tested on a casefolding tmpfs against identical control trees: detected by Wine, no gain, slight regression on directory listing — and one real benefit (the lowercase-shadow hazard becomes @@ -322,12 +341,15 @@ anything left to win lives: - **A launch costs 0.26 s and almost none of it is Raven's code.** Argument parsing, a few dozen `stat` calls, one `setns` and an `exec`. The time is Wine's services and the kernel's mount. -- **The one place Raven does real work is the registry projection** - a - hand-written parser over a 76 MB binary hive, 1 894 keys in 130 ms - and it - runs at environment creation and on `reproject`, not per launch. +- **The one place Raven does real work is the registry projection** - the + `nt-hive` reader over a 76 MB binary hive, 1 894 keys in 82 ms measured + today - and it runs at environment creation and on `reproject`, not per + launch. - **The measurable wins so far were architectural, not compiler flags.** - Sessions took a launch from 2.07 s to 0.26 s; masking fonts was worth 92 ms - and was reverted anyway on correctness grounds. + Sessions took a launch from 2.07 s to 0.26 s, and asking about one process + instead of all of them took Raven's own share of a warm launch from 32 ms to + 2; masking fonts was worth 92 ms and was reverted anyway on correctness + grounds. If the projection ever becomes the thing people wait on - a much larger hive, a base with far more installed software - that is where profiling should start. diff --git a/docs/internals/registry-projection.md b/docs/internals/registry-projection.md index bc09443..9906e08 100644 --- a/docs/internals/registry-projection.md +++ b/docs/internals/registry-projection.md @@ -51,10 +51,10 @@ genuinely carries over: - `HKLM\Software\\…` — where a program installed itself, its options, its licence state -- `HKLM\Software\Classes\CLSID`, `Interface`, `TypeLib` — COM registrations - pointing at libraries that are present in the base +- a named handful of Microsoft subtrees — `DirectX`, `.NETFramework`, + `NET Framework Setup`, `COM3`, `Ole`, `Windows Script Host` — allowed back + over the blanket refusal of `HKLM\Software\Microsoft` - `HKCU\Software\\…` from `NTUSER.DAT` — per-user settings -- `HKCU\Software\Classes` from `UsrClass.dat` — per-user COM and associations **Never projected** — the keys that describe a *machine*: @@ -63,10 +63,18 @@ genuinely carries over: - the subtrees of `HKLM\Software\Microsoft\Windows NT\CurrentVersion` that record which physical installation this was -**Rewritten or dropped** — values that name storage by device rather than by -drive letter. `C:\Program Files\…` is correct, because C: *is* the base. -`\Device\HarddiskVolume2\…` names a volume that does not exist and must not -survive the projection. +**Rewritten** — the drive letter a never-booted Windows records for itself. A +base applied from a WIM has never run `specialize`, so its hive still describes +the *setup* environment and `SystemRoot` reads `X:\Windows`. Under Raven the +installation is C:, and `emit::rewrite_setup_drive` replaces every such letter +on the way through. + +**Known and unhandled** — values that name storage by device rather than by +drive letter. `C:\Program Files\…` is correct, because C: *is* the base, but +`\Device\HarddiskVolume2\…` names a volume that does not exist. Nothing +rewrites or drops those: the allow list reaches only `HKLM\Software` and +`HKCU\Software`, where none has yet been seen, so the case is recorded here +rather than handled. A projection that produced one would carry it through. ## How it is written @@ -144,12 +152,16 @@ makes a cached projection safe to reuse. ## Testing -- **Round-trip.** A known hive corpus projects to a known `.reg`, byte for byte. -- **The deny list holds.** No output line falls under a denied subtree — asserted - against the output, not the intent, so a rules edit that widens the allow list - too far fails a test rather than shipping. +- **What crosses, crosses.** A third-party vendor key and each named Microsoft + subtree reach the output, including through the 32-bit mirror. +- **The deny list holds.** The OS version key and the machine's identity do not + appear — asserted against the output, not the intent, so a rules edit that + widens the allow list too far fails a test rather than shipping. +- **The setup drive letter is rewritten.** `X:\` becomes `C:\` on the way + through. - **Idempotency.** Projecting twice produces identical output. -- **Device paths do not survive.** No `\Device\` reference reaches the prefix. +- **A hive that is not a hive is an error, not a panic.** The reader is fed + input a real ISO could hand it. The corpus was the awkward part, and it is settled: hives are Microsoft's and the repository cannot carry one, so the fixtures are **built at test time from a diff --git a/docs/internals/shadow-set.md b/docs/internals/shadow-set.md index c97c364..f3c114a 100644 --- a/docs/internals/shadow-set.md +++ b/docs/internals/shadow-set.md @@ -103,9 +103,9 @@ and reviewed, rather than a string that has to be believed. merges on the exact byte path, so the trees stayed separate and the mount showed both. Wine's own case-insensitivity acts a layer above the filesystem and could not help. `env create` now renames the Wine layer to the base's -spelling — `normalise_case` in `src/layer.rs`, 338 renames against a real -Windows 11 base — after which the trees merge, and the merged mount is what -carried a real installer and a running game end to end. +spelling — `normalise_case` in `crates/raven/src/layer.rs`, 338 renames +against a real Windows 11 base — after which the trees merge, and the merged +mount is what carried a real installer and a running game end to end. ## The first measured entry, and it is not a library @@ -147,17 +147,18 @@ investigation were Wine genuinely resolving activation contexts against the real store; and the reason a bare game exited while its dialog flashed was the same machinery failing earlier. -## The second measured entry, also not a library +## The second measured entry, also not a library, and since withdrawn -**`Windows\Fonts` is shadowed.** win32u re-enumerates and re-checks every font +**`Windows\Fonts` was shadowed.** win32u re-enumerates and re-checks every font file at every process start; the real base carries ~340 of them, and that was 92 of the 105 milliseconds each process spawn cost over plain Wine (227 → 135 ms with the mask — the full attribution is in [performance.md](performance.md)). Wine's own `Fonts` directory is empty and -text renders through the host's fontconfig, so the mask restores exactly the -plain-Wine situation, and the game still reaches its title screen under it. -What the entry costs: a program that reads Microsoft's font *files* — not just -the faces — will not find them. None has been seen yet; the corpus will say. +text renders through the host's fontconfig, so the mask restored exactly the +plain-Wine situation, and the game still reached its title screen under it. +What the entry cost: a program that reads Microsoft's font *files* — not just +the faces — would not find them. None was ever seen. The cost that withdrew +the entry was a different one, set out below. ## The two mechanisms diff --git a/docs/project/consolidation.md b/docs/project/consolidation.md index b43806e..c5c2077 100644 --- a/docs/project/consolidation.md +++ b/docs/project/consolidation.md @@ -79,9 +79,15 @@ launched into a running environment fails. Children of an already-running program are fine — they inherit the namespace — but a second independent launch is not. -**Done when:** a second launch joins the existing namespace instead of failing, -or refuses with an error that says why. The keeper-plus-`nsenter` pattern is -already proven to work; it is not built. +**Done.** The first launch starts an *anchor* — a Raven process that creates +the namespace, mounts the overlay and then does nothing but stay alive — and +every later launch `setns`es into it, so a second independent launch joins the +same namespace and the same `wineserver` rather than failing. Joining needs no +privilege: the anchor maps the user to uid 0 inside, and owning that user +namespace is what permits entering the mount namespace. `raven env status` +marks the anchor among the holders, recognising it by what the process is +running — `session-anchor` as its first argument — rather than by its name, +because the window can hold a session too. ### 1.4 Uninstall @@ -100,11 +106,12 @@ handler pointing at a path that no longer exists. **Partly done** — see [performance.md](../internals/performance.md). A fixed workload, identical in both conditions, wall-clock: process spawn was **2.0×** -(113 → 228 ms) before the fonts mask and is **1.19×** (113 → 135 ms) since — -see 2.2 and 4 — and directory enumeration is 6.6× — of which 6.0× is the real -`System32` holding six times the entries, leaving **~17% per entry** as the -overlay's share. The lesson joined the list above: a ratio between two -differently-sized workloads measures the workload. +(113 → 228 ms), and **1.19×** (113 → 135 ms) while `Windows\Fonts` was masked +— a mask since withdrawn on correctness grounds, so 2.0× is again what a cold +spawn costs, see 2.2 and 4 — and directory enumeration is 6.6× — of which 6.0× +is the real `System32` holding six times the entries, leaving **~17% per +entry** as the overlay's share. The lesson joined the list above: a ratio +between two differently-sized workloads measures the workload. **Still open:** a frame-time or input-to-response number for the same program under Raven and under plain Wine, on the same machine, in the same scene. The @@ -121,8 +128,8 @@ trace-counting artifact (809 was lines-per-file, not caches; the real count is 9). Tested anyway, on identical control trees on plain and casefolding tmpfs: Wine detects the fold and gains nothing — same caches, same spawn time, and its non-wildcard listing path degrades to a full readdir. The real per-process -cost was `C:\windows\fonts` — see 4, where it became the shadow set's second -measured entry, worth 92 of the 105 ms. +cost was `C:\windows\fonts` — see 4, where masking it was worth 92 of the +105 ms, until that mask was given back. One finding survives `casefold`'s funeral: on a casefolding filesystem the lowercase-shadow hazard (a `wineboot` update creating a literal `windows` @@ -142,8 +149,8 @@ the server; sync is `ntsync`; steady state is the message pump. The 7.75× was an instantaneous CPU% glance, and on capture day the same glance pointed the other way while the request streams stayed identical. A whole line of attack is closed: the launch overhead is entirely client-side — and it turned out to be -the per-process font re-check, which became the shadow set's second measured -entry (see 4). Details in [performance.md](../internals/performance.md). +the per-process font re-check, which the shadow set masked until that mask was +given back (see 4). Details in [performance.md](../internals/performance.md). ### 2.4 Not the problem, so nobody re-checks it @@ -167,15 +174,16 @@ tables costing something real. ### 3.2 The package itself -**Done.** The `PKGBUILD` in `packaging/` installs all four pieces and its +**Done.** The `PKGBUILD` in `packaging/` installs every piece and its description states that installing changes what every `.exe` does: | File | Goes to | |---|---| | `raven.conf` | `/usr/lib/binfmt.d/raven.conf` — applied by pacman's own `systemd-binfmt` hook in the same transaction | | `wine-mask.conf` | `/etc/binfmt.d/wine.conf` — masks Wine's, restored on uninstall | -| `raven.desktop` | `/usr/share/applications/` | -| the binary | `/usr/bin/raven`, plus `rvn` beside it | +| `raven.desktop`, `raven-gui.desktop` | `/usr/share/applications/` | +| the binaries | `/usr/bin/raven`, plus `rvn` beside it, and `/usr/bin/raven-gui` | +| the icons | `/usr/share/icons/hicolor/`, 16 to 512, all named `raven` so the desktop entries' `Icon=` resolves | Uninstall reverses everything: the registration dies with the package instead of dangling, and the mask's removal hands `.exe` files back to Wine. A @@ -196,11 +204,15 @@ can install — the machinery has yet to run once. ## 4. The shadow set — the actual research -Two entries are measured. `Windows\WinSxS` must be hidden, or installers -render without text and ignore every click. `Windows\Fonts` must be hidden, or -every process start pays ~92 ms re-checking ~340 font files (227 → 135 ms -measured; text renders through fontconfig either way, and plain Wine's own -Fonts directory is empty). That is the whole list. +One entry is measured. `Windows\WinSxS` must be hidden, or installers render +without text and ignore every click. `Windows\Fonts` was the second and has +been given back: masking it saved ~92 ms of every process start (227 → 135 ms +measured) because `win32u` re-checks ~340 font files at each one, but a Windows +whose registry declares 961 fonts while `C:\Windows\Fonts` holds none is +incoherent, and a real Windows is the whole premise. Sessions made the trade +cheap to reverse — the cost falls on the anchor now, not on every launch. +`layer::reconcile` un-masks the directory before every mount, so environments +created under the mask healed themselves. That is the whole list. **Next, in order:** @@ -224,8 +236,10 @@ entry, and a corpus that regression-tests it. Stated so nobody mistakes the current evidence for more than it is. -- **No 3D.** The one game run is 2D software-rendered. Nothing here says - anything about Direct3D, DXVK, or a GPU. +- **One 3D game, and no D3D12.** ShineHill — Steam, GameMaker, Direct3D 11, + 64-bit — renders through DXVK on the GPU against the real Windows, which is + one title on one driver. `raven env vkd3d` installs vkd3d-proton beside it, + but no D3D12 game has been tried, so that route is installed and unproven. - **One installer framework.** - **No anti-cheat.** It is a compatibility target, not a feature: a game whose publisher enabled the Proton path should behave the same under Raven, and diff --git a/docs/project/corpus.md b/docs/project/corpus.md index 68d5e2f..67edf49 100644 --- a/docs/project/corpus.md +++ b/docs/project/corpus.md @@ -43,7 +43,7 @@ statement about a class of them. | `media` | Audio or video missing while the game itself runs. Wine decodes through GStreamer, and a system with GStreamer's libraries but not its plugins fails silently | Not Raven's code, but Raven's job to report: `raven doctor` names the missing package | | `enumeration` | Discovers hardware through SetupDi device interfaces, which Wine never registers - Rufus is the type specimen | A Wine patch, not a Raven change | | `drm` | A copy-protection wrapper refuses before the program starts. Steam's is the common one: launched outside a running Steam client it aborts with `Application load error 5:0000065434` | Not Raven's to fix. Test a title without the wrapper, or run the store client itself | -| `d3d` | A Direct3D problem: version unsupported, device creation fails, rendering wrong | Depends. D3D 8-11 is DXVK's; D3D12 needs vkd3d-proton, which Raven does not install | +| `d3d` | A Direct3D problem: version unsupported, device creation fails, rendering wrong | Depends. D3D 8-11 is DXVK's; D3D12 needs vkd3d-proton, which `raven env vkd3d` installs | | `wine` | Fails identically under plain Wine - not Raven's doing | Upstream | | `raven` | Raven's own bug: the mount, the projection, the shadow set, the layer | **Yes - fix it** | @@ -108,8 +108,9 @@ an evening rediscovering it. Named so the gaps are visible rather than merely absent: -- **A Direct3D 12 title.** D3D12 is vkd3d-proton's, not DXVK's, and Raven - installs neither. Cyberpunk 2077 is the type specimen sitting untested. +- **A Direct3D 12 title.** D3D12 is vkd3d-proton's, not DXVK's, and while + `raven env vkd3d` installs it, nothing has yet driven it. Cyberpunk 2077 is + the type specimen sitting untested. - **A 32-bit Direct3D title.** The x32 libraries are installed and unexercised. - **Anything using COM**, the category most likely to be broken by a design decision rather than a bug. diff --git a/docs/project/landscape.md b/docs/project/landscape.md index 26c08a2..07f1478 100644 --- a/docs/project/landscape.md +++ b/docs/project/landscape.md @@ -107,7 +107,7 @@ Doing it deliberately means three things Wine has no reason to build: [../internals/registry-projection.md](../internals/registry-projection.md). 3. **The library shadow must be exact.** Some libraries must be Wine's for reasons of physics, some may be Microsoft's, and the line is only beginning - to be measured — two entries so far, neither of them a DLL. See + to be measured — one entry so far, and it is not a DLL. See [../internals/shadow-set.md](../internals/shadow-set.md). That third point is the part of Raven that is genuinely new. The first two are diff --git a/docs/project/status.md b/docs/project/status.md index 4ba85da..725e359 100644 --- a/docs/project/status.md +++ b/docs/project/status.md @@ -108,7 +108,11 @@ help text. Traced with `WINEDEBUG=+file`: Wine opens **zero** `.mui` files. So Launching a process against the real Windows cost about **+95 ms** at first. The cause was measured — win32u re-checking ~340 real font files at every process start — and masking the base's `Windows\Fonts` brought it to **135 ms -against plain Wine's 113 (1.19×)**. Four plausible theories were falsified on +against plain Wine's 113 (1.19×)**. That mask has since been withdrawn: a +Windows whose registry declares 961 fonts while `C:\Windows\Fonts` holds none +is not the real thing, and sessions made the trade cheap to reverse, because +the cost now falls once on the anchor rather than on every launch. Four +plausible theories were falsified on the way, the `wineserver` excess turned out not to exist at all, and the whole investigation is in [../internals/performance.md](../internals/performance.md). @@ -153,19 +157,22 @@ programs tested you cannot know what you have. ## Built -Fifteen commands, counting each leaf subcommand once. The path from an -installation image to `./program.exe` is complete, and so is recovery when -something is left holding an environment. +Twenty commands, counting each leaf subcommand once, and a window over the +same library. The path from an installation image to `./program.exe` is +complete, and so is recovery when something is left holding an environment. | | | |---|---| | `raven doctor` | namespaces, Wine, `ntsync`, what is deployed — and which handler the kernel gives `.exe` files to, with the fix when it is not Raven's | | `raven base editions` / `deploy` / `list` | the immutable Windows installations | | `raven env create` / `list` / `destroy` | environments, cheap and disposable | -| `raven env status` / `stop` | who holds a running environment's mount, and releasing it | +| `raven env status` / `stop` / `start` | who holds a running environment's mount, releasing it, and bringing one up before it is needed | +| `raven env attach` / `detach` | a real block device wired in as a raw drive | +| `raven env dxvk` / `vkd3d` | Direct3D on Vulkan, from a build you supply | | `raven env default` / `reproject` | which environment is used by default; re-run the projection | | `raven binfmt` | what to install so the kernel recognises `.exe` | | `raven launch` / `run` / `exec` | running a program, at three levels of explicitness | +| `raven-gui` | a window over the same library: environments, bases, diagnostics | Measured against the real Windows 11 base: @@ -187,11 +194,13 @@ rather than by extension, and eight projection tests against hives built by a ## Not built -Coverage, more than mechanism. One installer framework has been exercised and -one 2D game runs; NSIS, InstallShield, MSI, Squirrel, and anything touching -Direct3D are all unexplored. Concurrent launches into one environment refuse -cleanly instead of joining the running namespace. And a release has yet to -produce its first signed asset — the machinery is wired, the proof is not. +Coverage, more than mechanism. One installer framework has been exercised; +NSIS, InstallShield, MSI and Squirrel are unexplored, as is COM, as is anything +keeping its strings in `.mui` files. One game renders through Direct3D 11 and +DXVK; vkd3d-proton installs and no Direct3D 12 title has driven it. Concurrent +launches into one environment now join the running namespace rather than +refusing — see 1.3 in +[consolidation.md](consolidation.md). ## A real program, end to end @@ -223,17 +232,20 @@ Ordered by how much damage a wrong assumption would do. | | Question | Why it matters | |---|---|---| | 1 | Can Wine be made to resolve `.mui` resources? | Without it every real Windows console utility is mute, and any program that keeps its strings in MUI — which is the modern default — shows blank text. This is now the largest known gap. | -| 2 | Does the residual ~20 ms per process matter to a running game? | The fonts cost is fixed and `casefold` is tested and closed (Wine detects it on any filesystem and gains nothing). What remains needs a frame-time or input-to-response number in a real scene before it deserves an owner. | +| 2 | Does the per-process spawn cost matter to a running game? | It is attributed — win32u re-checking the base's ~340 font files — and the mask that removed it was withdrawn on correctness grounds, so it is back at about +115 ms. `casefold` is tested and closed (Wine detects it on any filesystem and gains nothing). What remains needs a frame-time or input-to-response number in a real scene before it deserves an owner. | | 3 | Which Wine files must be in the upper lower-layer? | The shadow set, now expressed as "which paths does the Wine layer need to contain". See [../internals/shadow-set.md](../internals/shadow-set.md). | -| 3 | Does the projection's `X:` to `C:` rewrite cover everything, or is a never-booted hive missing more? | `SystemRoot` was found by looking. What else describes the setup environment is unknown until something reads the whole hive. | -| 4 | What do hardened systems need? | `linux-hardened`, Ubuntu's AppArmor policy and SELinux-enforcing systems all change the mount story. Rootless Podman solves this with `fuse-overlayfs` and `context=` labelling, so the answers exist; which one Raven needs is unmeasured. | -| 5 | Does `overlayfs` accept an `ntfs3` lower layer? | Gates the "bring your own Windows partition" path only. The ISO path does not touch NTFS at all. | -| 6 | Synthetic or locally-generated registry test corpus? | The repository cannot carry Microsoft's hives. Decide before the first test, not after. | +| 4 | Does the projection's `X:` to `C:` rewrite cover everything, or is a never-booted hive missing more? | `SystemRoot` was found by looking. What else describes the setup environment is unknown until something reads the whole hive. | +| 5 | What do hardened systems need? | `linux-hardened`, Ubuntu's AppArmor policy and SELinux-enforcing systems all change the mount story. Rootless Podman solves this with `fuse-overlayfs` and `context=` labelling, so the answers exist; which one Raven needs is unmeasured. | +| 6 | Does `overlayfs` accept an `ntfs3` lower layer? | Gates the "bring your own Windows partition" path only. The ISO path does not touch NTFS at all. | | 7 | Do the dropped NTFS attributes matter? | 131 323 security descriptors, 83 967 short names and 14 287 xattr sets were discarded on deployment. Nothing is known to need them yet, and `--unix-data` is the lever if something does. | -The question that used to sit at the top of this table — whether Wine would -accept an `overlayfs` mount as its C: drive — is answered and has moved to -**Measured**. It was the one that could have invalidated the design. +Two have been answered since and moved out of the table. Whether Wine would +accept an `overlayfs` mount as its C: drive was the one that could have +invalidated the design. And the registry test corpus is settled: the +repository cannot carry Microsoft's hives, so the fixtures are built at test +time from a small `.reg` description by `regf` — a different implementation +from the `nt-hive` reader Raven uses, which is what makes the test worth +something. ## Carried upstream @@ -243,8 +255,8 @@ One finding that is not Raven's to fix. iced crate. A command-line program with no user interface cannot use it without pulling in a GUI toolkit to compute `~/.local/share/Colony/Raven/`. Eidos hit this and worked around it with `eidos-paths`; Raven will carry its own -`src/paths.rs` for the same reason, which makes it the second workaround rather -than the first. +`crates/raven/src/paths.rs` for the same reason, which makes it the second +workaround rather than the first. The fix is a `colony-paths` crate that `colony-ui` re-exports, and it belongs in Project-Colony-Resources. Raised there, not solved here. diff --git a/packaging/README.md b/packaging/README.md index f251806..e66b8a1 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -1,6 +1,6 @@ # Packaging files -What the package installs beyond the binary, wired together by +What the package installs beyond the binaries, wired together by [`PKGBUILD`](PKGBUILD); the reasoning behind each file is in [../docs/internals/packaging.md](../docs/internals/packaging.md). @@ -8,6 +8,7 @@ What the package installs beyond the binary, wired together by |---|---|---| | `raven.conf` | `/usr/lib/binfmt.d/` | makes the kernel hand `.exe` files to Raven, so `./program.exe` runs from a shell — pacman's `systemd-binfmt` hook applies it in the same transaction | | `raven.desktop` | `/usr/share/applications/` | makes a **file manager** open them, which `binfmt` alone does not do | +| `raven-gui.desktop` | `/usr/share/applications/` | gives the window an application-menu entry — it manages bases and environments rather than opening a file, so it is launched by name and never resolved through a MIME type | | `wine-mask.conf` | `/etc/binfmt.d/wine.conf` | disables Wine's own `.exe` registration, which otherwise wins — an `/etc` file shadows Wine's `/usr/lib` one by name without touching a file the wine package owns | The two are needed for different things and neither replaces the other. From 68e0a0b42d8e11ccd0997b7bc5e4c692ffdfa4ce Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sun, 6 Sep 2026 15:30:32 +0200 Subject: [PATCH 36/38] fix: only Raven's own working directories are hidden from base list The atomic deploy hid every directory beginning with a dot, which is more than it needed: a base a user had named with a leading dot was listed before that change and would have silently disappeared. The rule is now the exact prefix Raven writes, so nothing that was visible stops being visible. --- crates/raven/src/base.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/raven/src/base.rs b/crates/raven/src/base.rs index 442221a..c75bb67 100644 --- a/crates/raven/src/base.rs +++ b/crates/raven/src/base.rs @@ -171,17 +171,21 @@ pub fn deploy(image: &Path, index: u32, id: &str) -> Result { }) } -/// Where a deploy applies before it has earned the real name. +/// The prefix a deploy works under before it has earned the real name. /// -/// Hidden, so `list` passes over it: a base is a finished Windows, and -/// anything else under that directory is Raven's own working state. +/// A leading dot so the directory is hidden, and a word after it so the rule +/// that skips it is exact: a name is only ever hidden from `list` when Raven +/// wrote it, never because a user chose one starting with a dot. +const PARTIAL: &str = ".partial-"; + +/// Where a deploy applies before it has earned the real name. fn partial_name(id: &str) -> String { - format!(".partial-{id}") + format!("{PARTIAL}{id}") } /// Whether a directory in the bases folder is a deployed Windows. fn is_base_dir(name: &str) -> bool { - !name.starts_with('.') + !name.starts_with(PARTIAL) } /// Rewrites the reparse points a WIM leaves behind as absolute symlinks. @@ -253,8 +257,9 @@ mod partial_tests { assert_eq!(partial_name("win11-26200-pro"), ".partial-win11-26200-pro"); assert!(!is_base_dir(&partial_name("win11-26200-pro"))); assert!(is_base_dir("win11-26200-pro")); - // Nothing hidden is a base; that is what makes the partial name safe. - assert!(!is_base_dir(".anything")); + // Only Raven's own working directories are skipped. A base a user + // named with a leading dot was listed before this and still is. + assert!(is_base_dir(".anything")); } } From 3ec536ba5a271d9159ed5217d67582c11ac4ae48 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sun, 6 Sep 2026 15:43:28 +0200 Subject: [PATCH 37/38] fix: the window could no longer stop an environment anything was using The re-check added to the stop button refused whenever something other than Raven's own anchor held the environment - which is the ordinary state from the moment Start warms Wine, since `wineserver` and the services hold the mount too. So every Stop the user can see went dead: the card's, the detail screen's, and the banner's own "Stop everything using it", which is offered only for that case and therefore could never do the thing it named. Pressing it re-raised its own banner, and the environment could only be released from a terminal. The judgement and the button are separated now. `Action::message` says which stop each banner asks for, so the mapping is one function with a test on it, and only the stop offered as "the session" - whose label promises nothing of the user's is running, on a banner that outlives the poll - looks again before acting. Two fixes claimed by the previous commit were not in it: a Python script aborted on its first substitution and the rest never ran. They are here. A launcher waiting on the start lock now stops the moment the lock goes away rather than sitting out the full thirty seconds after the holder has given up, and an anchor's failure carries its cause, so "preparing the layer failed at " arrives with the errno that says whether it is a bug or a chmod. Also from the review: an interrupted Direct3D upgrade no longer reports itself as the build it half-replaced; `raven doctor` names an interrupted deploy, which is several gigabytes under a name `base list` hides on purpose, and `Base::find` refuses that name; `unpack`'s doc comment is back on `unpack`; CI asserts the lock through `cargo test --locked` rather than a second fat-LTO release build; and two comments and two documentation sentences that were wrong about their own subject are right. --- .github/workflows/ci.yml | 14 ++++----- crates/raven-gui/src/errors.rs | 36 +++++++++++++++++++++-- crates/raven-gui/src/main.rs | 46 +++++++++++++++++++++--------- crates/raven-gui/src/model.rs | 2 +- crates/raven-gui/src/view/mod.rs | 2 +- crates/raven/src/base.rs | 28 +++++++++++++++++- crates/raven/src/d3d.rs | 18 ++++++++++-- crates/raven/src/env.rs | 5 ++-- crates/raven/src/main.rs | 10 +++++++ crates/raven/src/session.rs | 49 +++++++++++++++++++++++++------- docs/project/consolidation.md | 3 +- docs/project/status.md | 5 ++-- 12 files changed, 173 insertions(+), 45 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4614a09..ae5ea0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,15 +49,13 @@ jobs: echo "apparmor userns restriction: $(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null || echo none)" echo "If the first is 0 or the second is 1, the mount tests below skipped." + # `--locked` was the gap: the release workflow and the PKGBUILD both + # build locked, so a Cargo.lock that no longer agreed with Cargo.toml + # passed every check and then failed the release, where it is expensive + # and public. It is asserted here rather than by a second, fat-LTO + # release build, which would cost minutes to learn the same thing. - name: Test - run: cargo test --verbose - - # `--locked` here and nowhere else was the gap: the release workflow and - # the PKGBUILD both build locked, so a Cargo.lock that no longer agrees - # with Cargo.toml passed every check and then failed the release, where - # it is expensive and public. This is the cheap place to find that out. - - name: Build the way a release and a package do - run: cargo build --release --locked + run: cargo test --verbose --locked # The org rule is that every dependency sits on its latest release. This makes # drift visible in a diff rather than leaving it to be discovered later. diff --git a/crates/raven-gui/src/errors.rs b/crates/raven-gui/src/errors.rs index 7685ed3..c7a61e9 100644 --- a/crates/raven-gui/src/errors.rs +++ b/crates/raven-gui/src/errors.rs @@ -32,10 +32,18 @@ impl Action { } } - /// The environment the action is about. - pub fn env(&self) -> &str { + /// What pressing it asks for. + /// + /// Only `StopSession` goes through the stop that checks again first: its + /// label promises nothing of the user's is running, and the banner + /// carrying it outlives the poll, so that promise can go stale. + /// `StopHolders` has already said it will end the programs it lists, and + /// routing it through the same check made it refuse in exactly the case + /// it exists for - a button that re-raised its own banner for ever. + pub fn message(&self) -> crate::Message { match self { - Action::StopSession(name) | Action::StopHolders(name) => name, + Action::StopSession(name) => crate::Message::StopSession(name.clone()), + Action::StopHolders(name) => crate::Message::Stop(name.clone()), } } } @@ -150,6 +158,28 @@ mod tests { ); } + #[test] + fn the_stop_that_ends_the_users_programs_does_not_ask_permission_first() { + // The guarded stop refuses when anything but Raven's anchor holds the + // environment - which is exactly when this banner is raised. Sending + // it there made the button a loop: it re-raised its own banner and + // released nothing, and every card's Stop went with it, because Wine's + // services hold the mount from the moment Start warms them. + let busy = explain(&Error::EnvironmentBusy { + name: "games".into(), + holders: "1234 (ShineHill.exe)".into(), + }); + assert!( + matches!(busy.action.unwrap().message(), crate::Message::Stop(n) if n == "games"), + "a stop that names what it ends must go straight through" + ); + let session = explain(&Error::SessionHolds("games".into())); + assert!( + matches!(session.action.unwrap().message(), crate::Message::StopSession(n) if n == "games"), + "and the one promising nothing is running must look again" + ); + } + #[test] fn what_an_action_raises_is_marked_as_such_so_a_refresh_cannot_erase_it() { // The environments screen reloads every two seconds. If these came back diff --git a/crates/raven-gui/src/main.rs b/crates/raven-gui/src/main.rs index 5fa1eed..7e2caa4 100644 --- a/crates/raven-gui/src/main.rs +++ b/crates/raven-gui/src/main.rs @@ -53,15 +53,28 @@ pub enum Message { Refresh, Start(String), Stop(String), + /// A stop offered on the promise that only Raven's anchor holds the + /// environment. Distinct from `Stop` because that promise is checked + /// again before it acts; see `errors::Action::message`. + StopSession(String), // `raven::Error` holds `std::io::Error` in some variants, so it cannot be // `Clone` - and iced's widgets (`Button::on_press` among them) require // `Message: Clone`. `Arc` is `Clone` regardless of what it wraps, so it // carries the error across that boundary without the library changing. Acted(Result<(), std::sync::Arc>), Open(String), - InstallD3d { env: String, vkd3d: bool }, - RemoveD3d { env: String, vkd3d: bool }, - Detach { env: String, letter: char }, + InstallD3d { + env: String, + vkd3d: bool, + }, + RemoveD3d { + env: String, + vkd3d: bool, + }, + Detach { + env: String, + letter: char, + }, Reproject(String), DeployImageChanged(String), DeployEditionChanged(String), @@ -200,18 +213,23 @@ impl App { e.warm_up(); Ok(()) }), - Message::Stop(name) => act(name, |e| { - // The banner that offers this says "Stop the session", on the - // library's judgement that only Raven's own anchor holds the - // environment - and that banner is deliberately long-lived, - // exempt from the poll that clears the others. A game started - // in between would be killed by a button whose words promised - // it would not be. Asking again costs one read and lets the - // banner relabel itself instead. + // What every Stop control the user can see asks for: the card's, + // the detail screen's, and the banner that already says it will + // end the programs it lists. Wine's services hold the mount from + // the moment an environment is started, so a stop that refused + // while anything but the anchor held it would refuse always. + Message::Stop(name) => act(name, |e| e.stop().map(|_| ())), + Message::StopSession(name) => act(name, |e| { + // This one is offered on the library's judgement that only + // Raven's own anchor holds the environment, and the banner + // carrying it is deliberately long-lived - exempt from the + // poll that clears the others. A program started in between + // would be killed by a button whose words promised it would + // not be, so the judgement is taken again; when it has + // changed, the error relabels the banner honestly. match e.ensure_not_running() { - // Nothing there, or nothing but our own anchor. - Ok(()) | Err(raven::Error::SessionHolds(_)) => e.stop().map(|_| ()), - Err(other) => Err(other), + Err(busy @ raven::Error::EnvironmentBusy { .. }) => Err(busy), + _ => e.stop().map(|_| ()), } }), Message::Acted(Ok(())) => { diff --git a/crates/raven-gui/src/model.rs b/crates/raven-gui/src/model.rs index 50d5cbb..4289ef0 100644 --- a/crates/raven-gui/src/model.rs +++ b/crates/raven-gui/src/model.rs @@ -152,7 +152,7 @@ pub fn checks() -> Vec { /// Who the kernel hands a double-clicked `.exe` to. /// -/// The check `raven doctor` leads with, and the one the window most needs: +/// The check `raven doctor` closes with, and the one the window most needs: /// Wine registers a handler for the same `MZ` magic, the kernel silently /// picks the most recently registered, and losing that race looks exactly /// like Raven losing its prefix. Someone using the window rather than a diff --git a/crates/raven-gui/src/view/mod.rs b/crates/raven-gui/src/view/mod.rs index 74f1649..3ee1b97 100644 --- a/crates/raven-gui/src/view/mod.rs +++ b/crates/raven-gui/src/view/mod.rs @@ -99,7 +99,7 @@ pub fn shell(app: &App) -> Element<'_, Message> { r = r.push( button(text(action.label()).size(t.sz(12))) .style(theme::button_style(p)) - .on_press(Message::Stop(action.env().to_owned())), + .on_press(action.message()), ); } container(r) diff --git a/crates/raven/src/base.rs b/crates/raven/src/base.rs index c75bb67..445365f 100644 --- a/crates/raven/src/base.rs +++ b/crates/raven/src/base.rs @@ -51,7 +51,7 @@ impl Base { pub fn find(id: &str) -> Result { let path = paths::bases_dir()?.join(paths::check_name(id)?); - if !path.is_dir() { + if !is_base_dir(id) || !path.is_dir() { return Err(Error::NoSuchBase(id.to_owned())); } Ok(Base { @@ -178,6 +178,32 @@ pub fn deploy(image: &Path, index: u32, id: &str) -> Result { /// wrote it, never because a user chose one starting with a dot. const PARTIAL: &str = ".partial-"; +/// The deploys that were interrupted, and the directories they left. +/// +/// Applying an image into a partial directory means an interrupted deploy +/// leaves one behind - several gigabytes of it - and hiding it from `list` +/// would hide it from the user too. `raven doctor` reports these so the +/// space is accountable; the next deploy of the same id clears one on its +/// own. +pub fn partials() -> Result, Error> { + let dir = paths::bases_dir()?; + if !dir.exists() { + return Ok(Vec::new()); + } + let mut out: Vec<(String, PathBuf)> = std::fs::read_dir(&dir) + .map_err(|e| Error::Layer(dir.clone(), e))? + .filter_map(Result::ok) + .filter(|e| e.path().is_dir()) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().into_owned(); + name.strip_prefix(PARTIAL) + .map(|id| (id.to_owned(), e.path())) + }) + .collect(); + out.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(out) +} + /// Where a deploy applies before it has earned the real name. fn partial_name(id: &str) -> String { format!("{PARTIAL}{id}") diff --git a/crates/raven/src/d3d.rs b/crates/raven/src/d3d.rs index b6199ed..2fe42da 100644 --- a/crates/raven/src/d3d.rs +++ b/crates/raven/src/d3d.rs @@ -152,6 +152,20 @@ impl Environment { for r in &created { let _ = std::fs::remove_file(self.upper().join(r)); } + // An upgrade has by now overwritten some of the previous + // build's libraries in place, and those are not undone - + // deleting them would uninstall what was working. What is + // left is genuinely neither version, so the record stops + // naming one: `dxvk` says so, and reinstalling either build + // puts it right. + if !ours.is_empty() { + let was = self.d3d_build(rt).unwrap_or_else(|| rt.key.to_string()); + let _ = write_manifest( + &self.d3d_manifest_path(rt), + &format!("{was} - interrupted upgrade, reinstall to settle it"), + &ours, + ); + } return Err(Error::Layer(to.clone(), e)); } if is_new { @@ -331,8 +345,6 @@ fn unique_dlls(done: &[Shadow]) -> Vec { names } -/// A directory holding the DXVK build, plus a guard that deletes it again if we -/// created it by extracting an archive. /// Records which libraries Raven has put in the environment, and which build /// they came from - "which DXVK do I have" being the first question after /// "is one installed". @@ -341,6 +353,8 @@ fn write_manifest(path: &std::path::Path, version: &str, files: &[String]) -> Re std::fs::write(path, body).map_err(|e| Error::Layer(path.to_path_buf(), e)) } +/// A directory holding the DXVK build, plus a guard that deletes it again if we +/// created it by extracting an archive. fn unpack(source: &Path) -> Result<(PathBuf, Option), Error> { if source.is_dir() { return Ok((source.to_path_buf(), None)); diff --git a/crates/raven/src/env.rs b/crates/raven/src/env.rs index c39c3af..45a3ff8 100644 --- a/crates/raven/src/env.rs +++ b/crates/raven/src/env.rs @@ -166,8 +166,9 @@ impl Environment { /// `holders` asks that of every process on the machine; this asks it of /// one. The difference is the launch path's whole cost: validating the /// recorded anchor is a question about a single pid, and answering it by - /// scanning `/proc` was measured at 37 ms of a 32 ms warm launch - the - /// scan *was* the launch. Reading one `mountinfo` gives the same answer. + /// scanning `/proc` took all but about 2 ms of a 32 ms warm launch on a + /// 454-process machine - the scan *was* the launch. Reading the one + /// process's `mountinfo` gives the same answer. pub fn holds(&self, pid: u32) -> bool { let needle = mountinfo_needle(&self.upper()); std::fs::read_to_string(format!("/proc/{pid}/mountinfo")) diff --git a/crates/raven/src/main.rs b/crates/raven/src/main.rs index 558b44f..b816fa0 100644 --- a/crates/raven/src/main.rs +++ b/crates/raven/src/main.rs @@ -306,6 +306,16 @@ fn doctor() -> Result<()> { "bases : {}", base::Base::list()?.len() ); + // A deploy that was interrupted leaves several gigabytes under a hidden + // name that `base list` passes over on purpose. Nothing else would ever + // mention it, so the diagnostics do. + for (id, path) in base::partials()? { + out!( + " interrupted deploy of {id:?}, still on disk at {}", + path.display() + ); + out!(" deploy {id:?} again to finish it, or remove that directory"); + } out!( "environments : {}", env::Environment::list()?.len() diff --git a/crates/raven/src/session.rs b/crates/raven/src/session.rs index 7a2c85e..1d2df63 100644 --- a/crates/raven/src/session.rs +++ b/crates/raven/src/session.rs @@ -90,7 +90,7 @@ impl Environment { // Somebody else is starting one. Wait for their session // rather than racing it, and fall back to trying // ourselves if their attempt died without cleaning up. - if let Some(pid) = self.wait_for_session() { + if let Some(pid) = self.wait_for_session(&lock) { return Ok(pid); } let _ = std::fs::remove_file(&lock); @@ -103,13 +103,23 @@ impl Environment { started } - /// Waits briefly for somebody else's anchor to come up. - fn wait_for_session(&self) -> Option { + /// Waits for somebody else's anchor to come up, or for them to give up. + /// + /// Both endings matter. Watching only for the session meant that when the + /// holder failed - a renamed base, a kernel that refuses the mount - it + /// dropped its lock within milliseconds and the waiter still sat out the + /// full thirty seconds before trying anything itself. The lock going away + /// is the holder saying it is no longer starting one, and it is the same + /// signal that clears a lock left behind by a launcher that was killed. + fn wait_for_session(&self, lock: &std::path::Path) -> Option { let deadline = std::time::Instant::now() + READY_TIMEOUT; while std::time::Instant::now() < deadline { if let Some(pid) = self.session() { return Some(pid); } + if !lock.exists() { + return None; + } std::thread::sleep(std::time::Duration::from_millis(50)); } None @@ -316,11 +326,11 @@ fn read_line_before(pipe: &std::process::ChildStdout, limit: std::time::Duration /// line is written only afterwards, and why a binary has to reach this before /// starting any runtime of its own. /// -/// In the library rather than the launcher because `start_session` runs -/// `current_exe()` as the anchor: whichever binary asked for the session is -/// the one that must be able to hold it. The window asks too, and until it -/// could answer, its Start started a second window instead, said nothing on -/// the pipe, and was killed thirty seconds later with a message blaming the +/// In the library rather than the launcher because either binary may end up +/// running it: `start_session` prefers the `raven` beside it, and falls back +/// to whichever binary asked. The window asks too, and until it could answer, +/// its Start started a second window instead, said nothing on the pipe, and +/// was killed thirty seconds later with a message blaming the /// kernel. pub fn anchor(name: &str) -> ! { use crate::mount::MountBackend as _; @@ -334,6 +344,19 @@ pub fn anchor(name: &str) -> ! { let _ = std::io::stdout().flush(); std::process::exit(1); }; + // `Error::Layer` reads "preparing the layer failed at " and keeps + // the errno as a source, which anyhow prints for the CLI and `Display` + // drops. This line is all the launcher ever shows, and "permission + // denied" is the difference between a bug and a chmod. + let because = |e: &dyn std::error::Error| -> String { + let mut out = e.to_string(); + let mut source = e.source(); + while let Some(s) = source { + out.push_str(&format!(": {s}")); + source = s.source(); + } + out + }; let e = match crate::env::Environment::open(name) { Ok(e) => e, Err(err) => report(format_args!("{err}")), @@ -353,7 +376,10 @@ pub fn anchor(name: &str) -> ! { // would otherwise stay masked for ever, and a user should not have to // rebuild to receive a fix. if let Err(err) = crate::layer::reconcile(&e.layer()) { - report(format_args!("could not reconcile the layer: {err}")); + report(format_args!( + "could not reconcile the layer: {}", + because(&err) + )); } if !crate::mount::UserNsOverlay::is_available() { report(format_args!( @@ -361,7 +387,10 @@ pub fn anchor(name: &str) -> ! { )); } if let Err(err) = crate::mount::UserNsOverlay.mount(&spec) { - report(format_args!("could not mount the overlay: {err}")); + report(format_args!( + "could not mount the overlay: {}", + because(&err) + )); } let pid = std::process::id(); // Written only after the mount exists, so a reader of this file never diff --git a/docs/project/consolidation.md b/docs/project/consolidation.md index c5c2077..48d388b 100644 --- a/docs/project/consolidation.md +++ b/docs/project/consolidation.md @@ -210,7 +210,8 @@ been given back: masking it saved ~92 ms of every process start (227 → 135 ms measured) because `win32u` re-checks ~340 font files at each one, but a Windows whose registry declares 961 fonts while `C:\Windows\Fonts` holds none is incoherent, and a real Windows is the whole premise. Sessions made the trade -cheap to reverse — the cost falls on the anchor now, not on every launch. +cheap to reverse: a launch costs about a quarter of a second rather than two, +so the same per-process 92 ms is a much smaller share of it than it was. `layer::reconcile` un-masks the directory before every mount, so environments created under the mask healed themselves. That is the whole list. diff --git a/docs/project/status.md b/docs/project/status.md index 725e359..feac112 100644 --- a/docs/project/status.md +++ b/docs/project/status.md @@ -110,8 +110,9 @@ The cause was measured — win32u re-checking ~340 real font files at every process start — and masking the base's `Windows\Fonts` brought it to **135 ms against plain Wine's 113 (1.19×)**. That mask has since been withdrawn: a Windows whose registry declares 961 fonts while `C:\Windows\Fonts` holds none -is not the real thing, and sessions made the trade cheap to reverse, because -the cost now falls once on the anchor rather than on every launch. Four +is not the real thing, and sessions made the trade cheap to reverse: a launch +costs about a quarter of a second rather than two, so the same per-process 92 +ms is a much smaller share of it than it was. Four plausible theories were falsified on the way, the `wineserver` excess turned out not to exist at all, and the whole investigation is in [../internals/performance.md](../internals/performance.md). From 1487679cd648f9249f0e3451e6733e19a114f23b Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Sun, 6 Sep 2026 15:43:57 +0200 Subject: [PATCH 38/38] fix: an absolute program path does not need a working directory to exist `launch::target` asked for the caller's directory before deciding anything, so a launch by absolute path - which is what a file manager passes - failed outright when that directory had been deleted underneath the shell. An absolute path is already what it means, so it is resolved without asking. --- crates/raven/src/launch.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/raven/src/launch.rs b/crates/raven/src/launch.rs index 27f2e19..6a0ed17 100644 --- a/crates/raven/src/launch.rs +++ b/crates/raven/src/launch.rs @@ -184,6 +184,12 @@ pub fn resolve(exe: &Path) -> Result { /// a mount point that exists only in the anchor's namespace, so resolving /// through the filesystem would fail for exactly the paths that work. pub fn target(exe: &Path) -> Result<(PathBuf, PathBuf), Error> { + // An absolute path needs no directory to be resolved against, and asking + // for one would fail where the caller's own directory has been deleted - + // which a file manager launching by absolute path should not care about. + if exe.is_absolute() { + return Ok(resolve_against(Path::new("/"), exe)); + } let here = std::env::current_dir().map_err(|e| Error::Tool("raven", e))?; Ok(resolve_against(&here, exe)) } @@ -273,7 +279,13 @@ mod target_tests { here.to_path_buf() ) ); - // An absolute path is already what it means. + // An absolute path is already what it means, whatever it is + // resolved against - which is what lets `target` skip asking for a + // working directory that may no longer exist. + assert_eq!( + resolve_against(Path::new("/"), Path::new("/opt/g/game.exe")), + resolve_against(here, Path::new("/opt/g/game.exe")) + ); assert_eq!( resolve_against(here, Path::new("/opt/g/game.exe")), (