rooms-launcher/src/main.rs
Yuhki 0ae31d8627 Update dependencies and refactor code for improved functionality and performance
- Added new dependencies including `aes`, `bzip2`, `zip`, and others in `Cargo.lock`.
- Updated existing dependencies to their latest versions.
- Refactored image handling in `video.rs` for better readability and performance.
- Enhanced game installation logic in `installer.rs` to support new game types.
- Introduced settings management in the application state.
- Improved error handling and logging throughout the codebase.
- Cleaned up unused code and optimized existing functions for better efficiency.
2025-07-09 10:27:17 +02:00

606 lines
No EOL
23 KiB
Rust

// #![windows_subsystem = "windows"]
mod utils;
mod components;
use components::settings::{SettingsModal, SettingsMessage};
use components::installer::{install_game, refresh_install};
use directories::ProjectDirs;
use ::image::{DynamicImage, ImageReader};
use iced::{
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, 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
};
pub const ORGANIZATION: &str = "ReversedRooms";
pub const PRODUCT: &str = "RoomsLauncher";
// Trait for converting DynamicImage to image::Handle
trait InterfaceImage {
fn into_handle(self) -> image::Handle;
}
impl InterfaceImage for DynamicImage {
fn into_handle(self) -> image::Handle {
image::Handle::from_rgba(self.width(), self.height(), self.to_rgba8().into_raw())
}
}
pub fn main() -> iced::Result {
let icon_image = ImageReader::new(Cursor::new(include_bytes!("../resources/icon.png")))
.with_guessed_format()
.unwrap()
.decode()
.unwrap();
let settings = Settings {
decorations: false,
icon: Some(icon::from_rgba(icon_image.as_rgba8().unwrap().to_vec(), icon_image.width(), icon_image.height()).unwrap()),
size: Size::new(0.0, 0.0),
maximized: false,
fullscreen: false,
position: window::Position::Centered,
max_size: None,
min_size: None,
visible: true,
resizable: false,
transparent: false,
level: window::Level::Normal,
exit_on_close_request: false,
..Settings::default()
};
iced::application(Launcher::boot, Launcher::update, Launcher::view)
.subscription(Launcher::subscription)
.title(Launcher::title)
.window(settings)
.font(include_bytes!("../resources/segoe-mdl2-assets.ttf"))
.font(include_bytes!("../resources/QuodlibetSans-Regular.ttf"))
.window_size((1280.0, 720.0))
.run()
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, EnumIter, Default, Serialize, Deserialize)]
enum PossibleGames {
#[default]
WutheringWaves,
HonkaiStarRail,
ZenlessZoneZero,
GenshinImpact,
}
impl PossibleGames {
fn get_preferred_size(&self) -> (u32, u32) {
match self {
Self::WutheringWaves => (1280, 760),
_ => (1280, 720),
}
}
}
#[derive(Debug)]
enum Launcher {
Loaded(Box<State>),
}
#[derive(Debug)]
struct VideoBackground {
video: Option<Video>,
first_frame: DynamicImage
}
impl Clone for VideoBackground {
fn clone(&self) -> Self {
Self {
video: None, // Do not clone the video field
first_frame: self.first_frame.clone(),
}
}
}
#[derive(Debug)]
enum LauncherBackground {
Video(VideoBackground),
Image(DynamicImage),
}
impl Clone for LauncherBackground {
fn clone(&self) -> Self {
match self {
Self::Video(vb) => Self::Video(vb.clone()),
Self::Image(img) => Self::Image(img.clone()),
}
}
}
#[derive(Debug, Default)]
struct State {
selected_game: PossibleGames,
installed_games: Vec<PossibleGames>,
installed_game_servers: Vec<PossibleGames>,
db_software_installed: bool,
downloaded_games: Vec<PossibleGames>,
background: Option<LauncherBackground>,
splash_images: HashMap<PossibleGames, DynamicImage>,
icon_images: HashMap<PossibleGames, DynamicImage>,
notification: Option<String>,
is_installing: bool,
game_path: String,
settings_modal: SettingsModal,
}
impl State {
fn get_background_element(&'_ self) -> Option<Element<'_, Message>> {
self.background.as_ref().map(|bg| match bg {
LauncherBackground::Video(video_object) => {
if let Some(video) = &video_object.video {
VideoPlayer::new(video).into()
} else {
image(video_object.first_frame.clone().into_handle()).into()
}
},
LauncherBackground::Image(img) => image(img.clone().into_handle()).into(),
})
}
fn get_game_icon_row(&'_ self) -> Element<'_, Message> {
container(row![
self.create_game_icon(&PossibleGames::WutheringWaves),
self.create_game_icon(&PossibleGames::HonkaiStarRail),
self.create_game_icon(&PossibleGames::ZenlessZoneZero),
self.create_game_icon(&PossibleGames::GenshinImpact),
]
.spacing(10)
).align_x(Center).width(Length::Fill).into()
}
fn create_game_icon(&self, game: &PossibleGames) -> Element<'_, Message> {
if let Some(icon) = self.icon_images.get(game) {
let is_selected = &self.selected_game == game;
let img = if is_selected {
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))
};
if is_selected {
opaque(img)
} else {
mouse_area(img)
.on_press(Message::GameSelected(Some(game.clone())))
.interaction(Interaction::Pointer)
.into()
}
} else {
text("loading").size(10).into()
}
}
fn get_splash(&self) -> Option<Element<'_, Message>> {
self.splash_images.get(&self.selected_game).map(|splash| {
let (width, height) = self.selected_game.get_preferred_size();
image(splash.clone().into_handle())
.width(Length::Fixed(width as f32))
.height(Length::Fixed(height as f32))
.into()
})
}
fn is_game_downloaded(&self, game: &PossibleGames) -> bool {
self.downloaded_games.contains(game)
}
fn mark_game_as_downloaded(&mut self, game: &PossibleGames) {
if !self.downloaded_games.contains(game) {
self.downloaded_games.push(game.clone());
}
}
fn start_install(&mut self) {
self.is_installing = true;
}
fn finish_install(&mut self) {
self.is_installing = false;
}
fn path() -> PathBuf {
ProjectDirs::from("com", ORGANIZATION, PRODUCT)
.unwrap()
.data_dir()
.join("launcher-state.json")
}
fn load(self) -> Result<Self, Box<dyn std::error::Error>> {
let background = self.background.clone();
let splash_images = self.splash_images.clone();
let icon_images = self.icon_images.clone();
Ok(read_to_string(Self::path())
.ok()
.and_then(|contents| serde_json::from_str::<SavedState>(&contents).ok())
.map(|saved_state| Self {
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,
downloaded_games: saved_state.downloaded_games,
background,
splash_images,
icon_images,
notification: saved_state.notification,
is_installing: saved_state.is_installing,
game_path: saved_state.game_path.clone(),
settings_modal: SettingsModal::new(saved_state.game_path),
})
.unwrap_or(self))
}
fn save(&mut self) -> Result<(), SaveError> {
let saved_state = SavedState {
selected_game: self.selected_game.clone(),
installed_games: self.installed_games.clone(),
installed_game_servers: self.installed_game_servers.clone(),
db_software_installed: self.db_software_installed,
downloaded_games: self.downloaded_games.clone(),
notification: self.notification.clone(),
is_installing: self.is_installing,
game_path: self.game_path.clone(),
};
let path = Self::path();
if let Some(dir) = path.parent() {
create_dir_all(dir).map_err(|_| SaveError::Write)?;
}
let json = serde_json::to_string_pretty(&saved_state).map_err(|_| SaveError::Format)?;
let mut file = fs::File::create(path).map_err(|_| SaveError::Write)?;
file.write_all(json.as_bytes()).map_err(|_| SaveError::Write)?;
file.flush().map_err(|_| SaveError::Write)?;
Ok(())
}
fn show_notification(&mut self, msg: &str) {
self.notification = Some(msg.to_string());
}
fn dismiss_notification(&mut self) {
self.notification = None;
}
fn update_settings(&mut self, msg: SettingsMessage) {
match msg {
SettingsMessage::GamePathChanged(path) => {
self.game_path = path.clone();
self.settings_modal.game_path = path;
}
SettingsMessage::Save => {
self.settings_modal.is_open = false;
let _ = self.save();
}
SettingsMessage::Cancel => {
self.settings_modal.is_open = false;
}
}
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
struct SavedState {
selected_game: PossibleGames,
installed_games: Vec<PossibleGames>,
installed_game_servers: Vec<PossibleGames>,
db_software_installed: bool,
downloaded_games: Vec<PossibleGames>,
notification: Option<String>,
is_installing: bool,
game_path: String,
}
#[derive(Debug, Clone)]
enum SaveError {
Write,
Format,
}
#[derive(Debug, Clone)]
enum Message {
EventOccurred(Event),
DragWindow,
LoadIcons(HashMap<PossibleGames, DynamicImage>),
LoadSplashes(HashMap<PossibleGames, DynamicImage>),
LoadVideo(()),
RefreshInstall,
GameSelected(Option<PossibleGames>),
DownloadOrStart,
InstallGameResult(Result<(), String>),
Close,
Minimize,
DismissNotification,
OpenSettings,
Settings(SettingsMessage),
}
async fn get_icons() -> HashMap<PossibleGames, DynamicImage> {
PossibleGames::iter()
.map(|game| (game.clone(), get_game_icon_dynamic_image(&game)))
.collect()
}
async fn get_splashes() -> HashMap<PossibleGames, DynamicImage> {
PossibleGames::iter()
.filter_map(|game| get_game_splash_dynamic_image(&game).map(|splash| (game, splash)))
.collect()
}
impl Launcher {
fn boot() -> (Self, Task<Message>) {
(
Self::Loaded(Box::new(State::default().load().unwrap())),
Task::perform(refresh_install(), |_| Message::RefreshInstall)
.chain(Task::batch([
Task::perform(async { None::<PossibleGames> }, Message::GameSelected),
Task::perform(get_icons(), Message::LoadIcons),
Task::perform(get_splashes(), Message::LoadSplashes),
]))
)
}
fn title(&self) -> String {
format!("RR Launcher v{}", env!("CARGO_PKG_VERSION"))
}
fn update(&mut self, message: Message) -> Task<Message> {
match self {
Self::Loaded(state) => {
match message {
Message::DragWindow => {
window::get_latest().and_then(move |id: window::Id| {window::drag(id)})
}
Message::Close => {
state.save().unwrap();
window::get_latest().and_then(window::close)
},
Message::Minimize => {
window::get_latest().and_then(|id| window::minimize(id, true))
},
Message::EventOccurred(Event::Window(window::Event::CloseRequested)) => {
state.save().unwrap();
window::get_latest().and_then(window::close)
},
Message::EventOccurred(_) => Task::none(),
Message::LoadSplashes(splashes) => {
state.splash_images = splashes;
Task::none()
},
Message::LoadVideo(()) => {
state.background = get_game_background(&state.selected_game, true).ok();
Task::none()
},
Message::LoadIcons(icons) => {
state.icon_images = icons;
Task::none()
},
Message::RefreshInstall => Task::none(),
Message::GameSelected(game) => {
if let Some(game) = game {
state.selected_game = game;
}
let (width, height) = state.selected_game.get_preferred_size();
let resize_task = window::get_latest().and_then(move |id| {
window::resize(id, Size { width: width as f32, height: height as f32 })
});
if let Ok(background) = get_game_background(&state.selected_game, false) {
if let LauncherBackground::Video(_) = background {
state.background = Some(background);
return resize_task.chain(Task::perform(async {}, Message::LoadVideo));
}
state.background = Some(background);
} else {
state.background = None;
}
resize_task
},
Message::DownloadOrStart => {
let selected_game = state.selected_game.clone();
if state.is_game_downloaded(&selected_game) {
println!("Starting game: {selected_game:?}");
// TODO: Add actual game starting logic here
Task::none()
} else {
println!("Downloading game: {selected_game:?}");
state.start_install();
Task::perform(install_game(selected_game), Message::InstallGameResult)
}
},
Message::InstallGameResult(result) => {
state.finish_install();
match result {
Ok(()) => {
let selected_game = state.selected_game.clone();
state.mark_game_as_downloaded(&selected_game);
if selected_game == PossibleGames::WutheringWaves {
state.show_notification("All dependencies for Wuthering Waves were installed successfully.");
}
}
Err(e) => {
state.show_notification(&format!("Failed to install game: {e}"));
}
}
Task::none()
},
Message::DismissNotification => {
state.dismiss_notification();
Task::none()
},
Message::OpenSettings => {
state.settings_modal.is_open = true;
Task::none()
},
Message::Settings(msg) => {
state.update_settings(msg);
Task::none()
},
}
}
}
}
fn subscription(&self) -> Subscription<Message> {
event::listen_with(|event, _, _| {
if event == Event::Window(window::Event::CloseRequested) {
Some(Message::EventOccurred(event))
} else {
None
}
})
}
fn view(&'_ self) -> Element<'_, Message> {
let mut font = Font::with_name("Quodlibet Sans");
font.weight = Weight::Normal;
font.stretch = Stretch::Normal;
let mut bolded_font = Font::with_name("Quodlibet Sans");
bolded_font.weight = Weight::Bold;
bolded_font.stretch = Stretch::Normal;
match self {
Self::Loaded(state) => {
let top_bar = stack![
mouse_area(container(
stack![
row![
text("Rooms Launcher").size(35).font(font).color(Color::from_rgb8(255, 255, 255)),
Space::new(Length::Fill, Length::Fixed(0.0)),
row![
opaque(mouse_area(text("\u{E949}").font(Font::with_name("Segoe MDL2 Assets")).size(25)).on_release(Message::Minimize).interaction(Interaction::Pointer)),
opaque(mouse_area(text("\u{E106}").font(Font::with_name("Segoe MDL2 Assets")).size(25)).on_release(Message::Close).interaction(Interaction::Pointer))
].spacing(20)
].align_y(Center).padding(4),
state.get_game_icon_row(),
]
)
.padding(Padding { top: 5.0, right: 10.0, bottom: 5.0, left: 5.0 })
.align_y(Top)
.align_x(Center)
.width(Length::Fill)
.style(move |_|
container::Style {
text_color: Color::from_rgba8(255, 255, 255, 1.0).into(),
background: Some(Color::from_rgba8(0, 0, 0, 0.75).into()),
..container::Style::default()
}))
.on_press(Message::DragWindow)
.interaction(Interaction::Move)
];
let button_text = if state.is_game_downloaded(&state.selected_game) {
"Start"
} else if state.is_installing {
"Installing..."
} else {
"Download"
};
let download_button = button(text(button_text).size(25).font(bolded_font).align_x(Center))
.padding(Padding { top: 10.0, right: 70.0, bottom: 10.0, left: 70.0 })
.style(|_, _| button::Style {
text_color: Color::from_rgba8(0, 0, 0, 1.0),
background: Some(Color::from_rgba8(255, 255, 255, 1.0).into()),
border: border::rounded(5),
..button::Style::default()
})
.on_press(Message::DownloadOrStart);
let settings_button = button(text("Settings").size(25).font(bolded_font).align_x(Center))
.padding(Padding { top: 10.0, right: 30.0, bottom: 10.0, left: 30.0 })
.style(|_, _| button::Style {
text_color: Color::from_rgba8(0, 0, 0, 1.0),
background: Some(Color::from_rgba8(255, 255, 255, 1.0).into()),
border: border::rounded(5),
..button::Style::default()
})
.on_press(Message::OpenSettings);
let bottom_bar = container(row![
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(download_button
.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),
background: Some(Color::from_rgba8(255, 255, 255, 1.0).into()),
border: border::rounded(5),
..button::Style::default()
}
}))
.interaction(Interaction::Pointer)),
Space::new(Length::Fixed(20.0), Length::Fixed(0.0)),
opaque(mouse_area(settings_button)
.interaction(Interaction::Pointer)),
])
.align_y(Bottom)
.width(Length::Fill)
.style(move |_theme| style_container(180.0, true))
.padding(20);
let user_area = column![top_bar, Space::new(Length::Fill, Length::Fill), bottom_bar]
.width(Length::Fill)
.height(Length::Fill);
let bg_element = if let Some(bg) = state.get_background_element() {
bg
} else {
center(text("").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()
};
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);
if let Some(ref msg) = state.notification {
let msgbox = container(
column![
text(msg).size(22),
button("OK").on_press(Message::DismissNotification)
]
.spacing(20)
.padding(20)
)
.width(Length::Fixed(400.0))
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(|_| container::Style {
background: Some(Color::from_rgba8(0,0,0,0.85).into()),
border: border::rounded(10),
..container::Style::default()
});
final_stack = final_stack.push(center(msgbox));
}
if state.settings_modal.is_open {
let modal_elem = state.settings_modal.view().map(Message::Settings);
final_stack = final_stack.push(modal_elem);
}
final_stack.into()
}
}
}
}