feat: instances v2 (#6431)

* feat: base of instances v2

* feat: use old profiles with compat layer

* prototype: instances v2

* fix: install_from using profile

* fix: skins migration fix

* fix: frontend still using profile path

* fix: add update proj multiselect guard

* fix: cargo fmt

* fix: content missing fields

* feat: break up app-lib/api/instance.rs

* fix: check_content_updates mismatch

* fix: updater modal cleanup w/new structure

* feat: better update all handling

* fix: remove preview_update_all

* fix: feedback on bulk update + lint

* fix: rem transitions

* fix: change to jsonb

* feat: app db backup after update

* fix: lint

* fix: sqlx prepare + use sqlx macros

* fix: lint

* fix: bugs

* feat: defuck the installing process up

* fix: bug of hell

* fix: shear

* fix: fmt

* fix: install progress spacing + change mc/content/overrides to bytes

* fix: lint

* fix: prepr

* fix: navtabs anim not working in app

* fix: worlds.vue improvements + browse page fixes

* feat: optimise queries + adapter fns

* fix: lint

* fix: lint

* feat: shared modrinth-content-management crate (#6469)

* feat: disable warnings setting

* feat: add instances shortcuts (#6329)

* Add modrinth://launch deep link to start a profile

Support external profile launching via modrinth://launch/{profile_path} for integrations such as Stream Deck.

* Change route to /launch/profile/{id} for future extensibility

* fix: ensure profile path is url decoded

* fix: URL-decode profile path from deep link

* fix: use urlencoding crate for URL decoding

* feat: implement app instance shortcuts

* feat: change windows shortcut creation to use windows api instead

* feat: implement creating a shortcut launching world/server

* format

* fmt

* fix multiline inline tables

* pnpm prepr

* feat: move create shortcut to last item

* refactor: split up shortcuts.rs for individual platforms

* refactor: turn profile launch url into url type

* use string literal and add safety comment

* pt2

* refactor: rename anything that's profile into instance

* update mac shortcut

---------

Co-authored-by: DJCheesusReal <134006619+DJCheesusReal@users.noreply.github.com>

---------

Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: DJCheesusReal <134006619+DJCheesusReal@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-06-25 21:19:29 +00:00
committed by GitHub
co-authored by DJCheesusReal Truman Gao
parent ef4044534f
commit 734720e11e
353 changed files with 24745 additions and 9771 deletions
+55
View File
@@ -0,0 +1,55 @@
use crate::api::Result;
use std::path::Path;
use url::Url;
pub(super) const SHORTCUT_EXTENSION: &str = "desktop";
pub(super) async fn create_shortcut(
profile_name: &str,
launch_url: &Url,
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.as_str()),
),
)
.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 escape_desktop_entry_value(input: &str) -> String {
input
.replace('\\', "\\\\")
.replace('\n', "\\n")
.replace('\r', "")
}
fn quote_desktop_exec_arg(input: &str) -> String {
format!(
"\"{}\"",
input
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "\\$")
.replace('`', "\\`")
)
}
+90
View File
@@ -0,0 +1,90 @@
use crate::api::Result;
use std::{
hash::{DefaultHasher, Hash, Hasher},
path::Path,
};
use url::Url;
pub(super) const SHORTCUT_EXTENSION: &str = "app";
pub(super) async fn create_shortcut(
profile_name: &str,
launch_url: &Url,
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");
tokio::fs::write(
&executable_path,
format!(
"#!/bin/sh\nexec /usr/bin/open {}\n",
shell_quote(launch_url.as_str()),
),
)
.await?;
tokio::fs::write(
resources_dir.join("icon.icns"),
include_bytes!("../../../icons/icon.icns"),
)
.await?;
tokio::fs::write(
contents_dir.join("Info.plist"),
format!(r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>launch</string>
<key>CFBundleIdentifier</key>
<string>{}</string>
<key>CFBundleIconFile</key>
<string>icon.icns</string>
<key>CFBundleName</key>
<string>{}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
</dict>
</plist>
"#,
macos_shortcut_identifier(launch_url.as_str()),
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(())
}
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())
}
fn shell_quote(input: &str) -> String {
format!("'{}'", input.replace('\'', "'\\''"))
}
fn escape_xml(input: &str) -> String {
input
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+114
View File
@@ -0,0 +1,114 @@
use crate::api::Result;
use std::path::{Path, PathBuf};
use tauri::Runtime;
use url::Url;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "linux")]
use linux::{SHORTCUT_EXTENSION, create_shortcut};
#[cfg(target_os = "macos")]
use macos::{SHORTCUT_EXTENSION, create_shortcut};
#[cfg(target_os = "windows")]
use windows::{SHORTCUT_EXTENSION, create_shortcut};
pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("shortcuts")
.invoke_handler(tauri::generate_handler![create_instance_shortcut])
.build()
}
#[tauri::command]
pub async fn create_instance_shortcut(
instance_name: String,
instance_id: String,
output_path: PathBuf,
server: Option<String>,
singleplayer_world: Option<String>,
) -> Result<PathBuf> {
if server.is_some() && singleplayer_world.is_some() {
return Err(std::io::Error::other(
"shortcut cannot launch both a server and a singleplayer world",
)
.into());
}
let launch_url =
instance_launch_url(instance_id, server, singleplayer_world);
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(&instance_name, &launch_url, &output_path).await
{
cleanup_shortcut_artifact(&output_path, output_path_existed).await;
return Err(error);
}
Ok(output_path)
}
fn instance_launch_url(
instance_id: String,
server: Option<String>,
singleplayer_world: Option<String>,
) -> Url {
let mut launch_url = Url::parse("modrinth://launch/instance")
.expect("static launch URL should parse");
launch_url
.path_segments_mut()
.expect("launch URL should support path segments")
.push(&instance_id);
if let Some(server) = server {
launch_url.query_pairs_mut().append_pair("server", &server);
} else if let Some(singleplayer_world) = singleplayer_world {
launch_url
.query_pairs_mut()
.append_pair("singleplayer_world", &singleplayer_world);
}
launch_url
}
fn shortcut_path_with_extension(mut path: PathBuf) -> PathBuf {
if path
.extension()
.is_none_or(|current_extension| current_extension != SHORTCUT_EXTENSION)
{
path.set_extension(SHORTCUT_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
);
}
}
+125
View File
@@ -0,0 +1,125 @@
use crate::api::Result;
use std::{
os::windows::ffi::OsStrExt,
path::{Path, PathBuf},
};
use url::Url;
use windows::{
Win32::{
System::Com::{
CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
COINIT_DISABLE_OLE1DDE, CoCreateInstance, CoInitializeEx,
CoUninitialize, IPersistFile,
},
UI::Shell::{IShellLinkW, ShellLink},
},
core::{Interface, PCWSTR},
};
pub(super) const SHORTCUT_EXTENSION: &str = "lnk";
pub(super) async fn create_shortcut(
_profile_name: &str,
launch_url: &Url,
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 output_path = output_path.to_path_buf();
let launch_url = launch_url.to_string();
tokio::task::spawn_blocking(move || {
create_windows_shortcut(
output_path,
target_path,
working_dir,
launch_url,
)
})
.await
.map_err(|error| {
std::io::Error::other(format!(
"failed to join shortcut creation task: {error}"
))
})??;
Ok(())
}
fn create_windows_shortcut(
output_path: PathBuf,
target_path: PathBuf,
working_dir: PathBuf,
launch_url: String,
) -> std::io::Result<()> {
let output_path = windows_wide_path(&output_path);
let target_path = windows_wide_path(&target_path);
let working_dir = windows_wide_path(&working_dir);
let launch_url = windows_wide_string(&launch_url);
// SAFETY:
// - COM is initialized for this blocking thread before any COM object is created.
// - `_com` is declared before the COM interface values, so it is dropped
// after them and calls `CoUninitialize` only once they are released.
// - Every PCWSTR points to a NUL-terminated UTF-16 buffer that lives until
// each call using it has returned.
unsafe {
let init_result = CoInitializeEx(
None,
COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE,
);
windows_result(init_result.ok())?;
let _com = WindowsComGuard;
let shortcut: IShellLinkW = windows_result(CoCreateInstance(
&ShellLink,
None,
CLSCTX_INPROC_SERVER,
))?;
windows_result(shortcut.SetPath(windows_pcwstr(&target_path)))?;
windows_result(shortcut.SetArguments(windows_pcwstr(&launch_url)))?;
windows_result(
shortcut.SetWorkingDirectory(windows_pcwstr(&working_dir)),
)?;
windows_result(
shortcut.SetIconLocation(windows_pcwstr(&target_path), 0),
)?;
let persist_file: IPersistFile = windows_result(shortcut.cast())?;
windows_result(persist_file.Save(windows_pcwstr(&output_path), true))?;
}
Ok(())
}
fn windows_result<T>(result: windows::core::Result<T>) -> std::io::Result<T> {
result.map_err(std::io::Error::other)
}
struct WindowsComGuard;
impl Drop for WindowsComGuard {
fn drop(&mut self) {
unsafe {
CoUninitialize();
}
}
}
fn windows_wide_path(path: &Path) -> Vec<u16> {
path.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
fn windows_wide_string(value: &str) -> Vec<u16> {
value.encode_utf16().chain(std::iter::once(0)).collect()
}
fn windows_pcwstr(value: &[u16]) -> PCWSTR {
PCWSTR::from_raw(value.as_ptr())
}