feat(launcher): offer local and Remote Play launch choices

This commit is contained in:
toorux committed 2026-10-07 11:46:11 +08:00
1 parent 6a50b4a74b
commit fbd3f9cfa3
9 files changed
+318 -83

No files matched your search

+14 -2
View File
@@ -953,14 +953,26 @@ impl Agent {
}
Ok(json!(true))
}
"steam.list" => Ok(json!(crate::steam::discover(&crate::steam::home()?))),
"steam.list" => {
if p["remoteOnly"] == true {
Ok(crate::steam::remote_snapshot())
} else {
Ok(json!(crate::steam::discover_launcher(
&crate::steam::home()?
)))
}
}
"steam.launch" => {
let launcher_session = p["launcherSession"].as_u64();
let id = p["app"]
.as_u64()
.filter(|v| *v > 0 && *v <= u32::MAX as u64)
.context("Invalid Steam app")? as u32;
crate::steam::launch(id)?;
if let Some(client) = p["remoteClient"].as_str() {
crate::steam::launch_remote(id, client)?;
} else {
crate::steam::launch(id)?;
}
push(
&self.commands,
json!({"kind":"launcher.close","handoff":true,"launcherSession":launcher_session}),
+156 -68
View File
@@ -1,4 +1,5 @@
//! Local Steam library discovery. No account API or network access is needed.
//! Local Steam libraries and connected Remote Play hosts.
mod remote;
use anyhow::{ensure, Context, Result};
use serde::Serialize;
use std::{
@@ -9,6 +10,9 @@ use std::{
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct App {
pub remote: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_client: Option<String>,
pub id: u32,
pub name: String,
pub kind: &'static str,
@@ -202,77 +206,13 @@ pub fn discover(home: &Path) -> Vec<App> {
{
continue;
}
let dir = steam.join("appcache/librarycache").join(id.to_string());
let mut candidates: Vec<_> = fs::read_dir(dir)
.ok()
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.collect();
// Newer Steam clients keep named artwork one level below a content hash.
let nested: Vec<_> = candidates
.iter()
.filter(|p| p.is_dir())
.take(16)
.cloned()
.collect();
for path in nested {
candidates.extend(
fs::read_dir(path)
.ok()
.into_iter()
.flatten()
.flatten()
.take(32)
.map(|e| e.path()),
);
}
for suffix in ["icon.jpg", "icon.png", "library_600x900.jpg", "header.jpg"] {
candidates.push(
steam
.join("appcache/librarycache")
.join(format!("{id}_{suffix}")),
);
}
let artwork_fit = |p: &Path| {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
if name.contains("600x900") || name == "library_capsule.jpg" {
(0, "cover")
} else if name.contains("header") {
(1, "cover")
} else if name == "logo.png" {
(2, "contain")
} else {
(3, "contain")
}
};
candidates.sort_by_key(|p| (artwork_fit(p).0, p.clone()));
let icon = candidates.into_iter().take(32).find_map(|p| {
if fs::metadata(&p).ok()?.len() > 1024 * 1024 {
return None;
}
let b = fs::read(&p).ok()?;
let mime = if b.starts_with(b"\x89PNG\r\n\x1a\n") {
"image/png"
} else if b.starts_with(&[0xff, 0xd8, 0xff]) {
"image/jpeg"
} else {
return None;
};
use base64::Engine;
Some((
format!(
"data:{mime};base64,{}",
base64::engine::general_purpose::STANDARD.encode(b)
),
artwork_fit(&p).1,
))
});
let icon = cached_artwork(&steam, id);
apps.insert(
id,
App {
id,
remote: false,
remote_client: None,
name: name.into(),
kind: if text(v, "installdir").is_some_and(|dir| {
fs::read_dir(library.join("steamapps/common").join(dir)).is_ok_and(
@@ -295,6 +235,117 @@ pub fn discover(home: &Path) -> Vec<App> {
}
apps.into_values().collect()
}
fn cached_artwork(steam: &Path, id: u32) -> Option<(String, &'static str)> {
let dir = steam.join("appcache/librarycache").join(id.to_string());
let mut candidates: Vec<_> = fs::read_dir(dir)
.ok()
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.collect();
// Newer Steam clients keep named artwork one level below a content hash.
let nested: Vec<_> = candidates
.iter()
.filter(|p| p.is_dir())
.take(16)
.cloned()
.collect();
for path in nested {
candidates.extend(
fs::read_dir(path)
.ok()
.into_iter()
.flatten()
.flatten()
.take(32)
.map(|e| e.path()),
);
}
for suffix in ["icon.jpg", "icon.png", "library_600x900.jpg", "header.jpg"] {
candidates.push(
steam
.join("appcache/librarycache")
.join(format!("{id}_{suffix}")),
);
}
let artwork_fit = |p: &Path| {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
if name.contains("600x900") || name == "library_capsule.jpg" {
(0, "cover")
} else if name.contains("header") {
(1, "cover")
} else if name == "logo.png" {
(2, "contain")
} else {
(3, "contain")
}
};
candidates.sort_by_key(|p| (artwork_fit(p).0, p.clone()));
candidates.into_iter().take(32).find_map(|p| {
if fs::metadata(&p).ok()?.len() > 1024 * 1024 {
return None;
}
let b = fs::read(&p).ok()?;
let mime = if b.starts_with(b"\x89PNG\r\n\x1a\n") {
"image/png"
} else if b.starts_with(&[0xff, 0xd8, 0xff]) {
"image/jpeg"
} else {
return None;
};
use base64::Engine;
Some((
format!(
"data:{mime};base64,{}",
base64::engine::general_purpose::STANDARD.encode(b)
),
artwork_fit(&p).1,
))
})
}
pub fn remote_snapshot() -> serde_json::Value {
serde_json::json!(remote::cached())
}
pub fn discover_launcher(home: &Path) -> Vec<App> {
merge_remote(home, discover(home), remote::cached())
}
fn merge_remote(home: &Path, local: Vec<App>, remote: Vec<remote::RemoteApp>) -> Vec<App> {
let mut apps: BTreeMap<_, _> = local.into_iter().map(|a| (a.id, a)).collect();
let steam = home.join(".local/share/Steam");
let steam = if steam.exists() {
steam
} else {
home.join(".steam/steam")
};
for a in remote {
if let Some(existing) = apps.get_mut(&a.id) {
// Keep local metadata and a single stable icon, but expose both launch targets.
if existing.remote_client.is_none() {
existing.remote_client = Some(a.client);
}
continue;
}
apps.entry(a.id).or_insert_with(|| {
let icon = cached_artwork(&steam, a.id);
App {
id: a.id,
name: a.name,
kind: "steam",
icon_fit: icon.as_ref().map(|v| v.1).unwrap_or("contain"),
icon: icon.map(|v| v.0),
remote: true,
remote_client: Some(a.client),
}
});
}
apps.into_values().collect()
}
pub fn launch_remote(id: u32, client: &str) -> Result<()> {
remote::launch(id, client)
}
pub fn home() -> Result<PathBuf> {
ensure!(
unsafe { libc::geteuid() } != 0,
@@ -322,6 +373,43 @@ fn launch_installed(home: &Path, id: u32) -> Result<()> {
mod tests {
use super::*;
#[test]
fn remote_inventory_merges_launch_choices_without_duplicate_icons() {
let home = tempfile::tempdir().unwrap();
let local = vec![App {
id: 42,
name: "Local".into(),
kind: "steam",
icon: None,
icon_fit: "contain",
remote: false,
remote_client: None,
}];
let rows = vec![
remote::RemoteApp {
id: 42,
name: "Same remote".into(),
client: "12".into(),
},
remote::RemoteApp {
id: 43,
name: "Remote".into(),
client: "12".into(),
},
remote::RemoteApp {
id: 43,
name: "Other host".into(),
client: "13".into(),
},
];
let apps = merge_remote(home.path(), local, rows);
assert_eq!(apps.len(), 2);
assert!(!apps[0].remote);
assert_eq!(apps[0].name, "Local");
assert_eq!(apps[0].remote_client.as_deref(), Some("12"));
assert!(apps[1].remote);
assert_eq!(apps[1].remote_client.as_deref(), Some("12"));
}
#[test]
fn nested_escaped_and_invalid() {
let t = tokens("\"libraryfolders\" { \"0\" { \"path\" \"/a\\\\b\" } }").unwrap();
let v = object(&t, &mut 0, 0, false).unwrap();
+22
View File
@@ -0,0 +1,22 @@
async function(request) {
const store=window.appStore, remote=window.RemotePlayStore_SteamUI;
if(!store||!remote||!Array.isArray(store.allApps)||!Array.isArray(remote.devices)) {
if(request.mode==='list')return [];
throw Error('Steam remote library is unavailable');
}
const connected=new Set(remote.devices.filter(d=>d.status==='Connected').map(d=>String(d.clientId)));
const rows=[];
for(const a of store.allApps) {
if(!Number.isInteger(a.appid)||a.appid<=0||a.appid>4294967295||a.visible_in_game_list===false||!(a.app_type&(1|2|8)))continue;
for(const r of a.remote_per_client_data||[]) {
const client=String(r.clientid);
if(r.installed===true&&/^\d{1,20}$/.test(client)&&connected.has(client)&&a.BIsPerClientDataLocal?.(r)===false)
rows.push({id:a.appid,name:String(a.display_name||''),client});
}
}
if(request.mode==='list')return rows.slice(0,10000);
if(request.mode!=='launch'||!rows.some(r=>r.id===request.id&&r.client===request.client))return false;
if(typeof SteamClient.Apps.StreamGame!=='function')throw Error('Steam streaming is unavailable');
await SteamClient.Apps.StreamGame(request.id,request.client,-1);
return true;
}
+77
View File
@@ -0,0 +1,77 @@
//! Steam's current connected-host inventory; no account scraping or media reads.
use anyhow::{ensure, Result};
use serde::{Deserialize, Serialize};
use std::sync::{
atomic::{AtomicBool, Ordering},
Mutex, OnceLock,
};
use std::time::{Duration, Instant};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub(super) struct RemoteApp {
pub id: u32,
pub name: String,
pub client: String,
}
fn query(request: serde_json::Value) -> Result<serde_json::Value> {
crate::apk::steam_ui::evaluate(&format!("({})({request})", include_str!("remote.js")))
}
fn inventory(value: serde_json::Value) -> Result<Vec<RemoteApp>> {
let rows: Vec<RemoteApp> = serde_json::from_value(value)?;
ensure!(rows.len() <= 10000, "Remote library is too large");
Ok(rows
.into_iter()
.filter(|a| {
a.id > 0 && !a.name.trim().is_empty() && a.name.len() <= 1024 && valid_client(&a.client)
})
.collect())
}
fn valid_client(client: &str) -> bool {
!client.is_empty()
&& client.len() <= 20
&& client.bytes().all(|b| b.is_ascii_digit())
&& client != "0"
}
static CACHE: OnceLock<Mutex<Option<(Instant, Vec<RemoteApp>)>>> = OnceLock::new();
static REFRESHING: AtomicBool = AtomicBool::new(false);
pub(super) fn cached() -> Vec<RemoteApp> {
let cache = CACHE.get_or_init(|| Mutex::new(None));
let snapshot = cache.lock().unwrap().clone();
if snapshot
.as_ref()
.is_none_or(|(at, _)| at.elapsed() >= Duration::from_secs(3))
&& REFRESHING
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
std::thread::spawn(|| {
let rows = query(serde_json::json!({"mode":"list"}))
.and_then(inventory)
.unwrap_or_default();
*CACHE.get().unwrap().lock().unwrap() = Some((Instant::now(), rows));
REFRESHING.store(false, Ordering::Release);
});
}
snapshot
.filter(|(at, _)| at.elapsed() < Duration::from_secs(15))
.map(|(_, v)| v)
.unwrap_or_default()
}
pub(super) fn launch(id: u32, client: &str) -> Result<()> {
ensure!(id > 0 && valid_client(client), "Invalid remote Steam game");
ensure!(
query(serde_json::json!({"mode":"launch","id":id,"client":client}))? == true,
"Remote game or host is no longer available"
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_remote_entries_and_clients_are_rejected() {
let rows=inventory(serde_json::json!([{ "id":1,"name":"Game","client":"123"},{"id":0,"name":"Invalid","client":"12"},{"id":2,"name":"Unsafe","client":"1);evil()"}])).unwrap();
assert_eq!(rows.len(), 1);
assert!(launch(42, "0").is_err());
assert!(launch(42, r#"1"2"#).is_err());
}
}