use std::{
fs, io,
path::{Path, PathBuf},
};
use crate::{
error::{ParseError, ParseErrorKind},
Error, Result,
};
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct Shortcut {
pub app_id: u32,
pub app_name: String,
pub executable: String,
pub start_dir: String,
}
impl Shortcut {
pub fn new(app_id: u32, app_name: String, executable: String, start_dir: String) -> Self {
Self {
app_id,
app_name,
executable,
start_dir,
}
}
pub fn steam_id(&self) -> u64 {
let executable = self.executable.as_bytes();
let app_name = self.app_name.as_bytes();
let algorithm = crc::Crc::<u32>::new(&crc::CRC_32_ISO_HDLC);
let mut digest = algorithm.digest();
digest.update(executable);
digest.update(app_name);
let top = digest.finalize() | 0x80000000;
((top as u64) << 32) | 0x02000000
}
}
pub struct Iter {
dir: PathBuf,
read_dir: fs::ReadDir,
pending: std::vec::IntoIter<Shortcut>,
}
impl Iter {
pub(crate) fn new(steam_dir: &Path) -> Result<Self> {
let user_data = steam_dir.join("userdata");
if !user_data.is_dir() {
return Err(Error::parse(
ParseErrorKind::Shortcut,
ParseError::missing(),
&user_data,
));
}
let read_dir = fs::read_dir(&user_data).map_err(|io| Error::io(io, &user_data))?;
Ok(Self {
dir: user_data,
read_dir,
pending: Vec::new().into_iter(),
})
}
}
impl Iterator for Iter {
type Item = Result<Shortcut>;
fn next(&mut self) -> Option<Self::Item> {
let item = loop {
if let Some(shortcut) = self.pending.next() {
break Ok(shortcut);
}
let maybe_entry = self.read_dir.next()?;
match maybe_entry {
Ok(entry) => {
let shortcuts_path = entry.path().join("config").join("shortcuts.vdf");
match fs::read(&shortcuts_path) {
Ok(contents) => {
if let Some(shortcuts) = parse_shortcuts(&contents) {
self.pending = shortcuts.into_iter();
continue;
} else {
break Err(Error::parse(
ParseErrorKind::Shortcut,
ParseError::unexpected_structure(),
&shortcuts_path,
));
}
}
Err(err) => {
if err.kind() == io::ErrorKind::NotFound {
continue;
} else {
break Err(Error::io(err, &shortcuts_path));
}
}
}
}
Err(err) => break Err(Error::io(err, &self.dir)),
}
};
Some(item)
}
}
fn parse_shortcuts(contents: &[u8]) -> Option<Vec<Shortcut>> {
let mut shortcuts = Vec::new();
let mut factory = ShortcutFactory::default();
for maybe_field_start in contents
.iter()
.enumerate()
.filter(|(_, b)| [1, 2].contains(b))
.map(|(i, _)| i)
{
let kind = contents[maybe_field_start];
let null_at = contents[1 + maybe_field_start..]
.iter()
.enumerate()
.filter(|(_, &b)| b == 0)
.map(|(i, _)| i)
.next()?;
let (field, the_rest) = contents[1 + maybe_field_start..].split_at(null_at);
let the_rest = &the_rest[1..];
match kind {
1 if field.eq_ignore_ascii_case(b"appname") => {
let name = the_rest.split(|&b| b == 0).next()?;
let name = String::from_utf8_lossy(name).into_owned();
if let Some(res) = factory.set_app_name(name) {
match res {
Ok(shortcut) => shortcuts.push(shortcut),
Err(_) => return None,
}
}
}
1 if field.eq_ignore_ascii_case(b"exe") => {
let exe = the_rest.split(|&b| b == 0).next()?;
let exe = String::from_utf8_lossy(exe).into_owned();
if let Some(res) = factory.set_executable(exe) {
match res {
Ok(shortcut) => shortcuts.push(shortcut),
Err(_) => return None,
}
}
}
1 if field.eq_ignore_ascii_case(b"startdir") => {
let dir = the_rest.split(|&b| b == 0).next()?;
let dir = String::from_utf8_lossy(dir).into_owned();
if let Some(res) = factory.set_start_dir(dir) {
match res {
Ok(shortcut) => shortcuts.push(shortcut),
Err(_) => return None,
}
}
}
2 if field.eq_ignore_ascii_case(b"appid") => {
let bytes = the_rest.get(..4)?.try_into().unwrap();
let app_id = u32::from_le_bytes(bytes);
if let Some(res) = factory.set_app_id(app_id) {
match res {
Ok(shortcut) => shortcuts.push(shortcut),
Err(_) => return None,
}
}
}
_ => {}
}
}
(factory == ShortcutFactory::default()).then_some(shortcuts)
}
struct TooMany;
type FieldResult = Option<std::result::Result<Shortcut, TooMany>>;
#[derive(Debug, Default, PartialEq, Eq)]
struct ShortcutFactory {
app_id: Option<u32>,
app_name: Option<String>,
executable: Option<String>,
start_dir: Option<String>,
}
impl ShortcutFactory {
fn set_app_id(&mut self, id: u32) -> FieldResult {
let was = self.app_id.replace(id);
if was.is_some() {
Some(Err(TooMany))
} else {
self.finish().map(Ok)
}
}
fn set_app_name(&mut self, name: String) -> FieldResult {
let was = self.app_name.replace(name);
if was.is_some() {
Some(Err(TooMany))
} else {
self.finish().map(Ok)
}
}
fn set_executable(&mut self, exe: String) -> FieldResult {
let was = self.executable.replace(exe);
if was.is_some() {
Some(Err(TooMany))
} else {
self.finish().map(Ok)
}
}
fn set_start_dir(&mut self, dir: String) -> FieldResult {
let was = self.start_dir.replace(dir);
if was.is_some() {
Some(Err(TooMany))
} else {
self.finish().map(Ok)
}
}
fn finish(&mut self) -> Option<Shortcut> {
match self {
Self {
app_id: Some(app_id),
app_name: Some(app_name),
executable: Some(executable),
start_dir: Some(start_dir),
} => {
let shortcut = Shortcut {
app_id: *app_id,
app_name: app_name.to_owned(),
executable: executable.to_owned(),
start_dir: start_dir.to_owned(),
};
std::mem::take(self);
Some(shortcut)
}
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanity() {
let contents = include_bytes!("../tests/assets/shortcuts.vdf");
let shortcuts = parse_shortcuts(contents).unwrap();
assert_eq!(
shortcuts,
vec![
Shortcut {
app_id: 2786274309,
app_name: "Anki".into(),
executable: "\"anki\"".into(),
start_dir: "\"./\"".into(),
},
Shortcut {
app_id: 2492174738,
app_name: "LibreOffice Calc".into(),
executable: "\"libreoffice\"".into(),
start_dir: "\"./\"".into(),
},
Shortcut {
app_id: 3703025501,
app_name: "foo.sh".into(),
executable: "\"/usr/local/bin/foo.sh\"".into(),
start_dir: "\"/usr/local/bin/\"".into(),
}
],
);
let steam_ids: Vec<_> = shortcuts
.iter()
.map(|shortcut| shortcut.steam_id())
.collect();
assert_eq!(
steam_ids,
[0xe89614fe02000000, 0xdb01c79902000000, 0x9d55017302000000,]
);
}
#[test]
fn different_case() {
let contents = include_bytes!("../tests/assets/shortcuts_different_key_case.vdf");
let shortcuts = parse_shortcuts(contents).unwrap();
assert_eq!(
shortcuts,
vec![Shortcut {
app_id: 2931025216,
app_name: "Second Life".into(),
executable: "\"/Applications/Second Life Viewer.app\"".into(),
start_dir: "\"/Applications/\"".into(),
}]
);
}
#[test]
fn different_order() {
let contents = include_bytes!("../tests/assets/shortcuts_different_order.vdf");
let shortcuts = parse_shortcuts(contents).unwrap();
assert_eq!(
shortcuts,
vec![Shortcut {
app_id: 2797129511,
app_name: "The Wolf Among Us".into(),
executable: "\"/opt/Heroic/heroic\"".into(),
start_dir: "\"/home/spencer\"".into(),
}]
);
}
}