mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-25 11:02:45 +01:00
feat(launcher): offer local and Remote Play launch choices
This commit is contained in:
1 parent
6a50b4a74b
commit
fbd3f9cfa3
9 files changed
+318
-83
No files matched your search
+14
-2
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user