mirror of
https://github.com/modrinth/code.git
synced 2026-08-02 06:05:54 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d8327f785 | ||
|
|
854cf5b41f | ||
|
|
79ce305268 | ||
|
|
0b47b65398 | ||
|
|
ace09d39b0 | ||
|
|
ab7145533e | ||
|
|
6ddd41da92 | ||
|
|
c849abf483 | ||
|
|
ce7ca2e035 | ||
|
|
954a198c37 | ||
|
|
d223705b14 | ||
|
|
7cb0b1bac3 | ||
|
|
325d7ee799 | ||
|
|
fe5a620d0c | ||
|
|
648d57dae5 |
@@ -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
@@ -10626,6 +10626,7 @@ dependencies = [
|
||||
"tracing-error",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid 1.18.1",
|
||||
"whoami",
|
||||
"windows",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -235,6 +235,14 @@ fn main() {
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
),
|
||||
)
|
||||
.plugin(
|
||||
"shortcuts",
|
||||
InlinedPlugin::new()
|
||||
.commands(&["create_profile_shortcut"])
|
||||
.default_permission(
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
),
|
||||
)
|
||||
.plugin(
|
||||
"utils",
|
||||
InlinedPlugin::new()
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"cache:default",
|
||||
"files:default",
|
||||
"settings:default",
|
||||
"shortcuts:default",
|
||||
"tags:default",
|
||||
"utils:default",
|
||||
"ads:default",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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('&', "&")
|
||||
.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('`', "\\`")
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -212,6 +212,9 @@ pub enum CommandPayload {
|
||||
InstallServer {
|
||||
id: String,
|
||||
},
|
||||
LaunchProfile {
|
||||
path: String,
|
||||
},
|
||||
RunMRPack {
|
||||
// run or install .mrpack
|
||||
path: PathBuf,
|
||||
|
||||
Reference in New Issue
Block a user