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    'main_loop: 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                break 'main_loop;
290            } else if program.option.trim() == "n" || program.option.trim() == "N" {
291                program.option = String::with_capacity(3);
292                continue;
293            } else {
294                error!("Unexpected input! Ending program.");
295                break 'main_loop;
296            }
297        } else {
298            // Print 'Waiting for Rocket League to start...' only once by changing the value of is_waiting to true
299            if !program.is_waiting {
300                green!("Waiting for Rocket League to start.\r");
301                io::stdout()
302                    .flush()
303                    .expect("could not flush the output stream");
304                thread::sleep(Duration::from_millis(500));
305                white!("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                black_bold!("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                print!("{}[2K\r", 27 as char);
316                red!("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            }
322        }
323    }
324}
325
326/// This function takes in a reference string `process_name: &str` and starts a stopwatch
327/// which keeps track of the amount of seconds that pass whilst the process is running.
328/// The stopwatch is ended and the File operations are run at the end of the process.
329/// The date and elapsed time are stored in the `date.txt` file and the hours is stored in
330/// `hours.txt`
331fn record_hours(
332    process_name: &str,
333    stop_tracker: Arc<Mutex<bool>>,
334    currently_tracking: Arc<Mutex<bool>>,
335) {
336    let mut sw = Stopwatch::start_new();
337
338    blue_ln_bold!("\nRocket League is running\n");
339
340    *currently_tracking.try_lock().unwrap_or_else(|e| {
341        error!("error when attempting to access lock for currently_tracking: {e}");
342        panic!("could not access lock for currently_tracking");
343    }) = true;
344
345    // Start live stopwatch
346    live_stopwatch(process_name, stop_tracker.clone());
347
348    *currently_tracking.try_lock().unwrap_or_else(|e| {
349        error!("error when attempting to access lock for currently_tracking: {e}");
350        panic!("could not access lock for currently_tracking");
351    }) = false;
352
353    *stop_tracker.try_lock().unwrap_or_else(|e| {
354        error!("error when attempting to access lock for stop_tracking: {e}");
355        panic!("could not access lock for stop_tracking");
356    }) = false;
357
358    // Stop the stopwatch
359    sw.stop();
360
361    info!("Record Hours: START\n");
362
363    let seconds: u64 = sw.elapsed_ms() as u64 / 1000;
364    let hours: f32 = (sw.elapsed_ms() as f32 / 1000_f32) / 3600_f32;
365
366    let hours_result = File::open("C:\\RLHoursFolder\\hours.txt");
367    let date_result = File::open("C:\\RLHoursFolder\\date.txt");
368
369    // Write date and seconds to date.txt
370    write_to_date(date_result, &seconds).unwrap_or_else(|e| {
371        error!("error writing to date.txt: {e}");
372        process::exit(1);
373    });
374
375    // Buffer which stores the hours in the past two weeks
376    let hours_buffer = calculate_past_two().unwrap_or_else(|e| {
377        warn!("failed to calculate past two: {e}");
378        0
379    });
380
381    if hours_buffer != 0 {
382        let hours_past_two = hours_buffer as f32 / 3600_f32;
383
384        write_to_hours(hours_result, &seconds, &hours, &hours_past_two, &sw).unwrap_or_else(|e| {
385            error!("error writing to hours.txt: {e}");
386            process::exit(1);
387        });
388        info!("Record Hours: FINISHED\n")
389    } else {
390        warn!("past two returned zero seconds")
391    }
392}
393
394fn live_stopwatch(process_name: &str, stop_tracker: Arc<Mutex<bool>>) {
395    let mut timer_early = SystemTime::now();
396
397    let mut seconds: u8 = 0;
398    let mut minutes: u8 = 0;
399    let mut hours: u16 = 0;
400
401    while check_for_process(process_name)
402        && !*stop_tracker.try_lock().unwrap_or_else(|e| {
403            error!("error when attempting to access lock for stop_tracking: {e}");
404            panic!("could not access lock for stop_tracking");
405        })
406    {
407        let timer_now = timer_early
408            .checked_add(Duration::from_millis(999))
409            .unwrap_or_else(|| {
410                error!("could not return system time");
411                SystemTime::now()
412            });
413
414        let delay = timer_now.duration_since(timer_early).unwrap_or_else(|e| {
415            warn!(
416                "system time is ahead of the timer. SystemTime difference: {:?}",
417                e.duration()
418            );
419            Duration::from_millis(1000)
420        });
421
422        // Check if current seconds are greater than or equal to 1 minute
423        if seconds == 59 {
424            seconds = 0;
425            minutes += 1;
426
427            // Check if current minutes are greater than or equal to 1 hour
428            if minutes == 60 {
429                minutes = 0;
430                hours += 1;
431            }
432        } else {
433            seconds += 1;
434        }
435        print!("{}[2K\r", 27 as char);
436
437        // Print the output for the timer
438        if hours < 10 && minutes < 10 && seconds < 10 {
439            cyan!("Time Elapsed: 0{}:0{}:0{}\r", hours, minutes, seconds);
440        } else if hours >= 10 {
441            if minutes < 10 && seconds < 10 {
442                cyan!("Time Elapsed: {}:0{}:0{}\r", hours, minutes, seconds);
443            } else if minutes < 10 && seconds >= 10 {
444                cyan!("Time Elapsed: {}:0{}:{}\r", hours, minutes, seconds);
445            } else if minutes >= 10 && seconds < 10 {
446                cyan!("Time Elapsed: {}:{}:0{}\r", hours, minutes, seconds);
447            } else {
448                cyan!("Time Elapsed: {}:{}:{}\r", hours, minutes, seconds);
449            }
450        } else if hours < 10 && minutes >= 10 && seconds < 10 {
451            cyan!("Time Elapsed: 0{}:{}:0{}\r", hours, minutes, seconds);
452        } else if hours < 10 && minutes < 10 && seconds >= 10 {
453            cyan!("Time Elapsed: 0{}:0{}:{}\r", hours, minutes, seconds);
454        } else {
455            cyan!("Time Elapsed: 0{}:{}:{}\r", hours, minutes, seconds);
456        }
457
458        // Flush the output
459        io::stdout()
460            .flush()
461            .unwrap_or_else(|_| warn!("could not flush output stream"));
462
463        thread::sleep(delay);
464
465        timer_early += Duration::from_millis(999)
466    }
467}
468
469/// This function takes the `contents: &str` parameter which contains the contents from the `hours.txt` file
470/// and returns a tuple of `(u64, f32)` which contains the seconds and hours from the file.
471fn retrieve_time(contents: &str) -> Result<(u64, f32), Box<dyn Error>> {
472    // Split the contents by newline character
473    let split_new_line: Vec<&str> = contents.split("\n").collect();
474
475    // Split the seconds and hours string references by whitspace
476    let split_whitspace_sec: Vec<&str> = split_new_line[1].split_whitespace().collect();
477    let split_whitespace_hrs: Vec<&str> = split_new_line[2].split_whitespace().collect();
478
479    // Split the seconds and hours string references by characters
480    let split_char_sec = split_whitspace_sec[2].chars();
481    let split_char_hrs = split_whitespace_hrs[2].chars();
482
483    let mut sec_vec: Vec<char> = vec![];
484    let mut hrs_vec: Vec<char> = vec![];
485
486    // Loop through Chars iterator to push only numeric characters to the seconds Vector
487    for num in split_char_sec {
488        if num.is_numeric() {
489            sec_vec.push(num);
490        }
491    }
492
493    // Loop through the Chars iterator to push numeric characters (plus the period character for decimals) to the hours Vector
494    for num in split_char_hrs {
495        if num.is_numeric() || num == '.' {
496            hrs_vec.push(num);
497        }
498    }
499
500    let seconds_str: String = sec_vec.iter().collect();
501    let hours_str: String = hrs_vec.iter().collect();
502
503    let old_seconds: u64 = seconds_str.parse()?;
504    let old_hours: f32 = hours_str.parse()?;
505
506    // Return a tuple of the old seconds and old hours
507    Ok((old_seconds, old_hours))
508}
509
510/// This function constructs a new [`String`] which will have the contents to write to `hours.txt` with new hours and seconds
511/// and returns it.
512fn return_new_hours(
513    contents: &str,
514    seconds: &u64,
515    hours: &f32,
516    past_two: &f32,
517) -> Result<String, Box<dyn Error>> {
518    yellow_ln_bold!("Getting old hours...");
519    // Retrieves the old hours and seconds from the contents String
520    let (old_seconds, old_hours) = retrieve_time(contents)?;
521
522    let added_seconds = old_seconds + *seconds;
523    let added_hours = old_hours + *hours;
524
525    Ok(format!(
526        "Rocket League Hours\nTotal Seconds: {}s\nTotal Hours: {:.1}hrs\nHours Past Two Weeks: {:.1}hrs\n",
527        added_seconds, added_hours, past_two
528    ))
529}
530
531/// This function writes the new contents to the `hours.txt` file. This includes the total `seconds`, `hours`, and `hours_past_two`.
532/// This function then returns a [`Result<()>`] when file operations were all successful.
533///
534/// # Errors
535/// This function returns an [`io::Error`] if any file operations failed.
536fn write_to_hours(
537    hours_result: IoResult<File>,
538    seconds: &u64,
539    hours: &f32,
540    hours_past_two: &f32,
541    sw: &Stopwatch,
542) -> Result<(), Box<dyn Error>> {
543    // Check if the file exists
544    if let Ok(mut file) = hours_result {
545        let mut contents = String::new();
546
547        // Attempt to read from the hours.txt file
548        file.read_to_string(&mut contents)?;
549
550        // Stores the new contents for the file as a String
551        let rl_hours_str = return_new_hours(&contents, seconds, hours, hours_past_two)?;
552
553        // Attempt to write to hours.txt
554        let mut truncated_file = File::create("C:\\RLHoursFolder\\hours.txt")?;
555
556        yellow_ln_bold!("Writing to hours.txt...");
557
558        // Check if the write was successful
559        truncated_file.write_all(rl_hours_str.as_bytes())?;
560
561        green_ln_bold!("Successful!\n");
562        Ok(())
563    } else {
564        // Check if the file was created successfully
565        let mut file = File::create("C:\\RLHoursFolder\\hours.txt")?;
566        let total_seconds = sw.elapsed_ms() / 1000;
567        let total_hours: f32 = (sw.elapsed_ms() as f32 / 1000_f32) / 3600_f32;
568        let rl_hours_str = format!(
569                                "Rocket League Hours\nTotal Seconds: {}s\nTotal Hours: {:.1}hrs\nHours Past Two Weeks: {:.1}hrs\n", total_seconds, total_hours, hours_past_two
570                            );
571
572        yellow_ln_bold!("Writing to hours.txt...");
573
574        // Checks if the write was successful
575        file.write_all(rl_hours_str.as_bytes())?;
576
577        green_ln_bold!("The hours file was successfully created");
578        Ok(())
579    }
580}
581
582/// This function writes new contents to the `date.txt` file. This uses the [`Local`] struct which allows us to use the [`Local::now()`]
583/// 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()`]
584/// which returns us the date by itself.
585///
586/// # Errors
587/// Returns an [`io::Error`] if there were any file operations which failed.
588fn write_to_date(date_result: IoResult<File>, seconds: &u64) -> IoResult<()> {
589    // Check if the date file exists
590    if date_result.is_ok() {
591        let mut append_date_result = File::options()
592            .append(true)
593            .open("C:\\RLHoursFolder\\date.txt")?;
594
595        // Attenot to open the date.txt file
596        let today = Local::now().date_naive();
597
598        let today_str = format!("{} {}s\n", today, seconds);
599
600        yellow_ln_bold!("Appending to date.txt...");
601
602        // Checks if the write was successful
603        append_date_result.write_all(today_str.as_bytes())?;
604
605        green_ln_bold!("Successful!\n");
606        Ok(())
607    } else {
608        // Check if the file was created
609        let mut file = File::create("C:\\RLHoursFolder\\date.txt")?;
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        file.write_all(today_str.as_bytes())?;
618
619        green_ln_bold!("The date file was successfully created");
620        Ok(())
621    }
622}
623
624/// This function checks if the process passed in via `name: &str` is running and returns a [`bool`] value
625fn check_for_process(name: &str) -> bool {
626    let sys = System::new_all();
627    let mut result = false;
628
629    for process in sys.processes_by_exact_name(name.as_ref()) {
630        if process.name() == name {
631            result = true;
632            break;
633        }
634    }
635
636    result
637}