Compare commits
12 Commits
3.2.0
...
40a32d7a60
| Author | SHA1 | Date | |
|---|---|---|---|
| 40a32d7a60 | |||
| 52c60acf37 | |||
| 2788cd6e8b | |||
| 2d1941db33 | |||
| 00958d5238 | |||
| b8a0b448c7 | |||
| 09487e945e | |||
| 2532ab0059 | |||
| 16fe91b3a5 | |||
| eca08f16c5 | |||
| e76d67f3a8 | |||
| 9811b36e58 |
Generated
+1
@@ -1010,6 +1010,7 @@ dependencies = [
|
|||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"url",
|
||||||
"widestring",
|
"widestring",
|
||||||
"winapi",
|
"winapi",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ once_cell = "1.21.4"
|
|||||||
futures-util = "0.3.32"
|
futures-util = "0.3.32"
|
||||||
byteorder = "1.5.0"
|
byteorder = "1.5.0"
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
|
url = "2"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
winreg = "0.56.0"
|
winreg = "0.56.0"
|
||||||
|
|||||||
+53
-15
@@ -12,6 +12,7 @@ use tokio::io::AsyncWriteExt;
|
|||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tokio::time::{Duration, sleep};
|
use tokio::time::{Duration, sleep};
|
||||||
|
|
||||||
|
use crate::OpenedUrls;
|
||||||
use crate::osudb::parse_osudb;
|
use crate::osudb::parse_osudb;
|
||||||
use crate::presence;
|
use crate::presence;
|
||||||
use crate::utils::{
|
use crate::utils::{
|
||||||
@@ -20,6 +21,11 @@ use crate::utils::{
|
|||||||
set_osu_config_vals, set_osu_user_config_vals,
|
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]
|
#[tauri::command]
|
||||||
pub fn get_launcher_version() -> String {
|
pub fn get_launcher_version() -> String {
|
||||||
env!("CARGO_PKG_VERSION").to_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_bytes = fs::read(osu_db_path).await.ok()?;
|
||||||
let osu_db = parse_osudb(osu_db_bytes).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)
|
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");
|
let skins_folder = path.join("Skins");
|
||||||
|
|
||||||
if !skins_folder.exists() {
|
if !skins_folder.exists() {
|
||||||
return None;
|
return Some(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
@@ -431,21 +442,48 @@ pub async fn run_osu(folder: String, patch: bool) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if patch {
|
#[cfg(windows)]
|
||||||
sleep(Duration::from_secs(3)).await;
|
{
|
||||||
|
if patch {
|
||||||
|
let patcher_exe_path = PathBuf::from(&folder)
|
||||||
|
.join("EZPPLauncher")
|
||||||
|
.join("patcher")
|
||||||
|
.join("osu!.patcher.exe");
|
||||||
|
|
||||||
let patcher_exe_path = PathBuf::from(&folder)
|
if patcher_exe_path.exists() {
|
||||||
.join("EZPPLauncher")
|
#[cfg(windows)]
|
||||||
.join("patcher")
|
{
|
||||||
.join("osu!.patcher.exe");
|
let mut sys = System::new_all();
|
||||||
|
|
||||||
if patcher_exe_path.exists() {
|
loop {
|
||||||
#[cfg(windows)]
|
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
|
||||||
{
|
|
||||||
let _ = Command::new(&patcher_exe_path)
|
let mut found = false;
|
||||||
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
|
|
||||||
.spawn()
|
for (_pid, process) in sys.processes() {
|
||||||
.map_err(|e| format!("Failed to run patcher: {e}"))?;
|
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}"))?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-4
@@ -1,21 +1,25 @@
|
|||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
// 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::Manager;
|
||||||
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
pub mod osudb;
|
||||||
pub mod presence;
|
pub mod presence;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
pub mod osudb;
|
|
||||||
use crate::commands::{
|
use crate::commands::{
|
||||||
check_for_corruption, download_ezpp_launcher_update, download_ezpp_launcher_update_files,
|
check_for_corruption, download_ezpp_launcher_update, download_ezpp_launcher_update_files,
|
||||||
encrypt_string, exit, find_osu_installation, get_beatmapsets_count,
|
encrypt_string, exit, find_osu_installation, get_beatmapsets_count,
|
||||||
get_ezpp_launcher_update_files, get_hwid, get_launcher_version, get_osu_release_stream,
|
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,
|
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,
|
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,
|
opened_urls, presence_connect, presence_disconnect, presence_is_connected,
|
||||||
presence_update_user, replace_ui_files, run_osu, run_osu_updater, set_osu_config_values,
|
presence_update_status, presence_update_user, replace_ui_files, run_osu, run_osu_updater,
|
||||||
set_osu_user_config_values, valid_osu_folder,
|
set_osu_config_values, set_osu_user_config_values, valid_osu_folder,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct OpenedUrls(Mutex<Vec<PathBuf>>);
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -33,7 +37,39 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let app = builder
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(url) = url::Url::parse(&maybe_file) {
|
||||||
|
if let Ok(path) = url.to_file_path() {
|
||||||
|
if path.extension().and_then(|str| str.to_str())
|
||||||
|
== Some("ezpplauncher-theme")
|
||||||
|
{
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let maybe_file_path = PathBuf::from(maybe_file);
|
||||||
|
if maybe_file_path.extension().and_then(|str| str.to_str()) == Some("ezpplauncher-theme") {
|
||||||
|
files.push(maybe_file_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.state::<OpenedUrls>().0.lock().unwrap().extend(files);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
opened_urls,
|
||||||
get_hwid,
|
get_hwid,
|
||||||
find_osu_installation,
|
find_osu_installation,
|
||||||
valid_osu_folder,
|
valid_osu_folder,
|
||||||
|
|||||||
+214
-146
@@ -3,13 +3,17 @@ use discord_rich_presence::{
|
|||||||
activity::{Activity, Assets, Button, Timestamps},
|
activity::{Activity, Assets, Button, Timestamps},
|
||||||
};
|
};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use std::future::Future;
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::Mutex as StdMutex;
|
use std::sync::Mutex as StdMutex;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{mpsc, oneshot};
|
||||||
use tokio::time::{Duration, interval};
|
use tokio::time::{Duration, interval};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PresenceButton {
|
||||||
|
pub label: String,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct PresenceData {
|
pub struct PresenceData {
|
||||||
pub state: String,
|
pub state: String,
|
||||||
@@ -18,6 +22,21 @@ pub struct PresenceData {
|
|||||||
pub large_image_text: String,
|
pub large_image_text: String,
|
||||||
pub small_image_key: Option<String>,
|
pub small_image_key: Option<String>,
|
||||||
pub small_image_text: 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)]
|
#[derive(Debug)]
|
||||||
@@ -26,14 +45,21 @@ enum PresenceCommand {
|
|||||||
Disconnect(oneshot::Sender<()>),
|
Disconnect(oneshot::Sender<()>),
|
||||||
UpdateData(PresenceData),
|
UpdateData(PresenceData),
|
||||||
IsConnected(oneshot::Sender<bool>),
|
IsConnected(oneshot::Sender<bool>),
|
||||||
Tick,
|
}
|
||||||
|
|
||||||
|
enum ReconnectResult {
|
||||||
|
Connected(DiscordIpcClient),
|
||||||
|
Failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PresenceActor {
|
struct PresenceActor {
|
||||||
receiver: mpsc::Receiver<PresenceCommand>,
|
receiver: mpsc::Receiver<PresenceCommand>,
|
||||||
|
reconnect_rx: mpsc::Receiver<ReconnectResult>,
|
||||||
|
reconnect_tx: mpsc::Sender<ReconnectResult>,
|
||||||
client: Option<DiscordIpcClient>,
|
client: Option<DiscordIpcClient>,
|
||||||
data: PresenceData,
|
data: PresenceData,
|
||||||
start_timestamp: i64,
|
start_timestamp: i64,
|
||||||
|
reconnecting: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PresenceActor {
|
impl PresenceActor {
|
||||||
@@ -43,175 +69,183 @@ impl PresenceActor {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.as_secs() as i64;
|
.as_secs() as i64;
|
||||||
|
|
||||||
let data = PresenceData {
|
let (reconnect_tx, reconnect_rx) = mpsc::channel(1);
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
PresenceActor {
|
Self {
|
||||||
receiver,
|
receiver,
|
||||||
|
reconnect_rx,
|
||||||
|
reconnect_tx,
|
||||||
client: None,
|
client: None,
|
||||||
data,
|
data: PresenceData::default(),
|
||||||
start_timestamp: start,
|
start_timestamp: start,
|
||||||
|
reconnecting: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run(&mut self) {
|
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 {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
Some(cmd) = self.receiver.recv() => {
|
Some(cmd) = self.receiver.recv() => {
|
||||||
match cmd {
|
match cmd {
|
||||||
PresenceCommand::Connect(responder) => self.handle_connect(responder).await,
|
PresenceCommand::Connect(resp) => self.cmd_connect(resp).await,
|
||||||
PresenceCommand::Disconnect(responder) => {
|
PresenceCommand::Disconnect(resp) => self.cmd_disconnect(resp).await,
|
||||||
self.handle_disconnect(responder).await;
|
PresenceCommand::UpdateData(data) => {
|
||||||
},
|
self.data = data;
|
||||||
PresenceCommand::UpdateData(new_data) => {
|
self.push_presence().await;
|
||||||
self.data = new_data;
|
|
||||||
},
|
|
||||||
PresenceCommand::IsConnected(responder) => {
|
|
||||||
let _ = responder.send(self.client.is_some());
|
|
||||||
}
|
}
|
||||||
PresenceCommand::Tick => {
|
PresenceCommand::IsConnected(resp) => {
|
||||||
if self.client.is_some() {
|
let _ = resp.send(self.client.is_some());
|
||||||
self.handle_update().await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = 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(
|
async fn cmd_connect(&mut self, resp: oneshot::Sender<bool>) {
|
||||||
&mut self,
|
if self.client.is_some() {
|
||||||
responder: oneshot::Sender<bool>,
|
let _ = resp.send(true);
|
||||||
) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
return;
|
||||||
Box::pin(async move {
|
}
|
||||||
if self.client.is_some() {
|
|
||||||
let _ = responder.send(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("Actor: Connecting to Discord...");
|
match try_connect_once() {
|
||||||
let mut dc_client = DiscordIpcClient::new("1032772293220384808");
|
Some(client) => {
|
||||||
match dc_client.connect() {
|
self.client = Some(client);
|
||||||
Ok(()) => {
|
self.reconnecting = false;
|
||||||
self.client = Some(dc_client);
|
self.push_presence().await;
|
||||||
println!("Actor: Connected successfully.");
|
let _ = resp.send(true);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
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() {
|
if let Some(mut client) = self.client.take() {
|
||||||
println!("Actor: Disconnecting from Discord...");
|
|
||||||
let _ = client.clear_activity();
|
let _ = client.clear_activity();
|
||||||
let _ = client.close();
|
let _ = client.close();
|
||||||
println!("Actor: Disconnected successfully.");
|
println!("Presence: disconnected.");
|
||||||
}
|
}
|
||||||
let _ = responder.send(());
|
let _ = resp.send(());
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_update(&mut self) {
|
async fn push_presence(&mut self) {
|
||||||
if let Some(client) = self.client.as_mut() {
|
let client = match self.client.as_mut() {
|
||||||
let mut assets = Assets::new()
|
Some(c) => c,
|
||||||
.large_image(&self.data.large_image_key)
|
None => return,
|
||||||
.large_text(&self.data.large_image_text);
|
};
|
||||||
|
|
||||||
if let Some(key) = &self.data.small_image_key {
|
let mut assets = Assets::new()
|
||||||
assets = assets.small_image(key);
|
.large_image(&self.data.large_image_key)
|
||||||
}
|
.large_text(&self.data.large_image_text);
|
||||||
if let Some(text) = &self.data.small_image_text {
|
|
||||||
assets = assets.small_text(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()
|
fn try_connect_once() -> Option<DiscordIpcClient> {
|
||||||
.state(&self.data.state)
|
let mut client = DiscordIpcClient::new("1032772293220384808");
|
||||||
.details(&self.data.details)
|
match client.connect() {
|
||||||
.timestamps(Timestamps::new().start(self.start_timestamp))
|
Ok(()) => {
|
||||||
.assets(assets)
|
println!("Presence: IPC connected.");
|
||||||
.buttons(vec![
|
Some(client)
|
||||||
Button::new(
|
}
|
||||||
"Download the Launcher",
|
Err(e) => {
|
||||||
"https://git.ez-pp.farm/EZPPFarm/EZPPLauncher/releases/latest",
|
eprintln!("Presence: IPC connect error: {:?}", e);
|
||||||
),
|
None
|
||||||
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.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static PRESENCE_TX: Lazy<mpsc::Sender<PresenceCommand>> = Lazy::new(|| {
|
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);
|
let mut actor = PresenceActor::new(rx);
|
||||||
tokio::spawn(async move { actor.run().await });
|
tokio::spawn(async move { actor.run().await });
|
||||||
tx
|
tx
|
||||||
});
|
});
|
||||||
|
|
||||||
pub static PRESENCE_DATA: Lazy<StdMutex<PresenceData>> = Lazy::new(|| {
|
pub static PRESENCE_DATA: Lazy<StdMutex<PresenceData>> =
|
||||||
StdMutex::new(PresenceData {
|
Lazy::new(|| StdMutex::new(PresenceData::default()));
|
||||||
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 async fn connect() -> bool {
|
pub async fn connect() -> bool {
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
@@ -223,8 +257,7 @@ pub async fn connect() -> bool {
|
|||||||
|
|
||||||
pub async fn disconnect() {
|
pub async fn disconnect() {
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
// Use a timeout to ensure we don't hang the exit process if Discord is unresponsive
|
if PRESENCE_TX.send(PresenceCommand::Disconnect(tx)).await.is_ok() {
|
||||||
if let Ok(_) = PRESENCE_TX.send(PresenceCommand::Disconnect(tx)).await {
|
|
||||||
let _ = tokio::time::timeout(Duration::from_secs(2), rx).await;
|
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>) {
|
pub fn update_status(state: Option<&str>, details: Option<&str>, large_image_key: Option<&str>) {
|
||||||
let mut data = PRESENCE_DATA.lock().unwrap();
|
let data = {
|
||||||
if let Some(s) = state {
|
let mut guard = PRESENCE_DATA.lock().unwrap();
|
||||||
data.state = s.to_string();
|
if let Some(s) = state {
|
||||||
}
|
guard.state = s.to_string();
|
||||||
if let Some(d) = details {
|
}
|
||||||
data.details = d.to_string();
|
if let Some(d) = details {
|
||||||
}
|
guard.details = d.to_string();
|
||||||
if let Some(img) = large_image_key {
|
}
|
||||||
data.large_image_key = img.to_string();
|
if let Some(img) = large_image_key {
|
||||||
}
|
guard.large_image_key = img.to_string();
|
||||||
let data_clone = data.clone();
|
}
|
||||||
|
guard.clone()
|
||||||
|
};
|
||||||
|
|
||||||
let tx = PRESENCE_TX.clone();
|
let tx = PRESENCE_TX.clone();
|
||||||
tokio::spawn(async move {
|
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>) {
|
pub fn update_user(username: Option<&str>, id: Option<&str>) {
|
||||||
let mut data = PRESENCE_DATA.lock().unwrap();
|
let data = {
|
||||||
data.small_image_key = id.map(|id_str| format!("https://a.ez-pp.farm/{}", id_str));
|
let mut guard = PRESENCE_DATA.lock().unwrap();
|
||||||
data.small_image_text = username.map(|s| s.to_string());
|
guard.small_image_key = id.map(|s| format!("https://a.ez-pp.farm/{}", s));
|
||||||
let data_clone = data.clone();
|
guard.small_image_text = username.map(str::to_string);
|
||||||
|
guard.clone()
|
||||||
|
};
|
||||||
|
|
||||||
let tx = PRESENCE_TX.clone();
|
let tx = PRESENCE_TX.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = tx.send(PresenceCommand::UpdateData(data_clone)).await;
|
let _ = tx.send(PresenceCommand::UpdateData(data)).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -42,6 +42,15 @@
|
|||||||
"nsis",
|
"nsis",
|
||||||
"appimage"
|
"appimage"
|
||||||
],
|
],
|
||||||
|
"fileAssociations": [
|
||||||
|
{
|
||||||
|
"ext": ["ezpplauncher-theme"],
|
||||||
|
"name": "ezppLauncher Theme",
|
||||||
|
"description": "ezppLauncher Theme File",
|
||||||
|
"role": "Viewer",
|
||||||
|
"mimeType": "application/x-ezpplauncher-theme"
|
||||||
|
}
|
||||||
|
],
|
||||||
"windows": {
|
"windows": {
|
||||||
"webviewInstallMode": {
|
"webviewInstallMode": {
|
||||||
"silent": true,
|
"silent": true,
|
||||||
|
|||||||
@@ -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
@@ -17,20 +17,14 @@ export function normalizeOsuName(name: string): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getHWID = async () => {
|
export const getHWID = async (): Promise<string | undefined> =>
|
||||||
const hwid = await invoke('get_hwid');
|
await invoke<string | undefined>('get_hwid');
|
||||||
return typeof hwid === 'string' ? hwid : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isValidOsuFolder = async (folder: string): Promise<boolean> => {
|
export const isValidOsuFolder = async (folder: string): Promise<boolean | undefined> =>
|
||||||
const result = await invoke('valid_osu_folder', { folder });
|
await invoke<boolean | undefined>('valid_osu_folder', { folder });
|
||||||
return typeof result === 'boolean' ? result : false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const autoDetectOsuInstallFolder = async () => {
|
export const autoDetectOsuInstallFolder = async () =>
|
||||||
const result = await invoke('find_osu_installation');
|
await invoke<string | undefined>('find_osu_installation');
|
||||||
return typeof result === 'string' ? result : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setUserConfigValues = async (
|
export const setUserConfigValues = async (
|
||||||
osuFolderPath: string,
|
osuFolderPath: string,
|
||||||
@@ -50,29 +44,23 @@ export const setConfigValues = async (
|
|||||||
entries,
|
entries,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getReleaseStream = async (folder: string) => {
|
export const getReleaseStream = async (folder: string): Promise<string | undefined> =>
|
||||||
const result = await invoke('get_osu_release_stream', { folder });
|
await invoke<string | undefined>('get_osu_release_stream', { folder });
|
||||||
return typeof result === 'string' ? result : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getVersion = async (folder: string) => {
|
export const getVersion = async (folder: string): Promise<string | undefined> =>
|
||||||
const result = await invoke('get_osu_version', { folder });
|
await invoke<string | undefined>('get_osu_version', { folder });
|
||||||
return typeof result === 'string' ? result : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBeatmapSetsCount = async (folder: string) => {
|
export const getBeatmapSetsCount = async (folder: string): Promise<number | undefined> =>
|
||||||
const result = await invoke('get_beatmapsets_count', {
|
await invoke<number | undefined>('get_beatmapsets_count', {
|
||||||
folder,
|
folder,
|
||||||
});
|
});
|
||||||
return typeof result === 'number' ? result : 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSkin = async (folder: string) => {
|
export const getSkin = async (folder: string) => {
|
||||||
const result = await invoke('get_osu_skin', {
|
const result = await invoke<string | undefined>('get_osu_skin', {
|
||||||
folder,
|
folder,
|
||||||
});
|
});
|
||||||
|
|
||||||
return typeof result === 'string' ? result : 'Default';
|
return result || 'Default';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const runUpdater = async (folder: string) => await invoke('run_osu_updater', { folder });
|
export const runUpdater = async (folder: string) => await invoke('run_osu_updater', { folder });
|
||||||
@@ -113,8 +101,8 @@ export const downloadEZPPLauncherUpdateFiles = async (
|
|||||||
allFiles: UpdateFile[],
|
allFiles: UpdateFile[],
|
||||||
progressCallback: (file: UpdateStatus) => void
|
progressCallback: (file: UpdateStatus) => void
|
||||||
) => {
|
) => {
|
||||||
const downloadStatusListen = await listen('download-progress', (event) =>
|
const downloadStatusListen = await listen<UpdateStatus>('download-progress', (event) =>
|
||||||
progressCallback(event.payload as UpdateStatus)
|
progressCallback(event.payload)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await invoke('download_ezpp_launcher_update_files', { folder, updateFiles, allFiles });
|
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 });
|
await invoke('replace_ui_files', { folder, revert });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isOsuRunning = async () => {
|
export const isOsuRunning = async (): Promise<boolean> => await invoke<boolean>('is_osu_running');
|
||||||
const result = await invoke('is_osu_running');
|
|
||||||
return typeof result === 'boolean' ? result : false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getLauncherVersion = async () => await invoke<string>('get_launcher_version');
|
export const getLauncherVersion = async () => await invoke<string>('get_launcher_version');
|
||||||
export const exit = async () => await invoke('exit');
|
export const exit = async () => await invoke('exit');
|
||||||
export const getPlatform = async () => await invoke<string>('get_platform');
|
export const getPlatform = async (): Promise<string> => await invoke<string>('get_platform');
|
||||||
export const isOsuCorrupted = async (folder: string) =>
|
export const isOsuCorrupted = async (folder: string): Promise<boolean> =>
|
||||||
await invoke<boolean>('check_for_corruption', { folder });
|
await invoke<boolean>('check_for_corruption', { folder });
|
||||||
export const hasWMCTRL = async () => await invoke<boolean>('has_wmctrl');
|
export const hasWMCTRL = async (): Promise<boolean> => await invoke<boolean>('has_wmctrl');
|
||||||
export const hasOsuWinello = async () => await invoke<boolean>('has_osuwinello');
|
export const hasOsuWinello = async (): Promise<boolean> => await invoke<boolean>('has_osuwinello');
|
||||||
export const hasNet8 = async () => await invoke<boolean>('has_net8');
|
export const hasNet8 = async (): Promise<boolean> => await invoke<boolean>('has_net8');
|
||||||
export const encryptString = async (str: string, entropy: string) =>
|
export const encryptString = async (str: string, entropy: string): Promise<string> =>
|
||||||
await invoke<string>('encrypt_string', { string: str, entropy });
|
await invoke<string>('encrypt_string', { string: str, entropy });
|
||||||
export const downloadUpdate = async (
|
export const downloadUpdate = async (
|
||||||
url: string,
|
url: string,
|
||||||
|
|||||||
+135
-18
@@ -20,6 +20,16 @@ export type Theme = {
|
|||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ThemeInfo = {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
apiVersion: string;
|
||||||
|
author: string;
|
||||||
|
entry: string;
|
||||||
|
style: string;
|
||||||
|
preview: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const getDownloadableThemes = async () => {
|
export const getDownloadableThemes = async () => {
|
||||||
const themes: Theme[] = [];
|
const themes: Theme[] = [];
|
||||||
|
|
||||||
@@ -82,15 +92,7 @@ export const getThemes = async (): Promise<Theme[]> => {
|
|||||||
const themeConfigFilePath = await path.join(folderPath, themeFolder.name, 'theme.json');
|
const themeConfigFilePath = await path.join(folderPath, themeFolder.name, 'theme.json');
|
||||||
if (await fs.exists(themeConfigFilePath)) {
|
if (await fs.exists(themeConfigFilePath)) {
|
||||||
const themeConfig = await fs.readTextFile(themeConfigFilePath);
|
const themeConfig = await fs.readTextFile(themeConfigFilePath);
|
||||||
const theme = JSON.parse(themeConfig) as {
|
const theme = JSON.parse(themeConfig) as ThemeInfo;
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
apiVersion: string;
|
|
||||||
author: string;
|
|
||||||
entry: string;
|
|
||||||
style: string;
|
|
||||||
preview: string;
|
|
||||||
};
|
|
||||||
const scriptFile = await path.join(folderPath, themeFolder.name, theme.entry);
|
const scriptFile = await path.join(folderPath, themeFolder.name, theme.entry);
|
||||||
if (await fs.exists(scriptFile)) {
|
if (await fs.exists(scriptFile)) {
|
||||||
themes.push({
|
themes.push({
|
||||||
@@ -272,15 +274,7 @@ export const downloadTheme = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const themeInfo = await fs.readTextFile(await path.join(themeFolder, 'theme.json'));
|
const themeInfo = await fs.readTextFile(await path.join(themeFolder, 'theme.json'));
|
||||||
const themeInfoObj = JSON.parse(themeInfo) as {
|
const themeInfoObj = JSON.parse(themeInfo) as ThemeInfo;
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
apiVersion: string;
|
|
||||||
author: string;
|
|
||||||
entry: string;
|
|
||||||
style: string;
|
|
||||||
preview: string;
|
|
||||||
};
|
|
||||||
const themeScriptUrl = convertFileSrc(
|
const themeScriptUrl = convertFileSrc(
|
||||||
await path.normalize(`${themeFolder}/${themeInfoObj.entry}`)
|
await path.normalize(`${themeFolder}/${themeInfoObj.entry}`)
|
||||||
);
|
);
|
||||||
@@ -390,6 +384,129 @@ export const deleteTheme = async (themeToUninstall: Theme) => {
|
|||||||
return true;
|
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 (!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 installedThemes of reloadedThemes) {
|
||||||
|
if (installedThemes.name === themeName) {
|
||||||
|
installedThemes.name = themeInfoObj.name;
|
||||||
|
installedThemes.author = themeInfoObj.author;
|
||||||
|
installedThemes.version = themeInfoObj.version;
|
||||||
|
installedThemes.scriptUrl = themeScriptUrl;
|
||||||
|
installedThemes.assets = themeAssets;
|
||||||
|
installedThemes.preview = themePreview;
|
||||||
|
installedThemes.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) => {
|
export const toSafeName = (name: string) => {
|
||||||
return (
|
return (
|
||||||
name
|
name
|
||||||
|
|||||||
+246
-4
@@ -46,6 +46,7 @@
|
|||||||
Paintbrush,
|
Paintbrush,
|
||||||
Trash,
|
Trash,
|
||||||
CloudDownload,
|
CloudDownload,
|
||||||
|
Import,
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import NumberFlow from '@number-flow/svelte';
|
import NumberFlow from '@number-flow/svelte';
|
||||||
import * as AlertDialog from '@/components/ui/alert-dialog';
|
import * as AlertDialog from '@/components/ui/alert-dialog';
|
||||||
@@ -62,7 +63,7 @@
|
|||||||
setCurrentTimeGlobalMedia,
|
setCurrentTimeGlobalMedia,
|
||||||
urlIsValidImage,
|
urlIsValidImage,
|
||||||
} from '@/utils';
|
} from '@/utils';
|
||||||
import { fade, fly } from 'svelte/transition';
|
import { fade, fly, scale } from 'svelte/transition';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import Label from '@/components/ui/label/label.svelte';
|
import Label from '@/components/ui/label/label.svelte';
|
||||||
import {
|
import {
|
||||||
@@ -122,7 +123,17 @@
|
|||||||
import { animate } from 'animejs';
|
import { animate } from 'animejs';
|
||||||
import { sileo } from 'sileo';
|
import { sileo } from 'sileo';
|
||||||
import ScrollContainer from '@/components/ui/scroll-container/ScrollContainer.svelte';
|
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 selectedView = $state('home');
|
||||||
let progress = $state(-1);
|
let progress = $state(-1);
|
||||||
@@ -134,6 +145,47 @@
|
|||||||
|
|
||||||
let downloadingUpdate = $state(false);
|
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 downloadingEZPPFiles = $state(false);
|
||||||
let cleanup = $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);
|
if (beatmapSetCount) beatmapSets.set(beatmapSetCount);
|
||||||
|
|
||||||
const skins_list = await getSkins(selectedPath);
|
const skins_list = await getSkins(selectedPath);
|
||||||
@@ -720,6 +772,74 @@
|
|||||||
}, 450);
|
}, 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 = 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const config = $userSettings;
|
const config = $userSettings;
|
||||||
const trackingConsent = config.value('tracking_consent');
|
const trackingConsent = config.value('tracking_consent');
|
||||||
@@ -736,6 +856,8 @@
|
|||||||
ease: (t: number) => (t - 1) ** 7 + 1,
|
ease: (t: number) => (t - 1) ** 7 + 1,
|
||||||
onComplete: doBPMAnimation,
|
onComplete: doBPMAnimation,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setupThemeImport();
|
||||||
return () => {
|
return () => {
|
||||||
window.clearInterval(animateInterval);
|
window.clearInterval(animateInterval);
|
||||||
};
|
};
|
||||||
@@ -902,7 +1024,127 @@
|
|||||||
</AlertDialog.Content>
|
</AlertDialog.Content>
|
||||||
</AlertDialog.Root>
|
</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
|
<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"
|
class="p-3 border-r border-r-theme-900 flex flex-col items-center gap-2 z-10 bg-black/40 backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
getVersion,
|
getVersion,
|
||||||
isValidOsuFolder,
|
isValidOsuFolder,
|
||||||
} from '@/osuUtil';
|
} from '@/osuUtil';
|
||||||
|
import { dev } from '$app/environment';
|
||||||
|
|
||||||
let selectedStep = $state(1);
|
let selectedStep = $state(1);
|
||||||
const steps = ['Welcome', 'Locate your osu! Installation', 'Appearance Settings'];
|
const steps = ['Welcome', 'Locate your osu! Installation', 'Appearance Settings'];
|
||||||
@@ -371,7 +372,7 @@
|
|||||||
else selectedStep = Math.min(selectedStep + 1, steps.length);
|
else selectedStep = Math.min(selectedStep + 1, steps.length);
|
||||||
}}
|
}}
|
||||||
disabled={selectedStep > steps.length ||
|
disabled={selectedStep > steps.length ||
|
||||||
(selectedStep === 2 && osuInstallPath.length <= 0)}
|
(selectedStep === 2 && !dev && osuInstallPath.length <= 0)}
|
||||||
>{selectedStep >= steps.length ? 'Finish' : 'Next'}</Button
|
>{selectedStep >= steps.length ? 'Finish' : 'Next'}</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user