steam_tui/
client.rs

1use crate::interface::{
2    account::Account, executable::*, game::Game, game_status::*, steam_cmd::SteamCmd,
3};
4
5use crate::util::{
6    error::STError,
7    log::log,
8    parser::*,
9    paths::{
10        cache_location, executable_exists, install_script_location, launch_script_location,
11        steam_run_wrapper,
12    },
13};
14
15use port_scanner::scan_port;
16
17use std::process;
18use std::sync::Arc;
19
20use std::collections::HashSet;
21use std::collections::VecDeque;
22use std::fs;
23use std::sync::mpsc::{channel, Receiver, Sender};
24use std::sync::Mutex;
25use std::thread;
26
27const STEAM_PORT: u16 = 57343;
28
29#[derive(PartialEq, Clone)]
30pub enum State {
31    LoggedOut,
32    LoggedIn,
33    Failed,
34    Terminated(String),
35    Loaded(i32, i32),
36}
37
38pub enum Command {
39    Cli(String),
40    Install(i32, Arc<Mutex<Option<GameStatus>>>),
41    Run(i32, Vec<Executable>, Arc<Mutex<Option<GameStatus>>>),
42    StartClient,
43    Restart,
44}
45
46fn execute(
47    state: Arc<Mutex<State>>,
48    sender: Sender<String>,
49    receiver: Receiver<Command>,
50) -> Result<(), STError> {
51    let mut cmd = SteamCmd::new()?;
52    let mut queue = VecDeque::new();
53    let mut games = Vec::new();
54    let mut account: Option<Account> = None;
55
56    // TODO(#20) Pass in Arcs for download status into threads. If requested state is in
57    // downloading, show satus.
58    let mut downloading: HashSet<i32> = HashSet::new();
59
60    // Cleanup the steam process if steam-tui quits.
61    let mut cleanup: Option<Sender<bool>> = None;
62
63    loop {
64        queue.push_front(receiver.recv()?);
65        loop {
66            match queue.pop_back() {
67                None => break,
68                // For flag reference see:
69                //   https://developer.valvesoftware.com/wiki/Command_line_options#Steam_.28Windows.29
70                //   and https://gist.github.com/davispuh/6600880
71                Some(Command::StartClient) => {
72                    if !scan_port(STEAM_PORT) {
73                        let (sender, termination) = channel();
74                        cleanup = Some(sender);
75                        thread::spawn(move || {
76                            let mut child = process::Command::new("steam")
77                                .args(vec![
78                                    "-console",
79                                    "-dev",
80                                    "-nofriendsui",
81                                    "-no-browser",
82                                    "+open",
83                                    "steam://",
84                                ])
85                                .stdout(process::Stdio::null())
86                                .stderr(process::Stdio::null())
87                                .spawn()
88                                .unwrap();
89
90                            // TODO: Currently doesn't kill all grand-children processes.
91                            while let Ok(terminate) = termination.recv() {
92                                if terminate {
93                                    let _ = child.kill();
94                                    break;
95                                }
96                            }
97                        });
98                    }
99                }
100                Some(Command::Restart) => {
101                    let mut state = state.lock()?;
102                    *state = State::LoggedOut;
103                    cmd = SteamCmd::new()?;
104                    let user = match account {
105                        Some(ref acct) => acct.account.clone(),
106                        _ => "".to_string(),
107                    };
108                    queue.push_front(Command::Cli(format!("login {}", user)));
109                }
110                Some(Command::Install(id, status)) => {
111                    if let Some(ref acct) = account {
112                        if downloading.contains(&id) {
113                            continue;
114                        }
115                        downloading.insert(id);
116                        let name = acct.account.clone();
117                        thread::spawn(move || {
118                            {
119                                let mut reference = status.lock().unwrap();
120                                *reference = Some(GameStatus::msg(&*reference, "processing..."));
121                            }
122                            match SteamCmd::script(
123                                install_script_location(name.clone(), id)
124                                    .unwrap()
125                                    .to_str()
126                                    .expect("Installation thread failed."),
127                            ) {
128                                Ok(mut cmd) => {
129                                    // Scrub past unused data.
130                                    for _ in 1..15 {
131                                        cmd.next();
132                                    }
133                                    while let Ok(buf) = cmd.maybe_next() {
134                                        let response = String::from_utf8_lossy(&buf);
135                                        // TODO: Investigate why download updates don't seem to
136                                        // appear...
137                                        match *INSTALL_LEX.tokenize(&response).as_slice() {
138                                            ["Update", a, b] => {
139                                                let a = a.parse::<f64>().unwrap_or(0.);
140                                                let b = b.parse::<f64>().unwrap_or(1.);
141                                                let mut reference = status.lock().unwrap();
142                                                let update =
143                                                    format!("downloading {}%", 100. * a / b);
144                                                *reference =
145                                                    Some(GameStatus::msg(&*reference, &update));
146                                            }
147                                            ["ERROR", msg] => {
148                                                let mut reference = status.lock().unwrap();
149                                                let update = format!("Failed: {}", msg);
150                                                *reference =
151                                                    Some(GameStatus::msg(&*reference, &update));
152                                            }
153                                            ["Success"] => {
154                                                let mut reference = status.lock().unwrap();
155                                                let size = match &*reference {
156                                                    Some(gs) => gs.size,
157                                                    _ => 0.,
158                                                };
159                                                *reference = Some(GameStatus {
160                                                    state: "Success!".to_string(),
161                                                    installdir: "".to_string(),
162                                                    size,
163                                                });
164                                                // TODO: call app_status and update after success.
165                                            }
166                                            _ => {
167                                                log!("unmatched", response);
168                                            }
169                                        }
170                                    }
171                                }
172                                Err(err) => {
173                                    let err = format!("{:?}", err);
174                                    let mut reference = status.lock().unwrap();
175                                    *reference = Some(GameStatus {
176                                        state: format!("Failed: {}", err),
177                                        installdir: "".to_string(),
178                                        size: 0.,
179                                    });
180                                    log!("Install script for:", name, "failed", err);
181                                }
182                            }
183                        });
184                    };
185                }
186                Some(Command::Run(id, executables, status)) => {
187                    {
188                        let mut reference = status.lock().unwrap();
189                        *reference = Some(GameStatus::msg(&*reference, "launching..."));
190                    }
191                    // IF steam is running (we can check for port tcp/57343), then
192                    //   SteamCmd::script("login, app_run <>, quit")
193                    // otherwise attempt to launch normally.
194                    if scan_port(STEAM_PORT) {
195                        if let Some(ref acct) = account {
196                            let name = acct.account.clone();
197                            thread::spawn(move || {
198                                if let Err(err) = SteamCmd::script(
199                                    launch_script_location(name.clone(), id)
200                                        .unwrap()
201                                        .to_str()
202                                        .expect("Launch thread failed."),
203                                ) {
204                                    let err = format!("{:?}", err);
205                                    log!("Run script for:", name, "failed", err);
206                                    {
207                                        let mut reference = status.lock().unwrap();
208                                        *reference = Some(GameStatus::msg(
209                                            &*reference,
210                                            &format!("Error with script (trying direct): {}", err),
211                                        ));
212                                    }
213                                    // Try again as per #51
214                                    run_process(
215                                        "steam".to_string(),
216                                        vec![
217                                            "-silent".to_string(),
218                                            "-applaunch".to_string(),
219                                            id.to_string(),
220                                        ],
221                                        status,
222                                    );
223                                }
224                            });
225                            break;
226                        }
227                    }
228                    let mut launched = false;
229                    for launchable in executables {
230                        if let Ok(path) = executable_exists(&launchable.executable) {
231                            log!(path);
232                            let mut command = match launchable.platform {
233                                Platform::Windows => vec![
234                                    "wine".to_string(),
235                                    path.into_os_string().into_string().unwrap(),
236                                ],
237                                _ => vec![path.to_str().unwrap_or("").to_string()],
238                            };
239                            let mut args = launchable
240                                .arguments
241                                .clone()
242                                .split(' ')
243                                .map(|x| x.to_string())
244                                .collect::<Vec<String>>();
245                            command.append(&mut args);
246                            log!("Finding entry");
247                            let entry = match steam_run_wrapper(id) {
248                                Ok(wrapper) => wrapper.into_os_string().into_string().unwrap(),
249                                Err(STError::Problem(_)) => command.remove(0),
250                                Err(err) => {
251                                    let mut reference = status.lock().unwrap();
252                                    *reference = Some(GameStatus::msg(
253                                        &*reference,
254                                        "Could not find entry program.",
255                                    ));
256                                    return Err(err);
257                                } // unwrap and rewrap to explicitly note this is an err.
258                            };
259                            log!("Exits loop");
260                            let status = status.clone();
261                            thread::spawn(move || {
262                                {
263                                    let mut reference = status.lock().unwrap();
264                                    *reference = Some(GameStatus::msg(&*reference, "running..."));
265                                }
266                                run_process(entry, command, status);
267                            });
268                            launched = true;
269                            break;
270                        } else {
271                            log!("Tried", launchable.executable);
272                        }
273                    }
274                    if !launched {
275                        let mut reference = status.lock().unwrap();
276                        *reference = Some(GameStatus::msg(
277                            &*reference,
278                            "Failed: Could not find executable to launch. Try setting $STEAM_APP_DIR",
279                        ));
280                    }
281                }
282                // Execute and handles response to various SteamCmd Commands.
283                Some(Command::Cli(line)) => {
284                    cmd.write(&line)?;
285                    let mut updated = 0;
286                    let waiting = queue.len();
287                    let buf = cmd.maybe_next()?;
288                    let response = String::from_utf8_lossy(&buf);
289                    match *INPUT_LEX.tokenize(&line).as_slice() {
290                        ["login", _] => {
291                            let response = response.to_string();
292                            if response.contains("Login Failure") || response.contains("FAILED") {
293                                let mut state = state.lock()?;
294                                *state = State::Failed;
295                            } else {
296                                queue.push_front(Command::Cli("info".to_string()));
297                            }
298                            log!("login");
299                        }
300                        ["info"] => {
301                            account = match Account::new(&response.to_string()) {
302                                Ok(acct) => Some(acct),
303                                _ => None,
304                            };
305                            let mut state = state.lock()?;
306                            *state = State::Loaded(0, -2);
307                            log!("info");
308                        }
309                        ["licenses_print"] => {
310                            // Extract licenses
311                            if response == "[0m" {
312                                continue;
313                            }
314
315                            games = Vec::new();
316                            let licenses = response.to_string();
317                            let keys = keys_from_licenses(licenses);
318                            let total = keys.len();
319                            updated += total as i32;
320                            for key in keys {
321                                queue.push_front(Command::Cli(format!(
322                                    "package_info_print {}",
323                                    key
324                                )));
325                            }
326                            let mut state = state.lock()?;
327                            *state = State::Loaded(0, total as i32);
328                            log!("licenses_print");
329                        }
330                        ["package_info_print", key] => {
331                            let mut lines = response.lines();
332                            updated += 1;
333                            if let Datum::Nest(map) = parse(&mut lines) {
334                                if let Some(map) = map.get(key) {
335                                    if let Some(Datum::Nest(apps)) = map.maybe_nest()?.get("appids")
336                                    {
337                                        for wrapper in apps.values() {
338                                            if let Datum::Value(id) = wrapper {
339                                                let key = id.parse::<i32>().unwrap_or(-1);
340                                                if key >= 0 {
341                                                    queue.push_front(Command::Cli(format!(
342                                                        "app_info_print {}",
343                                                        key
344                                                    )));
345                                                }
346                                            }
347                                        }
348                                    }
349                                }
350                            };
351                            let mut state = state.lock()?;
352                            match *state {
353                                State::Loaded(_, _) => {}
354                                _ => *state = State::Loaded(updated, queue.len() as i32),
355                            }
356                            log!("package_info_print");
357                        }
358                        ["app_info_print", key] => {
359                            updated += 1;
360                            log!("Checking game");
361                            // Bug requires additional scan
362                            // do a proper check here in case this is ever fixed.
363                            // A bit of a hack, but will do for now.
364                            let mut response = response;
365                            log!(response);
366                            if response == "[0m" {
367                                cmd.write("")?;
368                                cmd.write("")?;
369                                while !response.starts_with("[0mAppID") {
370                                    if let Ok(buf) = cmd.maybe_next() {
371                                        response =
372                                            String::from_utf8_lossy(&buf).into_owned().into();
373                                    }
374                                }
375                            }
376                            let mut lines = response.lines();
377
378                            match Game::new(key, &mut lines) {
379                                Ok(game) => {
380                                    log!("got game");
381                                    games.push(game);
382                                    log!(key);
383                                }
384                                Err(err) => {
385                                    log!(err)
386                                }
387                            };
388                        }
389                        ["app_status", _id] => {
390                            sender.send(response.to_string())?;
391                        }
392                        ["quit"] => {
393                            if let Some(cleanup) = cleanup {
394                                let _ = cleanup.send(true);
395                            }
396                            sender.send(response.to_string())?;
397                            return Ok(());
398                        }
399                        _ => {
400                            // Send back response for debugging reasons.
401                            sender.send(response.to_string())?;
402                            // Fail since unknown commands should never be executed.
403                            return Err(STError::Problem(format!(
404                                "Unknown command sent {}",
405                                response
406                            )));
407                        }
408                    }
409
410                    // If in Loading state, update progress.
411                    let mut state = state.lock()?;
412                    if let State::Loaded(o, e) = *state {
413                        updated += o;
414                        let total = e + (queue.len() - waiting) as i32;
415                        *state = if updated == total {
416                            games.sort_by(|a, b| a.name.cmp(&b.name));
417                            fs::write(cache_location()?, serde_json::to_string(&games)?)?;
418                            games = Vec::new();
419                            State::LoggedIn
420                        } else {
421                            State::Loaded(updated, total)
422                        }
423                    }
424                    // Iterate to scrub past Steam> prompt
425                    let buf = cmd.maybe_next()?;
426                    let mut prompt = String::from_utf8_lossy(&buf);
427                    log!(prompt);
428                    while prompt != "[1m\nSteam>" {
429                        if let Ok(buf) = cmd.maybe_next() {
430                            prompt = String::from_utf8_lossy(&buf).into_owned().into();
431                        } else {
432                            cmd.write("")?;
433                        }
434                    }
435                }
436            }
437        }
438    }
439}
440
441fn run_process(entry: String, command: Vec<String>, status: Arc<Mutex<Option<GameStatus>>>) {
442    match process::Command::new(entry).args(command).output() {
443        Ok(output) => {
444            let stderr = output.stderr.clone();
445            let stderr_snippet = &(String::from_utf8_lossy(&stderr)[..50]);
446            let mut reference = status.lock().unwrap();
447            *reference = Some(GameStatus::msg(
448                &*reference,
449                &(match output.status.code() {
450                    Some(0) => format!("ran (success)"),
451                    Some(n) => format!("failed with code {}: ({}...)", n, stderr_snippet),
452                    None => format!("Process terminated."),
453                }),
454            ));
455
456            log!("Launching stdout:", &std::str::from_utf8(&output.stdout));
457            log!("Launching stderr:", &std::str::from_utf8(&output.stderr));
458        }
459        Err(err) => {
460            let mut reference = status.lock().unwrap();
461            *reference = Some(GameStatus::msg(
462                &*reference,
463                &format!("failed to launch: {}", err),
464            ));
465        }
466    }
467}
468
469/// Manages and interfaces with SteamCmd threads.
470pub struct Client {
471    receiver: Mutex<Receiver<String>>,
472    sender: Mutex<Sender<Command>>,
473    state: Arc<Mutex<State>>,
474}
475
476impl Client {
477    /// Spawns a StemCmd process to interface with.
478    pub fn new() -> Client {
479        let (tx1, rx1) = channel();
480        let (tx2, rx2) = channel();
481
482        let client = Client {
483            receiver: Mutex::new(rx1),
484            sender: Mutex::new(tx2),
485            state: Arc::new(Mutex::new(State::LoggedOut)),
486        };
487        Client::start_process(client.state.clone(), tx1, rx2);
488        client
489    }
490
491    /// Ensures `State` is `State::LoggedIn`.
492    pub fn is_logged_in(&self) -> Result<bool, STError> {
493        Ok(self.get_state()? == State::LoggedIn)
494    }
495
496    pub fn get_state(&self) -> Result<State, STError> {
497        Ok(self.state.lock()?.clone())
498    }
499
500    /// Runs installation script for the provided game id.
501    pub fn install(&self, game: &Game) -> Result<(), STError> {
502        let sender = self.sender.lock()?;
503        sender.send(Command::Install(game.id as i32, game.status_counter()))?;
504        Ok(())
505    }
506
507    /// Quits previous SteamCmd instance, and spawns a new one. This can be useful for getting more
508    /// state data. Old processes fail to update due to short comings in SteamCmd.
509    pub fn restart(&self) -> Result<(), STError> {
510        let sender = self.sender.lock()?;
511        sender.send(Command::Restart)?;
512        Ok(())
513    }
514
515    /// Launches the provided game id using 'app_run' in steamcmd, or the raw executable depending
516    /// on the Steam client state.
517    pub fn run(&self, game: &Game) -> Result<(), STError> {
518        let sender = self.sender.lock()?;
519        sender.send(Command::Run(
520            game.id,
521            game.executable.to_owned().to_vec(),
522            game.status_counter(),
523        ))?;
524        Ok(())
525    }
526
527    /// Attempts to login the provided user string.
528    pub fn login(&self, user: &str) -> Result<(), STError> {
529        if user.is_empty() {
530            return Err(STError::Problem(
531                "Blank string. Requires user to log in.".to_string(),
532            ));
533        }
534        let mut state = self.state.lock()?;
535        *state = State::LoggedOut;
536        let sender = self.sender.lock()?;
537        sender.send(Command::Cli(format!("login {}", user)))?;
538        Ok(())
539    }
540
541    /// Starts off the process of parsing all games from SteamCmd. First `State` is set to be in an
542    /// unloaded state for `State::Loaded`.  The process start by calling 'licenses_print' which
543    /// then extracts packageIDs, and calls 'package_info_print' for each package. This in turn
544    /// extracts appIDs, and gets app particular data by calling 'app_info_print' and binds it to a
545    /// `Game` object. When all data is loaded, the games are dumped to a file and the state is
546    /// changed to `State::LoggedIn` indicating that all data has been extracted and can be
547    /// presented.
548    /// TODO(#8): Check for cached games prior to reloading everything, unless explicitly
549    /// restarted.
550    pub fn load_games(&self) -> Result<(), STError> {
551        let mut state = self.state.lock()?;
552        *state = State::Loaded(0, -1);
553        let sender = self.sender.lock()?;
554        sender.send(Command::Cli(String::from("licenses_print")))?;
555        Ok(())
556    }
557
558    /// Extracts games from cached location.
559    pub fn games(&self) -> Result<Vec<Game>, STError> {
560        let db_content = fs::read_to_string(cache_location()?)?;
561        let parsed: Vec<Game> = serde_json::from_str(&db_content)?;
562        let mut processed: Vec<Game> = parsed
563            .iter()
564            .map(|game| Game::move_with_status((*game).clone(), self.status(game.id).ok()))
565            .collect();
566        processed.dedup_by(|a, b| a.id == b.id);
567        Ok(processed)
568    }
569
570    /// Binds data from 'app_status' to a `GameStatus` object.
571    pub fn status(&self, id: i32) -> Result<GameStatus, STError> {
572        log!("Getting status for", id);
573        let sender = self.sender.lock()?;
574        sender.send(Command::Cli(format!("app_status {}", id)))?;
575        let receiver = self.receiver.lock()?;
576        GameStatus::new(&receiver.recv()?)
577    }
578
579    /// Started up a headless steam instance in the background so that games can be launched
580    /// through steamcmd.
581    pub fn start_client(&self) -> Result<(), STError> {
582        let sender = self.sender.lock()?;
583        sender.send(Command::StartClient)?;
584        Ok(())
585    }
586
587    fn start_process(
588        state: Arc<Mutex<State>>,
589        sender: Sender<String>,
590        receiver: Receiver<Command>,
591    ) {
592        thread::spawn(move || {
593            let local = state.clone();
594            match execute(state, sender, receiver) {
595                Ok(_) => {}
596                Err(e) => {
597                    let mut state = local
598                        .lock()
599                        .expect("We need to inform the other thread that this broke.");
600                    *state = State::Terminated(format!("Fatal Error in client thread:\n{}", e));
601                }
602            };
603        });
604    }
605}
606
607impl Default for Client {
608    fn default() -> Self {
609        Self::new()
610    }
611}
612
613impl Drop for Client {
614    fn drop(&mut self) {
615        let sender = self
616            .sender
617            .lock()
618            .expect("In destructor, error handling is meaningless");
619        let _ = sender.send(Command::Cli(String::from("quit")));
620        let receiver = self.receiver.lock().expect("In destructor");
621        let _ = receiver.recv();
622    }
623}
624
625// Just some helpers broken out for testing
626fn keys_from_licenses(licenses: String) -> Vec<i32> {
627    licenses
628        .lines()
629        .enumerate()
630        .filter(|(i, _)| i % 4 == 0)
631        .map(|(_, l)| match *LICENSE_LEX.tokenize(l).as_slice() {
632            ["packageID", id] => id.parse::<i32>().unwrap_or(-1),
633            _ => -1,
634        })
635        .filter(|x| x >= &0)
636        .collect::<Vec<i32>>()
637}
638
639#[cfg(test)]
640mod tests {
641    use crate::client::{Client, Command, State};
642    use crate::util::error::STError;
643    use std::sync::mpsc::channel;
644    use std::sync::Arc;
645    use std::sync::Mutex;
646
647    // Impure cases call to `steamcmd` which requires FHS.
648    #[test]
649    fn test_polluted_data_impure() {
650        let (tx1, receiver) = channel();
651        let (sender, rx2) = channel();
652        Client::start_process(Arc::new(Mutex::new(State::LoggedOut)), tx1, rx2);
653        let pollution = String::from("pollution„ ™️ ö ®Ø 天 🎉 Maxisâ¢\n\n\n\nquit\nbash");
654        sender
655            .send(Command::Cli(pollution.clone()))
656            .expect("Fails to send message...");
657        assert!(&receiver
658            .recv()
659            .expect("Channel dies")
660            .contains(&"pollution".to_string()));
661    }
662
663    #[test]
664    fn test_implicit_line_ending_impure() {
665        let (tx1, receiver) = channel();
666        let (sender, rx2) = channel();
667        Client::start_process(Arc::new(Mutex::new(State::LoggedOut)), tx1, rx2);
668        let message = String::from("doesn't hang");
669        sender
670            .send(Command::Cli(message.clone()))
671            .expect("Fails to send message...");
672        assert!(&receiver
673            .recv()
674            .expect("Channel dies")
675            .contains(&"Command not found: doesn't".to_string()));
676    }
677
678    #[test]
679    fn test_blank_login() {
680        let client = Client::new();
681        let result = client.login("");
682        if let Err(STError::Problem(expected)) = result {
683            assert!(expected.contains(&"Blank".to_string()));
684            return;
685        }
686        panic!("Failed to unwrap")
687    }
688}