Compare commits

...

21 Commits

Author SHA1 Message Date
HorizonCode afdd74175a fix: cursor smooth movement on higher refreshrate monitors 2026-05-21 20:30:00 +02:00
HorizonCode 6f8043d2d0 fix: cursor rotating on higher refreshrate monitors 2026-05-21 20:23:04 +02:00
HorizonCode f7fff7748a chore: format 2026-05-21 15:31:13 +02:00
HorizonCode d5286263b9 chore: add nsis hook on uninstall to delete user .ezpplauncher dir 2026-05-21 15:30:54 +02:00
HorizonCode 1cc1a4a0c3 chore: remove theme debug script 2026-05-21 14:54:33 +02:00
HorizonCode d2ae9e939f chore: add dynamic button for beatmap display 2026-05-21 14:48:10 +02:00
HorizonCode bde922ab9f fix: file theme import by association file 2026-05-21 14:26:16 +02:00
HorizonCode a1f96a7de3 fix: build 2026-05-21 14:25:13 +02:00
HorizonCode 3b2c902264 fix: build 2026-05-21 14:25:08 +02:00
HorizonCode 40a32d7a60 chore: add theme import for file association 2026-05-21 13:27:31 +02:00
HorizonCode 52c60acf37 chore: add drag and drop theme import 2026-05-21 13:23:03 +02:00
HorizonCode 2788cd6e8b chore: format 2026-05-21 11:44:56 +02:00
HorizonCode 2d1941db33 chore: add drag-and-drop box to Launch page 2026-05-21 11:43:59 +02:00
HorizonCode 00958d5238 chore: only handle .ezpplauncher-theme files 2026-05-21 10:51:51 +02:00
HorizonCode b8a0b448c7 fix: type def for beatmapSetCount 2026-05-21 10:42:13 +02:00
HorizonCode 09487e945e fix: cleanup type defs 2026-05-21 10:41:22 +02:00
HorizonCode 2532ab0059 fix: add ezpplauncher-theme file association to tauri.config.json 2026-05-21 10:27:44 +02:00
HorizonCode 16fe91b3a5 chore: rework presence, add dynamic button functions to presence 2026-05-21 09:21:15 +02:00
HorizonCode eca08f16c5 fix: wait spawning patcher until osu is spawned 2026-05-21 08:18:50 +02:00
HorizonCode e76d67f3a8 fix: add missing imports 2026-05-20 16:32:26 +02:00
HorizonCode 9811b36e58 wip: theme file import from fs 2026-05-20 16:09:05 +02:00
21 changed files with 999 additions and 287 deletions
+7 -2
View File
@@ -15,6 +15,7 @@
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-shell": "^2.3.5",
"@types/node": "^25.9.1",
"animejs": "^4.1.3",
"buffer": "^6.0.3",
"crypto-js": "^4.2.0",
@@ -302,7 +303,7 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@20.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA=="],
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
@@ -794,7 +795,7 @@
"typescript-eslint": ["typescript-eslint@8.42.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.42.0", "@typescript-eslint/parser": "8.42.0", "@typescript-eslint/typescript-estree": "8.42.0", "@typescript-eslint/utils": "8.42.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ozR/rQn+aQXQxh1YgbCzQWDFrsi9mcg+1PM3l/z5o1+20P7suOIaNg515bpr/OYt6FObz/NHcBstydDLHWeEKg=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
@@ -854,6 +855,8 @@
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"bun-types/@types/node": ["@types/node@20.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA=="],
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"eslint/@eslint/core": ["@eslint/core@0.15.2", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg=="],
@@ -906,6 +909,8 @@
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"eslint-plugin-svelte/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
}
}
+3 -2
View File
@@ -5,9 +5,9 @@
"type": "module",
"scripts": {
"debug": "bun run tauri dev",
"build": "bun ./scripts/sync-version.ts && bun run vite:build && bun run tauri:build",
"build": "bun ./scripts/build.ts",
"vite:dev": "vite dev",
"vite:build": "vite build",
"vite:build": "vite build && exit 0",
"tauri:build": "tauri build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@@ -28,6 +28,7 @@
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-shell": "^2.3.5",
"@types/node": "^25.9.1",
"animejs": "^4.1.3",
"buffer": "^6.0.3",
"crypto-js": "^4.2.0",
+37
View File
@@ -0,0 +1,37 @@
import { spawn } from 'child_process';
function runVite(): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn('bunx', ['--bun', 'vite', 'build'], { stdio: ['inherit', 'pipe', 'pipe'] });
proc.stdout?.on('data', (data: Buffer) => {
process.stdout.write(data);
if (data.toString().includes('✔ done')) {
setTimeout(() => {
proc.kill();
resolve();
}, 500);
}
});
proc.stderr?.on('data', (data: Buffer) => process.stderr.write(data));
proc.on('close', (code) => {
if (code === 0 || code === null) resolve();
else reject(new Error(`vite build exited with code ${code}`));
});
});
}
function run(cmd: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn(cmd, args, { stdio: 'inherit' });
proc.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`${cmd} exited with code ${code}`));
});
});
}
await run('bun', ['./scripts/sync-version.ts']);
await runVite();
await run('bunx', ['tauri', 'build']);
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"types": ["@types/bun", "@types/node"]
},
"include": ["*.ts"]
}
+1
View File
@@ -1010,6 +1010,7 @@ dependencies = [
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tokio",
"url",
"widestring",
"winapi",
"windows-sys 0.61.2",
+1
View File
@@ -47,6 +47,7 @@ once_cell = "1.21.4"
futures-util = "0.3.32"
byteorder = "1.5.0"
anyhow = "1.0.102"
url = "2"
[target.'cfg(windows)'.dependencies]
winreg = "0.56.0"
+15
View File
@@ -3,4 +3,19 @@
${OrIf} ${Silent}
Exec '"$INSTDIR\${MAINBINARYNAME}.exe"'
${EndIf}
!macroend
!macro NSIS_HOOK_PREUNINSTALL
${If} $DeleteAppDataCheckboxState = 1
MessageBox MB_YESNO "Do you want to remove all EZPPLauncher data and themes?" IDYES do_delete IDNO skip_delete
do_delete:
StrCpy $R9 1
skip_delete:
${EndIf}
!macroend
!macro NSIS_HOOK_POSTUNINSTALL
${If} $R9 = 1
RMDir /r "$PROFILE\.ezpplauncher"
${EndIf}
!macroend
+69 -16
View File
@@ -12,6 +12,7 @@ use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::time::{Duration, sleep};
use crate::OpenedUrls;
use crate::osudb::parse_osudb;
use crate::presence;
use crate::utils::{
@@ -20,6 +21,11 @@ use crate::utils::{
set_osu_config_vals, set_osu_user_config_vals,
};
#[tauri::command]
pub fn opened_urls(app: tauri::AppHandle) -> Vec<PathBuf> {
app.state::<OpenedUrls>().0.lock().unwrap().clone()
}
#[tauri::command]
pub fn get_launcher_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
@@ -163,7 +169,12 @@ pub async fn get_beatmapsets_count(folder: String) -> Option<u64> {
}
let osu_db_bytes = fs::read(osu_db_path).await.ok()?;
let osu_db = parse_osudb(osu_db_bytes).ok()?;
let beatmap_sets = osu_db.beatmaps.iter().map(|b| b.beatmapset_id).collect::<HashSet<_>>().len();
let beatmap_sets = osu_db
.beatmaps
.iter()
.map(|b| b.beatmapset_id)
.collect::<HashSet<_>>()
.len();
Some(beatmap_sets as u64)
}
@@ -242,7 +253,7 @@ pub async fn get_skins_count(folder: String) -> Option<u64> {
let skins_folder = path.join("Skins");
if !skins_folder.exists() {
return None;
return Some(0);
}
let mut count = 0;
@@ -431,21 +442,48 @@ pub async fn run_osu(folder: String, patch: bool) -> Result<(), String> {
}
};
if patch {
sleep(Duration::from_secs(3)).await;
#[cfg(windows)]
{
if patch {
let patcher_exe_path = PathBuf::from(&folder)
.join("EZPPLauncher")
.join("patcher")
.join("osu!.patcher.exe");
let patcher_exe_path = PathBuf::from(&folder)
.join("EZPPLauncher")
.join("patcher")
.join("osu!.patcher.exe");
if patcher_exe_path.exists() {
#[cfg(windows)]
{
let mut sys = System::new_all();
if patcher_exe_path.exists() {
#[cfg(windows)]
{
let _ = Command::new(&patcher_exe_path)
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
.spawn()
.map_err(|e| format!("Failed to run patcher: {e}"))?;
loop {
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
let mut found = false;
for (_pid, process) in sys.processes() {
if process.name() == "osu!.exe" {
let pid = process.pid();
let title = get_window_title_by_pid(pid);
if !title.is_empty() && !title.contains("updater") {
found = true;
break;
}
}
}
if found {
break;
}
sleep(Duration::from_millis(500)).await;
}
let _ = Command::new(&patcher_exe_path)
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
.spawn()
.map_err(|e| format!("Failed to run patcher: {e}"))?;
}
}
}
}
@@ -829,6 +867,21 @@ pub async fn presence_update_user(user: PresenceUser) {
presence::update_user(user.username.as_deref(), user.id.as_deref());
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PresenceButton {
text: Option<String>,
url: Option<String>,
}
#[tauri::command]
pub async fn presence_update_button(button: PresenceButton) {
match (button.text.as_deref(), button.url.as_deref()) {
(Some(text), Some(url)) => presence::set_button(text, url),
_ => presence::clear_button(),
}
}
#[tauri::command]
pub async fn presence_is_connected() -> bool {
presence::has_presence().await
@@ -928,4 +981,4 @@ pub async fn install_ezpp_launcher_update(app: AppHandle) -> Result<(), String>
#[tauri::command]
pub async fn install_ezpp_launcher_update(_app: AppHandle) -> Result<(), String> {
Ok(())
}
}
+55 -5
View File
@@ -1,21 +1,26 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::Manager;
use tauri_plugin_fs::FsExt;
pub mod commands;
pub mod osudb;
pub mod presence;
pub mod utils;
pub mod osudb;
use crate::commands::{
check_for_corruption, download_ezpp_launcher_update, download_ezpp_launcher_update_files,
encrypt_string, exit, find_osu_installation, get_beatmapsets_count,
get_ezpp_launcher_update_files, get_hwid, get_launcher_version, get_osu_release_stream,
get_osu_skin, get_osu_version, get_platform, get_skins, get_skins_count, has_net8,
has_osuwinello, has_wmctrl, install_ezpp_launcher_update, is_osu_running, open_url_in_browser,
presence_connect, presence_disconnect, presence_is_connected, presence_update_status,
presence_update_user, replace_ui_files, run_osu, run_osu_updater, set_osu_config_values,
set_osu_user_config_values, valid_osu_folder,
opened_urls, presence_connect, presence_disconnect, presence_is_connected,
presence_update_status, presence_update_user, replace_ui_files, run_osu, run_osu_updater,
set_osu_config_values, set_osu_user_config_values, valid_osu_folder,
};
struct OpenedUrls(Mutex<Vec<PathBuf>>);
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(target_os = "linux")]
@@ -26,14 +31,59 @@ pub fn run() {
let mut builder = tauri::Builder::default();
#[cfg(desktop)]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
let app_window = app.get_webview_window("main").expect("no main window");
app_window.set_focus().expect("failed to focus");
if args.len() > 1 {
use tauri::Emitter;
let mut files = Vec::new();
for maybe_file in args.iter().skip(1) {
if maybe_file.starts_with("-") {
continue;
}
let path = PathBuf::from(maybe_file);
if path.extension().and_then(|e| e.to_str()) == Some("ezpplauncher-theme") {
app.fs_scope().allow_file(&path).ok();
files.push(path);
}
}
app.state::<OpenedUrls>().0.lock().unwrap().extend(files.clone());
app.emit("opened", files.clone()).ok();
}
}));
}
let app = builder
.manage(OpenedUrls(Mutex::new(vec![])))
.setup(|app| {
let args = std::env::args().collect::<Vec<_>>();
if args.len() > 1 {
let mut files = Vec::new();
for maybe_file in args.iter().skip(1) {
if maybe_file.starts_with("-") {
continue;
}
let path = PathBuf::from(maybe_file);
if path.extension().and_then(|e| e.to_str()) == Some("ezpplauncher-theme") {
app.fs_scope().allow_file(&path).ok();
files.push(path);
}
}
app.state::<OpenedUrls>().0.lock().unwrap().extend(files);
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
opened_urls,
get_hwid,
find_osu_installation,
valid_osu_folder,
+215 -147
View File
@@ -3,13 +3,17 @@ use discord_rich_presence::{
activity::{Activity, Assets, Button, Timestamps},
};
use once_cell::sync::Lazy;
use std::future::Future;
use std::pin::Pin;
use std::sync::Mutex as StdMutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::{mpsc, oneshot};
use tokio::time::{Duration, interval};
#[derive(Clone, Debug)]
pub struct PresenceButton {
pub label: String,
pub url: String,
}
#[derive(Clone, Debug)]
pub struct PresenceData {
pub state: String,
@@ -18,6 +22,21 @@ pub struct PresenceData {
pub large_image_text: String,
pub small_image_key: Option<String>,
pub small_image_text: Option<String>,
pub dynamic_button: Option<PresenceButton>,
}
impl Default for PresenceData {
fn default() -> Self {
Self {
state: "Idle in Launcher...".to_string(),
details: " ".to_string(),
large_image_key: "ezppfarm".to_string(),
large_image_text: "EZPPFarm".to_string(),
small_image_key: None,
small_image_text: None,
dynamic_button: None,
}
}
}
#[derive(Debug)]
@@ -26,14 +45,21 @@ enum PresenceCommand {
Disconnect(oneshot::Sender<()>),
UpdateData(PresenceData),
IsConnected(oneshot::Sender<bool>),
Tick,
}
enum ReconnectResult {
Connected(DiscordIpcClient),
Failed,
}
struct PresenceActor {
receiver: mpsc::Receiver<PresenceCommand>,
reconnect_rx: mpsc::Receiver<ReconnectResult>,
reconnect_tx: mpsc::Sender<ReconnectResult>,
client: Option<DiscordIpcClient>,
data: PresenceData,
start_timestamp: i64,
reconnecting: bool,
}
impl PresenceActor {
@@ -43,175 +69,183 @@ impl PresenceActor {
.unwrap()
.as_secs() as i64;
let data = PresenceData {
state: "Idle in Launcher...".to_string(),
details: " ".to_string(),
large_image_key: "ezppfarm".to_string(),
large_image_text: "EZPPFarm".to_string(),
small_image_key: None,
small_image_text: None,
};
let (reconnect_tx, reconnect_rx) = mpsc::channel(1);
PresenceActor {
Self {
receiver,
reconnect_rx,
reconnect_tx,
client: None,
data,
data: PresenceData::default(),
start_timestamp: start,
reconnecting: false,
}
}
async fn run(&mut self) {
let mut update_interval = interval(Duration::from_secs(5));
let mut heartbeat = interval(Duration::from_secs(5));
heartbeat.tick().await;
loop {
tokio::select! {
Some(cmd) = self.receiver.recv() => {
match cmd {
PresenceCommand::Connect(responder) => self.handle_connect(responder).await,
PresenceCommand::Disconnect(responder) => {
self.handle_disconnect(responder).await;
},
PresenceCommand::UpdateData(new_data) => {
self.data = new_data;
},
PresenceCommand::IsConnected(responder) => {
let _ = responder.send(self.client.is_some());
PresenceCommand::Connect(resp) => self.cmd_connect(resp).await,
PresenceCommand::Disconnect(resp) => self.cmd_disconnect(resp).await,
PresenceCommand::UpdateData(data) => {
self.data = data;
self.push_presence().await;
}
PresenceCommand::Tick => {
if self.client.is_some() {
self.handle_update().await;
}
PresenceCommand::IsConnected(resp) => {
let _ = resp.send(self.client.is_some());
}
}
}
_ = update_interval.tick() => {
let _ = PRESENCE_TX.send(PresenceCommand::Tick).await;
Some(result) = self.reconnect_rx.recv() => {
self.reconnecting = false;
match result {
ReconnectResult::Connected(client) => {
println!("Presence: reconnect succeeded.");
self.client = Some(client);
self.push_presence().await;
}
ReconnectResult::Failed => {
eprintln!("Presence: all reconnect attempts exhausted.");
}
}
}
_ = heartbeat.tick() => {
self.push_presence().await;
}
}
}
}
fn handle_connect(
&mut self,
responder: oneshot::Sender<bool>,
) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
Box::pin(async move {
if self.client.is_some() {
let _ = responder.send(true);
return;
}
async fn cmd_connect(&mut self, resp: oneshot::Sender<bool>) {
if self.client.is_some() {
let _ = resp.send(true);
return;
}
println!("Actor: Connecting to Discord...");
let mut dc_client = DiscordIpcClient::new("1032772293220384808");
match dc_client.connect() {
Ok(()) => {
self.client = Some(dc_client);
println!("Actor: Connected successfully.");
self.handle_update().await;
let _ = responder.send(true);
}
Err(e) => {
eprintln!(
"Failed to connect to Discord: {:?}. Scheduling bounded retry...",
e
);
let tx = PRESENCE_TX.clone();
tokio::spawn(async move {
for attempt in 1..=3 {
tokio::time::sleep(Duration::from_secs(5 * attempt)).await;
let (resp_tx, rx) = oneshot::channel();
if tx.send(PresenceCommand::Connect(resp_tx)).await.is_ok() {
if rx.await.unwrap_or(false) {
println!("Actor: Reconnection attempt {} succeeded", attempt);
break;
}
println!(
"Actor: Reconnection attempt {} failed, retrying...",
attempt
);
}
}
});
if !responder.is_closed() {
let _ = responder.send(false);
}
}
match try_connect_once() {
Some(client) => {
self.client = Some(client);
self.reconnecting = false;
self.push_presence().await;
let _ = resp.send(true);
}
})
None => {
eprintln!("Presence: initial connect failed; spawning reconnect task.");
let _ = resp.send(false);
self.spawn_reconnect_task();
}
}
}
async fn handle_disconnect(&mut self, responder: oneshot::Sender<()>) {
async fn cmd_disconnect(&mut self, resp: oneshot::Sender<()>) {
if let Some(mut client) = self.client.take() {
println!("Actor: Disconnecting from Discord...");
let _ = client.clear_activity();
let _ = client.close();
println!("Actor: Disconnected successfully.");
println!("Presence: disconnected.");
}
let _ = responder.send(());
let _ = resp.send(());
}
async fn handle_update(&mut self) {
if let Some(client) = self.client.as_mut() {
let mut assets = Assets::new()
.large_image(&self.data.large_image_key)
.large_text(&self.data.large_image_text);
async fn push_presence(&mut self) {
let client = match self.client.as_mut() {
Some(c) => c,
None => return,
};
if let Some(key) = &self.data.small_image_key {
assets = assets.small_image(key);
}
if let Some(text) = &self.data.small_image_text {
assets = assets.small_text(text);
let mut assets = Assets::new()
.large_image(&self.data.large_image_key)
.large_text(&self.data.large_image_text);
if let Some(key) = &self.data.small_image_key {
assets = assets.small_image(key);
}
if let Some(text) = &self.data.small_image_text {
assets = assets.small_text(text);
}
let buttons = if let Some(btn) = &self.data.dynamic_button {
vec![
Button::new(&btn.label, &btn.url),
Button::new("Join EZPPFarm", "https://ez-pp.farm/discord"),
]
} else {
vec![
Button::new("Download the Launcher", "https://git.ez-pp.farm/EZPPFarm/EZPPLauncher/releases/latest"),
Button::new("Join EZPPFarm", "https://ez-pp.farm/discord"),
]
};
let activity = Activity::new()
.state(&self.data.state)
.details(&self.data.details)
.timestamps(Timestamps::new().start(self.start_timestamp))
.assets(assets)
.buttons(buttons);
if let Err(e) = client.set_activity(activity) {
eprintln!("Presence: set_activity failed ({:?}); will reconnect.", e);
self.client = None;
self.spawn_reconnect_task();
}
}
fn spawn_reconnect_task(&mut self) {
if self.reconnecting {
return;
}
self.reconnecting = true;
let tx = self.reconnect_tx.clone();
tokio::spawn(async move {
const MAX_ATTEMPTS: u32 = 5;
for attempt in 1..=MAX_ATTEMPTS {
let delay = Duration::from_secs(5 * attempt as u64);
println!(
"Presence: reconnect attempt {}/{} in {:?}…",
attempt, MAX_ATTEMPTS, delay
);
tokio::time::sleep(delay).await;
if let Some(client) = try_connect_once() {
let _ = tx.send(ReconnectResult::Connected(client)).await;
return;
}
}
let _ = tx.send(ReconnectResult::Failed).await;
});
}
}
let activity = Activity::new()
.state(&self.data.state)
.details(&self.data.details)
.timestamps(Timestamps::new().start(self.start_timestamp))
.assets(assets)
.buttons(vec![
Button::new(
"Download the Launcher",
"https://git.ez-pp.farm/EZPPFarm/EZPPLauncher/releases/latest",
),
Button::new("Join EZPPFarm", "https://ez-pp.farm/discord"),
]);
if let Err(e) = client.set_activity(activity) {
eprintln!("Failed to set activity, disconnecting: {:?}", e);
self.client = None;
// Don't await reconnection here - send command to avoid blocking the actor
let tx = PRESENCE_TX.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(1)).await;
let (resp_tx, _) = oneshot::channel();
let _ = tx.send(PresenceCommand::Connect(resp_tx)).await;
});
} else {
println!("Actor: Presence updated successfully.");
}
fn try_connect_once() -> Option<DiscordIpcClient> {
let mut client = DiscordIpcClient::new("1032772293220384808");
match client.connect() {
Ok(()) => {
println!("Presence: IPC connected.");
Some(client)
}
Err(e) => {
eprintln!("Presence: IPC connect error: {:?}", e);
None
}
}
}
static PRESENCE_TX: Lazy<mpsc::Sender<PresenceCommand>> = Lazy::new(|| {
let (tx, rx) = mpsc::channel(10);
let (tx, rx) = mpsc::channel(32);
let mut actor = PresenceActor::new(rx);
tokio::spawn(async move { actor.run().await });
tx
});
pub static PRESENCE_DATA: Lazy<StdMutex<PresenceData>> = Lazy::new(|| {
StdMutex::new(PresenceData {
state: "Idle in Launcher...".to_string(),
details: " ".to_string(),
large_image_key: "ezppfarm".to_string(),
large_image_text: "EZPPFarm".to_string(),
small_image_key: None,
small_image_text: None,
})
});
pub static PRESENCE_DATA: Lazy<StdMutex<PresenceData>> =
Lazy::new(|| StdMutex::new(PresenceData::default()));
pub async fn connect() -> bool {
let (tx, rx) = oneshot::channel();
@@ -223,8 +257,7 @@ pub async fn connect() -> bool {
pub async fn disconnect() {
let (tx, rx) = oneshot::channel();
// Use a timeout to ensure we don't hang the exit process if Discord is unresponsive
if let Ok(_) = PRESENCE_TX.send(PresenceCommand::Disconnect(tx)).await {
if PRESENCE_TX.send(PresenceCommand::Disconnect(tx)).await.is_ok() {
let _ = tokio::time::timeout(Duration::from_secs(2), rx).await;
}
}
@@ -242,30 +275,65 @@ pub async fn has_presence() -> bool {
}
pub fn update_status(state: Option<&str>, details: Option<&str>, large_image_key: Option<&str>) {
let mut data = PRESENCE_DATA.lock().unwrap();
if let Some(s) = state {
data.state = s.to_string();
}
if let Some(d) = details {
data.details = d.to_string();
}
if let Some(img) = large_image_key {
data.large_image_key = img.to_string();
}
let data_clone = data.clone();
let data = {
let mut guard = PRESENCE_DATA.lock().unwrap();
if let Some(s) = state {
guard.state = s.to_string();
}
if let Some(d) = details {
guard.details = d.to_string();
}
if let Some(img) = large_image_key {
guard.large_image_key = img.to_string();
}
guard.clone()
};
let tx = PRESENCE_TX.clone();
tokio::spawn(async move {
let _ = tx.send(PresenceCommand::UpdateData(data_clone)).await;
let _ = tx.send(PresenceCommand::UpdateData(data)).await;
});
}
pub fn set_button(label: &str, url: &str) {
let data = {
let mut guard = PRESENCE_DATA.lock().unwrap();
guard.dynamic_button = Some(PresenceButton {
label: label.to_string(),
url: url.to_string(),
});
guard.clone()
};
let tx = PRESENCE_TX.clone();
tokio::spawn(async move {
let _ = tx.send(PresenceCommand::UpdateData(data)).await;
});
}
pub fn clear_button() {
let data = {
let mut guard = PRESENCE_DATA.lock().unwrap();
guard.dynamic_button = None;
guard.clone()
};
let tx = PRESENCE_TX.clone();
tokio::spawn(async move {
let _ = tx.send(PresenceCommand::UpdateData(data)).await;
});
}
pub fn update_user(username: Option<&str>, id: Option<&str>) {
let mut data = PRESENCE_DATA.lock().unwrap();
data.small_image_key = id.map(|id_str| format!("https://a.ez-pp.farm/{}", id_str));
data.small_image_text = username.map(|s| s.to_string());
let data_clone = data.clone();
let data = {
let mut guard = PRESENCE_DATA.lock().unwrap();
guard.small_image_key = id.map(|s| format!("https://a.ez-pp.farm/{}", s));
guard.small_image_text = username.map(str::to_string);
guard.clone()
};
let tx = PRESENCE_TX.clone();
tokio::spawn(async move {
let _ = tx.send(PresenceCommand::UpdateData(data_clone)).await;
let _ = tx.send(PresenceCommand::UpdateData(data)).await;
});
}
}
+9
View File
@@ -42,6 +42,15 @@
"nsis",
"appimage"
],
"fileAssociations": [
{
"ext": ["ezpplauncher-theme"],
"name": "ezppLauncher Theme",
"description": "ezppLauncher Theme File",
"role": "Viewer",
"mimeType": "application/x-ezpplauncher-theme"
}
],
"windows": {
"webviewInstallMode": {
"silent": true,
@@ -7,17 +7,26 @@
let { smoothCursor = true }: { smoothCursor?: boolean } = $props();
const VELOCITY_WINDOW_MS = 16;
const SPEED_THRESHOLD = 9;
const REQUIRED_CONSECUTIVE = 3;
let mouseX = $state(0);
let mouseY = $state(0);
let lastMouseX = $state(0);
let lastMouseY = $state(0);
let lastMouseX = 0;
let lastMouseY = 0;
let isMouseDown = $state(false);
let isHoveringInteractive = $state(false);
let dragStartX = $state(0);
let dragStartY = $state(0);
let dragStartX = 0;
let dragStartY = 0;
let degrees = $state(0);
let applyRotation = $state(false);
let lastVelocityTime = 0;
let consecutiveHighSpeed = 0;
let velocityAccumX = 0;
let velocityAccumY = 0;
function isInteractive(el: Element | null): boolean {
while (el) {
const tag = el.tagName.toLowerCase();
@@ -30,7 +39,6 @@
) {
return true;
}
el = el.parentElement;
}
return false;
@@ -43,17 +51,32 @@
const deltaX = e.pageX - window.pageXOffset - dragStartX;
const deltaY = e.pageY - window.pageYOffset - dragStartY;
const velocityX = mouseX - lastMouseX;
const velocityY = mouseY - lastMouseY;
velocityAccumX += mouseX - lastMouseX;
velocityAccumY += mouseY - lastMouseY;
const dist = Math.sqrt(velocityX * velocityX + velocityY * velocityY);
const now = performance.now();
const dt = now - lastVelocityTime;
if (!applyRotation && isMouseDown && dist > 80) applyRotation = true;
if (dt >= VELOCITY_WINDOW_MS) {
const speed = Math.sqrt(velocityAccumX ** 2 + velocityAccumY ** 2) / dt;
if (speed > SPEED_THRESHOLD) {
consecutiveHighSpeed++;
if (!applyRotation && isMouseDown && consecutiveHighSpeed >= REQUIRED_CONSECUTIVE) {
applyRotation = true;
}
} else {
consecutiveHighSpeed = 0;
}
velocityAccumX = 0;
velocityAccumY = 0;
lastVelocityTime = now;
}
let newDegrees = (Math.atan2(-deltaX, deltaY) * 180) / Math.PI + 24.3;
let diff = (newDegrees - degrees) % 360;
if (diff < -180) diff += 360;
if (diff > 180) diff -= 360;
@@ -67,26 +90,18 @@
isHoveringInteractive = isInteractive(el);
if (!isMouseDown) {
if (isHoveringInteractive) {
animate(cursorAdditive, {
opacity: 1,
duration: 800,
ease: (t: number) => (t - 1) ** 5 + 1,
});
} else {
animate(cursorAdditive, {
opacity: 0,
duration: 800,
ease: (t: number) => (t - 1) ** 5 + 1,
});
}
animate(cursorAdditive, {
opacity: isHoveringInteractive ? 1 : 0,
duration: 800,
ease: (t: number) => (t - 1) ** 5 + 1,
});
}
animate(cursor, {
duration: smoothCursor ? $cursorSmoothness : 0,
translateX: mouseX,
translateY: mouseY,
ease: (t: number) => (t - 1) ** 5 + 1,
ease: (t: number) => t === 1 ? 1 : 1 - Math.pow(2, -10 * t),
});
animate(cursor, {
@@ -107,6 +122,11 @@
dragStartX = event.clientX;
dragStartY = event.clientY;
velocityAccumX = 0;
velocityAccumY = 0;
consecutiveHighSpeed = 0;
lastVelocityTime = performance.now();
isMouseDown = true;
animate(cursorInner, {
scale: 0.9,
@@ -124,6 +144,10 @@
const handleMouseUp = () => {
isMouseDown = false;
applyRotation = false;
consecutiveHighSpeed = 0;
velocityAccumX = 0;
velocityAccumY = 0;
animate(cursorInner, {
scale: 1,
duration: 500,
@@ -179,4 +203,4 @@
:global(html.hiddenCursor textarea) {
cursor: none !important;
}
</style>
</style>
+12 -10
View File
@@ -1,19 +1,21 @@
export function estimateRefreshRate(): Promise<number> {
export async function estimateFrameRate(): Promise<number> {
return new Promise((resolve) => {
const last = performance.now();
let frames = 0;
const samples: number[] = [];
let last = performance.now();
function loop() {
const tick = () => {
const now = performance.now();
frames++;
samples.push(now - last);
last = now;
if (now - last >= 1000) {
resolve(frames - 2);
if (samples.length < 60) {
requestAnimationFrame(tick);
} else {
requestAnimationFrame(loop);
const avgDelta = samples.reduce((a, b) => a + b) / samples.length;
resolve(Math.round(1000 / avgDelta));
}
}
};
requestAnimationFrame(loop);
requestAnimationFrame(tick);
});
}
+60
View File
@@ -0,0 +1,60 @@
import { listen } from '@tauri-apps/api/event';
import { onDestroy } from 'svelte';
type TauriDragDropEvent = {
paths: string[];
position: { x: number; y: number };
};
export const useDropZone = ({ onDrop }: { onDrop: (paths: string[]) => void }) => {
let element = $state<HTMLDivElement | null>(null);
let dragIn = $state(false);
let isDraggingOverApp = $state(false);
const isOver = (x: number, y: number) => {
if (!element) return false;
const el = document.elementFromPoint(x, y);
return element === el || element.contains(el); // ← fix
};
const dropUnlisten = listen<TauriDragDropEvent>('tauri://drag-drop', (e) => {
const { x, y } = e.payload.position;
if (isOver(x, y)) {
onDrop(e.payload.paths);
}
dragIn = false;
isDraggingOverApp = false;
});
const overUnlisten = listen<TauriDragDropEvent>('tauri://drag-over', (e) => {
const { x, y } = e.payload.position;
isDraggingOverApp = true;
dragIn = isOver(x, y);
});
const leaveUnlisten = listen('tauri://drag-leave', () => {
dragIn = false;
isDraggingOverApp = false;
});
onDestroy(async () => {
(await dropUnlisten)();
(await overUnlisten)();
(await leaveUnlisten)();
});
return {
get ref() {
return element;
},
set ref(el: HTMLDivElement | null) {
element = el;
},
get dragIn() {
return dragIn;
},
get isDraggingOverApp() {
return isDraggingOverApp;
},
};
};
+23 -38
View File
@@ -17,20 +17,14 @@ export function normalizeOsuName(name: string): string {
);
}
export const getHWID = async () => {
const hwid = await invoke('get_hwid');
return typeof hwid === 'string' ? hwid : undefined;
};
export const getHWID = async (): Promise<string | undefined> =>
await invoke<string | undefined>('get_hwid');
export const isValidOsuFolder = async (folder: string): Promise<boolean> => {
const result = await invoke('valid_osu_folder', { folder });
return typeof result === 'boolean' ? result : false;
};
export const isValidOsuFolder = async (folder: string): Promise<boolean | undefined> =>
await invoke<boolean | undefined>('valid_osu_folder', { folder });
export const autoDetectOsuInstallFolder = async () => {
const result = await invoke('find_osu_installation');
return typeof result === 'string' ? result : undefined;
};
export const autoDetectOsuInstallFolder = async () =>
await invoke<string | undefined>('find_osu_installation');
export const setUserConfigValues = async (
osuFolderPath: string,
@@ -50,29 +44,23 @@ export const setConfigValues = async (
entries,
});
export const getReleaseStream = async (folder: string) => {
const result = await invoke('get_osu_release_stream', { folder });
return typeof result === 'string' ? result : undefined;
};
export const getReleaseStream = async (folder: string): Promise<string | undefined> =>
await invoke<string | undefined>('get_osu_release_stream', { folder });
export const getVersion = async (folder: string) => {
const result = await invoke('get_osu_version', { folder });
return typeof result === 'string' ? result : undefined;
};
export const getVersion = async (folder: string): Promise<string | undefined> =>
await invoke<string | undefined>('get_osu_version', { folder });
export const getBeatmapSetsCount = async (folder: string) => {
const result = await invoke('get_beatmapsets_count', {
export const getBeatmapSetsCount = async (folder: string): Promise<number | undefined> =>
await invoke<number | undefined>('get_beatmapsets_count', {
folder,
});
return typeof result === 'number' ? result : 0;
};
export const getSkin = async (folder: string) => {
const result = await invoke('get_osu_skin', {
const result = await invoke<string | undefined>('get_osu_skin', {
folder,
});
return typeof result === 'string' ? result : 'Default';
return result || 'Default';
};
export const runUpdater = async (folder: string) => await invoke('run_osu_updater', { folder });
@@ -113,8 +101,8 @@ export const downloadEZPPLauncherUpdateFiles = async (
allFiles: UpdateFile[],
progressCallback: (file: UpdateStatus) => void
) => {
const downloadStatusListen = await listen('download-progress', (event) =>
progressCallback(event.payload as UpdateStatus)
const downloadStatusListen = await listen<UpdateStatus>('download-progress', (event) =>
progressCallback(event.payload)
);
try {
await invoke('download_ezpp_launcher_update_files', { folder, updateFiles, allFiles });
@@ -127,20 +115,17 @@ export const replaceUIFiles = async (folder: string, revert: boolean) => {
await invoke('replace_ui_files', { folder, revert });
};
export const isOsuRunning = async () => {
const result = await invoke('is_osu_running');
return typeof result === 'boolean' ? result : false;
};
export const isOsuRunning = async (): Promise<boolean> => await invoke<boolean>('is_osu_running');
export const getLauncherVersion = async () => await invoke<string>('get_launcher_version');
export const exit = async () => await invoke('exit');
export const getPlatform = async () => await invoke<string>('get_platform');
export const isOsuCorrupted = async (folder: string) =>
export const getPlatform = async (): Promise<string> => await invoke<string>('get_platform');
export const isOsuCorrupted = async (folder: string): Promise<boolean> =>
await invoke<boolean>('check_for_corruption', { folder });
export const hasWMCTRL = async () => await invoke<boolean>('has_wmctrl');
export const hasOsuWinello = async () => await invoke<boolean>('has_osuwinello');
export const hasNet8 = async () => await invoke<boolean>('has_net8');
export const encryptString = async (str: string, entropy: string) =>
export const hasWMCTRL = async (): Promise<boolean> => await invoke<boolean>('has_wmctrl');
export const hasOsuWinello = async (): Promise<boolean> => await invoke<boolean>('has_osuwinello');
export const hasNet8 = async (): Promise<boolean> => await invoke<boolean>('has_net8');
export const encryptString = async (str: string, entropy: string): Promise<string> =>
await invoke<string>('encrypt_string', { string: str, entropy });
export const downloadUpdate = async (
url: string,
+2
View File
@@ -16,4 +16,6 @@ export const updateStatus = async (status: {
});
export const updateUser = async (user: { username: string; id?: string | null }) =>
await invoke('presence_update_user', { user: { username: user.username, id: user.id } });
export const updateButton = async (button: { text?: string; url?: string }) =>
await invoke('presence_update_button', { button: button });
export const isConnected = async () => await invoke<boolean>('presence_is_connected');
+163 -28
View File
@@ -20,6 +20,16 @@ export type Theme = {
updateAvailable: boolean;
};
export type ThemeInfo = {
name: string;
version: string;
apiVersion: string;
author: string;
entry: string;
style: string;
preview: string;
};
export const getDownloadableThemes = async () => {
const themes: Theme[] = [];
@@ -82,15 +92,7 @@ export const getThemes = async (): Promise<Theme[]> => {
const themeConfigFilePath = await path.join(folderPath, themeFolder.name, 'theme.json');
if (await fs.exists(themeConfigFilePath)) {
const themeConfig = await fs.readTextFile(themeConfigFilePath);
const theme = JSON.parse(themeConfig) as {
name: string;
version: string;
apiVersion: string;
author: string;
entry: string;
style: string;
preview: string;
};
const theme = JSON.parse(themeConfig) as ThemeInfo;
const scriptFile = await path.join(folderPath, themeFolder.name, theme.entry);
if (await fs.exists(scriptFile)) {
themes.push({
@@ -272,15 +274,7 @@ export const downloadTheme = async (
}
}
const themeInfo = await fs.readTextFile(await path.join(themeFolder, 'theme.json'));
const themeInfoObj = JSON.parse(themeInfo) as {
name: string;
version: string;
apiVersion: string;
author: string;
entry: string;
style: string;
preview: string;
};
const themeInfoObj = JSON.parse(themeInfo) as ThemeInfo;
const themeScriptUrl = convertFileSrc(
await path.normalize(`${themeFolder}/${themeInfoObj.entry}`)
);
@@ -292,8 +286,13 @@ export const downloadTheme = async (
const downloadableThemes = await getDownloadableThemes();
custom_themes.update((themes) => {
const reloadedThemes = [...themes];
for (const theme of downloadableThemes) {
if (!reloadedThemes.find((t) => t.name === theme.name)) reloadedThemes.push(theme);
if (theme.name === 'Default') continue; // never overwrite Default from remote
if (!reloadedThemes.find((t) => t.name === theme.name)) {
reloadedThemes.push(theme);
}
const installedTheme = reloadedThemes.find((t) => t.name === theme.name);
if (installedTheme) {
@@ -306,17 +305,21 @@ export const downloadTheme = async (
}
}
}
for (const installedThemes of reloadedThemes) {
if (installedThemes.name === theme.name) {
installedThemes.name = themeInfoObj.name;
installedThemes.author = themeInfoObj.author;
installedThemes.version = themeInfoObj.version;
installedThemes.scriptUrl = themeScriptUrl;
installedThemes.assets = themeAssets;
installedThemes.preview = themePreview;
installedThemes.status = 'installed';
for (const installedTheme of reloadedThemes) {
if (installedTheme.name === 'Default') continue; // never touch Default
if (installedTheme.name === theme.name) {
installedTheme.name = themeInfoObj.name;
installedTheme.author = themeInfoObj.author;
installedTheme.version = themeInfoObj.version;
installedTheme.scriptUrl = themeScriptUrl;
installedTheme.assets = themeAssets;
installedTheme.preview = themePreview;
installedTheme.status = 'installed';
}
}
return reloadedThemes.sort((a, b) => {
if (a.name === 'Default') return -1;
if (b.name === 'Default') return 1;
@@ -390,6 +393,138 @@ export const deleteTheme = async (themeToUninstall: Theme) => {
return true;
};
export const checkThemeFromFile = async (filePath: string) => {
const normalizedFilePath = await path.normalize(filePath);
if (!(await fs.exists(normalizedFilePath))) {
return {
success: false,
error: 'File not found',
};
}
const fileBuffer = await fs.readFile(normalizedFilePath);
let themeInfo: ThemeInfo | undefined = undefined;
try {
const zipFile = await zip.loadAsync(fileBuffer);
const themeConfig = zipFile.file('theme.json');
if (!themeConfig) {
return {
success: false,
error: 'Theme config file not found',
};
}
const themeConfigData = await themeConfig.async('string');
themeInfo = JSON.parse(themeConfigData) as {
name: string;
version: string;
apiVersion: string;
author: string;
entry: string;
style: string;
preview: string;
};
} catch {
return {
success: false,
error: 'Could not read theme file',
};
}
return {
success: !!themeInfo,
themeInfo,
};
};
export const importThemeFromFile = async (themeName: string, filePath: string) => {
const baseThemeFolder = await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
const themeFolder = await path.join(baseThemeFolder, toSafeName(themeName));
if (!(await fs.exists(baseThemeFolder))) await fs.mkdir(baseThemeFolder, { recursive: true });
if (!(await fs.exists(themeFolder))) await fs.mkdir(themeFolder, { recursive: true });
const fileBuffer = await fs.readFile(filePath);
const zipFile = await zip.loadAsync(fileBuffer);
for (const zipEntry of Object.keys(zipFile.files)) {
const file = zipFile.file(zipEntry);
if (file) {
try {
const fileData = await file.async('uint8array');
const filePath = await path.join(themeFolder, zipEntry);
const dirPath = await path.dirname(filePath);
if (!(await fs.exists(dirPath))) await fs.mkdir(dirPath, { recursive: true });
await new Promise((res) => setTimeout(res, 250));
await fs.writeFile(filePath, fileData);
} catch (err) {
console.log(err);
return {
success: false,
error: 'Failed to extract theme file.',
};
}
}
}
const themeInfo = await fs.readTextFile(await path.join(themeFolder, 'theme.json'));
const themeInfoObj = JSON.parse(themeInfo) as ThemeInfo;
const themeScriptUrl = convertFileSrc(
await path.normalize(`${themeFolder}/${themeInfoObj.entry}`)
);
const themeAssets = convertFileSrc(await path.normalize(`${themeFolder}/assets`));
const themePreview = convertFileSrc(
await path.normalize(`${themeFolder}/assets/${themeInfoObj.preview}`)
);
const downloadableThemes = await getDownloadableThemes();
custom_themes.update((themes) => {
const reloadedThemes = [...themes];
for (const theme of downloadableThemes) {
if (theme.name === 'Default') continue; // never overwrite Default from remote
if (!reloadedThemes.find((t) => t.name === theme.name)) {
reloadedThemes.push(theme);
}
const installedTheme = reloadedThemes.find((t) => t.name === theme.name);
if (installedTheme) {
const installedThemeVersion = new SemVer(installedTheme.version);
const downloadableThemeVersion = new SemVer(theme.version);
if (downloadableThemeVersion.compare(installedThemeVersion) > 0) {
installedTheme.updateAvailable = true;
const index = reloadedThemes.findIndex((t) => t.name === theme.name);
reloadedThemes[index] = installedTheme;
}
}
}
for (const installedTheme of reloadedThemes) {
if (installedTheme.name === 'Default') continue; // never touch Default
if (installedTheme.name === themeName) {
installedTheme.name = themeInfoObj.name;
installedTheme.author = themeInfoObj.author;
installedTheme.version = themeInfoObj.version;
installedTheme.scriptUrl = themeScriptUrl;
installedTheme.assets = themeAssets;
installedTheme.preview = themePreview;
installedTheme.status = 'installed';
}
}
return reloadedThemes.sort((a, b) => {
if (a.name === 'Default') return -1;
if (b.name === 'Default') return 1;
if (a.status === 'installed' && b.status !== 'installed') return -1;
if (a.status !== 'installed' && b.status === 'installed') return 1;
return a.name.localeCompare(b.name);
});
});
return {
success: true,
};
};
export const toSafeName = (name: string) => {
return (
name
+255 -4
View File
@@ -46,6 +46,7 @@
Paintbrush,
Trash,
CloudDownload,
Import,
} from 'lucide-svelte';
import NumberFlow from '@number-flow/svelte';
import * as AlertDialog from '@/components/ui/alert-dialog';
@@ -62,7 +63,7 @@
setCurrentTimeGlobalMedia,
urlIsValidImage,
} from '@/utils';
import { fade, fly } from 'svelte/transition';
import { fade, fly, scale } from 'svelte/transition';
import { Checkbox } from '@/components/ui/checkbox';
import Label from '@/components/ui/label/label.svelte';
import {
@@ -122,7 +123,17 @@
import { animate } from 'animejs';
import { sileo } from 'sileo';
import ScrollContainer from '@/components/ui/scroll-container/ScrollContainer.svelte';
import { deleteTheme, downloadTheme, loadTheme } from '@/themes';
import {
checkThemeFromFile,
deleteTheme,
downloadTheme,
importThemeFromFile,
loadTheme,
type ThemeInfo,
} from '@/themes';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { useDropZone } from '@/dropZone.svelte';
let selectedView = $state('home');
let progress = $state(-1);
@@ -134,6 +145,47 @@
let downloadingUpdate = $state(false);
let dragAndDrop = useDropZone({
onDrop: async (file) => {
const firstFile = file[0];
if (!firstFile.endsWith('.ezpplauncher-theme')) {
sileo.error({
title: 'Uhh...',
description: 'Dropped file is not a valid theme file.',
fill: '#181825',
styles: {
description: 'text-center!',
},
});
return;
}
const importResult = await checkThemeFromFile(firstFile);
if (!importResult.success || !importResult.themeInfo) {
sileo.error({
title: 'Hmmm...',
description: importResult.error || 'An unknown error occurred.',
fill: '#181825',
styles: {
description: 'text-center!',
},
});
return;
}
droppedTheme = {
filePath: firstFile,
themeInfo: importResult.themeInfo,
};
},
});
let droppedTheme = $state<
| {
filePath: string;
themeInfo: ThemeInfo;
}
| undefined
>(undefined);
let themeInstalling = $state(false);
let downloadingEZPPFiles = $state(false);
let cleanup = $state(false);
@@ -183,7 +235,7 @@
},
});
const beatmapSetCount: number | null = await getBeatmapSetsCount(selectedPath);
const beatmapSetCount = await getBeatmapSetsCount(selectedPath);
if (beatmapSetCount) beatmapSets.set(beatmapSetCount);
const skins_list = await getSkins(selectedPath);
@@ -472,6 +524,10 @@
if (userStatus?.player_status.online) {
let largeImageKey = 'ezppfarm';
let details = 'Idle...';
let customButton: { text?: string; url?: string } = {
text: undefined,
url: undefined,
};
let state =
userStatus.player_status.status.info_text.length > 0
? userStatus.player_status.status.info_text
@@ -527,6 +583,10 @@
const beatmapCoverImage = `https://assets.ppy.sh/beatmaps/${userStatus.player_status.status.beatmap.set_id}/covers/list@2x.jpg`;
const isValidImage = await urlIsValidImage(beatmapCoverImage);
if (isValidImage) largeImageKey = beatmapCoverImage;
customButton = {
text: 'View Beatmap',
url: `https://ez-pp.farm/beatmapsets/${userStatus.player_status.status.beatmap.set_id}/${userStatus.player_status.status.beatmap.id}`,
};
}
details = `[${gamemodeName}] ${details}`;
@@ -539,6 +599,7 @@
username += ` (#${currentModeStats.rank})`;
await Promise.all([
presence.updateButton(customButton),
presence.updateUser({
username,
id: $currentUser.id.toFixed(),
@@ -720,6 +781,74 @@
}, 450);
};
const setupThemeImport = async () => {
const initialUrls = await invoke<string[]>('opened_urls');
if (initialUrls.length > 0) {
const firstFile = initialUrls[0];
if (!firstFile.endsWith('.ezpplauncher-theme')) {
sileo.error({
title: 'Uhh...',
description: 'Dropped file is not a valid theme file.',
fill: '#181825',
styles: {
description: 'text-center!',
},
});
return;
}
const importResult = await checkThemeFromFile(firstFile);
if (!importResult.success || !importResult.themeInfo) {
sileo.error({
title: 'Hmmm...',
description: importResult.error || 'An unknown error occurred.',
fill: '#181825',
styles: {
description: 'text-center!',
},
});
return;
}
droppedTheme = {
filePath: firstFile,
themeInfo: importResult.themeInfo,
};
}
await listen<string[]>('opened', async (event) => {
const files = event.payload;
if (files.length > 0) {
const firstFile = files[0];
if (!firstFile.endsWith('.ezpplauncher-theme')) {
sileo.error({
title: 'Uhh...',
description: 'Dropped file is not a valid theme file.',
fill: '#181825',
styles: {
description: 'text-center!',
},
});
return;
}
const importResult = await checkThemeFromFile(firstFile);
if (!importResult.success || !importResult.themeInfo) {
sileo.error({
title: 'Hmmm...',
description: importResult.error || 'An unknown error occurred.',
fill: '#181825',
styles: {
description: 'text-center!',
},
});
return;
}
droppedTheme = {
filePath: firstFile,
themeInfo: importResult.themeInfo,
};
}
});
};
onMount(() => {
const config = $userSettings;
const trackingConsent = config.value('tracking_consent');
@@ -736,6 +865,8 @@
ease: (t: number) => (t - 1) ** 7 + 1,
onComplete: doBPMAnimation,
});
setupThemeImport();
return () => {
window.clearInterval(animateInterval);
};
@@ -902,7 +1033,127 @@
</AlertDialog.Content>
</AlertDialog.Root>
<div class="grid grid-cols-[0.085fr_1fr] h-[100vh] relative">
<AlertDialog.Root open={$newVersion === undefined && !!droppedTheme}>
<AlertDialog.Content
class="bg-theme-950 border-theme-800 p-0 max-w-2xl"
escapeKeydownBehavior="ignore"
interactOutsideBehavior="ignore"
>
<div
class="flex flex-col items-center justify-center border-b border-theme-800 bg-black/40 rounded-t-lg p-3"
>
<Import size={40} />
<span class="font-semibold text-xl">Import Theme</span>
</div>
<div
class="flex flex-col items-center text-sm text-center bg-theme-900 border border-theme-800 rounded-lg mx-3 p-3"
>
<p class="mb-4">
You are about to import the theme <strong>"{droppedTheme?.themeInfo.name}"</strong> by
<strong>{droppedTheme?.themeInfo.author}</strong>.
</p>
<div
class="mb-4 text-red-400 bg-red-800/20 border border-red-700/20 rounded-lg p-2 flex flex-col items-center justify-center"
>
<div class="text-base font-semibold">Warning</div>
<div>Themes from external sources may contain malicious code or assets.</div>
<div>Only import themes from creators you trust.</div>
</div>
<p>Are you sure you want to proceed with the installation?</p>
</div>
<div class="flex items-center justify-center mb-3 gap-4 mt-4">
<Button
class="min-w-28"
onclick={async () => {
if (!droppedTheme) {
sileo.error({
title: 'Hmmm...',
description: 'Failed to install theme.',
fill: '#181825',
styles: {
description: 'text-center!',
},
});
return;
}
themeInstalling = true;
try {
const importResult = await importThemeFromFile(
droppedTheme.themeInfo.name,
droppedTheme.filePath
);
if (!importResult.success) {
sileo.error({
title: 'Hmmm...',
description:
importResult.error || 'An unknown error occurred while importing your theme.',
fill: '#181825',
styles: {
description: 'text-center!',
},
});
return;
}
sileo.success({
title: 'Yay!',
description: 'Theme imported successfully',
fill: '#181825',
styles: {
description: 'text-center!',
},
});
} catch {
sileo.error({
title: 'Hmmm...',
description: 'An unknown error occurred while importing your theme.',
fill: '#181825',
styles: {
description: 'text-center!',
},
});
} finally {
droppedTheme = undefined;
themeInstalling = false;
}
}}
disabled={themeInstalling}
>
{#if themeInstalling}
<LoaderCircle class="animate-spin" size={18} />
{:else}
Yes, import
{/if}
</Button>
<Button
variant="outline"
onclick={async () => {
themeInstalling = false;
droppedTheme = undefined;
}}
disabled={themeInstalling}
>
No, don't import
</Button>
</div>
</AlertDialog.Content>
</AlertDialog.Root>
<div class="grid grid-cols-[0.085fr_1fr] h-[100vh] relative" bind:this={dragAndDrop.ref}>
{#if dragAndDrop.isDraggingOverApp}
<div
class="fixed top-0 left-0 w-full h-full z-50 flex items-center justify-center bg-black/10 backdrop-blur-sm"
transition:fade={{ duration: 300 }}
>
<div
class="w-[90vw] h-[90vh] flex flex-col items-center justify-center bg-theme-900 border border-theme-800 rounded-lg"
transition:scale={{ start: 0.7, duration: 300 }}
>
<Import size={64} />
<p>Drag and Drop a .ezpplauncher-theme file here to import</p>
</div>
</div>
{/if}
<div
class="p-3 border-r border-r-theme-900 flex flex-col items-center gap-2 z-10 bg-black/40 backdrop-blur-sm"
>
+8 -8
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import Logo from '$assets/logo.png';
import { estimateRefreshRate } from '@/displayUtils';
import { estimateFrameRate } from '@/displayUtils';
import {
beatmapSets,
currentLoadingInfo,
@@ -67,15 +67,15 @@
};
const calculateCursorSmoothness = async () => {
const refreshRate = await estimateRefreshRate();
const refreshRate = await estimateFrameRate();
const hzMin = 60;
const hzMax = 144;
const durationMin = 70;
const durationMax = 180;
const hzMax = 500;
const durationMin = 350;
const durationMax = 600;
const clamped = Math.min(Math.max(refreshRate, hzMin), hzMax);
const duration =
durationMin + ((refreshRate - hzMin) / (hzMax - hzMin)) * (durationMax - durationMin);
durationMin + ((clamped - hzMin) / (hzMax - hzMin)) * (durationMax - durationMin);
console.log(Math.round(duration));
cursorSmoothness.set(Math.round(duration));
};
+2 -1
View File
@@ -40,6 +40,7 @@
getVersion,
isValidOsuFolder,
} from '@/osuUtil';
import { dev } from '$app/environment';
let selectedStep = $state(1);
const steps = ['Welcome', 'Locate your osu! Installation', 'Appearance Settings'];
@@ -371,7 +372,7 @@
else selectedStep = Math.min(selectedStep + 1, steps.length);
}}
disabled={selectedStep > steps.length ||
(selectedStep === 2 && osuInstallPath.length <= 0)}
(selectedStep === 2 && !dev && osuInstallPath.length <= 0)}
>{selectedStep >= steps.length ? 'Finish' : 'Next'}</Button
>
</div>
+2 -1
View File
@@ -9,7 +9,8 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
"moduleResolution": "bundler",
"types": ["@types/node"]
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files