diff --git a/public/images/steam.png b/public/images/steam.png new file mode 100644 index 0000000..b163937 Binary files /dev/null and b/public/images/steam.png differ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 690349d..6f87ea8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "ashpd" version = "0.11.1" @@ -363,6 +369,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -987,6 +999,7 @@ dependencies = [ "rfd", "serde", "serde_json", + "steam_shortcuts_util", "tauri", "tauri-build", "tauri-plugin-drpc", @@ -2505,6 +2518,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2628,6 +2647,27 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom_locate" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3" +dependencies = [ + "bytecount", + "memchr", + "nom", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -4202,6 +4242,18 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "steam_shortcuts_util" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0543ebdb23a93b196aceebc53f70cc5a573bb74248a974b3f5fa3883e6a89b6" +dependencies = [ + "ascii", + "crc32fast", + "nom", + "nom_locate", +] + [[package]] name = "string_cache" version = "0.8.9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6787be8..f1a629d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,3 +28,4 @@ rfd = "0.15" libc = "0.2" image = "0.24" percent-encoding = "2.3" +steam_shortcuts_util = "1.1.8" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 13dd81e..6045399 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ use std::fs; use std::io::Write; use std::path::PathBuf; use std::process::Command; +use steam_shortcuts_util::{Shortcut, parse_shortcuts, shortcuts_to_bytes}; #[cfg(target_os = "macos")] use std::process::Stdio; @@ -1680,6 +1681,124 @@ fn delete_screenshot(path: String) -> Result<(), String> { fs::remove_file(path).map_err(|e| e.to_string()) } +#[tauri::command] +async fn add_to_steam( + app: AppHandle, + instance_id: String, + name: String, + title_image: String, + panorama_image: String, +) -> Result<(), String> { + let exe_path = std::env::current_exe().map_err(|e| e.to_string())?; + let exe_str = exe_path.to_string_lossy().to_string(); + let launch_options = format!("\"{}\"", instance_id); + let start_dir = exe_path.parent().map(|p| p.to_string_lossy().to_string()).unwrap_or_default(); + let app_id_32 = steam_shortcuts_util::app_id_generator::calculate_app_id(&exe_str, &name); + let app_id_64 = ((app_id_32 as u64) << 32) | 0x02000000; + let mut userdata_dirs = Vec::new(); + #[cfg(target_os = "linux")] + { + if let Ok(home) = std::env::var("HOME") { + let h = PathBuf::from(home); + userdata_dirs.push(h.join(".steam/steam/userdata")); + userdata_dirs.push(h.join(".local/share/Steam/userdata")); + userdata_dirs.push(h.join(".var/app/com.valvesoftware.Steam/.local/share/Steam/userdata")); + } + } + #[cfg(target_os = "windows")] + { + userdata_dirs.push(PathBuf::from("C:\\Program Files (x86)\\Steam\\userdata")); + } + + let valid_userdata_dirs: Vec = userdata_dirs.into_iter().filter(|d| d.exists()).collect(); + if valid_userdata_dirs.is_empty() { + return Err("Steam userdata directory not found.".into()); + } + + for userdata_root in valid_userdata_dirs { + let entries = fs::read_dir(&userdata_root).map_err(|e| e.to_string())?; + for entry in entries.flatten() { + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + let user_config_dir = entry.path().join("config"); + let shortcuts_path = user_config_dir.join("shortcuts.vdf"); + if !user_config_dir.exists() { continue; } + + let content = if shortcuts_path.exists() { + fs::read(&shortcuts_path).unwrap_or_default() + } else { + Vec::new() + }; + + let shortcuts = if !content.is_empty() { + parse_shortcuts(&content).map_err(|e| e.to_string())? + } else { + Vec::new() + }; + + let mut owned_shortcuts: Vec = shortcuts.iter().map(|s| s.to_owned()).collect(); + + if owned_shortcuts.iter().any(|s| s.app_name == name && s.exe == exe_str) { + continue; + } + + owned_shortcuts.push(steam_shortcuts_util::shortcut::ShortcutOwned { + order: owned_shortcuts.len().to_string(), + app_id: app_id_32, + app_name: name.clone(), + exe: exe_str.clone(), + start_dir: start_dir.clone(), + icon: "".to_string(), + shortcut_path: "".to_string(), + launch_options: launch_options.clone(), + is_hidden: false, + allow_desktop_config: true, + allow_overlay: true, + open_vr: 0, + dev_kit: 0, + dev_kit_game_id: "".to_string(), + dev_kit_overrite_app_id: 0, + last_play_time: 0, + tags: Vec::new(), + }); + + let final_shortcuts: Vec = owned_shortcuts.iter().map(|s| s.borrow()).collect(); + let new_content = shortcuts_to_bytes(&final_shortcuts); + fs::write(&shortcuts_path, new_content).map_err(|e| e.to_string())?; + let grid_dir = user_config_dir.join("grid"); + if !grid_dir.exists() { + let _ = fs::create_dir_all(&grid_dir); + } + + if grid_dir.exists() { + let resource_dir = app.path().resource_dir().unwrap_or_default(); + let resolve_image_path = |p: &str| { + if p.starts_with('/') { + resource_dir.join(&p[1..]) + } else { + PathBuf::from(p) + } + }; + + let title_path = resolve_image_path(&title_image); + let panorama_path = resolve_image_path(&panorama_image); + if let Ok(img_data) = fs::read(&title_path) { + let _ = fs::write(grid_dir.join(format!("{}p.png", app_id_64)), &img_data); + } + if let Ok(img_data) = fs::read(&panorama_path) { + let _ = fs::write(grid_dir.join(format!("{}_hero.png", app_id_64)), &img_data); + let _ = fs::write(grid_dir.join(format!("{}.png", app_id_64)), &img_data); + } + if let Ok(img_data) = fs::read(&title_path) { + let _ = fs::write(grid_dir.join(format!("{}_logo.png", app_id_64)), &img_data); + } + } + } + } + } + + Ok(()) +} + #[tauri::command] fn open_screenshot_folder(app: AppHandle, path: String) { let p = std::path::Path::new(&path); @@ -1708,7 +1827,7 @@ pub fn run() { .register_uri_scheme_protocol("screenshots", |_app, request| { let uri = request.uri().path(); let decoded_path = percent_encoding::percent_decode_str(uri).decode_utf8_lossy(); - let mut path_str = decoded_path.to_string(); + let path_str = decoded_path.to_string(); #[cfg(target_os = "windows")] if path_str.starts_with('/') { path_str = path_str[1..].to_string(); @@ -1731,7 +1850,28 @@ pub fn run() { } } }) - .invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, pick_folder, download_runner, delete_instance, sync_dlc, fetch_skin, workshop_install, workshop_uninstall, workshop_list_installed, get_screenshots, delete_screenshot, open_screenshot_folder, save_global_skin_pck, check_game_update, check_macos_runtime_installed, check_macos_runtime_installed_fast, download_logo, pick_file, save_file_dialog, write_binary_file, read_binary_file]) + .invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, pick_folder, download_runner, delete_instance, sync_dlc, fetch_skin, workshop_install, workshop_uninstall, workshop_list_installed, get_screenshots, delete_screenshot, open_screenshot_folder, save_global_skin_pck, check_game_update, check_macos_runtime_installed, check_macos_runtime_installed_fast, download_logo, pick_file, save_file_dialog, write_binary_file, read_binary_file, add_to_steam]) + .setup(|app| { + let args: Vec = std::env::args().collect(); + if args.len() > 1 && !args[1].starts_with('-') { + let instance_id = args[1].clone(); + let app_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.hide(); + } + let state = app_handle.state::(); + match launch_game(app_handle.clone(), state, instance_id, Vec::new()).await { + Ok(_) => { app_handle.exit(0); } + Err(e) => { + eprintln!("Auto-launch error: {}", e); + app_handle.exit(1); + } + } + }); + } + Ok(()) + }) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 79abeb6..753f13a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -11,6 +11,7 @@ fn main() { let stage = env::var("EMERALD_LAUNCH_STAGE").unwrap_or_else(|_| "0".to_string()); if stage == "0" { let mut cmd = Command::new(env::current_exe().unwrap()); + cmd.args(env::args().skip(1)); cmd.env("EMERALD_LAUNCH_STAGE", "1"); let wayland_libs = ["/usr/lib64/libwayland-client.so.0", "/usr/lib/libwayland-client.so.0"]; @@ -76,6 +77,7 @@ fn main() { println!("Emerald: Automatic recovery triggered for graphics crash/invisible launch."); } let mut retry_cmd = Command::new(env::current_exe().unwrap()); + retry_cmd.args(env::args().skip(1)); retry_cmd.env("EMERALD_LAUNCH_STAGE", "2") .env("GDK_BACKEND", "x11") .env("WEBKIT_DISABLE_DMABUF_RENDERER", "1") diff --git a/src/components/common/AchievementToast.tsx b/src/components/common/AchievementToast.tsx index a6427dc..ccaae5c 100644 --- a/src/components/common/AchievementToast.tsx +++ b/src/components/common/AchievementToast.tsx @@ -6,7 +6,7 @@ interface AchievementToastProps { onClose: () => void; onClick?: () => void; title?: string; - variant?: "error" | "update"; + variant?: "error" | "update" | "steam"; } export function AchievementToast({ @@ -43,6 +43,11 @@ export function AchievementToast({ ); } + if (variant === "steam") { + return ( + Steam + ); + } return ( { - onClick(); - onClose(); - } + onClick(); + onClose(); + } : undefined } className={`fixed top-6 right-6 z-[9999] ${onClick ? "cursor-pointer" : ""}`} diff --git a/src/components/views/VersionsView.tsx b/src/components/views/VersionsView.tsx index bee195f..f5a0b1e 100644 --- a/src/components/views/VersionsView.tsx +++ b/src/components/views/VersionsView.tsx @@ -26,9 +26,8 @@ const DeleteConfirmButton = memo(function DeleteConfirmButton({ onClick={onClick} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} - className={`w-24 h-10 flex items-center justify-center mc-text-shadow transition-colors ${ - isDanger ? "text-red-500" : "text-white" - } ${isHovered ? (isDanger ? "text-red-400" : "text-[#FFFF55]") : ""}`} + className={`w-24 h-10 flex items-center justify-center mc-text-shadow transition-colors ${isDanger ? "text-red-500" : "text-white" + } ${isHovered ? (isDanger ? "text-red-400" : "text-[#FFFF55]") : ""}`} style={{ backgroundImage: isHovered ? "url('/images/button_highlighted.png')" @@ -62,7 +61,9 @@ const VersionsView = memo(function VersionsView() { downloadingId, downloadProgress, updatesAvailable, + addToSteam, } = useGame(); + const { isDayTime } = useConfig(); const [focusIndex, setFocusIndex] = useState(0); const [focusBtn, setFocusBtn] = useState(0); const [isImportModalOpen, setIsImportModalOpen] = useState(false); @@ -100,7 +101,7 @@ const VersionsView = memo(function VersionsView() { const edition = editions[focusIndex]; const isInstalled = installedVersions.includes(edition.id); const isCustom = edition.id.startsWith("custom_"); - const maxBtn = isInstalled ? (isCustom ? 4 : 3) : 1; + const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1; setFocusBtn((prev) => (prev <= 0 ? maxBtn : prev - 1)); } } else if (e.key === "ArrowRight") { @@ -109,7 +110,7 @@ const VersionsView = memo(function VersionsView() { const edition = editions[focusIndex]; const isInstalled = installedVersions.includes(edition.id); const isCustom = edition.id.startsWith("custom_"); - const maxBtn = isInstalled ? (isCustom ? 4 : 3) : 1; + const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1; setFocusBtn((prev) => (prev >= maxBtn ? 0 : prev + 1)); } } else if (e.key === "Enter") { @@ -142,10 +143,26 @@ const VersionsView = memo(function VersionsView() { playPressSound(); setDeleteConfirmEdition(edition); } - } else if (focusBtn === 4 && isCustom) { + } else if (focusBtn === 4) { + playPressSound(); + const PANORAMA_PROFILES = ["legacy_evolved", "360revived"]; + const panoId = PANORAMA_PROFILES.includes(edition.id) + ? edition.id + : "legacy_evolved"; + const panoramaUrl = `/panorama/${panoId}_Panorama_Background_${isDayTime ? "Day" : "Night"}.png`; + addToSteam( + edition.id, + edition.name, + edition.titleImage, + panoramaUrl, + ); + } else if (focusBtn === 5 && isCustom) { playPressSound(); setEditingEdition(edition); setIsImportModalOpen(true); + } else if (focusBtn === 6 && isCustom) { + playBackSound(); + onDeleteEdition(edition.id); } } else { if (focusBtn === 1) { @@ -185,6 +202,8 @@ const VersionsView = memo(function VersionsView() { setActiveView, toggleInstall, handleCancelDownload, + addToSteam, + isDayTime ]); useEffect(() => { @@ -261,11 +280,9 @@ const VersionsView = memo(function VersionsView() {
!isComingSoon && setFocusIndex(i)} >
@@ -302,18 +319,17 @@ const VersionsView = memo(function VersionsView() { !isComingSoon && handleEditionClick(edition, i) } disabled={isComingSoon} - className={`flex-1 text-left min-w-0 outline-none rounded ${ - focusIndex === i && focusBtn === 0 && !isComingSoon - ? "ring-2 ring-white" - : "" - } ${isComingSoon ? "cursor-not-allowed" : ""}`} + className={`flex-1 text-left min-w-0 outline-none rounded ${focusIndex === i && focusBtn === 0 && !isComingSoon + ? "ring-2 ring-white" + : "" + } ${isComingSoon ? "cursor-not-allowed" : ""}`} >
{edition.logo && ( )} {edition.name} @@ -346,9 +361,8 @@ const VersionsView = memo(function VersionsView() { )}

{edition.desc}

@@ -371,7 +385,7 @@ const VersionsView = memo(function VersionsView() { backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === "cancel") || - (focusIndex === i && focusBtn === 1) + (focusIndex === i && focusBtn === 1) ? "url('/images/Button_Square_Highlighted.png')" : "url('/images/Button_Square.png')", backgroundSize: "100% 100%", @@ -407,7 +421,7 @@ const VersionsView = memo(function VersionsView() { backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === "download") || - (focusIndex === i && focusBtn === 1) + (focusIndex === i && focusBtn === 1) ? "url('/images/Button_Square_Highlighted.png')" : "url('/images/Button_Square.png')", backgroundSize: "100% 100%", @@ -441,7 +455,7 @@ const VersionsView = memo(function VersionsView() { backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === "cancel") || - (focusIndex === i && focusBtn === 1) + (focusIndex === i && focusBtn === 1) ? "url('/images/Button_Square_Highlighted.png')" : "url('/images/Button_Square.png')", backgroundSize: "100% 100%", @@ -476,7 +490,7 @@ const VersionsView = memo(function VersionsView() { backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === "update") || - (focusIndex === i && focusBtn === 1) + (focusIndex === i && focusBtn === 1) ? "url('/images/Button_Square_Highlighted.png')" : "url('/images/Button_Square.png')", backgroundSize: "100% 100%", @@ -510,7 +524,7 @@ const VersionsView = memo(function VersionsView() { backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === "folder") || - (focusIndex === i && focusBtn === 2) + (focusIndex === i && focusBtn === 2) ? "url('/images/Button_Square_Highlighted.png')" : "url('/images/Button_Square.png')", backgroundSize: "100% 100%", @@ -539,7 +553,7 @@ const VersionsView = memo(function VersionsView() { backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === "delete") || - (focusIndex === i && focusBtn === 3) + (focusIndex === i && focusBtn === 3) ? "url('/images/Button_Square_Highlighted.png')" : "url('/images/Button_Square.png')", backgroundSize: "100% 100%", @@ -553,6 +567,33 @@ const VersionsView = memo(function VersionsView() { style={{ imageRendering: "pixelated" }} /> + {isCustom && ( <>