rl_hours_tracker/
lib.rs

1//! # Rocket League Hours Tracker
2//! This was made specifically for the Epic Games version of Rocket League
3//! as the Epic Games launcher has no way of showing the past two hours played in
4//! the same way that steam is able to.
5//!
6//! However, this program can and should still work with the steam version of the game.
7//!
8//! It is `HIGHLY` recommended to not manually alter the files that are created by this program
9//! otherwise it could lead to unwanted behaviour by the program
10//!
11//! ``` rust
12//!     println!("You got it Oneil :)");
13//! ```
14
15//! ## Library
16//! The Rocket League Hours Tracker library contains modules which provide additional
17//! functionality to the Rocket League Hours Tracker binary. This library currently
18//! implements the [`website_files`] module, which provides the functionality to generate
19//! the Html, CSS, and JavaScript for the Rocket League Hours Tracker website, and the [`update`]
20//! module, which is the built in updater for the binary which retrieves the update from the GitHub
21//! repository.
22//!
23//! The website functionality takes adavantage of the [`build_html`] library, which allows us
24//! to generate the Html for the website, alongside the [`webbrowser`] library, which allows us
25//! to open the website in a browser.
26//!
27//! The update module only operates when using the installed version of the program which can be found in the
28//! [releases](https://github.com/OneilNvM/rl-hours-tracker/releases) section on the GitHub repository. This
29//! module uses the [`reqwest`] crate to make HTTP requests to the rl-hours-tracker repository in order to retrieve
30//! the new update from the releases section. This module has the functionality to check for any new updates, update
31//! the program, and clean up any additional files made during the update.
32//!
33//! ### Use Case
34//! Within the [`website_files`] module, there is a public function [`website_files::generate_website_files`],
35//! which writes the files for the website in the website directory in `RlHoursFolder`. This function accepts a
36//! [`bool`] value, which determines whether the option to open the website in a browser should appear when this
37//! function is called.
38//!
39//! ```
40//! use rl_hours_tracker::website_files;
41//!
42//! // This will generate the website files and prompt you with the option to open the
43//! // webstie in a browser.
44//! website_files::generate_website_files(true);
45//!
46//! // This will also generate the website but will not prompt the user to open the website
47//! // in a browser.
48//! website_files::generate_website_files(false);
49//! ```
50//!
51//! The [`update`] module has two public asynchronous functions available: [`update::check_for_update`] and [`update::update`].
52//! The [`update::check_for_update`] function is responsible for sending a HTTP request to the repository and checking the version
53//! number of the latest release, and comparing it to the current version of the program. The [`update::update`] function is responsible
54//! updating the program by sending a HTTP request to the repository to retrieve the update zip from the latest release, and unzipping the
55//! zip files contents to replace the old program files with the newest version.
56//!
57//! ```
58//! use rl_hours_tracker::update;
59//! use tokio::runtime::Runtime;
60//!
61//! // This creates a tokio runtime instance for running our function
62//! let rt = Runtime::new().unwrap();
63//!
64//! // This runs our asynchronous function which checks for an update
65//! rt.block_on(update::check_for_update())?;
66//! ```
67//!
68//! The [`update::check_for_update`] function does use the [`update::update`] function when it finds that there is a new release on the GitHub, however
69//! the update function can be used by itself in a different context if needed.
70//!
71//! ```
72//! use rl_hours_tracker::update;
73//! use tokio::runtime::Runtime;
74//!
75//! // This creates a tokio runtime instance for running our function
76//! let rt = Runtime::new().unwrap();
77//!
78//! // This runs our asynchronous function which updates the program
79//! rt.block_on(update::update())?;
80//! ```
81use chrono::Local;
82use colour::{black_bold, blue_ln_bold, cyan, green, green_ln_bold, red, white, yellow_ln_bold};
83use log::{error, info, warn, LevelFilter};
84use log4rs::{
85    append::{console::ConsoleAppender, file::FileAppender},
86    config::{Appender, Logger, Root},
87    encode::pattern::PatternEncoder,
88    Config, Handle,
89};
90use std::{
91    error::Error,
92    fmt::Display,
93    fs::{self, File},
94    io::{self, Read, Write},
95    process,
96    sync::{Arc, Mutex},
97    thread,
98    time::{Duration, SystemTime},
99};
100use stopwatch::Stopwatch;
101use sysinfo::System;
102use tokio::runtime::Runtime;
103
104use crate::calculate_past_two::calculate_past_two;
105
106pub mod calculate_past_two;
107#[cfg(test)]
108mod tests;
109pub mod update;
110pub mod website_files;
111pub mod winit_tray_icon;
112
113/// Type alias for Results which only return [`std::io::Error`] as its error variant.
114pub type IoResult<T> = Result<T, io::Error>;
115
116/// Contains the relevant data for running the program
117struct ProgramRunVars {
118    process_name: String,
119    is_waiting: bool,
120    option: String,
121    currently_tracking: Arc<Mutex<bool>>,
122    stop_tracker: Arc<Mutex<bool>>,
123}
124
125impl ProgramRunVars {
126    fn new(stop_tracker: Arc<Mutex<bool>>, currently_tracking: Arc<Mutex<bool>>) -> Self {
127        Self {
128            process_name: String::from("RocketLeague.exe"),
129            is_waiting: false,
130            option: String::with_capacity(3),
131            stop_tracker,
132            currently_tracking,
133        }
134    }
135}
136
137/// Custom error for [`calculate_past_two`] function
138#[derive(Debug, Clone)]
139pub struct PastTwoError;
140
141impl Display for PastTwoError {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        write!(
144            f,
145            "next closest date to the date two weeks ago could not be found."
146        )
147    }
148}
149
150impl Error for PastTwoError {}
151
152/// Initializes logging configuration for the program
153///
154/// Logs are stored in `C:/RLHoursFolder/logs`
155pub fn initialize_logging() -> Result<Handle, Box<dyn Error>> {
156    // Create appenders
157    let stdout = ConsoleAppender::builder().build();
158    let general_logs = FileAppender::builder()
159        .build("C:/RLHoursFolder/logs/general_$TIME{%Y-%m-%d_%H-%M-%S}.log")?;
160    let wti_errors = FileAppender::builder()
161        .build("C:/RLHoursFolder/logs/tray-icon_$TIME{%Y-%m-%d_%H-%M-%S}.log")?;
162    let requests = FileAppender::builder()
163        .encoder(Box::new(PatternEncoder::new("{d} - {m}{n}")))
164        .build("C:/RLHoursFolder/logs/requests.log")?;
165
166    // Create loggers
167    let rl_hours_tracker_logger = Logger::builder()
168        .additive(false)
169        .appenders(vec!["general_logs"])
170        .build("rl_hours_tracker", LevelFilter::Info);
171    let rl_hours_tracker_update_logger = Logger::builder()
172        .additive(false)
173        .appenders(vec!["requests", "general_logs"])
174        .build("rl_hours_tracker::update", LevelFilter::Trace);
175    let rl_hours_tracker_cpt_logger = Logger::builder()
176        .additive(false)
177        .appenders(vec!["general_logs"])
178        .build("rl_hours_tracker::calculate_past_two", LevelFilter::Info);
179    let rl_hours_tracker_wti_logger = Logger::builder()
180        .additive(false)
181        .appenders(vec!["general_logs"])
182        .build("rl_hours_tracker::winit_tray_icon", LevelFilter::Error);
183
184    // Move loggers and appenders into vectors
185    let loggers = vec![
186        rl_hours_tracker_logger,
187        rl_hours_tracker_update_logger,
188        rl_hours_tracker_cpt_logger,
189        rl_hours_tracker_wti_logger,
190    ];
191    let appenders = vec![
192        Appender::builder().build("stdout", Box::new(stdout)),
193        Appender::builder().build("general_logs", Box::new(general_logs)),
194        Appender::builder().build("requests", Box::new(requests)),
195        Appender::builder().build("wti_errors", Box::new(wti_errors)),
196    ];
197
198    let config = Config::builder()
199        .appenders(appenders)
200        .loggers(loggers)
201        .build(Root::builder().appender("stdout").build(LevelFilter::Warn))?;
202
203    // Initialize logging configuration
204    let handle = log4rs::init_config(config)?;
205
206    Ok(handle)
207}
208
209/// This runs the [`update::check_for_update`] function
210pub fn run_self_update() -> Result<(), Box<dyn Error>> {
211    let rt = Runtime::new()?;
212
213    rt.block_on(update::check_for_update())?;
214
215    Ok(())
216}
217
218/// This function runs the program
219pub fn run(stop_tracker: Arc<Mutex<bool>>, currently_tracking: Arc<Mutex<bool>>) {
220    let mut program = ProgramRunVars::new(stop_tracker, currently_tracking);
221
222    // Run the main loop
223    run_main_loop(&mut program);
224}
225
226/// This function creates the directories for the program. It creates a local [`Vec<Result>`]
227/// which stores [`fs::create_dir`] results.
228///
229/// This function then returns a [`Vec<Result>`] which stores any errors that may have occurred
230///
231/// # Errors
232/// This function stores an [`io::Error`] in the output Vector if there was any issue creating a folder.
233pub fn create_directory() -> Vec<IoResult<()>> {
234    // Create the folder directories for the program
235    let folder = fs::create_dir("C:\\RLHoursFolder");
236    let website_folder = fs::create_dir("C:\\RLHoursFolder\\website");
237    let website_pages = fs::create_dir("C:\\RLHoursFolder\\website\\pages");
238    let website_css = fs::create_dir("C:\\RLHoursFolder\\website\\css");
239    let website_js = fs::create_dir("C:\\RLHoursFolder\\website\\js");
240    let website_images = fs::create_dir("C:\\RLHoursFolder\\website\\images");
241
242    // Store the folder results in Vector
243    let folder_vec: Vec<IoResult<()>> = vec![
244        folder,
245        website_folder,
246        website_pages,
247        website_css,
248        website_js,
249        website_images,
250    ];
251
252    // Iterate through all the folder creations and filter for any errors
253    let result: Vec<IoResult<()>> = folder_vec.into_iter().filter(|f| f.is_err()).collect();
254
255    result
256}
257
258/// This function runs the main loop of the program. This checks if the `RocketLeague.exe` process is running and
259/// runs the [`record_hours`] function if it is running, otherwise it will continue to wait for the process to start.
260fn run_main_loop(program: &mut ProgramRunVars) {
261    loop {
262        // Check if the process is running
263        if check_for_process(&program.process_name) {
264            record_hours(
265                &program.process_name,
266                program.stop_tracker.clone(),
267                program.currently_tracking.clone(),
268            );
269
270            // Generate the website files
271            website_files::generate_website_files(true)
272                .unwrap_or_else(|e| warn!("failed to generate website files: {e}"));
273
274            program.is_waiting = false;
275
276            print!("End program (");
277            green!("y");
278            print!(" / ");
279            red!("n");
280            print!("): ");
281            std::io::stdout()
282                .flush()
283                .unwrap_or_else(|_| println!("End program (y/n)?\n"));
284            io::stdin()
285                .read_line(&mut program.option)
286                .unwrap_or_default();
287
288            if program.option.trim() == "y" || program.option.trim() == "Y" {
289                print!("{}[2K\r", 27 as char);
290                std::io::stdout()
291                    .flush()
292                    .expect("could not flush the output stream");
293                yellow_ln_bold!("Goodbye!");
294                process::exit(0);
295            } else if program.option.trim() == "n" || program.option.trim() == "N" {
296                program.option = String::with_capacity(3);
297                continue;
298            } else {
299                error!("Unexpected input! Ending program.");
300                process::exit(0)
301            }
302        } else {
303            // Print 'Waiting for Rocket League to start...' only once by changing the value of is_waiting to true
304            if !program.is_waiting {
305                green!("Waiting for Rocket League to start.\r");
306                io::stdout()
307                    .flush()
308                    .expect("could not flush the output stream");
309                thread::sleep(Duration::from_millis(500));
310                white!("Waiting for Rocket League to start..\r");
311                io::stdout()
312                    .flush()
313                    .expect("could not flush the output stream");
314                thread::sleep(Duration::from_millis(500));
315                black_bold!("Waiting for Rocket League to start...\r");
316                io::stdout()
317                    .flush()
318                    .expect("could not flush the output stream");
319                thread::sleep(Duration::from_millis(500));
320                print!("{}[2K\r", 27 as char);
321                red!("Waiting for Rocket League to start\r");
322                io::stdout()
323                    .flush()
324                    .expect("could not flush the output stream");
325                thread::sleep(Duration::from_millis(500));
326            }
327        }
328    }
329}
330
331/// This function takes in a reference string `process_name: &str` and starts a stopwatch
332/// which keeps track of the amount of seconds that pass whilst the process is running.
333/// The stopwatch is ended and the File operations are run at the end of the process.
334/// The date and elapsed time are stored in the `date.txt` file and the hours is stored in
335/// `hours.txt`
336fn record_hours(
337    process_name: &str,
338    stop_tracker: Arc<Mutex<bool>>,
339    currently_tracking: Arc<Mutex<bool>>,
340) {
341    let mut sw = Stopwatch::start_new();
342
343    blue_ln_bold!("\nRocket League is running\n");
344
345    *currently_tracking.try_lock().unwrap_or_else(|e| {
346        error!("error when attempting to access lock for currently_tracking: {e}");
347        panic!("could not access lock for currently_tracking");
348    }) = true;
349
350    // Start live stopwatch
351    live_stopwatch(process_name, stop_tracker.clone());
352
353    *currently_tracking.try_lock().unwrap_or_else(|e| {
354        error!("error when attempting to access lock for currently_tracking: {e}");
355        panic!("could not access lock for currently_tracking");
356    }) = false;
357
358    *stop_tracker.try_lock().unwrap_or_else(|e| {
359        error!("error when attempting to access lock for stop_tracking: {e}");
360        panic!("could not access lock for stop_tracking");
361    }) = false;
362
363    // Stop the stopwatch
364    sw.stop();
365
366    info!("Record Hours: START\n");
367
368    let seconds: u64 = sw.elapsed_ms() as u64 / 1000;
369    let hours: f32 = (sw.elapsed_ms() as f32 / 1000_f32) / 3600_f32;
370
371    let hours_result = File::open("C:\\RLHoursFolder\\hours.txt");
372    let date_result = File::open("C:\\RLHoursFolder\\date.txt");
373
374    // Write date and seconds to date.txt
375    write_to_date(date_result, &seconds).unwrap_or_else(|e| {
376        error!("error writing to date.txt: {e}");
377        process::exit(1);
378    });
379
380    // Buffer which stores the hours in the past two weeks
381    let hours_buffer = calculate_past_two().unwrap_or_else(|e| {
382        warn!("failed to calculate past two: {e}");
383        0
384    });
385
386    if hours_buffer != 0 {
387        let hours_past_two = hours_buffer as f32 / 3600_f32;
388
389        write_to_hours(hours_result, &seconds, &hours, &hours_past_two, &sw).unwrap_or_else(|e| {
390            error!("error writing to hours.txt: {e}");
391            process::exit(1);
392        });
393        info!("Record Hours: FINISHED\n")
394    } else {
395        warn!("past two returned zero seconds")
396    }
397}
398
399fn live_stopwatch(process_name: &str, stop_tracker: Arc<Mutex<bool>>) {
400    let mut timer_early = SystemTime::now();
401
402    let mut seconds: u8 = 0;
403    let mut minutes: u8 = 0;
404    let mut hours: u16 = 0;
405
406    while check_for_process(process_name)
407        && !*stop_tracker.try_lock().unwrap_or_else(|e| {
408            error!("error when attempting to access lock for stop_tracking: {e}");
409            panic!("could not access lock for stop_tracking");
410        })
411    {
412        let timer_now = timer_early
413            .checked_add(Duration::from_millis(999))
414            .unwrap_or_else(|| {
415                error!("could not return system time");
416                SystemTime::now()
417            });
418
419        let delay = timer_now.duration_since(timer_early).unwrap_or_else(|e| {
420            warn!(
421                "system time is ahead of the timer. SystemTime difference: {:?}",
422                e.duration()
423            );
424            Duration::from_millis(1000)
425        });
426
427        // Check if current seconds are greater than or equal to 1 minute
428        if seconds == 59 {
429            seconds = 0;
430            minutes += 1;
431
432            // Check if current minutes are greater than or equal to 1 hour
433            if minutes == 60 {
434                minutes = 0;
435                hours += 1;
436            }
437        } else {
438            seconds += 1;
439        }
440        print!("{}[2K\r", 27 as char);
441
442        // Print the output for the timer
443        if hours < 10 && minutes < 10 && seconds < 10 {
444            cyan!("Time Elapsed: 0{}:0{}:0{}\r", hours, minutes, seconds);
445        } else if hours >= 10 {
446            if minutes < 10 && seconds < 10 {
447                cyan!("Time Elapsed: {}:0{}:0{}\r", hours, minutes, seconds);
448            } else if minutes < 10 && seconds >= 10 {
449                cyan!("Time Elapsed: {}:0{}:{}\r", hours, minutes, seconds);
450            } else if minutes >= 10 && seconds < 10 {
451                cyan!("Time Elapsed: {}:{}:0{}\r", hours, minutes, seconds);
452            } else {
453                cyan!("Time Elapsed: {}:{}:{}\r", hours, minutes, seconds);
454            }
455        } else if hours < 10 && minutes >= 10 && seconds < 10 {
456            cyan!("Time Elapsed: 0{}:{}:0{}\r", hours, minutes, seconds);
457        } else if hours < 10 && minutes < 10 && seconds >= 10 {
458            cyan!("Time Elapsed: 0{}:0{}:{}\r", hours, minutes, seconds);
459        } else {
460            cyan!("Time Elapsed: 0{}:{}:{}\r", hours, minutes, seconds);
461        }
462
463        // Flush the output
464        io::stdout()
465            .flush()
466            .unwrap_or_else(|_| warn!("could not flush output stream"));
467
468        thread::sleep(delay);
469
470        timer_early += Duration::from_millis(999)
471    }
472}
473
474/// This function takes the `contents: &str` parameter which contains the contents from the `hours.txt` file
475/// and returns a tuple of `(u64, f32)` which contains the seconds and hours from the file.
476fn retrieve_time(contents: &str) -> Result<(u64, f32), Box<dyn Error>> {
477    // Split the contents by newline character
478    let split_new_line: Vec<&str> = contents.split("\n").collect();
479
480    // Split the seconds and hours string references by whitspace
481    let split_whitspace_sec: Vec<&str> = split_new_line[1].split_whitespace().collect();
482    let split_whitespace_hrs: Vec<&str> = split_new_line[2].split_whitespace().collect();
483
484    // Split the seconds and hours string references by characters
485    let split_char_sec = split_whitspace_sec[2].chars();
486    let split_char_hrs = split_whitespace_hrs[2].chars();
487
488    let mut sec_vec: Vec<char> = vec![];
489    let mut hrs_vec: Vec<char> = vec![];
490
491    // Loop through Chars iterator to push only numeric characters to the seconds Vector
492    for num in split_char_sec {
493        if num.is_numeric() {
494            sec_vec.push(num);
495        }
496    }
497
498    // Loop through the Chars iterator to push numeric characters (plus the period character for decimals) to the hours Vector
499    for num in split_char_hrs {
500        if num.is_numeric() || num == '.' {
501            hrs_vec.push(num);
502        }
503    }
504
505    let seconds_str: String = sec_vec.iter().collect();
506    let hours_str: String = hrs_vec.iter().collect();
507
508    let old_seconds: u64 = seconds_str.parse()?;
509    let old_hours: f32 = hours_str.parse()?;
510
511    // Return a tuple of the old seconds and old hours
512    Ok((old_seconds, old_hours))
513}
514
515/// This function constructs a new [`String`] which will have the contents to write to `hours.txt` with new hours and seconds
516/// and returns it.
517fn return_new_hours(
518    contents: &str,
519    seconds: &u64,
520    hours: &f32,
521    past_two: &f32,
522) -> Result<String, Box<dyn Error>> {
523    yellow_ln_bold!("Getting old hours...");
524    // Retrieves the old hours and seconds from the contents String
525    let (old_seconds, old_hours) = retrieve_time(contents)?;
526
527    let added_seconds = old_seconds + *seconds;
528    let added_hours = old_hours + *hours;
529
530    Ok(format!(
531        "Rocket League Hours\nTotal Seconds: {}s\nTotal Hours: {:.1}hrs\nHours Past Two Weeks: {:.1}hrs\n",
532        added_seconds, added_hours, past_two
533    ))
534}
535
536/// This function writes the new contents to the `hours.txt` file. This includes the total `seconds`, `hours`, and `hours_past_two`.
537/// This function then returns a [`Result<()>`] when file operations were all successful.
538///
539/// # Errors
540/// This function returns an [`io::Error`] if any file operations failed.
541fn write_to_hours(
542    hours_result: IoResult<File>,
543    seconds: &u64,
544    hours: &f32,
545    hours_past_two: &f32,
546    sw: &Stopwatch,
547) -> Result<(), Box<dyn Error>> {
548    // Check if the file exists
549    if let Ok(mut file) = hours_result {
550        let mut contents = String::new();
551
552        // Attempt to read from the hours.txt file
553        file.read_to_string(&mut contents)?;
554
555        // Stores the new contents for the file as a String
556        let rl_hours_str = return_new_hours(&contents, seconds, hours, hours_past_two)?;
557
558        // Attempt to write to hours.txt
559        let mut truncated_file = File::create("C:\\RLHoursFolder\\hours.txt")?;
560
561        yellow_ln_bold!("Writing to hours.txt...");
562
563        // Check if the write was successful
564        truncated_file.write_all(rl_hours_str.as_bytes())?;
565
566        green_ln_bold!("Successful!\n");
567        Ok(())
568    } else {
569        // Check if the file was created successfully
570        let mut file = File::create("C:\\RLHoursFolder\\hours.txt")?;
571        let total_seconds = sw.elapsed_ms() / 1000;
572        let total_hours: f32 = (sw.elapsed_ms() as f32 / 1000_f32) / 3600_f32;
573        let rl_hours_str = format!(
574                                "Rocket League Hours\nTotal Seconds: {}s\nTotal Hours: {:.1}hrs\nHours Past Two Weeks: {:.1}hrs\n", total_seconds, total_hours, hours_past_two
575                            );
576
577        yellow_ln_bold!("Writing to hours.txt...");
578
579        // Checks if the write was successful
580        file.write_all(rl_hours_str.as_bytes())?;
581
582        green_ln_bold!("The hours file was successfully created");
583        Ok(())
584    }
585}
586
587/// This function writes new contents to the `date.txt` file. This uses the [`Local`] struct which allows us to use the [`Local::now()`]
588/// function to retrieve the local date and time as [`DateTime<Local>`]. The date is then turned into a [`NaiveDate`] by using [`DateTime<Local>::date_naive()`]
589/// which returns us the date by itself.
590///
591/// # Errors
592/// Returns an [`io::Error`] if there were any file operations which failed.
593fn write_to_date(date_result: IoResult<File>, seconds: &u64) -> IoResult<()> {
594    // Check if the date file exists
595    if date_result.is_ok() {
596        let mut append_date_result = File::options()
597            .append(true)
598            .open("C:\\RLHoursFolder\\date.txt")?;
599
600        // Attenot to open the date.txt file
601        let today = Local::now().date_naive();
602
603        let today_str = format!("{} {}s\n", today, seconds);
604
605        yellow_ln_bold!("Appending to date.txt...");
606
607        // Checks if the write was successful
608        append_date_result.write_all(today_str.as_bytes())?;
609
610        green_ln_bold!("Successful!\n");
611        Ok(())
612    } else {
613        // Check if the file was created
614        let mut file = File::create("C:\\RLHoursFolder\\date.txt")?;
615        let today = Local::now().date_naive();
616
617        let today_str = format!("{} {}s\n", today, seconds);
618
619        yellow_ln_bold!("Appending to date.txt...");
620
621        // Checks if the write was successful
622        file.write_all(today_str.as_bytes())?;
623
624        green_ln_bold!("The date file was successfully created");
625        Ok(())
626    }
627}
628
629/// This function checks if the process passed in via `name: &str` is running and returns a [`bool`] value
630fn check_for_process(name: &str) -> bool {
631    let sys = System::new_all();
632    let mut result = false;
633
634    for process in sys.processes_by_exact_name(name.as_ref()) {
635        if process.name() == name {
636            result = true;
637            break;
638        }
639    }
640
641    result
642}