0.4.0: Add game switching, implement proper error handling thru out installer, and splash screen, probably more.

This commit is contained in:
RabbyDevs 2025-05-04 17:29:00 +03:00
parent 898f32f38c
commit 5239d2cd98
6 changed files with 238 additions and 97 deletions

2
Cargo.lock generated
View file

@ -3155,7 +3155,7 @@ dependencies = [
[[package]] [[package]]
name = "reversed-rooms-launcher" name = "reversed-rooms-launcher"
version = "0.3.0" version = "0.4.0"
dependencies = [ dependencies = [
"directories", "directories",
"file-format", "file-format",

View file

@ -1,7 +1,7 @@
#![feature(let_chains)] #![feature(let_chains)]
[package] [package]
name = "reversed-rooms-launcher" name = "reversed-rooms-launcher"
version = "0.3.0" version = "0.4.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View file

@ -469,8 +469,8 @@ impl Video {
} }
/// Get the size/resolution of the video as `(width, height)`. /// Get the size/resolution of the video as `(width, height)`.
pub fn size(&self) -> (i32, i32) { pub fn size(&self) -> (u32, u32) {
(self.read().width, self.read().height) (self.read().width.try_into().unwrap(), self.read().height.try_into().unwrap())
} }
/// Get the framerate of the video as frames per second. /// Get the framerate of the video as frames per second.

View file

@ -1,5 +1,6 @@
use std::path::PathBuf;
use std::{fs::create_dir_all, io::Write, path::Path}; use std::{fs::create_dir_all, io::Write, path::Path};
use std::fs::OpenOptions; use std::fs::{DirEntry, OpenOptions};
use directories::ProjectDirs; use directories::ProjectDirs;
use reqwest::blocking::Client; use reqwest::blocking::Client;
@ -150,42 +151,61 @@ fn refresh_kuro_install(proj_dirs: &ProjectDirs, client: &Client) -> Result<(),
} }
fn update_file_if_needed(dir: &Path, client: &Client, file_url: &str, file_type: &str) -> Result<(), String> { fn update_file_if_needed(dir: &Path, client: &Client, file_url: &str, file_type: &str) -> Result<(), String> {
let current_file: Option<DirEntry> = {
let mut current_file = None;
for path in dir.read_dir().unwrap() {
let path = path.unwrap();
if path.file_name().into_string().unwrap().starts_with(format!("{}_", file_type).as_str()) {
current_file = Some(path);
}
}
current_file
};
let filename = extract_filename_from_url(file_url); let filename = extract_filename_from_url(file_url);
let expected_file = format!("{}_{}", file_type, filename); let expected_file = format!("{}_{}", file_type, filename);
let file_path = dir.join(&expected_file); let file_path = dir.join(&expected_file);
let file_exists = file_path.exists(); if let Some(file) = current_file {
if filename != file.file_name().into_string().unwrap().strip_prefix(format!("{}_", file_type).as_str()).unwrap() {
if !file_exists { update_file(client, file_path, file_type, file_url)?;
eprintln!("Downloading {} file from: {}", file_type, file_url); } else {
let file_bytes = client eprintln!("{} file already exists at: {}", file_type, file.path().display());
.get(file_url) }
.send()
.map_err(|e| format!("Failed to send request for {} file: {}", file_type, e))?
.bytes()
.map_err(|e| format!("Failed to get bytes for {} file: {}", file_type, e))?;
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&file_path)
.map_err(|e| format!("Failed to create {} file ({}): {}", file_type, file_path.display(), e))?;
file.write_all(&file_bytes)
.map_err(|e| format!("Failed to write {} file: {}", file_type, e))?;
file.flush()
.map_err(|e| format!("Failed to flush {} file: {}", file_type, e))?;
eprintln!("Successfully downloaded {} file to: {}", file_type, file_path.display());
} else { } else {
eprintln!("{} file already exists at: {}", file_type, file_path.display()); update_file(client, file_path, file_type, file_url)?;
} }
Ok(()) Ok(())
} }
fn update_file(client: &Client, file_path: PathBuf, file_type: &str, file_url: &str) -> Result<(), String> {
eprintln!("Downloading {} file from: {}", file_type, file_url);
let file_bytes = client
.get(file_url)
.send()
.map_err(|e| format!("Failed to send request for {} file: {}", file_type, e))?
.bytes()
.map_err(|e| format!("Failed to get bytes for {} file: {}", file_type, e))?;
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&file_path)
.map_err(|e| format!("Failed to create {} file ({}): {}", file_type, file_path.display(), e))?;
file.write_all(&file_bytes)
.map_err(|e| format!("Failed to write {} file: {}", file_type, e))?;
file.flush()
.map_err(|e| format!("Failed to flush {} file: {}", file_type, e))?;
eprintln!("Successfully downloaded {} file to: {}", file_type, file_path.display());
Ok(())
}
fn refresh_hoyo_install(proj_dirs: &ProjectDirs, client: &Client) -> Result<(), String> { fn refresh_hoyo_install(proj_dirs: &ProjectDirs, client: &Client) -> Result<(), String> {
let hoyo_url = "https://sg-hyp-api.hoyoverse.com/hyp/hyp-connect/api/getGames?launcher_id=VYTpXlbWo8&language=en-us"; let hoyo_url = "https://sg-hyp-api.hoyoverse.com/hyp/hyp-connect/api/getGames?launcher_id=VYTpXlbWo8&language=en-us";
@ -227,6 +247,9 @@ fn refresh_hoyo_install(proj_dirs: &ProjectDirs, client: &Client) -> Result<(),
let icon_url = &game.display.icon.url; let icon_url = &game.display.icon.url;
update_file_if_needed(data_dir, client, icon_url, "icon")?; update_file_if_needed(data_dir, client, icon_url, "icon")?;
let bg_url = &game.display.background.url;
update_file_if_needed(data_dir, client, bg_url, "background")?;
} }
Ok(()) Ok(())

