7654b20b2a
apple / swift (push) Successful in 54s
android / android (push) Failing after 1m44s
ci / rust (push) Successful in 1m18s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 31s
ci / bench (push) Successful in 1m50s
decky / build-publish (push) Successful in 10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
flatpak / build-publish (push) Failing after 2s
deb / build-publish (push) Failing after 2m56s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m4s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m48s
docker / deploy-docs (push) Successful in 17s
Validated live on an RTX 4090 (Windows 11) host streaming to the Rust reference client over the LAN: SudoVDA virtual display → DXGI Desktop Duplication (D3D11 zero-copy) → NVENC HEVC → punktfunk/1. 720p60 and 1080p60 both clean (181 / 177 frames, 0 mismatched, p50 1.6 / 3.45 ms cross-machine), coexisting with Apollo. Two real-hardware bugs the GPU-less VM couldn't surface: - DXGI capturer: the SudoVDA virtual monitor's DXGI output is enumerated under the GPU that *renders* it (the 4090, LUID 0x15df6), NOT under the SudoVDA "adapter" LUID SudoVDA reports (0x23276). Restricting the output search to that LUID found nothing → "adapter has no output named \\.\DISPLAYn". Now search ALL adapters for the GDI name, bind the D3D11 device to whichever adapter exposes it (NVENC then shares that device), with a settle-retry (the output appears a beat after display creation) and topology logging. - native_pairing / apps: keyed config paths off raw $HOME, which a Windows service/scheduled-task context doesn't set → "HOME unset" hard-fail at m3-host startup. Route both through gamestream::config_dir(), which falls back to %APPDATA% on Windows (cert/paired/apps now under AppData\Roaming). clippy -D warnings + build green on x86_64-pc-windows-msvc (default and --features nvenc) and Linux (78/78 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
145 lines
4.8 KiB
Rust
145 lines
4.8 KiB
Rust
//! The app catalog: what `/applist` advertises and what `/launch?appid=N` selects. Each entry
|
|
//! maps to a session recipe — which compositor backend hosts it and (for gamescope) which
|
|
//! command runs nested. Loaded from `~/.config/punktfunk/apps.json`; sensible defaults otherwise.
|
|
//!
|
|
//! ```json
|
|
//! [ {"id":1,"title":"Desktop"},
|
|
//! {"id":2,"title":"Steam","compositor":"gamescope","cmd":"steam -gamepadui"} ]
|
|
//! ```
|
|
|
|
use serde_json::Value;
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct AppEntry {
|
|
pub id: u32,
|
|
pub title: String,
|
|
/// `None` = auto-detect (the desktop session's compositor).
|
|
pub compositor: Option<crate::vdisplay::Compositor>,
|
|
/// Command gamescope runs nested (gamescope entries only).
|
|
pub cmd: Option<String>,
|
|
}
|
|
|
|
fn config_path() -> Option<std::path::PathBuf> {
|
|
// `config_dir()` resolves XDG/HOME on Linux and %APPDATA% on Windows (no HOME needed).
|
|
Some(super::config_dir().join("apps.json"))
|
|
}
|
|
|
|
fn parse_compositor(s: &str) -> Option<crate::vdisplay::Compositor> {
|
|
use crate::vdisplay::Compositor::*;
|
|
match s.to_ascii_lowercase().as_str() {
|
|
"kwin" | "kde" => Some(Kwin),
|
|
"mutter" | "gnome" => Some(Mutter),
|
|
"gamescope" => Some(Gamescope),
|
|
"wlroots" | "sway" => Some(Wlroots),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The catalog: the user's `apps.json` if present, else defaults (Desktop, plus gamescope
|
|
/// entries when gamescope is installed).
|
|
pub fn catalog() -> Vec<AppEntry> {
|
|
if let Some(path) = config_path() {
|
|
if let Ok(raw) = std::fs::read_to_string(&path) {
|
|
match serde_json::from_str::<Value>(&raw) {
|
|
Ok(Value::Array(items)) => {
|
|
let apps: Vec<AppEntry> = items
|
|
.iter()
|
|
.filter_map(|it| {
|
|
Some(AppEntry {
|
|
id: it.get("id")?.as_u64()? as u32,
|
|
title: it.get("title")?.as_str()?.to_string(),
|
|
compositor: it
|
|
.get("compositor")
|
|
.and_then(|c| c.as_str())
|
|
.and_then(parse_compositor),
|
|
cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from),
|
|
})
|
|
})
|
|
.collect();
|
|
if !apps.is_empty() {
|
|
return apps;
|
|
}
|
|
tracing::warn!(path = %path.display(), "apps.json parsed to zero entries — using defaults");
|
|
}
|
|
_ => {
|
|
tracing::warn!(path = %path.display(), "apps.json malformed — using defaults")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let mut apps = vec![AppEntry {
|
|
id: 1,
|
|
title: "Desktop".into(),
|
|
compositor: None,
|
|
cmd: None,
|
|
}];
|
|
if which("gamescope") {
|
|
if which("steam") {
|
|
apps.push(AppEntry {
|
|
id: 2,
|
|
title: "Steam".into(),
|
|
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
|
cmd: Some("steam -gamepadui".into()),
|
|
});
|
|
}
|
|
if which("vkcube") {
|
|
apps.push(AppEntry {
|
|
id: 3,
|
|
title: "vkcube (test)".into(),
|
|
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
|
cmd: Some("vkcube".into()),
|
|
});
|
|
}
|
|
}
|
|
apps
|
|
}
|
|
|
|
pub fn by_id(id: u32) -> Option<AppEntry> {
|
|
catalog().into_iter().find(|a| a.id == id)
|
|
}
|
|
|
|
/// Render the GameStream `/applist` XML.
|
|
pub fn applist_xml() -> String {
|
|
let mut xml =
|
|
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n");
|
|
for app in catalog() {
|
|
xml.push_str(&format!(
|
|
"<App>\n<IsHdrSupported>0</IsHdrSupported>\n<AppTitle>{}</AppTitle>\n<ID>{}</ID>\n</App>\n",
|
|
xml_escape(&app.title),
|
|
app.id
|
|
));
|
|
}
|
|
xml.push_str("</root>\n");
|
|
xml
|
|
}
|
|
|
|
fn xml_escape(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
}
|
|
|
|
fn which(bin: &str) -> bool {
|
|
std::env::var_os("PATH")
|
|
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join(bin).is_file()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn default_catalog_has_desktop() {
|
|
let apps = catalog();
|
|
assert!(apps.iter().any(|a| a.id == 1 && a.title == "Desktop"));
|
|
}
|
|
|
|
#[test]
|
|
fn applist_xml_is_wellformed_ish() {
|
|
let xml = applist_xml();
|
|
assert!(xml.contains("<AppTitle>Desktop</AppTitle>"));
|
|
assert!(xml.starts_with("<?xml"));
|
|
assert_eq!(xml.matches("<App>").count(), xml.matches("</App>").count());
|
|
}
|
|
}
|