game_list::View (wip)
[skip ci]
This commit is contained in:
46
Cargo.lock
generated
46
Cargo.lock
generated
@@ -287,6 +287,12 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ascii"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ash"
|
name = "ash"
|
||||||
version = "0.38.0+1.3.281"
|
version = "0.38.0+1.3.281"
|
||||||
@@ -789,6 +795,12 @@ version = "3.19.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytecount"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytemuck"
|
name = "bytemuck"
|
||||||
version = "1.24.0"
|
version = "1.24.0"
|
||||||
@@ -1471,14 +1483,17 @@ name = "dash-frontend"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"gio 0.21.5",
|
"gio 0.21.5",
|
||||||
"glam",
|
"glam",
|
||||||
"gtk",
|
"gtk",
|
||||||
|
"keyvalues-parser",
|
||||||
"log",
|
"log",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"steam_shortcuts_util",
|
||||||
"wayvr-ipc",
|
"wayvr-ipc",
|
||||||
"wgui",
|
"wgui",
|
||||||
"wlx-common",
|
"wlx-common",
|
||||||
@@ -3026,6 +3041,14 @@ dependencies = [
|
|||||||
"ucd-trie",
|
"ucd-trie",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "keyvalues-parser"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "git+https://github.com/CosmicHorrorDev/vdf-rs.git?rev=fc6dcbea9eb13cacb98dea40063f6f56cde6e145#fc6dcbea9eb13cacb98dea40063f6f56cde6e145"
|
||||||
|
dependencies = [
|
||||||
|
"pest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kurbo"
|
name = "kurbo"
|
||||||
version = "0.11.3"
|
version = "0.11.3"
|
||||||
@@ -3446,6 +3469,17 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom_locate"
|
||||||
|
version = "4.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3"
|
||||||
|
dependencies = [
|
||||||
|
"bytecount",
|
||||||
|
"memchr",
|
||||||
|
"nom 7.1.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "noop_proc_macro"
|
name = "noop_proc_macro"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -5269,6 +5303,18 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "steam_shortcuts_util"
|
||||||
|
version = "1.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0543ebdb23a93b196aceebc53f70cc5a573bb74248a974b3f5fa3883e6a89b6"
|
||||||
|
dependencies = [
|
||||||
|
"ascii",
|
||||||
|
"crc32fast",
|
||||||
|
"nom 7.1.3",
|
||||||
|
"nom_locate",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strict-num"
|
name = "strict-num"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
|||||||
@@ -16,3 +16,6 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
wlx-common = { path = "../wlx-common" }
|
wlx-common = { path = "../wlx-common" }
|
||||||
wayvr-ipc = { path = "../wayvr-ipc", default-features = false }
|
wayvr-ipc = { path = "../wayvr-ipc", default-features = false }
|
||||||
|
base64 = "0.22.1"
|
||||||
|
keyvalues-parser = { git = "https://github.com/CosmicHorrorDev/vdf-rs.git", rev = "fc6dcbea9eb13cacb98dea40063f6f56cde6e145" }
|
||||||
|
steam_shortcuts_util = "1.1.8"
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
|
|
||||||
<elements>
|
<elements>
|
||||||
<TabTitle translation="GAMES" icon="dashboard/games.svg" />
|
<TabTitle translation="GAMES" icon="dashboard/games.svg" />
|
||||||
|
<div id="game_list_parent" align_items="center" />
|
||||||
</elements>
|
</elements>
|
||||||
</layout>
|
</layout>
|
||||||
7
dash-frontend/assets/gui/view/game_list.xml
Normal file
7
dash-frontend/assets/gui/view/game_list.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<layout>
|
||||||
|
<include src="../t_group_box.xml" />
|
||||||
|
|
||||||
|
<elements>
|
||||||
|
<div id="list_parent" gap="8" flex_direction="row" flex_wrap="wrap" justify_content="center" />
|
||||||
|
</elements>
|
||||||
|
</layout>
|
||||||
@@ -64,5 +64,10 @@
|
|||||||
"NO_WINDOWS_FOUND": "Keine Fenster gefunden",
|
"NO_WINDOWS_FOUND": "Keine Fenster gefunden",
|
||||||
"WINDOW_OPTIONS": "Fensteroptionen",
|
"WINDOW_OPTIONS": "Fensteroptionen",
|
||||||
"APPLICATION_STARTED": "Anwendung gestartet",
|
"APPLICATION_STARTED": "Anwendung gestartet",
|
||||||
"LIST_OF_WINDOWS": "Fensterliste"
|
"LIST_OF_WINDOWS": "Fensterliste",
|
||||||
|
"CLOSE_WINDOW": "Fenster schließen",
|
||||||
|
"GAME_LIST": {
|
||||||
|
"NO_GAMES_FOUND": "Keine Spiele gefunden"
|
||||||
|
},
|
||||||
|
"TERMINATE_PROCESS": "Prozess beenden"
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,9 @@
|
|||||||
},
|
},
|
||||||
"CLOSE_WINDOW": "Close window",
|
"CLOSE_WINDOW": "Close window",
|
||||||
"FAILED_TO_LAUNCH_APPLICATION": "Failed to launcha application:",
|
"FAILED_TO_LAUNCH_APPLICATION": "Failed to launcha application:",
|
||||||
|
"GAME_LIST": {
|
||||||
|
"NO_GAMES_FOUND": "No games found"
|
||||||
|
},
|
||||||
"GAMES": "Games",
|
"GAMES": "Games",
|
||||||
"GENERAL_SETTINGS": "General settings",
|
"GENERAL_SETTINGS": "General settings",
|
||||||
"HEIGHT": "Height",
|
"HEIGHT": "Height",
|
||||||
|
|||||||
@@ -64,5 +64,10 @@
|
|||||||
"NO_WINDOWS_FOUND": "No se encontraron ventanas",
|
"NO_WINDOWS_FOUND": "No se encontraron ventanas",
|
||||||
"WINDOW_OPTIONS": "Opciones de ventana",
|
"WINDOW_OPTIONS": "Opciones de ventana",
|
||||||
"APPLICATION_STARTED": "Aplicación iniciada",
|
"APPLICATION_STARTED": "Aplicación iniciada",
|
||||||
"LIST_OF_WINDOWS": "Lista de ventanas"
|
"LIST_OF_WINDOWS": "Lista de ventanas",
|
||||||
|
"CLOSE_WINDOW": "Cerrar ventana",
|
||||||
|
"GAME_LIST": {
|
||||||
|
"NO_GAMES_FOUND": "No se encontraron juegos"
|
||||||
|
},
|
||||||
|
"TERMINATE_PROCESS": "Finalizar proceso"
|
||||||
}
|
}
|
||||||
@@ -64,5 +64,10 @@
|
|||||||
"NO_WINDOWS_FOUND": "ウィンドウが見つかりませんでした",
|
"NO_WINDOWS_FOUND": "ウィンドウが見つかりませんでした",
|
||||||
"WINDOW_OPTIONS": "ウィンドウオプション",
|
"WINDOW_OPTIONS": "ウィンドウオプション",
|
||||||
"APPLICATION_STARTED": "アプリケーションが起動しました",
|
"APPLICATION_STARTED": "アプリケーションが起動しました",
|
||||||
"LIST_OF_WINDOWS": "ウィンドウ一覧"
|
"LIST_OF_WINDOWS": "ウィンドウ一覧",
|
||||||
|
"CLOSE_WINDOW": "ウィンドウを閉じる",
|
||||||
|
"GAME_LIST": {
|
||||||
|
"NO_GAMES_FOUND": "ゲームが見つかりませんでした"
|
||||||
|
},
|
||||||
|
"TERMINATE_PROCESS": "プロセスを終了する"
|
||||||
}
|
}
|
||||||
@@ -64,5 +64,10 @@
|
|||||||
"NO_WINDOWS_FOUND": "Nie znaleziono okien",
|
"NO_WINDOWS_FOUND": "Nie znaleziono okien",
|
||||||
"WINDOW_OPTIONS": "Opcje okna",
|
"WINDOW_OPTIONS": "Opcje okna",
|
||||||
"APPLICATION_STARTED": "Aplikacja uruchomiona",
|
"APPLICATION_STARTED": "Aplikacja uruchomiona",
|
||||||
"LIST_OF_WINDOWS": "Lista okien"
|
"LIST_OF_WINDOWS": "Lista okien",
|
||||||
|
"CLOSE_WINDOW": "Zamknij okno",
|
||||||
|
"GAME_LIST": {
|
||||||
|
"NO_GAMES_FOUND": "Nie znaleziono gier"
|
||||||
|
},
|
||||||
|
"TERMINATE_PROCESS": "Zakończ proces"
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,30 @@
|
|||||||
use wgui::{
|
use wgui::{
|
||||||
assets::AssetPath,
|
assets::AssetPath,
|
||||||
parser::{ParseDocumentParams, ParserState},
|
parser::{Fetchable, ParseDocumentParams, ParserState},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::tab::{Tab, TabParams, TabType};
|
use crate::{
|
||||||
|
tab::{Tab, TabParams, TabType},
|
||||||
|
views::game_list,
|
||||||
|
};
|
||||||
|
|
||||||
pub struct TabGames {
|
pub struct TabGames {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub state: ParserState,
|
pub state: ParserState,
|
||||||
|
|
||||||
|
view_game_list: game_list::View,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tab for TabGames {
|
impl Tab for TabGames {
|
||||||
fn get_type(&self) -> TabType {
|
fn get_type(&self) -> TabType {
|
||||||
TabType::Games
|
TabType::Games
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, params: super::TabUpdateParams) -> anyhow::Result<()> {
|
||||||
|
self.view_game_list.update(params.layout, params.interface)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TabGames {
|
impl TabGames {
|
||||||
@@ -28,6 +39,15 @@ impl TabGames {
|
|||||||
params.parent_id,
|
params.parent_id,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(Self { state })
|
let game_list_parent = state.get_widget_id("game_list_parent")?;
|
||||||
|
|
||||||
|
let view_game_list = game_list::View::new(game_list::Params {
|
||||||
|
frontend_tasks: params.frontend_tasks.clone(),
|
||||||
|
globals: params.globals,
|
||||||
|
layout: params.layout,
|
||||||
|
parent_id: game_list_parent,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self { state, view_game_list })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod desktop_finder;
|
pub mod desktop_finder;
|
||||||
pub mod pactl_wrapper;
|
pub mod pactl_wrapper;
|
||||||
pub mod popup_manager;
|
pub mod popup_manager;
|
||||||
|
pub mod steam_utils;
|
||||||
pub mod toast_manager;
|
pub mod toast_manager;
|
||||||
pub mod various;
|
pub mod various;
|
||||||
|
|||||||
408
dash-frontend/src/util/steam_utils.rs
Normal file
408
dash-frontend/src/util/steam_utils.rs
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
use base64::{Engine as _, engine::general_purpose};
|
||||||
|
use keyvalues_parser::{Obj, Vdf};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use steam_shortcuts_util::parse_shortcuts;
|
||||||
|
|
||||||
|
pub struct SteamUtils {
|
||||||
|
steam_root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_steam_root() -> anyhow::Result<PathBuf> {
|
||||||
|
let home = PathBuf::from(std::env::var("HOME")?);
|
||||||
|
|
||||||
|
let steam_paths: [&str; 3] = [
|
||||||
|
".steam/steam",
|
||||||
|
".steam/debian-installation",
|
||||||
|
".var/app/com.valvesoftware.Steam/data/Steam",
|
||||||
|
];
|
||||||
|
let Some(steam_path) = steam_paths
|
||||||
|
.iter()
|
||||||
|
.map(|path| home.join(path))
|
||||||
|
.filter(|p| p.exists())
|
||||||
|
.next()
|
||||||
|
else {
|
||||||
|
anyhow::bail!("Couldn't find Steam installation in search paths");
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(steam_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type AppID = String;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct AppManifest {
|
||||||
|
pub app_id: AppID,
|
||||||
|
pub run_game_id: AppID,
|
||||||
|
pub name: String,
|
||||||
|
pub cover_b64: Option<String>,
|
||||||
|
pub raw_state_flags: u64, // documentation: https://github.com/lutris/lutris/blob/master/docs/steam.rst
|
||||||
|
pub last_played: Option<u64>, // unix timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum GameSortMethod {
|
||||||
|
NameAsc,
|
||||||
|
NameDesc,
|
||||||
|
PlayDateDesc,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_obj_first<'a>(obj: &'a Obj<'_>, key: &str) -> Option<&'a Obj<'a>> {
|
||||||
|
obj.get(key)?.first()?.get_obj()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_str_first<'a>(obj: &'a Obj<'_>, key: &str) -> Option<&'a str> {
|
||||||
|
obj.get(key)?.first()?.get_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vdf_parse_libraryfolders<'a>(vdf_root: &'a Vdf<'a>) -> Option<Vec<AppEntry>> {
|
||||||
|
let obj_libraryfolders = vdf_root.value.get_obj()?;
|
||||||
|
|
||||||
|
let mut res = Vec::<AppEntry>::new();
|
||||||
|
|
||||||
|
let mut num = 0;
|
||||||
|
loop {
|
||||||
|
let Some(library_folder) = get_obj_first(obj_libraryfolders, format!("{}", num).as_str()) else {
|
||||||
|
// no more libraries to find
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(apps) = get_obj_first(library_folder, "apps") else {
|
||||||
|
// no apps?
|
||||||
|
num += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(path) = get_str_first(library_folder, "path") else {
|
||||||
|
// no path?
|
||||||
|
num += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
//log::trace!("path: {}", path);
|
||||||
|
|
||||||
|
res.extend(
|
||||||
|
apps
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item.0.parse::<u64>().ok())
|
||||||
|
.map(|app_id| AppEntry {
|
||||||
|
app_id: app_id.to_string(),
|
||||||
|
root_path: String::from(path),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
num += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vdf_parse_appstate<'a>(app_id: AppID, vdf_root: &'a Vdf<'a>) -> Option<AppManifest> {
|
||||||
|
let app_state_obj = vdf_root.value.get_obj()?;
|
||||||
|
|
||||||
|
let name = app_state_obj.get("name")?.first()?.get_str()?;
|
||||||
|
|
||||||
|
let raw_state_flags = app_state_obj
|
||||||
|
.get("StateFlags")?
|
||||||
|
.first()?
|
||||||
|
.get_str()?
|
||||||
|
.parse::<u64>()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let last_played = match app_state_obj.get("LastPlayed") {
|
||||||
|
Some(s) => Some(s.first()?.get_str()?.parse::<u64>().ok()?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(AppManifest {
|
||||||
|
app_id: app_id.clone(),
|
||||||
|
run_game_id: app_id,
|
||||||
|
cover_b64: None,
|
||||||
|
name: String::from(name),
|
||||||
|
raw_state_flags,
|
||||||
|
last_played,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppEntry {
|
||||||
|
pub root_path: String,
|
||||||
|
pub app_id: AppID,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(app_id: AppID, force_kill: bool) -> anyhow::Result<()> {
|
||||||
|
log::info!("Stopping Steam game with AppID {}", app_id);
|
||||||
|
|
||||||
|
for game in list_running_games()? {
|
||||||
|
if game.app_id != app_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Killing process with PID {} and its children", game.pid);
|
||||||
|
let _ = std::process::Command::new("pkill")
|
||||||
|
.arg(if force_kill { "-9" } else { "-11" })
|
||||||
|
.arg("-P")
|
||||||
|
.arg(format!("{}", game.pid))
|
||||||
|
.spawn()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn launch(app_id: AppID) -> anyhow::Result<()> {
|
||||||
|
log::info!("Launching Steam game with AppID {}", app_id);
|
||||||
|
call_steam(&format!("steam://rungameid/{}", app_id))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RunningGame {
|
||||||
|
pub app_id: AppID,
|
||||||
|
pub pid: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Shortcut {
|
||||||
|
name: String,
|
||||||
|
exe: String,
|
||||||
|
run_game_id: u64,
|
||||||
|
app_id: u64,
|
||||||
|
cover_b64: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_running_games() -> anyhow::Result<Vec<RunningGame>> {
|
||||||
|
let mut res = Vec::<RunningGame>::new();
|
||||||
|
|
||||||
|
let entries = std::fs::read_dir("/proc")?;
|
||||||
|
for entry in entries.into_iter().flatten() {
|
||||||
|
let path_cmdline = entry.path().join("cmdline");
|
||||||
|
let Ok(cmdline) = std::fs::read(path_cmdline) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let proc_file_name = entry.file_name();
|
||||||
|
let Some(pid) = proc_file_name.to_str() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(pid) = pid.parse::<i32>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let args: Vec<&str> = cmdline
|
||||||
|
.split(|byte| *byte == 0x00)
|
||||||
|
.filter_map(|arg| match std::str::from_utf8(arg) {
|
||||||
|
Ok(arg) => Some(arg),
|
||||||
|
Err(_) => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut has_steam_launch = false;
|
||||||
|
for arg in &args {
|
||||||
|
if *arg == "SteamLaunch" {
|
||||||
|
has_steam_launch = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !has_steam_launch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running game process found. Parse AppID
|
||||||
|
for arg in &args {
|
||||||
|
let pat = "AppId=";
|
||||||
|
let Some(pos) = arg.find(pat) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if pos != 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((_, second)) = arg.split_at_checked(pat.len()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(app_id_num) = second.parse::<u64>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// AppID found. Add it to the list
|
||||||
|
res.push(RunningGame {
|
||||||
|
app_id: app_id_num.to_string(),
|
||||||
|
pid,
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_steam(arg: &str) -> anyhow::Result<()> {
|
||||||
|
match std::process::Command::new("xdg-open").arg(arg).spawn() {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(_) => {
|
||||||
|
std::process::Command::new("steam").arg(arg).spawn()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shortcut_to_fake_manifest(shortcut: &Shortcut) -> AppManifest {
|
||||||
|
AppManifest {
|
||||||
|
app_id: shortcut.app_id.to_string(),
|
||||||
|
run_game_id: shortcut.run_game_id.to_string(),
|
||||||
|
name: shortcut.name.clone(),
|
||||||
|
cover_b64: shortcut.cover_b64.clone(),
|
||||||
|
raw_state_flags: 0, // Not applicable for shortcuts, 0 by default
|
||||||
|
last_played: None, // Steam does not use this for shortcuts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_rungameid(app_id: u32) -> u64 {
|
||||||
|
(app_id as u64) << 32 | 0x02000000
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SteamUtils {
|
||||||
|
fn convert_cover_to_base64(app_id: &u32, original_path: &Path) -> std::io::Result<Option<String>> {
|
||||||
|
// List of supported extensions with their MIME types
|
||||||
|
let extensions = [
|
||||||
|
("png", "image/png"),
|
||||||
|
("jpg", "image/jpeg"),
|
||||||
|
("jpeg", "image/jpeg"),
|
||||||
|
("webp", "image/webp"),
|
||||||
|
("bmp", "image/bmp"),
|
||||||
|
("gif", "image/gif"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (ext, mime) in extensions.iter() {
|
||||||
|
let filepath = original_path.join("grid").join(format!("{}p.{}", app_id, ext));
|
||||||
|
if filepath.exists() {
|
||||||
|
let mut file = fs::File::open(&filepath)?;
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
file.read_to_end(&mut buffer)?;
|
||||||
|
|
||||||
|
let base64_string = general_purpose::STANDARD.encode(&buffer);
|
||||||
|
let data_uri = format!("data:{};base64,{}", mime, base64_string);
|
||||||
|
return Ok(Some(data_uri));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_shortcuts(&self) -> Result<Vec<Shortcut>, Box<dyn std::error::Error>> {
|
||||||
|
let userdata_dir = self.steam_root.join("userdata");
|
||||||
|
let user_dirs = fs::read_dir(userdata_dir)?;
|
||||||
|
|
||||||
|
let mut shortcuts: Vec<Shortcut> = Vec::new();
|
||||||
|
|
||||||
|
for user in user_dirs.flatten() {
|
||||||
|
let config_path = user.path().join("config");
|
||||||
|
let shortcut_path = config_path.join("shortcuts.vdf");
|
||||||
|
|
||||||
|
if !shortcut_path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = std::fs::read(&shortcut_path)?;
|
||||||
|
let shortcuts_data = parse_shortcuts(content.as_slice())?;
|
||||||
|
|
||||||
|
for s in shortcuts_data {
|
||||||
|
let run_game_id = compute_rungameid(s.app_id);
|
||||||
|
let cover_base64 = match SteamUtils::convert_cover_to_base64(&s.app_id, &config_path) {
|
||||||
|
Ok(path) => path, // If successful, use the new path
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Error converting cover for app {}: {}", s.app_id, e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
shortcuts.push(Shortcut {
|
||||||
|
name: s.app_name.to_string(),
|
||||||
|
exe: s.exe.to_string(),
|
||||||
|
run_game_id,
|
||||||
|
app_id: s.app_id as u64,
|
||||||
|
cover_b64: cover_base64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(shortcuts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_dir_steamapps(&self) -> PathBuf {
|
||||||
|
self.steam_root.join("steamapps")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new() -> anyhow::Result<Self> {
|
||||||
|
let steam_root = get_steam_root()?;
|
||||||
|
|
||||||
|
Ok(Self { steam_root })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_app_manifest(&self, app_entry: &AppEntry) -> anyhow::Result<AppManifest> {
|
||||||
|
let manifest_path =
|
||||||
|
PathBuf::from(&app_entry.root_path).join(format!("steamapps/appmanifest_{}.acf", app_entry.app_id));
|
||||||
|
|
||||||
|
let vdf_data = std::fs::read_to_string(manifest_path)?;
|
||||||
|
let vdf_root = keyvalues_parser::Vdf::parse(&vdf_data)?;
|
||||||
|
|
||||||
|
let Some(manifest) = vdf_parse_appstate(app_entry.app_id.clone(), &vdf_root) else {
|
||||||
|
anyhow::bail!("Failed to parse AppState");
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_installed_games(&self, sort_method: GameSortMethod) -> anyhow::Result<Vec<AppManifest>> {
|
||||||
|
let path = self.get_dir_steamapps().join("libraryfolders.vdf");
|
||||||
|
let vdf_data = std::fs::read_to_string(path)?;
|
||||||
|
|
||||||
|
let vdf_root = keyvalues_parser::Vdf::parse(&vdf_data)?;
|
||||||
|
|
||||||
|
let Some(apps) = vdf_parse_libraryfolders(&vdf_root) else {
|
||||||
|
anyhow::bail!("Failed to fetch installed Steam apps");
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut games: Vec<AppManifest> = apps
|
||||||
|
.iter()
|
||||||
|
.filter_map(|app_entry| {
|
||||||
|
let manifest = match self.get_app_manifest(app_entry) {
|
||||||
|
Ok(manifest) => manifest,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to get app manifest for AppID {}: {}", app_entry.app_id, e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some(manifest)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if let Ok(shortcuts) = self.list_shortcuts() {
|
||||||
|
let mut fake_manifests = shortcuts
|
||||||
|
.iter()
|
||||||
|
.map(shortcut_to_fake_manifest)
|
||||||
|
.collect::<Vec<AppManifest>>();
|
||||||
|
games.append(&mut fake_manifests);
|
||||||
|
} else {
|
||||||
|
log::error!("Failed to read non-Steam shortcuts");
|
||||||
|
}
|
||||||
|
|
||||||
|
match sort_method {
|
||||||
|
GameSortMethod::NameAsc => {
|
||||||
|
games.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
}
|
||||||
|
GameSortMethod::NameDesc => {
|
||||||
|
games.sort_by(|a, b| b.name.cmp(&a.name));
|
||||||
|
}
|
||||||
|
GameSortMethod::PlayDateDesc => {
|
||||||
|
games.sort_by(|a, b| b.last_played.cmp(&a.last_played));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(games)
|
||||||
|
}
|
||||||
|
}
|
||||||
301
dash-frontend/src/views/game_list.rs
Normal file
301
dash-frontend/src/views/game_list.rs
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
|
use wayvr_ipc::packet_server::{self, WvrWindowHandle};
|
||||||
|
use wgui::{
|
||||||
|
assets::AssetPath,
|
||||||
|
components::{
|
||||||
|
self,
|
||||||
|
button::ComponentButton,
|
||||||
|
tooltip::{TooltipInfo, TooltipSide},
|
||||||
|
},
|
||||||
|
drawing::{self, GradientMode},
|
||||||
|
globals::WguiGlobals,
|
||||||
|
i18n::Translation,
|
||||||
|
layout::{Layout, WidgetID, WidgetPair},
|
||||||
|
parser::{Fetchable, ParseDocumentParams, ParserState},
|
||||||
|
taffy::{
|
||||||
|
self,
|
||||||
|
prelude::{length, percent},
|
||||||
|
},
|
||||||
|
widget::{
|
||||||
|
ConstructEssentials,
|
||||||
|
label::{WidgetLabel, WidgetLabelParams},
|
||||||
|
rectangle,
|
||||||
|
util::WLength,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use wlx_common::dash_interface::BoxDashInterface;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
frontend::{FrontendTask, FrontendTasks},
|
||||||
|
task::Tasks,
|
||||||
|
util::{
|
||||||
|
popup_manager::MountPopupParams,
|
||||||
|
steam_utils::{self, SteamUtils},
|
||||||
|
},
|
||||||
|
views::window_options,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum Task {
|
||||||
|
AppManifestClicked(steam_utils::AppManifest),
|
||||||
|
Refresh,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Params<'a> {
|
||||||
|
pub globals: &'a WguiGlobals,
|
||||||
|
pub frontend_tasks: FrontendTasks,
|
||||||
|
pub layout: &'a mut Layout,
|
||||||
|
pub parent_id: WidgetID,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct View {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub parser_state: ParserState,
|
||||||
|
tasks: Tasks<Task>,
|
||||||
|
frontend_tasks: FrontendTasks,
|
||||||
|
globals: WguiGlobals,
|
||||||
|
id_list_parent: WidgetID,
|
||||||
|
steam_utils: steam_utils::SteamUtils,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl View {
|
||||||
|
pub fn new(params: Params) -> anyhow::Result<Self> {
|
||||||
|
let doc_params = &ParseDocumentParams {
|
||||||
|
globals: params.globals.clone(),
|
||||||
|
path: AssetPath::BuiltIn("gui/view/game_list.xml"),
|
||||||
|
extra: Default::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let parser_state = wgui::parser::parse_from_assets(doc_params, params.layout, params.parent_id)?;
|
||||||
|
let list_parent = parser_state.fetch_widget(¶ms.layout.state, "list_parent")?;
|
||||||
|
|
||||||
|
let tasks = Tasks::new();
|
||||||
|
|
||||||
|
let steam_utils = SteamUtils::new()?;
|
||||||
|
|
||||||
|
tasks.push(Task::Refresh);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
parser_state,
|
||||||
|
tasks,
|
||||||
|
frontend_tasks: params.frontend_tasks,
|
||||||
|
globals: params.globals.clone(),
|
||||||
|
id_list_parent: list_parent.id,
|
||||||
|
steam_utils,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&mut self, layout: &mut Layout, interface: &mut BoxDashInterface) -> anyhow::Result<()> {
|
||||||
|
loop {
|
||||||
|
let tasks = self.tasks.drain();
|
||||||
|
if tasks.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for task in tasks {
|
||||||
|
match task {
|
||||||
|
Task::Refresh => self.refresh(layout, interface)?,
|
||||||
|
Task::AppManifestClicked(manifest) => self.action_app_manifest_clicked(manifest)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Games {
|
||||||
|
manifests: Vec<steam_utils::AppManifest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const BORDER_COLOR_DEFAULT: drawing::Color = drawing::Color::new(1.0, 1.0, 1.0, 0.3);
|
||||||
|
const BORDER_COLOR_HOVERED: drawing::Color = drawing::Color::new(1.0, 1.0, 1.0, 1.0);
|
||||||
|
|
||||||
|
const GAME_COVER_SIZE_X: f32 = 140.0;
|
||||||
|
const GAME_COVER_SIZE_Y: f32 = 210.0;
|
||||||
|
|
||||||
|
pub fn construct_game_cover(
|
||||||
|
ess: &mut ConstructEssentials,
|
||||||
|
globals: &WguiGlobals,
|
||||||
|
manifest: &steam_utils::AppManifest,
|
||||||
|
) -> anyhow::Result<(WidgetPair, Rc<ComponentButton>)> {
|
||||||
|
let (widget_button, button) = components::button::construct(
|
||||||
|
ess,
|
||||||
|
components::button::Params {
|
||||||
|
color: Some(drawing::Color::new(1.0, 1.0, 1.0, 0.1)),
|
||||||
|
border_color: Some(BORDER_COLOR_DEFAULT),
|
||||||
|
hover_border_color: Some(BORDER_COLOR_HOVERED),
|
||||||
|
round: WLength::Units(12.0),
|
||||||
|
border: 2.0,
|
||||||
|
tooltip: Some(TooltipInfo {
|
||||||
|
side: TooltipSide::Bottom,
|
||||||
|
text: Translation::from_raw_text(&manifest.name),
|
||||||
|
}),
|
||||||
|
style: taffy::Style {
|
||||||
|
position: taffy::Position::Relative,
|
||||||
|
align_items: Some(taffy::AlignItems::Center),
|
||||||
|
justify_content: Some(taffy::JustifyContent::Center),
|
||||||
|
size: taffy::Size {
|
||||||
|
width: length(GAME_COVER_SIZE_X),
|
||||||
|
height: length(GAME_COVER_SIZE_Y),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let rect_gradient = |color: drawing::Color, color2: drawing::Color| {
|
||||||
|
rectangle::WidgetRectangle::create(rectangle::WidgetRectangleParams {
|
||||||
|
color,
|
||||||
|
color2,
|
||||||
|
round: WLength::Units(12.0),
|
||||||
|
gradient: GradientMode::Vertical,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let rect_gradient_style = |align_self: taffy::AlignSelf, height: f32| taffy::Style {
|
||||||
|
position: taffy::Position::Absolute,
|
||||||
|
align_self: Some(align_self),
|
||||||
|
size: taffy::Size {
|
||||||
|
width: percent(1.0),
|
||||||
|
height: percent(height),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// top shine
|
||||||
|
ess.layout.add_child(
|
||||||
|
widget_button.id,
|
||||||
|
rect_gradient(
|
||||||
|
drawing::Color::new(1.0, 1.0, 1.0, 0.25),
|
||||||
|
drawing::Color::new(1.0, 1.0, 1.0, 0.0),
|
||||||
|
),
|
||||||
|
rect_gradient_style(taffy::AlignSelf::Baseline, 0.05),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// top white gradient
|
||||||
|
ess.layout.add_child(
|
||||||
|
widget_button.id,
|
||||||
|
rect_gradient(
|
||||||
|
drawing::Color::new(1.0, 1.0, 1.0, 0.2),
|
||||||
|
drawing::Color::new(1.0, 1.0, 1.0, 0.0),
|
||||||
|
),
|
||||||
|
rect_gradient_style(taffy::AlignSelf::Baseline, 0.5),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// bottom black gradient
|
||||||
|
ess.layout.add_child(
|
||||||
|
widget_button.id,
|
||||||
|
rect_gradient(
|
||||||
|
drawing::Color::new(0.0, 0.0, 0.0, 0.0),
|
||||||
|
drawing::Color::new(0.0, 0.0, 0.0, 0.2),
|
||||||
|
),
|
||||||
|
rect_gradient_style(taffy::AlignSelf::End, 0.5),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// bottom shadow
|
||||||
|
ess.layout.add_child(
|
||||||
|
widget_button.id,
|
||||||
|
rect_gradient(
|
||||||
|
drawing::Color::new(0.0, 0.0, 0.0, 0.0),
|
||||||
|
drawing::Color::new(0.0, 0.0, 0.0, 0.2),
|
||||||
|
),
|
||||||
|
rect_gradient_style(taffy::AlignSelf::End, 0.05),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok((widget_button, button))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_game_list(
|
||||||
|
globals: &WguiGlobals,
|
||||||
|
ess: &mut ConstructEssentials,
|
||||||
|
interface: &mut BoxDashInterface,
|
||||||
|
games: &Games,
|
||||||
|
tasks: &Tasks<Task>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
for manifest in &games.manifests {
|
||||||
|
let (_, button) = construct_game_cover(ess, globals, manifest)?;
|
||||||
|
|
||||||
|
button.on_click({
|
||||||
|
let tasks = tasks.clone();
|
||||||
|
let manifest = manifest.clone();
|
||||||
|
Box::new(move |_, _| {
|
||||||
|
tasks.push(Task::AppManifestClicked(manifest.clone()));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl View {
|
||||||
|
fn game_list(&self) -> anyhow::Result<Games> {
|
||||||
|
let manifests = self
|
||||||
|
.steam_utils
|
||||||
|
.list_installed_games(steam_utils::GameSortMethod::PlayDateDesc)?;
|
||||||
|
|
||||||
|
Ok(Games { manifests })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh(&mut self, layout: &mut Layout, interface: &mut BoxDashInterface) -> anyhow::Result<()> {
|
||||||
|
layout.remove_children(self.id_list_parent);
|
||||||
|
|
||||||
|
let mut text: Option<Translation> = None;
|
||||||
|
match self.game_list() {
|
||||||
|
Ok(list) => {
|
||||||
|
if list.manifests.is_empty() {
|
||||||
|
text = Some(Translation::from_translation_key("GAME_LIST.NO_GAMES_FOUND"))
|
||||||
|
} else {
|
||||||
|
fill_game_list(
|
||||||
|
&self.globals,
|
||||||
|
&mut ConstructEssentials {
|
||||||
|
layout,
|
||||||
|
parent: self.id_list_parent,
|
||||||
|
},
|
||||||
|
interface,
|
||||||
|
&list,
|
||||||
|
&self.tasks,
|
||||||
|
)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => text = Some(Translation::from_raw_text(&format!("Error: {:?}", e))),
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(text) = text.take() {
|
||||||
|
layout.add_child(
|
||||||
|
self.id_list_parent,
|
||||||
|
WidgetLabel::create(
|
||||||
|
&mut self.globals.get(),
|
||||||
|
WidgetLabelParams {
|
||||||
|
content: text,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Default::default(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action_app_manifest_clicked(&mut self, manifest: steam_utils::AppManifest) -> anyhow::Result<()> {
|
||||||
|
self.frontend_tasks.push(FrontendTask::MountPopup(MountPopupParams {
|
||||||
|
title: Translation::from_raw_text(&manifest.name),
|
||||||
|
on_content: {
|
||||||
|
let frontend_tasks = self.frontend_tasks.clone();
|
||||||
|
let globals = self.globals.clone();
|
||||||
|
let tasks = self.tasks.clone();
|
||||||
|
|
||||||
|
Rc::new(move |data| {
|
||||||
|
// todo
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod app_launcher;
|
pub mod app_launcher;
|
||||||
pub mod audio_settings;
|
pub mod audio_settings;
|
||||||
|
pub mod game_list;
|
||||||
pub mod process_list;
|
pub mod process_list;
|
||||||
pub mod window_list;
|
pub mod window_list;
|
||||||
pub mod window_options;
|
pub mod window_options;
|
||||||
|
|||||||
Reference in New Issue
Block a user