View file

@ -1,5 +1,4 @@
// TODO: fix loading the save data lol // #![windows_subsystem = "windows"]
#![windows_subsystem = "windows"]
mod utils; mod utils;
mod components; mod components;
@ -7,13 +6,13 @@ use components::installer::refresh_install;
use directories::ProjectDirs; use directories::ProjectDirs;
use ::image::{DynamicImage, ImageReader}; use ::image::{DynamicImage, ImageReader};
use iced::{ use iced::{
alignment::Vertical::{Bottom, Top}, border, event, font::{Stretch, Weight}, mouse::Interaction, widget::{button, column, container, image, mouse_area, opaque, row, stack, text, Space}, window::{self, icon, Settings}, Alignment::Center, Color, Element, Event, Font, Length, Padding, Size, Subscription, Task alignment::Vertical::{Bottom, Top}, border, event, font::{Stretch, Weight}, mouse::Interaction, widget::{button, center, column, container, image, mouse_area, opaque, row, stack, text, Space}, window::{self, icon, Settings}, Alignment::Center, Color, Element, Event, Font, Length, Padding, Size, Subscription, Task
}; };
use iced_video_player::{Video, VideoPlayer}; use iced_video_player::{Video, VideoPlayer};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use strum::IntoEnumIterator; use strum::IntoEnumIterator;
use strum_macros::EnumIter; use strum_macros::EnumIter;
use utils::visual_helper::{get_game_background, get_game_icon_dynamic_image, style_container}; use utils::visual_helper::{get_game_background, get_game_icon_dynamic_image, get_game_splash_dynamic_image, style_container};
use std::{ use std::{
collections::HashMap, env, fs::{self, create_dir_all, read_to_string}, io::{Cursor, Write}, path::PathBuf collections::HashMap, env, fs::{self, create_dir_all, read_to_string}, io::{Cursor, Write}, path::PathBuf
}; };
@ -77,6 +76,21 @@ enum PossibleGames {
GenshinImpact, GenshinImpact,
} }
trait IdentifibleGameType {
fn get_game_preferred_size(&self) -> (u32, u32);
}
impl IdentifibleGameType for PossibleGames {
fn get_game_preferred_size(&self) -> (u32, u32) {
match self {
PossibleGames::WutheringWaves => (1280, 760),
PossibleGames::HonkaiStarRail => (1280, 720),
PossibleGames::ZenlessZoneZero => (1280, 720),
PossibleGames::GenshinImpact => (1280, 720),
}
}
}
#[derive(Debug)] #[derive(Debug)]
enum Launcher { enum Launcher {
Loaded(Box<State>), Loaded(Box<State>),
@ -95,17 +109,21 @@ struct State {
installed_game_servers: Vec<PossibleGames>, installed_game_servers: Vec<PossibleGames>,
db_software_installed: bool, db_software_installed: bool,
background: Option<LauncherBackground>, background: Option<LauncherBackground>,
splash_images: HashMap<PossibleGames, DynamicImage>,
icon_images: HashMap<PossibleGames, DynamicImage> icon_images: HashMap<PossibleGames, DynamicImage>
} }
impl State { impl State {
fn get_background_element(&self) -> Element<Message> { fn get_background_element(&self) -> Option<Element<Message>> {
match self.background.as_ref().unwrap() { if let Some(background) = self.background.as_ref() {
LauncherBackground::Video(video) => VideoPlayer::new(video).into(), match background {
LauncherBackground::Image(img) => image(image::Handle::from_rgba(img.width(), img.height(), img.to_rgba8().into_raw())).into(), LauncherBackground::Video(video) => Some(VideoPlayer::new(video).into()),
LauncherBackground::Image(img) => Some(image(image::Handle::from_rgba(img.width(), img.height(), img.to_rgba8().into_raw())).into()),
}
} else {
None
} }
} }
fn get_game_icon_row(&self) -> Element<Message> { fn get_game_icon_row(&self) -> Element<Message> {
container(row![ container(row![
self.create_game_icon(&PossibleGames::WutheringWaves), self.create_game_icon(&PossibleGames::WutheringWaves),
@ -117,23 +135,36 @@ impl State {
).align_x(Center).width(Length::Fill).into() ).align_x(Center).width(Length::Fill).into()
} }
fn create_game_icon(&self, game: &PossibleGames) -> Element<Message> { fn create_game_icon(&self, game: &PossibleGames) -> Element<Message> {
let icon = self.icon_images.get(game).unwrap(); if let Some(icon) = self.icon_images.get(game) {
let img = if &self.selected_game == game { let img = if &self.selected_game == game {
image(icon.clone().into_handle()) image(icon.clone().into_handle())
.width(Length::Fixed(52.0)) .width(Length::Fixed(52.0))
.height(Length::Fixed(68.0)) .height(Length::Fixed(68.0))
} else { } else {
image(icon.brighten(-85).blur(2.5).adjust_contrast(-15.0).into_handle()) image(icon.brighten(-85).blur(2.5).adjust_contrast(-15.0).into_handle())
.width(Length::Fixed(48.0)) .width(Length::Fixed(48.0))
.height(Length::Fixed(64.0)) .height(Length::Fixed(64.0))
}; };
mouse_area( mouse_area(
img img
) )
// .on_press(Message::GameSelected(game.clone())) .on_press(Message::GameSelected(game.clone()))
.interaction(Interaction::Pointer) .interaction(Interaction::Pointer)
.into() .into()
} else {
text("loading").size(10).into()
}
}
fn get_splash(&self) -> Option<Element<Message>> {
if let Some(splash) = self.splash_images.get(&self.selected_game) {
let preferred_size = self.selected_game.get_game_preferred_size();
Some(image(splash.clone().into_handle())
.width(Length::Fixed(preferred_size.0 as f32))
.height(Length::Fixed(preferred_size.1 as f32)).into())
} else {
None
}
} }
} }
@ -153,7 +184,6 @@ impl From<SavedState> for Box<State> {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum LoadError { enum LoadError {
File,
Format, Format,
} }
@ -166,8 +196,12 @@ enum SaveError {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum Message { enum Message {
EventOccurred(Event), EventOccurred(Event),
DragStarted(), DragWindow,
// GameSelected(PossibleGames), LoadIcons(HashMap<PossibleGames, DynamicImage>),
LoadSplashes(HashMap<PossibleGames, DynamicImage>),
LoadBackground(()),
RefreshInstall(()),
GameSelected(PossibleGames),
Close, Close,
Minimize Minimize
} }
@ -181,19 +215,21 @@ impl State {
} }
fn load(self) -> Result<State, LoadError> { fn load(self) -> Result<State, LoadError> {
let contents = read_to_string(Self::path()).map_err(|_| LoadError::File)?; if let Ok(contents) = read_to_string(Self::path()) {
let saved_state: SavedState = serde_json::from_str(&contents).map_err(|_| LoadError::Format)?;
let saved_state: SavedState = Ok(State {
serde_json::from_str(&contents).map_err(|_| LoadError::Format)?; selected_game: saved_state.selected_game,
installed_games: saved_state.installed_games,
Ok(State { installed_game_servers: saved_state.installed_game_servers,
selected_game: saved_state.selected_game, db_software_installed: saved_state.db_software_installed,
installed_games: saved_state.installed_games, background: self.background,
installed_game_servers: saved_state.installed_game_servers, splash_images: self.splash_images,
db_software_installed: saved_state.db_software_installed, icon_images: self.icon_images,
background: self.background, })
icon_images: self.icon_images } else {
}) Ok(self)
}
} }
fn save(&mut self) -> Result<(), SaveError> { fn save(&mut self) -> Result<(), SaveError> {
@ -222,22 +258,29 @@ impl State {
} }
} }
async fn get_icons() -> HashMap<PossibleGames, DynamicImage> {
let mut icons: HashMap<PossibleGames, DynamicImage> = HashMap::new();
for game in PossibleGames::iter() {
let icon = get_game_icon_dynamic_image(&game);
icons.insert(game, icon);
};
icons
}
async fn get_splashes() -> HashMap<PossibleGames, DynamicImage> {
let mut splashes: HashMap<PossibleGames, DynamicImage> = HashMap::new();
for game in PossibleGames::iter() {
let splash = get_game_splash_dynamic_image(&game);
if let Some(splash) = splash {splashes.insert(game, splash)} else {continue};
};
splashes
}
async fn empty() {}
impl Launcher { impl Launcher {
fn boot() -> (Self, Task<Message>) { fn boot() -> (Self, Task<Message>) {
refresh_install().unwrap(); (Self::Loaded(Box::new(State::default().load().unwrap())), Task::batch([Task::perform(empty(), Message::LoadBackground), Task::perform(get_icons(), Message::LoadIcons), Task::perform(get_splashes(), Message::LoadSplashes), Task::perform(empty(), Message::RefreshInstall)]))
let launcher_bg = get_game_background(&PossibleGames::default());
let mut icons = HashMap::new();
for game in PossibleGames::iter() {
let icon = get_game_icon_dynamic_image(&game);
icons.insert(game, icon);
}
let final_state = State {
background: Some(launcher_bg),
icon_images: icons,
..State::default()
};
(Self::Loaded(Box::new(final_state)), Task::none())
} }
fn title(&self) -> String { fn title(&self) -> String {
@ -248,7 +291,7 @@ impl Launcher {
match self { match self {
Launcher::Loaded(state) => { Launcher::Loaded(state) => {
match message { match message {
Message::DragStarted() => { Message::DragWindow => {
window::get_latest().and_then(move |id: window::Id| { window::get_latest().and_then(move |id: window::Id| {
window::drag(id) window::drag(id)
}) })
@ -270,8 +313,34 @@ impl Launcher {
} else { } else {
Task::none() Task::none()
} }
} },
_ => Task::none() Message::LoadSplashes(splashes) => {
state.splash_images = splashes;
Task::none()
},
Message::LoadBackground(()) => {
state.background = Some(get_game_background(&PossibleGames::default()));
let (width, height) = state.selected_game.get_game_preferred_size();
window::get_latest().and_then(move |id: window::Id| {
window::resize(id, Size { width: width as f32, height: height as f32 })
})
},
Message::LoadIcons(icons) => {
state.icon_images = icons;
Task::none()
},
Message::RefreshInstall(_result) => {
refresh_install().unwrap();
Task::none()
},
Message::GameSelected(game) => {
state.background = Some(get_game_background(&game));
state.selected_game = game;
let (width, height) = state.selected_game.get_game_preferred_size();
window::get_latest().and_then(move |id: window::Id| {
window::resize(id, Size { width: width as f32, height: height as f32 })
})
},
} }
} }
} }
@ -321,7 +390,7 @@ impl Launcher {
background: Some(Color::from_rgba8(0, 0, 0, 0.75).into()), background: Some(Color::from_rgba8(0, 0, 0, 0.75).into()),
..container::Style::default() ..container::Style::default()
})) }))
.on_press(Message::DragStarted()) .on_press(Message::DragWindow)
.interaction(Interaction::Grab) .interaction(Interaction::Grab)
]; ];
@ -350,9 +419,21 @@ impl Launcher {
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill); .height(Length::Fill);
let bg_element: Element<Message> = state.get_background_element(); let bg_element = if let Some(bg) = state.get_background_element() {
bg
} else {
center(text("Loading...").size(50)).style(|_| {container::Style { text_color: Some(Color::from_rgba8(255, 255, 255, 1.0)), background: Some(Color::from_rgba8(0, 0, 0, 1.0).into()), ..container::Style::default() }}).into()
};
stack![bg_element, user_area].into() let mut final_stack = stack![bg_element];
if let Some(splash) = state.get_splash() {
final_stack = final_stack.push(splash);
}
final_stack = final_stack.push(user_area);
final_stack.into()
} }
} }
} }

