From 75af4e0a126f49a661efb6ff9211ba01659d80da Mon Sep 17 00:00:00 2001 From: Harald Sitter Date: Wed, 18 Jun 2025 01:40:14 +0200 Subject: [PATCH] implement SessionManagement1 SessionManagement1 provides control over which sessions are started by the login manager. See the dbus API documentation for more information. This commit lifts rust-ini from transitive dependency to direct dependency since we need slightly better control over the ini content. SessionManagement1 is implemented in both user and root scope. User scope implements the public API and internally calls into root scope for root-elevated actions, where necessary augmenting with user-level actions (namely managing user systemd services). Two SDDM configuration files are in play here - `/etc/sddm.conf.d/yy-steamos-session.conf`: acting as "persistent" default configuration - `/etc/sddm.conf.d/zz-steamos-autologin.conf`: the "temporary" configuration overrides the persistent one whenever present and is meant to be cleared by either the login manager or the session. This follows current behavior where we have oneshot sessions that simply reset the temporary back to gamescope. Internal configuration keys are stored in the persistent config inside an `[X-SteamOS]` section. --- Cargo.lock | 1 + Cargo.toml | 1 + com.steampowered.SteamOSManager1.xml | 60 ++++++++++ src/bin/steamosctl.rs | 67 ++++++++++- src/lib.rs | 1 + src/manager/root.rs | 28 +++++ src/manager/user.rs | 74 ++++++++++++ src/proxy/mod.rs | 2 + src/proxy/session_management1.rs | 42 +++++++ src/session_management.rs | 172 +++++++++++++++++++++++++++ 10 files changed, 445 insertions(+), 3 deletions(-) create mode 100644 src/proxy/session_management1.rs create mode 100644 src/session_management.rs diff --git a/Cargo.lock b/Cargo.lock index 956c0de..46bf358 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1064,6 +1064,7 @@ dependencies = [ "nix 0.30.1", "num_enum", "regex", + "rust-ini", "serde", "serde_json", "strum", diff --git a/Cargo.toml b/Cargo.toml index abb3f81..0566fac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ libc = "0.2" nix = { version = "0.30", default-features = false, features = ["fs", "poll", "signal", "time"] } num_enum = "0.7" regex = "1" +rust-ini = "0.21" serde = { version = "1.0", default-features = false, features = ["derive"] } serde_json = "1.0" strum = { version = "0.27", features = ["derive"] } diff --git a/com.steampowered.SteamOSManager1.xml b/com.steampowered.SteamOSManager1.xml index a189c9c..3430be6 100644 --- a/com.steampowered.SteamOSManager1.xml +++ b/com.steampowered.SteamOSManager1.xml @@ -7,6 +7,7 @@ Copyright © 2023 Collabora Ltd. Copyright © 2024 Igalia S.L. Copyright © 2024 Valve Corporation. + Copyright © 2025 Harald Sitter SPDX-License-Identifier: MIT --> @@ -681,4 +682,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/bin/steamosctl.rs b/src/bin/steamosctl.rs index 9d374be..1a1b702 100644 --- a/src/bin/steamosctl.rs +++ b/src/bin/steamosctl.rs @@ -1,6 +1,7 @@ /* * Copyright © 2023 Collabora Ltd. * Copyright © 2024 Valve Software + * Copyright © 2025 Harald Sitter * * SPDX-License-Identifier: MIT */ @@ -17,9 +18,9 @@ use steamos_manager::power::{CPUScalingGovernor, GPUPerformanceLevel, GPUPowerPr use steamos_manager::proxy::{ AmbientLightSensor1Proxy, BatteryChargeLimit1Proxy, CpuScaling1Proxy, FactoryReset1Proxy, FanControl1Proxy, GpuPerformanceLevel1Proxy, GpuPowerProfile1Proxy, HdmiCec1Proxy, - LowPowerMode1Proxy, Manager2Proxy, PerformanceProfile1Proxy, ScreenReader0Proxy, Storage1Proxy, - TdpLimit1Proxy, UpdateBios1Proxy, UpdateDock1Proxy, WifiDebug1Proxy, WifiDebugDump1Proxy, - WifiPowerManagement1Proxy, + LowPowerMode1Proxy, Manager2Proxy, PerformanceProfile1Proxy, ScreenReader0Proxy, + SessionManagement1Proxy, Storage1Proxy, TdpLimit1Proxy, UpdateBios1Proxy, UpdateDock1Proxy, + WifiDebug1Proxy, WifiDebugDump1Proxy, WifiPowerManagement1Proxy, }; use steamos_manager::screenreader::{ScreenReaderAction, ScreenReaderMode}; use steamos_manager::wifi::{WifiBackend, WifiDebugMode, WifiPowerManagement}; @@ -269,6 +270,36 @@ enum Commands { /// `toggle_mode` action: ScreenReaderAction, }, + + /// Switch to desktop mode + SwitchToDesktopMode, + + /// Switch to game mode + SwitchToGameMode, + + /// Switch to a specific session type + SwitchToSession { + /// The session type to switch to, e.g. `plasma`, `gamescope`. + ty: String, + }, + + /// Default desktop session type + GetDefaultDesktopSessionType, + + /// Set default desktop session type + SetDefaultDesktopSessionType { + /// The session type to set as default, e.g. `plasma`, `plasmax11`. + ty: String, + }, + + /// Default session type + GetDefaultSessionType, + + /// Set default session type + SetDefaultSessionType { + /// The session type to set as default, e.g. `plasma`, `gamescope`. + ty: String, + }, } async fn get_all_properties(conn: &Connection) -> Result<()> { @@ -638,6 +669,36 @@ async fn main() -> Result<()> { .trigger_action(*action as u32, now.try_into()?) .await?; } + Commands::SwitchToDesktopMode => { + let proxy = SessionManagement1Proxy::new(&conn).await?; + proxy.switch_to_desktop_mode().await?; + } + Commands::SwitchToGameMode => { + let proxy = SessionManagement1Proxy::new(&conn).await?; + proxy.switch_to_game_mode().await?; + } + Commands::SwitchToSession { ty } => { + let proxy = SessionManagement1Proxy::new(&conn).await?; + proxy.switch_to_session(ty.as_str()).await?; + } + Commands::GetDefaultDesktopSessionType => { + let proxy = SessionManagement1Proxy::new(&conn).await?; + let ty = proxy.default_desktop_session_type().await?; + println!("Default desktop session type: {ty}"); + } + Commands::SetDefaultDesktopSessionType { ty } => { + let proxy = SessionManagement1Proxy::new(&conn).await?; + proxy.set_default_desktop_session_type(ty.as_str()).await?; + } + Commands::GetDefaultSessionType => { + let proxy = SessionManagement1Proxy::new(&conn).await?; + let ty = proxy.default_session_type().await?; + println!("Default session type: {ty}"); + } + Commands::SetDefaultSessionType { ty } => { + let proxy = SessionManagement1Proxy::new(&conn).await?; + proxy.set_default_session_type(ty.as_str()).await?; + } } Ok(()) diff --git a/src/lib.rs b/src/lib.rs index 54c92a2..aa6031d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,6 +36,7 @@ pub mod hardware; pub mod power; pub mod proxy; pub mod screenreader; +pub mod session_management; pub mod wifi; #[cfg(test)] diff --git a/src/manager/root.rs b/src/manager/root.rs index 9b1d564..279e36c 100644 --- a/src/manager/root.rs +++ b/src/manager/root.rs @@ -2,6 +2,7 @@ * Copyright © 2023 Collabora Ltd. * Copyright © 2024 Valve Software * Copyright © 2024 Igalia S.L. + * Copyright © 2025 Harald Sitter * * SPDX-License-Identifier: MIT */ @@ -33,6 +34,9 @@ use crate::power::{ GPUPerformanceLevel, GPUPowerProfile, SysfsWritten, TdpLimitManager, }; use crate::process::{run_script, script_output}; +use crate::session_management::{ + clear_ephemeral_session, set_session_to_switch_to, write_default_desktop_session_type, write_default_session_type +}; use crate::wifi::{ extract_wifi_trace, generate_wifi_dump, set_wifi_backend, set_wifi_debug_mode, set_wifi_power_management_state, WifiBackend, WifiDebugMode, WifiPowerManagement, @@ -488,6 +492,30 @@ impl SteamOSManager { .map_err(to_zbus_fdo_error) } + async fn set_session_to_switch_to(&self, ty: &str) -> fdo::Result<()> { + set_session_to_switch_to(ty) + .await + .inspect_err(|message| error!("Error switching to session: {message}")) + .map_err(to_zbus_fdo_error) + } + + async fn set_default_desktop_session_type(&mut self, ty: &str) -> fdo::Result<()> { + write_default_desktop_session_type(ty) + .await + .map_err(to_zbus_fdo_error) + } + + async fn set_default_session_type(&mut self, ty: &str) -> fdo::Result<()> { + write_default_session_type(ty) + .await + .map_err(to_zbus_fdo_error) + } + + async fn set_login_successful(&mut self) -> fdo::Result<()> { + clear_ephemeral_session().await + .map_err(to_zbus_fdo_error) + } + /// A version property. #[zbus(property(emits_changed_signal = "const"))] async fn version(&self) -> u32 { diff --git a/src/manager/user.rs b/src/manager/user.rs index 797f0c3..27a1f2a 100644 --- a/src/manager/user.rs +++ b/src/manager/user.rs @@ -2,6 +2,7 @@ * Copyright © 2023 Collabora Ltd. * Copyright © 2024 Valve Software * Copyright © 2024 Igalia S.L. + * Copyright © 2025 Harald Sitter * * SPDX-License-Identifier: MIT */ @@ -33,6 +34,9 @@ use crate::power::{ get_max_charge_level, get_platform_profile, TdpManagerCommand, }; use crate::screenreader::{OrcaManager, ScreenReaderAction, ScreenReaderMode}; +use crate::session_management::{ + read_default_desktop_session_type, read_default_session_type, restart_session, +}; use crate::wifi::{ get_wifi_backend, get_wifi_power_management_state, list_wifi_interfaces, WifiBackend, }; @@ -155,6 +159,11 @@ struct PerformanceProfile1 { tdp_limit_manager: Option>, } +struct SessionManagement1 { + proxy: Proxy<'static>, + connection: Connection, +} + struct ScreenReader0 { screen_reader: OrcaManager<'static>, } @@ -721,6 +730,65 @@ impl ScreenReader0 { } } +#[interface(name = "com.steampowered.SteamOSManager1.SessionManagement1")] +impl SessionManagement1 { + async fn switch_to_desktop_mode(&self) -> fdo::Result<()> { + self.switch_to_session( + read_default_desktop_session_type() + .await + .map_err(to_zbus_fdo_error)? + .as_str(), + ) + .await + } + + async fn switch_to_game_mode(&self) -> fdo::Result<()> { + self.switch_to_session("gamescope-wayland").await + } + + async fn switch_to_session(&self, ty: &str) -> fdo::Result<()> { + let _: () = method!(self, "SetSessionToSwitchTo", ty)?; + restart_session(&self.connection) + .await + .map_err(to_zbus_fdo_error) + } + + #[zbus(property)] + async fn default_desktop_session_type(&self) -> fdo::Result { + read_default_desktop_session_type() + .await + .map_err(to_zbus_fdo_error) + } + + #[zbus(property)] + async fn set_default_desktop_session_type( + &self, + ty: &str, + #[zbus(signal_emitter)] ctx: SignalEmitter<'_>, + ) -> zbus::Result<()> { + let _: () = self + .proxy + .call("SetDefaultDesktopSessionType", &(ty)) + .await?; + self.default_desktop_session_type_changed(&ctx).await + } + + #[zbus(property)] + async fn default_session_type(&self) -> fdo::Result { + read_default_session_type().await.map_err(to_zbus_fdo_error) + } + + #[zbus(property)] + async fn set_default_session_type( + &self, + ty: &str, + #[zbus(signal_emitter)] ctx: SignalEmitter<'_>, + ) -> zbus::Result<()> { + let _: () = self.proxy.call("SetDefaultSessionType", &(ty)).await?; + self.default_session_type_changed(&ctx).await + } +} + #[interface(name = "com.steampowered.SteamOSManager1.Storage1")] impl Storage1 { async fn format_device( @@ -1085,6 +1153,10 @@ pub(crate) async fn create_interfaces( channel: daemon, }; let screen_reader = ScreenReader0::new(&session).await?; + let session_management = SessionManagement1 { + proxy: proxy.clone(), + connection: session.clone(), + }; let wifi_debug = WifiDebug1 { proxy: proxy.clone(), }; @@ -1140,6 +1212,8 @@ pub(crate) async fn create_interfaces( object_server.at(MANAGER_PATH, screen_reader).await?; + object_server.at(MANAGER_PATH, session_management).await?; + if steam_deck_variant().await.unwrap_or_default() == SteamDeckVariant::Galileo { object_server.at(MANAGER_PATH, wifi_debug).await?; } diff --git a/src/proxy/mod.rs b/src/proxy/mod.rs index f8c0acf..470173e 100644 --- a/src/proxy/mod.rs +++ b/src/proxy/mod.rs @@ -26,6 +26,7 @@ mod low_power_mode1; mod manager2; mod performance_profile1; mod screenreader0; +mod session_management1; mod storage1; mod tdp_limit1; mod update_bios1; @@ -45,6 +46,7 @@ pub use crate::proxy::low_power_mode1::LowPowerMode1Proxy; pub use crate::proxy::manager2::Manager2Proxy; pub use crate::proxy::performance_profile1::PerformanceProfile1Proxy; pub use crate::proxy::screenreader0::ScreenReader0Proxy; +pub use crate::proxy::session_management1::SessionManagement1Proxy; pub use crate::proxy::storage1::Storage1Proxy; pub use crate::proxy::tdp_limit1::TdpLimit1Proxy; pub use crate::proxy::update_bios1::UpdateBios1Proxy; diff --git a/src/proxy/session_management1.rs b/src/proxy/session_management1.rs new file mode 100644 index 0000000..3d2abd9 --- /dev/null +++ b/src/proxy/session_management1.rs @@ -0,0 +1,42 @@ +//! # D-Bus interface proxy for: `com.steampowered.SteamOSManager1.SessionManagement1` +//! +//! This code was generated by `zbus-xmlgen` `5.1.0` from D-Bus introspection data. +//! Source: `com.steampowered.SteamOSManager1.xml`. +//! +//! You may prefer to adapt it, instead of using it verbatim. +//! +//! More information can be found in the [Writing a client proxy] section of the zbus +//! documentation. +//! +//! +//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html +//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, +use zbus::proxy; +#[proxy( + interface = "com.steampowered.SteamOSManager1.SessionManagement1", + default_service = "com.steampowered.SteamOSManager1", + default_path = "/com/steampowered/SteamOSManager1", + assume_defaults = true +)] +pub trait SessionManagement1 { + /// SwitchToDesktopMode method + fn switch_to_desktop_mode(&self) -> zbus::Result<()>; + + /// SwitchToGameMode method + fn switch_to_game_mode(&self) -> zbus::Result<()>; + + /// SwitchToSession method + fn switch_to_session(&self, type_: &str) -> zbus::Result<()>; + + /// DefaultDesktopSessionType property + #[zbus(property)] + fn default_desktop_session_type(&self) -> zbus::Result; + #[zbus(property)] + fn set_default_desktop_session_type(&self, value: &str) -> zbus::Result<()>; + + /// DefaultSessionType property + #[zbus(property)] + fn default_session_type(&self) -> zbus::Result; + #[zbus(property)] + fn set_default_session_type(&self, value: &str) -> zbus::Result<()>; +} diff --git a/src/session_management.rs b/src/session_management.rs new file mode 100644 index 0000000..5248b78 --- /dev/null +++ b/src/session_management.rs @@ -0,0 +1,172 @@ +/* + * Copyright © 2025 Harald Sitter + * + * SPDX-License-Identifier: MIT + */ + +use anyhow::{anyhow, bail, Result}; +use ini::Ini; +use tracing::debug; +use std::path::Path; +use tokio::fs; +use zbus::{self, Connection}; + +use crate::systemd::SystemdUnit; + +// This is our persistent store. It applies unless an ephemeral session is set. +const PERSISTENT_CONFIG_FILE: &str = "/etc/sddm.conf.d/yy-steamos-session.conf"; +// The ephemeral session configuration is ordered AFTER the persistent one so it can temporarily override it. +const EPHEMERAL_CONFIG_FILE: &str = "/etc/sddm.conf.d/zz-steamos-autologin.conf"; +const CONFIG_SECTION_STEAM: &str = "X-SteamOS"; +const CONFIG_SECTION_AUTOLOGIN: &str = "Autologin"; +const DEFAULT_DESKTOP_SESSION: &str = "plasmax11"; +const DEFAULT_SESSION: &str = "gamescope-wayland"; +const CONFIG_KEY_DEFAULT_DESKTOP_SESSION: &str = "DefaultDesktopSession"; +const CONFIG_KEY_SESSION: &str = "Session"; + +async fn find_type_in_dir( + prefix: impl AsRef, + ty: &str +) -> Result { + let type_without_suffix = ty.trim_end_matches(".desktop"); + let expected_session = format!("{type_without_suffix}.desktop"); + + let mut dir = fs::read_dir(prefix.as_ref()).await?; + while let Some(entry) = dir.next_entry().await? { + let file_name = entry.file_name(); + let session: &str = &file_name.to_string_lossy(); + if session == &expected_session { + return Ok(expected_session); + } + } + + bail!( "Session type {ty} not found in directory {}", + prefix.as_ref().display() + ) +} + +async fn ensure_session_exists(ty: &str) -> Result { + // Guard against bad input strings. Notably we don't want relative paths here as they would allow inspecting + // all root owned files. + // While we are at it also figure out if the session type has a one-shot variant and prefer that. + + for dir in ["/usr/share/wayland-sessions", "/usr/share/xsessions"] { + match find_type_in_dir(dir, ty).await { + Ok(session) => return Ok(session), + Err(e) => debug!("{e}"), // output and try next + } + } + + bail!("Session type {ty} not found in any of the known session directories") +} + +async fn ini_load_async(path: &str) -> Result { + let data = tokio::fs::read_to_string(path).await?; + Ini::load_from_str(data.as_str()).map_err(|e| anyhow!("Failed to load INI from {path}: {e}")) +} + +async fn read_type_from_config( + config_file: &str, + config_section: &str, + config_key: &str, + default_session: &str, +) -> Result { + let config = match ini_load_async(config_file).await { + Ok(config) => config, + _ => return Ok(default_session.to_owned()), + }; + match config.section(Some(config_section)) { + Some(section) => { + let session = section.get(config_key); + return Ok(session.unwrap_or(default_session).to_owned()); + } + None => return Ok(default_session.to_owned()), + } +} + +async fn write_type_to_config( + config_file: &str, + config_section: &str, + config_key: &str, + session_name: &str, +) -> Result<()> { + let mut config = match ini_load_async(config_file).await { + Ok(config) => config, + _ => Ini::new(), + }; + config + .with_section(Some(config_section)) + .set(config_key, session_name); + config.write_to_file(config_file)?; + Ok(()) +} + +pub(crate) async fn set_session_to_switch_to(ty: &str) -> Result<()> { + ensure_session_exists(ty).await?; + write_type_to_config( + EPHEMERAL_CONFIG_FILE, + CONFIG_SECTION_AUTOLOGIN, + CONFIG_KEY_SESSION, + &ty, + ) + .await +} + +pub(crate) async fn read_default_desktop_session_type() -> Result { + read_type_from_config( + PERSISTENT_CONFIG_FILE, + CONFIG_SECTION_STEAM, + CONFIG_KEY_DEFAULT_DESKTOP_SESSION, + DEFAULT_DESKTOP_SESSION, + ) + .await +} + +pub(crate) async fn write_default_desktop_session_type(ty: &str) -> Result<()> { + ensure_session_exists(ty).await?; + write_type_to_config( + PERSISTENT_CONFIG_FILE, + CONFIG_SECTION_STEAM, + CONFIG_KEY_DEFAULT_DESKTOP_SESSION, + &ty, + ) + .await +} + +pub(crate) async fn read_default_session_type() -> Result { + read_type_from_config( + PERSISTENT_CONFIG_FILE, + CONFIG_SECTION_AUTOLOGIN, + CONFIG_KEY_SESSION, + DEFAULT_SESSION, + ) + .await +} + +pub(crate) async fn write_default_session_type(ty: &str) -> Result<()> { + ensure_session_exists(ty).await?; + write_type_to_config( + PERSISTENT_CONFIG_FILE, + CONFIG_SECTION_AUTOLOGIN, + CONFIG_KEY_SESSION, + ty, + ) + .await +} + +pub(crate) async fn restart_session(connection: &Connection) -> Result<()> { + for service in ["plasma-workspace.target", "gamescope-session.service"] { + let unit = SystemdUnit::new(connection.clone(), service).await?; + unit.stop() + .await + .map_err(|e| anyhow!("Failed to stop {service}: {e}"))?; + } + + Ok(()) +} + +pub(crate) async fn clear_ephemeral_session() -> Result<()> { + tokio::fs::remove_file(EPHEMERAL_CONFIG_FILE) + .await + .map_err(|e| anyhow!("Failed to clear ephemeral session: {e}")) +}