From 2896a68757452e5f26150503c9f19651a0b0d9d8 Mon Sep 17 00:00:00 2001 From: HorizonCode Date: Thu, 3 Jul 2025 14:23:11 +0200 Subject: [PATCH] feat: add osu! version and release stream retrieval, along with skins count functionality --- src-tauri/src/lib.rs | 59 +++++++++++++++++-- src-tauri/src/utils.rs | 27 +++++++++ src/lib/api/osuapi.ts | 33 +++++++++++ src/lib/global.ts | 4 ++ src/lib/types.ts | 24 ++++++++ src/lib/utils.ts | 28 +++++++++ src/pages/Launch.svelte | 121 ++++++++++++++++++++++++++++++++------- src/pages/Loading.svelte | 30 ++++++++-- 8 files changed, 295 insertions(+), 31 deletions(-) create mode 100644 src/lib/api/osuapi.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a891732..8c1f8af 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,9 +6,7 @@ use winreg::RegKey; use winreg::enums::*; mod utils; -use crate::utils::check_folder_completeness; -use crate::utils::get_osu_user_config; - +use crate::utils::{check_folder_completeness, get_osu_user_config, get_osu_config}; #[tauri::command] fn get_hwid() -> String { @@ -140,8 +138,8 @@ fn find_osu_installation() -> Option { #[tauri::command] fn get_beatmapsets_count(folder: String) -> Option { let path = PathBuf::from(folder); - let osu_config = get_osu_user_config(path.clone()); - let songs_path = osu_config + let osu_user_config = get_osu_user_config(path.clone()); + let songs_path = osu_user_config .and_then(|config| config.get("Songs").cloned()) .unwrap_or_else(|| path.join("Songs").to_string_lossy().into_owned()); let songs_folder = PathBuf::from(songs_path); @@ -169,6 +167,52 @@ fn get_beatmapsets_count(folder: String) -> Option { return Some(count); } +#[tauri::command] +fn get_skins_count(folder: String) -> Option { + let path = PathBuf::from(folder); + let skins_folder = path.join("Skins"); + + if !skins_folder.exists() { + return None; + } + + let mut count = 0; + if let Ok(entries) = std::fs::read_dir(skins_folder) { + for entry in entries.flatten() { + if entry.file_type().map_or(false, |ft| ft.is_dir()) { + let dir_path = entry.path(); + if let Ok(files) = std::fs::read_dir(&dir_path) { + for file in files.flatten() { + if file.path().extension().map_or(false, |ext| ext == "ini") { + count += 1; + break; + } + } + } + } + } + } + return Some(count); +} + +#[tauri::command] +fn get_osu_version(folder: String) -> String { + let path = PathBuf::from(folder); + let osu_user_config = get_osu_user_config(path.clone()); + return osu_user_config + .and_then(|config| config.get("LastVersion").cloned()) + .unwrap_or_else(|| "failed.".to_string()); +} + +#[tauri::command] +fn get_osu_release_stream(folder: String) -> String { + let path = PathBuf::from(folder); + let osu_config = get_osu_config(path.clone()); + return osu_config + .and_then(|config| config.get("_ReleaseStream").cloned()) + .unwrap_or_else(|| "Stable40".to_string()); +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let mut builder = tauri::Builder::default(); @@ -188,7 +232,10 @@ pub fn run() { get_hwid, find_osu_installation, valid_osu_folder, - get_beatmapsets_count + get_beatmapsets_count, + get_skins_count, + get_osu_version, + get_osu_release_stream ]) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index aee088c..28619ab 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -52,3 +52,30 @@ pub fn get_osu_user_config>( return Some(config_map); } + +pub fn get_osu_config>( + osu_folder_path: P, +) -> Option> { + // Ensure the osu! folder path is valid + if !osu_folder_path.as_ref().exists() { + return None; + } + + // get the osu!.cfg file from the osu! folder + let osu_config_path = osu_folder_path.as_ref().join("osu!.cfg"); + if !osu_config_path.exists() { + return None; + } + + // read the osu config and return it as a map, key and value are separated by ' = ' + let mut config_map = std::collections::HashMap::new(); + if let Ok(contents) = std::fs::read_to_string(osu_config_path) { + for line in contents.lines() { + if let Some((key, value)) = line.split_once(" = ") { + config_map.insert(key.trim().to_string(), value.trim().to_string()); + } + } + } + + return Some(config_map); +} diff --git a/src/lib/api/osuapi.ts b/src/lib/api/osuapi.ts new file mode 100644 index 0000000..0364b11 --- /dev/null +++ b/src/lib/api/osuapi.ts @@ -0,0 +1,33 @@ +import type { StreamsResult } from '@/types'; +import { betterFetch } from '@better-fetch/fetch'; + +const API_ENDPOINT = 'https://osu.ppy.sh/api/'; + +const timeout = 5000; // 5 seconds; + +export const osuapi = { + latestBuildVersion: async (releaseStream: string): Promise => { + const request = await betterFetch(`${API_ENDPOINT}v2/changelog`, { + timeout, + query: { + stream: 'none', + }, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'EZPPLauncher', + }, + }); + if (request.error) { + if (request.error.status >= 500 && request.error.status < 600) + throw new Error('Server not reachable'); + return undefined; + } + const releaseData = request.data; + const selectedRelease = releaseData.streams.find( + (releaseBuild) => + releaseBuild.name.toLowerCase() === releaseStream.replaceAll(' ', '').toLowerCase() + ); + if (!selectedRelease) return undefined; + return selectedRelease.latest_build.display_version; + }, +}; diff --git a/src/lib/global.ts b/src/lib/global.ts index ff9f47b..a4eebcd 100644 --- a/src/lib/global.ts +++ b/src/lib/global.ts @@ -15,6 +15,10 @@ export const serverConnectionFails = writable(0); export const onlineFriends = writable(undefined); export const beatmapSets = writable(undefined); +export const skins = writable(undefined); + +export const osuStream = writable(undefined); +export const osuBuild = writable(undefined); export const setupValues = () => { updatePing(); diff --git a/src/lib/types.ts b/src/lib/types.ts index 9b58f84..e06fe95 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -95,3 +95,27 @@ export type EZPPUserInfo = { time: Date; }[]; }; + +export type StreamsResult = { + streams: { + id: number; + name: string; + display_name: string; + is_featured: boolean; + latest_build: { + created_at: Date; + display_version: string; + id: number; + users: number; + version: string; + youtube_id: null | string; + update_stream: { + id: number; + name: string; + display_name: string; + is_featured: boolean; + }; + }; + user_count: number; + }[]; +}; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 045199e..007b8d2 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -50,3 +50,31 @@ export const formatTimeReadable = (initialSeconds: number) => { return result.trim(); }; + +export const releaseStreamToReadable = (releaseStream: string) => { + if (releaseStream.toLowerCase() === 'cuttingedge') return 'Cutting Edge'; + return 'Stable'; +}; + +export const compareBuildNumbers = (current: string, target: string): number => { + const parse = (version: string): [number, number] => { + const cleaned = version.split(/[^0-9.]/)[0]; + + const [baseStr, hotfixStr] = cleaned.split('.'); + const base = parseInt(baseStr, 10); + const hotfix = hotfixStr ? parseInt(hotfixStr, 10) : 0; + + return [base, hotfix]; + }; + + const [currentBase, currentHotfix] = parse(current); + const [targetBase, targetHotfix] = parse(target); + + if (targetBase > currentBase) { + return targetBase - currentBase + targetHotfix; + } else if (targetBase === currentBase) { + return targetHotfix - currentHotfix; + } else { + return -1; + } +}; diff --git a/src/pages/Launch.svelte b/src/pages/Launch.svelte index a0ecdbb..a5344cb 100644 --- a/src/pages/Launch.svelte +++ b/src/pages/Launch.svelte @@ -4,7 +4,15 @@ import Badge from '@/components/ui/badge/badge.svelte'; import Button from '@/components/ui/button/button.svelte'; import * as Select from '@/components/ui/select'; - import { beatmapSets, currentView, serverConnectionFails, serverPing } from '@/global'; + import { + beatmapSets, + currentView, + osuBuild, + osuStream, + serverConnectionFails, + serverPing, + skins, + } from '@/global'; import { LoaderCircle, Logs, @@ -20,11 +28,17 @@ Circle, LogOut, LogIn, + Brush, } from 'lucide-svelte'; import NumberFlow from '@number-flow/svelte'; import * as AlertDialog from '@/components/ui/alert-dialog'; import Progress from '@/components/ui/progress/progress.svelte'; - import { formatTimeReadable, numberHumanReadable } from '@/utils'; + import { + compareBuildNumbers, + formatTimeReadable, + numberHumanReadable, + releaseStreamToReadable, + } from '@/utils'; import { fade, scale } from 'svelte/transition'; import { Checkbox } from '@/components/ui/checkbox'; import Label from '@/components/ui/label/label.svelte'; @@ -52,9 +66,11 @@ validModeTypeCombinationsSorted, } from '@/gamemode'; import { currentUserInfo } from '@/data'; + import { osuapi } from '@/api/osuapi'; let selectedTab = $state('home'); let launching = $state(false); + let launchInfo = $state(''); let selectedGamemode = $derived( getGamemodeInt(modeIntToStr($preferredMode), typeIntToStr($preferredType)) @@ -97,6 +113,44 @@ } } }; + + const launch = async (offline: boolean) => { + if (!$osuBuild || !$osuStream) { + toast.error('Hmmm...', { + description: 'There was an issue detecting your installed osu! version', + }); + return; + } + launchInfo = 'Looking for updates...'; + launching = true; + try { + const streamInfo = await osuapi.latestBuildVersion($osuStream ?? 'stable40'); + if (!streamInfo) { + toast.error('Hmmm...', { + description: 'Failed to check for updates.', + }); + launching = false; + return; + } + + const versions = compareBuildNumbers($osuBuild, streamInfo); + + if (versions > 0) { + launchInfo = 'Update found!'; + } else { + launchInfo = 'You are up to date!'; + } + } catch { + toast.error('Hmmm...', { + description: 'Failed to check for updates.', + }); + launching = false; + } + + setTimeout(() => { + launching = false; + }, 5000); + }; @@ -109,7 +163,7 @@
- Downloading update files... + {launchInfo}
@@ -350,7 +404,7 @@ class="my-auto bg-theme-900/90 flex flex-col items-center justify-center gap-6 border border-theme-800/90 rounded-lg p-6" in:scale={{ duration: $reduceAnimations ? 0 : 400, start: 0.98 }} > -
+
@@ -379,6 +433,30 @@
Beatmap Sets imported
+
+
+ +
+
+
+ +
+
+ {#if $reduceAnimations} + {numberHumanReadable($skins ?? 0)} + {:else} + + {/if} +
+
+
Skins
+