Watch
1
0
Fork
You've already forked souveraine
0

usb: build the full KVM composite surface

This commit is contained in:
Fimeg 2026-08-07 14:48:07 -04:00
commit 8cd2adb828
15 changed files with 1562 additions and 47 deletions

View file

@ -53,6 +53,8 @@ jobs:
# old primary/public split: a signal that reads like coverage and isn't. # old primary/public split: a signal that reads like coverage and isn't.
- name: cargo test (sessiond) - name: cargo test (sessiond)
run: cargo test --features sessiond --bin souveraine-sessiond run: cargo test --features sessiond --bin souveraine-sessiond
- name: cargo test (USB KVM receiver)
run: cargo test --features usb-kvm --bin souveraine-usb-kvm
- name: cargo clippy - name: cargo clippy
run: cargo clippy -- -D warnings run: cargo clippy -- -D warnings
# Advisory, deliberately. Turning this on for the first time surfaced 9 # Advisory, deliberately. Turning this on for the first time surfaced 9
@ -64,6 +66,8 @@ jobs:
- name: cargo clippy (sessiond) - name: cargo clippy (sessiond)
continue-on-error: true continue-on-error: true
run: cargo clippy --features sessiond --bin souveraine-sessiond -- -D warnings run: cargo clippy --features sessiond --bin souveraine-sessiond -- -D warnings
- name: cargo clippy (USB KVM receiver)
run: cargo clippy --features usb-kvm --bin souveraine-usb-kvm -- -D warnings
no-ai-attribution: no-ai-attribution:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -157,6 +161,12 @@ jobs:
file "$NBIN" | grep -q aarch64 || { echo "sensord is not aarch64" >&2; exit 1; } file "$NBIN" | grep -q aarch64 || { echo "sensord is not aarch64" >&2; exit 1; }
cp "$NBIN" souveraine-sensord-aarch64 cp "$NBIN" souveraine-sensord-aarch64
# Full USB KVM receiver: GUD display-in and the HID return surface.
./scripts/build-cross.sh --features usb-kvm --bin souveraine-usb-kvm
UBIN="$CARGO_TARGET_DIR/aarch64-unknown-linux-gnu/release/souveraine-usb-kvm"
file "$UBIN" | grep -q aarch64 || { echo "usb-kvm is not aarch64" >&2; exit 1; }
cp "$UBIN" souveraine-usb-kvm-aarch64
# The wry host: self-hosted sites as apps, and the rig she wears. # The wry host: self-hosted sites as apps, and the rig she wears.
# Needs webkit2gtk on the build sysroot; it is already installed on # Needs webkit2gtk on the build sysroot; it is already installed on
# the phone (TASK-59 phase 1 measured against it). # the phone (TASK-59 phase 1 measured against it).
@ -218,6 +228,7 @@ jobs:
sha256sum souveraine-aarch64 souveraine-x86_64 \ sha256sum souveraine-aarch64 souveraine-x86_64 \
souveraine-sessiond-aarch64 souveraine-sensord-aarch64 \ souveraine-sessiond-aarch64 souveraine-sensord-aarch64 \
souveraine-usb-kvm-aarch64 \
souveraine-secrets-aarch64 souveraine-secrets-x86_64 \ souveraine-secrets-aarch64 souveraine-secrets-x86_64 \
souveraine-machined-aarch64 souveraine-machined-x86_64 \ souveraine-machined-aarch64 souveraine-machined-x86_64 \
souveraine-sessiond-x86_64 \ souveraine-sessiond-x86_64 \
@ -375,6 +386,7 @@ jobs:
cp packaging/souveraine-sessiond.service "$PKG_WORK/" cp packaging/souveraine-sessiond.service "$PKG_WORK/"
cp "souveraine-sensord-$ARCH" "$PKG_WORK/souveraine-sensord-binary" cp "souveraine-sensord-$ARCH" "$PKG_WORK/souveraine-sensord-binary"
cp packaging/souveraine-sensord.service "$PKG_WORK/" cp packaging/souveraine-sensord.service "$PKG_WORK/"
cp "souveraine-usb-kvm-$ARCH" "$PKG_WORK/souveraine-usb-kvm-binary"
# The wry host. Guarded, and the PKGBUILD source line is removed # The wry host. Guarded, and the PKGBUILD source line is removed
# when it is absent: it needs webkit2gtk in the aarch64 sysroot, # when it is absent: it needs webkit2gtk in the aarch64 sysroot,
# and a missing avatar must not stop sessiond reaching the phone. # and a missing avatar must not stop sessiond reaching the phone.
@ -398,6 +410,7 @@ jobs:
bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine' bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine'
if [ "$ARCH" = aarch64 ]; then if [ "$ARCH" = aarch64 ]; then
bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine-sessiond' bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine-sessiond'
bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine-usb-kvm'
fi fi
cp "$PKG" "$ARCH_REPO/" cp "$PKG" "$ARCH_REPO/"
gpg --batch --yes --local-user "$ARCHIVE_KEY" \ gpg --batch --yes --local-user "$ARCHIVE_KEY" \

187
Cargo.lock generated
View file

