mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 01:26:23 +00:00
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:
co-authored by
DJCheesusReal
Truman Gao
parent
ef4044534f
commit
734720e11e
@@ -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('`', "\\`")
|
||||
)
|
||||
}
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user