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