diff --git a/.github/workflows/turbo-ci.yml b/.github/workflows/turbo-ci.yml
index a31fcb8bc3..8a2cdcb522 100644
--- a/.github/workflows/turbo-ci.yml
+++ b/.github/workflows/turbo-ci.yml
@@ -122,6 +122,14 @@ jobs:
restore-keys: |
pnpm-cache-
+ - name: Cache Gradle distribution
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: |
+ ~/.gradle/wrapper/dists
+ ~/.gradle/caches
+ key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('packages/app-lib/java/gradle/wrapper/gradle-wrapper.properties') }}
+
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
with:
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 @@
Create a server
Open folder
Export modpack
+ Create shortcut
@@ -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