dotfiles/pkgs/rofi-steam-game-list/src/main.rs

451 lines
14 KiB
Rust

//! I tried to create a proper parser, but those abstractions turned out to be not-so-zero cost!
//! Here's a simple version instead
#![allow(clippy::similar_names)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::needless_pass_by_value)]
use fork::daemon;
use std::collections::{HashMap, HashSet};
use std::io::{self, prelude::*};
use std::time::{Duration, SystemTime};
fn read_file(p: impl AsRef<std::path::Path>) -> io::Result<Vec<u8>> {
let p = p.as_ref().to_owned();
let mut vec = Vec::new();
let mut file = std::fs::File::open(p)?;
file.read_to_end(&mut vec)?;
Ok(vec)
}
fn read_file_s(p: impl AsRef<std::path::Path>) -> io::Result<String> {
let p = p.as_ref().to_owned();
let mut s = String::new();
let mut file = std::fs::File::open(p)?;
std::io::Read::read_to_string(&mut file, &mut s)?;
Ok(s)
}
fn write_file(p: impl AsRef<std::path::Path>, data: Vec<u8>) -> io::Result<()> {
let p = p.as_ref().to_owned();
let mut file = std::fs::File::create(p)?;
std::io::Write::write_all(&mut file, &data)
}
#[derive(Clone, Debug, PartialEq)]
pub struct AppInfoEntry {
pub app_id: u32,
pub info_state: u32,
pub last_updated: u32,
pub pics_token: u64,
pub text_vdf_sha1: [u8; 20],
pub change_number: u32,
pub info: HashMap<Vec<u8>, Value>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct AppInfo {
pub magic: u32,
pub universe: u32,
pub entries: Vec<AppInfoEntry>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Value {
Map(HashMap<Vec<u8>, Value>),
String(Vec<u8>),
}
#[allow(clippy::missing_const_for_fn)]
impl Value {
fn into_map(self) -> Option<HashMap<Vec<u8>, Self>> {
if let Self::Map(map) = self {
Some(map)
} else {
None
}
}
fn into_string(self) -> Option<Vec<u8>> {
if let Self::String(s) = self {
Some(s)
} else {
None
}
}
}
fn read_map(reader: &mut impl io::Read) -> io::Result<HashMap<Vec<u8>, Value>> {
let mut ret = HashMap::new();
let mut buf = [0u8];
let mut buf2 = [0u8; 2];
let mut buf4 = [0u8; 4];
let mut buf8 = [0u8; 8];
loop {
reader.read_exact(&mut buf)?;
let kind = buf[0];
if kind == 8 || kind == 11 {
break Ok(ret);
}
let mut key = vec![];
loop {
reader.read_exact(&mut buf)?;
if buf[0] == 0 {
break;
}
key.push(buf[0]);
}
#[allow(clippy::match_same_arms)]
match kind {
0 => {
ret.insert(key, Value::Map(read_map(reader)?));
}
1 => {
let mut s = vec![];
loop {
reader.read_exact(&mut buf)?;
if buf[0] == 0 {
break;
}
s.push(buf[0]);
}
ret.insert(key, Value::String(s));
}
2 => {
reader.read_exact(&mut buf4)?;
// ret.insert(key, Value::I32(i32::from_le_bytes(buf4)))?;
}
3 => {
reader.read_exact(&mut buf4)?;
// ret.insert(key, Value::F32(f32::from_le_bytes(buf4)))?;
}
4 => {
reader.read_exact(&mut buf4)?;
// ret.insert(key, Value::Pointer(i32::from_le_bytes(buf4)))?;
}
5 => {
let mut s = vec![0u16; 2];
loop {
reader.read_exact(&mut buf2)?;
if buf2 == [0u8, 0u8] {
break;
}
s.extend_from_slice(&[u16::from_le_bytes(buf2)]);
}
// utf-8 is used instead of utf-16 here
// ret.insert(key, Value::WideString(s))?;
}
7 => {
reader.read_exact(&mut buf8)?;
// ret.insert(key, Value::U64(u64::from_le_bytes(buf8)))?;
}
10 => {
reader.read_exact(&mut buf8)?;
// ret.insert(key, Value::I64(i64::from_le_bytes(buf8)))?;
}
n => panic!("invalid vdf data type: {n}"),
}
}
}
fn read_app_info(reader: &mut impl io::Read) -> io::Result<AppInfo> {
let mut buf4 = [0u8; 4];
// let mut buf8 = [0u8; 8];
// let mut buf20 = [0u8; 20];
let mut buf64 = [0u8; 64];
reader.read_exact(&mut buf4)?;
assert_eq!(buf4, [0x28, 0x44, 0x56, 0x07]);
reader.read_exact(&mut buf4)?;
assert_eq!(u32::from_le_bytes(buf4), 1);
let mut ret = AppInfo {
magic: 0x0756_4428,
universe: 1,
entries: vec![],
};
loop {
reader.read_exact(&mut buf4)?;
let app_id = u32::from_le_bytes(buf4);
if app_id == 0 {
break Ok(ret);
}
let mut entry = AppInfoEntry {
app_id,
info_state: 0,
last_updated: 0,
pics_token: 0,
text_vdf_sha1: [0u8; 20],
change_number: 0,
info: HashMap::new(),
};
reader.read_exact(&mut buf64[..4 * 3 + 8 + 20 + 4 + 20])?;
// reader.read_exact(&mut buf4)?;
// size
// reader.read_exact(&mut buf4)?;
// entry.info_state = u32::from_le_bytes(buf4);
// reader.read_exact(&mut buf4)?;
// entry.last_updated = u32::from_le_bytes(buf4);
// reader.read_exact(&mut buf8)?;
// entry.pics_token = u64::from_le_bytes(buf8);
// reader.read_exact(&mut buf20)?;
// entry.text_vdf_sha1 = buf20;
// reader.read_exact(&mut buf4)?;
// entry.change_number = u32::from_le_bytes(buf4);
// reader.read_exact(&mut buf20)?;
// bin sha1
entry.info = read_map(reader)?;
ret.entries.push(entry);
}
}
fn home() -> String {
std::env::var("HOME").unwrap()
}
fn xdg_home() -> String {
std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| home() + "/.local/share")
}
fn xdg_cache() -> String {
std::env::var("XDG_CACHE_HOME").unwrap_or_else(|_| home() + "/.cache")
}
fn cache_dir() -> String {
let dir = xdg_cache() + "/rofi-steam-game-list";
let _ = std::fs::create_dir_all(&dir);
dir
}
fn history_dir() -> String {
let dir = xdg_home() + "/rofi-steam-game-list";
let _ = std::fs::create_dir_all(&dir);
dir
}
fn history(k: &str) -> HashMap<u32, u32> {
let dir = history_dir();
let mut ret = HashMap::new();
let Ok(data) = read_file(dir + "/history_" + k) else {
return ret;
};
if data.len() < 8 {
return ret;
}
let count = u32::from_le_bytes(data[4..8].try_into().unwrap());
let data = &mut &data[8..];
let mut buf4 = [0u8; 4];
for _ in 0..count {
if std::io::Read::read_exact(data, &mut buf4).is_err() {
return ret;
}
let k = u32::from_le_bytes(buf4);
if std::io::Read::read_exact(data, &mut buf4).is_err() {
return ret;
}
let v = u32::from_le_bytes(buf4);
ret.insert(k, v);
}
ret
}
fn write_history(m: &HashMap<u32, u32>, k: &str) {
let dir = history_dir();
let mut data = vec![];
data.extend_from_slice(&[0; 4]);
data.extend_from_slice(&(m.len() as u32).to_le_bytes());
for (k, v) in m.iter() {
data.extend_from_slice(&k.to_le_bytes());
data.extend_from_slice(&v.to_le_bytes());
}
let _ = write_file(dir + "/history_" + k, data);
}
fn read_time(s: String) -> io::Result<SystemTime> {
std::fs::metadata(s + "/Steam/appcache/appinfo.vdf")?.modified()
}
fn read_appinfo(target_type: &str, s: String) -> io::Result<(SystemTime, Vec<(u32, String)>)> {
let time = read_time(s.clone())?;
let vec = read_file(s + "/Steam/appcache/appinfo.vdf")?;
let data = read_app_info(&mut &vec[..])?;
let mut ret = Vec::new();
let target_types = target_type.split(',').collect::<HashSet<_>>();
for mut info in data.entries {
if let Some(mut x) = info
.info
.remove(&b"appinfo"[..])
.and_then(Value::into_map)
.and_then(|mut x| x.remove(&b"common"[..]))
.and_then(Value::into_map)
{
if let Some(mut t) = x
.remove(&b"type"[..])
.and_then(Value::into_string)
.and_then(|x| String::from_utf8(x).ok())
{
if let Some(n) = x
.remove(&b"name"[..])
.and_then(Value::into_string)
.and_then(|x| String::from_utf8(x).ok())
{
t.make_ascii_lowercase();
if target_types.contains(t.as_str()) {
ret.push((info.app_id, n));
}
}
}
}
}
Ok((time, ret))
}
fn list_appids(s: &str) -> HashSet<u32> {
let Ok(data) = read_file_s(s.to_owned() + "/Steam/steamapps/libraryfolders.vdf") else {
return HashSet::new();
};
let ret = keyvalues_parser::Vdf::parse(&data)
.unwrap()
.value
.get_obj()
.unwrap()
.values()
.flat_map(|x| {
x.iter().flat_map(|x| {
x.get_obj().unwrap().get("apps").into_iter().flat_map(|x| {
x.iter().flat_map(|x| {
x.get_obj()
.unwrap()
.keys()
.filter_map(|x| x.parse::<u32>().ok())
})
})
})
})
.collect::<HashSet<u32>>();
ret
}
fn read_cache(k: &str) -> io::Result<(SystemTime, Vec<(u32, String)>)> {
let path = cache_dir() + "/type_" + k;
let mut file = std::fs::File::open(path)?;
let mut data = Vec::new();
file.read_to_end(&mut data)?;
if data.len() >= 16 {
let (_, data) = data.split_at(4);
let (first, data) = data.split_at(8);
let time = SystemTime::UNIX_EPOCH
+ Duration::from_millis(u64::from_le_bytes(first.try_into().unwrap()));
let (first, data) = data.split_at(4);
let count = u32::from_le_bytes(first.try_into().unwrap());
let data = &mut &data[..];
let mut buf4 = [0u8; 4];
let mut buf1 = [0u8; 1];
let mut ret = Vec::with_capacity(count as usize);
for _ in 0..count {
std::io::Read::read_exact(data, &mut buf4)?;
std::io::Read::read_exact(data, &mut buf1)?;
let len = if buf1[0] == 255 {
std::io::Read::read_exact(data, &mut buf1)?;
255 + buf1[0] as usize
} else {
buf1[0] as usize
};
let mut buf = vec![0; len];
std::io::Read::read_exact(data, &mut buf)?;
if let Ok(s) = String::from_utf8(buf) {
ret.push((u32::from_le_bytes(buf4), s));
}
}
Ok((time, ret))
} else {
Err(std::io::Error::new(
std::io::ErrorKind::Other,
"invalid app id cache format",
))
}
}
fn write_cache(k: &str, time: SystemTime, ids: &[(u32, String)]) {
let mut data = Vec::new();
data.extend_from_slice(&[0; 4]);
data.extend_from_slice(
&(time
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as u64)
.to_le_bytes(),
);
data.extend_from_slice(&(ids.len() as u32).to_le_bytes());
for (id, s) in ids {
if s.len() > u8::MAX as usize + u8::MAX as usize {
continue;
}
data.extend_from_slice(&id.to_le_bytes());
if s.len() > u8::MAX as usize {
data.extend_from_slice(&255u8.to_le_bytes());
data.extend_from_slice(&((s.len() - u8::MAX as usize) as u8).to_le_bytes());
} else {
data.extend_from_slice(&(s.len() as u8).to_le_bytes());
}
data.extend_from_slice(s.as_bytes());
}
let path = cache_dir() + "/type_" + k;
if let Ok(mut file) = std::fs::File::create(path) {
let _ = file.write_all(&data);
}
}
fn main() {
let target_type = std::env::var("STEAM_GAME_LIST_TYPE").map_or_else(
|_| "game,application".to_owned(),
|mut x| {
x.make_ascii_lowercase();
x
},
);
if let Ok(appid) = std::env::var("ROFI_INFO") {
let _ = daemon(true, false);
let mut cmd = std::process::Command::new("xdg-open")
.arg(&format!("steam://rungameid/{appid}"))
.spawn()
.unwrap();
if let Ok(x) = appid.parse::<u32>() {
let mut history = history(&target_type);
history.entry(x).and_modify(|curr| *curr += 1).or_insert(1);
write_history(&history, &target_type);
}
let _ = cmd.wait();
return;
}
let xdg_home = xdg_home();
let target_type2 = target_type.clone();
let target_type3 = target_type.clone();
let history_thread = std::thread::spawn(move || history(&target_type2));
let cache_thread = std::thread::spawn(move || {
read_cache(&target_type3).ok()
});
let installed_games = list_appids(&xdg_home);
let history = history_thread.join().unwrap();
let mut time1 = None;
if let Some((time, app_info)) = cache_thread.join().ok().flatten() {
time1 = Some(time);
let mut app_info_2 = app_info
.iter()
.filter_map(|x| {
if installed_games.contains(&x.0) {
Some(x.clone())
} else {
None
}
})
.collect::<Vec<_>>();
app_info_2.sort_by_key(|x| u32::MAX - history.get(&x.0).unwrap_or(&0));
let mut stdout = std::io::stdout().lock();
for (app_id, n) in &app_info_2 {
let icon = format!("{xdg_home}/Steam/appcache/librarycache/{app_id}_icon.jpg");
if std::fs::metadata(&icon).is_ok() {
writeln!(stdout, "{n}\0info\x1f{app_id}\x1ficon\x1f{icon}").unwrap();
} else {
writeln!(stdout, "{n}\0info\x1f{app_id}").unwrap();
}
}
}
let _ = daemon(true, false);
if matches!(time1, Some(time) if read_time(xdg_home.clone()).unwrap() <= time) {
return;
}
if let Ok((time, app_info)) = read_appinfo(&target_type, xdg_home) {
write_cache(&target_type, time, &app_info);
}
}