@ -2112,7 +2112,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98"
dependencies = [ dependencies = [
"ctor-proc-macro", "ctor-proc-macro",
"dtor", "dtor 0.3.0",
]
[[package]]
name = "ctor"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf"
dependencies = [
"dtor 0.8.1",
] ]
[[package]] [[package]]
@ -2669,6 +2678,46 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "drm"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f"
dependencies = [
"bitflags 2.11.1",
"bytemuck",
"drm-ffi",
"drm-fourcc",
"libc",
"rustix 0.38.44",
]
[[package]]
name = "drm-ffi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690"
dependencies = [
"drm-sys",
"rustix 1.1.4",
]
[[package]]
name = "drm-fourcc"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4"
[[package]]
name = "drm-sys"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8"
dependencies = [
"libc",
"linux-raw-sys 0.9.4",
]
[[package]] [[package]]
name = "dtoa" name = "dtoa"
version = "1.0.11" version = "1.0.11"
@ -2693,6 +2742,12 @@ dependencies = [
"dtor-proc-macro", "dtor-proc-macro",
] ]
[[package]]
name = "dtor"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b"
[[package]] [[package]]
name = "dtor-proc-macro" name = "dtor-proc-macro"
version = "0.0.6" version = "0.0.6"
@ -2766,6 +2821,12 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encode_unicode"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@ -3404,6 +3465,27 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "gadgetry-most-foul"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "706c81a929c7009eaa6427d32b3af28ce5dd2133abafe63ff79f9c8fd79e849d"
dependencies = [
"async-io",
"bitflags 2.11.1",
"byteorder",
"futures",
"futures-util",
"libc",
"log",
"macaddr",
"proc-mounts",
"rustix 1.1.4",
"slab",
"strum 0.28.0",
"uuid",
]
[[package]] [[package]]
name = "gdk" name = "gdk"
version = "0.18.2" version = "0.18.2"
@ -3897,6 +3979,21 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "gud-gadget"
version = "0.1.0"
source = "git+https://github.com/samcday/gud-gadget?rev=33024b6152633eb8359375419281fa3ebac9c9cb#33024b6152633eb8359375419281fa3ebac9c9cb"
dependencies = [
"anyhow",
"bytes",
"gadgetry-most-foul",
"lz4",
"serde",
"ssmarshal",
"thiserror 1.0.69",
"tracing",
]
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.14" version = "0.4.14"
@ -5140,6 +5237,12 @@ version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "linux-raw-sys"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
@ -5210,6 +5313,25 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lz4"
version = "1.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4"
dependencies = [
"lz4-sys",
]
[[package]]
name = "lz4-sys"
version = "1.11.1+lz4-1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6"
dependencies = [
"cc",
"libc",
]
[[package]] [[package]]
name = "lz4_flex" name = "lz4_flex"
version = "0.13.1" version = "0.13.1"
@ -5226,6 +5348,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "macaddr"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8"
[[package]] [[package]]
name = "mach2" name = "mach2"
version = "0.4.3" version = "0.4.3"
@ -6696,6 +6824,15 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "partition-identity"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b25ada56da40bd4266a924109037ec9a9c59306e002dd2c30b00c0c3d648b93"
dependencies = [
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "password-hash" name = "password-hash"
version = "0.5.0" version = "0.5.0"
@ -7201,6 +7338,15 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "proc-mounts"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d652f8435d0ab70bf4f3590a6a851d59604831a458086541b95238cc51ffcf2"
dependencies = [
"partition-identity",
]
[[package]] [[package]]
name = "profiling" name = "profiling"
version = "1.0.18" version = "1.0.18"
@ -9105,8 +9251,12 @@ version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
dependencies = [ dependencies = [
"as-raw-xcb-connection",
"bytemuck", "bytemuck",
"drm",
"fastrand",
"js-sys", "js-sys",
"memmap2",
"ndk 0.9.0", "ndk 0.9.0",
"objc2 0.6.4", "objc2 0.6.4",
"objc2-core-foundation", "objc2-core-foundation",
@ -9115,10 +9265,16 @@ dependencies = [
"objc2-quartz-core 0.3.2", "objc2-quartz-core 0.3.2",
"raw-window-handle", "raw-window-handle",
"redox_syscall 0.5.18", "redox_syscall 0.5.18",
"rustix 1.1.4",
"tiny-xlib",
"tracing", "tracing",
"wasm-bindgen", "wasm-bindgen",
"wayland-backend",
"wayland-client",
"wayland-sys",
"web-sys", "web-sys",
"windows-sys 0.61.2", "windows-sys 0.61.2",
"x11rb",
] ]
[[package]] [[package]]
@ -9177,9 +9333,11 @@ dependencies = [
"ed25519-dalek", "ed25519-dalek",
"figlet-rs", "figlet-rs",
"futures", "futures",
"gadgetry-most-foul",
"git2", "git2",
"glob", "glob",
"gtk", "gtk",
"gud-gadget",
"hex", "hex",
"hkdf", "hkdf",
"hound", "hound",
@ -9206,6 +9364,7 @@ dependencies = [
"serde_yaml", "serde_yaml",
"sha2", "sha2",
"shellexpand", "shellexpand",
"softbuffer",
"sqlx", "sqlx",
"sysinfo", "sysinfo",
"tao 0.36.0", "tao 0.36.0",
@ -9232,6 +9391,7 @@ dependencies = [
"walkdir", "walkdir",
"wayland-client", "wayland-client",
"wayland-protocols", "wayland-protocols",
"winit",
"wry 0.56.0", "wry 0.56.0",
"zbus", "zbus",
"zvariant", "zvariant",
@ -9476,6 +9636,16 @@ dependencies = [
"urlencoding", "urlencoding",
] ]
[[package]]
name = "ssmarshal"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3e6ad23b128192ed337dfa4f1b8099ced0c2bf30d61e551b65fda5916dbb850"
dependencies = [
"encode_unicode",
"serde",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@ -10233,7 +10403,7 @@ dependencies = [
"anyhow", "anyhow",
"brotli", "brotli",
"cargo_metadata", "cargo_metadata",
"ctor", "ctor 0.8.0",
"dom_query 0.27.0", "dom_query 0.27.0",
"dunce", "dunce",
"glob", "glob",
@ -10510,6 +10680,19 @@ dependencies = [
"strict-num", "strict-num",
] ]
[[package]]
name = "tiny-xlib"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4"
dependencies = [
"as-raw-xcb-connection",
"ctor 0.10.1",
"libloading 0.8.9",
"pkg-config",
"tracing",
]
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.3" version = "0.8.3"

View file

@ -73,6 +73,13 @@ ratatui-ratty = { version = "0.2", optional = true } # Inline 3D graphics via R
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Full USB KVM receiver. Optional because only the aarch64 phone package ships
# it; the main substrate and desktop builds do not need a windowing stack.
gadgetry-most-foul = { version = "0.1.1", optional = true }
gud-gadget = { git = "https://github.com/samcday/gud-gadget", rev = "33024b6152633eb8359375419281fa3ebac9c9cb", optional = true }
softbuffer = { version = "0.4", optional = true }
winit = { version = "0.30", optional = true }
# Error handling # Error handling
anyhow = "1" anyhow = "1"
thiserror = "1" thiserror = "1"
@ -215,6 +222,11 @@ required-features = ["sessiond"]
name = "souveraine-sensord" name = "souveraine-sensord"
path = "src/bin/souveraine-sensord.rs" path = "src/bin/souveraine-sensord.rs"
[[bin]]
name = "souveraine-usb-kvm"
path = "src/bin/souveraine-usb-kvm.rs"
required-features = ["usb-kvm"]
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
lto = true lto = true
@ -238,10 +250,14 @@ gui = ["tuie/gui"]
secrets = ["dep:zbus", "dep:zvariant", "dep:aes", "dep:cbc", "dep:cipher", "dep:hkdf", "dep:num-bigint", "dep:num-traits", "dep:aes-gcm", "dep:argon2"] secrets = ["dep:zbus", "dep:zvariant", "dep:aes", "dep:cbc", "dep:cipher", "dep:hkdf", "dep:num-bigint", "dep:num-traits", "dep:aes-gcm", "dep:argon2"]
# Session authority daemon — holds ext-session-lock before/under the shell. # Session authority daemon — holds ext-session-lock before/under the shell.
sessiond = ["dep:wayland-client", "dep:wayland-protocols"] sessiond = ["dep:wayland-client", "dep:wayland-protocols"]
usb-kvm = ["dep:gadgetry-most-foul", "dep:gud-gadget", "dep:softbuffer", "dep:winit"]
wry = ["dep:wry"] wry = ["dep:wry"]
tao = ["dep:tao"] tao = ["dep:tao"]
gtk = ["dep:gtk"] gtk = ["dep:gtk"]
[patch."https://github.com/samcday/gadgetry-most-foul.git"]
gadgetry-most-foul = { version = "0.1.1" }
[[bin]] [[bin]]
name = "souveraine-web" name = "souveraine-web"
path = "src/bin/souveraine-web.rs" path = "src/bin/souveraine-web.rs"

View file

@ -24,8 +24,10 @@ sha256sums=('SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP')
# rather than declaring them as sources for every arch. # rather than declaring them as sources for every arch.
if [ "$CARCH" = aarch64 ]; then if [ "$CARCH" = aarch64 ]; then
source+=('souveraine-sessiond-binary' 'souveraine-sessiond.service' source+=('souveraine-sessiond-binary' 'souveraine-sessiond.service'
'souveraine-sensord-binary' 'souveraine-sensord.service') 'souveraine-sensord-binary' 'souveraine-sensord.service'
sha256sums+=('SKIP' 'SKIP' 'SKIP' 'SKIP') 'souveraine-usb-kvm-binary')
sha256sums+=('SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP')
depends+=('wayland' 'libx11' 'libxcursor' 'libxi' 'libxkbcommon' 'libxrandr')
# The wry host links the system webview, so it only cross-builds once # The wry host links the system webview, so it only cross-builds once
# webkit2gtk is in the aarch64 sysroot. Declared only when CI actually # webkit2gtk is in the aarch64 sysroot. Declared only when CI actually
# produced one: makepkg validates every entry in `source`, so naming a file # produced one: makepkg validates every entry in `source`, so naming a file
@ -64,6 +66,10 @@ package() {
install -Dm644 "$srcdir/souveraine-sensord.service" \ install -Dm644 "$srcdir/souveraine-sensord.service" \
"$pkgdir/usr/lib/systemd/user/souveraine-sensord.service" "$pkgdir/usr/lib/systemd/user/souveraine-sensord.service"
fi fi
if [ -f "$srcdir/souveraine-usb-kvm-binary" ]; then
install -Dm755 "$srcdir/souveraine-usb-kvm-binary" \
"$pkgdir/usr/bin/souveraine-usb-kvm"
fi
# Hardware button reporter. Every arch: it decides nothing and depends on # Hardware button reporter. Every arch: it decides nothing and depends on
# nothing, and a laptop with a power button is the same shape. Replaces # nothing, and a laptop with a power button is the same shape. Replaces
# blueline-power-button, which was owned by no package and carried its own # blueline-power-button, which was owned by no package and carried its own

View file

@ -40,8 +40,26 @@ export SOUVERAINE_AARCH64_SYSROOT="$SYSROOT"
export SOUVERAINE_AARCH64_LINKER_SCRIPTS="$LINKER_SCRIPTS" export SOUVERAINE_AARCH64_LINKER_SCRIPTS="$LINKER_SCRIPTS"
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER="$PWD/scripts/aarch64-linker" export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER="$PWD/scripts/aarch64-linker"
BIN_NAME=souveraine
EXPECT_BIN_NAME=false
for arg in "$@"; do
if $EXPECT_BIN_NAME; then
BIN_NAME="$arg"
EXPECT_BIN_NAME=false
continue
fi
case "$arg" in
--bin) EXPECT_BIN_NAME=true ;;
--bin=*) BIN_NAME="${arg#--bin=}" ;;
esac
done
cargo build --release --target aarch64-unknown-linux-gnu "$@" cargo build --release --target aarch64-unknown-linux-gnu "$@"
BIN="$TARGET_DIR/aarch64-unknown-linux-gnu/release/souveraine" BIN="$TARGET_DIR/aarch64-unknown-linux-gnu/release/$BIN_NAME"
[[ -x "$BIN" ]] || {
echo "requested build produced no executable: $BIN" >&2
exit 1
}
echo "== built: $BIN ==" echo "== built: $BIN =="
file "$BIN" file "$BIN"

View file

@ -0,0 +1,631 @@
use std::collections::BTreeSet;
use std::fs::OpenOptions;
use std::io::Write;
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use clap::Parser;
use gadgetry_most_foul::function::custom::{Custom, Interface};
use gadgetry_most_foul::Class;
use gud_gadget::{DisplayMode, Event, GUD_PIXEL_FORMAT_RGB565};
use softbuffer::{Context as SoftContext, Surface};
use tracing::{error, info, warn};
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
use winit::application::ApplicationHandler;
use winit::dpi::{LogicalSize, PhysicalPosition};
use winit::event::{ElementState, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent};
use winit::event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy};
use winit::keyboard::{KeyCode, PhysicalKey};
use winit::window::{Window, WindowAttributes, WindowId};
#[derive(Debug, Parser)]
#[command(about = "Receive a GUD display into a Wayland KVM surface")]
struct Args {
#[arg(long, default_value = "/run/souveraine-usb/ffs/gud")]
ffs_dir: PathBuf,
#[arg(long, default_value = "/run/souveraine-usb/keyboard")]
keyboard: PathBuf,
#[arg(long, default_value = "/run/souveraine-usb/pointer")]
pointer: PathBuf,
#[arg(long, default_value = "/run/souveraine-usb/gud.ready")]
ready_file: PathBuf,
#[arg(long, default_value_t = 1080)]
width: u32,
#[arg(long, default_value_t = 1920)]
height: u32,
#[arg(long, default_value = "USB KVM")]
title: String,
}
#[derive(Debug)]
enum UserEvent {
Frame,
GudStopped(String),
}
struct Frame {
width: u32,
height: u32,
rgb565: Vec<u8>,
xrgb8888: Vec<u32>,
generation: u64,
}
impl Frame {
fn new(width: u32, height: u32) -> Self {
let pixels = width as usize * height as usize;
Self {
width,
height,
rgb565: vec![0; pixels * 2],
xrgb8888: vec![0xff10_1010; pixels],
generation: 0,
}
}
fn convert_rect(&mut self, x: u32, y: u32, width: u32, height: u32) {
let end_x = x.saturating_add(width).min(self.width);
let end_y = y.saturating_add(height).min(self.height);
for row in y..end_y {
for column in x..end_x {
let pixel = row as usize * self.width as usize + column as usize;
let byte = pixel * 2;
let raw = u16::from_le_bytes([self.rgb565[byte], self.rgb565[byte + 1]]);
let r = u32::from((raw >> 11) & 0x1f) * 255 / 31;
let g = u32::from((raw >> 5) & 0x3f) * 255 / 63;
let b = u32::from(raw & 0x1f) * 255 / 31;
self.xrgb8888[pixel] = 0xff00_0000 | (r << 16) | (g << 8) | b;
}
}
self.generation = self.generation.wrapping_add(1);
}
}
enum HidReport {
Keyboard([u8; 8]),
Pointer([u8; 4]),
}
fn spawn_hid_writer(keyboard: PathBuf, pointer: PathBuf) -> mpsc::Sender<HidReport> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for report in rx {
let (path, bytes): (&Path, &[u8]) = match &report {
HidReport::Keyboard(bytes) => (&keyboard, bytes),
HidReport::Pointer(bytes) => (&pointer, bytes),
};
let result = OpenOptions::new()
.write(true)
.open(path)
.and_then(|mut endpoint| endpoint.write_all(bytes));
if let Err(error) = result {
warn!(path = %path.display(), %error, "HID report was not delivered");
}
}
});
tx
}
fn display_mode(width: u32, height: u32) -> DisplayMode {
let hsync_start = width.saturating_add(8);
let hsync_end = hsync_start.saturating_add(8);
let htotal = width.saturating_add(40);
let vsync_start = height.saturating_add(3);
let vsync_end = vsync_start.saturating_add(5);
let vtotal = height.saturating_add(40);
DisplayMode {
clock: htotal.saturating_mul(vtotal).saturating_mul(60) / 1000,
hdisplay: width as u16,
hsync_start: hsync_start as u16,
hsync_end: hsync_end as u16,
htotal: htotal as u16,
vdisplay: height as u16,
vsync_start: vsync_start as u16,
vsync_end: vsync_end as u16,
vtotal: vtotal as u16,
flags: 0,
}
}
fn spawn_gud(
ffs_dir: PathBuf,
ready_file: PathBuf,
frame: Arc<Mutex<Frame>>,
proxy: EventLoopProxy<UserEvent>,
running: Arc<AtomicBool>,
width: u32,
height: u32,
) -> thread::JoinHandle<()> {
thread::spawn(move || {
let result = (|| -> Result<()> {
let (mut pixel_data, pixel_endpoint) = gud_gadget::PixelDataEndpoint::new();
let mut gud = Custom::builder()
.with_interface(
Interface::new(Class::vendor_specific(Class::VENDOR_SPECIFIC, 0), "GUD")
.with_endpoint(pixel_endpoint),
)
.existing(&ffs_dir)
.with_context(|| format!("initializing {}", ffs_dir.display()))?;
if let Some(parent) = ready_file.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(&ready_file, b"ready\n")
.with_context(|| format!("writing {}", ready_file.display()))?;
info!(ffs = %ffs_dir.display(), "GUD FunctionFS responder ready");
let mode = display_mode(width, height);
while running.load(Ordering::Relaxed) {
let Some(event) = gud.event_timeout(Duration::from_millis(100))? else {
continue;
};
let Some(event) = gud_gadget::event(event)? else {
continue;
};
match event {
Event::GetDescriptor(request) => {
request.send_descriptor(width, height, width, height)?;
}
Event::GetPixelFormats(request) => {
request.send_pixel_formats(&[GUD_PIXEL_FORMAT_RGB565])?;
}
Event::GetDisplayModes(request) => request
.send_modes(&[display_mode(mode.hdisplay.into(), mode.vdisplay.into())])?,
Event::Buffer(update) => {
let dirty = (update.x, update.y, update.width, update.height);
let mut frame = frame.lock().unwrap_or_else(|e| e.into_inner());
let pitch = frame.width as usize * 2;
pixel_data.recv_buffer(update, &mut frame.rgb565, pitch, 2)?;
frame.convert_rect(dirty.0, dirty.1, dirty.2, dirty.3);
let _ = proxy.send_event(UserEvent::Frame);
}
}
}
Ok(())
})();
let _ = std::fs::remove_file(&ready_file);
if let Err(error) = result {
error!(%error, "GUD responder stopped");
let _ = proxy.send_event(UserEvent::GudStopped(error.to_string()));
}
})
}
struct App {
title: String,
frame: Arc<Mutex<Frame>>,
window: Option<Arc<Window>>,
context: Option<SoftContext<Arc<Window>>>,
surface: Option<Surface<Arc<Window>, Arc<Window>>>,
rendered_generation: u64,
rendered_size: Option<(u32, u32)>,
hid: mpsc::Sender<HidReport>,
keys: BTreeSet<u8>,
modifiers: u8,
pointer_buttons: u8,
cursor: Option<PhysicalPosition<f64>>,
touch: Option<TouchTrack>,
running: Arc<AtomicBool>,
}
struct TouchTrack {
id: u64,
last: PhysicalPosition<f64>,
travel: f64,
}
impl App {
fn keyboard_report(&self) {
let mut report = [0u8; 8];
report[0] = self.modifiers;
for (slot, key) in self.keys.iter().take(6).enumerate() {
report[slot + 2] = *key;
}
let _ = self.hid.send(HidReport::Keyboard(report));
}
fn pointer_report(&self, x: i32, y: i32, wheel: i32) {
let mut x = x;
let mut y = y;
let mut wheel = wheel;
loop {
let dx = x.clamp(-127, 127) as i8;
let dy = y.clamp(-127, 127) as i8;
let dw = wheel.clamp(-127, 127) as i8;
let _ = self.hid.send(HidReport::Pointer([
self.pointer_buttons,
dx as u8,
dy as u8,
dw as u8,
]));
x -= i32::from(dx);
y -= i32::from(dy);
wheel -= i32::from(dw);
if x == 0 && y == 0 && wheel == 0 {
break;
}
}
}
fn set_mouse_button(&mut self, button: MouseButton, pressed: bool) {
let mask = match button {
MouseButton::Left => 1,
MouseButton::Right => 2,
MouseButton::Middle => 4,
_ => return,
};
if pressed {
self.pointer_buttons |= mask;
} else {
self.pointer_buttons &= !mask;
}
self.pointer_report(0, 0, 0);
}
fn draw(&mut self) -> Result<()> {
let Some(window) = &self.window else {
return Ok(());
};
let Some(surface) = &mut self.surface else {
return Ok(());
};
let size = window.inner_size();
let width = NonZeroU32::new(size.width.max(1)).unwrap();
let height = NonZeroU32::new(size.height.max(1)).unwrap();
surface
.resize(width, height)
.map_err(|error| anyhow!(error.to_string()))?;
let frame = self.frame.lock().unwrap_or_else(|e| e.into_inner());
let size_key = (size.width, size.height);
if frame.generation == self.rendered_generation && self.rendered_size == Some(size_key) {
return Ok(());
}
let mut buffer = surface
.buffer_mut()
.map_err(|error| anyhow!(error.to_string()))?;
for y in 0..size.height {
let source_y = y as usize * frame.height as usize / size.height.max(1) as usize;
for x in 0..size.width {
let source_x = x as usize * frame.width as usize / size.width.max(1) as usize;
buffer[y as usize * size.width as usize + x as usize] =
frame.xrgb8888[source_y * frame.width as usize + source_x];
}
}
self.rendered_generation = frame.generation;
self.rendered_size = Some(size_key);
buffer
.present()
.map_err(|error| anyhow!(error.to_string()))?;
Ok(())
}
}
impl ApplicationHandler<UserEvent> for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() {
return;
}
let frame = self.frame.lock().unwrap_or_else(|e| e.into_inner());
let attributes = WindowAttributes::default()
.with_title(&self.title)
.with_inner_size(LogicalSize::new(frame.width / 2, frame.height / 2));
drop(frame);
match event_loop.create_window(attributes) {
Ok(window) => {
let window = Arc::new(window);
match SoftContext::new(window.clone()).and_then(|context| {
Surface::new(&context, window.clone()).map(|surface| (context, surface))
}) {
Ok((context, surface)) => {
self.context = Some(context);
self.surface = Some(surface);
window.request_redraw();
self.window = Some(window);
}
Err(error) => {
error!(%error, "could not create KVM drawing surface");
event_loop.exit();
}
}
}
Err(error) => {
error!(%error, "could not create KVM window");
event_loop.exit();
}
}
}
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
match event {
UserEvent::Frame => {
if let Some(window) = &self.window {
window.request_redraw();
}
}
UserEvent::GudStopped(reason) => {
error!(%reason, "closing KVM surface");
event_loop.exit();
}
}
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::RedrawRequested => {
if let Err(error) = self.draw() {
error!(%error, "KVM redraw failed");
event_loop.exit();
}
}
WindowEvent::Resized(_) => {
if let Some(window) = &self.window {
window.request_redraw();
}
}
WindowEvent::KeyboardInput { event, .. } => {
let PhysicalKey::Code(code) = event.physical_key else {
return;
};
let pressed = event.state == ElementState::Pressed;
if let Some(mask) = modifier_for(code) {
if pressed {
self.modifiers |= mask
} else {
self.modifiers &= !mask
}
} else if let Some(key) = hid_key(code) {
if pressed {
self.keys.insert(key);
} else {
self.keys.remove(&key);
}
}
self.keyboard_report();
}
WindowEvent::CursorMoved { position, .. } => {
if let Some(last) = self.cursor.replace(position) {
self.pointer_report(
(position.x - last.x).round() as i32,
(position.y - last.y).round() as i32,
0,
);
}
}
WindowEvent::CursorLeft { .. } => self.cursor = None,
WindowEvent::MouseInput { state, button, .. } => {
self.set_mouse_button(button, state == ElementState::Pressed);
}
WindowEvent::MouseWheel { delta, .. } => {
let wheel = match delta {
MouseScrollDelta::LineDelta(_, y) => (y * 3.0).round() as i32,
MouseScrollDelta::PixelDelta(position) => (position.y / 24.0).round() as i32,
};
self.pointer_report(0, 0, wheel);
}
WindowEvent::Touch(touch) => match touch.phase {
TouchPhase::Started => {
self.touch = Some(TouchTrack {
id: touch.id,
last: touch.location,
travel: 0.0,
});
}
TouchPhase::Moved => {
let Some(track) = &mut self.touch else { return };
if track.id != touch.id {
return;
}
let dx = touch.location.x - track.last.x;
let dy = touch.location.y - track.last.y;
track.last = touch.location;
track.travel += dx.abs() + dy.abs();
self.pointer_report(dx.round() as i32, dy.round() as i32, 0);
}
TouchPhase::Ended | TouchPhase::Cancelled => {
if self
.touch
.take()
.is_some_and(|track| track.id == touch.id && track.travel < 12.0)
{
self.pointer_buttons |= 1;
self.pointer_report(0, 0, 0);
self.pointer_buttons &= !1;
self.pointer_report(0, 0, 0);
}
}
},
WindowEvent::Focused(false) => {
self.keys.clear();
self.modifiers = 0;
self.pointer_buttons = 0;
self.keyboard_report();
self.pointer_report(0, 0, 0);
}
_ => {}
}
}
fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
self.running.store(false, Ordering::Relaxed);
self.keys.clear();
self.modifiers = 0;
self.pointer_buttons = 0;
self.keyboard_report();
self.pointer_report(0, 0, 0);
}
}
fn modifier_for(code: KeyCode) -> Option<u8> {
Some(match code {
KeyCode::ControlLeft => 0x01,
KeyCode::ShiftLeft => 0x02,
KeyCode::AltLeft => 0x04,
KeyCode::SuperLeft => 0x08,
KeyCode::ControlRight => 0x10,
KeyCode::ShiftRight => 0x20,
KeyCode::AltRight => 0x40,
KeyCode::SuperRight => 0x80,
_ => return None,
})
}
fn hid_key(code: KeyCode) -> Option<u8> {
Some(match code {
KeyCode::KeyA => 0x04,
KeyCode::KeyB => 0x05,
KeyCode::KeyC => 0x06,
KeyCode::KeyD => 0x07,
KeyCode::KeyE => 0x08,
KeyCode::KeyF => 0x09,
KeyCode::KeyG => 0x0a,
KeyCode::KeyH => 0x0b,
KeyCode::KeyI => 0x0c,
KeyCode::KeyJ => 0x0d,
KeyCode::KeyK => 0x0e,
KeyCode::KeyL => 0x0f,
KeyCode::KeyM => 0x10,
KeyCode::KeyN => 0x11,
KeyCode::KeyO => 0x12,
KeyCode::KeyP => 0x13,
KeyCode::KeyQ => 0x14,
KeyCode::KeyR => 0x15,
KeyCode::KeyS => 0x16,
KeyCode::KeyT => 0x17,
KeyCode::KeyU => 0x18,
KeyCode::KeyV => 0x19,
KeyCode::KeyW => 0x1a,
KeyCode::KeyX => 0x1b,
KeyCode::KeyY => 0x1c,
KeyCode::KeyZ => 0x1d,
KeyCode::Digit1 => 0x1e,
KeyCode::Digit2 => 0x1f,
KeyCode::Digit3 => 0x20,
KeyCode::Digit4 => 0x21,
KeyCode::Digit5 => 0x22,
KeyCode::Digit6 => 0x23,
KeyCode::Digit7 => 0x24,
KeyCode::Digit8 => 0x25,
KeyCode::Digit9 => 0x26,
KeyCode::Digit0 => 0x27,
KeyCode::Enter => 0x28,
KeyCode::Escape => 0x29,
KeyCode::Backspace => 0x2a,
KeyCode::Tab => 0x2b,
KeyCode::Space => 0x2c,
KeyCode::Minus => 0x2d,
KeyCode::Equal => 0x2e,
KeyCode::BracketLeft => 0x2f,
KeyCode::BracketRight => 0x30,
KeyCode::Backslash => 0x31,
KeyCode::Semicolon => 0x33,
KeyCode::Quote => 0x34,
KeyCode::Backquote => 0x35,
KeyCode::Comma => 0x36,
KeyCode::Period => 0x37,
KeyCode::Slash => 0x38,
KeyCode::CapsLock => 0x39,
KeyCode::F1 => 0x3a,
KeyCode::F2 => 0x3b,
KeyCode::F3 => 0x3c,
KeyCode::F4 => 0x3d,
KeyCode::F5 => 0x3e,
KeyCode::F6 => 0x3f,
KeyCode::F7 => 0x40,
KeyCode::F8 => 0x41,
KeyCode::F9 => 0x42,
KeyCode::F10 => 0x43,
KeyCode::F11 => 0x44,
KeyCode::F12 => 0x45,
KeyCode::PrintScreen => 0x46,
KeyCode::ScrollLock => 0x47,
KeyCode::Pause => 0x48,
KeyCode::Insert => 0x49,
KeyCode::Home => 0x4a,
KeyCode::PageUp => 0x4b,
KeyCode::Delete => 0x4c,
KeyCode::End => 0x4d,
KeyCode::PageDown => 0x4e,
KeyCode::ArrowRight => 0x4f,
KeyCode::ArrowLeft => 0x50,
KeyCode::ArrowDown => 0x51,
KeyCode::ArrowUp => 0x52,
_ => return None,
})
}
fn main() -> Result<()> {
tracing_subscriber::registry()
.with(fmt::layer())
.with(EnvFilter::from_default_env())
.init();
let args = Args::parse();
anyhow::ensure!(
args.width <= u16::MAX.into() && args.height <= u16::MAX.into(),
"display is too large for GUD mode fields"
);
let event_loop = EventLoop::<UserEvent>::with_user_event().build()?;
let frame = Arc::new(Mutex::new(Frame::new(args.width, args.height)));
let running = Arc::new(AtomicBool::new(true));
let gud = spawn_gud(
args.ffs_dir,
args.ready_file,
frame.clone(),
event_loop.create_proxy(),
running.clone(),
args.width,
args.height,
);
let mut app = App {
title: args.title,
frame,
window: None,
context: None,
surface: None,
rendered_generation: u64::MAX,
rendered_size: None,
hid: spawn_hid_writer(args.keyboard, args.pointer),
keys: BTreeSet::new(),
modifiers: 0,
pointer_buttons: 0,
cursor: None,
touch: None,
running,
};
event_loop.run_app(&mut app)?;
app.running.store(false, Ordering::Relaxed);
let _ = gud.join();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rgb565_dirty_rect_becomes_xrgb8888() {
let mut frame = Frame::new(2, 1);
frame.rgb565 = [0x00, 0xf8, 0xe0, 0x07].to_vec();
frame.convert_rect(0, 0, 2, 1);
assert_eq!(frame.xrgb8888, [0xffff_0000, 0xff00_ff00]);
assert_eq!(frame.generation, 1);
}
#[test]
fn physical_keys_use_usb_hid_usage_ids() {
assert_eq!(hid_key(KeyCode::KeyA), Some(0x04));
assert_eq!(hid_key(KeyCode::Digit0), Some(0x27));
assert_eq!(hid_key(KeyCode::F12), Some(0x45));
assert_eq!(hid_key(KeyCode::Delete), Some(0x4c));
assert_eq!(modifier_for(KeyCode::ControlLeft), Some(0x01));
assert_eq!(modifier_for(KeyCode::AltRight), Some(0x40));
}
}

