Compare commits

...
Author SHA1 Message Date
tdgao 9d8327f785 Merge branch 'main' into truman/add-instance-shortcuts 2026-06-23 16:55:26 -06:00
tdgao 854cf5b41f Merge branch 'main' into truman/add-instance-shortcuts 2026-06-10 10:10:08 -07:00
tdgao 79ce305268 Merge branch 'main' into truman/add-instance-shortcuts 2026-06-09 15:53:05 -07:00
tdgao 0b47b65398 feat: implement app instance shortcuts 2026-06-06 21:13:47 -07:00
DJCheesusReal ace09d39b0 fix: use urlencoding crate for URL decoding 2026-06-06 20:38:21 +01:00
Truman GaoandGitHub ab7145533e Merge branch 'main' into feat/external-profile-launch 2026-06-06 13:30:29 -06:00
tdgao 6ddd41da92 Merge branch 'feat/external-profile-launch' of github.com:DJCheesusReal/code into pr/DJCheesusReal/6123 2026-06-06 13:30:08 -06:00
DJCheesusReal c849abf483 fix: URL-decode profile path from deep link 2026-06-06 20:23:32 +01:00
DJCheesusReal ce7ca2e035 fix: increase Gradle network timeout to prevent download failures 2026-06-06 20:21:37 +01:00
tdgao 954a198c37 Merge branch 'feat/external-profile-launch' of github.com:DJCheesusReal/code into pr/DJCheesusReal/6123 2026-06-06 13:16:28 -06:00
tdgao d223705b14 fix: ensure profile path is url decoded 2026-06-06 13:16:19 -06:00
DJCheesusReal 7cb0b1bac3 fix(ci): increase Gradle timeout and add cache to fix CI failures
- Increase Gradle network timeout from 10s to 120s to prevent
  download timeouts when building theseus
- Cache Gradle wrapper distributions and caches in CI to avoid
  re-downloading on every run
2026-06-06 20:09:47 +01:00
DJCheesusReal 325d7ee799 Change route to /launch/profile/{id} for future extensibility 2026-06-06 10:57:48 +01:00
William YatesandGitHub fe5a620d0c Merge branch 'main' into feat/external-profile-launch 2026-05-19 18:27:15 +01:00
DJCheesusRealandCursor 648d57dae5 Add modrinth://launch deep link to start a profile
Support external profile launching via modrinth://launch/{profile_path} for integrations such as Stream Deck.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 17:10:16 +01:00
15 changed files with 370 additions and 4 deletions
+8
View File
@@ -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:
Generated
+1
View File
@@ -10626,6 +10626,7 @@ dependencies = [
"tracing-error",
"tracing-subscriber",
"url",
"urlencoding",
"uuid 1.18.1",
"whoami",
"windows",
+3 -1
View File
@@ -96,7 +96,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'
@@ -859,6 +859,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)
+15
View File
@@ -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)
+22 -2
View File
@@ -197,6 +197,10 @@
id: 'export-mrpack',
action: () => exportModal?.show(),
},
{
id: 'create-shortcut',
action: () => createShortcut(),
},
]"
>
<MoreVerticalIcon />
@@ -204,6 +208,7 @@
<template #host-a-server> <ServerIcon /> Create a server </template>
<template #open-folder> <FolderOpenIcon /> Open folder </template>
<template #export-mrpack> <PackageIcon /> Export modpack </template>
<template #create-shortcut> <ExternalIcon /> Create shortcut </template>
</OverflowMenu>
</ButtonStyled>
</div>
@@ -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' },
+8
View File
@@ -235,6 +235,14 @@ fn main() {
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"shortcuts",
InlinedPlugin::new()
.commands(&["create_profile_shortcut"])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"utils",
InlinedPlugin::new()
+1
View File
@@ -106,6 +106,7 @@
"cache:default",
"files:default",
"settings:default",
"shortcuts:default",
"tags:default",
"utils:default",
"ads:default",
+1
View File
@@ -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;
+278
View File
@@ -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<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
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<PathBuf> {
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!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n\
\t<key>CFBundleExecutable</key>\n\
\t<string>launch</string>\n\
\t<key>CFBundleIdentifier</key>\n\
\t<string>{}</string>\n\
\t<key>CFBundleIconFile</key>\n\
\t<string>icon.icns</string>\n\
\t<key>CFBundleName</key>\n\
\t<string>{}</string>\n\
\t<key>CFBundlePackageType</key>\n\
\t<string>APPL</string>\n\
</dict>\n\
</plist>\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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[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('`', "\\`")
)
}
+7
View File
@@ -129,11 +129,18 @@ pub async fn get_opening_command(
state: tauri::State<'_, crate::macos::deep_link::InitialPayload>,
) -> Result<Option<CommandPayload>> {
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)
};
+1
View File
@@ -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())
+1
View File
@@ -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 }
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
networkTimeout=120000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+20
View File
@@ -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<CommandPayload> {
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}"
+3
View File
@@ -212,6 +212,9 @@ pub enum CommandPayload {
InstallServer {
id: String,
},
LaunchProfile {
path: String,
},
RunMRPack {
// run or install .mrpack
path: PathBuf,