View file

@ -44,9 +44,9 @@ pub fn get_game_background(game: &PossibleGames) -> LauncherBackground {
fn get_background_file(proj_dirs: &ProjectDirs, game: &PossibleGames) -> Result<Vec<u8>, std::io::Error> { fn get_background_file(proj_dirs: &ProjectDirs, game: &PossibleGames) -> Result<Vec<u8>, std::io::Error> {
let game_dir = match game { let game_dir = match game {
PossibleGames::WutheringWaves => proj_dirs.data_dir().join("kuro/wuwa"), PossibleGames::WutheringWaves => proj_dirs.data_dir().join("kuro/wuwa"),
PossibleGames::ZenlessZoneZero => proj_dirs.data_dir().join("zzz"), PossibleGames::ZenlessZoneZero => proj_dirs.data_dir().join("hoyoverse/zzz"),
PossibleGames::HonkaiStarRail => proj_dirs.data_dir().join("hsr"), PossibleGames::HonkaiStarRail => proj_dirs.data_dir().join("hoyoverse/hsr"),
PossibleGames::GenshinImpact => proj_dirs.data_dir().join("gi"), PossibleGames::GenshinImpact => proj_dirs.data_dir().join("hoyoverse/gi"),
}; };
if !game_dir.exists() { if !game_dir.exists() {
@ -73,6 +73,20 @@ fn get_background_file(proj_dirs: &ProjectDirs, game: &PossibleGames) -> Result<
)) ))
} }
pub fn get_game_splash_dynamic_image(game: &PossibleGames) -> Option<DynamicImage> {
let proj_dirs = ProjectDirs::from("com", "RabbyDevs", "rr-launcher").unwrap();
let file_data = get_splash_file(&proj_dirs, game);
if let Some(data) = file_data {
let data_cursor = Cursor::new(data);
Some(ImageReader::new(data_cursor)
.with_guessed_format()
.unwrap()
.decode()
.unwrap())
} else {None}
}
pub fn get_game_icon_dynamic_image(game: &PossibleGames) -> DynamicImage { pub fn get_game_icon_dynamic_image(game: &PossibleGames) -> DynamicImage {
let proj_dirs = ProjectDirs::from("com", "RabbyDevs", "rr-launcher").unwrap(); let proj_dirs = ProjectDirs::from("com", "RabbyDevs", "rr-launcher").unwrap();
let file_data: &[u8] = match game { let file_data: &[u8] = match game {
@ -118,6 +132,29 @@ fn get_hoyo_game_icon_file(proj_dirs: &ProjectDirs, game: &PossibleGames) -> Vec
std::fs::read(icon.expect("installation went wrong.").path()).unwrap() std::fs::read(icon.expect("installation went wrong.").path()).unwrap()
} }
fn get_splash_file(proj_dirs: &ProjectDirs, game: &PossibleGames) -> Option<Vec<u8>> {
let game_path = match game {
PossibleGames::ZenlessZoneZero => "hoyoverse/zzz",
PossibleGames::HonkaiStarRail => "hoyoverse/hsr",
PossibleGames::GenshinImpact => "hoyoverse/gi",
PossibleGames::WutheringWaves => "kuro/wuwa"
};
let data_dir = proj_dirs.data_dir().join(game_path);
let icon: Option<DirEntry> = {
let mut icon = None;
for path in data_dir.read_dir().unwrap() {
let path = path.unwrap();
if path.file_name().into_string().unwrap().starts_with("splash_") {
icon = Some(path);
}
}
icon
};
icon.map(|icon| std::fs::read(icon.path()).unwrap())
}
fn rad(deg: f32) -> f32 { fn rad(deg: f32) -> f32 {
deg * std::f32::consts::PI / 180.0 deg * std::f32::consts::PI / 180.0
} }