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]]
name = "reversed-rooms-launcher"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"directories",
"file-format",

View file

@ -1,7 +1,7 @@
#![feature(let_chains)]
[package]
name = "reversed-rooms-launcher"
version = "0.3.0"
version = "0.4.0"
edition = "2024"
[dependencies]
@ -22,4 +22,4 @@ reqwest = { version = "0.12.15", features = ["blocking", "gzip", "json"] }
strip = true # Automatically strip symbols from the binary.
lto = true # Link-time optimization.
opt-level = 3 # Optimize for speed.
codegen-units = 1 # Maximum size reduction optimizations.
codegen-units = 1 # Maximum size reduction optimizations.

View file

@ -469,8 +469,8 @@ impl Video {
}
/// Get the size/resolution of the video as `(width, height)`.
pub fn size(&self) -> (i32, i32) {
(self.read().width, self.read().height)
pub fn size(&self) -> (u32, u32) {
(self.read().width.try_into().unwrap(), self.read().height.try_into().unwrap())
}
/// 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::OpenOptions;
use std::fs::{DirEntry, OpenOptions};
use directories::ProjectDirs;
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> {
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 expected_file = format!("{}_{}", file_type, filename);
let file_path = dir.join(&expected_file);
let file_exists = file_path.exists();
if !file_exists {
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());
if let Some(file) = current_file {
if filename != file.file_name().into_string().unwrap().strip_prefix(format!("{}_", file_type).as_str()).unwrap() {
update_file(client, file_path, file_type, file_url)?;
} else {
eprintln!("{} file already exists at: {}", file_type, file.path().display());
}
} else {
eprintln!("{} file already exists at: {}", file_type, file_path.display());
update_file(client, file_path, file_type, file_url)?;
}
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> {
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;
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(())

View file

@ -1,5 +1,4 @@
// TODO: fix loading the save data lol
#![windows_subsystem = "windows"]
// #![windows_subsystem = "windows"]
mod utils;
mod components;
@ -7,13 +6,13 @@ use components::installer::refresh_install;
use directories::ProjectDirs;
use ::image::{DynamicImage, ImageReader};
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 serde::{Deserialize, Serialize};
use strum::IntoEnumIterator;
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::{
collections::HashMap, env, fs::{self, create_dir_all, read_to_string}, io::{Cursor, Write}, path::PathBuf
};
@ -77,6 +76,21 @@ enum PossibleGames {
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)]
enum Launcher {
Loaded(Box<State>),
@ -95,17 +109,21 @@ struct State {
installed_game_servers: Vec<PossibleGames>,
db_software_installed: bool,
background: Option<LauncherBackground>,
splash_images: HashMap<PossibleGames, DynamicImage>,
icon_images: HashMap<PossibleGames, DynamicImage>
}
impl State {
fn get_background_element(&self) -> Element<Message> {
match self.background.as_ref().unwrap() {
LauncherBackground::Video(video) => VideoPlayer::new(video).into(),
LauncherBackground::Image(img) => image(image::Handle::from_rgba(img.width(), img.height(), img.to_rgba8().into_raw())).into(),
fn get_background_element(&self) -> Option<Element<Message>> {
if let Some(background) = self.background.as_ref() {
match background {
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> {
container(row![
self.create_game_icon(&PossibleGames::WutheringWaves),
@ -117,23 +135,36 @@ impl State {
).align_x(Center).width(Length::Fill).into()
}
fn create_game_icon(&self, game: &PossibleGames) -> Element<Message> {
let icon = self.icon_images.get(game).unwrap();
let img = if &self.selected_game == game {
image(icon.clone().into_handle())
.width(Length::Fixed(52.0))
.height(Length::Fixed(68.0))
if let Some(icon) = self.icon_images.get(game) {
let img = if &self.selected_game == game {
image(icon.clone().into_handle())
.width(Length::Fixed(52.0))
.height(Length::Fixed(68.0))
} else {
image(icon.brighten(-85).blur(2.5).adjust_contrast(-15.0).into_handle())
.width(Length::Fixed(48.0))
.height(Length::Fixed(64.0))
};
mouse_area(
img
)
.on_press(Message::GameSelected(game.clone()))
.interaction(Interaction::Pointer)
.into()
} else {
image(icon.brighten(-85).blur(2.5).adjust_contrast(-15.0).into_handle())
.width(Length::Fixed(48.0))
.height(Length::Fixed(64.0))
};
mouse_area(
img
)
// .on_press(Message::GameSelected(game.clone()))
.interaction(Interaction::Pointer)
.into()
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)]
enum LoadError {
File,
Format,
}
@ -166,8 +196,12 @@ enum SaveError {
#[derive(Debug, Clone)]
enum Message {
EventOccurred(Event),
DragStarted(),
// GameSelected(PossibleGames),
DragWindow,
LoadIcons(HashMap<PossibleGames, DynamicImage>),
LoadSplashes(HashMap<PossibleGames, DynamicImage>),
LoadBackground(()),
RefreshInstall(()),
GameSelected(PossibleGames),
Close,
Minimize
}
@ -181,19 +215,21 @@ impl State {
}
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 =
serde_json::from_str(&contents).map_err(|_| LoadError::Format)?;
Ok(State {
selected_game: saved_state.selected_game,
installed_games: saved_state.installed_games,
installed_game_servers: saved_state.installed_game_servers,
db_software_installed: saved_state.db_software_installed,
background: self.background,
icon_images: self.icon_images
})
Ok(State {
selected_game: saved_state.selected_game,
installed_games: saved_state.installed_games,
installed_game_servers: saved_state.installed_game_servers,
db_software_installed: saved_state.db_software_installed,
background: self.background,
splash_images: self.splash_images,
icon_images: self.icon_images,
})
} else {
Ok(self)
}
}
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 {
fn boot() -> (Self, Task<Message>) {
refresh_install().unwrap();
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())
(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)]))
}
fn title(&self) -> String {
@ -248,7 +291,7 @@ impl Launcher {
match self {
Launcher::Loaded(state) => {
match message {
Message::DragStarted() => {
Message::DragWindow => {
window::get_latest().and_then(move |id: window::Id| {
window::drag(id)
})
@ -270,8 +313,34 @@ impl Launcher {
} else {
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()),
..container::Style::default()
}))
.on_press(Message::DragStarted())
.on_press(Message::DragWindow)
.interaction(Interaction::Grab)
];
@ -329,7 +398,7 @@ impl Launcher {
text("The quick brown fox jumped over the lazy dog.").size(25).font(font),
Space::new(Length::Fill, Length::Fixed(0.0)),
opaque(mouse_area(button(text("Start").size(25).font(bolded_font).align_x(Center))
.padding(Padding { top: 10.0, right: 70.0, bottom: 10.0, left: 70.0 })
.padding(Padding { top: 10.0, right: 70.0, bottom: 10.0, left: 70.0 })
.style(move |_, _| {
button::Style {
text_color: Color::from_rgba8(0, 0, 0, 1.0),
@ -350,9 +419,21 @@ impl Launcher {
.width(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> {
let game_dir = match game {
PossibleGames::WutheringWaves => proj_dirs.data_dir().join("kuro/wuwa"),
PossibleGames::ZenlessZoneZero => proj_dirs.data_dir().join("zzz"),
PossibleGames::HonkaiStarRail => proj_dirs.data_dir().join("hsr"),
PossibleGames::GenshinImpact => proj_dirs.data_dir().join("gi"),
PossibleGames::ZenlessZoneZero => proj_dirs.data_dir().join("hoyoverse/zzz"),
PossibleGames::HonkaiStarRail => proj_dirs.data_dir().join("hoyoverse/hsr"),
PossibleGames::GenshinImpact => proj_dirs.data_dir().join("hoyoverse/gi"),
};
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 {
let proj_dirs = ProjectDirs::from("com", "RabbyDevs", "rr-launcher").unwrap();
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()
}
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 {
deg * std::f32::consts::PI / 180.0
}