Merge branch 'work/whiting/screenreader' into 'master'

Add screenreader support to steamos-manager.

See merge request holo/steamos-manager!1
This commit is contained in:
Jeremy Whiting 2025-05-29 22:29:21 -06:00
commit 334fc09a0b
14 changed files with 1015 additions and 5 deletions

View file

@ -8,7 +8,7 @@ test:
tags: tags:
- x86_64-linux-kvm-docker - x86_64-linux-kvm-docker
script: script:
- pacman -Sy --noconfirm --needed dbus rust - pacman -Sy --noconfirm --needed dbus rust gsettings-desktop-schemas
- dbus-run-session cargo test - dbus-run-session cargo test
workflow: workflow:

218
Cargo.lock generated
View file

@ -105,6 +105,16 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "cfg-expr"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e34e221e91c7eb5e8315b5c9cf1a61670938c0626451f954a51693ed44b37f45"
dependencies = [
"smallvec",
"target-lexicon",
]
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.0" version = "1.0.0"
@ -295,12 +305,32 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "futures-channel"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
]
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.31" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-io" name = "futures-io"
version = "0.3.31" version = "0.3.31"
@ -320,12 +350,43 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.31" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]]
name = "futures-task"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-util"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.16" version = "0.2.16"
@ -355,6 +416,91 @@ version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "gio"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2a5c3829f5794cb15120db87707b2ec03720edff7ad09eb7b711b532e3fe747"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-util",
"gio-sys",
"glib",
"libc",
"pin-project-lite",
"smallvec",
]
[[package]]
name = "gio-sys"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
"windows-sys 0.59.0",
]
[[package]]
name = "glib"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c501c495842c2b23cdacead803a5a343ca2a5d7a7ddaff14cc5f6cf22cfb92c2"
dependencies = [
"bitflags",
"futures-channel",
"futures-core",
"futures-executor",
"futures-task",
"futures-util",
"gio-sys",
"glib-macros",
"glib-sys",
"gobject-sys",
"libc",
"memchr",
"smallvec",
]
[[package]]
name = "glib-macros"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe6dc9ce29887c4b3b74d78d5ba473db160a258ae7ed883d23632ac7fed7bc9"
dependencies = [
"heck",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "glib-sys"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215"
dependencies = [
"libc",
"system-deps",
]
[[package]]
name = "gobject-sys"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@ -437,6 +583,12 @@ dependencies = [
"either", "either",
] ]
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@ -596,6 +748,12 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.32" version = "0.3.32"
@ -725,6 +883,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2"
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.219" version = "1.0.219"
@ -745,6 +909,18 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "serde_json"
version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
]
[[package]] [[package]]
name = "serde_repr" name = "serde_repr"
version = "0.1.20" version = "0.1.20"
@ -783,6 +959,21 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "slab"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
dependencies = [
"autocfg",
]
[[package]]
name = "smallvec"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.5.9" version = "0.5.9"
@ -807,6 +998,7 @@ dependencies = [
"async-trait", "async-trait",
"clap", "clap",
"config", "config",
"gio",
"inotify", "inotify",
"itertools", "itertools",
"lazy_static", "lazy_static",
@ -815,6 +1007,7 @@ dependencies = [
"num_enum", "num_enum",
"regex", "regex",
"serde", "serde",
"serde_json",
"strum", "strum",
"tempfile", "tempfile",
"tokio", "tokio",
@ -862,6 +1055,25 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "system-deps"
version = "7.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4be53aa0cba896d2dc615bd42bbc130acdcffa239e0a2d965ea5b3b2a86ffdb"
dependencies = [
"cfg-expr",
"heck",
"pkg-config",
"toml",
"version-compare",
]
[[package]]
name = "target-lexicon"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a"
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.20.0" version = "3.20.0"
@ -1069,6 +1281,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "version-compare"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b"
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.0+wasi-snapshot-preview1" version = "0.11.0+wasi-snapshot-preview1"

View file

@ -11,6 +11,7 @@ anyhow = "1"
async-trait = "0.1" async-trait = "0.1"
clap = { version = "4.5", default-features = false, features = ["derive", "help", "std", "usage"] } clap = { version = "4.5", default-features = false, features = ["derive", "help", "std", "usage"] }
config = { version = "0.15", default-features = false, features = ["async", "ini", "toml"] } config = { version = "0.15", default-features = false, features = ["async", "ini", "toml"] }
gio = "0.20"
inotify = { version = "0.11", default-features = false, features = ["stream"] } inotify = { version = "0.11", default-features = false, features = ["stream"] }
itertools = "0.14" itertools = "0.14"
lazy_static = "1" lazy_static = "1"
@ -19,6 +20,7 @@ nix = { version = "0.30", default-features = false, features = ["fs", "poll", "s
num_enum = "0.7" num_enum = "0.7"
regex = "1" regex = "1"
serde = { version = "1.0", default-features = false, features = ["derive"] } serde = { version = "1.0", default-features = false, features = ["derive"] }
serde_json = "1.0"
strum = { version = "0.27", features = ["derive"] } strum = { version = "0.27", features = ["derive"] }
tempfile = "3" tempfile = "3"
tokio = { version = "1", default-features = false, features = ["fs", "io-std", "io-util", "macros", "process", "rt-multi-thread", "signal", "sync"] } tokio = { version = "1", default-features = false, features = ["fs", "io-std", "io-util", "macros", "process", "rt-multi-thread", "signal", "sync"] }

View file

@ -33,3 +33,4 @@ install: target/release/steamos-manager target/release/steamosctl
install -m644 "data/user/com.steampowered.SteamOSManager1.service" "$(DESTDIR)/usr/share/dbus-1/services/" install -m644 "data/user/com.steampowered.SteamOSManager1.service" "$(DESTDIR)/usr/share/dbus-1/services/"
install -m644 "data/user/steamos-manager.service" "$(DESTDIR)/usr/lib/systemd/user/" install -m644 "data/user/steamos-manager.service" "$(DESTDIR)/usr/lib/systemd/user/"
install -m644 "data/user/orca.service" "$(DESTDIR)/usr/lib/systemd/user/"

View file

@ -298,6 +298,43 @@
</interface> </interface>
<!--
com.steampowered.SteamOSManager1.ScreenReader1
@short_description: Optional interface for managing a screen reader.
This interface is considered unstable and may change between verisons.
Once it is considered stable it will be renamed to ScreenReader1
-->
<interface name="com.steampowered.SteamOSManager1.ScreenReader0">
<!--
Enabled
True if screen reader is enabled, false otherwise.
-->
<property name="Enabled" type="b" access="readwrite"/>
<!--
Rate
The rate of speech output. Valid values are 0 for slowest to 100 for fastest.
-->
<property name="Rate" type="d" access="readwrite"/>
<!--
Pitch
The pitch for speech output. Valid values are 0.0 for lowest, and 10.0 for highest.
-->
<property name="Pitch" type="d" access="readwrite"/>
<!--
Volume
The volume for speech output. Valid values ar 0.0 for off, 10.0 for highest.
-->
<property name="Volume" type="d" access="readwrite"/>
</interface>
<!-- <!--
com.steampowered.SteamOSManager1.Storage1 com.steampowered.SteamOSManager1.Storage1
@short_description: Optional interface for managing storage devices @short_description: Optional interface for managing storage devices

View file

@ -0,0 +1,150 @@
{
"general": {
"orcaModifierKeys": [
"Insert",
"KP_Insert"
],
"enableSpeech": true,
"onlySpeakDisplayedText": false,
"speechServerFactory": "speechdispatcherfactory",
"speechServerInfo": null,
"voices": {
"default": {
"established": false
},
"uppercase": {
"average-pitch": 7.0
},
"hyperlink": {
"established": false
},
"system": {
"established": false
}
},
"speechVerbosityLevel": 1,
"readFullRowInGUITable": true,
"readFullRowInDocumentTable": true,
"readFullRowInSpreadSheet": false,
"enableSpeechIndentation": false,
"enableEchoByCharacter": false,
"enableEchoByWord": false,
"enableEchoBySentence": false,
"enableKeyEcho": true,
"enableAlphabeticKeys": true,
"enableNumericKeys": true,
"enablePunctuationKeys": true,
"enableSpace": true,
"enableModifierKeys": true,
"enableFunctionKeys": true,
"enableActionKeys": true,
"enableNavigationKeys": false,
"enableDiacriticalKeys": false,
"enablePauseBreaks": true,
"enableTutorialMessages": true,
"enableMnemonicSpeaking": false,
"enablePositionSpeaking": false,
"enableBraille": true,
"disableBrailleEOL": false,
"brailleVerbosityLevel": 1,
"brailleRolenameStyle": 1,
"brailleSelectorIndicator": 192,
"brailleLinkIndicator": 192,
"enableSound": true,
"soundVolume": 0.5,
"playSoundForRole": false,
"playSoundForState": false,
"playSoundForPositionInSet": false,
"playSoundForValue": false,
"verbalizePunctuationStyle": 1,
"presentToolTips": false,
"sayAllStyle": 1,
"keyboardLayout": 1,
"speakBlankLines": true,
"speakNumbersAsDigits": false,
"speakMisspelledIndicator": true,
"textAttributesToSpeak": [],
"textAttributesToBraille": [],
"textAttributesBrailleIndicator": 0,
"profile": [
"Default",
"default"
],
"speakProgressBarUpdates": true,
"brailleProgressBarUpdates": false,
"beepProgressBarUpdates": false,
"progressBarUpdateInterval": 10,
"progressBarVerbosity": 1,
"ignoreStatusBarProgressBars": true,
"enableBrailleWordWrap": false,
"enableContractedBraille": false,
"brailleContractionTable": "",
"enableMouseReview": false,
"speakCellCoordinates": true,
"speakSpreadsheetCoordinates": true,
"alwaysSpeakSelectedSpreadsheetRange": false,
"speakCellSpan": true,
"speakCellHeaders": true,
"skipBlankCells": false,
"largeObjectTextLength": 75,
"structuralNavigationEnabled": true,
"wrappedStructuralNavigation": true,
"chatMessageVerbosity": 0,
"chatSpeakRoomName": false,
"chatAnnounceBuddyTyping": false,
"chatRoomHistories": false,
"enableFlashMessages": true,
"brailleFlashTime": 5000,
"flashIsPersistent": false,
"flashIsDetailed": true,
"messagesAreDetailed": true,
"presentDateFormat": "%x",
"presentTimeFormat": "%X",
"activeProfile": [
"Default",
"default"
],
"startingProfile": [
"Default",
"default"
],
"spellcheckSpellError": true,
"spellcheckSpellSuggestion": true,
"spellcheckPresentContext": true,
"useColorNames": true,
"capitalizationStyle": "none",
"findResultsVerbosity": 2,
"findResultsMinimumLength": 4,
"structNavTriggersFocusMode": false,
"caretNavTriggersFocusMode": false,
"layoutMode": true,
"nativeNavTriggersFocusMode": true,
"rewindAndFastForwardInSayAll": false,
"structNavInSayAll": false,
"speakDescription": true,
"speakContextBlockquote": true,
"speakContextPanel": true,
"speakContextLandmark": true,
"speakContextNonLandmarkForm": true,
"speakContextList": true,
"speakContextTable": true,
"sayAllContextBlockquote": true,
"sayAllContextPanel": true,
"sayAllContextLandmark": true,
"sayAllContextNonLandmarkForm": true,
"sayAllContextList": true,
"sayAllContextTable": true
},
"profiles": {
"default": {
"profile": [
"Default",
"default"
],
"pronunciations": {},
"keybindings": {}
}
},
"pronunciations": {},
"keybindings": {}
}

9
data/user/orca.service Normal file
View file

@ -0,0 +1,9 @@
[Unit]
Description=Orca Screen Reader
[Service]
Type=simple
ExecStart=/usr/bin/orca
ExecReload=/usr/bin/orca --replace
Restart=on-failure
EnvironmentFile=%t/gamescope-environment

View file

@ -6,7 +6,7 @@
*/ */
use anyhow::Result; use anyhow::Result;
use clap::{Parser, Subcommand}; use clap::{ArgAction, Parser, Subcommand};
use itertools::Itertools; use itertools::Itertools;
use std::collections::HashMap; use std::collections::HashMap;
use std::io::Cursor; use std::io::Cursor;
@ -16,8 +16,8 @@ use steamos_manager::power::{CPUScalingGovernor, GPUPerformanceLevel, GPUPowerPr
use steamos_manager::proxy::{ use steamos_manager::proxy::{
AmbientLightSensor1Proxy, BatteryChargeLimit1Proxy, CpuScaling1Proxy, FactoryReset1Proxy, AmbientLightSensor1Proxy, BatteryChargeLimit1Proxy, CpuScaling1Proxy, FactoryReset1Proxy,
FanControl1Proxy, GpuPerformanceLevel1Proxy, GpuPowerProfile1Proxy, HdmiCec1Proxy, FanControl1Proxy, GpuPerformanceLevel1Proxy, GpuPowerProfile1Proxy, HdmiCec1Proxy,
LowPowerMode1Proxy, Manager2Proxy, PerformanceProfile1Proxy, Storage1Proxy, TdpLimit1Proxy, LowPowerMode1Proxy, Manager2Proxy, PerformanceProfile1Proxy, ScreenReader0Proxy, Storage1Proxy,
UpdateBios1Proxy, UpdateDock1Proxy, WifiDebug1Proxy, WifiDebugDump1Proxy, TdpLimit1Proxy, UpdateBios1Proxy, UpdateDock1Proxy, WifiDebug1Proxy, WifiDebugDump1Proxy,
WifiPowerManagement1Proxy, WifiPowerManagement1Proxy,
}; };
use steamos_manager::wifi::{WifiBackend, WifiDebugMode, WifiPowerManagement}; use steamos_manager::wifi::{WifiBackend, WifiDebugMode, WifiPowerManagement};
@ -206,6 +206,42 @@ enum Commands {
/// Get the model and variant of this device, if known /// Get the model and variant of this device, if known
GetDeviceModel, GetDeviceModel,
/// Get whether screen reader is enabled or not.
GetScreenReaderEnabled,
/// Enable or disable the screen reader
SetScreenReaderEnabled {
#[arg(action = ArgAction::Set, required = true)]
enable: bool,
},
/// Get screen reader rate
GetScreenReaderRate,
/// Set screen reader rate
SetScreenReaderRate {
/// Valid rates between 0.0 for slowest and 100.0 for fastest.
rate: f64,
},
/// Get screen reader pitch
GetScreenReaderPitch,
/// Set screen reader pitch
SetScreenReaderPitch {
/// Valid pitches between 0.0 for lowest and 10.0 for highest.
pitch: f64,
},
/// Get screen reader volume
GetScreenReaderVolume,
/// Set screen reader volume
SetScreenReaderVolume {
/// Valid volume between 0.0 for off, and 10.0 for loudest.
volume: f64,
},
} }
async fn get_all_properties(conn: &Connection) -> Result<()> { async fn get_all_properties(conn: &Connection) -> Result<()> {
@ -519,6 +555,42 @@ async fn main() -> Result<()> {
println!("Model: {device}"); println!("Model: {device}");
println!("Variant: {variant}"); println!("Variant: {variant}");
} }
Commands::GetScreenReaderEnabled => {
let proxy = ScreenReader0Proxy::new(&conn).await?;
let enabled = proxy.enabled().await?;
println!("Enabled: {enabled}");
}
Commands::SetScreenReaderEnabled { enable } => {
let proxy = ScreenReader0Proxy::new(&conn).await?;
proxy.set_enabled(*enable).await?;
}
Commands::GetScreenReaderRate => {
let proxy = ScreenReader0Proxy::new(&conn).await?;
let rate = proxy.rate().await?;
println!("Rate: {rate}");
}
Commands::SetScreenReaderRate { rate } => {
let proxy = ScreenReader0Proxy::new(&conn).await?;
proxy.set_rate(*rate).await?;
}
Commands::GetScreenReaderPitch => {
let proxy = ScreenReader0Proxy::new(&conn).await?;
let pitch = proxy.pitch().await?;
println!("Pitch: {pitch}");
}
Commands::SetScreenReaderPitch { pitch } => {
let proxy = ScreenReader0Proxy::new(&conn).await?;
proxy.set_pitch(*pitch).await?;
}
Commands::GetScreenReaderVolume => {
let proxy = ScreenReader0Proxy::new(&conn).await?;
let volume = proxy.volume().await?;
println!("Volume: {volume}");
}
Commands::SetScreenReaderVolume { volume } => {
let proxy = ScreenReader0Proxy::new(&conn).await?;
proxy.set_volume(*volume).await?;
}
} }
Ok(()) Ok(())

View file

@ -25,6 +25,7 @@ mod job;
mod manager; mod manager;
mod platform; mod platform;
mod process; mod process;
mod screenreader;
mod sls; mod sls;
mod systemd; mod systemd;
mod udev; mod udev;

View file

@ -656,7 +656,7 @@ mod test {
fake_model(SteamDeckVariant::Unknown) fake_model(SteamDeckVariant::Unknown)
.await .await
.expect("fake_model"); .expect("fake_model");
assert_eq!(proxy.als_calibration_gain().await.unwrap(), &[]); assert!(proxy.als_calibration_gain().await.unwrap().is_empty());
fake_model(SteamDeckVariant::Jupiter) fake_model(SteamDeckVariant::Jupiter)
.await .await

View file

@ -31,6 +31,7 @@ use crate::power::{
get_gpu_clocks, get_gpu_clocks_range, get_gpu_performance_level, get_gpu_power_profile, get_gpu_clocks, get_gpu_clocks_range, get_gpu_performance_level, get_gpu_power_profile,
get_max_charge_level, get_platform_profile, tdp_limit_manager, TdpManagerCommand, get_max_charge_level, get_platform_profile, tdp_limit_manager, TdpManagerCommand,
}; };
use crate::screenreader::OrcaManager;
use crate::wifi::{ use crate::wifi::{
get_wifi_backend, get_wifi_power_management_state, list_wifi_interfaces, WifiBackend, get_wifi_backend, get_wifi_power_management_state, list_wifi_interfaces, WifiBackend,
}; };
@ -153,6 +154,10 @@ struct PerformanceProfile1 {
tdp_limit_manager: UnboundedSender<TdpManagerCommand>, tdp_limit_manager: UnboundedSender<TdpManagerCommand>,
} }
struct ScreenReader0 {
screen_reader: OrcaManager<'static>,
}
struct Storage1 { struct Storage1 {
proxy: Proxy<'static>, proxy: Proxy<'static>,
job_manager: UnboundedSender<JobManagerCommand>, job_manager: UnboundedSender<JobManagerCommand>,
@ -618,6 +623,68 @@ impl PerformanceProfile1 {
} }
} }
impl ScreenReader0 {
async fn new(connection: &Connection) -> Result<ScreenReader0> {
let screen_reader = OrcaManager::new(connection).await?;
Ok(ScreenReader0 { screen_reader })
}
}
#[interface(name = "com.steampowered.SteamOSManager1.ScreenReader0")]
impl ScreenReader0 {
#[zbus(property)]
async fn enabled(&self) -> bool {
self.screen_reader.enabled()
}
#[zbus(property)]
async fn set_enabled(&mut self, enabled: bool) -> fdo::Result<()> {
self.screen_reader
.set_enabled(enabled)
.await
.map_err(to_zbus_fdo_error)
}
#[zbus(property)]
async fn rate(&self) -> f64 {
self.screen_reader.rate()
}
#[zbus(property)]
async fn set_rate(&mut self, rate: f64) -> fdo::Result<()> {
self.screen_reader
.set_rate(rate)
.await
.map_err(to_zbus_fdo_error)
}
#[zbus(property)]
async fn pitch(&self) -> f64 {
self.screen_reader.pitch()
}
#[zbus(property)]
async fn set_pitch(&mut self, pitch: f64) -> fdo::Result<()> {
self.screen_reader
.set_pitch(pitch)
.await
.map_err(to_zbus_fdo_error)
}
#[zbus(property)]
async fn volume(&self) -> f64 {
self.screen_reader.volume()
}
#[zbus(property)]
async fn set_volume(&mut self, volume: f64) -> fdo::Result<()> {
self.screen_reader
.set_volume(volume)
.await
.map_err(to_zbus_fdo_error)
}
}
#[interface(name = "com.steampowered.SteamOSManager1.Storage1")] #[interface(name = "com.steampowered.SteamOSManager1.Storage1")]
impl Storage1 { impl Storage1 {
async fn format_device( async fn format_device(
@ -919,6 +986,7 @@ pub(crate) async fn create_interfaces(
proxy: proxy.clone(), proxy: proxy.clone(),
channel: daemon, channel: daemon,
}; };
let screen_reader = ScreenReader0::new(&session).await?;
let wifi_debug = WifiDebug1 { let wifi_debug = WifiDebug1 {
proxy: proxy.clone(), proxy: proxy.clone(),
}; };
@ -971,6 +1039,8 @@ pub(crate) async fn create_interfaces(
object_server.at(MANAGER_PATH, manager2).await?; object_server.at(MANAGER_PATH, manager2).await?;
object_server.at(MANAGER_PATH, screen_reader).await?;
if steam_deck_variant().await.unwrap_or_default() == SteamDeckVariant::Galileo { if steam_deck_variant().await.unwrap_or_default() == SteamDeckVariant::Galileo {
object_server.at(MANAGER_PATH, wifi_debug).await?; object_server.at(MANAGER_PATH, wifi_debug).await?;
} }

View file

@ -25,6 +25,7 @@ mod hdmi_cec1;
mod low_power_mode1; mod low_power_mode1;
mod manager2; mod manager2;
mod performance_profile1; mod performance_profile1;
mod screenreader0;
mod storage1; mod storage1;
mod tdp_limit1; mod tdp_limit1;
mod update_bios1; mod update_bios1;
@ -43,6 +44,7 @@ pub use crate::proxy::hdmi_cec1::HdmiCec1Proxy;
pub use crate::proxy::low_power_mode1::LowPowerMode1Proxy; pub use crate::proxy::low_power_mode1::LowPowerMode1Proxy;
pub use crate::proxy::manager2::Manager2Proxy; pub use crate::proxy::manager2::Manager2Proxy;
pub use crate::proxy::performance_profile1::PerformanceProfile1Proxy; pub use crate::proxy::performance_profile1::PerformanceProfile1Proxy;
pub use crate::proxy::screenreader0::ScreenReader0Proxy;
pub use crate::proxy::storage1::Storage1Proxy; pub use crate::proxy::storage1::Storage1Proxy;
pub use crate::proxy::tdp_limit1::TdpLimit1Proxy; pub use crate::proxy::tdp_limit1::TdpLimit1Proxy;
pub use crate::proxy::update_bios1::UpdateBios1Proxy; pub use crate::proxy::update_bios1::UpdateBios1Proxy;

View file

@ -0,0 +1,45 @@
//! # D-Bus interface proxy for: `com.steampowered.SteamOSManager1.ScreenReader0`
//!
//! 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.ScreenReader0",
default_service = "com.steampowered.SteamOSManager1",
default_path = "/com/steampowered/SteamOSManager1",
assume_defaults = true
)]
pub trait ScreenReader0 {
/// Enabled property
#[zbus(property)]
fn enabled(&self) -> zbus::Result<bool>;
#[zbus(property)]
fn set_enabled(&self, value: bool) -> zbus::Result<()>;
/// Pitch property
#[zbus(property)]
fn pitch(&self) -> zbus::Result<f64>;
#[zbus(property)]
fn set_pitch(&self, value: f64) -> zbus::Result<()>;
/// Rate property
#[zbus(property)]
fn rate(&self) -> zbus::Result<f64>;
#[zbus(property)]
fn set_rate(&self, value: f64) -> zbus::Result<()>;
/// Volume property
#[zbus(property)]
fn volume(&self) -> zbus::Result<f64>;
#[zbus(property)]
fn set_volume(&self, value: f64) -> zbus::Result<()>;
}

403
src/screenreader.rs Normal file
View file

@ -0,0 +1,403 @@
/*
* Copyright © 2025 Collabora Ltd.
* Copyright © 2025 Valve Software
*
* SPDX-License-Identifier: MIT
*/
use anyhow::{anyhow, bail, ensure, Result};
use gio::{prelude::SettingsExt, Settings};
use lazy_static::lazy_static;
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::ops::RangeInclusive;
use std::path::PathBuf;
use tokio::fs::{read_to_string, write};
use tracing::{debug, error, info, trace, warn};
#[cfg(not(test))]
use xdg::BaseDirectories;
use zbus::Connection;
#[cfg(test)]
use crate::path;
use crate::systemd::SystemdUnit;
#[cfg(test)]
const TEST_ORCA_SETTINGS: &str = "data/test-orca-settings.conf";
#[cfg(test)]
const ORCA_SETTINGS: &str = "orca-settings.conf";
#[cfg(not(test))]
const ORCA_SETTINGS: &str = "orca/user-settings.conf";
const PITCH_SETTING: &str = "average-pitch";
const RATE_SETTING: &str = "rate";
const VOLUME_SETTING: &str = "gain";
const ENABLE_SETTING: &str = "enableSpeech";
const A11Y_SETTING: &str = "org.gnome.desktop.a11y.applications";
const SCREEN_READER_SETTING: &str = "screen-reader-enabled";
const PITCH_DEFAULT: f64 = 5.0;
const RATE_DEFAULT: f64 = 50.0;
const VOLUME_DEFAULT: f64 = 10.0;
lazy_static! {
static ref VALID_SETTINGS: HashMap<&'static str, RangeInclusive<f64>> = HashMap::from_iter([
(PITCH_SETTING, (0.0..=10.0)),
(RATE_SETTING, (0.0..=100.0)),
(VOLUME_SETTING, (0.0..=10.0)),
]);
}
pub(crate) struct OrcaManager<'dbus> {
orca_unit: SystemdUnit<'dbus>,
rate: f64,
pitch: f64,
volume: f64,
enabled: bool,
}
impl<'dbus> OrcaManager<'dbus> {
pub async fn new(connection: &Connection) -> Result<OrcaManager<'dbus>> {
let mut manager = OrcaManager {
orca_unit: SystemdUnit::new(connection.clone(), "orca.service").await?,
rate: RATE_DEFAULT,
pitch: PITCH_DEFAULT,
volume: VOLUME_DEFAULT,
enabled: true,
};
let _ = manager
.load_values()
.await
.inspect_err(|e| warn!("Failed to load orca configuration: {e}"));
let a11ysettings = Settings::new(A11Y_SETTING);
manager.enabled = a11ysettings.boolean(SCREEN_READER_SETTING);
Ok(manager)
}
#[cfg(not(test))]
fn settings_path(&self) -> Result<PathBuf> {
let xdg_base = BaseDirectories::new();
Ok(xdg_base
.get_data_home()
.ok_or(anyhow!("No XDG_DATA_HOME found"))?
.join(ORCA_SETTINGS))
}
#[cfg(test)]
fn settings_path(&self) -> Result<PathBuf> {
Ok(path(ORCA_SETTINGS))
}
pub fn enabled(&self) -> bool {
self.enabled
}
pub async fn set_enabled(&mut self, enable: bool) -> Result<()> {
if self.enabled == enable {
return Ok(());
}
#[cfg(not(test))]
{
let a11ysettings = Settings::new(A11Y_SETTING);
a11ysettings
.set_boolean(SCREEN_READER_SETTING, enable)
.map_err(|e| anyhow!("Unable to set screen reader enabled gsetting, {e}"))?;
}
self.set_orca_enabled(enable).await?;
if enable {
self.restart_orca().await?;
} else {
self.stop_orca().await?;
}
self.enabled = enable;
Ok(())
}
pub fn pitch(&self) -> f64 {
self.pitch
}
pub async fn set_pitch(&mut self, pitch: f64) -> Result<()> {
trace!("set_pitch called with {pitch}");
self.set_orca_option(PITCH_SETTING, pitch).await?;
self.pitch = pitch;
Ok(())
}
pub fn rate(&self) -> f64 {
self.rate
}
pub async fn set_rate(&mut self, rate: f64) -> Result<()> {
trace!("set_rate called with {rate}");
self.set_orca_option(RATE_SETTING, rate).await?;
self.rate = rate;
Ok(())
}
pub fn volume(&self) -> f64 {
self.volume
}
pub async fn set_volume(&mut self, volume: f64) -> Result<()> {
trace!("set_volume called with {volume}");
self.set_orca_option(VOLUME_SETTING, volume).await?;
self.volume = volume;
Ok(())
}
async fn set_orca_enabled(&mut self, enabled: bool) -> Result<()> {
// Change json file
let data = read_to_string(self.settings_path()?).await?;
let mut json: Value = serde_json::from_str(&data)?;
let general = json
.as_object_mut()
.ok_or(anyhow!("orca user-settings.conf json is not an object"))?
.entry("general")
.or_insert(Value::Object(Map::new()));
general
.as_object_mut()
.ok_or(anyhow!("orca user-settings.conf general is not an object"))?
.insert(ENABLE_SETTING.to_string(), Value::Bool(enabled));
let data = serde_json::to_string_pretty(&json)?;
Ok(write(self.settings_path()?, data.as_bytes()).await?)
}
async fn load_values(&mut self) -> Result<()> {
debug!("Loading orca values from user-settings.conf");
let data = read_to_string(self.settings_path()?).await?;
let json: Value = serde_json::from_str(&data)?;
let Some(default_voice) = json
.get("profiles")
.and_then(|profiles| profiles.get("default"))
.and_then(|default_profile| default_profile.get("voices"))
.and_then(|voices| voices.get("default"))
else {
warn!("Orca user-settings.conf missing default voice");
self.pitch = PITCH_DEFAULT;
self.rate = RATE_DEFAULT;
self.volume = VOLUME_DEFAULT;
return Ok(());
};
if let Some(pitch) = default_voice.get(PITCH_SETTING) {
self.pitch = pitch.as_f64().unwrap_or_else(|| {
error!("Unable to convert orca pitch setting to float value");
PITCH_DEFAULT
});
} else {
warn!("Unable to load default pitch from orca user-settings.conf");
self.pitch = PITCH_DEFAULT;
}
if let Some(rate) = default_voice.get(RATE_SETTING) {
self.rate = rate.as_f64().unwrap_or_else(|| {
error!("Unable to convert orca rate setting to float value");
RATE_DEFAULT
});
} else {
warn!("Unable to load default voice rate from orca user-settings.conf");
}
if let Some(volume) = default_voice.get(VOLUME_SETTING) {
self.volume = volume.as_f64().unwrap_or_else(|| {
error!("Unable to convert orca volume value to float value");
VOLUME_DEFAULT
});
} else {
warn!("Unable to load default voice volume from orca user-settings.conf");
}
info!(
"Done loading orca user-settings.conf, values: Rate: {}, Pitch: {}, Volume: {}",
self.rate, self.pitch, self.volume
);
Ok(())
}
async fn set_orca_option(&self, option: &str, value: f64) -> Result<()> {
if let Some(range) = VALID_SETTINGS.get(option) {
ensure!(
range.contains(&value),
"orca option {option} value {value} out of range"
);
} else {
bail!("Invalid orca option {option}");
}
let data = read_to_string(self.settings_path()?).await?;
let mut json: Value = serde_json::from_str(&data)?;
let profiles = json
.as_object_mut()
.ok_or(anyhow!("orca user-settings.conf json is not an object"))?
.entry("profiles")
.or_insert(Value::Object(Map::new()));
let default_profile = profiles
.as_object_mut()
.ok_or(anyhow!("orca user-settings.conf profiles is not an object"))?
.entry("default")
.or_insert(Value::Object(Map::new()));
let voices = default_profile
.as_object_mut()
.ok_or(anyhow!(
"orca user-settings.conf default profile is not an object"
))?
.entry("voices")
.or_insert(Value::Object(Map::new()));
let default_voice = voices
.as_object_mut()
.ok_or(anyhow!("orca user-settings.conf voices is not an object"))?
.entry("default")
.or_insert(Value::Object(Map::new()));
default_voice
.as_object_mut()
.ok_or(anyhow!(
"orca user-settings.conf default voice is not an object"
))?
.insert(option.to_string(), value.into());
let data = serde_json::to_string_pretty(&json)?;
Ok(write(self.settings_path()?, data.as_bytes()).await?)
}
#[cfg(not(test))]
async fn restart_orca(&self) -> Result<()> {
trace!("Restarting orca...");
self.orca_unit.restart().await
}
#[cfg(test)]
async fn restart_orca(&self) -> Result<()> {
Ok(())
}
#[cfg(not(test))]
async fn stop_orca(&self) -> Result<()> {
trace!("Stopping orca...");
self.orca_unit.stop().await
}
#[cfg(test)]
async fn stop_orca(&self) -> Result<()> {
Ok(())
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::testing;
use tokio::fs::{copy, remove_file};
#[tokio::test]
async fn test_enable_disable() {
let mut h = testing::start();
copy(TEST_ORCA_SETTINGS, h.test.path().join(ORCA_SETTINGS))
.await
.unwrap();
let mut manager = OrcaManager::new(&h.new_dbus().await.expect("new_dbus"))
.await
.expect("OrcaManager::new");
let enable_result = manager.set_enabled(true).await;
assert!(enable_result.is_ok());
assert_eq!(manager.enabled(), true);
let disable_result = manager.set_enabled(false).await;
assert!(disable_result.is_ok());
assert_eq!(manager.enabled(), false);
}
#[tokio::test]
async fn test_pitch() {
let mut h = testing::start();
copy(TEST_ORCA_SETTINGS, h.test.path().join(ORCA_SETTINGS))
.await
.unwrap();
let mut manager = OrcaManager::new(&h.new_dbus().await.expect("new_dbus"))
.await
.expect("OrcaManager::new");
let set_result = manager.set_pitch(5.0).await;
assert!(set_result.is_ok());
assert_eq!(manager.pitch(), 5.0);
let too_low_result = manager.set_pitch(-1.0).await;
assert!(too_low_result.is_err());
assert_eq!(manager.pitch(), 5.0);
let too_high_result = manager.set_pitch(12.0).await;
assert!(too_high_result.is_err());
assert_eq!(manager.pitch(), 5.0);
remove_file(h.test.path().join(ORCA_SETTINGS))
.await
.unwrap();
let nofile_result = manager.set_pitch(7.0).await;
assert_eq!(manager.pitch(), 5.0);
assert!(nofile_result.is_err());
}
#[tokio::test]
async fn test_rate() {
let mut h = testing::start();
copy(TEST_ORCA_SETTINGS, h.test.path().join(ORCA_SETTINGS))
.await
.unwrap();
let mut manager = OrcaManager::new(&h.new_dbus().await.expect("new_dbus"))
.await
.expect("OrcaManager::new");
let set_result = manager.set_rate(5.0).await;
assert!(set_result.is_ok());
assert_eq!(manager.rate(), 5.0);
let too_low_result = manager.set_rate(-1.0).await;
assert!(too_low_result.is_err());
assert_eq!(manager.rate(), 5.0);
let too_high_result = manager.set_rate(101.0).await;
assert!(too_high_result.is_err());
assert_eq!(manager.rate(), 5.0);
remove_file(h.test.path().join(ORCA_SETTINGS))
.await
.unwrap();
let nofile_result = manager.set_rate(7.0).await;
assert_eq!(manager.rate(), 5.0);
assert!(nofile_result.is_err());
}
#[tokio::test]
async fn test_volume() {
let mut h = testing::start();
copy(TEST_ORCA_SETTINGS, h.test.path().join(ORCA_SETTINGS))
.await
.unwrap();
let mut manager = OrcaManager::new(&h.new_dbus().await.expect("new_dbus"))
.await
.expect("OrcaManager::new");
let set_result = manager.set_volume(5.0).await;
assert!(set_result.is_ok());
assert_eq!(manager.volume(), 5.0);
let too_low_result = manager.set_volume(-1.0).await;
assert!(too_low_result.is_err());
assert_eq!(manager.volume(), 5.0);
let too_high_result = manager.set_volume(12.0).await;
assert!(too_high_result.is_err());
assert_eq!(manager.volume(), 5.0);
remove_file(h.test.path().join(ORCA_SETTINGS))
.await
.unwrap();
let nofile_result = manager.set_volume(7.0).await;
assert_eq!(manager.volume(), 5.0);
assert!(nofile_result.is_err());
}
}