View file

@ -23,6 +23,7 @@ use tracing::{info, warn};
use crate::sessiond::bearer::{Bearer, BearerEvidence}; use crate::sessiond::bearer::{Bearer, BearerEvidence};
use crate::sessiond::protocol::{ use crate::sessiond::protocol::{
Button, ButtonEdge, ButtonGesture, InputTrigger, SensorSource, SensorValue, TouchGesture, Button, ButtonEdge, ButtonGesture, InputTrigger, SensorSource, SensorValue, TouchGesture,
UsbMode,
}; };
/// How long a locked, lit panel waits for input before it blanks. /// How long a locked, lit panel waits for input before it blanks.
@ -439,6 +440,13 @@ pub enum Action {
/// changes what every other peripheral means. Casey, 2026-08-05: "that /// changes what every other peripheral means. Casey, 2026-08-05: "that
/// whole power usb state probably belongs in the power switch options". /// whole power usb state probably belongs in the power switch options".
PowerMenu, PowerMenu,
/// Change the port's gadget posture through usb-signaller.
///
/// The mode daemon is the mechanism and souveraine-upower is its adjacent
/// power sensor. Neither decides. sessiond emits this action so the mode
/// change is named, audited, and later refusable when a known peer or an
/// active probe lease says the port is occupied.
UsbMode(UsbMode),
/// Request the session lock, because something wants the panel dark and /// Request the session lock, because something wants the panel dark and
/// the session is not locked yet. /// the session is not locked yet.
/// ///
@ -2479,6 +2487,22 @@ impl DeviceStateMachine {
) )
} }
/// Ask for a USB gadget posture.
///
/// The decision is intentionally tiny today: all advertised device-role
/// modes are admissible. The seam matters because attachment identity and
/// probe ownership will make that conditional; when they land, the refusal
/// belongs here rather than in QML or usb-signaller. Every request is
/// written now, so that future rule has a trail to inherit.
pub fn request_usb_mode(&mut self, mode: UsbMode, why: &str) -> Vec<Action> {
self.record_decision(
"usb-mode-request",
serde_json::json!({ "mode": mode.as_str() }),
why,
);
vec![Action::UsbMode(mode)]
}
/// The executor reports the panel's real state. Turning on counts as /// The executor reports the panel's real state. Turning on counts as
/// input, so a dt2w wake starts the blank budget from the wake itself. /// input, so a dt2w wake starts the blank budget from the wake itself.
/// ///

