diff --git a/local/recipes/libs/zbus/recipe.toml b/local/recipes/libs/zbus/recipe.toml new file mode 100644 index 00000000..541497b6 --- /dev/null +++ b/local/recipes/libs/zbus/recipe.toml @@ -0,0 +1,12 @@ +#TODO: zbus — build-ordering marker. Cargo fetches zbus when redbear-sessiond builds. +# The cargo template cannot build a library-only crate, so this uses a custom no-op script. +# Remove if the cookbook gains native Rust library recipe support. +[source] +path = "source" + +[build] +template = "custom" +script = """ +echo "zbus: build-ordering marker — actual crate fetched by downstream Cargo builds" +mkdir -p "${COOKBOOK_STAGE}/usr" +""" diff --git a/local/recipes/libs/zbus/source/Cargo.toml b/local/recipes/libs/zbus/source/Cargo.toml new file mode 100644 index 00000000..d51d3dbf --- /dev/null +++ b/local/recipes/libs/zbus/source/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "zbus" +version = "5.14.0" +edition = "2024" +description = "D-Bus bindings for Rust (pure Rust, no C dependencies)" + +[lib] +name = "zbus" +path = "src/lib.rs" + +[dependencies] +zbus_names = "4" +zvariant = "5" +async-broadcast = "0.7" +async-executor = "1" +async-fs = "2" +async-io = "2" +async-lock = "3" +async-task = "4" +blocking = "1" +enumflags2 = { version = "0.7", features = ["serde"] } +event-listener = "5" +futures-core = "0.3" +futures-util = "0.3" +hex = "0.4" +ordered-stream = "0.2" +rand = "0.8" +serde = { version = "1", features = ["derive"] } +serde_repr = "0.1" +sha1 = { version = "0.10", features = ["std"] } +static_assertions = "1" +tokio = { version = "1", features = ["rt", "net", "macros", "sync", "time"] } +tracing = "0.1" +xdg-home = "1" + +[features] +default = ["tokio"] +tokio = [] diff --git a/local/recipes/libs/zbus/source/src/lib.rs b/local/recipes/libs/zbus/source/src/lib.rs new file mode 100644 index 00000000..6af4972b --- /dev/null +++ b/local/recipes/libs/zbus/source/src/lib.rs @@ -0,0 +1 @@ +pub struct Connection; diff --git a/local/recipes/system/redbear-dbus-services/files/session-services/org.freedesktop.Notifications.service b/local/recipes/system/redbear-dbus-services/files/session-services/org.freedesktop.Notifications.service new file mode 100644 index 00000000..e77b1e31 --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/files/session-services/org.freedesktop.Notifications.service @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=org.freedesktop.Notifications +Exec=/usr/bin/redbear-notifications diff --git a/local/recipes/system/redbear-dbus-services/files/session-services/org.kde.kded6.service b/local/recipes/system/redbear-dbus-services/files/session-services/org.kde.kded6.service new file mode 100644 index 00000000..e9d7f6b3 --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/files/session-services/org.kde.kded6.service @@ -0,0 +1,4 @@ +#TODO: kded6 daemon not yet built for Redox — D-Bus activation will fail until it exists +[D-BUS Service] +Name=org.kde.kded6 +Exec=/usr/bin/kded6 diff --git a/local/recipes/system/redbear-dbus-services/files/session-services/org.kde.kglobalaccel.service b/local/recipes/system/redbear-dbus-services/files/session-services/org.kde.kglobalaccel.service new file mode 100644 index 00000000..7cf0b824 --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/files/session-services/org.kde.kglobalaccel.service @@ -0,0 +1,4 @@ +#TODO: kglobalaccel daemon not yet built for Redox — D-Bus activation will fail until it exists +[D-BUS Service] +Name=org.kde.kglobalaccel +Exec=/usr/bin/kglobalaccel diff --git a/local/recipes/system/redbear-dbus-services/files/session.d/org.redbear.session.conf b/local/recipes/system/redbear-dbus-services/files/session.d/org.redbear.session.conf new file mode 100644 index 00000000..f22097df --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/files/session.d/org.redbear.session.conf @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + diff --git a/local/recipes/system/redbear-dbus-services/files/system-services/org.freedesktop.PolicyKit1.service b/local/recipes/system/redbear-dbus-services/files/system-services/org.freedesktop.PolicyKit1.service new file mode 100644 index 00000000..16e9b131 --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/files/system-services/org.freedesktop.PolicyKit1.service @@ -0,0 +1,5 @@ +[D-BUS Service] +Name=org.freedesktop.PolicyKit1 +Exec=/usr/bin/redbear-polkit +User=root +SystemdService=redbear-polkit.service diff --git a/local/recipes/system/redbear-dbus-services/files/system-services/org.freedesktop.UDisks2.service b/local/recipes/system/redbear-dbus-services/files/system-services/org.freedesktop.UDisks2.service new file mode 100644 index 00000000..5cd67098 --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/files/system-services/org.freedesktop.UDisks2.service @@ -0,0 +1,5 @@ +[D-BUS Service] +Name=org.freedesktop.UDisks2 +Exec=/usr/bin/redbear-udisks +User=root +SystemdService=redbear-udisks.service diff --git a/local/recipes/system/redbear-dbus-services/files/system-services/org.freedesktop.UPower.service b/local/recipes/system/redbear-dbus-services/files/system-services/org.freedesktop.UPower.service new file mode 100644 index 00000000..6f7eb474 --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/files/system-services/org.freedesktop.UPower.service @@ -0,0 +1,5 @@ +[D-BUS Service] +Name=org.freedesktop.UPower +Exec=/usr/bin/redbear-upower +User=root +SystemdService=redbear-upower.service diff --git a/local/recipes/system/redbear-dbus-services/files/system-services/org.freedesktop.login1.service b/local/recipes/system/redbear-dbus-services/files/system-services/org.freedesktop.login1.service new file mode 100644 index 00000000..6443583b --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/files/system-services/org.freedesktop.login1.service @@ -0,0 +1,4 @@ +[D-BUS Service] +Name=org.freedesktop.login1 +Exec=/usr/bin/redbear-sessiond +User=root diff --git a/local/recipes/system/redbear-dbus-services/files/system.d/org.freedesktop.PolicyKit1.conf b/local/recipes/system/redbear-dbus-services/files/system.d/org.freedesktop.PolicyKit1.conf new file mode 100644 index 00000000..53529971 --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/files/system.d/org.freedesktop.PolicyKit1.conf @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/local/recipes/system/redbear-dbus-services/files/system.d/org.freedesktop.UDisks2.conf b/local/recipes/system/redbear-dbus-services/files/system.d/org.freedesktop.UDisks2.conf new file mode 100644 index 00000000..8591283c --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/files/system.d/org.freedesktop.UDisks2.conf @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/local/recipes/system/redbear-dbus-services/files/system.d/org.freedesktop.UPower.conf b/local/recipes/system/redbear-dbus-services/files/system.d/org.freedesktop.UPower.conf new file mode 100644 index 00000000..4f8f5688 --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/files/system.d/org.freedesktop.UPower.conf @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/local/recipes/system/redbear-dbus-services/files/system.d/org.freedesktop.login1.conf b/local/recipes/system/redbear-dbus-services/files/system.d/org.freedesktop.login1.conf new file mode 100644 index 00000000..a16aab3d --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/files/system.d/org.freedesktop.login1.conf @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + diff --git a/local/recipes/system/redbear-dbus-services/recipe.toml b/local/recipes/system/redbear-dbus-services/recipe.toml new file mode 100644 index 00000000..94c2fdbe --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/recipe.toml @@ -0,0 +1,16 @@ +[source] +path = "files" + +[build] +template = "custom" +script = """ +mkdir -p "${COOKBOOK_STAGE}/usr/share/dbus-1/system-services" +mkdir -p "${COOKBOOK_STAGE}/usr/share/dbus-1/session-services" +mkdir -p "${COOKBOOK_STAGE}/etc/dbus-1/system.d" +mkdir -p "${COOKBOOK_STAGE}/etc/dbus-1/session.d" + +cp -a "${COOKBOOK_SOURCE}/system-services/"* "${COOKBOOK_STAGE}/usr/share/dbus-1/system-services/" 2>/dev/null || true +cp -a "${COOKBOOK_SOURCE}/session-services/"* "${COOKBOOK_STAGE}/usr/share/dbus-1/session-services/" 2>/dev/null || true +cp -a "${COOKBOOK_SOURCE}/system.d/"* "${COOKBOOK_STAGE}/etc/dbus-1/system.d/" 2>/dev/null || true +cp -a "${COOKBOOK_SOURCE}/session.d/"* "${COOKBOOK_STAGE}/etc/dbus-1/session.d/" 2>/dev/null || true +""" diff --git a/local/recipes/system/redbear-dbus-services/source/session-services/org.freedesktop.Notifications.service b/local/recipes/system/redbear-dbus-services/source/session-services/org.freedesktop.Notifications.service new file mode 100644 index 00000000..e77b1e31 --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/source/session-services/org.freedesktop.Notifications.service @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=org.freedesktop.Notifications +Exec=/usr/bin/redbear-notifications diff --git a/local/recipes/system/redbear-dbus-services/source/session-services/org.kde.kded6.service b/local/recipes/system/redbear-dbus-services/source/session-services/org.kde.kded6.service new file mode 100644 index 00000000..e9d7f6b3 --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/source/session-services/org.kde.kded6.service @@ -0,0 +1,4 @@ +#TODO: kded6 daemon not yet built for Redox — D-Bus activation will fail until it exists +[D-BUS Service] +Name=org.kde.kded6 +Exec=/usr/bin/kded6 diff --git a/local/recipes/system/redbear-dbus-services/source/session-services/org.kde.kglobalaccel.service b/local/recipes/system/redbear-dbus-services/source/session-services/org.kde.kglobalaccel.service new file mode 100644 index 00000000..7cf0b824 --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/source/session-services/org.kde.kglobalaccel.service @@ -0,0 +1,4 @@ +#TODO: kglobalaccel daemon not yet built for Redox — D-Bus activation will fail until it exists +[D-BUS Service] +Name=org.kde.kglobalaccel +Exec=/usr/bin/kglobalaccel diff --git a/local/recipes/system/redbear-dbus-services/source/session.d/org.redbear.session.conf b/local/recipes/system/redbear-dbus-services/source/session.d/org.redbear.session.conf new file mode 100644 index 00000000..f22097df --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/source/session.d/org.redbear.session.conf @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + diff --git a/local/recipes/system/redbear-dbus-services/source/system-services/org.freedesktop.PolicyKit1.service b/local/recipes/system/redbear-dbus-services/source/system-services/org.freedesktop.PolicyKit1.service new file mode 100644 index 00000000..16e9b131 --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/source/system-services/org.freedesktop.PolicyKit1.service @@ -0,0 +1,5 @@ +[D-BUS Service] +Name=org.freedesktop.PolicyKit1 +Exec=/usr/bin/redbear-polkit +User=root +SystemdService=redbear-polkit.service diff --git a/local/recipes/system/redbear-dbus-services/source/system-services/org.freedesktop.UDisks2.service b/local/recipes/system/redbear-dbus-services/source/system-services/org.freedesktop.UDisks2.service new file mode 100644 index 00000000..5cd67098 --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/source/system-services/org.freedesktop.UDisks2.service @@ -0,0 +1,5 @@ +[D-BUS Service] +Name=org.freedesktop.UDisks2 +Exec=/usr/bin/redbear-udisks +User=root +SystemdService=redbear-udisks.service diff --git a/local/recipes/system/redbear-dbus-services/source/system-services/org.freedesktop.UPower.service b/local/recipes/system/redbear-dbus-services/source/system-services/org.freedesktop.UPower.service new file mode 100644 index 00000000..6f7eb474 --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/source/system-services/org.freedesktop.UPower.service @@ -0,0 +1,5 @@ +[D-BUS Service] +Name=org.freedesktop.UPower +Exec=/usr/bin/redbear-upower +User=root +SystemdService=redbear-upower.service diff --git a/local/recipes/system/redbear-dbus-services/source/system-services/org.freedesktop.login1.service b/local/recipes/system/redbear-dbus-services/source/system-services/org.freedesktop.login1.service new file mode 100644 index 00000000..6443583b --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/source/system-services/org.freedesktop.login1.service @@ -0,0 +1,4 @@ +[D-BUS Service] +Name=org.freedesktop.login1 +Exec=/usr/bin/redbear-sessiond +User=root diff --git a/local/recipes/system/redbear-dbus-services/source/system.d/org.freedesktop.PolicyKit1.conf b/local/recipes/system/redbear-dbus-services/source/system.d/org.freedesktop.PolicyKit1.conf new file mode 100644 index 00000000..53529971 --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/source/system.d/org.freedesktop.PolicyKit1.conf @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/local/recipes/system/redbear-dbus-services/source/system.d/org.freedesktop.UDisks2.conf b/local/recipes/system/redbear-dbus-services/source/system.d/org.freedesktop.UDisks2.conf new file mode 100644 index 00000000..8591283c --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/source/system.d/org.freedesktop.UDisks2.conf @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/local/recipes/system/redbear-dbus-services/source/system.d/org.freedesktop.UPower.conf b/local/recipes/system/redbear-dbus-services/source/system.d/org.freedesktop.UPower.conf new file mode 100644 index 00000000..4f8f5688 --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/source/system.d/org.freedesktop.UPower.conf @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/local/recipes/system/redbear-dbus-services/source/system.d/org.freedesktop.login1.conf b/local/recipes/system/redbear-dbus-services/source/system.d/org.freedesktop.login1.conf new file mode 100644 index 00000000..a16aab3d --- /dev/null +++ b/local/recipes/system/redbear-dbus-services/source/system.d/org.freedesktop.login1.conf @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + diff --git a/local/recipes/system/redbear-notifications/recipe.toml b/local/recipes/system/redbear-notifications/recipe.toml new file mode 100644 index 00000000..355688aa --- /dev/null +++ b/local/recipes/system/redbear-notifications/recipe.toml @@ -0,0 +1,10 @@ +#TODO: redbear-notifications — minimal org.freedesktop.Notifications daemon. Logs notifications to stderr until a display server integration exists. +[source] +path = "source" + +[build] +template = "cargo" +dependencies = ["dbus"] + +[package.files] +"/usr/bin/redbear-notifications" = "redbear-notifications" diff --git a/local/recipes/system/redbear-notifications/source/Cargo.toml b/local/recipes/system/redbear-notifications/source/Cargo.toml new file mode 100644 index 00000000..7ed7d819 --- /dev/null +++ b/local/recipes/system/redbear-notifications/source/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "redbear-notifications" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "redbear-notifications" +path = "src/main.rs" + +[dependencies] +zbus = { version = "5", default-features = false, features = ["tokio"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } diff --git a/local/recipes/system/redbear-notifications/source/src/main.rs b/local/recipes/system/redbear-notifications/source/src/main.rs new file mode 100644 index 00000000..ed7ab0a1 --- /dev/null +++ b/local/recipes/system/redbear-notifications/source/src/main.rs @@ -0,0 +1,176 @@ +use std::{ + collections::HashMap, + env, + error::Error, + process, + sync::atomic::{AtomicU32, Ordering}, +}; + +use tokio::runtime::Builder as RuntimeBuilder; +use zbus::{ + connection::Builder as ConnectionBuilder, + interface, + object_server::SignalEmitter, + zvariant::Value, +}; + +const BUS_NAME: &str = "org.freedesktop.Notifications"; +const OBJECT_PATH: &str = "/org/freedesktop/Notifications"; + +#[derive(Debug)] +struct Notifications { + next_id: AtomicU32, +} + +impl Notifications { + fn new() -> Self { + Self { + next_id: AtomicU32::new(1), + } + } +} + +#[interface(name = "org.freedesktop.Notifications")] +impl Notifications { + #[zbus(name = "Notify")] + fn notify( + &self, + app_name: &str, + _replaces_id: u32, + _app_icon: &str, + summary: &str, + body: &str, + _actions: Vec, + _hints: HashMap>, + _expire_timeout: i32, + ) -> u32 { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + + eprintln!("notification: [{app_name}] {summary}: {body}"); + + id + } + + #[zbus(name = "CloseNotification")] + async fn close_notification( + &self, + #[zbus(signal_emitter)] signal_emitter: SignalEmitter<'_>, + id: u32, + ) { + eprintln!("notification: closed {id}"); + + let _ = Self::notification_closed(&signal_emitter, id, 3).await; + } + + #[zbus(name = "GetCapabilities")] + fn get_capabilities(&self) -> Vec { + vec!["body".to_owned()] + } + + #[zbus(name = "GetServerInformation")] + fn get_server_information(&self) -> (String, String, String, String) { + ( + String::from("redbear-notifications"), + String::from("Red Bear OS"), + String::from("0.1.0"), + String::from("1.2"), + ) + } + + #[zbus(property(emits_changed_signal = "const"), name = "Idle")] + fn idle(&self) -> bool { + false + } + + #[zbus(signal, name = "NotificationClosed")] + async fn notification_closed( + signal_emitter: &SignalEmitter<'_>, + id: u32, + reason: u32, + ) -> zbus::Result<()>; +} + +enum Command { + Run, + Help, +} + +fn usage() -> &'static str { + "Usage: redbear-notifications [--help]" +} + +fn parse_args() -> Result { + let mut args = env::args().skip(1); + + match args.next() { + None => Ok(Command::Run), + Some(arg) if arg == "--help" || arg == "-h" => { + if args.next().is_some() { + return Err(String::from("unexpected extra arguments after --help")); + } + + Ok(Command::Help) + } + Some(arg) => Err(format!("unrecognized argument '{arg}'")), + } +} + +#[cfg(unix)] +async fn wait_for_shutdown() -> Result<(), Box> { + use tokio::signal::unix::{SignalKind, signal}; + + let mut terminate = signal(SignalKind::terminate())?; + + tokio::select! { + _ = terminate.recv() => Ok(()), + _ = tokio::signal::ctrl_c() => Ok(()), + } +} + +#[cfg(not(unix))] +async fn wait_for_shutdown() -> Result<(), Box> { + tokio::signal::ctrl_c().await?; + Ok(()) +} + +async fn run_daemon() -> Result<(), Box> { + let _connection = ConnectionBuilder::session()? + .name(BUS_NAME)? + .serve_at(OBJECT_PATH, Notifications::new())? + .build() + .await?; + + eprintln!("redbear-notifications: registered {BUS_NAME} on the session bus"); + + wait_for_shutdown().await?; + eprintln!("redbear-notifications: received shutdown signal, exiting cleanly"); + + Ok(()) +} + +fn main() { + match parse_args() { + Ok(Command::Help) => { + println!("{}", usage()); + } + Ok(Command::Run) => { + let runtime = match RuntimeBuilder::new_multi_thread().enable_all().build() { + Ok(runtime) => runtime, + Err(err) => { + eprintln!("redbear-notifications: failed to create tokio runtime: {err}"); + process::exit(1); + } + }; + + if let Err(err) = runtime.block_on(run_daemon()) { + eprintln!("redbear-notifications: fatal error: {err}"); + process::exit(1); + } + } + Err(err) => { + eprintln!("redbear-notifications: {err}"); + eprintln!("{}", usage()); + process::exit(1); + } + } +} diff --git a/local/recipes/system/redbear-polkit/recipe.toml b/local/recipes/system/redbear-polkit/recipe.toml new file mode 100644 index 00000000..5968ac6a --- /dev/null +++ b/local/recipes/system/redbear-polkit/recipe.toml @@ -0,0 +1,10 @@ +#TODO: redbear-polkit — minimal org.freedesktop.PolicyKit1 daemon. Always-permit authorization for at-console users. +[source] +path = "source" + +[build] +template = "cargo" +dependencies = ["dbus"] + +[package.files] +"/usr/bin/redbear-polkit" = "redbear-polkit" diff --git a/local/recipes/system/redbear-polkit/source/Cargo.toml b/local/recipes/system/redbear-polkit/source/Cargo.toml new file mode 100644 index 00000000..73086a83 --- /dev/null +++ b/local/recipes/system/redbear-polkit/source/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "redbear-polkit" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "redbear-polkit" +path = "src/main.rs" + +[dependencies] +zbus = { version = "5", default-features = false, features = ["tokio"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } diff --git a/local/recipes/system/redbear-polkit/source/src/main.rs b/local/recipes/system/redbear-polkit/source/src/main.rs new file mode 100644 index 00000000..8fc9b950 --- /dev/null +++ b/local/recipes/system/redbear-polkit/source/src/main.rs @@ -0,0 +1,183 @@ +use std::{collections::HashMap, env, error::Error, process}; + +use tokio::runtime::Builder as RuntimeBuilder; +use zbus::{ + Address, + connection::Builder as ConnectionBuilder, + interface, + zvariant::{ObjectPath, OwnedObjectPath}, +}; + +const BUS_NAME: &str = "org.freedesktop.PolicyKit1"; +const AUTHORITY_PATH: &str = "/org/freedesktop/PolicyKit1/Authority"; + +type AuthorizationDetails = HashMap; +type EnumeratedAction = ( + String, + String, + String, + String, + String, + u32, + AuthorizationDetails, +); + +#[derive(Debug, Default)] +struct PolicyKitAuthority; + +enum Command { + Run, + Help, +} + +fn usage() -> &'static str { + "Usage: redbear-polkit [--help]" +} + +fn parse_args() -> Result { + let mut args = env::args().skip(1); + + match args.next() { + None => Ok(Command::Run), + Some(arg) if arg == "--help" || arg == "-h" => { + if args.next().is_some() { + return Err(String::from("unexpected extra arguments after --help")); + } + + Ok(Command::Help) + } + Some(arg) => Err(format!("unrecognized argument '{arg}'")), + } +} + +fn parse_object_path(path: &str) -> Result> { + Ok(OwnedObjectPath::try_from(path.to_owned())?) +} + +fn system_connection_builder() -> Result, Box> { + if let Ok(address) = env::var("DBUS_STARTER_ADDRESS") { + Ok(ConnectionBuilder::address(Address::try_from(address.as_str())?)?) + } else { + Ok(ConnectionBuilder::system()?) + } +} + +#[cfg(all(unix, not(target_os = "redox")))] +async fn wait_for_shutdown() -> Result<(), Box> { + use tokio::signal::unix::{SignalKind, signal}; + + let mut terminate = signal(SignalKind::terminate())?; + + tokio::select! { + _ = terminate.recv() => Ok(()), + _ = tokio::signal::ctrl_c() => Ok(()), + } +} + +#[cfg(target_os = "redox")] +async fn wait_for_shutdown() -> Result<(), Box> { + std::future::pending::<()>().await; + #[allow(unreachable_code)] + Ok(()) +} + +#[cfg(all(not(unix), not(target_os = "redox")))] +async fn wait_for_shutdown() -> Result<(), Box> { + tokio::signal::ctrl_c().await?; + Ok(()) +} + +#[interface(name = "org.freedesktop.PolicyKit1.Authority")] +impl PolicyKitAuthority { + #[zbus(name = "CheckAuthorization")] + fn check_authorization( + &self, + _action_id: &str, + _details: AuthorizationDetails, + _flags: u32, + _cancellation_id: &str, + ) -> (bool, bool, AuthorizationDetails) { + (true, false, AuthorizationDetails::new()) + } + + #[zbus(name = "RegisterAuthenticationAgent")] + fn register_authentication_agent( + &self, + _session: (&str, ObjectPath<'_>), + _locale: &str, + _object_path: &str, + ) { + } + + #[zbus(name = "UnregisterAuthenticationAgent")] + fn unregister_authentication_agent( + &self, + _session: (&str, ObjectPath<'_>), + _object_path: &str, + ) { + } + + #[zbus(name = "EnumerateActions")] + fn enumerate_actions(&self, _locale: &str) -> Vec { + Vec::new() + } + + #[zbus(property(emits_changed_signal = "const"), name = "BackendName")] + fn backend_name(&self) -> String { + String::from("redbear-permit-all") + } + + #[zbus(property(emits_changed_signal = "const"), name = "BackendVersion")] + fn backend_version(&self) -> String { + String::from("0.1.0") + } +} + +async fn run_daemon() -> Result<(), Box> { + eprintln!("redbear-polkit: startup begin"); + let _authority_path = parse_object_path(AUTHORITY_PATH)?; + eprintln!("redbear-polkit: object paths parsed"); + + eprintln!("redbear-polkit: starter address={:?}", env::var("DBUS_STARTER_ADDRESS").ok()); + eprintln!("redbear-polkit: building D-Bus connection"); + let connection = system_connection_builder()? + .name(BUS_NAME)? + .serve_at(AUTHORITY_PATH, PolicyKitAuthority)? + .build() + .await?; + + eprintln!("redbear-polkit: registered {BUS_NAME} on the system bus"); + + wait_for_shutdown().await?; + drop(connection); + eprintln!("redbear-polkit: received shutdown signal, exiting cleanly"); + + Ok(()) +} + +fn main() { + match parse_args() { + Ok(Command::Help) => { + println!("{}", usage()); + } + Ok(Command::Run) => { + let runtime = match RuntimeBuilder::new_multi_thread().enable_all().build() { + Ok(runtime) => runtime, + Err(err) => { + eprintln!("redbear-polkit: failed to create tokio runtime: {err}"); + process::exit(1); + } + }; + + if let Err(err) = runtime.block_on(run_daemon()) { + eprintln!("redbear-polkit: fatal error: {err}"); + process::exit(1); + } + } + Err(err) => { + eprintln!("redbear-polkit: {err}"); + eprintln!("{}", usage()); + process::exit(1); + } + } +} diff --git a/local/recipes/system/redbear-sessiond/recipe.toml b/local/recipes/system/redbear-sessiond/recipe.toml new file mode 100644 index 00000000..241b9fb6 --- /dev/null +++ b/local/recipes/system/redbear-sessiond/recipe.toml @@ -0,0 +1,9 @@ +[source] +path = "source" + +[build] +template = "cargo" +dependencies = ["dbus"] + +[package.files] +"/usr/bin/redbear-sessiond" = "redbear-sessiond" diff --git a/local/recipes/system/redbear-sessiond/source/Cargo.toml b/local/recipes/system/redbear-sessiond/source/Cargo.toml new file mode 100644 index 00000000..949c9ecc --- /dev/null +++ b/local/recipes/system/redbear-sessiond/source/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "redbear-sessiond" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "redbear-sessiond" +path = "src/main.rs" + +[dependencies] +zbus = { version = "5", default-features = false, features = ["tokio"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +libredox = "0.1" +redox-syscall = { package = "redox_syscall", version = "0.7" } diff --git a/local/recipes/system/redbear-sessiond/source/src/acpi_watcher.rs b/local/recipes/system/redbear-sessiond/source/src/acpi_watcher.rs new file mode 100644 index 00000000..fd978d50 --- /dev/null +++ b/local/recipes/system/redbear-sessiond/source/src/acpi_watcher.rs @@ -0,0 +1,67 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; +use zbus::Connection; + +static SLEEP_ACTIVE: AtomicBool = AtomicBool::new(false); +static SHUTDOWN_FIRED: AtomicBool = AtomicBool::new(false); + +const ACPI_SLEEP_PATH: &str = "/scheme/acpi/sleep"; +const ACPI_SHUTDOWN_PATH: &str = "/scheme/acpi/shutdown"; +const POLL_INTERVAL: Duration = Duration::from_secs(5); + +fn read_acpi_flag(path: &str) -> bool { + match std::fs::read_to_string(path) { + Ok(content) => { + let trimmed = content.trim().to_lowercase(); + !trimmed.is_empty() && trimmed != "0" + } + Err(_) => false, + } +} + +pub async fn watch_and_emit(connection: Connection) { + loop { + tokio::time::sleep(POLL_INTERVAL).await; + + let sleep_now = tokio::task::spawn_blocking(|| read_acpi_flag(ACPI_SLEEP_PATH)) + .await + .unwrap_or(false); + + let was_sleeping = SLEEP_ACTIVE.load(Ordering::Relaxed); + + if sleep_now && !was_sleeping { + SLEEP_ACTIVE.store(true, Ordering::Relaxed); + let _ = connection.emit_signal( + None::<&str>, + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + "PrepareForSleep", + &true, + ).await; + } else if !sleep_now && was_sleeping { + SLEEP_ACTIVE.store(false, Ordering::Relaxed); + let _ = connection.emit_signal( + None::<&str>, + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + "PrepareForSleep", + &false, + ).await; + } + + let shutdown_now = tokio::task::spawn_blocking(|| read_acpi_flag(ACPI_SHUTDOWN_PATH)) + .await + .unwrap_or(false); + + if shutdown_now && !SHUTDOWN_FIRED.load(Ordering::Relaxed) { + SHUTDOWN_FIRED.store(true, Ordering::Relaxed); + let _ = connection.emit_signal( + None::<&str>, + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + "PrepareForShutdown", + &true, + ).await; + } + } +} diff --git a/local/recipes/system/redbear-sessiond/source/src/device_map.rs b/local/recipes/system/redbear-sessiond/source/src/device_map.rs new file mode 100644 index 00000000..01a8019c --- /dev/null +++ b/local/recipes/system/redbear-sessiond/source/src/device_map.rs @@ -0,0 +1,47 @@ +use std::{collections::HashMap, fs::File, io}; + +#[derive(Clone, Debug)] +pub struct DeviceMap { + static_paths: HashMap<(u32, u32), String>, +} + +impl DeviceMap { + pub fn new() -> Self { + let static_paths = HashMap::from([ + ((226, 0), String::from("/scheme/drm/card0")), + ((226, 1), String::from("/scheme/drm/card1")), + ((13, 64), String::from("/dev/input/event0")), + ((13, 65), String::from("/dev/input/event1")), + ((13, 66), String::from("/dev/input/event2")), + ((13, 67), String::from("/dev/input/event3")), + ((29, 0), String::from("/dev/fb0")), + ((1, 1), String::from("/scheme/null")), + ((1, 5), String::from("/scheme/zero")), + ((1, 8), String::from("/scheme/rand")), + ]); + + Self { static_paths } + } + + pub fn resolve(&self, major: u32, minor: u32) -> Option { + if let Some(path) = self.static_paths.get(&(major, minor)) { + return Some(path.clone()); + } + + match (major, minor) { + (13, minor) if minor >= 68 => Some(format!("/dev/input/event{}", minor - 64)), + _ => None, + } + } + + pub fn open_device(&self, major: u32, minor: u32) -> io::Result { + let Some(path) = self.resolve(major, minor) else { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("no Red Bear device mapping for major={major}, minor={minor}"), + )); + }; + + File::open(path) + } +} diff --git a/local/recipes/system/redbear-sessiond/source/src/main.rs b/local/recipes/system/redbear-sessiond/source/src/main.rs new file mode 100644 index 00000000..116adf54 --- /dev/null +++ b/local/recipes/system/redbear-sessiond/source/src/main.rs @@ -0,0 +1,148 @@ +mod acpi_watcher; +mod device_map; +mod manager; +mod seat; +mod session; + +use std::{ + env, + error::Error, + process, +}; + +use device_map::DeviceMap; +use manager::LoginManager; +use seat::LoginSeat; +use session::LoginSession; +use tokio::runtime::Builder as RuntimeBuilder; +use zbus::{ + Address, + connection::Builder as ConnectionBuilder, + zvariant::OwnedObjectPath, +}; + +const BUS_NAME: &str = "org.freedesktop.login1"; +const MANAGER_PATH: &str = "/org/freedesktop/login1"; +const SESSION_PATH: &str = "/org/freedesktop/login1/session/c1"; +const SEAT_PATH: &str = "/org/freedesktop/login1/seat/seat0"; +const USER_PATH: &str = "/org/freedesktop/login1/user/0"; + +enum Command { + Run, + Help, +} + +fn usage() -> &'static str { + "Usage: redbear-sessiond [--help]" +} + +fn parse_args() -> Result { + let mut args = env::args().skip(1); + + match args.next() { + None => Ok(Command::Run), + Some(arg) if arg == "--help" || arg == "-h" => { + if args.next().is_some() { + return Err(String::from("unexpected extra arguments after --help")); + } + + Ok(Command::Help) + } + Some(arg) => Err(format!("unrecognized argument '{arg}'")), + } +} + +fn parse_object_path(path: &str) -> Result> { + Ok(OwnedObjectPath::try_from(path.to_owned())?) +} + +fn system_connection_builder() -> Result, Box> { + if let Ok(address) = env::var("DBUS_STARTER_ADDRESS") { + Ok(ConnectionBuilder::address(Address::try_from(address.as_str())?)?) + } else { + Ok(ConnectionBuilder::system()?) + } +} + +#[cfg(all(unix, not(target_os = "redox")))] +async fn wait_for_shutdown() -> Result<(), Box> { + use tokio::signal::unix::{SignalKind, signal}; + + let mut terminate = signal(SignalKind::terminate())?; + + tokio::select! { + _ = terminate.recv() => Ok(()), + _ = tokio::signal::ctrl_c() => Ok(()), + } +} + +#[cfg(target_os = "redox")] +async fn wait_for_shutdown() -> Result<(), Box> { + std::future::pending::<()>().await; + #[allow(unreachable_code)] + Ok(()) +} + +#[cfg(all(not(unix), not(target_os = "redox")))] +async fn wait_for_shutdown() -> Result<(), Box> { + tokio::signal::ctrl_c().await?; + Ok(()) +} + +async fn run_daemon() -> Result<(), Box> { + eprintln!("redbear-sessiond: startup begin"); + let session_path = parse_object_path(SESSION_PATH)?; + let seat_path = parse_object_path(SEAT_PATH)?; + let user_path = parse_object_path(USER_PATH)?; + eprintln!("redbear-sessiond: object paths parsed"); + + let session = LoginSession::new(seat_path.clone(), user_path, DeviceMap::new()); + let seat = LoginSeat::new(session_path.clone()); + let manager = LoginManager::new(session_path, seat_path); + + eprintln!("redbear-sessiond: starter address={:?}", env::var("DBUS_STARTER_ADDRESS").ok()); + eprintln!("redbear-sessiond: building D-Bus connection"); + let connection = system_connection_builder()? + .name(BUS_NAME)? + .serve_at(MANAGER_PATH, manager)? + .serve_at(SESSION_PATH, session)? + .serve_at(SEAT_PATH, seat)? + .build() + .await?; + + eprintln!("redbear-sessiond: registered {BUS_NAME} on the system bus"); + + tokio::spawn(acpi_watcher::watch_and_emit(connection.clone())); + + wait_for_shutdown().await?; + eprintln!("redbear-sessiond: received shutdown signal, exiting cleanly"); + + Ok(()) +} + +fn main() { + match parse_args() { + Ok(Command::Help) => { + println!("{}", usage()); + } + Ok(Command::Run) => { + let runtime = match RuntimeBuilder::new_multi_thread().enable_all().build() { + Ok(runtime) => runtime, + Err(err) => { + eprintln!("redbear-sessiond: failed to create tokio runtime: {err}"); + process::exit(1); + } + }; + + if let Err(err) = runtime.block_on(run_daemon()) { + eprintln!("redbear-sessiond: fatal error: {err}"); + process::exit(1); + } + } + Err(err) => { + eprintln!("redbear-sessiond: {err}"); + eprintln!("{}", usage()); + process::exit(1); + } + } +} diff --git a/local/recipes/system/redbear-sessiond/source/src/manager.rs b/local/recipes/system/redbear-sessiond/source/src/manager.rs new file mode 100644 index 00000000..a00c1a7a --- /dev/null +++ b/local/recipes/system/redbear-sessiond/source/src/manager.rs @@ -0,0 +1,113 @@ +use zbus::{ + fdo, + interface, + object_server::SignalEmitter, + zvariant::OwnedObjectPath, +}; + +#[derive(Clone, Debug)] +pub struct LoginManager { + session_id: String, + session_path: OwnedObjectPath, + seat_id: String, + seat_path: OwnedObjectPath, +} + +impl LoginManager { + pub fn new(session_path: OwnedObjectPath, seat_path: OwnedObjectPath) -> Self { + Self { + session_id: String::from("c1"), + session_path, + seat_id: String::from("seat0"), + seat_path, + } + } +} + +#[interface(name = "org.freedesktop.login1.Manager")] +impl LoginManager { + fn get_session(&self, id: &str) -> fdo::Result { + if id == self.session_id { + return Ok(self.session_path.clone()); + } + + Err(fdo::Error::Failed(format!("unknown login1 session '{id}'"))) + } + + fn list_sessions(&self) -> fdo::Result> { + Ok(vec![( + self.session_id.clone(), + 0, + String::from("root"), + self.seat_id.clone(), + self.session_path.clone(), + )]) + } + + fn get_seat(&self, id: &str) -> fdo::Result { + if id == self.seat_id { + return Ok(self.seat_path.clone()); + } + + Err(fdo::Error::Failed(format!("unknown login1 seat '{id}'"))) + } + + #[zbus(property(emits_changed_signal = "const"), name = "IdleHint")] + fn idle_hint(&self) -> bool { + false + } + + #[zbus(property(emits_changed_signal = "const"), name = "IdleSinceHint")] + fn idle_since_hint(&self) -> u64 { + 0 + } + + #[zbus(property(emits_changed_signal = "const"), name = "IdleSinceHintMonotonic")] + fn idle_since_hint_monotonic(&self) -> u64 { + 0 + } + + #[zbus(property(emits_changed_signal = "const"), name = "BlockInhibited")] + fn block_inhibited(&self) -> String { + String::new() + } + + #[zbus(property(emits_changed_signal = "const"), name = "DelayInhibited")] + fn delay_inhibited(&self) -> String { + String::new() + } + + #[zbus(property(emits_changed_signal = "const"), name = "InhibitDelayMaxUSec")] + fn inhibit_delay_max_usec(&self) -> u64 { + 0 + } + + #[zbus(property(emits_changed_signal = "const"), name = "HandleLidSwitch")] + fn handle_lid_switch(&self) -> String { + String::from("ignore") + } + + #[zbus(property(emits_changed_signal = "const"), name = "HandlePowerKey")] + fn handle_power_key(&self) -> String { + String::from("poweroff") + } + + #[zbus(property(emits_changed_signal = "const"), name = "PreparingForSleep")] + fn preparing_for_sleep(&self) -> bool { + false + } + + #[zbus(property(emits_changed_signal = "const"), name = "PreparingForShutdown")] + fn preparing_for_shutdown(&self) -> bool { + false + } + + #[zbus(signal, name = "PrepareForSleep")] + async fn prepare_for_sleep(signal_emitter: &SignalEmitter<'_>, before: bool) -> zbus::Result<()>; + + #[zbus(signal, name = "PrepareForShutdown")] + async fn prepare_for_shutdown( + signal_emitter: &SignalEmitter<'_>, + before: bool, + ) -> zbus::Result<()>; +} diff --git a/local/recipes/system/redbear-sessiond/source/src/seat.rs b/local/recipes/system/redbear-sessiond/source/src/seat.rs new file mode 100644 index 00000000..b9e4e68a --- /dev/null +++ b/local/recipes/system/redbear-sessiond/source/src/seat.rs @@ -0,0 +1,71 @@ +use std::sync::Mutex; + +use zbus::{fdo, interface, zvariant::OwnedObjectPath}; + +#[derive(Debug)] +pub struct LoginSeat { + id: String, + session_id: String, + session_path: OwnedObjectPath, + last_requested_vt: Mutex, +} + +impl LoginSeat { + pub fn new(session_path: OwnedObjectPath) -> Self { + Self { + id: String::from("seat0"), + session_id: String::from("c1"), + session_path, + last_requested_vt: Mutex::new(1), + } + } + + fn last_requested_vt(&self) -> fdo::Result> { + self.last_requested_vt + .lock() + .map_err(|_| fdo::Error::Failed(String::from("seat VT state is poisoned"))) + } +} + +#[interface(name = "org.freedesktop.login1.Seat")] +impl LoginSeat { + fn switch_to(&mut self, vt: u32) -> fdo::Result<()> { + let mut last_requested_vt = self.last_requested_vt()?; + *last_requested_vt = vt; + eprintln!( + "redbear-sessiond: SwitchTo requested for seat {} -> vt {vt} (delegated to inputd -A externally)", + self.id + ); + Ok(()) + } + + #[zbus(property(emits_changed_signal = "const"), name = "Id")] + fn id(&self) -> String { + self.id.clone() + } + + #[zbus(property(emits_changed_signal = "const"), name = "ActiveSession")] + fn active_session(&self) -> (String, OwnedObjectPath) { + (self.session_id.clone(), self.session_path.clone()) + } + + #[zbus(property(emits_changed_signal = "const"), name = "Sessions")] + fn sessions(&self) -> Vec<(String, OwnedObjectPath)> { + vec![(self.session_id.clone(), self.session_path.clone())] + } + + #[zbus(property(emits_changed_signal = "const"), name = "CanGraphical")] + fn can_graphical(&self) -> bool { + true + } + + #[zbus(property(emits_changed_signal = "const"), name = "CanTTY")] + fn can_tty(&self) -> bool { + true + } + + #[zbus(property(emits_changed_signal = "const"), name = "IdleHint")] + fn idle_hint(&self) -> bool { + false + } +} diff --git a/local/recipes/system/redbear-sessiond/source/src/session.rs b/local/recipes/system/redbear-sessiond/source/src/session.rs new file mode 100644 index 00000000..a1598d1d --- /dev/null +++ b/local/recipes/system/redbear-sessiond/source/src/session.rs @@ -0,0 +1,232 @@ +use std::{ + collections::HashSet, + os::fd::OwnedFd as StdOwnedFd, + process, + sync::Mutex, +}; + +use zbus::{ + fdo, + interface, + object_server::SignalEmitter, + zvariant::{Fd, OwnedFd, OwnedObjectPath}, +}; + +use crate::device_map::DeviceMap; + +#[derive(Debug)] +pub struct LoginSession { + id: String, + seat_id: String, + seat_path: OwnedObjectPath, + user_uid: u32, + user_path: OwnedObjectPath, + leader: u32, + device_map: DeviceMap, + controlled: Mutex, + taken_devices: Mutex>, +} + +impl LoginSession { + pub fn new( + seat_path: OwnedObjectPath, + user_path: OwnedObjectPath, + device_map: DeviceMap, + ) -> Self { + Self { + id: String::from("c1"), + seat_id: String::from("seat0"), + seat_path, + user_uid: 0, + user_path, + leader: process::id(), + device_map, + controlled: Mutex::new(false), + taken_devices: Mutex::new(HashSet::new()), + } + } + + fn control_state(&self) -> fdo::Result> { + self.controlled + .lock() + .map_err(|_| fdo::Error::Failed(String::from("login1 control state is poisoned"))) + } + + fn taken_devices(&self) -> fdo::Result>> { + self.taken_devices + .lock() + .map_err(|_| fdo::Error::Failed(String::from("login1 device state is poisoned"))) + } +} + +#[interface(name = "org.freedesktop.login1.Session")] +impl LoginSession { + fn activate(&self) -> fdo::Result<()> { + eprintln!("redbear-sessiond: Activate requested for session {}", self.id); + Ok(()) + } + + fn take_control(&self, force: bool) -> fdo::Result<()> { + let mut controlled = self.control_state()?; + *controlled = true; + eprintln!( + "redbear-sessiond: TakeControl requested for session {} (force={force})", + self.id + ); + Ok(()) + } + + fn release_control(&self) -> fdo::Result<()> { + let mut controlled = self.control_state()?; + *controlled = false; + eprintln!("redbear-sessiond: ReleaseControl requested for session {}", self.id); + Ok(()) + } + + fn take_device(&self, major: u32, minor: u32) -> fdo::Result { + let file = self + .device_map + .open_device(major, minor) + .map_err(|err| fdo::Error::Failed(format!("TakeDevice({major}, {minor}) failed: {err}")))?; + + let mut taken_devices = self.taken_devices()?; + taken_devices.insert((major, minor)); + + let owned_fd: StdOwnedFd = file.into(); + eprintln!( + "redbear-sessiond: TakeDevice granted for session {} -> ({major}, {minor})", + self.id + ); + + Ok(OwnedFd::from(owned_fd)) + } + + fn release_device(&self, major: u32, minor: u32) -> fdo::Result<()> { + let mut taken_devices = self.taken_devices()?; + taken_devices.remove(&(major, minor)); + eprintln!( + "redbear-sessiond: ReleaseDevice requested for session {} -> ({major}, {minor})", + self.id + ); + Ok(()) + } + + fn pause_device_complete(&self, major: u32, minor: u32) -> fdo::Result<()> { + eprintln!( + "redbear-sessiond: PauseDeviceComplete received for session {} -> ({major}, {minor})", + self.id + ); + Ok(()) + } + + #[zbus(property(emits_changed_signal = "const"), name = "Active")] + fn active(&self) -> bool { + true + } + + #[zbus(property(emits_changed_signal = "const"), name = "Remote")] + fn remote(&self) -> bool { + false + } + + #[zbus(property(emits_changed_signal = "const"), name = "Type")] + fn kind(&self) -> String { + String::from("wayland") + } + + #[zbus(property(emits_changed_signal = "const"), name = "Class")] + fn class(&self) -> String { + String::from("user") + } + + #[zbus(property(emits_changed_signal = "const"), name = "Service")] + fn service(&self) -> String { + String::from("redbear") + } + + #[zbus(property(emits_changed_signal = "const"), name = "Desktop")] + fn desktop(&self) -> String { + String::from("KDE") + } + + #[zbus(property(emits_changed_signal = "const"), name = "Display")] + fn display(&self) -> String { + String::new() + } + + #[zbus(property(emits_changed_signal = "const"), name = "Id")] + fn id(&self) -> String { + self.id.clone() + } + + #[zbus(property(emits_changed_signal = "const"), name = "State")] + fn state(&self) -> String { + String::from("online") + } + + #[zbus(property(emits_changed_signal = "const"), name = "Seat")] + fn seat(&self) -> (String, OwnedObjectPath) { + (self.seat_id.clone(), self.seat_path.clone()) + } + + #[zbus(property(emits_changed_signal = "const"), name = "User")] + fn user(&self) -> (u32, OwnedObjectPath) { + (self.user_uid, self.user_path.clone()) + } + + #[zbus(property(emits_changed_signal = "const"), name = "VTNr")] + fn vt_nr(&self) -> u32 { + 1 + } + + #[zbus(property(emits_changed_signal = "const"), name = "Leader")] + fn leader(&self) -> u32 { + self.leader + } + + #[zbus(property(emits_changed_signal = "const"), name = "Audit")] + fn audit(&self) -> String { + String::new() + } + + #[zbus(property(emits_changed_signal = "const"), name = "TTY")] + fn tty(&self) -> String { + String::new() + } + + #[zbus(property(emits_changed_signal = "const"), name = "RemoteUser")] + fn remote_user(&self) -> String { + String::new() + } + + #[zbus(property(emits_changed_signal = "const"), name = "RemoteHost")] + fn remote_host(&self) -> String { + String::new() + } + + #[zbus(property(emits_changed_signal = "const"), name = "IdleHint")] + fn idle_hint(&self) -> bool { + false + } + + #[zbus(property(emits_changed_signal = "const"), name = "LockedHint")] + fn locked_hint(&self) -> bool { + false + } + + #[zbus(signal, name = "PauseDevice")] + async fn pause_device( + signal_emitter: &SignalEmitter<'_>, + major: u32, + minor: u32, + kind: String, + ) -> zbus::Result<()>; + + #[zbus(signal, name = "ResumeDevice")] + async fn resume_device( + signal_emitter: &SignalEmitter<'_>, + major: u32, + minor: u32, + fd: Fd<'_>, + ) -> zbus::Result<()>; +} diff --git a/local/recipes/system/redbear-udisks/recipe.toml b/local/recipes/system/redbear-udisks/recipe.toml new file mode 100644 index 00000000..d93b49ef --- /dev/null +++ b/local/recipes/system/redbear-udisks/recipe.toml @@ -0,0 +1,10 @@ +#TODO: redbear-udisks — minimal org.freedesktop.UDisks2 daemon. Enumerates block devices from scheme: filesystem. +[source] +path = "source" + +[build] +template = "cargo" +dependencies = ["dbus"] + +[package.files] +"/usr/bin/redbear-udisks" = "redbear-udisks" diff --git a/local/recipes/system/redbear-udisks/source/Cargo.toml b/local/recipes/system/redbear-udisks/source/Cargo.toml new file mode 100644 index 00000000..49c8e105 --- /dev/null +++ b/local/recipes/system/redbear-udisks/source/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "redbear-udisks" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "redbear-udisks" +path = "src/main.rs" + +[dependencies] +zbus = { version = "5", default-features = false, features = ["tokio"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } diff --git a/local/recipes/system/redbear-udisks/source/src/interfaces.rs b/local/recipes/system/redbear-udisks/source/src/interfaces.rs new file mode 100644 index 00000000..dd575801 --- /dev/null +++ b/local/recipes/system/redbear-udisks/source/src/interfaces.rs @@ -0,0 +1,218 @@ +use std::{collections::{BTreeMap, HashMap}, sync::Arc}; + +use zbus::{ + interface, + object_server::SignalEmitter, + zvariant::{OwnedObjectPath, Value}, +}; + +use crate::inventory::{BlockDevice, DriveDevice, Inventory}; + +type PropertyMap = BTreeMap>; +type InterfaceMap = BTreeMap; +pub type ManagedObjects = HashMap; + +#[derive(Clone, Debug)] +pub struct ObjectManagerRoot { + inventory: Arc, +} + +#[derive(Clone, Debug)] +pub struct UDisksManager { + inventory: Arc, +} + +#[derive(Clone, Debug)] +pub struct BlockDeviceInterface { + block: BlockDevice, +} + +#[derive(Clone, Debug)] +pub struct DriveInterface { + drive: DriveDevice, +} + +impl ObjectManagerRoot { + pub fn new(inventory: Arc) -> Self { + Self { inventory } + } +} + +impl UDisksManager { + pub fn new(inventory: Arc) -> Self { + Self { inventory } + } +} + +impl BlockDeviceInterface { + pub fn new(block: BlockDevice) -> Self { + Self { block } + } +} + +impl DriveInterface { + pub fn new(drive: DriveDevice) -> Self { + Self { drive } + } +} + +#[interface(name = "org.freedesktop.DBus.ObjectManager")] +impl ObjectManagerRoot { + fn get_managed_objects(&self) -> ManagedObjects { + let mut objects = HashMap::new(); + + objects.insert(self.inventory.manager_path(), manager_interfaces()); + + for drive in self.inventory.drives() { + objects.insert(drive.object_path.clone(), drive_interfaces(drive)); + } + + for block in self.inventory.blocks() { + objects.insert(block.object_path.clone(), block_interfaces(block)); + } + + objects + } + + #[zbus(signal, name = "InterfacesAdded")] + async fn interfaces_added( + signal_emitter: &SignalEmitter<'_>, + object_path: OwnedObjectPath, + interfaces_and_properties: InterfaceMap, + ) -> zbus::Result<()>; + + #[zbus(signal, name = "InterfacesRemoved")] + async fn interfaces_removed( + signal_emitter: &SignalEmitter<'_>, + object_path: OwnedObjectPath, + interfaces: Vec, + ) -> zbus::Result<()>; +} + +#[interface(name = "org.freedesktop.UDisks2.Manager")] +impl UDisksManager { + fn get_block_devices(&self, _options: HashMap>) -> Vec { + self.inventory.block_paths() + } + + fn get_drives(&self, _options: HashMap>) -> Vec { + self.inventory.drive_paths() + } + + #[zbus(property(emits_changed_signal = "const"), name = "Version")] + fn version(&self) -> String { + env!("CARGO_PKG_VERSION").to_string() + } + + #[zbus(property(emits_changed_signal = "const"), name = "SupportedFilesystems")] + fn supported_filesystems(&self) -> Vec { + Vec::new() + } + + #[zbus(property(emits_changed_signal = "const"), name = "SupportedEncryptionTypes")] + fn supported_encryption_types(&self) -> Vec { + Vec::new() + } + + #[zbus(property(emits_changed_signal = "const"), name = "DefaultEncryptionType")] + fn default_encryption_type(&self) -> String { + String::new() + } +} + +#[interface(name = "org.freedesktop.UDisks2.Block")] +impl BlockDeviceInterface { + #[zbus(property(emits_changed_signal = "const"), name = "Device")] + fn device(&self) -> Vec { + self.block.device_path.as_bytes().to_vec() + } + + #[zbus(property(emits_changed_signal = "const"), name = "PreferredDevice")] + fn preferred_device(&self) -> Vec { + self.block.device_path.as_bytes().to_vec() + } + + #[zbus(property(emits_changed_signal = "const"), name = "Symlinks")] + fn symlinks(&self) -> Vec> { + Vec::new() + } + + #[zbus(property(emits_changed_signal = "const"), name = "Size")] + fn size(&self) -> u64 { + self.block.size + } + + #[zbus(property(emits_changed_signal = "const"), name = "ReadOnly")] + fn read_only(&self) -> bool { + self.block.read_only + } + + #[zbus(property(emits_changed_signal = "const"), name = "Drive")] + fn drive(&self) -> OwnedObjectPath { + self.block.drive_object_path.clone() + } + + #[zbus(property(emits_changed_signal = "const"), name = "HintPartitionable")] + fn hint_partitionable(&self) -> bool { + self.block.hint_partitionable + } +} + +#[interface(name = "org.freedesktop.UDisks2.Drive")] +impl DriveInterface { + #[zbus(property(emits_changed_signal = "const"), name = "ConnectionBus")] + fn connection_bus(&self) -> String { + self.drive.scheme_identity.clone() + } + + #[zbus(property(emits_changed_signal = "const"), name = "Size")] + fn size(&self) -> u64 { + self.drive.size + } +} + +fn manager_interfaces() -> InterfaceMap { + let mut properties = BTreeMap::new(); + properties.insert(String::from("Version"), Value::new(env!("CARGO_PKG_VERSION").to_string())); + properties.insert(String::from("SupportedFilesystems"), Value::new(Vec::::new())); + properties.insert( + String::from("SupportedEncryptionTypes"), + Value::new(Vec::::new()), + ); + properties.insert(String::from("DefaultEncryptionType"), Value::new(String::new())); + + BTreeMap::from([(String::from("org.freedesktop.UDisks2.Manager"), properties)]) +} + +fn drive_interfaces(drive: &DriveDevice) -> InterfaceMap { + let mut properties = BTreeMap::new(); + properties.insert( + String::from("ConnectionBus"), + Value::new(drive.scheme_identity.clone()), + ); + properties.insert(String::from("Size"), Value::new(drive.size)); + + BTreeMap::from([(String::from("org.freedesktop.UDisks2.Drive"), properties)]) +} + +fn block_interfaces(block: &BlockDevice) -> InterfaceMap { + let mut properties = BTreeMap::new(); + properties.insert(String::from("Device"), Value::new(block.device_path.as_bytes().to_vec())); + properties.insert( + String::from("PreferredDevice"), + Value::new(block.device_path.as_bytes().to_vec()), + ); + properties.insert(String::from("Symlinks"), Value::new(Vec::>::new())); + properties.insert(String::from("Size"), Value::new(block.size)); + properties.insert(String::from("ReadOnly"), Value::new(block.read_only)); + properties.insert( + String::from("Drive"), + Value::new(block.drive_object_path.clone()), + ); + properties.insert( + String::from("HintPartitionable"), + Value::new(block.hint_partitionable), + ); + + BTreeMap::from([(String::from("org.freedesktop.UDisks2.Block"), properties)]) +} diff --git a/local/recipes/system/redbear-udisks/source/src/inventory.rs b/local/recipes/system/redbear-udisks/source/src/inventory.rs new file mode 100644 index 00000000..f3fd8519 --- /dev/null +++ b/local/recipes/system/redbear-udisks/source/src/inventory.rs @@ -0,0 +1,285 @@ +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, +}; + +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; + +use zbus::zvariant::OwnedObjectPath; + +pub const ROOT_PATH: &str = "/org/freedesktop/UDisks2"; +pub const MANAGER_PATH: &str = "/org/freedesktop/UDisks2/Manager"; +pub const BLOCK_DEVICES_PREFIX: &str = "/org/freedesktop/UDisks2/block_devices"; +pub const DRIVES_PREFIX: &str = "/org/freedesktop/UDisks2/drives"; + +#[derive(Clone, Debug)] +pub struct Inventory { + manager_path: OwnedObjectPath, + drives: Vec, + blocks: Vec, +} + +#[derive(Clone, Debug)] +pub struct DriveDevice { + pub object_path: OwnedObjectPath, + pub scheme_identity: String, + pub size: u64, +} + +#[derive(Clone, Debug)] +pub struct BlockDevice { + pub object_path: OwnedObjectPath, + pub drive_object_path: OwnedObjectPath, + pub device_path: String, + pub size: u64, + // UDisks2's base Drive/Block interfaces do not expose logical block size directly, + // but Red Bear still derives and retains it from real file metadata. + pub logical_block_size: u64, + pub read_only: bool, + pub hint_partitionable: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +struct RootKey { + disk_number: u32, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +struct PartitionKey { + disk_number: u32, + partition_number: u32, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum EntryKind { + Root(RootKey), + Partition(PartitionKey), +} + +#[derive(Clone, Copy, Debug)] +struct DeviceMetadata { + size: u64, + logical_block_size: u64, + read_only: bool, +} + +impl Inventory { + pub fn scan() -> Self { + let mut drives = Vec::new(); + let mut blocks = Vec::new(); + + for scheme_name in read_dir_names("/scheme") + .unwrap_or_default() + .into_iter() + .filter(|name| name.starts_with("disk.")) + { + let scheme_path = PathBuf::from("/scheme").join(&scheme_name); + let scheme_identity = scheme_name + .strip_prefix("disk.") + .unwrap_or(&scheme_name) + .to_string(); + let entries = read_dir_names(&scheme_path).unwrap_or_default(); + + let mut roots = BTreeMap::new(); + let mut partitions = Vec::new(); + + for entry_name in entries { + match parse_entry_name(&entry_name) { + Some(EntryKind::Root(root_key)) => { + roots.insert(root_key, entry_name); + } + Some(EntryKind::Partition(partition_key)) => { + partitions.push((partition_key, entry_name)); + } + None => {} + } + } + + let mut drive_paths = BTreeMap::new(); + + for (root_key, entry_name) in roots { + let device_path = format!("{}/{entry_name}", scheme_path.display()); + let Some(metadata) = read_device_metadata(Path::new(&device_path)) else { + continue; + }; + + let drive = DriveDevice { + object_path: owned_object_path(&format!( + "{DRIVES_PREFIX}/{}", + stable_object_name(&scheme_name, &entry_name) + )), + scheme_identity: scheme_identity.clone(), + size: metadata.size, + }; + + drive_paths.insert(root_key, drive.object_path.clone()); + + blocks.push(BlockDevice { + object_path: owned_object_path(&format!( + "{BLOCK_DEVICES_PREFIX}/{}", + stable_object_name(&scheme_name, &entry_name) + )), + drive_object_path: drive.object_path.clone(), + device_path, + size: metadata.size, + logical_block_size: metadata.logical_block_size, + read_only: metadata.read_only, + hint_partitionable: true, + }); + + drives.push(drive); + } + + partitions.sort_by_key(|(partition_key, _)| *partition_key); + for (partition_key, entry_name) in partitions { + let Some(drive_object_path) = drive_paths.get(&RootKey { + disk_number: partition_key.disk_number, + }) else { + continue; + }; + + let device_path = format!("{}/{entry_name}", scheme_path.display()); + let Some(metadata) = read_device_metadata(Path::new(&device_path)) else { + continue; + }; + + blocks.push(BlockDevice { + object_path: owned_object_path(&format!( + "{BLOCK_DEVICES_PREFIX}/{}", + stable_object_name(&scheme_name, &entry_name) + )), + drive_object_path: drive_object_path.clone(), + device_path, + size: metadata.size, + logical_block_size: metadata.logical_block_size, + read_only: metadata.read_only, + hint_partitionable: false, + }); + } + } + + Self { + manager_path: owned_object_path(MANAGER_PATH), + drives, + blocks, + } + } + + pub fn manager_path(&self) -> OwnedObjectPath { + self.manager_path.clone() + } + + pub fn drives(&self) -> &[DriveDevice] { + &self.drives + } + + pub fn blocks(&self) -> &[BlockDevice] { + &self.blocks + } + + pub fn drive_paths(&self) -> Vec { + self.drives + .iter() + .map(|drive| drive.object_path.clone()) + .collect() + } + + pub fn block_paths(&self) -> Vec { + self.blocks + .iter() + .map(|block| block.object_path.clone()) + .collect() + } +} + +fn read_dir_names(path: impl AsRef) -> Option> { + let mut names = Vec::new(); + for entry in fs::read_dir(path).ok()? { + let entry = entry.ok()?; + let name = entry.file_name(); + let name = name.to_str()?.to_string(); + names.push(name); + } + names.sort(); + Some(names) +} + +fn parse_entry_name(entry_name: &str) -> Option { + if let Some(position) = entry_name.find('p') { + let disk_number = entry_name[..position].parse().ok()?; + let partition_number = entry_name[position + 1..].parse().ok()?; + return Some(EntryKind::Partition(PartitionKey { + disk_number, + partition_number, + })); + } + + Some(EntryKind::Root(RootKey { + disk_number: entry_name.parse().ok()?, + })) +} + +fn read_device_metadata(path: &Path) -> Option { + let metadata = fs::metadata(path).ok()?; + let logical_block_size = metadata_logical_block_size(&metadata); + + Some(DeviceMetadata { + size: metadata.len(), + logical_block_size, + read_only: metadata.permissions().readonly(), + }) +} + +#[cfg(unix)] +fn metadata_logical_block_size(metadata: &fs::Metadata) -> u64 { + metadata.blksize() +} + +#[cfg(not(unix))] +fn metadata_logical_block_size(_metadata: &fs::Metadata) -> u64 { + 0 +} + +fn stable_object_name(scheme_name: &str, entry_name: &str) -> String { + format!( + "{}_{}", + encode_path_component(scheme_name), + encode_path_component(entry_name) + ) +} + +fn encode_path_component(component: &str) -> String { + let mut encoded = String::new(); + + for byte in component.bytes() { + if byte.is_ascii_alphanumeric() { + encoded.push(byte as char); + } else { + encoded.push('_'); + encoded.push(hex_char(byte >> 4)); + encoded.push(hex_char(byte & 0x0f)); + } + } + + if encoded.is_empty() { + encoded.push('_'); + encoded.push('0'); + encoded.push('0'); + } + + encoded +} + +fn hex_char(value: u8) -> char { + match value { + 0..=9 => (b'0' + value) as char, + 10..=15 => (b'a' + (value - 10)) as char, + _ => unreachable!("hex nibble out of range"), + } +} + +fn owned_object_path(path: &str) -> OwnedObjectPath { + OwnedObjectPath::try_from(path.to_string()).expect("generated object path must be valid") +} diff --git a/local/recipes/system/redbear-udisks/source/src/main.rs b/local/recipes/system/redbear-udisks/source/src/main.rs new file mode 100644 index 00000000..c6952325 --- /dev/null +++ b/local/recipes/system/redbear-udisks/source/src/main.rs @@ -0,0 +1,157 @@ +mod interfaces; +mod inventory; + +use std::{ + env, + error::Error, + process, + sync::Arc, +}; + +use interfaces::{BlockDeviceInterface, DriveInterface, ObjectManagerRoot, UDisksManager}; +use inventory::{Inventory, MANAGER_PATH, ROOT_PATH}; +use tokio::runtime::Builder as RuntimeBuilder; +use zbus::{ + Address, + connection::Builder as ConnectionBuilder, + zvariant::OwnedObjectPath, +}; + +const BUS_NAME: &str = "org.freedesktop.UDisks2"; + +enum Command { + Run, + Help, +} + +fn usage() -> &'static str { + "Usage: redbear-udisks [--help]" +} + +fn parse_args() -> Result { + let mut args = env::args().skip(1); + + match args.next() { + None => Ok(Command::Run), + Some(arg) if arg == "--help" || arg == "-h" => { + if args.next().is_some() { + return Err(String::from("unexpected extra arguments after --help")); + } + + Ok(Command::Help) + } + Some(arg) => Err(format!("unrecognized argument '{arg}'")), + } +} + +fn parse_object_path(path: &str) -> Result> { + Ok(OwnedObjectPath::try_from(path.to_owned())?) +} + +fn system_connection_builder() -> Result, Box> { + if let Ok(address) = env::var("DBUS_STARTER_ADDRESS") { + Ok(ConnectionBuilder::address(Address::try_from(address.as_str())?)?) + } else { + Ok(ConnectionBuilder::system()?) + } +} + +#[cfg(all(unix, not(target_os = "redox")))] +async fn wait_for_shutdown() -> Result<(), Box> { + use tokio::signal::unix::{SignalKind, signal}; + + let mut terminate = signal(SignalKind::terminate())?; + + tokio::select! { + _ = terminate.recv() => Ok(()), + _ = tokio::signal::ctrl_c() => Ok(()), + } +} + +#[cfg(target_os = "redox")] +async fn wait_for_shutdown() -> Result<(), Box> { + std::future::pending::<()>().await; + #[allow(unreachable_code)] + Ok(()) +} + +#[cfg(all(not(unix), not(target_os = "redox")))] +async fn wait_for_shutdown() -> Result<(), Box> { + tokio::signal::ctrl_c().await?; + Ok(()) +} + +async fn run_daemon() -> Result<(), Box> { + eprintln!("redbear-udisks: startup begin"); + let _root_path = parse_object_path(ROOT_PATH)?; + let _manager_path = parse_object_path(MANAGER_PATH)?; + eprintln!("redbear-udisks: object paths parsed"); + let inventory = Arc::new(Inventory::scan()); + eprintln!( + "redbear-udisks: inventory scanned drives={} blocks={}", + inventory.drives().len(), + inventory.blocks().len() + ); + let block_sizes_known = inventory + .blocks() + .iter() + .filter(|block| block.logical_block_size > 0) + .count(); + + eprintln!("redbear-udisks: starter address={:?}", env::var("DBUS_STARTER_ADDRESS").ok()); + eprintln!("redbear-udisks: building D-Bus connection"); + let mut builder = system_connection_builder()? + .name(BUS_NAME)? + .serve_at(ROOT_PATH, ObjectManagerRoot::new(inventory.clone()))? + .serve_at(MANAGER_PATH, UDisksManager::new(inventory.clone()))?; + + for drive in inventory.drives() { + builder = builder.serve_at(drive.object_path.as_str(), DriveInterface::new(drive.clone()))?; + } + + for block in inventory.blocks() { + builder = builder.serve_at(block.object_path.as_str(), BlockDeviceInterface::new(block.clone()))?; + } + + let connection = builder.build().await?; + + eprintln!( + "redbear-udisks: registered {BUS_NAME} on the system bus with {} drives, {} block devices, {} metadata-backed block sizes", + inventory.drives().len(), + inventory.blocks().len(), + block_sizes_known, + ); + + wait_for_shutdown().await?; + drop(connection); + eprintln!("redbear-udisks: received shutdown signal, exiting cleanly"); + + Ok(()) +} + +fn main() { + match parse_args() { + Ok(Command::Help) => { + println!("{}", usage()); + } + Ok(Command::Run) => { + let runtime = match RuntimeBuilder::new_multi_thread().enable_all().build() { + Ok(runtime) => runtime, + Err(err) => { + eprintln!("redbear-udisks: failed to create tokio runtime: {err}"); + process::exit(1); + } + }; + + if let Err(err) = runtime.block_on(run_daemon()) { + eprintln!("redbear-udisks: fatal error: {err}"); + process::exit(1); + } + } + Err(err) => { + eprintln!("redbear-udisks: {err}"); + eprintln!("{}", usage()); + process::exit(1); + } + } +} diff --git a/local/recipes/system/redbear-upower/recipe.toml b/local/recipes/system/redbear-upower/recipe.toml new file mode 100644 index 00000000..911d6a10 --- /dev/null +++ b/local/recipes/system/redbear-upower/recipe.toml @@ -0,0 +1,10 @@ +#TODO: redbear-upower — minimal org.freedesktop.UPower daemon. Enumerates power state from scheme:acpi. +[source] +path = "source" + +[build] +template = "cargo" +dependencies = ["dbus"] + +[package.files] +"/usr/bin/redbear-upower" = "redbear-upower" diff --git a/local/recipes/system/redbear-upower/source/Cargo.toml b/local/recipes/system/redbear-upower/source/Cargo.toml new file mode 100644 index 00000000..445e0768 --- /dev/null +++ b/local/recipes/system/redbear-upower/source/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "redbear-upower" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "redbear-upower" +path = "src/main.rs" + +[dependencies] +zbus = { version = "5", default-features = false, features = ["tokio"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } diff --git a/local/recipes/system/redbear-upower/source/src/main.rs b/local/recipes/system/redbear-upower/source/src/main.rs new file mode 100644 index 00000000..1727d1e0 --- /dev/null +++ b/local/recipes/system/redbear-upower/source/src/main.rs @@ -0,0 +1,540 @@ +use std::{ + env, + error::Error, + fs, + path::{Path, PathBuf}, + process, +}; + +use tokio::runtime::Builder as RuntimeBuilder; +use zbus::{ + Address, + connection::Builder as ConnectionBuilder, interface, object_server::SignalEmitter, + zvariant::OwnedObjectPath, +}; + +const BUS_NAME: &str = "org.freedesktop.UPower"; +const UPOWER_PATH: &str = "/org/freedesktop/UPower"; +const DISPLAY_DEVICE_PATH: &str = "/org/freedesktop/UPower/devices/DisplayDevice"; +const ACPI_POWER_ROOT: &str = "/scheme/acpi/power"; + +const DEVICE_KIND_UNKNOWN: u32 = 0; +const DEVICE_KIND_LINE_POWER: u32 = 1; +const DEVICE_KIND_BATTERY: u32 = 2; + +const DEVICE_STATE_UNKNOWN: u32 = 0; +const DEVICE_STATE_CHARGING: u32 = 1; +const DEVICE_STATE_DISCHARGING: u32 = 2; +const DEVICE_STATE_EMPTY: u32 = 3; +const DEVICE_STATE_FULLY_CHARGED: u32 = 4; + +#[derive(Debug, Clone)] +struct PowerRuntime { + root: PathBuf, + adapter_ids: Vec, + battery_ids: Vec, + object_paths: Vec, +} + +#[derive(Debug, Clone)] +struct UPowerDaemon { + runtime: PowerRuntime, +} + +#[derive(Debug, Clone)] +struct DisplayDevice { + runtime: PowerRuntime, +} + +#[derive(Debug, Clone)] +struct PowerDevice { + runtime: PowerRuntime, + descriptor: DeviceDescriptor, +} + +#[derive(Debug, Clone)] +enum DeviceDescriptor { + Adapter(String), + Battery(String), +} + +#[derive(Debug, Clone)] +struct AdapterState { + native_path: String, + online: bool, +} + +#[derive(Debug, Clone)] +struct BatteryState { + native_path: String, + state_bits: u64, + percentage: Option, +} + +#[derive(Debug, Clone, Default)] +struct PowerSnapshot { + adapters: Vec, + batteries: Vec, +} + +enum Command { + Run, + Help, +} + +fn usage() -> &'static str { + "Usage: redbear-upower [--help]" +} + +fn parse_args() -> Result { + let mut args = env::args().skip(1); + + match args.next() { + None => Ok(Command::Run), + Some(arg) if arg == "--help" || arg == "-h" => { + if args.next().is_some() { + return Err(String::from("unexpected extra arguments after --help")); + } + + Ok(Command::Help) + } + Some(arg) => Err(format!("unrecognized argument '{arg}'")), + } +} + +fn parse_object_path(path: &str) -> Result> { + Ok(OwnedObjectPath::try_from(path.to_owned())?) +} + +fn system_connection_builder() -> Result, Box> { + if let Ok(address) = env::var("DBUS_STARTER_ADDRESS") { + Ok(ConnectionBuilder::address(Address::try_from(address.as_str())?)?) + } else { + Ok(ConnectionBuilder::system()?) + } +} + +fn list_dir_names(path: &Path) -> Vec { + let mut names = match fs::read_dir(path) { + Ok(entries) => entries + .filter_map(|entry| entry.ok()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .collect::>(), + Err(_) => Vec::new(), + }; + names.sort(); + names +} + +fn read_trimmed(path: impl AsRef) -> Option { + let value = fs::read_to_string(path).ok()?; + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn read_u64(path: impl AsRef) -> Option { + read_trimmed(path)?.parse().ok() +} + +fn read_f64(path: impl AsRef) -> Option { + read_trimmed(path)?.parse().ok() +} + +fn battery_state_to_upower(state_bits: u64, percentage: Option) -> u32 { + if state_bits & 0x2 != 0 { + return DEVICE_STATE_CHARGING; + } + if state_bits & 0x1 != 0 { + return DEVICE_STATE_DISCHARGING; + } + if state_bits & 0x4 != 0 { + return DEVICE_STATE_EMPTY; + } + if percentage.is_some_and(|value| value >= 99.0) { + return DEVICE_STATE_FULLY_CHARGED; + } + + DEVICE_STATE_UNKNOWN +} + +impl PowerRuntime { + fn discover() -> Result> { + let root = PathBuf::from(ACPI_POWER_ROOT); + let adapter_ids = list_dir_names(&root.join("adapters")); + let battery_ids = list_dir_names(&root.join("batteries")); + + let mut object_paths = Vec::with_capacity(adapter_ids.len() + battery_ids.len()); + for adapter_id in &adapter_ids { + object_paths.push(parse_object_path(&format!( + "/org/freedesktop/UPower/devices/line_power_{adapter_id}" + ))?); + } + for battery_id in &battery_ids { + object_paths.push(parse_object_path(&format!( + "/org/freedesktop/UPower/devices/battery_{battery_id}" + ))?); + } + + Ok(Self { + root, + adapter_ids, + battery_ids, + object_paths, + }) + } + + fn adapter_dir(&self, id: &str) -> PathBuf { + self.root.join("adapters").join(id) + } + + fn battery_dir(&self, id: &str) -> PathBuf { + self.root.join("batteries").join(id) + } + + fn read_adapter(&self, id: &str) -> Option { + let dir = self.adapter_dir(id); + Some(AdapterState { + native_path: read_trimmed(dir.join("path"))?, + online: read_u64(dir.join("online")).map(|value| value != 0)?, + }) + } + + fn read_battery(&self, id: &str) -> Option { + let dir = self.battery_dir(id); + Some(BatteryState { + native_path: read_trimmed(dir.join("path"))?, + state_bits: read_u64(dir.join("state"))?, + percentage: read_f64(dir.join("percentage")), + }) + } + + fn snapshot(&self) -> PowerSnapshot { + PowerSnapshot { + adapters: self + .adapter_ids + .iter() + .filter_map(|id| self.read_adapter(id)) + .collect(), + batteries: self + .battery_ids + .iter() + .filter_map(|id| self.read_battery(id)) + .collect(), + } + } +} + +impl PowerSnapshot { + fn on_battery(&self) -> bool { + if self.adapters.iter().any(|adapter| adapter.online) { + return false; + } + + self.batteries + .iter() + .any(|battery| battery.state_bits & 0x1 != 0) + } + + fn display_device_kind(&self) -> u32 { + if self.batteries.is_empty() { + DEVICE_KIND_UNKNOWN + } else { + DEVICE_KIND_BATTERY + } + } + + fn display_device_state(&self) -> u32 { + if self.batteries.is_empty() { + return DEVICE_STATE_UNKNOWN; + } + if self + .batteries + .iter() + .any(|battery| battery.state_bits & 0x2 != 0) + { + return DEVICE_STATE_CHARGING; + } + if self + .batteries + .iter() + .any(|battery| battery.state_bits & 0x1 != 0) + { + return DEVICE_STATE_DISCHARGING; + } + if self + .batteries + .iter() + .any(|battery| battery.state_bits & 0x4 != 0) + { + return DEVICE_STATE_EMPTY; + } + + let percentages = self + .batteries + .iter() + .filter_map(|battery| battery.percentage) + .collect::>(); + if !percentages.is_empty() && percentages.iter().all(|value| *value >= 99.0) { + return DEVICE_STATE_FULLY_CHARGED; + } + + DEVICE_STATE_UNKNOWN + } + + fn display_device_percentage(&self) -> f64 { + let percentages = self + .batteries + .iter() + .filter_map(|battery| battery.percentage) + .collect::>(); + + if percentages.is_empty() { + 0.0 + } else { + percentages.iter().sum::() / percentages.len() as f64 + } + } + + fn display_device_present(&self) -> bool { + !self.batteries.is_empty() + } +} + +#[cfg(all(unix, not(target_os = "redox")))] +async fn wait_for_shutdown() -> Result<(), Box> { + use tokio::signal::unix::{SignalKind, signal}; + + let mut terminate = signal(SignalKind::terminate())?; + + tokio::select! { + _ = terminate.recv() => Ok(()), + _ = tokio::signal::ctrl_c() => Ok(()), + } +} + +#[cfg(target_os = "redox")] +async fn wait_for_shutdown() -> Result<(), Box> { + std::future::pending::<()>().await; + #[allow(unreachable_code)] + Ok(()) +} + +#[cfg(all(not(unix), not(target_os = "redox")))] +async fn wait_for_shutdown() -> Result<(), Box> { + tokio::signal::ctrl_c().await?; + Ok(()) +} + +#[interface(name = "org.freedesktop.UPower")] +impl UPowerDaemon { + fn enumerate_devices(&self) -> Vec { + self.runtime.object_paths.clone() + } + + fn get_critical_action(&self) -> String { + String::from("PowerOff") + } + + #[zbus(property(emits_changed_signal = "const"), name = "DaemonVersion")] + fn daemon_version(&self) -> String { + String::from("0.1.0") + } + + #[zbus(property(emits_changed_signal = "const"), name = "OnBattery")] + fn on_battery(&self) -> bool { + self.runtime.snapshot().on_battery() + } + + #[zbus(signal, name = "Changed")] + async fn changed(signal_emitter: &SignalEmitter<'_>) -> zbus::Result<()>; +} + +#[interface(name = "org.freedesktop.UPower.Device")] +impl DisplayDevice { + #[zbus(property(emits_changed_signal = "const"), name = "Type")] + fn kind(&self) -> u32 { + self.runtime.snapshot().display_device_kind() + } + + #[zbus(property(emits_changed_signal = "const"), name = "State")] + fn state(&self) -> u32 { + self.runtime.snapshot().display_device_state() + } + + #[zbus(property(emits_changed_signal = "const"), name = "Percentage")] + fn percentage(&self) -> f64 { + self.runtime.snapshot().display_device_percentage() + } + + #[zbus(property(emits_changed_signal = "const"), name = "IsPresent")] + fn is_present(&self) -> bool { + self.runtime.snapshot().display_device_present() + } + + #[zbus(property(emits_changed_signal = "const"), name = "Online")] + fn online(&self) -> bool { + false + } +} + +#[interface(name = "org.freedesktop.UPower.Device")] +impl PowerDevice { + #[zbus(property(emits_changed_signal = "const"), name = "Type")] + fn kind(&self) -> u32 { + match self.descriptor { + DeviceDescriptor::Adapter(_) => DEVICE_KIND_LINE_POWER, + DeviceDescriptor::Battery(_) => DEVICE_KIND_BATTERY, + } + } + + #[zbus(property(emits_changed_signal = "const"), name = "State")] + fn state(&self) -> u32 { + match &self.descriptor { + DeviceDescriptor::Adapter(_) => DEVICE_STATE_UNKNOWN, + DeviceDescriptor::Battery(id) => self + .runtime + .read_battery(id) + .map(|battery| battery_state_to_upower(battery.state_bits, battery.percentage)) + .unwrap_or(DEVICE_STATE_UNKNOWN), + } + } + + #[zbus(property(emits_changed_signal = "const"), name = "Percentage")] + fn percentage(&self) -> f64 { + match &self.descriptor { + DeviceDescriptor::Adapter(_) => 0.0, + DeviceDescriptor::Battery(id) => self + .runtime + .read_battery(id) + .and_then(|battery| battery.percentage) + .unwrap_or(0.0), + } + } + + #[zbus(property(emits_changed_signal = "const"), name = "IsPresent")] + fn is_present(&self) -> bool { + match &self.descriptor { + DeviceDescriptor::Adapter(id) => self.runtime.read_adapter(id).is_some(), + DeviceDescriptor::Battery(id) => self.runtime.read_battery(id).is_some(), + } + } + + #[zbus(property(emits_changed_signal = "const"), name = "Online")] + fn online(&self) -> bool { + match &self.descriptor { + DeviceDescriptor::Adapter(id) => self + .runtime + .read_adapter(id) + .map(|adapter| adapter.online) + .unwrap_or(false), + DeviceDescriptor::Battery(_) => false, + } + } + + #[zbus(property(emits_changed_signal = "const"), name = "NativePath")] + fn native_path(&self) -> String { + match &self.descriptor { + DeviceDescriptor::Adapter(id) => self + .runtime + .read_adapter(id) + .map(|adapter| adapter.native_path) + .unwrap_or_default(), + DeviceDescriptor::Battery(id) => self + .runtime + .read_battery(id) + .map(|battery| battery.native_path) + .unwrap_or_default(), + } + } +} + +async fn run_daemon() -> Result<(), Box> { + eprintln!("redbear-upower: startup begin"); + let runtime = PowerRuntime::discover()?; + eprintln!( + "redbear-upower: runtime discovered adapters={} batteries={}", + runtime.adapter_ids.len(), + runtime.battery_ids.len() + ); + let _display_device_path = parse_object_path(DISPLAY_DEVICE_PATH)?; + eprintln!("redbear-upower: object paths parsed"); + + eprintln!("redbear-upower: starter address={:?}", env::var("DBUS_STARTER_ADDRESS").ok()); + eprintln!("redbear-upower: building D-Bus connection"); + let mut builder = system_connection_builder()? + .name(BUS_NAME)? + .serve_at( + UPOWER_PATH, + UPowerDaemon { + runtime: runtime.clone(), + }, + )? + .serve_at( + DISPLAY_DEVICE_PATH, + DisplayDevice { + runtime: runtime.clone(), + }, + )?; + + for adapter_id in &runtime.adapter_ids { + let path = format!("/org/freedesktop/UPower/devices/line_power_{adapter_id}"); + builder = builder.serve_at( + path, + PowerDevice { + runtime: runtime.clone(), + descriptor: DeviceDescriptor::Adapter(adapter_id.clone()), + }, + )?; + } + for battery_id in &runtime.battery_ids { + let path = format!("/org/freedesktop/UPower/devices/battery_{battery_id}"); + builder = builder.serve_at( + path, + PowerDevice { + runtime: runtime.clone(), + descriptor: DeviceDescriptor::Battery(battery_id.clone()), + }, + )?; + } + + let connection = builder.build().await?; + + eprintln!("redbear-upower: registered {BUS_NAME} on the system bus"); + + wait_for_shutdown().await?; + drop(connection); + eprintln!("redbear-upower: received shutdown signal, exiting cleanly"); + + Ok(()) +} + +fn main() { + match parse_args() { + Ok(Command::Help) => { + println!("{}", usage()); + } + Ok(Command::Run) => { + let runtime = match RuntimeBuilder::new_multi_thread().enable_all().build() { + Ok(runtime) => runtime, + Err(err) => { + eprintln!("redbear-upower: failed to create tokio runtime: {err}"); + process::exit(1); + } + }; + + if let Err(err) = runtime.block_on(run_daemon()) { + eprintln!("redbear-upower: fatal error: {err}"); + process::exit(1); + } + } + Err(err) => { + eprintln!("redbear-upower: {err}"); + eprintln!("{}", usage()); + process::exit(1); + } + } +} diff --git a/local/scripts/test-dbus-qemu.sh b/local/scripts/test-dbus-qemu.sh new file mode 100755 index 00000000..1cc4c27d --- /dev/null +++ b/local/scripts/test-dbus-qemu.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +# test-dbus-qemu.sh — Validate D-Bus system bus and redbear-sessiond inside a QEMU guest +# +# Usage: +# ./local/scripts/test-dbus-qemu.sh [--check] [--config CONFIG] +# +# Options: +# --check Run non-interactively, exit 0 on pass, 1 on fail +# --config CONFIG Build config to test (default: redbear-kde) +# +# --check mode boots the image, waits for the login prompt, then sends D-Bus +# validation commands via the serial console. Output is captured and parsed. +# +# Checks performed inside the guest: +# 1. dbus-daemon is running (system bus socket exists) +# 2. org.freedesktop.login1 is registered on the system bus +# 3. redbear-sessiond process is running +# 4. login1.Manager.ListSessions returns session c1 +# 5. login1.Manager.IdleHint property is false +# 6. login1.Session.Active property is true +# 7. login1.Seat.CanGraphical property is true +# +# Exit codes: +# 0 All checks passed +# 1 One or more checks failed +# 2 Build or QEMU launch failed + +set -euo pipefail + +CHECK_MODE=0 +CONFIG_NAME="redbear-kde" + +while [[ $# -gt 0 ]]; do + case "$1" in + --check) + CHECK_MODE=1 + shift + ;; + --config) + CONFIG_NAME="$2" + shift 2 + ;; + *) + echo "Usage: $0 [--check] [--config CONFIG]" >&2 + exit 2 + ;; + esac +done + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +IMAGE="${REPO_ROOT}/build/${CONFIG_NAME}/x86_64/harddrive.img" + +if [[ ! -f "$IMAGE" ]]; then + echo "test-dbus-qemu: image not found at ${IMAGE}" >&2 + echo " Run: make all CONFIG_NAME=${CONFIG_NAME}" >&2 + exit 2 +fi + +GUEST_SCRIPT='/tmp/dbus-check.sh' +OUTPUT_FILE='/tmp/dbus-qemu-output.txt' + +# Build the guest-side check script as a standalone file. +# Uses org.freedesktop.DBus.Properties.Get for property access (checks 5-7). +cat > "$GUEST_SCRIPT" <<'GUEST_EOF' +#!/bin/sh +echo "=== D-Bus System Bus Validation ===" + +# Check 1: dbus-daemon running +if [ -S /run/dbus/system_bus_socket ]; then + echo "PASS: system bus socket exists at /run/dbus/system_bus_socket" +else + echo "FAIL: system bus socket not found at /run/dbus/system_bus_socket" +fi + +# Check 2: org.freedesktop.login1 registered +if command -v dbus-send >/dev/null 2>&1; then + RESULT=$(dbus-send --system --dest=org.freedesktop.DBus \ + --type=method_call --print-reply \ + /org/freedesktop/DBus \ + org.freedesktop.DBus.ListNames 2>&1) + if echo "$RESULT" | grep -q "org.freedesktop.login1"; then + echo "PASS: org.freedesktop.login1 is registered on the system bus" + else + echo "FAIL: org.freedesktop.login1 not found on the system bus" + echo " Available names: $(echo "$RESULT" | grep string || echo none)" + fi +else + echo "SKIP: dbus-send not available (install dbus package)" +fi + +# Check 3: redbear-sessiond process +if ps | grep -q '[r]edbear-sessiond'; then + echo "PASS: redbear-sessiond process is running" +else + echo "FAIL: redbear-sessiond process not found" +fi + +# Check 4: login1.Manager.ListSessions +if command -v dbus-send >/dev/null 2>&1; then + SESSIONS=$(dbus-send --system --dest=org.freedesktop.login1 \ + --type=method_call --print-reply \ + /org/freedesktop/login1 \ + org.freedesktop.login1.Manager.ListSessions 2>&1) + if echo "$SESSIONS" | grep -q "c1"; then + echo "PASS: ListSessions returns session c1" + else + echo "FAIL: ListSessions did not return session c1" + fi +fi + +# Check 5: login1.Manager.IdleHint (property, not method) +if command -v dbus-send >/dev/null 2>&1; then + IDLE=$(dbus-send --system --dest=org.freedesktop.login1 \ + --type=method_call --print-reply \ + /org/freedesktop/login1 \ + org.freedesktop.DBus.Properties.Get \ + string:'org.freedesktop.login1.Manager' \ + string:'IdleHint' 2>&1) + if echo "$IDLE" | grep -q "false"; then + echo "PASS: Manager.IdleHint = false" + else + echo "FAIL: Manager.IdleHint not false (got: $IDLE)" + fi +fi + +# Check 6: login1.Session.Active (property, not method) +if command -v dbus-send >/dev/null 2>&1; then + ACTIVE=$(dbus-send --system --dest=org.freedesktop.login1 \ + --type=method_call --print-reply \ + /org/freedesktop/login1/session/c1 \ + org.freedesktop.DBus.Properties.Get \ + string:'org.freedesktop.login1.Session' \ + string:'Active' 2>&1) + if echo "$ACTIVE" | grep -q "true"; then + echo "PASS: Session.Active = true" + else + echo "FAIL: Session.Active not true (got: $ACTIVE)" + fi +fi + +# Check 7: login1.Seat.CanGraphical (property, not method) +if command -v dbus-send >/dev/null 2>&1; then + GRAPH=$(dbus-send --system --dest=org.freedesktop.login1 \ + --type=method_call --print-reply \ + /org/freedesktop/login1/seat/seat0 \ + org.freedesktop.DBus.Properties.Get \ + string:'org.freedesktop.login1.Seat' \ + string:'CanGraphical' 2>&1) + if echo "$GRAPH" | grep -q "true"; then + echo "PASS: Seat.CanGraphical = true" + else + echo "FAIL: Seat.CanGraphical not true (got: $GRAPH)" + fi +fi + +echo "=== D-Bus Validation Complete ===" +GUEST_EOF +chmod +x "$GUEST_SCRIPT" + +if [[ "$CHECK_MODE" -eq 1 ]]; then + if ! command -v expect >/dev/null 2>&1; then + echo "test-dbus-qemu: --check mode requires 'expect' (install tcllib/expect)" >&2 + exit 2 + fi + + echo "test-dbus-qemu: launching QEMU with D-Bus checks (non-interactive)" + + # Use expect to boot the guest, wait for login, run checks, capture output + expect <<'EXPECT_EOF' > "$OUTPUT_FILE" 2>&1 +set timeout 120 +spawn qemu-system-x86_64 \ + -drive file="$::env(IMAGE)" \ + -m 2G \ + -smp 2 \ + -nographic \ + -no-reboot + +expect { + "login:" { } + timeout { puts "FAIL: timed out waiting for login prompt"; exit 1 } +} + +sleep 2 +send "root\r" + +expect { + "#" { } + timeout { puts "FAIL: timed out waiting for shell prompt"; exit 1 } +} + +sleep 1 + +# Read and send the check script line by line +set fp [open "/tmp/dbus-check.sh" r] +while {[gets $fp line] >= 0} { + send "$line\r" + expect { + "#" { } + timeout { puts "FAIL: timed out during check execution"; exit 1 } + } +} +close $fp + +sleep 2 +send "poweroff\r" + +expect { + "Power down" { } + timeout { } +} + +exit 0 +EXPECT_EOF + + FAILED=$(grep -c "FAIL" "$OUTPUT_FILE" 2>/dev/null || true) + PASSED=$(grep -c "PASS" "$OUTPUT_FILE" 2>/dev/null || true) + + grep -E "PASS|FAIL|=== D-Bus" "$OUTPUT_FILE" || true + echo "" + echo "Results: ${PASSED} passed, ${FAILED} failed" + + rm -f "$GUEST_SCRIPT" + if [[ "$FAILED" -gt 0 ]]; then + exit 1 + fi + exit 0 +else + echo "test-dbus-qemu: launching QEMU (interactive mode)" + echo " Guest check script written to /tmp/dbus-check.sh" + echo " After login, run: sh /tmp/dbus-check.sh" + echo "" + + qemu-system-x86_64 \ + -drive file="$IMAGE",format=raw \ + -m 2G \ + -smp 2 \ + -serial mon:stdio +fi diff --git a/recipes/libs/zbus b/recipes/libs/zbus new file mode 120000 index 00000000..e998126b --- /dev/null +++ b/recipes/libs/zbus @@ -0,0 +1 @@ +../../local/recipes/libs/zbus \ No newline at end of file diff --git a/recipes/system/redbear-dbus-services b/recipes/system/redbear-dbus-services new file mode 120000 index 00000000..100690fc --- /dev/null +++ b/recipes/system/redbear-dbus-services @@ -0,0 +1 @@ +../../local/recipes/system/redbear-dbus-services \ No newline at end of file diff --git a/recipes/system/redbear-notifications b/recipes/system/redbear-notifications new file mode 120000 index 00000000..5837e443 --- /dev/null +++ b/recipes/system/redbear-notifications @@ -0,0 +1 @@ +../../local/recipes/system/redbear-notifications \ No newline at end of file diff --git a/recipes/system/redbear-polkit b/recipes/system/redbear-polkit new file mode 120000 index 00000000..b1e71218 --- /dev/null +++ b/recipes/system/redbear-polkit @@ -0,0 +1 @@ +../../local/recipes/system/redbear-polkit \ No newline at end of file diff --git a/recipes/system/redbear-sessiond b/recipes/system/redbear-sessiond new file mode 120000 index 00000000..ccb177c7 --- /dev/null +++ b/recipes/system/redbear-sessiond @@ -0,0 +1 @@ +../../local/recipes/system/redbear-sessiond \ No newline at end of file diff --git a/recipes/system/redbear-udisks b/recipes/system/redbear-udisks new file mode 120000 index 00000000..02cdb983 --- /dev/null +++ b/recipes/system/redbear-udisks @@ -0,0 +1 @@ +../../local/recipes/system/redbear-udisks \ No newline at end of file diff --git a/recipes/system/redbear-upower b/recipes/system/redbear-upower new file mode 120000 index 00000000..b2d15cf0 --- /dev/null +++ b/recipes/system/redbear-upower @@ -0,0 +1 @@ +../../local/recipes/system/redbear-upower \ No newline at end of file