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}")) +}