View file

@ -241,6 +241,20 @@ pub const VERBS: &[VerbDoc] = &[
refuses: &[], refuses: &[],
example: r#"{"op":"bearer"}"#, example: r#"{"op":"bearer"}"#,
}, },
VerbDoc {
op: "usb",
mutates: false,
summary: "USB-C role, gadget mode, charger evidence, attachment identity and probe owner",
refuses: &[RefusalCode::Unavailable],
example: r#"{"op":"usb"}"#,
},
VerbDoc {
op: "set_usb_mode",
mutates: true,
summary: "ask the device-state authority to change the USB gadget posture",
refuses: &[RefusalCode::RefusedByState, RefusalCode::Unavailable],
example: r#"{"op":"set_usb_mode","mode":"hid"}"#,
},
]; ];
/// A request plus the caller's declared intent. /// A request plus the caller's declared intent.
@ -381,6 +395,15 @@ pub enum Request {
/// *policy* (`set_policy`), which is the difference between steering the /// *policy* (`set_policy`), which is the difference between steering the
/// machine and reaching around it. /// machine and reaching around it.
Bearer, Bearer,
/// Port proprioception. The mechanism is adjacent to souveraine-upower:
/// usb-signaller reports/acts on the data posture, while UPower reports
/// charging posture. sessiond is the one place the two become a snapshot.
Usb,
/// Ask for a gadget posture. This does not let the shell write configfs;
/// it becomes an Action and is executed through usb-signaller's system
/// D-Bus API. Full KVM is admitted because usb-signaller prepares both
/// FunctionFS responders and rolls the composite back as one transaction.
SetUsbMode { mode: UsbMode },
/// Change the timed policy. Every field is optional; omitted fields keep /// Change the timed policy. Every field is optional; omitted fields keep
/// their current value. This is the seam the Settings control center reads /// their current value. This is the seam the Settings control center reads
/// and writes, so a control there is a view over the owning daemon rather /// and writes, so a control there is a view over the owning daemon rather
@ -412,6 +435,41 @@ pub enum Request {
}, },
} }
/// USB postures sessiond is prepared to own today.
///
/// Keep this narrower than usb-signaller's raw vocabulary. MTP and tethering
/// are not power-menu promises; host mode is unsafe until the SMB2/TCPM lane
/// can source VBUS. Full KVM is here because GUD and smoo now share one
/// readiness-gated, rollback-capable lifecycle in usb-signaller.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UsbMode {
Developer,
Hid,
Kvm,
ChargingOnly,
}
impl UsbMode {
pub fn as_usb_moded(self) -> &'static str {
match self {
Self::Developer => "developer_mode",
Self::Hid => "hid_mode",
Self::Kvm => "kvm_mode",
Self::ChargingOnly => "charging_only",
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Developer => "developer",
Self::Hid => "hid",
Self::Kvm => "kvm",
Self::ChargingOnly => "charging_only",
}
}
}
/// What produced a user input. Recorded so the forensic trail can tell a /// What produced a user input. Recorded so the forensic trail can tell a
/// deliberate power-button press from an accidental pocket touch. /// deliberate power-button press from an accidental pocket touch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -611,12 +669,10 @@ mod tests {
// And the other direction: every variant must be advertised. Bump this // And the other direction: every variant must be advertised. Bump this
// deliberately when a verb is added, having added its VerbDoc. // deliberately when a verb is added, having added its VerbDoc.
// 17 since `gesture` (2026-08-03): the compositor names what the // 19 since USB posture became a read plus a request (2026-08-07).
// fingers did and the machine decides what it means, so the naming had
// to become a verb rather than the compositor calling a tool itself.
assert_eq!( assert_eq!(
VERBS.len(), VERBS.len(),
17, 19,
"a Request variant was added without a VerbDoc" "a Request variant was added without a VerbDoc"
); );
} }
@ -652,4 +708,11 @@ mod tests {
r#""awaiting_shell_lock""# r#""awaiting_shell_lock""#
); );
} }
#[test]
fn full_kvm_is_a_named_usb_posture() {
let req: Request = serde_json::from_str(r#"{"op":"set_usb_mode","mode":"kvm"}"#).unwrap();
assert!(matches!(req, Request::SetUsbMode { mode: UsbMode::Kvm }));
assert_eq!(UsbMode::Kvm.as_usb_moded(), "kvm_mode");
}
} }

