diff --git a/Cargo.lock b/Cargo.lock index 0353475e15..622399dae1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10551,6 +10551,7 @@ dependencies = [ "tracing-error", "tracing-subscriber", "url", + "urlencoding", "uuid 1.18.1", "whoami", "windows", diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index d9faac6408..dee3162060 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -98,7 +98,7 @@ import { get_user, get_version } from '@/helpers/cache.js' import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js' import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts' import { create_profile_and_install_from_file } from '@/helpers/pack' -import { list } from '@/helpers/profile.js' +import { list, run } from '@/helpers/profile.js' import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts' import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get_opening_command, initialize_state } from '@/helpers/state' @@ -862,6 +862,8 @@ async function handleCommand(e) { source: 'CreationModalFileDrop', }) } + } else if (e.event === 'LaunchProfile') { + await run(decodeURIComponent(e.path)).catch(handleError) } else if (e.event === 'InstallServer') { await router.push(`/project/${e.id}`) await playServerProject(e.id).catch(handleError) diff --git a/apps/app-frontend/src/helpers/utils.js b/apps/app-frontend/src/helpers/utils.js index d0dd6797c0..2d49bc4836 100644 --- a/apps/app-frontend/src/helpers/utils.js +++ b/apps/app-frontend/src/helpers/utils.js @@ -1,4 +1,5 @@ import { invoke } from '@tauri-apps/api/core' +import { save } from '@tauri-apps/plugin-dialog' import { get_full_path, get_mod_full_path } from '@/helpers/profile' @@ -47,6 +48,20 @@ export async function showLauncherLogsFolder() { return await invoke('plugin:utils|show_launcher_logs_folder', {}) } +export async function createProfileShortcut(profileName, profilePath) { + const outputPath = await save({ + defaultPath: `Modrinth - ${profileName}`, + }) + + if (!outputPath) return null + + return await invoke('plugin:shortcuts|create_profile_shortcut', { + profileName, + profilePath, + outputPath, + }) +} + // Opens a profile's folder in the OS file explorer export async function showProfileInFolder(path) { const fullPath = await get_full_path(path) diff --git a/apps/app-frontend/src/pages/instance/Index.vue b/apps/app-frontend/src/pages/instance/Index.vue index 1e003a9235..1fc13a6a62 100644 --- a/apps/app-frontend/src/pages/instance/Index.vue +++ b/apps/app-frontend/src/pages/instance/Index.vue @@ -197,6 +197,10 @@ id: 'export-mrpack', action: () => exportModal?.show(), }, + { + id: 'create-shortcut', + action: () => createShortcut(), + }, ]" > @@ -204,6 +208,7 @@ + @@ -324,7 +329,7 @@ import { type InstanceContentData, loadInstanceContentData } from '@/helpers/ins import { get_by_profile_path } from '@/helpers/process' import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile' import type { GameInstance } from '@/helpers/types' -import { showProfileInFolder } from '@/helpers/utils.js' +import { createProfileShortcut, showProfileInFolder } from '@/helpers/utils.js' import { get_server_status, refreshWorlds } from '@/helpers/worlds' import { injectServerInstall } from '@/providers/server-install' import { handleSevereError } from '@/store/error.js' @@ -333,7 +338,7 @@ import { useBreadcrumbs, useTheming } from '@/store/state' dayjs.extend(duration) dayjs.extend(relativeTime) -const { handleError } = injectNotificationManager() +const { addNotification, handleError } = injectNotificationManager() const { playServerProject } = injectServerInstall() const queryClient = useQueryClient() const route = useRoute() @@ -578,6 +583,21 @@ const repairInstance = async () => { await finish_install(instance.value).catch(handleError) } +const createShortcut = async () => { + if (!instance.value) return + try { + const shortcutPath = await createProfileShortcut(instance.value.name, instance.value.path) + if (!shortcutPath) return + + addNotification({ + type: 'success', + title: 'Shortcut created', + }) + } catch (error) { + handleError(error) + } +} + const handleRightClick = (event: MouseEvent) => { const baseOptions = [ { name: 'add_content' }, diff --git a/apps/app/build.rs b/apps/app/build.rs index f4d8c14a7b..2722b482f3 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -234,6 +234,14 @@ fn main() { DefaultPermissionRule::AllowAllCommands, ), ) + .plugin( + "shortcuts", + InlinedPlugin::new() + .commands(&["create_profile_shortcut"]) + .default_permission( + DefaultPermissionRule::AllowAllCommands, + ), + ) .plugin( "utils", InlinedPlugin::new() diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json index 9bf2ad0b31..42a11c9d66 100644 --- a/apps/app/capabilities/plugins.json +++ b/apps/app/capabilities/plugins.json @@ -106,6 +106,7 @@ "cache:default", "files:default", "settings:default", + "shortcuts:default", "tags:default", "utils:default", "ads:default", diff --git a/apps/app/src/api/mod.rs b/apps/app/src/api/mod.rs index 9cea479b19..00f4ece5e1 100644 --- a/apps/app/src/api/mod.rs +++ b/apps/app/src/api/mod.rs @@ -14,6 +14,7 @@ pub mod process; pub mod profile; pub mod profile_create; pub mod settings; +pub mod shortcuts; pub mod tags; pub mod utils; diff --git a/apps/app/src/api/shortcuts.rs b/apps/app/src/api/shortcuts.rs new file mode 100644 index 0000000000..6cc534cf8c --- /dev/null +++ b/apps/app/src/api/shortcuts.rs @@ -0,0 +1,278 @@ +use crate::api::Result; +#[cfg(target_os = "macos")] +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::{Path, PathBuf}; +#[cfg(target_os = "windows")] +use std::process::Command; +use tauri::Runtime; + +pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("shortcuts") + .invoke_handler(tauri::generate_handler![create_profile_shortcut]) + .build() +} + +#[tauri::command] +pub async fn create_profile_shortcut( + profile_name: String, + profile_path: String, + output_path: PathBuf, +) -> Result { + let launch_url = format!( + "modrinth://launch/profile/{}", + urlencoding::encode(&profile_path) + ); + let output_path = shortcut_path_with_extension(output_path); + let output_path_existed = + tokio::fs::try_exists(&output_path).await.unwrap_or(false); + + if let Err(error) = + create_shortcut(&profile_name, &launch_url, &output_path).await + { + cleanup_shortcut_artifact(&output_path, output_path_existed).await; + return Err(error); + } + + Ok(output_path) +} + +#[cfg(target_os = "macos")] +async fn create_shortcut( + profile_name: &str, + launch_url: &str, + output_path: &Path, +) -> Result<()> { + let contents_dir = output_path.join("Contents"); + let macos_dir = contents_dir.join("MacOS"); + let resources_dir = contents_dir.join("Resources"); + tokio::fs::create_dir_all(&macos_dir).await?; + tokio::fs::create_dir_all(&resources_dir).await?; + + let executable_path = macos_dir.join("launch"); + let target_path = std::env::current_exe()?; + tokio::fs::write( + &executable_path, + format!( + "#!/bin/sh\nexec {} {}\n", + shell_quote(&target_path.to_string_lossy()), + shell_quote(launch_url), + ), + ) + .await?; + + tokio::fs::write( + resources_dir.join("icon.icns"), + include_bytes!("../../icons/icon.icns"), + ) + .await?; + + tokio::fs::write( + contents_dir.join("Info.plist"), + format!( + "\n\ + \n\ + \n\ + \n\ + \tCFBundleExecutable\n\ + \tlaunch\n\ + \tCFBundleIdentifier\n\ + \t{}\n\ + \tCFBundleIconFile\n\ + \ticon.icns\n\ + \tCFBundleName\n\ + \t{}\n\ + \tCFBundlePackageType\n\ + \tAPPL\n\ + \n\ + \n", + macos_shortcut_identifier(launch_url), + escape_xml(&format!("Launch {profile_name}")), + ), + ) + .await?; + + use std::os::unix::fs::PermissionsExt; + + let mut permissions = + tokio::fs::metadata(&executable_path).await?.permissions(); + permissions.set_mode(0o755); + tokio::fs::set_permissions(&executable_path, permissions).await?; + + Ok(()) +} + +#[cfg(target_os = "macos")] +fn macos_shortcut_identifier(launch_url: &str) -> String { + let mut hasher = DefaultHasher::new(); + launch_url.hash(&mut hasher); + + format!("com.modrinth.instance-shortcut.{:x}", hasher.finish()) +} + +#[cfg(target_os = "windows")] +async fn create_shortcut( + _profile_name: &str, + launch_url: &str, + output_path: &Path, +) -> Result<()> { + let target_path = std::env::current_exe()?; + let working_dir = target_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_default(); + let script = r#" +$WshShell = New-Object -ComObject WScript.Shell +$Shortcut = $WshShell.CreateShortcut($env:MODRINTH_SHORTCUT_PATH) +$Shortcut.TargetPath = $env:MODRINTH_TARGET_PATH +$Shortcut.Arguments = $env:MODRINTH_SHORTCUT_ARGUMENTS +$Shortcut.WorkingDirectory = $env:MODRINTH_WORKING_DIRECTORY +$Shortcut.IconLocation = $env:MODRINTH_TARGET_PATH +$Shortcut.Save() +"#; + + let status = Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ]) + .env("MODRINTH_SHORTCUT_PATH", output_path) + .env("MODRINTH_TARGET_PATH", &target_path) + .env("MODRINTH_SHORTCUT_ARGUMENTS", launch_url) + .env("MODRINTH_WORKING_DIRECTORY", working_dir) + .status()?; + + if !status.success() { + return Err(std::io::Error::other(format!( + "failed to create shortcut with exit status {status}" + )) + .into()); + } + + Ok(()) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +async fn create_shortcut( + profile_name: &str, + launch_url: &str, + output_path: &Path, +) -> Result<()> { + let target_path = std::env::current_exe()?; + tokio::fs::write( + output_path, + format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name={}\n\ + Exec={} {}\n\ + Icon=ModrinthApp\n\ + Terminal=false\n\ + Categories=Game;\n", + escape_desktop_entry_value(&format!("Launch {profile_name}")), + quote_desktop_exec_arg(&target_path.to_string_lossy()), + quote_desktop_exec_arg(launch_url), + ), + ) + .await?; + + use std::os::unix::fs::PermissionsExt; + + let mut permissions = tokio::fs::metadata(output_path).await?.permissions(); + permissions.set_mode(0o755); + tokio::fs::set_permissions(output_path, permissions).await?; + + Ok(()) +} + +fn shortcut_path_with_extension(mut path: PathBuf) -> PathBuf { + let extension = shortcut_extension(); + + if path + .extension() + .is_none_or(|current_extension| current_extension != extension) + { + path.set_extension(extension); + } + + path +} + +async fn cleanup_shortcut_artifact(path: &Path, existed: bool) { + if existed { + return; + } + + let result = match tokio::fs::metadata(path).await { + Ok(metadata) if metadata.is_dir() => { + tokio::fs::remove_dir_all(path).await + } + _ => tokio::fs::remove_file(path).await, + }; + + if let Err(error) = result + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + "failed to clean up shortcut artifact {}: {}", + path.display(), + error + ); + } +} + +fn shortcut_extension() -> &'static str { + #[cfg(target_os = "windows")] + { + "lnk" + } + + #[cfg(target_os = "macos")] + { + "app" + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + "desktop" + } +} + +#[cfg(target_os = "macos")] +fn shell_quote(input: &str) -> String { + format!("'{}'", input.replace('\'', "'\\''")) +} + +#[cfg(target_os = "macos")] +fn escape_xml(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn escape_desktop_entry_value(input: &str) -> String { + input + .replace('\\', "\\\\") + .replace('\n', "\\n") + .replace('\r', "") +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn quote_desktop_exec_arg(input: &str) -> String { + format!( + "\"{}\"", + input + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('$', "\\$") + .replace('`', "\\`") + ) +} diff --git a/apps/app/src/api/utils.rs b/apps/app/src/api/utils.rs index a2b013ae3c..28272588c6 100644 --- a/apps/app/src/api/utils.rs +++ b/apps/app/src/api/utils.rs @@ -129,11 +129,18 @@ pub async fn get_opening_command( state: tauri::State<'_, crate::macos::deep_link::InitialPayload>, ) -> Result> { let payload = state.payload.lock().await; + let cmd_arg = std::env::args_os() + .nth(1) + .map(|path| path.to_string_lossy().to_string()); return if let Some(payload) = payload.as_ref() { tracing::info!("opening command {payload}"); Ok(Some(handler::parse_command(payload).await?)) + } else if let Some(cmd_arg) = cmd_arg { + tracing::info!("opening command {cmd_arg:?}"); + + Ok(Some(handler::parse_command(&cmd_arg).await?)) } else { Ok(None) }; diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs index 4df5dcd8ee..9901dd8954 100644 --- a/apps/app/src/main.rs +++ b/apps/app/src/main.rs @@ -242,6 +242,7 @@ fn main() { .plugin(api::profile::init()) .plugin(api::profile_create::init()) .plugin(api::settings::init()) + .plugin(api::shortcuts::init()) .plugin(api::tags::init()) .plugin(api::utils::init()) .plugin(api::cache::init()) diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index 0cd7d5d9f7..2b9f075e78 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -104,6 +104,7 @@ tracing = { workspace = true } tracing-error = { workspace = true } tracing-subscriber = { workspace = true, features = ["chrono", "env-filter"] } url = { workspace = true, features = ["serde"] } +urlencoding = { workspace = true } uuid = { workspace = true, features = ["serde", "v4"] } whoami = { workspace = true } zbus = { workspace = true } diff --git a/packages/app-lib/src/api/handler.rs b/packages/app-lib/src/api/handler.rs index 500aaeb385..ffa0ca413d 100644 --- a/packages/app-lib/src/api/handler.rs +++ b/packages/app-lib/src/api/handler.rs @@ -7,6 +7,7 @@ use crate::{ }, util::io, }; +use urlencoding::decode; /// Handles external functions (such as through URL deep linkage) /// Link is extracted value (link) in somewhat URL format, such as @@ -28,6 +29,25 @@ pub async fn handle_url(sublink: &str) -> crate::Result { Some(("server", id)) => { CommandPayload::InstallServer { id: id.to_string() } } + // /launch/profile/{id} - Launches a profile + Some(("launch", rest)) if rest.starts_with("profile/") => { + let raw = rest.trim_start_matches("profile/"); + match decode(raw) { + Ok(decoded) => CommandPayload::LaunchProfile { + path: decoded.to_string(), + }, + Err(e) => { + emit_warning(&format!( + "Invalid UTF-8 in profile path: {e}" + )) + .await?; + return Err(crate::ErrorKind::InputError(format!( + "Invalid UTF-8 in profile path: {e}" + )) + .into()); + } + } + } _ => { emit_warning(&format!( "Invalid command, unrecognized path: {sublink}" diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs index f570d78a9a..1f642466a4 100644 --- a/packages/app-lib/src/event/mod.rs +++ b/packages/app-lib/src/event/mod.rs @@ -212,6 +212,9 @@ pub enum CommandPayload { InstallServer { id: String, }, + LaunchProfile { + path: String, + }, RunMRPack { // run or install .mrpack path: PathBuf,