libeidolon/
eidolon.rs

1extern crate regex;
2#[macro_use]
3extern crate serde_derive;
4extern crate butlerd;
5extern crate dirs;
6use butlerd::Butler;
7use config::*;
8extern crate serde_json;
9use std::fs::{DirEntry, OpenOptions};
10use std::io::{prelude::*, Read};
11use std::process::Command;
12use std::{env, fmt, fs, io};
13/// Represents a game registered in eidolon
14#[derive(Serialize, Deserialize, Debug)]
15pub struct Game {
16    pub command: String,
17    pub pname: String,
18    pub name: String,
19    pub typeg: games::GameType,
20}
21
22/// Module for working directly with the game registry
23pub mod games {
24    use self::GameType::*;
25    use crate::{helper::*, *};
26    /// An Enum for the different types of games Eidolon can support
27    #[derive(Serialize, Deserialize, Debug, PartialEq)]
28    #[serde(rename_all = "lowercase")]
29    pub enum GameType {
30        Itch,
31        Steam,
32        Lutris,
33        Exe,
34        Dolphin,
35        WyvernGOG,
36    }
37    impl fmt::Display for GameType {
38        fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
39            write!(f, "{:?}", self)
40        }
41    }
42    /// Checks game registry for old-formatted games, and attempts to convert them
43    pub fn check_games() {
44        let games = get_games();
45        for game in games {
46            let m = fs::metadata(gd() + &game);
47            if m.is_ok() {
48                if m.unwrap().is_dir() {
49                    let mut command = String::new();
50                    fs::File::open(gd() + &game + "/start")
51                        .unwrap()
52                        .read_to_string(&mut command)
53                        .unwrap();
54                    let mut commandl = command.lines();
55                    commandl.next().unwrap();
56                    let mut command = commandl.next().unwrap().to_string();
57                    let mut typeg = Exe;
58                    if command.contains("steam://rungameid") {
59                        typeg = Steam;
60                    } else if command.contains("lutris:rungameid") {
61                        typeg = Lutris;
62                    }
63                    let mut games = Game {
64                        name: game.clone(),
65                        pname: game.clone(),
66                        command: command,
67                        typeg: typeg,
68                    };
69                    add_game(games);
70                    println!("Converted {}", game);
71                    fs::remove_dir_all(gd() + &game).unwrap();
72                }
73            }
74        }
75    }
76    /// Adds a given and configured game to registry
77    pub fn add_game(game: Game) {
78        if fs::metadata(gd() + &game.name + ".json").is_ok() {
79            println!("  Already made a shortcut for {}", game.pname);
80        } else {
81            let mut ok = true;
82            let blocked = get_config().blocked;
83            for block in blocked {
84                if game.name == block {
85                    ok = false;
86                }
87            }
88            if ok {
89                OpenOptions::new()
90                    .create(true)
91                    .write(true)
92                    .open(gd() + &game.name + ".json")
93                    .unwrap()
94                    .write_all(serde_json::to_string(&game).unwrap().as_bytes())
95                    .unwrap();
96                println!("  Made shortcut for {}", game.pname);
97            } else {
98                println!("  {} is in your blocked list", game.pname);
99            }
100        }
101    }
102    /// Loads vec of all installed games
103    pub fn get_games() -> Vec<String> {
104        fs::read_dir(gd())
105            .expect("Can't read in games")
106            .collect::<Vec<io::Result<DirEntry>>>()
107            .into_iter()
108            .map(|entry| {
109                entry
110                    .unwrap()
111                    .file_name()
112                    .into_string()
113                    .unwrap()
114                    .replace(".json", "")
115            })
116            .collect::<Vec<String>>()
117    }
118    /// Prints currently installed games
119    pub fn list_games() {
120        println!("Currently registered games:");
121        let entries = get_games();
122        println!("Name - Procname - Type");
123        for entry in entries {
124            let game = read_game(entry).unwrap();
125            println!("{} - {} - {}", game.pname, game.name, game.typeg);
126        }
127    }
128    /// Runs a registered game, given name
129    pub fn run_game<N>(name: N) -> Result<String, String>
130    where
131        N: Into<String>,
132    {
133        let proced = create_procname(name.into());
134        let g = read_game(proced);
135        if g.is_ok() {
136            let g = g.unwrap();
137            match g.typeg {
138                Itch => {
139                    let butler = Butler::new().expect("Has butler been uninstalled?");
140                    butler.launch_game(&g.command);
141                    return Ok("Launched through butler".to_string());
142                }
143                Dolphin => {
144                    let result = Command::new("dolphin-emu-cli")
145                        .arg(g.command)
146                        .output()
147                        .expect("Couldn't run dolphin game");
148                    if !result.status.success() {
149                        return Err(String::from_utf8_lossy(&result.stderr)
150                            .into_owned()
151                            .to_string());
152                    } else {
153                        return Ok(String::from_utf8_lossy(&result.stdout)
154                            .into_owned()
155                            .to_string());
156                    }
157                }
158                WyvernGOG => {
159                    let path = std::path::PathBuf::from(&g.command);
160                    let start = path.join(std::path::PathBuf::from("start.sh"));
161                    let result = Command::new(start.to_str().unwrap())
162                        .output()
163                        .expect("Couldn't run GOG game!");
164                    if !result.status.success() {
165                        return Err(String::from_utf8_lossy(&result.stderr)
166                            .into_owned()
167                            .to_string());
168                    } else {
169                        return Ok(String::from_utf8_lossy(&result.stdout)
170                            .into_owned()
171                            .to_string());
172                    }
173                }
174                _ => {
175                    let result = Command::new("sh")
176                        .arg("-c")
177                        .arg(g.command)
178                        .output()
179                        .expect("Couldn't run selected game!");
180                    if !result.status.success() {
181                        return Err(String::from_utf8_lossy(&result.stderr)
182                            .into_owned()
183                            .to_string());
184                    } else {
185                        return Ok(String::from_utf8_lossy(&result.stdout)
186                            .into_owned()
187                            .to_string());
188                    }
189                }
190            }
191        } else {
192            println!("Couldn't find that game installed. Maybe you misspelled something?");
193            Err("Nonexistent".to_string())
194        }
195    }
196    /// Removes folder of named game
197    pub fn rm_game<N>(name: N)
198    where
199        N: Into<String>,
200    {
201        let res = fs::remove_file(String::from(gd() + create_procname(name).as_ref()) + ".json");
202        if res.is_ok() {
203            println!("Game removed!");
204        } else {
205            println!("Game did not exist. So, removed?");
206        }
207    }
208    /// Registers executable file as game with given name. Wine argguement indicates whether or not to run this game under wine
209    pub fn add_game_p<N>(name: N, exe: N, wine: bool)
210    where
211        N: Into<String>,
212    {
213        let (name, exe) = (name.into(), exe.into());
214        let mut path = env::current_dir().unwrap();
215        path.push(exe.clone());
216        //Adds pwd to exe path
217        let name = create_procname(name.as_str());
218        let pname = name.clone();
219        if fs::metadata(gd() + &name + ".json").is_ok() {
220            println!("A shortcut has already been made for {}", pname);
221        } else {
222            println!("Creating shortcut for {:?} with a name of {}", path, name);
223            let mut start = String::from("");
224            if wine {
225                let mut winestr = String::from("wine ");
226                if exe.to_lowercase().contains(".lnk") {
227                    winestr = winestr + "start ";
228                }
229                start.push_str(&winestr);
230            }
231            let command = String::from(
232                start
233                    + &(path
234                        .into_os_string()
235                        .into_string()
236                        .unwrap()
237                        .replace(" ", "\\ ")),
238            );
239            let game = Game {
240                pname: pname.to_string(),
241                name: name,
242                command: command,
243                typeg: Exe,
244            };
245            add_game(game);
246        }
247    }
248
249    /// Reads in a game's info from a name
250    pub fn read_game<N>(name: N) -> Result<Game, String>
251    where
252        N: Into<String>,
253    {
254        let name = name.into();
255        if fs::metadata(gd() + &name + ".json").is_ok() {
256            let mut stri = String::new();
257            fs::File::open(gd() + &name + ".json")
258                .unwrap()
259                .read_to_string(&mut stri)
260                .unwrap();
261            let g: Game = serde_json::from_str(&stri).unwrap();
262            return Ok(g);
263        }
264        return Err("No such game".to_string());
265    }
266}
267
268/// Functions related to automatic scanning and updating of game registry
269pub mod auto {
270    use self::GameType::*;
271    use crate::{games::*, helper::*, *};
272    /// A result from searching for steam games
273    pub struct SearchResult {
274        pub appid: String,
275        pub name: String,
276        pub outname: String,
277    }
278    /// Fetches lutris wine games and returns a vec of names and lutris ids as tuples
279    pub fn get_lutris() -> Result<Vec<(String, String)>, String> {
280        let games = Command::new("lutris").arg("-l").output();
281        if games.is_ok() {
282            let games = games.unwrap();
283            if games.status.success() {
284                let games_list = String::from_utf8_lossy(&games.stdout);
285                return Ok(games_list
286                    .lines()
287                    .filter(|x| x.find("wine").is_some())
288                    .map(|x| {
289                        let n = x.split("|").collect::<Vec<&str>>();
290                        (String::from(n[0].trim()), String::from(n[1].trim()))
291                    })
292                    .collect::<Vec<(String, String)>>());
293            } else {
294                return Err("Lutris not installed".to_string());
295            }
296        } else {
297            return Err("Lutris not installed".to_string());
298        }
299    }
300
301    /// Searches itch.io games and adds them to game registry
302    pub fn update_itch() {
303        if fs::metadata(get_home() + "/.config/itch").is_ok() {
304            let btest = Butler::new();
305            if btest.is_ok() {
306                let mut already = get_games()
307                    .iter_mut()
308                    .filter(|x| {
309                        let read = read_game(x.to_string()).unwrap();
310                        read.typeg == Itch
311                    })
312                    .map(|x| x.to_string())
313                    .collect::<Vec<String>>();
314                println!(">> Reading in itch.io games");
315                let butler = btest.expect("Couldn't start butler daemon");
316                let caves = butler.fetchall().expect("Couldn't fetch butler caves");
317                for cave in caves {
318                    let game = cave.game;
319                    let name = game.title;
320                    let procname = create_procname(name.as_str());
321                    let g = Game {
322                        pname: name,
323                        name: procname.clone(),
324                        command: cave.id,
325                        typeg: Itch,
326                    };
327                    add_game(g);
328                    let mut i = 0;
329                    while i < already.len() {
330                        if already[i] == procname {
331                            already.remove(i);
332                        }
333                        i += 1;
334                    }
335                }
336                for left in already {
337                    println!("{} has been uninstalled. Removing from registry.", left);
338                    rm_game(left);
339                }
340            } else {
341                println!("Itch.io client not installed!");
342            }
343        } else {
344            println!("Itch.io client not installed!");
345        }
346    }
347    // /Iterates through steam directories for installed steam games and creates registrations for all
348    pub fn update_steam(dirs: Vec<String>) {
349        let mut already = get_games();
350        for x in &dirs {
351            println!(">> Reading in steam library {}", &x);
352            let name = x.to_owned();
353            let entries_try = fs::read_dir(name.clone() + "/common");
354            if entries_try.is_ok() {
355                let entries = fs::read_dir(x.to_owned() + "/common")
356                    .expect("Can't read in games")
357                    .into_iter()
358                    .map(|x| proc_path(x.unwrap()));
359                for entry in entries {
360                    //Calls search games to get appid and proper name to make the script
361                    let results = search_games(entry, x.to_owned());
362                    if results.is_some() {
363                        let results = results.unwrap();
364                        let procname = create_procname(results.name.as_str());
365                        let pname = results.name;
366                        let command =
367                            String::from("steam 'steam://rungameid/") + &results.appid + "'";
368                        let game = Game {
369                            name: procname.clone(),
370                            pname: pname.clone(),
371                            command: command,
372                            typeg: Steam,
373                        };
374                        add_game(game);
375                        let mut i = 0;
376                        while i < already.len() {
377                            if already[i] == procname {
378                                already.remove(i);
379                            }
380                            i += 1;
381                        }
382                    }
383                }
384            } else {
385                println!(
386                    "Directory {} does not exist or is not a valid steam library",
387                    name
388                );
389            }
390        }
391        for al in already {
392            let typeg = read_game(al.clone()).unwrap().typeg;
393            if typeg == Steam {
394                println!("{} has been uninstalled. Removing game from registry.", al);
395                rm_game(al);
396            }
397        }
398    }
399    /// Adds lutris wine games from get_lutris
400    pub fn update_lutris() {
401        let lut = get_lutris();
402        if lut.is_err() {
403            println!(">> No wine games found in lutris, or lutris not installed");
404        } else {
405            println!(">> Reading in lutris wine games");
406            for game in lut.unwrap() {
407                let procname = create_procname(game.1.as_str());
408                let pname = game.1.clone();
409                let command = String::from("lutris lutris:rungameid/") + &game.0;
410                let g = Game {
411                    pname: pname,
412                    name: procname,
413                    command: command,
414                    typeg: Lutris,
415                };
416                add_game(g);
417            }
418        }
419    }
420    /// Searches given steam game directory for installed game with a directory name of [rawname]
421    pub fn search_games<N>(rawname: N, steamdir: N) -> Option<SearchResult>
422    where
423        N: Into<String>,
424    {
425        let (rawname, steamdir) = (rawname.into(), steamdir.into());
426        let entries = fs::read_dir(steamdir).expect("Can't read installed steam games");
427        for entry in entries {
428            let entry = entry.unwrap().path();
429            let new_entry = entry.into_os_string().into_string().unwrap();
430            if new_entry.find("appmanifest").is_some() {
431                let mut f = fs::File::open(&new_entry).expect("Couldn't open appmanifest");
432                let mut contents = String::new();
433                f.read_to_string(&mut contents)
434                    .expect("Could not read appmanifest");
435                if contents.find("installdir").is_some() {
436                    //Slices out the game data from the appmanifest. Will break the instant steam changes appmanifest formats
437                    let outname = contents
438                        .get(
439                            contents
440                                .find("installdir")
441                                .expect("Couldn't find install dir")
442                                + 14
443                                ..contents.find("LastUpdated").unwrap() - 4,
444                        )
445                        .unwrap();
446                    if outname == rawname {
447                        let appid = contents
448                            .get(
449                                contents.find("appid").unwrap() + 9
450                                    ..contents.find("Universe").unwrap() - 4,
451                            )
452                            .unwrap();
453                        let name = contents
454                            .get(
455                                contents.find("name").unwrap() + 8
456                                    ..contents.find("StateFlags").unwrap() - 4,
457                            )
458                            .unwrap();
459                        return Some(SearchResult {
460                            appid: String::from(appid),
461                            name: String::from(name),
462                            outname: String::from(outname),
463                        });
464                    }
465                }
466            }
467        }
468        //Return none if nothing can be found
469        return None;
470    }
471    /// Iterates through directory and imports each child directory
472    pub fn imports<N>(dir: N)
473    where
474        N: Into<String>,
475    {
476        let dir = dir.into();
477        let entries = fs::read_dir(&dir).expect("Can't read in folder of games");
478        println!("Reading in directory: {}", dir);
479        for entry in entries {
480            let entry = proc_path(entry.unwrap());
481            println!("Attempting import on {}", &entry);
482            import(entry.as_str());
483            println!("Finished attempted import on {}", &entry);
484        }
485    }
486    /// Scans a directory for common game formats and adds them.
487    pub fn import<N>(dir: N)
488    where
489        N: Into<String>,
490    {
491        let dir = dir.into();
492        let mut path = env::current_dir().unwrap();
493        let entry_format = &dir.split("/").collect::<Vec<&str>>();
494        let real_dir: String = String::from(entry_format[entry_format.len() - 1]);
495        let procname = create_procname(real_dir);
496        println!("Creating game registry named {}.", procname);
497        path.push(dir.clone());
498        let entries = fs::read_dir(&path).expect("Can't read in game folder");
499        let mut found_game = String::new();
500        for entry in entries {
501            let entry = proc_path(entry.unwrap());
502            let mut found = true;
503            if entry.find(".x86_64").is_some() {
504                println!("Found a unity exe. Assuming it is game");
505            } else if entry.find("start.sh").is_some() {
506                println!("Found a GOG game. Assuming it is game");
507            } else if entry.find("start").is_some() {
508                println!("Found older nicohman game exe. Assuming it is game");
509            } else {
510                found = false;
511            }
512            if found == true {
513                found_game = entry.to_owned();
514            }
515        }
516        if found_game.len() > 0 {
517            add_game_p(
518                procname,
519                path.into_os_string().into_string().unwrap() + "/" + &found_game,
520                false,
521            );
522        } else {
523            println!(
524                "Could not find recognizable game exe. You will have to manually specify using eidolon add [name] [exe]"
525            );
526        }
527    }
528}
529/// Functions for working with the config file/formats
530pub mod config {
531    use crate::{helper::*, *};
532    use regex::Regex;
533    /// Eidolon's user config
534    #[derive(Serialize, Deserialize, Debug)]
535    pub struct Config {
536        pub steam_dirs: Vec<String>,
537        pub menu_command: String,
538        pub prefix_command: String,
539        #[serde(default = "default_blocked")]
540        pub blocked: Vec<String>,
541    }
542    impl Config {
543        /// Default config
544        fn default() -> Config {
545            Config {
546                steam_dirs: vec!["$HOME/.local/share/Steam/steamapps".to_string()],
547                menu_command: "rofi -theme sidebar -mesg 'eidolon game:' -p '> ' -dmenu"
548                    .to_string(),
549                prefix_command: "".to_string(),
550                blocked: default_blocked(),
551            }
552        }
553    }
554    /// The pre-3.7 config
555    pub struct OldConfig {
556        pub steam_dirs: Vec<String>,
557        pub menu_command: String,
558        pub prefix_command: String,
559    }
560    fn default_blocked() -> Vec<String> {
561        vec![
562            "steamworks_common_redistributables".to_string(),
563            "proton_3.7".to_string(),
564            "proton_3.7_beta".to_string(),
565        ]
566    }
567    /// Converts pre-v1.2.7 config to JSON config
568    pub fn convert_config() {
569        let old = get_config_old();
570        let conf = Config {
571            steam_dirs: old
572                .steam_dirs
573                .into_iter()
574                .map(|x| String::from(x))
575                .collect::<Vec<String>>(),
576            menu_command: String::from(old.menu_command),
577            prefix_command: String::from(old.prefix_command),
578            blocked: default_blocked(),
579        };
580        OpenOptions::new()
581            .create(true)
582            .write(true)
583            .open(get_home() + "/.config/eidolon/config.json")
584            .unwrap()
585            .write_all(serde_json::to_string(&conf).unwrap().as_bytes())
586            .unwrap();
587        fs::remove_file(get_home() + "/.config/eidolon/config").unwrap();
588    }
589    /// Loads in eidolon config file
590    pub fn get_config() -> Config {
591        let mut conf_s = String::new();
592        fs::File::open(get_home() + "/.config/eidolon/config.json")
593            .expect("Couldn't read config")
594            .read_to_string(&mut conf_s)
595            .unwrap();
596        let mut config: Config = serde_json::from_str(&conf_s).unwrap();
597        let fixed = config.steam_dirs.into_iter();
598        config.steam_dirs = fixed
599            .map(|x| {
600                String::from(
601                    x.as_str()
602                        .replace("$HOME", &get_home())
603                        .replace("~", &get_home()),
604                )
605            })
606            .collect::<Vec<String>>();
607        config
608    }
609    /// This parses the config format that eidolon used prior to v1.2.7. This is used to convert the old format into the new JSON-based format when it is detected.
610    pub fn get_config_old() -> OldConfig {
611        let mut conf = String::new();
612        fs::File::open(get_home() + "/.config/eidolon/config")
613            .expect("Couldn't read config")
614            .read_to_string(&mut conf)
615            .expect("Couldn't read in config");
616        let mut conf = conf.lines();
617        let steam_dirs = conf.next().unwrap();
618        let steam_vec = Regex::new(r"(?:([^\|\s]+)\|)")
619            .expect("Couldn't create regex")
620            .captures_iter(steam_dirs)
621            .map(|x| String::from(x.get(1).unwrap().as_str().replace("$HOME", &get_home())))
622            .collect::<Vec<String>>();
623        let menu_command_base = String::from(conf.next().unwrap());
624        let prefix_command_bool = conf.next();
625        let mut prefix_command: &str;
626        if prefix_command_bool.is_some() {
627            prefix_command = prefix_command_bool.unwrap();
628            prefix_command = prefix_command.split('|').collect::<Vec<&str>>()[1];
629        } else {
630            prefix_command = " "
631        }
632        let menu_command = menu_command_base.split('|').collect::<Vec<&str>>()[1];
633        OldConfig {
634            steam_dirs: steam_vec,
635            menu_command: String::from(menu_command),
636            prefix_command: String::from(prefix_command),
637        }
638    }
639    /// Intializes basic directories and config for the first use
640    pub fn init() {
641        println!("Beginning config init");
642        if fs::metadata(get_home() + "/.config").is_err() {
643            fs::create_dir(get_home() + "/.config").expect("Couldn't create config directory");
644        }
645        fs::create_dir(get_home() + "/.config/eidolon").expect("Couldn't create eidolon directory");
646        fs::create_dir(get_home() + "/.config/eidolon/games")
647            .expect("Couldn't create games directory");
648        let mut file = OpenOptions::new()
649            .create(true)
650            .write(true)
651            .open(get_home() + "/.config/eidolon/config.json")
652            .unwrap();
653        file.write_all(
654            serde_json::to_string(&Config::default())
655                .unwrap()
656                .as_bytes(),
657        )
658        .unwrap();
659        println!("Correctly initialized base config.");
660    }
661    /// Checks if eidolon has been inited. If it hasn't, tries to init and returns false if that fails.
662    pub fn startup() -> bool {
663        if check_inited() {
664            true
665        } else {
666            init();
667            check_inited()
668        }
669    }
670    /// Check if eidolon has been initialized prior to this run
671    pub fn check_inited() -> bool {
672        if fs::metadata(get_home() + "/.config/eidolon").is_err() || fs::metadata(gd()).is_err() {
673            false
674        } else {
675            if fs::metadata(get_home() + "/.config/eidolon/config").is_ok() {
676                convert_config();
677                true
678            } else if fs::metadata(get_home() + "/.config/eidolon/config.json").is_ok() {
679                true
680            } else {
681                false
682            }
683        }
684    }
685    /// Returns the eidolon game directory
686    pub fn gd() -> String {
687        return get_home() + "/.config/eidolon/games/";
688    }
689}
690/// A set of helper functions commonly used by eidolon
691pub mod helper {
692    use regex::Regex;
693    use std::env;
694    use std::fs::DirEntry;
695    /// Formats game name into nice-looking underscored name for continuity with other names
696    pub fn create_procname<N>(rawname: N) -> String
697    where
698        N: Into<String>,
699    {
700        let mut basename = String::from(rawname.into()).to_lowercase();
701        basename = String::from(basename.trim());
702        let reg_white = Regex::new(r"-|\s").unwrap();
703        let reg_special = Regex::new(r"'|™|:").unwrap();
704        let white_formatted = reg_white.replace_all(&basename, "_");
705        let total_formatted = reg_special.replace_all(&white_formatted, "");
706        return String::from(total_formatted);
707    }
708
709    /// Converts DirEntry into a fully processed file/directory name
710    pub fn proc_path(path: DirEntry) -> String {
711        let base = path.file_name().into_string().unwrap();
712        return base;
713    }
714    /// Gets current user's home directory
715    pub fn get_home() -> String {
716        return String::from(dirs::home_dir().unwrap().to_str().unwrap());
717    }
718}