View file

@ -25,7 +25,7 @@ use crate::sessiond::idle;
use crate::sessiond::lock::{self, LockController, Msg, SessionOutcome}; use crate::sessiond::lock::{self, LockController, Msg, SessionOutcome};
use crate::sessiond::lockhint; use crate::sessiond::lockhint;
use crate::sessiond::protocol::{ use crate::sessiond::protocol::{
Envelope, InputTrigger, Phase, RefusalCode, Request, SensorSource, SensorValue, Envelope, InputTrigger, Phase, RefusalCode, Request, SensorSource, SensorValue, UsbMode,
LOCKED_ACK_TIMEOUT_SECS, MAX_REQUEST_BYTES, VERBS, LOCKED_ACK_TIMEOUT_SECS, MAX_REQUEST_BYTES, VERBS,
}; };
@ -283,6 +283,17 @@ fn execute(shared: &Arc<Shared>, action: Action) {
return; return;
} }
if let Action::UsbMode(mode) = action {
if let Err(error) = set_usb_mode(mode) {
warn!("USB mode {} failed: {error:#}", mode.as_str());
shared
.lock()
.device_state
.record_error("device-state", "usb-mode", &error.to_string());
}
return;
}
// Dim and restore carry a number, so they are not table entries. The // Dim and restore carry a number, so they are not table entries. The
// machine remembers the exact brightness the panel was at; the executor // machine remembers the exact brightness the panel was at; the executor
// reads the hardware and sets it back. Neither `brightnessctl -s/-r` nor // reads the hardware and sets it back. Neither `brightnessctl -s/-r` nor
@ -404,6 +415,7 @@ fn execute(shared: &Arc<Shared>, action: Action) {
vec!["-c", "souveraine", "ipc", "call", "powerMenu", "open"], vec!["-c", "souveraine", "ipc", "call", "powerMenu", "open"],
"power-menu-open", "power-menu-open",
), ),
Action::UsbMode(_) => unreachable!("handled above; expressed through usb-signaller"),
Action::Blank => { Action::Blank => {
// The invariant, enforced where it cannot be reasoned around: the // The invariant, enforced where it cannot be reasoned around: the
// panel does not go dark unless logind says this session is // panel does not go dark unless logind says this session is
@ -435,6 +447,94 @@ fn execute(shared: &Arc<Shared>, action: Action) {
run_executor(shared, program, &args, label, action); run_executor(shared, program, &args, label, action);
} }
const USB_MODED_DESTINATION: &str = "com.meego.usb_moded";
const USB_MODED_PATH: &str = "/com/meego/usb_moded";
const USB_MODED_INTERFACE: &str = "com.meego.usb_moded";
fn usb_moded_call(member: &str, argument: Option<&str>) -> Result<String> {
let mut command = std::process::Command::new("busctl");
command.args([
"--system",
"call",
USB_MODED_DESTINATION,
USB_MODED_PATH,
USB_MODED_INTERFACE,
member,
]);
if let Some(argument) = argument {
command.args(["s", argument]);
}
let output = command
.output()
.with_context(|| format!("calling usb-signaller {member}"))?;
if !output.status.success() {
anyhow::bail!(
"usb-signaller {member}: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let stdout = String::from_utf8(output.stdout).context("usb-signaller returned non-UTF8")?;
let start = stdout
.find('"')
.context("usb-signaller reply had no string")?
+ 1;
let end = stdout[start..]
.find('"')
.map(|offset| start + offset)
.context("usb-signaller reply had an unterminated string")?;
Ok(stdout[start..end].to_owned())
}
fn read_usb_role() -> Option<String> {
let entries = std::fs::read_dir("/sys/class/usb_role").ok()?;
for entry in entries.flatten() {
if let Ok(role) = std::fs::read_to_string(entry.path().join("role")) {
return Some(role.trim().to_owned());
}
}
None
}
fn usb_snapshot() -> Result<serde_json::Value> {
let raw_mode = usb_moded_call("mode_request", None)?;
let available = usb_moded_call("get_modes", None)?
.split(',')
.map(str::to_owned)
.collect::<Vec<_>>();
let charger_online = std::fs::read_to_string("/sys/class/power_supply/pmi8998-charger/online")
.ok()
.map(|value| value.trim() == "1");
Ok(serde_json::json!({
"ok": true,
"mode": raw_mode.strip_suffix("_mode").unwrap_or(&raw_mode),
"raw_mode": raw_mode,
"available_modes": available,
"data_role": read_usb_role(),
// Power evidence comes from the charger/upower lane, adjacent to the
// data-mode mechanism. A true Type-C source/sink role stays unknown
// until TCPM binds; online is not dressed up as more than it says.
"charger_online": charger_online,
"power_role": serde_json::Value::Null,
// Stable fields now, evidence later. Federation supplies identity;
// the probe registry supplies ownership. Neither is inferred from a
// VID/PID and neither absence is reported as "unclaimed".
"attached_identity": serde_json::Value::Null,
"probe_owner": serde_json::Value::Null,
}))
}
fn set_usb_mode(mode: UsbMode) -> Result<()> {
let _ = usb_moded_call("set_mode", Some(mode.as_usb_moded()))?;
let after = usb_moded_call("mode_request", None)?;
if after != mode.as_usb_moded() {
anyhow::bail!(
"requested {}, usb-signaller reports {after}",
mode.as_usb_moded()
);
}
Ok(())
}
/// The cellular v4 default's metric, which is a fixed reference point rather /// The cellular v4 default's metric, which is a fixed reference point rather
/// than something to set. /// than something to set.
/// ///
@ -1350,6 +1450,38 @@ fn handle_request(
let panel_on = shared.lock().device_state.panel_on(); let panel_on = shared.lock().device_state.panel_on();
serde_json::json!({ "ok": true, "requested": on, "panel_on": panel_on, "acted": acted }) serde_json::json!({ "ok": true, "requested": on, "panel_on": panel_on, "acted": acted })
} }
Request::Usb => match usb_snapshot() {
Ok(snapshot) => snapshot,
Err(error) => refuse(
RefusalCode::Unavailable,
&format!("USB posture is unavailable: {error:#}"),
),
},
Request::SetUsbMode { mode } => {
let actions = {
let mut d = shared.lock();
d.device_state
.request_usb_mode(mode, "USB mode requested through the session authority")
};
for action in actions {
execute(shared, action);
}
match usb_snapshot() {
Ok(snapshot) if snapshot["raw_mode"] == mode.as_usb_moded() => snapshot,
Ok(snapshot) => refuse(
RefusalCode::Unavailable,
&format!(
"USB mode did not settle at {} (reported {})",
mode.as_usb_moded(),
snapshot["raw_mode"]
),
),
Err(error) => refuse(
RefusalCode::Unavailable,
&format!("USB mode changed but could not be verified: {error:#}"),
),
}
}
Request::Bearer => { Request::Bearer => {
let d = shared.lock(); let d = shared.lock();
let b = &d.device_state.bearer; let b = &d.device_state.bearer;

View file

@ -43,6 +43,7 @@ services/ChargeRate.qml souveraine/services/ChargeRate.qml
services/Haptics.qml souveraine/services/Haptics.qml services/Haptics.qml souveraine/services/Haptics.qml
services/Selection.qml souveraine/services/Selection.qml services/Selection.qml souveraine/services/Selection.qml
services/DeviceEvidence.qml souveraine/services/DeviceEvidence.qml services/DeviceEvidence.qml souveraine/services/DeviceEvidence.qml
services/UsbState.qml souveraine/services/UsbState.qml
services/Gestures.qml souveraine/services/Gestures.qml services/Gestures.qml souveraine/services/Gestures.qml
modules/souveraine/dial/RadialDial.qml souveraine/modules/souveraine/dial/RadialDial.qml modules/souveraine/dial/RadialDial.qml souveraine/modules/souveraine/dial/RadialDial.qml
modules/souveraine/dial/DialHost.qml souveraine/modules/souveraine/dial/DialHost.qml modules/souveraine/dial/DialHost.qml souveraine/modules/souveraine/dial/DialHost.qml
@ -56,6 +57,7 @@ modules/souveraine/selection/qmldir souveraine/modules/souveraine/selection/q
modules/souveraine/windowSheet/WindowSheet.qml souveraine/modules/souveraine/windowSheet/WindowSheet.qml modules/souveraine/windowSheet/WindowSheet.qml souveraine/modules/souveraine/windowSheet/WindowSheet.qml
modules/souveraine/windowSheet/SheetButton.qml souveraine/modules/souveraine/windowSheet/SheetButton.qml modules/souveraine/windowSheet/SheetButton.qml souveraine/modules/souveraine/windowSheet/SheetButton.qml
modules/souveraine/windowSheet/PowerMenu.qml souveraine/modules/souveraine/windowSheet/PowerMenu.qml modules/souveraine/windowSheet/PowerMenu.qml souveraine/modules/souveraine/windowSheet/PowerMenu.qml
modules/souveraine/windowSheet/PowerOptionRow.qml souveraine/modules/souveraine/windowSheet/PowerOptionRow.qml
modules/souveraine/windowSheet/qmldir souveraine/modules/souveraine/windowSheet/qmldir modules/souveraine/windowSheet/qmldir souveraine/modules/souveraine/windowSheet/qmldir
services/Face.qml souveraine/services/Face.qml services/Face.qml souveraine/services/Face.qml
services/ViewtopControl.qml souveraine/services/ViewtopControl.qml services/ViewtopControl.qml souveraine/services/ViewtopControl.qml

View file

@ -15,12 +15,11 @@
// //
// ## What is honest here today // ## What is honest here today
// //
// The data role is real and readable `/sys/class/usb_role//role` reports // The data role and charger attachment are measured rather than guessed.
// `device`, and `pmi8998-charger` reports `online=1`, so "attached, drawing // UsbState reads sessiond's combined snapshot: usb-signaller owns the configfs
// power" is a measured fact rather than a guess. **Switching** the role is not // mechanism, souveraine-upower is adjacent charging evidence, and sessiond is
// offered yet: the sysfs node is root-owned, so the flip has to be a sessiond // the authority that can turn a requested posture into an audited Action.
// verb, and a button that cannot do the thing it names is the exact failure // This surface reads and asks; it never writes sysfs or calls the gadget daemon.
// this project keeps writing down. It is shown as state, and it says so.
// //
// There is no `/sys/class/typec/` on this device the tcpm driver is not // There is no `/sys/class/typec/` on this device the tcpm driver is not
// bound so the *power* role genuinely is not knowable here yet. The row // bound so the *power* role genuinely is not knowable here yet. The row
@ -48,20 +47,27 @@ Scope {
id: scope id: scope
property bool menuOpen: false property bool menuOpen: false
property bool powerOptionsOpen: false
property bool attachmentOptionsOpen: false
property bool batteryOptionsOpen: false
property bool hidOptionsOpen: false
// Measured, not assumed. Re-read each time the menu opens rather than // Measured, not assumed. Re-read each time the menu opens rather than
// polled: the port's state only matters while someone is looking at it, // polled: the port's state only matters while someone is looking at it,
// and a timer here would be a background reader of a file nobody needs. // and a timer here would be a background reader of a file nobody needs.
property string dataRole: "unknown" readonly property string dataRole: UsbState.dataRole
property bool chargerOnline: false readonly property bool chargerOnline: UsbState.chargerOnline
function refresh() { function refresh() {
roleProc.running = true; UsbState.refresh();
chargerProc.running = true;
} }
function open() { function open() {
scope.refresh(); scope.refresh();
scope.powerOptionsOpen = false;
scope.attachmentOptionsOpen = false;
scope.batteryOptionsOpen = false;
scope.hidOptionsOpen = false;
scope.menuOpen = true; scope.menuOpen = true;
} }
@ -85,22 +91,6 @@ Scope {
} }
} }
Process {
id: roleProc
command: ["cat", "/sys/class/usb_role/a600000.usb-role-switch/role"]
stdout: StdioCollector {
onStreamFinished: scope.dataRole = text.trim() || "unknown"
}
}
Process {
id: chargerProc
command: ["cat", "/sys/class/power_supply/pmi8998-charger/online"]
stdout: StdioCollector {
onStreamFinished: scope.chargerOnline = text.trim() === "1"
}
}
Variants { Variants {
model: Quickshell.screens model: Quickshell.screens
@ -129,7 +119,8 @@ Scope {
ColumnLayout { ColumnLayout {
anchors.centerIn: parent anchors.centerIn: parent
spacing: 30 width: Math.min(win.width - 32, 500)
spacing: 22
RowLayout { RowLayout {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
@ -170,16 +161,239 @@ Scope {
} }
} }
// The port, as measured. Not a control yet see the header. // One gentle door into the more peculiar things the phone can
// become. Live leaves ask sessiond; unfinished leaves keep their
// future shape without pretending that a tap did anything.
ColumnLayout {
Layout.fillWidth: true
spacing: 9
SheetLocal.PowerOptionRow {
Layout.fillWidth: true
icon: "power"
title: "Power Options"
detail: "USB roles, KVM and hardware modes"
expandable: true
expanded: scope.powerOptionsOpen
onTapped: {
scope.powerOptionsOpen = !scope.powerOptionsOpen;
if (!scope.powerOptionsOpen) {
scope.attachmentOptionsOpen = false;
scope.batteryOptionsOpen = false;
scope.hidOptionsOpen = false;
}
}
}
ColumnLayout {
Layout.fillWidth: true
visible: scope.powerOptionsOpen
spacing: 8
SheetLocal.PowerOptionRow {
Layout.fillWidth: true
icon: "usb"
title: "Port & attachment"
detail: scope.dataRole
+ (!UsbState.chargerKnown ? " · power unknown"
: scope.chargerOnline ? " · drawing power" : " · no charger")
badge: UsbState.available ? "live" : "waiting"
inset: 10
expandable: true
expanded: scope.attachmentOptionsOpen
onTapped: {
scope.attachmentOptionsOpen = !scope.attachmentOptionsOpen;
if (scope.attachmentOptionsOpen) {
scope.batteryOptionsOpen = false;
scope.hidOptionsOpen = false;
}
}
}
ColumnLayout {
Layout.fillWidth: true
visible: scope.attachmentOptionsOpen
spacing: 7
SheetLocal.PowerOptionRow {
Layout.fillWidth: true
icon: "fingerprint"
title: "Attached identity"
detail: UsbState.attachedIdentity
? UsbState.attachedIdentity : "USB enumeration + federation handshake"
badge: UsbState.attachedIdentity ? "known" : "next"
inset: 28
}
SheetLocal.PowerOptionRow {
Layout.fillWidth: true
icon: "hub"
title: "Probe ownership"
detail: UsbState.probeOwner
? UsbState.probeOwner : "Which trusted node already has instruments here"
badge: UsbState.probeOwner ? "claimed" : "next"
inset: 28
}
}
SheetLocal.PowerOptionRow {
Layout.fillWidth: true
icon: "battery_android_frame_shield"
title: "Battery care"
detail: "Charge ceiling, resume floor, agent discretion"
badge: "policy"
inset: 10
expandable: true
expanded: scope.batteryOptionsOpen
onTapped: {
scope.batteryOptionsOpen = !scope.batteryOptionsOpen;
if (scope.batteryOptionsOpen) {
scope.attachmentOptionsOpen = false;
scope.hidOptionsOpen = false;
}
}
}
ColumnLayout {
Layout.fillWidth: true
visible: scope.batteryOptionsOpen
spacing: 7
SheetLocal.PowerOptionRow {
Layout.fillWidth: true
icon: "battery_full_alt"
title: "Charge ceiling"
detail: "Stay below 100% inside a human safety envelope"
badge: "draft"
inset: 28
}
SheetLocal.PowerOptionRow {
Layout.fillWidth: true
icon: "battery_horiz_050"
title: "Resume floor"
detail: "Wide hysteresis instead of twitching at 98/99"
badge: "draft"
inset: 28
}
SheetLocal.PowerOptionRow {
Layout.fillWidth: true
icon: "neurology"
title: "Agent-managed"
detail: "May move the pair; every change is bounded and written"
badge: "planned"
inset: 28
}
}
SheetLocal.PowerOptionRow {
Layout.fillWidth: true
icon: "keyboard"
title: "USB HID Injection"
detail: "Let this screen become the other machine's hands"
badge: UsbState.mode === "hid" ? "active"
: UsbState.hasMode("hid_mode") ? "wired" : "building"
inset: 10
expandable: true
expanded: scope.hidOptionsOpen
onTapped: {
scope.hidOptionsOpen = !scope.hidOptionsOpen;
if (scope.hidOptionsOpen) {
scope.attachmentOptionsOpen = false;
scope.batteryOptionsOpen = false;
}
}
}
ColumnLayout {
Layout.fillWidth: true
visible: scope.hidOptionsOpen
spacing: 7
SheetLocal.PowerOptionRow {
Layout.fillWidth: true
icon: "keyboard_mouse"
title: "Keyboard + pointer"
detail: UsbState.mode === "hid"
? "Active · tap to restore developer USB"
: "Tap to arm boot keyboard, pointer and NCM"
badge: UsbState.busy ? "switching"
: UsbState.mode === "hid" ? "active"
: UsbState.hasMode("hid_mode") ? "ready" : "not installed"
inset: 28
available: UsbState.hasMode("hid_mode") && !UsbState.busy
onTapped: UsbState.setMode(
UsbState.mode === "hid" ? "developer" : "hid")
}
SheetLocal.PowerOptionRow {
Layout.fillWidth: true
icon: "text_fields"
title: "Type text…"
detail: "A guarded text payload for the attached host"
badge: "planned"
inset: 28
}
SheetLocal.PowerOptionRow {
Layout.fillWidth: true
icon: "security"
title: "Secure attention"
detail: "Hold to send Ctrl + Alt + Delete"
badge: "planned"
inset: 28
}
}
SheetLocal.PowerOptionRow {
Layout.fillWidth: true
icon: "desktop_windows"
title: "Full USB KVM"
detail: UsbState.mode === "kvm"
? "Active · tap to restore developer USB"
: "GUD display · HID input · NCM control · smoo"
badge: UsbState.busy ? "switching"
: UsbState.mode === "kvm" ? "active"
: UsbState.hasMode("kvm_mode") ? "ready" : "not installed"
inset: 10
available: UsbState.hasMode("kvm_mode") && !UsbState.busy
onTapped: UsbState.setMode(
UsbState.mode === "kvm" ? "developer" : "kvm")
}
SheetLocal.PowerOptionRow {
Layout.fillWidth: true
icon: "hard_drive"
title: "Host-backed volume"
detail: "Mount storage offered by the attached host"
badge: UsbState.mode === "kvm" ? "active" : "smoo"
inset: 10
}
SheetLocal.PowerOptionRow {
Layout.fillWidth: true
icon: "electrical_services"
title: "USB host + power share"
detail: "Become the host and source 5 V"
badge: "kernel"
inset: 10
}
}
// The port, as measured. Not a control yet see the
// architecture note at the top of this file.
StyledText { StyledText {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
Layout.maximumWidth: win.width * 0.8 Layout.maximumWidth: win.width * 0.8
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
color: "#c8ffffff" color: "#aaffffff"
font.pixelSize: Appearance.font.pixelSize.small font.pixelSize: Appearance.font.pixelSize.small
text: "USB-C: " + scope.dataRole text: "USB-C: " + scope.dataRole
+ (scope.chargerOnline ? " · charger attached" : " · no charger") + (!UsbState.chargerKnown ? " · power unknown"
: scope.chargerOnline ? " · charger attached" : " · no charger")
}
} }
} }
} }

View file

@ -0,0 +1,99 @@
// One nested choice in the held-power-button sheet.
//
// `available` is deliberately separate from Item.enabled. An unavailable
// option still opens when it owns children, so the hand can see the shape of
// the mode being built; a leaf that cannot act simply has no click target and
// wears its status instead of pretending a tap did something.
import QtQuick
import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
Rectangle {
id: root
property string icon: ""
property string title: ""
property string detail: ""
property string badge: ""
property bool expandable: false
property bool expanded: false
property bool available: false
property int inset: 0
signal tapped
implicitHeight: detail === "" ? 58 : 70
radius: 18
color: hit.pressed
? Appearance.colors.colLayer2
: Appearance.colors.colLayer1
border.width: 1
border.color: Appearance.colors.colLayer1Active
opacity: available || expandable ? 1.0 : 0.72
RowLayout {
anchors.fill: parent
anchors.leftMargin: 16 + root.inset
anchors.rightMargin: 16
spacing: 13
MaterialSymbol {
text: root.icon
iconSize: 27
color: Appearance.colors.colOnLayer1
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
StyledText {
Layout.fillWidth: true
text: root.title
color: Appearance.colors.colOnLayer1
font.pixelSize: Appearance.font.pixelSize.normal
elide: Text.ElideRight
}
StyledText {
Layout.fillWidth: true
visible: root.detail !== ""
text: root.detail
color: "#aaffffff"
font.pixelSize: Appearance.font.pixelSize.small
elide: Text.ElideRight
}
}
Rectangle {
visible: root.badge !== ""
implicitWidth: badgeText.implicitWidth + 18
implicitHeight: 26
radius: 13
color: Appearance.colors.colLayer2
StyledText {
id: badgeText
anchors.centerIn: parent
text: root.badge
color: "#c8ffffff"
font.pixelSize: Appearance.font.pixelSize.smaller
}
}
MaterialSymbol {
visible: root.expandable
text: root.expanded ? "expand_less" : "expand_more"
iconSize: 25
color: Appearance.colors.colOnLayer1
}
}
MouseArea {
id: hit
anchors.fill: parent
enabled: root.available || root.expandable
onClicked: root.tapped()
}
}

View file

@ -1,3 +1,4 @@
PowerMenu 1.0 PowerMenu.qml PowerMenu 1.0 PowerMenu.qml
PowerOptionRow 1.0 PowerOptionRow.qml
SheetButton 1.0 SheetButton.qml SheetButton 1.0 SheetButton.qml
WindowSheet 1.0 WindowSheet.qml WindowSheet 1.0 WindowSheet.qml

View file

@ -0,0 +1,112 @@
// One typed view over sessiond's USB posture and mode verbs.
//
// usb-signaller owns configfs, souveraine-upower supplies adjacent charging
// evidence, and federation/probe leases will supply who is attached and who is
// already working there. The shell owns none of it; it asks sessiond and shows
// the answer.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
property bool available: false
property bool busy: false
property string lastError: ""
property string mode: "unknown"
property string rawMode: "unknown_mode"
property var availableModes: []
property string dataRole: "unknown"
property bool chargerKnown: false
property bool chargerOnline: false
property var attachedIdentity: null
property var probeOwner: null
signal refreshed()
signal changeFailed(string reason)
function refresh() {
root._send({ op: "usb" });
}
function setMode(mode) {
if (!["developer", "hid", "kvm", "charging_only"].includes(mode)) {
root.changeFailed("unsupported USB mode: " + mode);
return;
}
root.busy = true;
root._send({ op: "set_usb_mode", mode: mode });
}
function hasMode(rawMode) {
return root.availableModes.includes(rawMode);
}
property var _queued: null
function _send(message) {
if (sock.connected) {
sock.write(JSON.stringify(message) + "\n");
return;
}
root._queued = message;
sock.connected = true;
}
Socket {
id: sock
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
onConnectionStateChanged: {
if (connected && root._queued) {
const message = root._queued;
root._queued = null;
sock.write(JSON.stringify(message) + "\n");
} else if (!connected && root._queued) {
root._queued = null;
root.available = false;
root.busy = false;
root.lastError = "sessiond socket unavailable";
root.changeFailed(root.lastError);
}
}
parser: SplitParser {
splitMarker: "\n"
onRead: message => {
let reply;
try {
reply = JSON.parse(message);
} catch (error) {
root.lastError = "unparseable USB reply";
root.busy = false;
root.changeFailed(root.lastError);
return;
}
if (reply.ok !== true) {
root.lastError = reply.reason || "USB request refused";
root.busy = false;
root.changeFailed(root.lastError);
return;
}
root.mode = reply.mode ?? "unknown";
root.rawMode = reply.raw_mode ?? "unknown_mode";
root.availableModes = reply.available_modes ?? [];
root.dataRole = reply.data_role ?? "unknown";
root.chargerKnown = reply.charger_online !== null
&& reply.charger_online !== undefined;
root.chargerOnline = reply.charger_online === true;
root.attachedIdentity = reply.attached_identity ?? null;
root.probeOwner = reply.probe_owner ?? null;
root.available = true;
root.busy = false;
root.lastError = "";
root.refreshed();
}
}
}
}

View file

@ -63,6 +63,7 @@ singleton Todo 1.0 Todo.qml
singleton Translation 1.0 Translation.qml singleton Translation 1.0 Translation.qml
singleton TrayService 1.0 TrayService.qml singleton TrayService 1.0 TrayService.qml
singleton Updates 1.0 Updates.qml singleton Updates 1.0 Updates.qml
singleton UsbState 1.0 UsbState.qml
singleton ViewtopControl 1.0 ViewtopControl.qml singleton ViewtopControl 1.0 ViewtopControl.qml
singleton WallpaperAssets 1.0 WallpaperAssets.qml singleton WallpaperAssets 1.0 WallpaperAssets.qml
singleton WallpaperDownload 1.0 WallpaperDownload.qml singleton WallpaperDownload 1.0 WallpaperDownload.qml