Monorepo missing features (#1273)

* fix tauri config

* fix package patch

* regen pnpm lock

* use new workflow

* New GH actions

* Update lockfile

* update scripts

* Fix build script

* Fix missing deps

* Fix assets eslint

* Update libraries lint

* Fix all lint configs

* update lockfile

* add fmt + clippy fails

* Separate App Tauri portion

* fix app features

* Fix lints

* install tauri cli

* update lockfile

* corepack, fix lints

* add store path

* fix unused import

* Fix tests

* Issue templates + port over tauri release

* fix actions

* fix before build command

* Add X86 target

* Update build matrix

* finalize actions

* make debug build smaller

* Use debug build to make cache smaller

* dummy commit

* change proj name

* update file name

* Use release builds for less space use

* Remove rust cache

* Readd for app build

* add merge queue trigger
This commit is contained in:
Geometrically
2024-07-09 15:17:38 -07:00
committed by GitHub
parent dab284f339
commit d1bc65c266
265 changed files with 1810 additions and 1871 deletions

View File

@@ -1,664 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { RouterView, RouterLink, useRouter, useRoute } from 'vue-router'
import {
HomeIcon,
SearchIcon,
LibraryIcon,
PlusIcon,
SettingsIcon,
FileIcon,
XIcon,
} from '@modrinth/assets'
import { Button, Notifications, Card } from '@modrinth/ui'
import { useLoading, useTheming } from '@/store/state'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { get } from '@/helpers/settings'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import ErrorModal from '@/components/ui/ErrorModal.vue'
import ModrinthLoadingIndicator from '@/components/modrinth-loading-indicator'
import { handleError, useNotifications } from '@/store/notifications.js'
import { offline_listener, command_listener, warning_listener } from '@/helpers/events.js'
import { MinimizeIcon, MaximizeIcon, ChatIcon } from '@/assets/icons'
import { type } from '@tauri-apps/api/os'
import { appWindow } from '@tauri-apps/api/window'
import { isDev, getOS, isOffline, showLauncherLogsFolder } from '@/helpers/utils.js'
import {
mixpanel_track,
mixpanel_init,
mixpanel_opt_out_tracking,
mixpanel_is_loaded,
} from '@/helpers/mixpanel'
import { saveWindowState, StateFlags } from 'tauri-plugin-window-state-api'
import { getVersion } from '@tauri-apps/api/app'
import { window as TauriWindow } from '@tauri-apps/api'
import { TauriEvent } from '@tauri-apps/api/event'
import { await_sync, check_safe_loading_bars_complete } from './helpers/state'
import { confirm } from '@tauri-apps/api/dialog'
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
import OnboardingScreen from '@/components/ui/tutorial/OnboardingScreen.vue'
import { install_from_file } from './helpers/pack'
import { useError } from '@/store/error.js'
const themeStore = useTheming()
const urlModal = ref(null)
const isLoading = ref(true)
const offline = ref(false)
const showOnboarding = ref(false)
const nativeDecorations = ref(false)
const onboardingVideo = ref()
const failureText = ref(null)
const os = ref('')
defineExpose({
initialize: async () => {
isLoading.value = false
const {
native_decorations,
theme,
opt_out_analytics,
collapsed_navigation,
advanced_rendering,
fully_onboarded,
} = await get()
// video should play if the user is not on linux, and has not onboarded
os.value = await getOS()
const dev = await isDev()
const version = await getVersion()
showOnboarding.value = !fully_onboarded
nativeDecorations.value = native_decorations
if (os.value !== 'MacOS') appWindow.setDecorations(native_decorations)
themeStore.setThemeState(theme)
themeStore.collapsedNavigation = collapsed_navigation
themeStore.advancedRendering = advanced_rendering
mixpanel_init('014c7d6a336d0efaefe3aca91063748d', { debug: dev, persistence: 'localStorage' })
if (opt_out_analytics) {
mixpanel_opt_out_tracking()
}
mixpanel_track('Launched', { version, dev, fully_onboarded })
if (!dev) document.addEventListener('contextmenu', (event) => event.preventDefault())
if ((await type()) === 'Darwin') {
document.getElementsByTagName('html')[0].classList.add('mac')
} else {
document.getElementsByTagName('html')[0].classList.add('windows')
}
offline.value = await isOffline()
await offline_listener((b) => {
offline.value = b
})
await warning_listener((e) =>
notificationsWrapper.value.addNotification({
title: 'Warning',
text: e.message,
type: 'warn',
}),
)
if (showOnboarding.value) {
onboardingVideo.value.play()
}
},
failure: async (e) => {
isLoading.value = false
failureText.value = e
os.value = await getOS()
},
})
const confirmClose = async () => {
const confirmed = await confirm(
'An action is currently in progress. Are you sure you want to exit?',
{
title: 'Modrinth',
type: 'warning',
},
)
return confirmed
}
const handleClose = async () => {
if (failureText.value != null) {
await TauriWindow.getCurrent().close()
return
}
// State should respond immeiately if it's safe to close
// If not, code is deadlocked or worse, so wait 2 seconds and then ask the user to confirm closing
// (Exception: if the user is changing config directory, which takes control of the state, and it's taking a significant amount of time for some reason)
const isSafe = await Promise.race([
check_safe_loading_bars_complete(),
new Promise((r) => setTimeout(r, 2000)),
])
if (!isSafe) {
const response = await confirmClose()
if (!response) {
return
}
}
await await_sync()
await TauriWindow.getCurrent().close()
}
const openSupport = async () => {
window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: 'https://discord.gg/modrinth',
},
})
}
TauriWindow.getCurrent().listen(TauriEvent.WINDOW_CLOSE_REQUESTED, async () => {
await handleClose()
})
const router = useRouter()
router.afterEach((to, from, failure) => {
if (mixpanel_is_loaded()) {
mixpanel_track('PageView', { path: to.path, fromPath: from.path, failed: failure })
}
})
const route = useRoute()
const isOnBrowse = computed(() => route.path.startsWith('/browse'))
const loading = useLoading()
const notifications = useNotifications()
const notificationsWrapper = ref()
watch(notificationsWrapper, () => {
notifications.setNotifs(notificationsWrapper.value)
})
const error = useError()
const errorModal = ref()
watch(errorModal, () => {
error.setErrorModal(errorModal.value)
})
document.querySelector('body').addEventListener('click', function (e) {
let target = e.target
while (target != null) {
if (target.matches('a')) {
if (
target.href &&
['http://', 'https://', 'mailto:', 'tel:'].some((v) => target.href.startsWith(v)) &&
!target.classList.contains('router-link-active') &&
!target.href.startsWith('http://localhost') &&
!target.href.startsWith('https://tauri.localhost')
) {
window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: target.href,
},
})
}
e.preventDefault()
break
}
target = target.parentElement
}
})
document.querySelector('body').addEventListener('auxclick', function (e) {
// disables middle click -> new tab
if (e.button === 1) {
e.preventDefault()
// instead do a left click
const event = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true,
})
e.target.dispatchEvent(event)
}
})
const accounts = ref(null)
command_listener(async (e) => {
if (e.event === 'RunMRPack') {
// RunMRPack should directly install a local mrpack given a path
if (e.path.endsWith('.mrpack')) {
await install_from_file(e.path).catch(handleError)
mixpanel_track('InstanceCreate', {
source: 'CreationModalFileDrop',
})
}
} else {
// Other commands are URL-based (deep linking)
urlModal.value.show(e)
}
})
</script>
<template>
<div v-if="failureText" class="failure dark-mode">
<div class="appbar-failure dark-mode">
<Button v-if="os != 'MacOS'" icon-only @click="TauriWindow.getCurrent().close()">
<XIcon />
</Button>
</div>
<div class="error-view dark-mode">
<Card class="error-text">
<div class="label">
<h3>
<span class="label__title size-card-header">Failed to initialize</span>
</h3>
</div>
<div class="error-div">
Modrinth App failed to load correctly. This may be because of a corrupted file, or because
the app is missing crucial files.
</div>
<div class="error-div">You may be able to fix it one of the following ways:</div>
<ul class="error-div">
<li>Ennsuring you are connected to the internet, then try restarting the app.</li>
<li>Redownloading the app.</li>
</ul>
<div class="error-div">
If it still does not work, you can seek support using the link below. You should provide
the following error, as well as any recent launcher logs in the folder below.
</div>
<div class="error-div">The following error was provided:</div>
<Card class="error-message">
{{ failureText.message }}
</Card>
<div class="button-row push-right">
<Button @click="showLauncherLogsFolder"><FileIcon />Open launcher logs</Button>
<Button @click="openSupport"><ChatIcon />Get support</Button>
</div>
</Card>
</div>
</div>
<SplashScreen v-else-if="isLoading" app-loading />
<OnboardingScreen v-else-if="showOnboarding" :finish="() => (showOnboarding = false)" />
<div v-else class="container">
<div class="nav-container">
<div class="nav-section">
<suspense>
<AccountsCard ref="accounts" mode="small" />
</suspense>
<div class="pages-list">
<RouterLink v-tooltip="'Home'" to="/" class="btn icon-only collapsed-button">
<HomeIcon />
</RouterLink>
<RouterLink
v-tooltip="'Browse'"
to="/browse/modpack"
class="btn icon-only collapsed-button"
:class="{
'router-link-active': isOnBrowse,
}"
>
<SearchIcon />
</RouterLink>
<RouterLink v-tooltip="'Library'" to="/library" class="btn icon-only collapsed-button">
<LibraryIcon />
</RouterLink>
<Suspense>
<InstanceCreationModal ref="installationModal" />
</Suspense>
</div>
</div>
<div class="settings pages-list">
<Button
v-tooltip="'Create profile'"
class="sleek-primary collapsed-button"
icon-only
:disabled="offline"
@click="() => $refs.installationModal.show()"
>
<PlusIcon />
</Button>
<RouterLink v-tooltip="'Settings'" to="/settings" class="btn icon-only collapsed-button">
<SettingsIcon />
</RouterLink>
</div>
</div>
<div class="view">
<div class="appbar-row">
<div data-tauri-drag-region class="appbar">
<section class="navigation-controls">
<Breadcrumbs data-tauri-drag-region />
</section>
<section class="mod-stats">
<Suspense>
<RunningAppBar />
</Suspense>
</section>
</div>
<section v-if="!nativeDecorations" class="window-controls">
<Button class="titlebar-button" icon-only @click="() => appWindow.minimize()">
<MinimizeIcon />
</Button>
<Button class="titlebar-button" icon-only @click="() => appWindow.toggleMaximize()">
<MaximizeIcon />
</Button>
<Button
class="titlebar-button close"
icon-only
@click="
() => {
saveWindowState(StateFlags.ALL)
handleClose()
}
"
>
<XIcon />
</Button>
</section>
</div>
<div class="router-view">
<ModrinthLoadingIndicator
offset-height="var(--appbar-height)"
offset-width="var(--sidebar-width)"
/>
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense @pending="loading.startLoading()" @resolve="loading.stopLoading()">
<component :is="Component"></component>
</Suspense>
</template>
</RouterView>
</div>
</div>
</div>
<URLConfirmModal ref="urlModal" />
<Notifications ref="notificationsWrapper" />
<ErrorModal ref="errorModal" />
</template>
<style lang="scss" scoped>
.sleek-primary {
background-color: var(--color-brand-highlight);
transition: all ease-in-out 0.1s;
}
.navigation-controls {
flex-grow: 1;
width: min-content;
}
.appbar-row {
display: flex;
flex-direction: row;
}
.window-controls {
z-index: 20;
display: none;
flex-direction: row;
align-items: center;
.titlebar-button {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all ease-in-out 0.1s;
background-color: var(--color-raised-bg);
color: var(--color-base);
border-radius: 0;
height: 3.25rem;
&.close {
&:hover,
&:active {
background-color: var(--color-red);
color: var(--color-accent-contrast);
}
}
&:hover,
&:active {
background-color: var(--color-button-bg);
color: var(--color-contrast);
}
}
}
.container {
--appbar-height: 3.25rem;
--sidebar-width: 4.5rem;
height: 100vh;
display: flex;
flex-direction: row;
overflow: hidden;
.view {
width: calc(100% - var(--sidebar-width));
background-color: var(--color-raised-bg);
.appbar {
display: flex;
align-items: center;
flex-grow: 1;
background: var(--color-raised-bg);
text-align: center;
padding: var(--gap-md);
height: 3.25rem;
gap: var(--gap-sm);
//no select
user-select: none;
-webkit-user-select: none;
}
.router-view {
width: 100%;
height: calc(100% - 3.125rem);
overflow: auto;
overflow-x: hidden;
background-color: var(--color-bg);
border-top-left-radius: var(--radius-xl);
}
}
}
.failure {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: var(--color-bg);
.appbar-failure {
display: flex; /* Change to flex to align items horizontally */
justify-content: flex-end; /* Align items to the right */
height: 3.25rem;
//no select
user-select: none;
-webkit-user-select: none;
}
.error-view {
display: flex; /* Change to flex to align items horizontally */
justify-content: center;
width: 100%;
background-color: var(--color-bg);
color: var(--color-base);
.card {
background-color: var(--color-raised-bg);
}
.error-text {
display: flex;
max-width: 60%;
gap: 0.25rem;
flex-direction: column;
.error-div {
// spaced out
margin: 0.5rem;
}
.error-message {
margin: 0.5rem;
background-color: var(--color-button-bg);
}
}
}
}
.nav-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
height: 100%;
background-color: var(--color-raised-bg);
box-shadow: var(--shadow-inset-sm), var(--shadow-floating);
padding: var(--gap-md);
}
.pages-list {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
gap: 0.5rem;
a {
display: flex;
align-items: center;
word-spacing: 3px;
background: inherit;
transition: all ease-in-out 0.1s;
color: var(--color-base);
box-shadow: none;
&.router-link-active {
color: var(--color-contrast);
background: var(--color-button-bg);
box-shadow: var(--shadow-floating);
}
&:hover {
background-color: var(--color-button-bg);
color: var(--color-contrast);
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
text-decoration: none;
}
}
&.primary {
color: var(--color-accent-contrast);
background-color: var(--color-brand);
}
}
.collapsed-button {
height: 3rem !important;
width: 3rem !important;
padding: 0.75rem;
border-radius: var(--radius-md);
box-shadow: none;
svg {
width: 1.5rem !important;
height: 1.5rem !important;
max-width: 1.5rem !important;
max-height: 1.5rem !important;
}
}
.instance-list {
display: flex;
flex-direction: column;
justify-content: center;
width: 70%;
margin: 0.4rem;
p:nth-child(1) {
font-size: 0.6rem;
}
& > p {
color: var(--color-base);
margin: 0.8rem 0;
font-size: 0.7rem;
line-height: 0.8125rem;
font-weight: 500;
text-transform: uppercase;
}
}
.user-section {
display: flex;
justify-content: flex-start;
align-items: center;
width: 100%;
height: 4.375rem;
section {
display: flex;
flex-direction: column;
justify-content: flex-start;
text-align: left;
margin-left: 0.5rem;
}
.username {
margin-bottom: 0.3rem;
font-weight: 400;
line-height: 1.25rem;
color: var(--color-contrast);
}
a {
font-weight: 400;
color: var(--color-secondary);
}
}
.nav-section {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
width: 100%;
height: 100%;
gap: 1rem;
}
.video {
margin-top: 2.25rem;
width: 100vw;
height: calc(100vh - 2.25rem);
object-fit: cover;
border-radius: var(--radius-md);
}
.button-row {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: var(--gap-md);
.transparent {
padding: var(--gap-sm) 0;
}
}
</style>

106
apps/app/src/api/auth.rs Normal file
View File

@@ -0,0 +1,106 @@
use crate::api::Result;
use chrono::{Duration, Utc};
use tauri::plugin::TauriPlugin;
use tauri::{Manager, UserAttentionType};
use theseus::prelude::*;
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::new("auth")
.invoke_handler(tauri::generate_handler![
auth_get_default_user,
auth_set_default_user,
auth_remove_user,
auth_users,
auth_get_user,
])
.build()
}
/// Authenticate a user with Hydra - part 1
/// This begins the authentication flow quasi-synchronously, returning a URL to visit (that the user will sign in at)
#[tauri::command]
pub async fn auth_login(app: tauri::AppHandle) -> Result<Option<Credentials>> {
let flow = minecraft_auth::begin_login().await?;
let start = Utc::now();
if let Some(window) = app.get_window("signin") {
window.close()?;
}
let window = tauri::WindowBuilder::new(
&app,
"signin",
tauri::WindowUrl::External(flow.redirect_uri.parse().map_err(
|_| {
theseus::ErrorKind::OtherError(
"Error parsing auth redirect URL".to_string(),
)
.as_error()
},
)?),
)
.title("Sign into Modrinth")
.always_on_top(true)
.center()
.build()?;
window.request_user_attention(Some(UserAttentionType::Critical))?;
while (Utc::now() - start) < Duration::minutes(10) {
if window.title().is_err() {
// user closed window, cancelling flow
return Ok(None);
}
if window
.url()
.as_str()
.starts_with("https://login.live.com/oauth20_desktop.srf")
{
if let Some((_, code)) =
window.url().query_pairs().find(|x| x.0 == "code")
{
window.close()?;
let val =
minecraft_auth::finish_login(&code.clone(), flow).await?;
return Ok(Some(val));
}
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
window.close()?;
Ok(None)
}
#[tauri::command]
pub async fn auth_remove_user(user: uuid::Uuid) -> Result<()> {
Ok(minecraft_auth::remove_user(user).await?)
}
#[tauri::command]
pub async fn auth_get_default_user() -> Result<Option<uuid::Uuid>> {
Ok(minecraft_auth::get_default_user().await?)
}
#[tauri::command]
pub async fn auth_set_default_user(user: uuid::Uuid) -> Result<()> {
Ok(minecraft_auth::set_default_user(user).await?)
}
/// Get a copy of the list of all user credentials
// invoke('plugin:auth|auth_users',user)
#[tauri::command]
pub async fn auth_users() -> Result<Vec<Credentials>> {
Ok(minecraft_auth::users().await?)
}
/// Get a user from the UUID
/// Prefer to use refresh instead, as it will refresh the credentials as well
// invoke('plugin:auth|auth_users',user)
#[tauri::command]
pub async fn auth_get_user(user: uuid::Uuid) -> Result<Credentials> {
Ok(minecraft_auth::get_user(user).await?)
}

View File

@@ -0,0 +1,71 @@
use std::path::PathBuf;
use crate::api::Result;
use theseus::pack::import::ImportLauncherType;
use theseus::pack::import;
use theseus::prelude::ProfilePathId;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("import")
.invoke_handler(tauri::generate_handler![
import_get_importable_instances,
import_import_instance,
import_is_valid_importable_instance,
import_get_default_launcher_path,
])
.build()
}
/// Gets a list of importable instances from a launcher type and base path
/// eg: get_importable_instances(ImportLauncherType::MultiMC, PathBuf::from("C:/MultiMC"))
/// returns ["Instance 1", "Instance 2"]
#[tauri::command]
pub async fn import_get_importable_instances(
launcher_type: ImportLauncherType,
base_path: PathBuf,
) -> Result<Vec<String>> {
Ok(import::get_importable_instances(launcher_type, base_path).await?)
}
/// Import an instance from a launcher type and base path
/// profile_path should be a blank profile for this purpose- if the function fails, it will be deleted
/// eg: import_instance(ImportLauncherType::MultiMC, PathBuf::from("C:/MultiMC"), "Instance 1")
#[tauri::command]
pub async fn import_import_instance(
profile_path: ProfilePathId,
launcher_type: ImportLauncherType,
base_path: PathBuf,
instance_folder: String,
) -> Result<()> {
import::import_instance(
profile_path,
launcher_type,
base_path,
instance_folder,
)
.await?;
Ok(())
}
/// Checks if this instance is valid for importing, given a certain launcher type
/// eg: is_valid_importable_instance(PathBuf::from("C:/MultiMC/Instance 1"), ImportLauncherType::MultiMC)
#[tauri::command]
pub async fn import_is_valid_importable_instance(
instance_folder: PathBuf,
launcher_type: ImportLauncherType,
) -> Result<bool> {
Ok(
import::is_valid_importable_instance(instance_folder, launcher_type)
.await,
)
}
/// Returns the default path for the given launcher type
/// None if it can't be found or doesn't exist
#[tauri::command]
pub async fn import_get_default_launcher_path(
launcher_type: ImportLauncherType,
) -> Result<Option<PathBuf>> {
Ok(import::get_default_launcher_path(launcher_type))
}

51
apps/app/src/api/jre.rs Normal file
View File

@@ -0,0 +1,51 @@
use std::path::PathBuf;
use crate::api::Result;
use tauri::plugin::TauriPlugin;
use theseus::prelude::JavaVersion;
use theseus::prelude::*;
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::new("jre")
.invoke_handler(tauri::generate_handler![
jre_find_filtered_jres,
jre_get_jre,
jre_test_jre,
jre_auto_install_java,
jre_get_max_memory,
])
.build()
}
// Finds the installation of Java 8, if it exists
#[tauri::command]
pub async fn jre_find_filtered_jres(
version: Option<u32>,
) -> Result<Vec<JavaVersion>> {
Ok(jre::find_filtered_jres(version).await?)
}
// Validates JRE at a given path
// Returns None if the path is not a valid JRE
#[tauri::command]
pub async fn jre_get_jre(path: PathBuf) -> Result<Option<JavaVersion>> {
jre::check_jre(path).await.map_err(|e| e.into())
}
// Tests JRE of a certain version
#[tauri::command]
pub async fn jre_test_jre(path: PathBuf, major_version: u32) -> Result<bool> {
Ok(jre::test_jre(path, major_version).await?)
}
// Auto installs java for the given java version
#[tauri::command]
pub async fn jre_auto_install_java(java_version: u32) -> Result<PathBuf> {
Ok(jre::auto_install_java(java_version).await?)
}
// Gets the maximum memory a system has available.
#[tauri::command]
pub async fn jre_get_max_memory() -> Result<u64> {
Ok(jre::get_max_memory().await?)
}

102
apps/app/src/api/logs.rs Normal file
View File

@@ -0,0 +1,102 @@
use crate::api::Result;
use theseus::logs::LogType;
use theseus::{
logs::{self, CensoredString, LatestLogCursor, Logs},
prelude::ProfilePathId,
};
/*
A log is a struct containing the filename string, stdout, and stderr, as follows:
pub struct Logs {
pub filename: String,
pub stdout: String,
pub stderr: String,
}
*/
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("logs")
.invoke_handler(tauri::generate_handler![
logs_get_logs,
logs_get_logs_by_filename,
logs_get_output_by_filename,
logs_delete_logs,
logs_delete_logs_by_filename,
logs_get_latest_log_cursor,
])
.build()
}
/// Get all Logs for a profile, sorted by filename
#[tauri::command]
pub async fn logs_get_logs(
profile_path: ProfilePathId,
clear_contents: Option<bool>,
) -> Result<Vec<Logs>> {
let val = logs::get_logs(profile_path, clear_contents).await?;
Ok(val)
}
/// Get a Log struct for a profile by profile id and filename string
#[tauri::command]
pub async fn logs_get_logs_by_filename(
profile_path: ProfilePathId,
log_type: LogType,
filename: String,
) -> Result<Logs> {
Ok(logs::get_logs_by_filename(profile_path, log_type, filename).await?)
}
/// Get the stdout for a profile by profile id and filename string
#[tauri::command]
pub async fn logs_get_output_by_filename(
profile_path: ProfilePathId,
log_type: LogType,
filename: String,
) -> Result<CensoredString> {
let profile_path = if let Some(p) =
crate::profile::get(&profile_path, None).await?
{
p.profile_id()
} else {
return Err(theseus::Error::from(
theseus::ErrorKind::UnmanagedProfileError(profile_path.to_string()),
)
.into());
};
Ok(
logs::get_output_by_filename(&profile_path, log_type, &filename)
.await?,
)
}
/// Delete all logs for a profile by profile id
#[tauri::command]
pub async fn logs_delete_logs(profile_path: ProfilePathId) -> Result<()> {
Ok(logs::delete_logs(profile_path).await?)
}
/// Delete a log for a profile by profile id and filename string
#[tauri::command]
pub async fn logs_delete_logs_by_filename(
profile_path: ProfilePathId,
log_type: LogType,
filename: String,
) -> Result<()> {
Ok(
logs::delete_logs_by_filename(profile_path, log_type, &filename)
.await?,
)
}
/// Get live log from a cursor
#[tauri::command]
pub async fn logs_get_latest_log_cursor(
profile_path: ProfilePathId,
cursor: u64, // 0 to start at beginning of file
) -> Result<LatestLogCursor> {
Ok(logs::get_latest_log_cursor(profile_path, cursor).await?)
}

View File

@@ -0,0 +1,45 @@
use crate::api::Result;
use daedalus::minecraft::VersionManifest;
use daedalus::modded::Manifest;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("metadata")
.invoke_handler(tauri::generate_handler![
metadata_get_game_versions,
metadata_get_fabric_versions,
metadata_get_forge_versions,
metadata_get_quilt_versions,
metadata_get_neoforge_versions,
])
.build()
}
/// Gets the game versions from daedalus
#[tauri::command]
pub async fn metadata_get_game_versions() -> Result<VersionManifest> {
Ok(theseus::metadata::get_minecraft_versions().await?)
}
/// Gets the fabric versions from daedalus
#[tauri::command]
pub async fn metadata_get_fabric_versions() -> Result<Manifest> {
Ok(theseus::metadata::get_fabric_versions().await?)
}
/// Gets the forge versions from daedalus
#[tauri::command]
pub async fn metadata_get_forge_versions() -> Result<Manifest> {
Ok(theseus::metadata::get_forge_versions().await?)
}
/// Gets the quilt versions from daedalus
#[tauri::command]
pub async fn metadata_get_quilt_versions() -> Result<Manifest> {
Ok(theseus::metadata::get_quilt_versions().await?)
}
/// Gets the quilt versions from daedalus
#[tauri::command]
pub async fn metadata_get_neoforge_versions() -> Result<Manifest> {
Ok(theseus::metadata::get_neoforge_versions().await?)
}

102
apps/app/src/api/mod.rs Normal file
View File

@@ -0,0 +1,102 @@
use serde::ser::SerializeStruct;
use serde::{Serialize, Serializer};
use thiserror::Error;
pub mod auth;
pub mod import;
pub mod jre;
pub mod logs;
pub mod metadata;
pub mod mr_auth;
pub mod pack;
pub mod process;
pub mod profile;
pub mod profile_create;
pub mod settings;
pub mod tags;
pub mod utils;
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
// // Main returnable Theseus GUI error
// // Needs to be Serializable to be returned to the JavaScript side
// #[derive(Error, Debug, Serialize)]
// pub enum TheseusGuiError {
// #[error(transparent)]
// Serializable(),
// }
// Serializable error intermediary, so TheseusGuiError can be Serializable (eg: so that we can return theseus::Errors in Tauri directly)
#[derive(Error, Debug)]
pub enum TheseusSerializableError {
#[error("{0}")]
Theseus(#[from] theseus::Error),
#[error("IO error: {0}")]
IO(#[from] std::io::Error),
#[error("Tauri error: {0}")]
Tauri(#[from] tauri::Error),
#[cfg(target_os = "macos")]
#[error("Callback error: {0}")]
Callback(String),
}
// Generic implementation of From<T> for ErrorTypeA
// impl<T> From<T> for TheseusGuiError
// where
// TheseusSerializableError: From<T>,
// {
// fn from(error: T) -> Self {
// TheseusGuiError::Serializable(TheseusSerializableError::from(error))
// }
// }
// This is a very simple macro that implements a very basic Serializable for each variant of TheseusSerializableError,
// where the field is the string. (This allows easy extension to errors without many match arms)
macro_rules! impl_serialize {
($($variant:ident),* $(,)?) => {
impl Serialize for TheseusSerializableError {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
// For the Theseus variant, we add a special display for the error,
// to view the spans if subscribed to them (which is information that is lost when serializing)
TheseusSerializableError::Theseus(theseus_error) => {
$crate::error::display_tracing_error(theseus_error);
let mut state = serializer.serialize_struct("Theseus", 2)?;
state.serialize_field("field_name", "Theseus")?;
state.serialize_field("message", &theseus_error.to_string())?;
state.end()
}
$(
TheseusSerializableError::$variant(message) => {
let mut state = serializer.serialize_struct(stringify!($variant), 2)?;
state.serialize_field("field_name", stringify!($variant))?;
state.serialize_field("message", &message.to_string())?;
state.end()
},
)*
}
}
}
};
}
// Use the macro to implement Serialize for TheseusSerializableError
#[cfg(target_os = "macos")]
impl_serialize! {
IO,
Tauri,
Callback
}
#[cfg(not(target_os = "macos"))]
impl_serialize! {
IO,
Tauri,
}

View File

@@ -0,0 +1,82 @@
use crate::api::Result;
use tauri::plugin::TauriPlugin;
use theseus::prelude::*;
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::new("mr_auth")
.invoke_handler(tauri::generate_handler![
authenticate_begin_flow,
authenticate_await_completion,
cancel_flow,
login_pass,
login_2fa,
create_account,
refresh,
logout,
get,
])
.build()
}
#[tauri::command]
pub async fn authenticate_begin_flow(provider: &str) -> Result<String> {
Ok(theseus::mr_auth::authenticate_begin_flow(provider).await?)
}
#[tauri::command]
pub async fn authenticate_await_completion() -> Result<ModrinthCredentialsResult>
{
Ok(theseus::mr_auth::authenticate_await_complete_flow().await?)
}
#[tauri::command]
pub async fn cancel_flow() -> Result<()> {
Ok(theseus::mr_auth::cancel_flow().await?)
}
#[tauri::command]
pub async fn login_pass(
username: &str,
password: &str,
challenge: &str,
) -> Result<ModrinthCredentialsResult> {
Ok(theseus::mr_auth::login_password(username, password, challenge).await?)
}
#[tauri::command]
pub async fn login_2fa(code: &str, flow: &str) -> Result<ModrinthCredentials> {
Ok(theseus::mr_auth::login_2fa(code, flow).await?)
}
#[tauri::command]
pub async fn create_account(
username: &str,
email: &str,
password: &str,
challenge: &str,
sign_up_newsletter: bool,
) -> Result<ModrinthCredentials> {
Ok(theseus::mr_auth::create_account(
username,
email,
password,
challenge,
sign_up_newsletter,
)
.await?)
}
#[tauri::command]
pub async fn refresh() -> Result<()> {
Ok(theseus::mr_auth::refresh().await?)
}
#[tauri::command]
pub async fn logout() -> Result<()> {
Ok(theseus::mr_auth::logout().await?)
}
#[tauri::command]
pub async fn get() -> Result<Option<ModrinthCredentials>> {
Ok(theseus::mr_auth::get_credentials().await?)
}

33
apps/app/src/api/pack.rs Normal file
View File

@@ -0,0 +1,33 @@
use crate::api::Result;
use theseus::{
pack::{
install_from::{CreatePackLocation, CreatePackProfile},
install_mrpack::install_zipped_mrpack,
},
prelude::*,
};
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("pack")
.invoke_handler(tauri::generate_handler![
pack_install,
pack_get_profile_from_pack,
])
.build()
}
#[tauri::command]
pub async fn pack_install(
location: CreatePackLocation,
profile: ProfilePathId,
) -> Result<ProfilePathId> {
Ok(install_zipped_mrpack(location, profile).await?)
}
#[tauri::command]
pub fn pack_get_profile_from_pack(
location: CreatePackLocation,
) -> Result<CreatePackProfile> {
Ok(pack::install_from::get_profile_from_pack(location))
}

View File

@@ -0,0 +1,78 @@
use crate::api::Result;
use theseus::prelude::*;
use uuid::Uuid;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("process")
.invoke_handler(tauri::generate_handler![
process_has_finished_by_uuid,
process_get_exit_status_by_uuid,
process_get_all_uuids,
process_get_all_running_uuids,
process_get_uuids_by_profile_path,
process_get_all_running_profile_paths,
process_get_all_running_profiles,
process_kill_by_uuid,
process_wait_for_by_uuid,
])
.build()
}
// Checks if a process has finished by process UUID
#[tauri::command]
pub async fn process_has_finished_by_uuid(uuid: Uuid) -> Result<bool> {
Ok(process::has_finished_by_uuid(uuid).await?)
}
// Gets process exit status by process UUID
#[tauri::command]
pub async fn process_get_exit_status_by_uuid(
uuid: Uuid,
) -> Result<Option<i32>> {
Ok(process::get_exit_status_by_uuid(uuid).await?)
}
// Gets all process UUIDs
#[tauri::command]
pub async fn process_get_all_uuids() -> Result<Vec<Uuid>> {
Ok(process::get_all_uuids().await?)
}
// Gets all running process UUIDs
#[tauri::command]
pub async fn process_get_all_running_uuids() -> Result<Vec<Uuid>> {
Ok(process::get_all_running_uuids().await?)
}
// Gets all process UUIDs by profile path
#[tauri::command]
pub async fn process_get_uuids_by_profile_path(
profile_path: ProfilePathId,
) -> Result<Vec<Uuid>> {
Ok(process::get_uuids_by_profile_path(profile_path).await?)
}
// Gets the Profile paths of each *running* stored process in the state
#[tauri::command]
pub async fn process_get_all_running_profile_paths(
) -> Result<Vec<ProfilePathId>> {
Ok(process::get_all_running_profile_paths().await?)
}
// Gets the Profiles (cloned) of each *running* stored process in the state
#[tauri::command]
pub async fn process_get_all_running_profiles() -> Result<Vec<Profile>> {
Ok(process::get_all_running_profiles().await?)
}
// Kill a process by process UUID
#[tauri::command]
pub async fn process_kill_by_uuid(uuid: Uuid) -> Result<()> {
Ok(process::kill_by_uuid(uuid).await?)
}
// Wait for a process to finish by process UUID
#[tauri::command]
pub async fn process_wait_for_by_uuid(uuid: Uuid) -> Result<()> {
Ok(process::wait_for_by_uuid(uuid).await?)
}

358
apps/app/src/api/profile.rs Normal file
View File

@@ -0,0 +1,358 @@
use crate::api::Result;
use daedalus::modded::LoaderVersion;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use theseus::{prelude::*, InnerProjectPathUnix};
use uuid::Uuid;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("profile")
.invoke_handler(tauri::generate_handler![
profile_remove,
profile_get,
profile_get_optimal_jre_key,
profile_get_full_path,
profile_get_mod_full_path,
profile_list,
profile_check_installed,
profile_install,
profile_update_all,
profile_update_project,
profile_add_project_from_version,
profile_add_project_from_path,
profile_toggle_disable_project,
profile_remove_project,
profile_update_managed_modrinth_version,
profile_repair_managed_modrinth,
profile_run,
profile_run_wait,
profile_run_credentials,
profile_run_wait_credentials,
profile_edit,
profile_edit_icon,
profile_export_mrpack,
profile_get_pack_export_candidates,
])
.build()
}
// Remove a profile
// invoke('plugin:profile|profile_add_path',path)
#[tauri::command]
pub async fn profile_remove(path: ProfilePathId) -> Result<()> {
profile::remove(&path).await?;
Ok(())
}
// Get a profile by path
// invoke('plugin:profile|profile_add_path',path)
#[tauri::command]
pub async fn profile_get(
path: ProfilePathId,
clear_projects: Option<bool>,
) -> Result<Option<Profile>> {
let res = profile::get(&path, clear_projects).await?;
Ok(res)
}
// Get a profile's full path
// invoke('plugin:profile|profile_get_full_path',path)
#[tauri::command]
pub async fn profile_get_full_path(path: ProfilePathId) -> Result<PathBuf> {
let res = profile::get_full_path(&path).await?;
Ok(res)
}
// Get's a mod's full path
// invoke('plugin:profile|profile_get_mod_full_path',path)
#[tauri::command]
pub async fn profile_get_mod_full_path(
path: ProfilePathId,
project_path: ProjectPathId,
) -> Result<PathBuf> {
let res = profile::get_mod_full_path(&path, &project_path).await?;
Ok(res)
}
// Get optimal java version from profile
#[tauri::command]
pub async fn profile_get_optimal_jre_key(
path: ProfilePathId,
) -> Result<Option<JavaVersion>> {
let res = profile::get_optimal_jre_key(&path).await?;
Ok(res)
}
// Get a copy of the profile set
// invoke('plugin:profile|profile_list')
#[tauri::command]
pub async fn profile_list(
clear_projects: Option<bool>,
) -> Result<HashMap<ProfilePathId, Profile>> {
let res = profile::list(clear_projects).await?;
Ok(res)
}
#[tauri::command]
pub async fn profile_check_installed(
path: ProfilePathId,
project_id: String,
) -> Result<bool> {
let profile = profile_get(path, None).await?;
if let Some(profile) = profile {
Ok(profile.projects.into_iter().any(|(_, project)| {
if let ProjectMetadata::Modrinth { project, .. } = &project.metadata
{
project.id == project_id
} else {
false
}
}))
} else {
Ok(false)
}
}
/// Installs/Repairs a profile
/// invoke('plugin:profile|profile_install')
#[tauri::command]
pub async fn profile_install(path: ProfilePathId, force: bool) -> Result<()> {
profile::install(&path, force).await?;
Ok(())
}
/// Updates all of the profile's projects
/// invoke('plugin:profile|profile_update_all')
#[tauri::command]
pub async fn profile_update_all(
path: ProfilePathId,
) -> Result<HashMap<ProjectPathId, ProjectPathId>> {
Ok(profile::update_all_projects(&path).await?)
}
/// Updates a specified project
/// invoke('plugin:profile|profile_update_project')
#[tauri::command]
pub async fn profile_update_project(
path: ProfilePathId,
project_path: ProjectPathId,
) -> Result<ProjectPathId> {
Ok(profile::update_project(&path, &project_path, None).await?)
}
// Adds a project to a profile from a version ID
// invoke('plugin:profile|profile_add_project_from_version')
#[tauri::command]
pub async fn profile_add_project_from_version(
path: ProfilePathId,
version_id: String,
) -> Result<ProjectPathId> {
Ok(profile::add_project_from_version(&path, version_id).await?)
}
// Adds a project to a profile from a path
// invoke('plugin:profile|profile_add_project_from_path')
#[tauri::command]
pub async fn profile_add_project_from_path(
path: ProfilePathId,
project_path: &Path,
project_type: Option<String>,
) -> Result<ProjectPathId> {
let res = profile::add_project_from_path(&path, project_path, project_type)
.await?;
Ok(res)
}
// Toggles disabling a project from its path
// invoke('plugin:profile|profile_toggle_disable_project')
#[tauri::command]
pub async fn profile_toggle_disable_project(
path: ProfilePathId,
project_path: ProjectPathId,
) -> Result<ProjectPathId> {
Ok(profile::toggle_disable_project(&path, &project_path).await?)
}
// Removes a project from a profile
// invoke('plugin:profile|profile_remove_project')
#[tauri::command]
pub async fn profile_remove_project(
path: ProfilePathId,
project_path: ProjectPathId,
) -> Result<()> {
profile::remove_project(&path, &project_path).await?;
Ok(())
}
// Updates a managed Modrinth profile to a version of version_id
#[tauri::command]
pub async fn profile_update_managed_modrinth_version(
path: ProfilePathId,
version_id: String,
) -> Result<()> {
Ok(
profile::update::update_managed_modrinth_version(&path, &version_id)
.await?,
)
}
// Repairs a managed Modrinth profile by updating it to the current version
#[tauri::command]
pub async fn profile_repair_managed_modrinth(
path: ProfilePathId,
) -> Result<()> {
Ok(profile::update::repair_managed_modrinth(&path).await?)
}
// Exports a profile to a .mrpack file (export_location should end in .mrpack)
// invoke('profile_export_mrpack')
#[tauri::command]
pub async fn profile_export_mrpack(
path: ProfilePathId,
export_location: PathBuf,
included_overrides: Vec<String>,
version_id: Option<String>,
description: Option<String>,
name: Option<String>, // only used to cache
) -> Result<()> {
profile::export_mrpack(
&path,
export_location,
included_overrides,
version_id,
description,
name,
)
.await?;
Ok(())
}
/// See [`profile::get_pack_export_candidates`]
#[tauri::command]
pub async fn profile_get_pack_export_candidates(
profile_path: ProfilePathId,
) -> Result<Vec<InnerProjectPathUnix>> {
let candidates = profile::get_pack_export_candidates(&profile_path).await?;
Ok(candidates)
}
// Run minecraft using a profile using the default credentials
// Returns the UUID, which can be used to poll
// for the actual Child in the state.
// invoke('plugin:profile|profile_run', path)
#[tauri::command]
pub async fn profile_run(path: ProfilePathId) -> Result<Uuid> {
let minecraft_child = profile::run(&path).await?;
let uuid = minecraft_child.read().await.uuid;
Ok(uuid)
}
// Run Minecraft using a profile using the default credentials, and wait for the result
// invoke('plugin:profile|profile_run_wait', path)
#[tauri::command]
pub async fn profile_run_wait(path: ProfilePathId) -> Result<()> {
let proc_lock = profile::run(&path).await?;
let mut proc = proc_lock.write().await;
Ok(process::wait_for(&mut proc).await?)
}
// Run Minecraft using a profile using chosen credentials
// Returns the UUID, which can be used to poll
// for the actual Child in the state.
// invoke('plugin:profile|profile_run_credentials', {path, credentials})')
#[tauri::command]
pub async fn profile_run_credentials(
path: ProfilePathId,
credentials: Credentials,
) -> Result<Uuid> {
let minecraft_child = profile::run_credentials(&path, &credentials).await?;
let uuid = minecraft_child.read().await.uuid;
Ok(uuid)
}
// Run Minecraft using a profile using the chosen credentials, and wait for the result
// invoke('plugin:profile|profile_run_wait', {path, credentials)
#[tauri::command]
pub async fn profile_run_wait_credentials(
path: ProfilePathId,
credentials: Credentials,
) -> Result<()> {
let proc_lock = profile::run_credentials(&path, &credentials).await?;
let mut proc = proc_lock.write().await;
Ok(process::wait_for(&mut proc).await?)
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct EditProfile {
pub metadata: Option<EditProfileMetadata>,
pub java: Option<JavaSettings>,
pub memory: Option<MemorySettings>,
pub resolution: Option<WindowSize>,
pub hooks: Option<Hooks>,
pub fullscreen: Option<bool>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct EditProfileMetadata {
pub name: Option<String>,
pub game_version: Option<String>,
pub loader: Option<ModLoader>,
pub loader_version: Option<LoaderVersion>,
pub linked_data: Option<LinkedData>,
pub groups: Option<Vec<String>>,
}
// Edits a profile
// invoke('plugin:profile|profile_edit', {path, editProfile})
#[tauri::command]
pub async fn profile_edit(
path: ProfilePathId,
edit_profile: EditProfile,
) -> Result<()> {
profile::edit(&path, |prof| {
if let Some(metadata) = edit_profile.metadata.clone() {
if let Some(name) = metadata.name {
prof.metadata.name = name;
}
if let Some(game_version) = metadata.game_version {
prof.metadata.game_version = game_version;
}
if let Some(loader) = metadata.loader {
prof.metadata.loader = loader;
}
prof.metadata.loader_version = metadata.loader_version;
prof.metadata.linked_data = metadata.linked_data;
if let Some(groups) = metadata.groups {
prof.metadata.groups = groups;
}
}
prof.java.clone_from(&edit_profile.java);
prof.memory = edit_profile.memory;
prof.resolution = edit_profile.resolution;
prof.fullscreen = edit_profile.fullscreen;
prof.hooks.clone_from(&edit_profile.hooks);
prof.metadata.date_modified = chrono::Utc::now();
async { Ok(()) }
})
.await?;
State::sync().await?;
Ok(())
}
// Edits a profile's icon
// invoke('plugin:profile|profile_edit_icon')
#[tauri::command]
pub async fn profile_edit_icon(
path: ProfilePathId,
icon_path: Option<&Path>,
) -> Result<()> {
profile::edit_icon(&path, icon_path).await?;
Ok(())
}

View File

@@ -0,0 +1,46 @@
use crate::api::Result;
use std::path::PathBuf;
use theseus::prelude::*;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("profile_create")
.invoke_handler(tauri::generate_handler![
profile_create,
profile_duplicate
])
.build()
}
// Creates a profile at the given filepath and adds it to the in-memory state
// invoke('plugin:profile_create|profile_add',profile)
#[tauri::command]
pub async fn profile_create(
name: String, // the name of the profile, and relative path
game_version: String, // the game version of the profile
modloader: ModLoader, // the modloader to use
loader_version: Option<String>, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
icon: Option<PathBuf>, // the icon for the profile
no_watch: Option<bool>,
) -> Result<ProfilePathId> {
let res = profile::create::profile_create(
name,
game_version,
modloader,
loader_version,
icon,
None,
None,
None,
no_watch,
)
.await?;
Ok(res)
}
// Creates a profile from a duplicate
// invoke('plugin:profile_create|profile_duplicate',profile)
#[tauri::command]
pub async fn profile_duplicate(path: ProfilePathId) -> Result<ProfilePathId> {
let res = profile::create::profile_create_from_duplicate(path).await?;
Ok(res)
}

View File

@@ -0,0 +1,48 @@
use std::path::PathBuf;
use crate::api::Result;
use theseus::prelude::*;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("settings")
.invoke_handler(tauri::generate_handler![
settings_get,
settings_set,
settings_change_config_dir,
settings_is_dir_writeable
])
.build()
}
// Get full settings
// invoke('plugin:settings|settings_get')
#[tauri::command]
pub async fn settings_get() -> Result<Settings> {
let res = settings::get().await?;
Ok(res)
}
// Set full settings
// invoke('plugin:settings|settings_set', settings)
#[tauri::command]
pub async fn settings_set(settings: Settings) -> Result<()> {
settings::set(settings).await?;
Ok(())
}
// Change config directory
// Seizes the entire State to do it
// invoke('plugin:settings|settings_change_config_dir', new_dir)
#[tauri::command]
pub async fn settings_change_config_dir(new_config_dir: PathBuf) -> Result<()> {
settings::set_config_dir(new_config_dir).await?;
Ok(())
}
#[tauri::command]
pub async fn settings_is_dir_writeable(
new_config_dir: PathBuf,
) -> Result<bool> {
let res = settings::is_dir_writeable(new_config_dir).await?;
Ok(res)
}

51
apps/app/src/api/tags.rs Normal file
View File

@@ -0,0 +1,51 @@
use crate::api::Result;
use theseus::tags::{Category, DonationPlatform, GameVersion, Loader, Tags};
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("tags")
.invoke_handler(tauri::generate_handler![
tags_get_categories,
tags_get_report_types,
tags_get_loaders,
tags_get_game_versions,
tags_get_donation_platforms,
tags_get_tag_bundle,
])
.build()
}
/// Gets cached category tags from the database
#[tauri::command]
pub async fn tags_get_categories() -> Result<Vec<Category>> {
Ok(theseus::tags::get_category_tags().await?)
}
/// Gets cached report type tags from the database
#[tauri::command]
pub async fn tags_get_report_types() -> Result<Vec<String>> {
Ok(theseus::tags::get_report_type_tags().await?)
}
/// Gets cached loader tags from the database
#[tauri::command]
pub async fn tags_get_loaders() -> Result<Vec<Loader>> {
Ok(theseus::tags::get_loader_tags().await?)
}
/// Gets cached game version tags from the database
#[tauri::command]
pub async fn tags_get_game_versions() -> Result<Vec<GameVersion>> {
Ok(theseus::tags::get_game_version_tags().await?)
}
/// Gets cached donation platform tags from the database
#[tauri::command]
pub async fn tags_get_donation_platforms() -> Result<Vec<DonationPlatform>> {
Ok(theseus::tags::get_donation_platform_tags().await?)
}
/// Gets cached tag bundle from the database
#[tauri::command]
pub async fn tags_get_tag_bundle() -> Result<Tags> {
Ok(theseus::tags::get_tag_bundle().await?)
}

191
apps/app/src/api/utils.rs Normal file
View File

@@ -0,0 +1,191 @@
use serde::{Deserialize, Serialize};
use theseus::{
handler,
prelude::{CommandPayload, DirectoryInfo},
State,
};
use crate::api::Result;
use std::{env, path::PathBuf, process::Command};
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("utils")
.invoke_handler(tauri::generate_handler![
get_os,
should_disable_mouseover,
show_in_folder,
show_launcher_logs_folder,
progress_bars_list,
safety_check_safe_loading_bars,
get_opening_command,
await_sync,
is_offline,
refresh_offline
])
.build()
}
/// Gets OS
#[tauri::command]
pub fn get_os() -> OS {
#[cfg(target_os = "windows")]
let os = OS::Windows;
#[cfg(target_os = "linux")]
let os = OS::Linux;
#[cfg(target_os = "macos")]
let os = OS::MacOS;
os
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::enum_variant_names)]
pub enum OS {
Windows,
Linux,
MacOS,
}
// Lists active progress bars
// Create a new HashMap with the same keys
// Values provided should not be used directly, as they are not guaranteed to be up-to-date
#[tauri::command]
pub async fn progress_bars_list(
) -> Result<std::collections::HashMap<uuid::Uuid, theseus::LoadingBar>> {
let res = theseus::EventState::list_progress_bars().await?;
Ok(res)
}
// Check if there are any safe loading bars running
#[tauri::command]
pub async fn safety_check_safe_loading_bars() -> Result<bool> {
Ok(theseus::safety::check_safe_loading_bars().await?)
}
// cfg only on mac os
// disables mouseover and fixes a random crash error only fixed by recent versions of macos
#[cfg(target_os = "macos")]
#[tauri::command]
pub async fn should_disable_mouseover() -> bool {
// We try to match version to 12.2 or higher. If unrecognizable to pattern or lower, we default to the css with disabled mouseover for safety
let os = os_info::get();
if let os_info::Version::Semantic(major, minor, _) = os.version() {
if *major >= 12 && *minor >= 3 {
// Mac os version is 12.3 or higher, we allow mouseover
return false;
}
}
true
}
#[cfg(not(target_os = "macos"))]
#[tauri::command]
pub async fn should_disable_mouseover() -> bool {
false
}
#[tauri::command]
pub fn show_in_folder(path: PathBuf) -> Result<()> {
{
#[cfg(target_os = "windows")]
{
if path.is_dir() {
Command::new("explorer")
.args([&path]) // The comma after select is not a typo
.spawn()?;
} else {
Command::new("explorer")
.args(["/select,", &path.to_string_lossy()]) // The comma after select is not a typo
.spawn()?;
}
}
#[cfg(target_os = "linux")]
{
use std::fs::metadata;
if path.to_string_lossy().to_string().contains(',') {
// see https://gitlab.freedesktop.org/dbus/dbus/-/issues/76
let new_path = match metadata(&path)?.is_dir() {
true => path,
false => {
let mut path2 = path.clone();
path2.pop();
path2
}
};
Command::new("xdg-open").arg(&new_path).spawn()?;
} else {
Command::new("xdg-open").arg(&path).spawn()?;
}
}
#[cfg(target_os = "macos")]
{
if path.is_dir() {
Command::new("open").args([&path]).spawn()?;
} else {
Command::new("open")
.args(["-R", &path.as_os_str().to_string_lossy()])
.spawn()?;
}
}
Ok::<(), theseus::Error>(())
}?;
Ok(())
}
#[tauri::command]
pub fn show_launcher_logs_folder() -> Result<()> {
let path = DirectoryInfo::launcher_logs_dir().unwrap_or_default();
// failure to get folder just opens filesystem
// (ie: if in debug mode only and launcher_logs never created)
show_in_folder(path)
}
// Get opening command
// For example, if a user clicks on an .mrpack to open the app.
// This should be called once and only when the app is done booting up and ready to receive a command
// Returns a Command struct- see events.js
#[tauri::command]
pub async fn get_opening_command() -> Result<Option<CommandPayload>> {
// Tauri is not CLI, we use arguments as path to file to call
let cmd_arg = env::args_os().nth(1);
let cmd_arg = cmd_arg.map(|path| path.to_string_lossy().to_string());
if let Some(cmd) = cmd_arg {
tracing::debug!("Opening command: {:?}", cmd);
return Ok(Some(handler::parse_command(&cmd).await?));
}
Ok(None)
}
// helper function called when redirected by a weblink (ie: modrith://do-something) or when redirected by a .mrpack file (in which case its a filepath)
// We hijack the deep link library (which also contains functionality for instance-checking)
pub async fn handle_command(command: String) -> Result<()> {
Ok(theseus::handler::parse_and_emit_command(&command).await?)
}
// Waits for state to be synced
#[tauri::command]
pub async fn await_sync() -> Result<()> {
State::sync().await?;
tracing::debug!("State synced");
Ok(())
}
/// Check if theseus is currently in offline mode, without a refresh attempt
#[tauri::command]
pub async fn is_offline() -> Result<bool> {
let state = State::get().await?;
let offline = *state.offline.read().await;
Ok(offline)
}
/// Refreshes whether or not theseus is in offline mode, and returns the new value
#[tauri::command]
pub async fn refresh_offline() -> Result<bool> {
let state = State::get().await?;
state.refresh_offline().await?;
let offline = *state.offline.read().await;
Ok(offline)
}

View File

@@ -1 +0,0 @@
<svg viewBox="0 0 2084 2084" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2"><g fill-rule="nonzero"><path d="M1041.67 81.38l272.437 159.032-825.246 478.685-272.438-157.971L1041.67 81.38zm87.28 371.074l274.024-159.032 463.937 271.945-276.14 153.73-461.821-266.643z" fill="#3b3b3b"/><path d="M216.42 561.126v961.081l825.247 479.746V1684.95l-551.222-321.774-1.587-644.079L216.42 561.126z" fill="#2e2e2e"/><path d="M1866.91 1517.97l-825.246 483.986v-317.003l550.164-320.714-1.058-645.139 276.14-153.73v952.6z" fill="#333"/><path d="M1590.77 719.097l-549.106 310.112v165.393l214.246-122.984v488.757l138.599-81.106V989.451l196.261-115.563V719.097z" fill="#89c236"/><path d="M488.858 719.097l1.587 644.079 152.353 90.118v-198.79l230.645 132.527v199.319l168.753 98.6v-655.741L488.858 719.097zm383.527 531.166l-227.471-131.466v-150.02l227.471 127.225v154.261z" fill="#7baf31"/></g></svg>

Before

Width:  |  Height:  |  Size: 952 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -1,10 +0,0 @@
<svg width="71" height="55" viewBox="0 0 71 55" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path d="M60.1045 4.8978C55.5792 2.8214 50.7265 1.2916 45.6527 0.41542C45.5603 0.39851 45.468 0.440769 45.4204 0.525289C44.7963 1.6353 44.105 3.0834 43.6209 4.2216C38.1637 3.4046 32.7345 3.4046 27.3892 4.2216C26.905 3.0581 26.1886 1.6353 25.5617 0.525289C25.5141 0.443589 25.4218 0.40133 25.3294 0.41542C20.2584 1.2888 15.4057 2.8186 10.8776 4.8978C10.8384 4.9147 10.8048 4.9429 10.7825 4.9795C1.57795 18.7309 -0.943561 32.1443 0.293408 45.3914C0.299005 45.4562 0.335386 45.5182 0.385761 45.5576C6.45866 50.0174 12.3413 52.7249 18.1147 54.5195C18.2071 54.5477 18.305 54.5139 18.3638 54.4378C19.7295 52.5728 20.9469 50.6063 21.9907 48.5383C22.0523 48.4172 21.9935 48.2735 21.8676 48.2256C19.9366 47.4931 18.0979 46.6 16.3292 45.5858C16.1893 45.5041 16.1781 45.304 16.3068 45.2082C16.679 44.9293 17.0513 44.6391 17.4067 44.3461C17.471 44.2926 17.5606 44.2813 17.6362 44.3151C29.2558 49.6202 41.8354 49.6202 53.3179 44.3151C53.3935 44.2785 53.4831 44.2898 53.5502 44.3433C53.9057 44.6363 54.2779 44.9293 54.6529 45.2082C54.7816 45.304 54.7732 45.5041 54.6333 45.5858C52.8646 46.6197 51.0259 47.4931 49.0921 48.2228C48.9662 48.2707 48.9102 48.4172 48.9718 48.5383C50.038 50.6034 51.2554 52.5699 52.5959 54.435C52.6519 54.5139 52.7526 54.5477 52.845 54.5195C58.6464 52.7249 64.529 50.0174 70.6019 45.5576C70.6551 45.5182 70.6887 45.459 70.6943 45.3942C72.1747 30.0791 68.2147 16.7757 60.1968 4.9823C60.1772 4.9429 60.1437 4.9147 60.1045 4.8978ZM23.7259 37.3253C20.2276 37.3253 17.3451 34.1136 17.3451 30.1693C17.3451 26.225 20.1717 23.0133 23.7259 23.0133C27.308 23.0133 30.1626 26.2532 30.1066 30.1693C30.1066 34.1136 27.28 37.3253 23.7259 37.3253ZM47.3178 37.3253C43.8196 37.3253 40.9371 34.1136 40.9371 30.1693C40.9371 26.225 43.7636 23.0133 47.3178 23.0133C50.9 23.0133 53.7545 26.2532 53.6986 30.1693C53.6986 34.1136 50.9 37.3253 47.3178 37.3253Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0">
<rect width="71" height="55" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,4 +0,0 @@
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path
d="m16 .396c-8.839 0-16 7.167-16 16 0 7.073 4.584 13.068 10.937 15.183.803.151 1.093-.344 1.093-.772 0-.38-.009-1.385-.015-2.719-4.453.964-5.391-2.151-5.391-2.151-.729-1.844-1.781-2.339-1.781-2.339-1.448-.989.115-.968.115-.968 1.604.109 2.448 1.645 2.448 1.645 1.427 2.448 3.744 1.74 4.661 1.328.14-1.031.557-1.74 1.011-2.135-3.552-.401-7.287-1.776-7.287-7.907 0-1.751.62-3.177 1.645-4.297-.177-.401-.719-2.031.141-4.235 0 0 1.339-.427 4.4 1.641 1.281-.355 2.641-.532 4-.541 1.36.009 2.719.187 4 .541 3.043-2.068 4.381-1.641 4.381-1.641.859 2.204.317 3.833.161 4.235 1.015 1.12 1.635 2.547 1.635 4.297 0 6.145-3.74 7.5-7.296 7.891.556.479 1.077 1.464 1.077 2.959 0 2.14-.02 3.864-.02 4.385 0 .416.28.916 1.104.755 6.4-2.093 10.979-8.093 10.979-15.156 0-8.833-7.161-16-16-16z"/>
</svg>

Before

Width:  |  Height:  |  Size: 901 B

View File

@@ -1,10 +0,0 @@
<svg
data-v-8c2610d6=""
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
fill="currentColor"
viewBox="0 0 380 380"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2;"
>
<path d="m282.83 170.73-.27-.69-26.14-68.22a6.815 6.815 0 0 0-2.69-3.24 7.013 7.013 0 0 0-8 .43 6.996 6.996 0 0 0-2.32 3.52l-17.65 54h-71.47l-17.65-54a6.864 6.864 0 0 0-2.32-3.53 7.013 7.013 0 0 0-8-.43 6.867 6.867 0 0 0-2.69 3.24L97.44 170l-.26.69c-7.708 20.139-1.115 43.113 16.1 56.1l.09.07.24.17 39.82 29.82 19.7 14.91 12 9.06a8.088 8.088 0 0 0 9.76 0l12-9.06 19.7-14.91 40.06-30 .1-.08c17.175-12.988 23.755-35.921 16.08-56.04Z" transform="translate(-186.013 -186.006) scale(1.97904)" style="fill-rule: nonzero;"/>
</svg>

Before

Width:  |  Height:  |  Size: 757 B

View File

@@ -1,22 +0,0 @@
<svg
data-v-8c2610d6=""
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
viewBox="0 0 100 100"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2;"><circle cx="50" cy="50" r="50" style="fill:#fff;"
/>
<g transform="translate(14.39 14.302) scale(.09916)">
<path
d="M-4117.16-2597.44v139.42h193.74c-8.51 44.84-34.04 82.8-72.33 108.33l116.84 90.66c68.07-62.84 107.35-155.13 107.35-264.77 0-25.53-2.29-50.07-6.55-73.63l-339.05-.01Z"
style="fill:#4285f4;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
<path
d="m-4318.92-2463.46-26.35 20.17-93.28 72.65c59.24 117.49 180.65 198.66 321.38 198.66 97.2 0 178.69-32.07 238.25-87.05l-116.83-90.66c-32.08 21.6-72.99 34.69-121.42 34.69-93.6 0-173.13-63.16-201.6-148.25l-.15-.21Z"
style="fill:#34a853;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
<path
d="M-4438.55-2693.33c-24.54 48.44-38.61 103.09-38.61 161.34 0 58.26 14.07 112.91 38.61 161.35 0 .32 119.79-92.95 119.79-92.95-7.2-21.6-11.46-44.5-11.46-68.4 0-23.89 4.26-46.8 11.46-68.4l-119.79-92.94Z"
style="fill:#fbbc05;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
<path
d="M-4117.16-2748.64c53.02 0 100.14 18.33 137.78 53.67l103.09-103.09c-62.51-58.25-143.67-93.93-240.87-93.93-140.73 0-262.15 80.84-321.39 198.66l119.79 92.95c28.47-85.09 108-148.26 201.6-148.26Z"
style="fill:#ea4335;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,18 +0,0 @@
export { default as BuyMeACoffeeIcon } from './bmac.svg'
export { default as DiscordIcon } from './discord.svg'
export { default as KoFiIcon } from './kofi.svg'
export { default as PatreonIcon } from './patreon.svg'
export { default as PaypalIcon } from './paypal.svg'
export { default as OpenCollectiveIcon } from './opencollective.svg'
export { default as TwitterIcon } from './twitter.svg'
export { default as GithubIcon } from './github.svg'
export { default as MastodonIcon } from './mastodon.svg'
export { default as RedditIcon } from './reddit.svg'
export { default as GoogleIcon } from './google.svg'
export { default as MicrosoftIcon } from './microsoft.svg'
export { default as SteamIcon } from './steam.svg'
export { default as GitLabIcon } from './gitlab.svg'
export { default as ATLauncherIcon } from './atlauncher.svg'
export { default as GDLauncherIcon } from './gdlauncher.png'
export { default as MultiMCIcon } from './multimc.webp'
export { default as PrismIcon } from './prism.svg'

View File

@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Ko-fi</title><path d="M23.881 8.948c-.773-4.085-4.859-4.593-4.859-4.593H.723c-.604 0-.679.798-.679.798s-.082 7.324-.022 11.822c.164 2.424 2.586 2.672 2.586 2.672s8.267-.023 11.966-.049c2.438-.426 2.683-2.566 2.658-3.734 4.352.24 7.422-2.831 6.649-6.916zm-11.062 3.511c-1.246 1.453-4.011 3.976-4.011 3.976s-.121.119-.31.023c-.076-.057-.108-.09-.108-.09-.443-.441-3.368-3.049-4.034-3.954-.709-.965-1.041-2.7-.091-3.71.951-1.01 3.005-1.086 4.363.407 0 0 1.565-1.782 3.468-.963 1.904.82 1.832 3.011.723 4.311zm6.173.478c-.928.116-1.682.028-1.682.028V7.284h1.77s1.971.551 1.971 2.638c0 1.913-.985 2.667-2.059 3.015z" fill="currentColor" /></svg>

Before

Width:  |  Height:  |  Size: 718 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M480,173.59c0-104.13-68.26-134.65-68.26-134.65C377.3,23.15,318.2,16.5,256.8,16h-1.51c-61.4.5-120.46,7.15-154.88,22.94,0,0-68.27,30.52-68.27,134.65,0,23.85-.46,52.35.29,82.59C34.91,358,51.11,458.37,145.32,483.29c43.43,11.49,80.73,13.89,110.76,12.24,54.47-3,85-19.42,85-19.42l-1.79-39.5s-38.93,12.27-82.64,10.77c-43.31-1.48-89-4.67-96-57.81a108.44,108.44,0,0,1-1-14.9,558.91,558.91,0,0,0,96.39,12.85c32.95,1.51,63.84-1.93,95.22-5.67,60.18-7.18,112.58-44.24,119.16-78.09C480.84,250.42,480,173.59,480,173.59ZM399.46,307.75h-50V185.38c0-25.8-10.86-38.89-32.58-38.89-24,0-36.06,15.53-36.06,46.24v67H231.16v-67c0-30.71-12-46.24-36.06-46.24-21.72,0-32.58,13.09-32.58,38.89V307.75h-50V181.67q0-38.65,19.75-61.39c13.6-15.15,31.4-22.92,53.51-22.92,25.58,0,44.95,9.82,57.75,29.48L256,147.69l12.45-20.85c12.81-19.66,32.17-29.48,57.75-29.48,22.11,0,39.91,7.77,53.51,22.92Q399.5,143,399.46,181.67Z"/></svg>

Before

Width:  |  Height:  |  Size: 962 B

View File

@@ -1,6 +0,0 @@
<svg data-v-8c2610d6="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21">
<path fill="#f25022" d="M1 1h9v9H1z"/>
<path fill="#00a4ef" d="M1 11h9v9H1z"/>
<path fill="#7fba00" d="M11 1h9v9h-9z"/>
<path fill="#ffb900" d="M11 11h9v9h-9z"/>
</svg>

Before

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Open Collective</title><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12c2.54 0 4.894-.79 6.834-2.135l-3.107-3.109a7.715 7.715 0 1 1 0-13.512l3.107-3.109A11.943 11.943 0 0 0 12 0zm9.865 5.166l-3.109 3.107A7.67 7.67 0 0 1 19.715 12a7.682 7.682 0 0 1-.959 3.727l3.109 3.107A11.943 11.943 0 0 0 24 12c0-2.54-.79-4.894-2.135-6.834z"/></svg>

Before

Width:  |  Height:  |  Size: 415 B

View File

@@ -1 +0,0 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Patreon</title><path d="M0 .48v23.04h4.22V.48zm15.385 0c-4.764 0-8.641 3.88-8.641 8.65 0 4.755 3.877 8.623 8.641 8.623 4.75 0 8.615-3.868 8.615-8.623C24 4.36 20.136.48 15.385.48z" fill="currentColor"/></svg>

Before

Width:  |  Height:  |  Size: 274 B

View File

@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>PayPal</title><path d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.106zm14.146-14.42a3.35 3.35 0 0 0-.607-.541c-.013.076-.026.175-.041.254-.93 4.778-4.005 7.201-9.138 7.201h-2.19a.563.563 0 0 0-.556.479l-1.187 7.527h-.506l-.24 1.516a.56.56 0 0 0 .554.647h3.882c.46 0 .85-.334.922-.788.06-.26.76-4.852.816-5.09a.932.932 0 0 1 .923-.788h.58c3.76 0 6.705-1.528 7.565-5.946.36-1.847.174-3.388-.777-4.471z" fill="currentColor"/></svg>

Before

Width:  |  Height:  |  Size: 706 B

View File

@@ -1,80 +0,0 @@
<svg
viewBox="0 0 12.7 12.7"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<title id="title261">Prism Launcher Logo</title>
<defs id="defs3603" />
<g id="layer1">
<g
id="g531"
transform="matrix(0.1353646,0,0,0.1353646,15.301582,0.52916663)" />
<g
id="g397">
<path
style="fill:#99cd61;fill-opacity:1;stroke-width:0.264583"
d="M 6.3500002,6.350001 Z"
id="path7899" />
<path
id="path3228"
style="fill:#df6277;fill-opacity:1;stroke-width:0.264583"
d="M 6.35 0.52916667 L 3.8292236 4.8947917 L 6.35 6.35 L 8.8702596 4.8947917 L 8.9798136 1.7952393 C 7.828708 1.1306481 6.6410414 0.52916667 6.35 0.52916667 z " />
<path
id="path2659"
style="fill:#fb9168;fill-opacity:1;stroke-width:0.264583"
d="M 8.9798136 1.7952393 L 6.35 6.35 L 8.8702596 7.8052083 L 11.391036 3.4395833 C 11.245515 3.1875341 10.130919 2.4598305 8.9798136 1.7952393 z " />
<path
id="path2708"
style="fill:#f3db6c;fill-opacity:1;stroke-width:0.264583"
d="M 11.391036 3.4395833 L 6.35 6.35 L 8.8702596 7.8052083 L 11.609111 6.35 C 11.609111 5.0208177 11.536557 3.6916326 11.391036 3.4395833 z " />
<path
id="path1737"
style="fill:#7ab392;fill-opacity:1;stroke-width:0.264583"
d="M 6.35 6.35 L 6.35 9.2604167 L 11.391036 9.2604167 C 11.536557 9.0083674 11.60911 7.6791823 11.609111 6.35 L 6.35 6.35 z " />
<path
id="path2937"
style="fill:#4b7cbc;fill-opacity:1;stroke-width:0.264583"
d="M 6.35 6.35 L 6.35 9.2604167 L 8.9798136 10.904761 C 10.130919 10.24017 11.245515 9.5124659 11.391036 9.2604167 L 6.35 6.35 z " />
<path
id="path3117"
style="fill:#6f488c;fill-opacity:1;stroke-width:0.264583"
d="M 6.35 6.35 L 3.8292236 7.8052083 L 6.35 12.170833 C 6.6410414 12.170833 7.8287079 11.569352 8.9798136 10.904761 L 6.35 6.35 z " />
<path
id="path2010"
style="fill:#4d3f33;fill-opacity:1;stroke-width:0.264583"
d="M 3.8292236 4.8947917 L 1.308964 9.2604167 C 1.6000054 9.7645152 5.7679172 12.170833 6.35 12.170833 L 6.35 6.35 L 3.8292236 4.8947917 z " />
<path
id="path1744"
style="fill:#7a573b;fill-opacity:1;stroke-width:0.264583"
d="M 1.308964 3.4395833 C 1.0179226 3.9436818 1.0179227 8.7563182 1.308964 9.2604167 L 6.35 6.35 L 6.35 3.4395833 L 1.308964 3.4395833 z " />
<path
id="path1739"
style="fill:#99cd61;fill-opacity:1;stroke-width:0.264583"
d="M 6.35 0.52916667 C 5.7679172 0.52916665 1.6000054 2.9354849 1.308964 3.4395833 L 6.35 6.35 L 6.35 0.52916667 z " />
<g
id="g379">
<g
id="g1657"
transform="matrix(0.87999988,0,0,0.87999988,-10.906495,-1.242093)">
<g
id="g7651"
transform="translate(13.259961,2.2775894)">
<path
id="path6659"
style="fill:#ffffff;stroke-width:0.264583"
d="m 6.3498163,2.9393223 c -0.3410461,0 -2.782726,1.4098777 -2.9532491,1.7052323 L 6.3498163,9.7602513 9.3035983,4.6445546 C 9.1330753,4.3492 6.6908624,2.9393223 6.3498163,2.9393223 Z"
transform="matrix(0.96974817,0,0,0.96974817,0.19209885,0.19209792)" />
</g>
<path
id="path461"
style="fill:#dfdfdf;fill-opacity:1;stroke-width:0.264583"
d="m 16.745875,6.9737355 2.863908,4.9609385 c 0.330729,0 2.69906,-1.367226 2.864424,-1.653646 0.165365,-0.2864204 0.165365,-3.0208729 0,-3.3072925 l -2.864424,1.6536459 z" />
</g>
<path
id="path5065"
style="fill:#d6d2d2;fill-opacity:1;stroke-width:0.264583"
d="m 3.8298625,4.8947933 c -0.1455111,0.2520549 -0.1455304,2.6583729 0,2.9104166 0.1455304,0.2520438 2.2292181,1.4552195 2.5202596,1.4552084 V 6.3500016 Z" />
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1 +0,0 @@
<svg id="Layer_1" enable-background="new 0 0 100 100" height="512" viewBox="0 0 100 100" width="512" xmlns="http://www.w3.org/2000/svg"><g id="_x33_2.Reddit"><path id="Icon_69_" d="m90 50.6c-.2-4.8-4.2-8.6-9.1-8.5-2.2.1-4.2 1-5.7 2.4-6.8-4.7-14.9-7.2-23.2-7.4l3.9-18.7 12.9 2.6c.4 3.3 3.3 5.7 6.6 5.3s5.7-3.3 5.3-6.6-3.3-5.7-6.6-5.3c-1.9.2-3.6 1.3-4.5 2.9l-14.7-2.9c-1-.2-2 .4-2.2 1.4l-4.4 20.9c-8.4.1-16.5 2.7-23.5 7.4-3.5-3.3-9.1-3.2-12.4.4-3.3 3.5-3.2 9.1.4 12.4.7.6 1.5 1.2 2.4 1.6-.1.9-.1 1.8 0 2.6 0 13.5 15.7 24.5 35.1 24.5s35.1-10.9 35.1-24.5c.1-.9.1-1.8 0-2.6 2.8-1.4 4.7-4.5 4.6-7.9zm-60.3 6.1c0-3.3 2.7-6 6-6s6 2.7 6 6-2.7 6-6 6-6-2.7-6-6zm35 16.6c-4.3 3.2-9.5 4.9-14.8 4.6-5.3.2-10.6-1.4-14.8-4.6-.6-.7-.5-1.7.2-2.3.6-.5 1.4-.5 2.1 0 3.6 2.6 8 4 12.5 3.8 4.5.2 8.9-1 12.6-3.7.7-.6 1.7-.6 2.4 0 .6.7.6 1.7 0 2.4v-.2zm-1-10.3c-3.3 0-6-2.7-6-6s2.7-6 6-6 6 2.7 6 6c.1 3.3-2.4 6.1-5.8 6.2-.1 0-.2 0-.3 0z"/></g></svg>

Before

Width:  |  Height:  |  Size: 924 B

View File

@@ -1,14 +0,0 @@
<svg
data-v-8c2610d6=""
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-steam"
viewBox="0 0 16 16"
>
<path
d="M.329 10.333A8.01 8.01 0 0 0 7.99 16C12.414 16 16 12.418 16 8s-3.586-8-8.009-8A8.006 8.006 0 0 0 0 7.468l.003.006 4.304 1.769A2.198 2.198 0 0 1 5.62 8.88l1.96-2.844-.001-.04a3.046 3.046 0 0 1 3.042-3.043 3.046 3.046 0 0 1 3.042 3.043 3.047 3.047 0 0 1-3.111 3.044l-2.804 2a2.223 2.223 0 0 1-3.075 2.11 2.217 2.217 0 0 1-1.312-1.568L.33 10.333Z"/>
<path
d="M4.868 12.683a1.715 1.715 0 0 0 1.318-3.165 1.705 1.705 0 0 0-1.263-.02l1.023.424a1.261 1.261 0 1 1-.97 2.33l-.99-.41a1.7 1.7 0 0 0 .882.84Zm3.726-6.687a2.03 2.03 0 0 0 2.027 2.029 2.03 2.03 0 0 0 2.027-2.029 2.03 2.03 0 0 0-2.027-2.027 2.03 2.03 0 0 0-2.027 2.027Zm2.03-1.527a1.524 1.524 0 1 1-.002 3.048 1.524 1.524 0 0 1 .002-3.048Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 881 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"/></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left-right"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/></svg>

Before

Width:  |  Height:  |  Size: 315 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bug"><rect width="8" height="14" x="8" y="6" rx="4"/><path d="m19 7-3 2"/><path d="m5 7 3 2"/><path d="m19 19-3-2"/><path d="m5 19 3-2"/><path d="M20 13h-4"/><path d="M4 13h4"/><path d="m10 4 1 2"/><path d="m14 4-1 2"/></svg>

Before

Width:  |  Height:  |  Size: 427 B

View File

@@ -1,14 +0,0 @@
export { default as ServerIcon } from './server.svg'
export { default as MinimizeIcon } from './minimize.svg'
export { default as MaximizeIcon } from './maximize.svg'
export { default as SwapIcon } from './arrow-left-right.svg'
export { default as ToggleIcon } from './toggle.svg'
export { default as PackageIcon } from './package.svg'
export { default as VersionIcon } from './milestone.svg'
export { default as MoreIcon } from './more.svg'
export { default as TextInputIcon } from './text-cursor-input.svg'
export { default as AddProjectImage } from './add-project.svg'
export { default as NewInstanceImage } from './new-instance.svg'
export { default as MenuIcon } from './menu.svg'
export { default as BugIcon } from './bug.svg'
export { default as ChatIcon } from './messages-square.svg'

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-scaling"><path d="M21 3 9 15"/><path d="M12 3H3v18h18v-9"/><path d="M16 3h5v5"/><path d="M14 15H9v-5"/></svg>

Before

Width:  |  Height:  |  Size: 311 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-menu"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>

Before

Width:  |  Height:  |  Size: 326 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-messages-square"><path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4c0-1.1.9-2 2-2h8a2 2 0 0 1 2 2v5Z"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/></svg>

Before

Width:  |  Height:  |  Size: 359 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-milestone"><path d="M18 6H5a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h13l4-3.5L18 6Z"/><path d="M12 13v8"/><path d="M12 3v3"/></svg>

Before

Width:  |  Height:  |  Size: 322 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-minus"><line x1="5" x2="19" y1="12" y2="12"/></svg>

Before

Width:  |  Height:  |  Size: 253 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-more-vertical"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>

Before

Width:  |  Height:  |  Size: 315 B

View File

@@ -1,138 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" width="924.76626" height="641.9894"
viewBox="0 0 924.76626 641.9894" xmlns:xlink="http://www.w3.org/1999/xlink">
<path
d="M901.84039,677.83256c3.74046-30.07761,22.38093-59.7128,51.06588-69.50087a139.64665,139.64665,0,0,0,.00692,95.87682c4.40777,11.92286,10.55173,24.725,6.40563,36.74145-2.57964,7.477-8.89041,13.1975-15.94457,16.7744-7.0546,3.5769-14.88373,5.28172-22.61488,6.95243l-1.52153,1.25839C906.96476,738.22193,898.09992,707.91018,901.84039,677.83256Z"
transform="translate(-137.61687 -129.0053)" fill="#f0f0f0"/>
<path
d="M953.19092,608.90041a119.36031,119.36031,0,0,0-29.67278,67.17592,51.40093,51.40093,0,0,0,.58468,16.09418,29.48054,29.48054,0,0,0,7.33219,13.67368c3.30477,3.631,7.10567,6.96234,9.47027,11.33341a18.051,18.051,0,0,1,.88188,14.73616c-2.08769,5.98787-6.20242,10.86849-10.39214,15.50149-4.65189,5.14407-9.56524,10.41344-11.54265,17.23311-.23959.8263-1.50772.40622-1.26849-.41883,3.44035-11.865,14.95818-18.60464,20.451-29.29134,2.563-4.98661,3.63886-10.77584,1.236-16.03972-2.10117-4.603-6.01776-8.04173-9.39569-11.68923a31.458,31.458,0,0,1-7.66744-13.10281,47.51861,47.51861,0,0,1-1.20129-16.01232,115.8007,115.8007,0,0,1,8.45741-35.19088,121.4766,121.4766,0,0,1,21.84309-35.0047c.57116-.63958,1.45134.36648.88391,1.00188Z"
transform="translate(-137.61687 -129.0053)" fill="#fff"/>
<path
d="M923.87771,667.99227a17.90785,17.90785,0,0,1-13.6314-18.75913c.06806-.85679,1.40267-.79127,1.33452.06666a16.58227,16.58227,0,0,0,12.71571,17.424c.83627.19883.41265,1.46618-.41883,1.26849Z"
transform="translate(-137.61687 -129.0053)" fill="#fff"/>
<path
d="M929.51688,704.22384a34.51591,34.51591,0,0,0,15.41342-19.87863c.24251-.82542,1.51071-.4056,1.26849.41883A35.90175,35.90175,0,0,1,930.11768,705.417c-.74074.43939-1.33752-.75611-.6008-1.19312Z"
transform="translate(-137.61687 -129.0053)" fill="#fff"/>
<path
d="M936.9349,631.34025a10.13673,10.13673,0,0,0,9.60617-.48764c.73481-.44857,1.33082.74746.6008,1.19311a11.359,11.359,0,0,1-10.6258.563.69037.69037,0,0,1-.42483-.84366.6713.6713,0,0,1,.84366-.42482Z"
transform="translate(-137.61687 -129.0053)" fill="#fff"/>
<path
d="M1040.342,662.054c-.45086.29314-.90172.58628-1.35313.89071a133.49486,133.49486,0,0,0-17.06883,13.32611c-.41728.37207-.83456.75542-1.24028,1.13871a140.73351,140.73351,0,0,0-30.56436,41.83882,136.67119,136.67119,0,0,0-7.48626,19.36917c-2.76241,9.166-5.02827,19.324-10.4964,26.82144a23.44535,23.44535,0,0,1-1.82656,2.25485H920.89105c-.1123-.05636-.22516-.1015-.338-.15786l-1.973.09022c.07927-.3495.169-.71029.24827-1.05979.04514-.20293.1013-.40585.14644-.60878.03358-.13529.06771-.27064.09028-.39464.011-.04508.02257-.09015.03358-.124.02257-.124.0567-.23679.07927-.3495q.744-3.0271,1.53315-6.05427c0-.01129,0-.01129.011-.02257,4.04783-15.36682,9.41411-30.53071,16.91138-44.398.22571-.41714.45086-.84557.69914-1.26271a130.41346,130.41346,0,0,1,11.71411-17.76824,115.29059,115.29059,0,0,1,7.67783-8.78262,95.87273,95.87273,0,0,1,23.99138-17.97117c17.72338-9.35762,38.2422-12.94283,57.18328-7.22677C1039.38361,661.74961,1039.85759,661.89611,1040.342,662.054Z"
transform="translate(-137.61687 -129.0053)" fill="#f0f0f0"/>
<path
d="M1040.23094,662.68115a119.36024,119.36024,0,0,0-64.13659,35.77114,51.40084,51.40084,0,0,0-9.223,13.20232,29.48052,29.48052,0,0,0-2.37816,15.33216c.45258,4.88882,1.48168,9.83714.738,14.75085a18.051,18.051,0,0,1-8.168,12.297c-5.272,3.524-11.49586,4.94359-17.63051,6.12029-6.81135,1.30649-13.90692,2.55561-19.59168,6.8102-.68879.5155-1.4484-.58341-.76065-1.09813,9.89051-7.40226,23.14454-5.84891,33.96439-11.07458,5.04874-2.43839,9.39323-6.41306,10.64393-12.06264,1.09368-4.94031.03684-10.044-.4642-14.99a31.458,31.458,0,0,1,1.76676-15.07818,47.51855,47.51855,0,0,1,8.68137-13.5082,115.80059,115.80059,0,0,1,27.94011-23.006,121.4767,121.4767,0,0,1,38.51573-14.79824c.84111-.16679.93817,1.16642.10255,1.33212Z"
transform="translate(-137.61687 -129.0053)" fill="#fff"/>
<path
d="M981.2486,692.21411A17.90784,17.90784,0,0,1,981.659,669.029c.57019-.64313,1.59636.21271,1.02541.85669a16.58227,16.58227,0,0,0-.33766,21.56781c.548.66224-.55326,1.4191-1.09813.76065Z"
transform="translate(-137.61687 -129.0053)" fill="#fff"/>
<path
d="M963.93725,724.53816a34.51588,34.51588,0,0,0,24.27507-6.592c.69059-.513,1.45041.58571.76065,1.09813a35.90171,35.90171,0,0,1-25.27435,6.80826c-.856-.09514-.61271-1.409.23863-1.31436Z"
transform="translate(-137.61687 -129.0053)" fill="#fff"/>
<path
d="M1013.74109,670.81085a10.13669,10.13669,0,0,0,7.96358,5.39423c.85677.08425.61256,1.39806-.23863,1.31436a11.359,11.359,0,0,1-8.82308-5.94794.69036.69036,0,0,1,.16874-.92939.67131.67131,0,0,1,.92939.16874Z"
transform="translate(-137.61687 -129.0053)" fill="#fff"/>
<path
d="M742.23915,525.98269a10.0558,10.0558,0,0,0-1.61015-15.335l9.13555-34.54737-17.60072,5.92017-5.97689,31.89915a10.11027,10.11027,0,0,0,16.05221,12.06308Z"
transform="translate(-137.61687 -129.0053)" fill="#9e616a"/>
<polygon points="686.055 629.029 673.795 629.028 667.963 581.74 686.057 581.741 686.055 629.029"
fill="#9e616a"/>
<path
d="M826.79864,769.91783l-39.53076-.00146v-.5a15.38728,15.38728,0,0,1,15.38647-15.38623h.001l24.144.001Z"
transform="translate(-137.61687 -129.0053)" fill="#2f2e41"/>
<polygon points="628.055 629.029 615.795 629.028 609.963 581.74 628.057 581.741 628.055 629.029"
fill="#9e616a"/>
<path
d="M768.79864,769.91783l-39.53076-.00146v-.5a15.38728,15.38728,0,0,1,15.38647-15.38623h.001l24.144.001Z"
transform="translate(-137.61687 -129.0053)" fill="#2f2e41"/>
<path id="b1d8f474-012b-4d97-a964-9f33bafc8058-812" data-name="Path 1095"
d="M758.85336,473.11447l-4.706,1.3s-12.294,79.7-13.294,121.7,3,148,3,148h26l21-180,10,180h28s20-236,11-255S758.85336,473.11447,758.85336,473.11447Z"
transform="translate(-137.61687 -129.0053)" fill="#2f2e41"/>
<circle id="e76160c9-f3a1-43ce-a248-d388186e85bb" data-name="Ellipse 219" cx="658.74398"
cy="179.98357" r="23.49291" fill="#9e616a"/>
<path id="bb67ace3-6b73-4a38-bac1-9d795c287a43-813" data-name="Path 1099"
d="M855.85336,363.11447l-36.464-23.83-37.786,1.488-36.75,20.342,14,78-6,45s54-6,66,3a51.277,51.277,0,0,0,24,10l-2-77Z"
transform="translate(-137.61687 -129.0053)" fill="#3f3d56"/>
<path id="f7a1be30-9471-4724-82e7-c8ff3c288394-814" data-name="Path 1101"
d="M751.85336,360.11447l-3.778-.784-3.222,1.784s-9,12-9,23-11,117-11,117l20,9,16-83Z"
transform="translate(-137.61687 -129.0053)" fill="#3f3d56"/>
<path id="a2423779-f29b-465e-aa9d-0fc12224f03e-815" data-name="Path 1104"
d="M816.93427,291.7643s6.14449-11.74646-7.3736-12.81432c0,0-11.5243-10.454-23.27076-1.91114,0,0-6.40715,0-9.91084,7.25044,0,0-5.03919-1.91114-6.14657,3.20358,0,0-3.68733,10.67861,0,20.28934s4.91212,10.6786,4.91212,10.6786-6.059-20.14943,8.6892-21.21729,31.252-10.28455,32.48107,1.46189,3.07866,14.64066,3.07866,14.64066S831.06737,296.56968,816.93427,291.7643Z"
transform="translate(-137.61687 -129.0053)" fill="#2f2e41"/>
<path id="e9616824-6bd8-4015-8eb7-3dcfe132b2c0-816" data-name="Subtraction 1"
d="M513.35252,429.96937h-291v-218h291Z" transform="translate(-137.61687 -129.0053)"
fill="#fff"/>
<path id="f4711ed8-dad6-4be5-800d-5829f7fb3f37-817" data-name="Subtraction 1"
d="M513.35252,429.96937h-291v-218h291Zm-288-215v212h285v-212Z"
transform="translate(-137.61687 -129.0053)" fill="#e6e6e6"/>
<g id="b3812a0d-69b2-4ac3-87c4-e066c2de2dfd" data-name="Group 41">
<circle id="fb0bff05-a63a-421c-8737-de050f830f30" data-name="Ellipse 220" cx="230.14466"
cy="192.37305" r="55.409" fill="var(--color-green)"/>
<path id="f9b355f0-5797-4243-8ae5-6fe77774d21b-818" data-name="Path 1106"
d="M390.77053,316.72537h-18.839v-18.839a4.433,4.433,0,0,0-4.433-4.433h0a4.433,4.433,0,0,0-4.433,4.433v18.839h-18.839a4.433,4.433,0,0,0-4.433,4.433v0h0a4.433,4.433,0,0,0,4.433,4.433h18.839v18.839a4.433,4.433,0,0,0,4.433,4.433h0a4.433,4.433,0,0,0,4.433-4.433V325.59137h18.839a4.433,4.433,0,0,0,4.433-4.433v0h0a4.433,4.433,0,0,0-4.433-4.433h0Z"
transform="translate(-137.61687 -129.0053)" fill="#fff"/>
</g>
<path d="M246.35588,211.0595h-108.739V129.0053h108.739Z"
transform="translate(-137.61687 -129.0053)" fill="#fff"/>
<path d="M246.35588,211.0595h-108.739V129.0053h108.739Zm-106.739-2h104.739V131.0053h-104.739Z"
transform="translate(-137.61687 -129.0053)" fill="#e6e6e6"/>
<rect id="a0756b3d-8076-4d61-8f2c-cdb882ad5331" data-name="Rectangle 342" x="14.34201"
y="22.34808" width="80.054" height="5.337" fill="#e4e4e4"/>
<rect id="b5dd6458-8ffa-4f7b-ade4-3f6bf4de284a" data-name="Rectangle 343" x="14.34201"
y="38.35803" width="80.054" height="5.337" fill="#e4e4e4"/>
<rect id="bf0c907d-dbb4-4364-8cdd-63eed4118a99" data-name="Rectangle 344" x="14.34201"
y="54.36908" width="80.054" height="5.337" fill="#e4e4e4"/>
<path d="M969.35588,770.0595h-108.739V688.0053h108.739Z"
transform="translate(-137.61687 -129.0053)" fill="#fff"/>
<path d="M969.35588,770.0595h-108.739V688.0053h108.739Zm-106.739-2h104.739V690.0053h-104.739Z"
transform="translate(-137.61687 -129.0053)" fill="#e6e6e6"/>
<rect id="b0c2ce11-0b20-4ceb-9ae2-a734f04b6c4a" data-name="Rectangle 342" x="737.34201"
y="581.34808" width="80.054" height="5.337" fill="#e4e4e4"/>
<rect id="bf5a9fda-652f-454e-a316-4f0fe63005cd" data-name="Rectangle 343" x="737.34201"
y="597.35803" width="80.054" height="5.337" fill="#e4e4e4"/>
<rect id="ad5f030a-4549-4063-8a1c-b5f384f37057" data-name="Rectangle 344" x="737.34201"
y="613.36908" width="80.054" height="5.337" fill="#e4e4e4"/>
<path d="M903.31826,622.82706l-81.22118-72.3,54.55741-61.28931,81.22119,72.3Z"
transform="translate(-137.61687 -129.0053)" fill="#fff"/>
<path
d="M903.31826,622.82706l-81.22118-72.3,54.55741-61.28931,81.22119,72.3ZM824.92074,550.363l78.23344,69.64043L955.052,561.70184,876.81858,492.0614Z"
transform="translate(-137.61687 -129.0053)" fill="#e6e6e6"/>
<rect id="b060c16b-2eb9-48d7-8f6b-fd5295a89bd0" data-name="Rectangle 342" x="897.96282"
y="504.04622" width="5.337" height="80.054"
transform="translate(-242.1991 726.03121) rotate(-48.32574)" fill="#e4e4e4"/>
<rect id="aedfd2a5-e201-47b6-a3ec-b56955ab97bd" data-name="Rectangle 343" x="887.31788"
y="516.00464" width="5.337" height="80.054"
transform="translate(-254.69846 722.08744) rotate(-48.32574)" fill="#e4e4e4"/>
<rect id="b4720297-2a1d-43a8-8ac0-2a4c1ee9f1d2" data-name="Rectangle 344" x="876.67222"
y="527.96389" width="5.337" height="80.054"
transform="translate(-267.19868 718.1434) rotate(-48.32574)" fill="#e4e4e4"/>
<path d="M307.92339,259.0595H186.61687V167.52189H307.92339Z"
transform="translate(-137.61687 -129.0053)" fill="#fff"/>
<path
d="M307.92339,259.0595H186.61687V167.52189H307.92339ZM188.848,256.82835H305.69224V169.753H188.848Z"
transform="translate(-137.61687 -129.0053)" fill="#e6e6e6"/>
<rect id="e724a003-6a25-4c83-be10-32efc1fd30bf" data-name="Rectangle 342" x="64.99958"
y="63.44755" width="89.30624" height="5.95382" fill="var(--color-green)"/>
<rect id="f16e8690-23eb-4802-9b18-78b8e9972275" data-name="Rectangle 343" x="64.99958"
y="81.30785" width="89.30624" height="5.95382" fill="var(--color-green)"/>
<rect id="ea05cbc1-1ae7-4cbc-994d-a1c0c2d5941e" data-name="Rectangle 344" x="64.99958"
y="99.16937" width="89.30624" height="5.95382" fill="var(--color-green)"/>
<path d="M622.73338,498.69378H437.61687V359.0053H622.73338Z"
transform="translate(-137.61687 -129.0053)" fill="#fff"/>
<path
d="M622.73338,498.69378H437.61687V359.0053H622.73338ZM441.02166,495.289H619.3286V362.41009H441.02166Z"
transform="translate(-137.61687 -129.0053)" fill="#e6e6e6"/>
<rect id="ac8b6f6a-33b0-4e01-bc67-8452ca74a9e6" data-name="Rectangle 342" x="324.41573"
y="268.04521" width="136.28335" height="9.08567" fill="var(--color-green)"/>
<rect id="e4f43daa-ee47-4f01-aaaf-288902bf6f27" data-name="Rectangle 343" x="324.41573"
y="295.30043" width="136.28335" height="9.08567" fill="var(--color-green)"/>
<rect id="b40e1bee-dfb3-4798-af30-e469c9a00d65" data-name="Rectangle 344" x="324.41573"
y="322.55752" width="136.28335" height="9.08567" fill="var(--color-green)"/>
<path
d="M1061.19244,770.9947h-480.294a1.19069,1.19069,0,0,1,0-2.38137h480.294a1.19068,1.19068,0,1,1,0,2.38137Z"
transform="translate(-137.61687 -129.0053)" fill="#cacaca"/>
<path
d="M843.422,525.22926a10.05578,10.05578,0,0,1,5.50137-14.40454l.06041-35.73479,15.48506,10.24922-2.43139,32.363a10.11028,10.11028,0,0,1-18.61545,7.52706Z"
transform="translate(-137.61687 -129.0053)" fill="#9e616a"/>
<path id="b2a3d507-71c0-44dd-96fb-9359ee9fed80-819" data-name="Path 1100"
d="M844.85336,367.11447l6.918-6.668,5.082,3.668s5,6,9,24,0,117,0,117l-22-3,3-52-9-44Z"
transform="translate(-137.61687 -129.0053)" fill="#3f3d56"/>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-package"><path d="M16.5 9.4 7.55 4.24"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.29 7 12 12 20.71 7"/><line x1="12" x2="12" y1="22" y2="12"/></svg>

Before

Width:  |  Height:  |  Size: 461 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-server"><rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/></svg>

Before

Width:  |  Height:  |  Size: 405 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-text-cursor-input"><path d="M5 4h1a3 3 0 0 1 3 3 3 3 0 0 1 3-3h1"/><path d="M13 20h-1a3 3 0 0 1-3-3 3 3 0 0 1-3 3H5"/><path d="M5 16H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h1"/><path d="M13 8h7a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-7"/><path d="M9 7v10"/></svg>

Before

Width:  |  Height:  |  Size: 449 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>

Before

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 MiB

View File

@@ -1,137 +0,0 @@
:root {
font-family: var(--font-standard);
color-scheme: dark;
--view-width: calc(100% - 5rem);
--expanded-view-width: calc(100% - 13rem);
}
body {
position: fixed;
width: 100%;
height: 100%;
overflow: hidden;
}
* {
box-sizing: border-box;
}
.card-divider {
background-color: var(--color-button-bg);
border: none;
color: var(--color-button-bg);
height: 1px;
margin: var(--gap-sm) 0;
}
.no-wrap {
white-space: nowrap;
}
.no-select {
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
a {
color: var(--color-link);
text-decoration: none;
&:hover {
text-decoration: none;
}
}
input {
border: none !important;
}
.badge {
display: flex;
border-radius: var(--radius-md);
white-space: nowrap;
align-items: center;
background-color: var(--color-bg);
padding-block: var(--gap-sm);
padding-inline: var(--gap-lg);
width: min-content;
svg {
width: 1.1rem;
height: 1.1rem;
margin-right: 0.5rem;
}
&.featured {
background-color: var(--color-brand-highlight);
color: var(--color-contrast);
}
}
.mac {
.nav-container {
padding-top: calc(var(--gap-md) + 1.75rem);
}
.account-card,
.card-section {
top: calc(var(--gap-md) + 1.75rem);
}
}
.windows {
.fake-appbar {
height: 2.5rem !important;
}
.window-controls {
display: flex !important;
}
.info-card {
right: 8rem;
}
.profile-card {
right: 8rem;
}
}
* {
scrollbar-width: auto;
scrollbar-color: var(--color-button-bg) var(--color-bg);
}
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
width: var(--gap-md);
border: 3px solid var(--color-bg);
}
*::-webkit-scrollbar-track {
background: var(--color-bg);
border: 3px solid var(--color-bg);
}
*::-webkit-scrollbar-thumb {
background-color: var(--color-button-bg);
border-radius: var(--radius-lg);
border: 3px solid var(--color-bg);
}
.highlighted {
box-shadow: 0 0 1rem var(--color-brand) !important;
}
.gecko {
background-color: var(--color-raised-bg);
box-shadow: none !important;
}
img {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}

View File

@@ -1,3 +0,0 @@
img {
pointer-events: none !important;
}

View File

@@ -1,395 +0,0 @@
<script setup>
import Instance from '@/components/ui/Instance.vue'
import { computed, ref } from 'vue'
import {
ClipboardCopyIcon,
FolderOpenIcon,
PlayIcon,
PlusIcon,
TrashIcon,
StopCircleIcon,
EyeIcon,
SearchIcon,
XIcon,
} from '@modrinth/assets'
import { ConfirmModal, Button, Card, DropdownSelect } from '@modrinth/ui'
import { formatCategoryHeader } from '@modrinth/utils'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import dayjs from 'dayjs'
import { useTheming } from '@/store/theme.js'
import { duplicate, remove } from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
const props = defineProps({
instances: {
type: Array,
default() {
return []
},
},
label: {
type: String,
default: '',
},
})
const instanceOptions = ref(null)
const instanceComponents = ref(null)
const themeStore = useTheming()
const currentDeleteInstance = ref(null)
const confirmModal = ref(null)
async function deleteProfile() {
if (currentDeleteInstance.value) {
instanceComponents.value = instanceComponents.value.filter(
(x) => x.instance.path !== currentDeleteInstance.value,
)
await remove(currentDeleteInstance.value).catch(handleError)
}
}
async function duplicateProfile(p) {
await duplicate(p).catch(handleError)
}
const handleRightClick = (event, profilePathId) => {
const item = instanceComponents.value.find((x) => x.instance.path === profilePathId)
const baseOptions = [
{ name: 'add_content' },
{ type: 'divider' },
{ name: 'edit' },
{ name: 'duplicate' },
{ name: 'open' },
{ name: 'copy' },
{ type: 'divider' },
{
name: 'delete',
color: 'danger',
},
]
instanceOptions.value.showMenu(
event,
item,
item.playing
? [
{
name: 'stop',
color: 'danger',
},
...baseOptions,
]
: [
{
name: 'play',
color: 'primary',
},
...baseOptions,
],
)
}
const handleOptionsClick = async (args) => {
switch (args.option) {
case 'play':
args.item.play(null, 'InstanceGridContextMenu')
break
case 'stop':
args.item.stop(null, 'InstanceGridContextMenu')
break
case 'add_content':
await args.item.addContent()
break
case 'edit':
await args.item.seeInstance()
break
case 'duplicate':
if (args.item.instance.install_stage == 'installed')
await duplicateProfile(args.item.instance.path)
break
case 'open':
await args.item.openFolder()
break
case 'copy':
await navigator.clipboard.writeText(args.item.instance.path)
break
case 'delete':
currentDeleteInstance.value = args.item.instance.path
confirmModal.value.show()
break
}
}
const search = ref('')
const group = ref('Category')
const filters = ref('All profiles')
const sortBy = ref('Name')
const filteredResults = computed(() => {
let instances = props.instances.filter((instance) => {
return instance.metadata.name.toLowerCase().includes(search.value.toLowerCase())
})
if (sortBy.value === 'Name') {
instances.sort((a, b) => {
return a.metadata.name.localeCompare(b.metadata.name)
})
}
if (sortBy.value === 'Game version') {
instances.sort((a, b) => {
return a.metadata.game_version.localeCompare(b.metadata.game_version)
})
}
if (sortBy.value === 'Last played') {
instances.sort((a, b) => {
return dayjs(b.metadata.last_played ?? 0).diff(dayjs(a.metadata.last_played ?? 0))
})
}
if (sortBy.value === 'Date created') {
instances.sort((a, b) => {
return dayjs(b.metadata.date_created).diff(dayjs(a.metadata.date_created))
})
}
if (sortBy.value === 'Date modified') {
instances.sort((a, b) => {
return dayjs(b.metadata.date_modified).diff(dayjs(a.metadata.date_modified))
})
}
if (filters.value === 'Custom instances') {
instances = instances.filter((instance) => {
return !instance.metadata?.linked_data
})
} else if (filters.value === 'Downloaded modpacks') {
instances = instances.filter((instance) => {
return instance.metadata?.linked_data
})
}
const instanceMap = new Map()
if (group.value === 'Loader') {
instances.forEach((instance) => {
const loader = formatCategoryHeader(instance.metadata.loader)
if (!instanceMap.has(loader)) {
instanceMap.set(loader, [])
}
instanceMap.get(loader).push(instance)
})
} else if (group.value === 'Game version') {
instances.forEach((instance) => {
if (!instanceMap.has(instance.metadata.game_version)) {
instanceMap.set(instance.metadata.game_version, [])
}
instanceMap.get(instance.metadata.game_version).push(instance)
})
} else if (group.value === 'Category') {
instances.forEach((instance) => {
if (instance.metadata.groups.length === 0) {
instance.metadata.groups.push('None')
}
for (const category of instance.metadata.groups) {
if (!instanceMap.has(category)) {
instanceMap.set(category, [])
}
instanceMap.get(category).push(instance)
}
})
} else {
return instanceMap.set('None', instances)
}
// For 'name', we intuitively expect the sorting to apply to the name of the group first, not just the name of the instance
// ie: Category A should come before B, even if the first instance in B comes before the first instance in A
if (sortBy.value === 'Name') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
// None should always be first
if (a[0] === 'None' && b[0] !== 'None') {
return -1
}
if (a[0] !== 'None' && b[0] === 'None') {
return 1
}
return a[0].localeCompare(b[0])
})
instanceMap.clear()
sortedEntries.forEach((entry) => {
instanceMap.set(entry[0], entry[1])
})
}
return instanceMap
})
</script>
<template>
<ConfirmModal
ref="confirmModal"
title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be removed. You will not be able to recover it."
:has-to-type="false"
proceed-label="Delete"
:noblur="!themeStore.advancedRendering"
@proceed="deleteProfile"
/>
<Card class="header">
<div class="iconified-input">
<SearchIcon />
<input v-model="search" type="text" placeholder="Search" class="search-input" />
<Button @click="() => (search = '')">
<XIcon />
</Button>
</div>
<div class="labeled_button">
<span>Sort by</span>
<DropdownSelect
v-model="sortBy"
class="sort-dropdown"
name="Sort Dropdown"
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
placeholder="Select..."
/>
</div>
<div class="labeled_button">
<span>Filter by</span>
<DropdownSelect
v-model="filters"
class="filter-dropdown"
name="Filter Dropdown"
:options="['All profiles', 'Custom instances', 'Downloaded modpacks']"
placeholder="Select..."
/>
</div>
<div class="labeled_button">
<span>Group by</span>
<DropdownSelect
v-model="group"
class="group-dropdown"
name="Group Dropdown"
:options="['Category', 'Loader', 'Game version', 'None']"
placeholder="Select..."
/>
</div>
</Card>
<div
v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({
key,
value,
}))"
:key="instanceSection.key"
class="row"
>
<div v-if="instanceSection.key !== 'None'" class="divider">
<p>{{ instanceSection.key }}</p>
<hr aria-hidden="true" />
</div>
<section class="instances">
<Instance
v-for="instance in instanceSection.value"
ref="instanceComponents"
:key="instance.path + instance.install_stage"
:instance="instance"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
/>
</section>
</div>
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EyeIcon /> View instance </template>
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
<template #delete> <TrashIcon /> Delete </template>
<template #open> <FolderOpenIcon /> Open folder </template>
<template #copy> <ClipboardCopyIcon /> Copy path </template>
</ContextMenu>
</template>
<style lang="scss" scoped>
.row {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
padding: 1rem;
.divider {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 1rem;
margin-bottom: 1rem;
p {
margin: 0;
font-size: 1rem;
white-space: nowrap;
color: var(--color-contrast);
}
hr {
background-color: var(--color-gray);
height: 1px;
width: 100%;
border: none;
}
}
}
.header {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
gap: 1rem;
align-items: inherit;
margin: 1rem 1rem 0 !important;
padding: 1rem;
width: calc(100% - 2rem);
.iconified-input {
flex-grow: 1;
input {
min-width: 100%;
}
}
.sort-dropdown {
width: 10rem;
}
.filter-dropdown {
width: 15rem;
}
.group-dropdown {
width: 10rem;
}
.labeled_button {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
}
}
.instances {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
width: 100%;
gap: 1rem;
margin-right: auto;
scroll-behavior: smooth;
overflow-y: auto;
}
</style>

View File

@@ -1,357 +0,0 @@
<script setup>
import {
ClipboardCopyIcon,
FolderOpenIcon,
PlayIcon,
PlusIcon,
TrashIcon,
DownloadIcon,
GlobeIcon,
StopCircleIcon,
ExternalIcon,
EyeIcon,
ChevronRightIcon,
} from '@modrinth/assets'
import { ConfirmModal } from '@modrinth/ui'
import Instance from '@/components/ui/Instance.vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ProjectCard from '@/components/ui/ProjectCard.vue'
import InstallConfirmModal from '@/components/ui/InstallConfirmModal.vue'
import ModInstallModal from '@/components/ui/ModInstallModal.vue'
import {
get_all_running_profile_paths,
get_uuids_by_profile_path,
kill_by_uuid,
} from '@/helpers/process.js'
import { handleError } from '@/store/notifications.js'
import { duplicate, remove, run } from '@/helpers/profile.js'
import { useRouter } from 'vue-router'
import { showProfileInFolder } from '@/helpers/utils.js'
import { useFetch } from '@/helpers/fetch.js'
import { install as pack_install } from '@/helpers/pack.js'
import { useTheming } from '@/store/state.js'
import { mixpanel_track } from '@/helpers/mixpanel'
import { handleSevereError } from '@/store/error.js'
const router = useRouter()
const props = defineProps({
instances: {
type: Array,
default() {
return []
},
},
label: {
type: String,
default: '',
},
canPaginate: Boolean,
})
const actualInstances = computed(() =>
props.instances.filter((x) => x && x.instances && x.instances[0]),
)
const modsRow = ref(null)
const instanceOptions = ref(null)
const instanceComponents = ref(null)
const rows = ref(null)
const confirmModal = ref(null)
const deleteConfirmModal = ref(null)
const modInstallModal = ref(null)
const themeStore = useTheming()
const currentDeleteInstance = ref(null)
async function deleteProfile() {
if (currentDeleteInstance.value) {
await remove(currentDeleteInstance.value).catch(handleError)
}
}
async function duplicateProfile(p) {
await duplicate(p).catch(handleError)
}
const handleInstanceRightClick = async (event, passedInstance) => {
const baseOptions = [
{ name: 'add_content' },
{ type: 'divider' },
{ name: 'edit' },
{ name: 'duplicate' },
{ name: 'open_folder' },
{ name: 'copy_path' },
{ type: 'divider' },
{
name: 'delete',
color: 'danger',
},
]
const running = await get_all_running_profile_paths().catch(handleError)
const options = running.includes(passedInstance.path)
? [
{
name: 'stop',
color: 'danger',
},
...baseOptions,
]
: [
{
name: 'play',
color: 'primary',
},
...baseOptions,
]
instanceOptions.value.showMenu(event, passedInstance, options)
}
const handleProjectClick = (event, passedInstance) => {
instanceOptions.value.showMenu(event, passedInstance, [
{
name: 'install',
color: 'primary',
},
{ type: 'divider' },
{
name: 'open_link',
},
{
name: 'copy_link',
},
])
}
const handleOptionsClick = async (args) => {
switch (args.option) {
case 'play':
await run(args.item.path).catch(handleSevereError)
mixpanel_track('InstanceStart', {
loader: args.item.metadata.loader,
game_version: args.item.metadata.game_version,
})
break
case 'stop':
for (const u of await get_uuids_by_profile_path(args.item.path).catch(handleError)) {
await kill_by_uuid(u).catch(handleError)
}
mixpanel_track('InstanceStop', {
loader: args.item.metadata.loader,
game_version: args.item.metadata.game_version,
})
break
case 'add_content':
await router.push({
path: `/browse/${args.item.metadata.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: args.item.path },
})
break
case 'edit':
await router.push({
path: `/instance/${encodeURIComponent(args.item.path)}/`,
})
break
case 'duplicate':
if (args.item.install_stage == 'installed') await duplicateProfile(args.item.path)
break
case 'delete':
currentDeleteInstance.value = args.item.path
deleteConfirmModal.value.show()
break
case 'open_folder':
await showProfileInFolder(args.item.path)
break
case 'copy_path':
await navigator.clipboard.writeText(args.item.path)
break
case 'install': {
const versions = await useFetch(
`https://api.modrinth.com/v2/project/${args.item.project_id}/version`,
'project versions',
)
if (args.item.project_type === 'modpack') {
await pack_install(
args.item.project_id,
versions[0].id,
args.item.title,
args.item.icon_url,
)
} else {
modInstallModal.value.show(args.item.project_id, versions)
}
break
}
case 'open_link':
window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: `https://modrinth.com/${args.item.project_type}/${args.item.slug}`,
},
})
break
case 'copy_link':
await navigator.clipboard.writeText(
`https://modrinth.com/${args.item.project_type}/${args.item.slug}`,
)
break
}
}
const maxInstancesPerRow = ref(1)
const maxProjectsPerRow = ref(1)
const calculateCardsPerRow = () => {
// Calculate how many cards fit in one row
const containerWidth = rows.value[0].clientWidth
// Convert container width from pixels to rem
const containerWidthInRem =
containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize)
maxInstancesPerRow.value = Math.floor((containerWidthInRem + 1) / 11)
maxProjectsPerRow.value = Math.floor((containerWidthInRem + 1) / 19)
}
onMounted(() => {
calculateCardsPerRow()
window.addEventListener('resize', calculateCardsPerRow)
})
onUnmounted(() => {
window.removeEventListener('resize', calculateCardsPerRow)
})
</script>
<template>
<ConfirmModal
ref="deleteConfirmModal"
title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be removed. You will not be able to recover it."
:has-to-type="false"
proceed-label="Delete"
:noblur="!themeStore.advancedRendering"
@proceed="deleteProfile"
/>
<div class="content">
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row">
<div class="header">
<router-link :to="row.route">{{ row.label }}</router-link>
<ChevronRightIcon />
</div>
<section v-if="row.instances[0].metadata" ref="modsRow" class="instances">
<Instance
v-for="instance in row.instances.slice(0, maxInstancesPerRow)"
:key="(instance?.project_id || instance?.id) + instance.install_stage"
:instance="instance"
@contextmenu.prevent.stop="(event) => handleInstanceRightClick(event, instance)"
/>
</section>
<section v-else ref="modsRow" class="projects">
<ProjectCard
v-for="project in row.instances.slice(0, maxProjectsPerRow)"
:key="project?.project_id"
ref="instanceComponents"
class="item"
:project="project"
:confirm-modal="confirmModal"
:mod-install-modal="modInstallModal"
@contextmenu.prevent.stop="(event) => handleProjectClick(event, project)"
/>
</section>
</div>
</div>
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EyeIcon /> View instance </template>
<template #delete> <TrashIcon /> Delete </template>
<template #open_folder> <FolderOpenIcon /> Open folder </template>
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
<template #install> <DownloadIcon /> Install </template>
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
</ContextMenu>
<InstallConfirmModal ref="confirmModal" />
<ModInstallModal ref="modInstallModal" />
</template>
<style lang="scss" scoped>
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
padding: 1rem;
gap: 1rem;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0;
background: transparent;
}
}
.row {
display: flex;
flex-direction: column;
align-items: flex-start;
overflow: hidden;
width: 100%;
min-width: 100%;
&:nth-child(even) {
background: var(--color-bg);
}
.header {
width: 100%;
margin-bottom: 1rem;
gap: var(--gap-xs);
display: flex;
flex-direction: row;
align-items: center;
a {
margin: 0;
font-size: var(--font-size-lg);
font-weight: bolder;
white-space: nowrap;
color: var(--color-contrast);
}
svg {
height: 1.5rem;
width: 1.5rem;
color: var(--color-contrast);
}
}
.instances {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-gap: 1rem;
width: 100%;
}
.projects {
display: grid;
width: 100%;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
grid-gap: 1rem;
.item {
width: 100%;
max-width: 100%;
}
}
}
</style>

View File

@@ -1,134 +0,0 @@
import { computed, defineComponent, h, onBeforeUnmount, ref, watch } from 'vue'
import { useLoading } from '@/store/state.js'
export default defineComponent({
props: {
throttle: {
type: Number,
default: 50,
},
duration: {
type: Number,
default: 500,
},
height: {
type: Number,
default: 3,
},
color: {
type: [String, Boolean],
default:
'repeating-linear-gradient(to right, var(--color-brand) 0%, var(--color-brand) 100%)',
},
offsetWidth: {
type: String,
default: '208px',
},
offsetHeight: {
type: String,
default: '52px',
},
},
setup(props, { slots }) {
const indicator = useLoadingIndicator({
duration: props.duration,
throttle: props.throttle,
})
onBeforeUnmount(() => indicator.clear)
const loading = useLoading()
watch(loading, (newValue) => {
if (newValue.loading) {
indicator.start()
} else {
indicator.finish()
}
})
return () =>
h(
'div',
{
style: {
position: 'fixed',
top: props.offsetHeight,
right: 0,
left: props.offsetWidth,
pointerEvents: 'none',
width: `calc((100vw - ${props.offsetWidth}) * ${indicator.progress.value / 100})`,
height: `${props.height}px`,
opacity: indicator.isLoading.value ? 1 : 0,
background: props.color || undefined,
backgroundSize: `${(100 / indicator.progress.value) * 100}% auto`,
transition: 'width 0.1s, height 0.4s, opacity 0.4s',
zIndex: 6,
},
},
slots,
)
},
})
function useLoadingIndicator(opts) {
const progress = ref(0)
const isLoading = ref(false)
const step = computed(() => 10000 / opts.duration)
let _timer = null
let _throttle = null
function start() {
clear()
progress.value = 0
if (opts.throttle) {
_throttle = setTimeout(() => {
isLoading.value = true
_startTimer()
}, opts.throttle)
} else {
isLoading.value = true
_startTimer()
}
}
function finish() {
progress.value = 100
_hide()
}
function clear() {
clearInterval(_timer)
clearTimeout(_throttle)
_timer = null
_throttle = null
}
function _increase(num) {
progress.value = Math.min(100, progress.value + num)
}
function _hide() {
clear()
setTimeout(() => {
isLoading.value = false
setTimeout(() => {
progress.value = 0
}, 400)
}, 500)
}
function _startTimer() {
_timer = setInterval(() => {
_increase(step.value)
}, 100)
}
return {
progress,
isLoading,
start,
finish,
clear,
}
}

View File

@@ -1,391 +0,0 @@
<template>
<div
v-if="mode !== 'isolated'"
ref="button"
v-tooltip.right="'Minecraft accounts'"
class="button-base avatar-button"
:class="{ expanded: mode === 'expanded' }"
@click="showCard = !showCard"
>
<Avatar
:size="mode === 'expanded' ? 'xs' : 'sm'"
:src="
selectedAccount
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
"
/>
</div>
<transition name="fade">
<Card
v-if="showCard || mode === 'isolated'"
ref="card"
class="account-card"
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
>
<div v-if="selectedAccount" class="selected account">
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.id}/128`" />
<div>
<h4>{{ selectedAccount.username }}</h4>
<p>Selected</p>
</div>
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.id)">
<TrashIcon />
</Button>
</div>
<div v-else class="logged-out account">
<h4>Not signed in</h4>
<Button v-tooltip="'Log in'" icon-only color="primary" @click="login()">
<LogInIcon />
</Button>
</div>
<div v-if="displayAccounts.length > 0" class="account-group">
<div v-for="account in displayAccounts" :key="account.id" class="account-row">
<Button class="option account" @click="setAccount(account)">
<Avatar :src="`https://mc-heads.net/avatar/${account.id}/128`" class="icon" />
<p>{{ account.username }}</p>
</Button>
<Button v-tooltip="'Log out'" icon-only @click="logout(account.id)">
<TrashIcon />
</Button>
</div>
</div>
<Button v-if="accounts.length > 0" @click="login()">
<PlusIcon />
Add account
</Button>
</Card>
</transition>
</template>
<script setup>
import { PlusIcon, TrashIcon, LogInIcon } from '@modrinth/assets'
import { Avatar, Button, Card } from '@modrinth/ui'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import {
users,
remove_user,
set_default_user,
login as login_flow,
get_default_user,
} from '@/helpers/auth'
import { handleError } from '@/store/state.js'
import { mixpanel_track } from '@/helpers/mixpanel'
import { process_listener } from '@/helpers/events'
import { handleSevereError } from '@/store/error.js'
defineProps({
mode: {
type: String,
required: true,
default: 'normal',
},
})
const emit = defineEmits(['change'])
const accounts = ref({})
const defaultUser = ref()
async function refreshValues() {
defaultUser.value = await get_default_user().catch(handleError)
accounts.value = await users().catch(handleError)
}
defineExpose({
refreshValues,
})
await refreshValues()
const displayAccounts = computed(() =>
accounts.value.filter((account) => defaultUser.value !== account.id),
)
const selectedAccount = computed(() =>
accounts.value.find((account) => account.id === defaultUser.value),
)
async function setAccount(account) {
defaultUser.value = account.id
await set_default_user(account.id).catch(handleError)
emit('change')
}
async function login() {
const loggedIn = await login_flow().catch(handleSevereError)
if (loggedIn) {
await setAccount(loggedIn)
await refreshValues()
}
mixpanel_track('AccountLogIn')
}
const logout = async (id) => {
await remove_user(id).catch(handleError)
await refreshValues()
if (!selectedAccount.value && accounts.value.length > 0) {
await setAccount(accounts.value[0])
await refreshValues()
} else {
emit('change')
}
mixpanel_track('AccountLogOut')
}
let showCard = ref(false)
let card = ref(null)
let button = ref(null)
const handleClickOutside = (event) => {
const elements = document.elementsFromPoint(event.clientX, event.clientY)
if (
card.value &&
card.value.$el !== event.target &&
!elements.includes(card.value.$el) &&
!button.value.contains(event.target)
) {
showCard.value = false
}
}
const unlisten = await process_listener(async (e) => {
if (e.event === 'launched') {
await refreshValues()
}
})
onMounted(() => {
window.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside)
})
onUnmounted(() => {
unlisten()
})
</script>
<style scoped lang="scss">
.selected {
background: var(--color-brand-highlight);
border-radius: var(--radius-lg);
color: var(--color-contrast);
gap: 1rem;
}
.logged-out {
background: var(--color-bg);
border-radius: var(--radius-lg);
gap: 1rem;
}
.account {
width: max-content;
display: flex;
align-items: center;
text-align: left;
padding: 0.5rem 1rem;
h4,
p {
margin: 0;
}
}
.account-card {
position: absolute;
display: flex;
flex-direction: column;
top: 0.5rem;
left: 5.5rem;
z-index: 11;
gap: 0.5rem;
padding: 1rem;
border: 1px solid var(--color-button-bg);
width: max-content;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
max-height: 98vh;
overflow-y: auto;
&::-webkit-scrollbar-track {
border-top-right-radius: 1rem;
border-bottom-right-radius: 1rem;
}
&::-webkit-scrollbar {
border-top-right-radius: 1rem;
border-bottom-right-radius: 1rem;
}
&.hidden {
display: none;
}
&.expanded {
left: 13.5rem;
}
&.isolated {
position: relative;
left: 0;
top: 0;
}
}
.accounts-title {
font-size: 1.2rem;
font-weight: bolder;
}
.account-group {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.option {
width: calc(100% - 2.25rem);
background: var(--color-raised-bg);
color: var(--color-base);
box-shadow: none;
img {
margin-right: 0.5rem;
}
}
.icon {
--size: 1.5rem !important;
}
.account-row {
display: flex;
flex-direction: row;
gap: 0.5rem;
vertical-align: center;
justify-content: space-between;
padding-right: 1rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.avatar-button {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-base);
background-color: var(--color-raised-bg);
border-radius: var(--radius-md);
width: 100%;
text-align: left;
&.expanded {
border: 1px solid var(--color-button-bg);
padding: 1rem;
}
}
.avatar-text {
margin: auto 0 auto 0.25rem;
display: flex;
flex-direction: column;
}
.text {
width: 6rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.accounts-text {
display: flex;
align-items: center;
gap: 0.25rem;
margin: 0;
}
.qr-code {
background-color: white !important;
border-radius: var(--radius-md);
}
.modal-body {
display: flex;
flex-direction: row;
gap: var(--gap-lg);
align-items: center;
padding: var(--gap-xl);
.modal-text {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
width: 100%;
h2,
p {
margin: 0;
}
.code-text {
display: flex;
flex-direction: row;
gap: var(--gap-xs);
align-items: center;
.code {
background-color: var(--color-bg);
border-radius: var(--radius-md);
border: solid 1px var(--color-button-bg);
font-family: var(--mono-font);
letter-spacing: var(--gap-md);
color: var(--color-contrast);
font-size: 2rem;
font-weight: bold;
padding: var(--gap-sm) 0 var(--gap-sm) var(--gap-md);
}
.btn {
width: 2.5rem;
height: 2.5rem;
}
}
}
}
.button-row {
display: flex;
flex-direction: row;
}
.modal {
position: absolute;
}
.code {
color: var(--color-brand);
padding: 0.05rem 0.1rem;
// row not column
display: flex;
.card {
background: var(--color-base);
color: var(--color-contrast);
padding: 0.5rem 1rem;
}
}
</style>

View File

@@ -1,87 +0,0 @@
<template>
<div class="breadcrumbs">
<Button class="breadcrumbs__back transparent" icon-only @click="$router.back()">
<ChevronLeftIcon />
</Button>
<Button class="breadcrumbs__forward transparent" icon-only @click="$router.forward()">
<ChevronRightIcon />
</Button>
{{ breadcrumbData.resetToNames(breadcrumbs) }}
<div v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name" class="breadcrumbs__item">
<router-link
v-if="breadcrumb.link"
:to="{
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id)),
query: breadcrumb.query,
}"
>{{
breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1))
: breadcrumb.name
}}
</router-link>
<span v-else class="selected">{{
breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1))
: breadcrumb.name
}}</span>
<ChevronRightIcon v-if="breadcrumb.link" class="chevron" />
</div>
</div>
</template>
<script setup>
import { ChevronRightIcon, ChevronLeftIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useRoute } from 'vue-router'
import { computed } from 'vue'
const route = useRoute()
const breadcrumbData = useBreadcrumbs()
const breadcrumbs = computed(() => {
const additionalContext =
route.meta.useContext === true
? breadcrumbData.context
: route.meta.useRootContext === true
? breadcrumbData.rootContext
: null
return additionalContext ? [additionalContext, ...route.meta.breadcrumb] : route.meta.breadcrumb
})
</script>
<style scoped lang="scss">
.breadcrumbs {
display: flex;
flex-direction: row;
.breadcrumbs__item {
display: flex;
flex-direction: row;
vertical-align: center;
margin: auto 0;
.chevron,
a {
margin: auto 0;
}
}
.breadcrumbs__back,
.breadcrumbs__forward {
margin: auto 0;
color: var(--color-base);
height: unset;
width: unset;
}
.breadcrumbs__forward {
margin-right: 1rem;
}
}
.selected {
color: var(--color-contrast);
}
</style>

View File

@@ -1,176 +0,0 @@
<template>
<transition name="fade">
<div
v-show="shown"
ref="contextMenu"
class="context-menu"
:style="{
left: left,
top: top,
}"
>
<div v-for="(option, index) in options" :key="index" @click.stop="optionClicked(option.name)">
<hr v-if="option.type === 'divider'" class="divider" />
<div
v-else-if="!(isLinkedData(item) && option.name === `add_content`)"
class="item clickable"
:class="[option.color ?? 'base']"
>
<slot :name="option.name" />
</div>
</div>
</div>
</transition>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
const emit = defineEmits(['menu-closed', 'option-clicked'])
const item = ref(null)
const contextMenu = ref(null)
const options = ref([])
const left = ref('0px')
const top = ref('0px')
const shown = ref(false)
defineExpose({
showMenu: (event, passedItem, passedOptions) => {
item.value = passedItem
options.value = passedOptions
const menuWidth = contextMenu.value.clientWidth
const menuHeight = contextMenu.value.clientHeight
if (menuWidth + event.pageX >= window.innerWidth) {
left.value = event.pageX - menuWidth + 2 + 'px'
} else {
left.value = event.pageX - 2 + 'px'
}
if (menuHeight + event.pageY >= window.innerHeight) {
top.value = event.pageY - menuHeight + 2 + 'px'
} else {
top.value = event.pageY - 2 + 'px'
}
shown.value = true
},
})
const isLinkedData = (item) => {
if (item.instance != undefined && item.instance.metadata.linked_data) {
return true
} else if (item.metadata != undefined && item.metadata.linked_data) {
return true
}
return false
}
const hideContextMenu = () => {
shown.value = false
emit('menu-closed')
}
const optionClicked = (option) => {
emit('option-clicked', {
item: item.value,
option: option,
})
hideContextMenu()
}
const onEscKeyRelease = (event) => {
if (event.keyCode === 27) {
hideContextMenu()
}
}
const handleClickOutside = (event) => {
const elements = document.elementsFromPoint(event.clientX, event.clientY)
if (
contextMenu.value &&
contextMenu.value.$el !== event.target &&
!elements.includes(contextMenu.value.$el)
) {
hideContextMenu()
}
}
onMounted(() => {
window.addEventListener('click', handleClickOutside)
document.body.addEventListener('keyup', onEscKeyRelease)
})
onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside)
document.removeEventListener('keyup', onEscKeyRelease)
})
</script>
<style lang="scss" scoped>
.context-menu {
background-color: var(--color-raised-bg);
border-radius: var(--radius-md);
box-shadow: var(--shadow-floating);
border: 1px solid var(--color-button-bg);
margin: 0;
position: fixed;
z-index: 1000000;
overflow: hidden;
padding: var(--gap-sm);
.item {
align-items: center;
color: var(--color-base);
cursor: pointer;
display: flex;
gap: var(--gap-sm);
padding: var(--gap-sm);
border-radius: var(--radius-sm);
&:hover,
&:active {
&.base {
background-color: var(--color-button-bg);
color: var(--color-contrast);
}
&.primary {
background-color: var(--color-brand);
color: var(--color-accent-contrast);
font-weight: bold;
}
&.danger {
background-color: var(--color-red);
color: var(--color-accent-contrast);
font-weight: bold;
}
&.contrast {
background-color: var(--color-orange);
color: var(--color-accent-contrast);
font-weight: bold;
}
}
}
.divider {
border: 1px solid var(--color-button-bg);
margin: var(--gap-sm);
pointer-events: none;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,226 +0,0 @@
<script setup>
import { XIcon, IssuesIcon, LogInIcon } from '@modrinth/assets'
import { Modal } from '@modrinth/ui'
import { ChatIcon } from '@/assets/icons'
import { ref } from 'vue'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleError } from '@/store/notifications.js'
import mixpanel from 'mixpanel-browser'
import { handleSevereError } from '@/store/error.js'
const errorModal = ref()
const error = ref()
const title = ref('An error occurred')
const errorType = ref('unknown')
const supportLink = ref('https://support.modrinth.com')
const metadata = ref({})
defineExpose({
async show(errorVal) {
if (errorVal.message && errorVal.message.includes('Minecraft authentication error:')) {
title.value = 'Unable to sign in to Minecraft'
errorType.value = 'minecraft_auth'
supportLink.value =
'https://support.modrinth.com/en/articles/9038231-minecraft-sign-in-issues'
if (
errorVal.message.includes('existing connection was forcibly closed') ||
errorVal.message.includes('error sending request for url')
) {
metadata.value.network = true
}
if (errorVal.message.includes('because the target machine actively refused it')) {
metadata.value.hostsFile = true
}
} else if (errorVal.message && errorVal.message.includes('User is not logged in')) {
title.value = 'Sign in to Minecraft'
errorType.value = 'minecraft_sign_in'
supportLink.value = 'https://support.modrinth.com'
} else {
title.value = 'An error occurred'
errorType.value = 'unknown'
supportLink.value = 'https://support.modrinth.com'
metadata.value = {}
}
error.value = errorVal
errorModal.value.show()
},
})
const loadingMinecraft = ref(false)
async function loginMinecraft() {
try {
loadingMinecraft.value = true
const loggedIn = await login_flow()
if (loggedIn) {
await set_default_user(loggedIn.id).catch(handleError)
}
await mixpanel.track('AccountLogIn')
loadingMinecraft.value = false
errorModal.value.hide()
} catch (err) {
loadingMinecraft.value = false
handleSevereError(err)
}
}
</script>
<template>
<Modal ref="errorModal" :header="title">
<div class="modal-body">
<div class="markdown-body">
<template v-if="errorType === 'minecraft_auth'">
<template v-if="metadata.network">
<h3>Network issues</h3>
<p>
It looks like there were issues with the Modrinth App connecting to Microsoft's
servers. This is often the result of a poor connection, so we recommend trying again
to see if it works. If issues continue to persist, follow the steps in
<a
href="https://support.modrinth.com/en/articles/9038231-minecraft-sign-in-issues#h_e71a5f805f"
>
our support article
</a>
to troubleshoot.
</p>
</template>
<template v-else-if="metadata.hostsFile">
<h3>Network issues</h3>
<p>
The Modrinth App tried to connect to Microsoft / Xbox / Minecraft services, but the
remote server rejected the connection. This may indicate that these services are
blocked by the hosts file. Please visit
<a
href="https://support.modrinth.com/en/articles/9038231-minecraft-sign-in-issues#h_d694a29256"
>
our support article
</a>
for steps on how to fix the issue.
</p>
</template>
<template v-else>
<h3>Try another Microsoft account</h3>
<p>
Double check you've signed in with the right account. You may own Minecraft on a
different Microsoft account.
</p>
<div class="cta-button">
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
<LogInIcon /> Try another account
</button>
</div>
<h3>Using PC Game Pass, coming from Bedrock, or just bought the game?</h3>
<p>
Try signing in with the
<a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a>
first. Once you're done, come back here and sign in!
</p>
</template>
<div class="cta-button">
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
<LogInIcon /> Try signing in again
</button>
</div>
<hr />
<p>
If nothing is working and you need help, visit
<a :href="supportLink">our support page</a>
and start a chat using the widget in the bottom right and we will be more than happy to
assist! Make sure to provide the following debug information to the agent:
</p>
<details>
<summary>Debug information</summary>
{{ error.message ?? error }}
</details>
</template>
<div v-else-if="errorType === 'minecraft_sign_in'">
<div class="warning-banner">
<div class="warning-banner__title">
<IssuesIcon />
<span>Installed the app before April 23rd, 2024?</span>
</div>
<div class="warning-banner__description">
Modrinth has updated our sign-in workflow to allow for better stability, security, and
performance. You must sign in again so your credentials can be upgraded to this new
flow.
</div>
</div>
<p>
To play this instance, you must sign in through Microsoft below. If you don't have a
Minecraft account, you can purchase the game on the
<a href="https://www.minecraft.net/en-us/store/minecraft-java-bedrock-edition-pc"
>Minecraft website</a
>.
</p>
<div class="cta-button">
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
<LogInIcon /> Sign in to Minecraft
</button>
</div>
</div>
<template v-else>
{{ error.message ?? error }}
</template>
</div>
<div class="input-group push-right">
<a :href="supportLink" class="btn" @click="errorModal.hide()"><ChatIcon /> Get support</a>
<button class="btn" @clicdck="errorModal.hide()"><XIcon /> Close</button>
</div>
</div>
</Modal>
</template>
<style>
.light-mode {
--color-orange-bg: rgba(255, 163, 71, 0.2);
}
.dark-mode,
.oled-mode {
--color-orange-bg: rgba(224, 131, 37, 0.2);
}
</style>
<style scoped lang="scss">
.cta-button {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
}
.warning-banner {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: var(--gap-lg);
background-color: var(--color-orange-bg);
border: 2px solid var(--color-orange);
border-radius: var(--radius-md);
margin-bottom: 1rem;
}
.warning-banner__title {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 700;
svg {
color: var(--color-orange);
height: 1.5rem;
width: 1.5rem;
}
}
.modal-body {
display: flex;
flex-direction: column;
gap: var(--gap-md);
padding: var(--gap-lg);
}
</style>

View File

@@ -1,305 +0,0 @@
<script setup>
import { XIcon, PlusIcon } from '@modrinth/assets'
import { Button, Checkbox, Modal } from '@modrinth/ui'
import { PackageIcon, VersionIcon } from '@/assets/icons'
import { ref } from 'vue'
import { export_profile_mrpack, get_pack_export_candidates } from '@/helpers/profile.js'
import { open } from '@tauri-apps/api/dialog'
import { handleError } from '@/store/notifications.js'
import { useTheming } from '@/store/theme'
const props = defineProps({
instance: {
type: Object,
required: true,
},
})
defineExpose({
show: () => {
exportModal.value.show()
initFiles()
},
})
const exportModal = ref(null)
const nameInput = ref(props.instance.metadata.name)
const exportDescription = ref('')
const versionInput = ref('1.0.0')
const files = ref([])
const folders = ref([])
const showingFiles = ref(false)
const themeStore = useTheming()
const initFiles = async () => {
const newFolders = new Map()
const sep = '/'
files.value = []
await get_pack_export_candidates(props.instance.path).then((filePaths) =>
filePaths
.map((folder) => ({
path: folder,
name: folder.split(sep).pop(),
selected:
folder.startsWith('mods') ||
folder.startsWith('datapacks') ||
folder.startsWith('resourcepacks') ||
folder.startsWith('shaderpacks') ||
folder.startsWith('config'),
disabled:
folder === 'profile.json' ||
folder.startsWith('modrinth_logs') ||
folder.startsWith('.fabric') ||
folder.includes('.DS_Store'),
}))
.forEach((pathData) => {
const parent = pathData.path.split(sep).slice(0, -1).join(sep)
if (parent !== '') {
if (newFolders.has(parent)) {
newFolders.get(parent).push(pathData)
} else {
newFolders.set(parent, [pathData])
}
} else {
files.value.push(pathData)
}
}),
)
folders.value = [...newFolders.entries()].map(([name, value]) => [
{
name,
showingMore: false,
},
value,
])
}
await initFiles()
const exportPack = async () => {
const filesToExport = files.value.filter((file) => file.selected).map((file) => file.path)
folders.value.forEach((args) => {
args[1].forEach((child) => {
if (child.selected) {
filesToExport.push(child.path)
}
})
})
const outputPath = await open({
directory: true,
multiple: false,
})
if (outputPath) {
export_profile_mrpack(
props.instance.path,
outputPath + `/${nameInput.value} ${versionInput.value}.mrpack`,
filesToExport,
versionInput.value,
exportDescription.value,
nameInput.value,
).catch((err) => handleError(err))
exportModal.value.hide()
}
}
</script>
<template>
<Modal ref="exportModal" header="Export modpack" :noblur="!themeStore.advancedRendering">
<div class="modal-body">
<div class="labeled_input">
<p>Modpack Name</p>
<div class="iconified-input">
<PackageIcon />
<input v-model="nameInput" type="text" placeholder="Modpack name" class="input" />
<Button @click="nameInput = ''">
<XIcon />
</Button>
</div>
</div>
<div class="labeled_input">
<p>Version number</p>
<div class="iconified-input">
<VersionIcon />
<input v-model="versionInput" type="text" placeholder="1.0.0" class="input" />
<Button @click="versionInput = ''">
<XIcon />
</Button>
</div>
</div>
<div class="adjacent-input">
<div class="labeled_input">
<p>Description</p>
<div class="textarea-wrapper">
<textarea v-model="exportDescription" placeholder="Enter modpack description..." />
</div>
</div>
</div>
<div class="table">
<div class="table-head">
<div class="table-cell row-wise">
Select files and folders to include in pack
<Button
class="sleek-primary collapsed-button"
icon-only
@click="() => (showingFiles = !showingFiles)"
>
<PlusIcon v-if="!showingFiles" />
<XIcon v-else />
</Button>
</div>
</div>
<div v-if="showingFiles" class="table-content">
<div v-for="[path, children] of folders" :key="path.name" class="table-row">
<div class="table-cell file-entry">
<div class="file-primary">
<Checkbox
:model-value="children.every((child) => child.selected)"
:label="path.name"
class="select-checkbox"
:disabled="children.every((x) => x.disabled)"
@update:model-value="
(newValue) => children.forEach((child) => (child.selected = newValue))
"
/>
<Checkbox
v-model="path.showingMore"
class="select-checkbox dropdown"
collapsing-toggle-style
/>
</div>
<div v-if="path.showingMore" class="file-secondary">
<div v-for="child in children" :key="child.path" class="file-secondary-row">
<Checkbox
v-model="child.selected"
:label="child.name"
class="select-checkbox"
:disabled="child.disabled"
/>
</div>
</div>
</div>
</div>
<div v-for="file in files" :key="file.path" class="table-row">
<div class="table-cell file-entry">
<div class="file-primary">
<Checkbox
v-model="file.selected"
:label="file.name"
:disabled="file.disabled"
class="select-checkbox"
/>
</div>
</div>
</div>
</div>
</div>
<div class="button-row push-right">
<Button @click="exportModal.hide">
<XIcon />
Cancel
</Button>
<Button color="primary" @click="exportPack">
<PackageIcon />
Export
</Button>
</div>
</div>
</Modal>
</template>
<style scoped lang="scss">
.modal-body {
padding: var(--gap-xl);
display: flex;
flex-direction: column;
gap: var(--gap-md);
}
.labeled_input {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
p {
margin: 0;
}
}
.select-checkbox {
gap: var(--gap-sm);
button.checkbox {
border: none;
}
&.dropdown {
margin-left: auto;
}
}
.table-content {
max-height: 18rem;
overflow-y: auto;
}
.table {
border: 1px solid var(--color-bg);
}
.file-entry {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
}
.file-primary {
display: flex;
align-items: center;
gap: var(--gap-sm);
}
.file-secondary {
margin-left: var(--gap-xl);
display: flex;
flex-direction: column;
gap: var(--gap-sm);
height: 100%;
vertical-align: center;
}
.file-secondary-row {
display: flex;
align-items: center;
gap: var(--gap-sm);
}
.button-row {
display: flex;
gap: var(--gap-sm);
align-items: center;
}
.row-wise {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.textarea-wrapper {
// margin-top: 1rem;
height: 12rem;
textarea {
max-height: 12rem;
}
.preview {
overflow-y: auto;
}
}
</style>

View File

@@ -1,176 +0,0 @@
<template>
<Modal
ref="incompatibleModal"
header="Incompatibility warning"
:noblur="!themeStore.advancedRendering"
>
<div class="modal-body">
<p>
This {{ versions?.length > 0 ? 'project' : 'version' }} is not compatible with the instance
you're trying to install it on. Are you sure you want to continue? Dependencies will not be
installed.
</p>
<table>
<tr class="header">
<th>{{ instance?.metadata.name }}</th>
<th>{{ projectTitle }}</th>
</tr>
<tr class="content">
<td class="data">
{{ instance?.metadata.loader }} {{ instance?.metadata.game_version }}
</td>
<td>
<DropdownSelect
v-if="versions?.length > 1"
v-model="selectedVersion"
:options="versions"
placeholder="Select version"
name="Version select"
:display-name="
(version) =>
`${version?.name} (${version?.loaders
.map((name) => formatCategory(name))
.join(', ')} - ${version?.game_versions.join(', ')})`
"
render-up
/>
<span v-else>
<span>
{{ selectedVersion?.name }} ({{
selectedVersion?.loaders.map((name) => formatCategory(name)).join(', ')
}}
- {{ selectedVersion?.game_versions.join(', ') }})
</span>
</span>
</td>
</tr>
</table>
<div class="button-group">
<Button @click="() => incompatibleModal.hide()"><XIcon />Cancel</Button>
<Button color="primary" :disabled="installing" @click="install()">
<DownloadIcon /> {{ installing ? 'Installing' : 'Install' }}
</Button>
</div>
</div>
</Modal>
</template>
<script setup>
import { XIcon, DownloadIcon } from '@modrinth/assets'
import { Button, Modal, DropdownSelect } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { add_project_from_version as installMod } from '@/helpers/profile'
import { ref } from 'vue'
import { handleError, useTheming } from '@/store/state.js'
import { mixpanel_track } from '@/helpers/mixpanel'
const themeStore = useTheming()
const instance = ref(null)
const project = ref(null)
const projectType = ref(null)
const projectTitle = ref(null)
const versions = ref(null)
const selectedVersion = ref(null)
const incompatibleModal = ref(null)
const installing = ref(false)
let markInstalled = () => {}
defineExpose({
show: (
instanceVal,
projectTitleVal,
selectedVersions,
extMarkInstalled,
projectIdVal,
projectTypeVal,
) => {
instance.value = instanceVal
projectTitle.value = projectTitleVal
versions.value = selectedVersions
selectedVersion.value = selectedVersions[0]
project.value = projectIdVal
projectType.value = projectTypeVal
incompatibleModal.value.show()
markInstalled = extMarkInstalled
mixpanel_track('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' })
},
})
const install = async () => {
installing.value = true
await installMod(instance.value.path, selectedVersion.value.id).catch(handleError)
installing.value = false
markInstalled()
incompatibleModal.value.hide()
mixpanel_track('ProjectInstall', {
loader: instance.value.metadata.loader,
game_version: instance.value.metadata.game_version,
id: project.value,
version_id: selectedVersion.value.id,
project_type: projectType.value,
title: projectTitle.value,
source: 'ProjectIncompatibilityWarningModal',
})
}
</script>
<style lang="scss" scoped>
.data {
text-transform: capitalize;
}
table {
width: 100%;
border-radius: var(--radius-lg);
border-collapse: collapse;
box-shadow: 0 0 0 1px var(--color-button-bg);
}
th {
text-align: left;
padding: 1rem;
background-color: var(--color-bg);
overflow: hidden;
border-bottom: 1px solid var(--color-button-bg);
}
th:first-child {
border-top-left-radius: var(--radius-lg);
border-right: 1px solid var(--color-button-bg);
}
th:last-child {
border-top-right-radius: var(--radius-lg);
}
td {
padding: 1rem;
}
td:first-child {
border-right: 1px solid var(--color-button-bg);
}
.button-group {
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.modal-body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
:deep(.animated-dropdown .options) {
max-height: 13.375rem;
}
}
</style>

View File

@@ -1,72 +0,0 @@
<script setup>
import { XIcon, DownloadIcon } from '@modrinth/assets'
import { Button, Modal } from '@modrinth/ui'
import { install as pack_install } from '@/helpers/pack'
import { ref } from 'vue'
import { mixpanel_track } from '@/helpers/mixpanel'
import { useTheming } from '@/store/theme.js'
import { handleError } from '@/store/state.js'
const themeStore = useTheming()
const version = ref('')
const title = ref('')
const projectId = ref('')
const icon = ref('')
const confirmModal = ref(null)
const installing = ref(false)
defineExpose({
show: (projectIdVal, versionId, projectTitle, projectIcon) => {
projectId.value = projectIdVal
version.value = versionId
title.value = projectTitle
icon.value = projectIcon
installing.value = false
confirmModal.value.show()
mixpanel_track('PackInstallStart')
},
})
async function install() {
installing.value = true
console.log(`Installing ${projectId.value} ${version.value} ${title.value} ${icon.value}`)
confirmModal.value.hide()
await pack_install(
projectId.value,
version.value,
title.value,
icon.value ? icon.value : null,
).catch(handleError)
mixpanel_track('PackInstall', {
id: projectId.value,
version_id: version.value,
title: title.value,
source: 'ConfirmModal',
})
}
</script>
<template>
<Modal ref="confirmModal" header="Are you sure?" :noblur="!themeStore.advancedRendering">
<div class="modal-body">
<p>You already have this modpack installed. Are you sure you want to install it again?</p>
<div class="input-group push-right">
<Button @click="() => $refs.confirmModal.hide()"><XIcon />Cancel</Button>
<Button color="primary" :disabled="installing" @click="install()"
><DownloadIcon /> {{ installing ? 'Installing' : 'Install' }}</Button
>
</div>
</div>
</Modal>
</template>
<style lang="scss" scoped>
.modal-body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
</style>

View File

@@ -1,356 +0,0 @@
<script setup>
import { onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { DownloadIcon, StopCircleIcon, PlayIcon } from '@modrinth/assets'
import { Card, Avatar, AnimatedLogo } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/tauri'
import InstallConfirmModal from '@/components/ui/InstallConfirmModal.vue'
import { install as pack_install } from '@/helpers/pack'
import { list, run } from '@/helpers/profile'
import {
get_all_running_profile_paths,
get_uuids_by_profile_path,
kill_by_uuid,
} from '@/helpers/process'
import { process_listener } from '@/helpers/events'
import { useFetch } from '@/helpers/fetch.js'
import { handleError } from '@/store/state.js'
import { showProfileInFolder } from '@/helpers/utils.js'
import ModInstallModal from '@/components/ui/ModInstallModal.vue'
import { mixpanel_track } from '@/helpers/mixpanel'
import { handleSevereError } from '@/store/error.js'
const props = defineProps({
instance: {
type: Object,
default() {
return {}
},
},
})
const confirmModal = ref(null)
const modInstallModal = ref(null)
const playing = ref(false)
const uuid = ref(null)
const modLoading = ref(
props.instance.install_stage ? props.instance.install_stage !== 'installed' : false,
)
watch(
() => props.instance,
() => {
modLoading.value = props.instance.install_stage
? props.instance.install_stage !== 'installed'
: false
},
)
const router = useRouter()
const seeInstance = async () => {
const instancePath = props.instance.metadata
? `/instance/${encodeURIComponent(props.instance.path)}/`
: `/project/${encodeURIComponent(props.instance.project_id)}/`
await router.push(instancePath)
}
const checkProcess = async () => {
const runningPaths = await get_all_running_profile_paths().catch(handleError)
if (runningPaths.includes(props.instance.path)) {
playing.value = true
return
}
playing.value = false
uuid.value = null
}
const install = async (e) => {
e?.stopPropagation()
modLoading.value = true
const versions = await useFetch(
`https://api.modrinth.com/v2/project/${props.instance.project_id}/version`,
'project versions',
)
if (props.instance.project_type === 'modpack') {
const packs = Object.values(await list(true).catch(handleError))
if (
packs.length === 0 ||
!packs
.map((value) => value.metadata)
.find((pack) => pack.linked_data?.project_id === props.instance.project_id)
) {
modLoading.value = true
await pack_install(
props.instance.project_id,
versions[0].id,
props.instance.title,
props.instance.icon_url,
).catch(handleError)
modLoading.value = false
mixpanel_track('PackInstall', {
id: props.instance.project_id,
version_id: versions[0].id,
title: props.instance.title,
source: 'InstanceCard',
})
} else
confirmModal.value.show(
props.instance.project_id,
versions[0].id,
props.instance.title,
props.instance.icon_url,
)
} else {
modInstallModal.value.show(
props.instance.project_id,
versions,
props.instance.title,
props.instance.project_type,
)
}
modLoading.value = false
}
const play = async (e, context) => {
e?.stopPropagation()
modLoading.value = true
uuid.value = await run(props.instance.path).catch(handleSevereError)
modLoading.value = false
playing.value = true
mixpanel_track('InstancePlay', {
loader: props.instance.metadata.loader,
game_version: props.instance.metadata.game_version,
source: context,
})
}
const stop = async (e, context) => {
e?.stopPropagation()
playing.value = false
// If we lost the uuid for some reason, such as a user navigating
// from-then-back to this page, we will get all uuids by the instance path.
// For-each uuid, kill the process.
if (!uuid.value) {
const uuids = await get_uuids_by_profile_path(props.instance.path).catch(handleError)
uuid.value = uuids[0]
uuids.forEach(async (u) => await kill_by_uuid(u).catch(handleError))
} else await kill_by_uuid(uuid.value).catch(handleError) // If we still have the uuid, just kill it
mixpanel_track('InstanceStop', {
loader: props.instance.metadata.loader,
game_version: props.instance.metadata.game_version,
source: context,
})
uuid.value = null
}
const openFolder = async () => {
await showProfileInFolder(props.instance.path)
}
const addContent = async () => {
await router.push({
path: `/browse/${props.instance.metadata.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: props.instance.path },
})
}
defineExpose({
install,
playing,
play,
stop,
seeInstance,
openFolder,
addContent,
instance: props.instance,
})
const unlisten = await process_listener((e) => {
if (e.event === 'finished' && e.uuid === uuid.value) playing.value = false
})
onUnmounted(() => unlisten())
</script>
<template>
<div class="instance">
<Card class="instance-card-item button-base" @click="seeInstance" @mouseenter="checkProcess">
<Avatar
size="lg"
:src="
props.instance.metadata
? !props.instance.metadata.icon ||
(props.instance.metadata.icon && props.instance.metadata.icon.startsWith('http'))
? props.instance.metadata.icon
: convertFileSrc(props.instance.metadata?.icon)
: props.instance.icon_url
"
alt="Mod card"
class="mod-image"
/>
<div class="project-info">
<p class="title">{{ props.instance.metadata?.name || props.instance.title }}</p>
<p class="description">
{{ props.instance.metadata?.loader }}
{{ props.instance.metadata?.game_version || props.instance.latest_version }}
</p>
</div>
</Card>
<div
v-if="props.instance.metadata && playing === false && modLoading === false"
class="install cta button-base"
@click="(e) => play(e, 'InstanceCard')"
>
<PlayIcon />
</div>
<div v-else-if="modLoading === true && playing === false" class="cta loading-cta">
<AnimatedLogo class="loading-indicator" />
</div>
<div
v-else-if="playing === true"
class="stop cta button-base"
@click="(e) => stop(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<StopCircleIcon />
</div>
<div v-else class="install cta button-base" @click="install"><DownloadIcon /></div>
<InstallConfirmModal ref="confirmModal" />
<ModInstallModal ref="modInstallModal" />
</div>
</template>
<style lang="scss">
.loading-indicator {
width: 2.5rem !important;
height: 2.5rem !important;
svg {
width: 2.5rem !important;
height: 2.5rem !important;
}
}
</style>
<style lang="scss" scoped>
.instance {
position: relative;
&:hover {
.cta {
opacity: 1;
bottom: calc(var(--gap-md) + 4.25rem);
}
}
}
.cta {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
z-index: 1;
width: 3rem;
height: 3rem;
right: calc(var(--gap-md) * 2);
bottom: 3.25rem;
opacity: 0;
transition:
0.2s ease-in-out bottom,
0.2s ease-in-out opacity,
0.1s ease-in-out filter !important;
cursor: pointer;
box-shadow: var(--shadow-floating);
svg {
color: var(--color-accent-contrast);
width: 1.5rem !important;
height: 1.5rem !important;
}
&.install {
background: var(--color-brand);
display: flex;
}
&.stop {
background: var(--color-red);
display: flex;
}
&.loading-cta {
background: hsl(220, 11%, 10%) !important;
display: flex;
justify-content: center;
align-items: center;
}
}
.instance-card-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
padding: var(--gap-md);
transition: 0.1s ease-in-out all !important; /* overrides Omorphia defaults */
margin-bottom: 0;
.mod-image {
--size: 100%;
width: 100% !important;
height: auto !important;
max-width: unset !important;
max-height: unset !important;
aspect-ratio: 1 / 1 !important;
}
.project-info {
margin-top: 1rem;
width: 100%;
.title {
color: var(--color-contrast);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
width: 100%;
margin: 0;
font-weight: 600;
font-size: 1rem;
line-height: 110%;
display: inline-block;
}
.description {
color: var(--color-base);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-weight: 500;
font-size: 0.775rem;
line-height: 125%;
margin: 0.25rem 0 0;
text-transform: capitalize;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
</style>

View File

@@ -1,679 +0,0 @@
<template>
<Modal ref="modal" header="Create instance" :noblur="!themeStore.advancedRendering">
<div class="modal-header">
<Chips v-model="creationType" :items="['custom', 'from file', 'import from launcher']" />
</div>
<hr class="card-divider" />
<div v-if="creationType === 'custom'" class="modal-body">
<div class="image-upload">
<Avatar :src="display_icon" size="md" :rounded="true" />
<div class="image-input">
<Button @click="upload_icon()">
<UploadIcon />
Select icon
</Button>
<Button :disabled="!display_icon" @click="reset_icon">
<XIcon />
Remove icon
</Button>
</div>
</div>
<div class="input-row">
<p class="input-label">Name</p>
<input
v-model="profile_name"
autocomplete="off"
class="text-input"
type="text"
maxlength="100"
/>
</div>
<div class="input-row">
<p class="input-label">Loader</p>
<Chips v-model="loader" :items="loaders" />
</div>
<div class="input-row">
<p class="input-label">Game version</p>
<div class="versions">
<multiselect
v-model="game_version"
class="selector"
:options="game_versions"
:multiple="false"
:searchable="true"
placeholder="Select game version"
open-direction="top"
:show-labels="false"
/>
<Checkbox
v-if="showAdvanced"
v-model="showSnapshots"
class="filter-checkbox"
label="Include snapshots"
/>
</div>
</div>
<div v-if="showAdvanced && loader !== 'vanilla'" class="input-row">
<p class="input-label">Loader version</p>
<Chips v-model="loader_version" :items="['stable', 'latest', 'other']" />
</div>
<div v-if="showAdvanced && loader_version === 'other' && loader !== 'vanilla'">
<div v-if="game_version" class="input-row">
<p class="input-label">Select version</p>
<multiselect
v-model="specified_loader_version"
class="selector"
:options="selectable_versions"
:searchable="true"
placeholder="Select loader version"
open-direction="top"
:show-labels="false"
/>
</div>
<div v-else class="input-row">
<p class="warning">Select a game version before you select a loader version</p>
</div>
</div>
<div class="input-group push-right">
<Button @click="toggle_advanced">
<CodeIcon />
{{ showAdvanced ? 'Hide advanced' : 'Show advanced' }}
</Button>
<Button @click="hide()">
<XIcon />
Cancel
</Button>
<Button color="primary" :disabled="!check_valid || creating" @click="create_instance()">
<PlusIcon v-if="!creating" />
{{ creating ? 'Creating...' : 'Create' }}
</Button>
</div>
</div>
<div v-else-if="creationType === 'from file'" class="modal-body">
<Button @click="openFile"> <FolderOpenIcon /> Import from file </Button>
<div class="info"><InfoIcon /> Or drag and drop your .mrpack file</div>
</div>
<div v-else class="modal-body">
<Chips
v-model="selectedProfileType"
:items="profileOptions"
:format-label="(profile) => profile?.name"
/>
<div class="path-selection">
<h3>{{ selectedProfileType.name }} path</h3>
<div class="path-input">
<div class="iconified-input">
<FolderOpenIcon />
<input
v-model="selectedProfileType.path"
type="text"
placeholder="Path to launcher"
@change="setPath"
/>
<Button @click="() => (selectedLauncherPath = '')">
<XIcon />
</Button>
</div>
<Button icon-only @click="selectLauncherPath">
<FolderSearchIcon />
</Button>
<Button icon-only @click="reload">
<UpdatedIcon />
</Button>
</div>
</div>
<div class="table">
<div class="table-head table-row">
<div class="toggle-all table-cell">
<Checkbox
class="select-checkbox"
:model-value="
profiles.get(selectedProfileType.name)?.every((child) => child.selected)
"
@update:model-value="
(newValue) =>
profiles
.get(selectedProfileType.name)
?.forEach((child) => (child.selected = newValue))
"
/>
</div>
<div class="name-cell table-cell">Profile name</div>
</div>
<div
v-if="
profiles.get(selectedProfileType.name) &&
profiles.get(selectedProfileType.name).length > 0
"
class="table-content"
>
<div
v-for="(profile, index) in profiles.get(selectedProfileType.name)"
:key="index"
class="table-row"
>
<div class="checkbox-cell table-cell">
<Checkbox v-model="profile.selected" class="select-checkbox" />
</div>
<div class="name-cell table-cell">
{{ profile.name }}
</div>
</div>
</div>
<div v-else class="table-content empty">No profiles found</div>
</div>
<div class="button-row">
<Button
:disabled="
loading ||
!Array.from(profiles.values())
.flatMap((e) => e)
.some((e) => e.selected)
"
color="primary"
@click="next"
>
{{
loading
? 'Importing...'
: Array.from(profiles.values())
.flatMap((e) => e)
.some((e) => e.selected)
? `Import ${
Array.from(profiles.values())
.flatMap((e) => e)
.filter((e) => e.selected).length
} profiles`
: 'Select profiles to import'
}}
</Button>
<ProgressBar
v-if="loading"
:progress="(importedProfiles / (totalProfiles + 0.0001)) * 100"
/>
</div>
</div>
</Modal>
</template>
<script setup>
import {
PlusIcon,
UploadIcon,
XIcon,
CodeIcon,
FolderOpenIcon,
InfoIcon,
FolderSearchIcon,
UpdatedIcon,
} from '@modrinth/assets'
import { Avatar, Button, Chips, Modal, Checkbox } from '@modrinth/ui'
import { computed, onUnmounted, ref, shallowRef } from 'vue'
import { get_loaders } from '@/helpers/tags'
import { create } from '@/helpers/profile'
import { open } from '@tauri-apps/api/dialog'
import { tauri } from '@tauri-apps/api'
import {
get_game_versions,
get_fabric_versions,
get_forge_versions,
get_quilt_versions,
get_neoforge_versions,
} from '@/helpers/metadata'
import { handleError } from '@/store/notifications.js'
import Multiselect from 'vue-multiselect'
import { mixpanel_track } from '@/helpers/mixpanel'
import { useTheming } from '@/store/state.js'
import { listen } from '@tauri-apps/api/event'
import { install_from_file } from '@/helpers/pack.js'
import {
get_default_launcher_path,
get_importable_instances,
import_instance,
} from '@/helpers/import.js'
import ProgressBar from '@/components/ui/ProgressBar.vue'
const themeStore = useTheming()
const profile_name = ref('')
const game_version = ref('')
const loader = ref('vanilla')
const loader_version = ref('stable')
const specified_loader_version = ref('')
const icon = ref(null)
const display_icon = ref(null)
const showAdvanced = ref(false)
const creating = ref(false)
const showSnapshots = ref(false)
const creationType = ref('from file')
const isShowing = ref(false)
defineExpose({
show: async () => {
game_version.value = ''
specified_loader_version.value = ''
profile_name.value = ''
creating.value = false
showAdvanced.value = false
showSnapshots.value = false
loader.value = 'vanilla'
loader_version.value = 'stable'
icon.value = null
display_icon.value = null
isShowing.value = true
modal.value.show()
unlistener.value = await listen('tauri://file-drop', async (event) => {
// Only if modal is showing
if (!isShowing.value) return
if (creationType.value !== 'from file') return
hide()
if (event.payload && event.payload.length > 0 && event.payload[0].endsWith('.mrpack')) {
await install_from_file(event.payload[0]).catch(handleError)
mixpanel_track('InstanceCreate', {
source: 'CreationModalFileDrop',
})
}
})
mixpanel_track('InstanceCreateStart', { source: 'CreationModal' })
},
})
const unlistener = ref(null)
const hide = () => {
isShowing.value = false
modal.value.hide()
if (unlistener.value) {
unlistener.value()
unlistener.value = null
}
}
onUnmounted(() => {
if (unlistener.value) {
unlistener.value()
unlistener.value = null
}
})
const [
fabric_versions,
forge_versions,
quilt_versions,
neoforge_versions,
all_game_versions,
loaders,
] = await Promise.all([
get_fabric_versions().then(shallowRef).catch(handleError),
get_forge_versions().then(shallowRef).catch(handleError),
get_quilt_versions().then(shallowRef).catch(handleError),
get_neoforge_versions().then(shallowRef).catch(handleError),
get_game_versions().then(shallowRef).catch(handleError),
get_loaders()
.then((value) =>
value
.filter((item) => item.supported_project_types.includes('modpack'))
.map((item) => item.name.toLowerCase()),
)
.then(ref)
.catch(handleError),
])
loaders.value.unshift('vanilla')
const game_versions = computed(() => {
return all_game_versions.value.versions
.filter((item) => {
let defaultVal = item.type === 'release' || showSnapshots.value
if (loader.value === 'fabric') {
defaultVal &= fabric_versions.value.gameVersions.some((x) => item.id === x.id)
} else if (loader.value === 'forge') {
defaultVal &= forge_versions.value.gameVersions.some((x) => item.id === x.id)
} else if (loader.value === 'quilt') {
defaultVal &= quilt_versions.value.gameVersions.some((x) => item.id === x.id)
} else if (loader.value === 'neoforge') {
defaultVal &= neoforge_versions.value.gameVersions.some((x) => item.id === x.id)
}
return defaultVal
})
.map((item) => item.id)
})
const modal = ref(null)
const check_valid = computed(() => {
return (
profile_name.value.trim() &&
game_version.value &&
game_versions.value.includes(game_version.value)
)
})
const create_instance = async () => {
creating.value = true
const loader_version_value =
loader_version.value === 'other' ? specified_loader_version.value : loader_version.value
const loaderVersion = loader.value === 'vanilla' ? null : loader_version_value ?? 'stable'
hide()
creating.value = false
await create(
profile_name.value,
game_version.value,
loader.value,
loader.value === 'vanilla' ? null : loader_version_value ?? 'stable',
icon.value,
).catch(handleError)
mixpanel_track('InstanceCreate', {
profile_name: profile_name.value,
game_version: game_version.value,
loader: loader.value,
loader_version: loaderVersion,
has_icon: !!icon.value,
source: 'CreationModal',
})
}
const upload_icon = async () => {
icon.value = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'svg', 'webp', 'gif', 'jpg'],
},
],
})
if (!icon.value) return
display_icon.value = tauri.convertFileSrc(icon.value)
}
const reset_icon = () => {
icon.value = null
display_icon.value = null
}
const selectable_versions = computed(() => {
if (game_version.value) {
if (loader.value === 'fabric') {
return fabric_versions.value.gameVersions[0].loaders.map((item) => item.id)
} else if (loader.value === 'forge') {
return forge_versions.value.gameVersions
.find((item) => item.id === game_version.value)
.loaders.map((item) => item.id)
} else if (loader.value === 'quilt') {
return quilt_versions.value.gameVersions[0].loaders.map((item) => item.id)
} else if (loader.value === 'neoforge') {
return neoforge_versions.value.gameVersions
.find((item) => item.id === game_version.value)
.loaders.map((item) => item.id)
}
}
return []
})
const toggle_advanced = () => {
showAdvanced.value = !showAdvanced.value
}
const openFile = async () => {
const newProject = await open({ multiple: false })
if (!newProject) return
hide()
await install_from_file(newProject).catch(handleError)
mixpanel_track('InstanceCreate', {
source: 'CreationModalFileOpen',
})
}
const profiles = ref(
new Map([
['MultiMC', []],
['GDLauncher', []],
['ATLauncher', []],
['Curseforge', []],
['PrismLauncher', []],
]),
)
const loading = ref(false)
const importedProfiles = ref(0)
const totalProfiles = ref(0)
const selectedProfileType = ref('MultiMC')
const profileOptions = ref([
{ name: 'MultiMC', path: '' },
{ name: 'GDLauncher', path: '' },
{ name: 'ATLauncher', path: '' },
{ name: 'Curseforge', path: '' },
{ name: 'PrismLauncher', path: '' },
])
// Attempt to get import profiles on default paths
const promises = profileOptions.value.map(async (option) => {
const path = await get_default_launcher_path(option.name).catch(handleError)
if (!path || path === '') return
// Try catch to allow failure and simply ignore default path attempt
try {
const instances = await get_importable_instances(option.name, path)
if (!instances) return
profileOptions.value.find((profile) => profile.name === option.name).path = path
profiles.value.set(
option.name,
instances.map((name) => ({ name, selected: false })),
)
} catch (error) {
// Allow failure silently
}
})
await Promise.all(promises)
const selectLauncherPath = async () => {
selectedProfileType.value.path = await open({ multiple: false, directory: true })
if (selectedProfileType.value.path) {
await reload()
}
}
const reload = async () => {
const instances = await get_importable_instances(
selectedProfileType.value.name,
selectedProfileType.value.path,
).catch(handleError)
if (instances) {
profiles.value.set(
selectedProfileType.value.name,
instances.map((name) => ({ name, selected: false })),
)
} else {
profiles.value.set(selectedProfileType.value.name, [])
}
}
const setPath = () => {
profileOptions.value.find((profile) => profile.name === selectedProfileType.value.name).path =
selectedProfileType.value.path
}
const next = async () => {
importedProfiles.value = 0
totalProfiles.value = Array.from(profiles.value.values())
.map((profiles) => profiles.filter((profile) => profile.selected).length)
.reduce((a, b) => a + b, 0)
loading.value = true
for (const launcher of Array.from(profiles.value.entries()).map(([launcher, profiles]) => ({
launcher,
path: profileOptions.value.find((option) => option.name === launcher).path,
profiles,
}))) {
for (const profile of launcher.profiles.filter((profile) => profile.selected)) {
await import_instance(launcher.launcher, launcher.path, profile.name)
.catch(handleError)
.then(() => console.log(`Successfully Imported ${profile.name} from ${launcher.launcher}`))
profile.selected = false
importedProfiles.value++
}
}
loading.value = false
}
</script>
<style lang="scss" scoped>
.modal-body {
display: flex;
flex-direction: column;
padding: var(--gap-lg);
gap: var(--gap-md);
}
.input-label {
font-size: 1rem;
font-weight: bolder;
color: var(--color-contrast);
margin-bottom: 0.5rem;
}
.text-input {
width: 20rem;
}
.image-upload {
display: flex;
gap: 1rem;
}
.image-input {
display: flex;
flex-direction: column;
gap: 0.5rem;
justify-content: center;
}
.warning {
font-style: italic;
}
.versions {
display: flex;
flex-direction: row;
gap: 1rem;
}
:deep(button.checkbox) {
border: none;
}
.selector {
max-width: 20rem;
}
.labeled-divider {
text-align: center;
}
.labeled-divider:after {
background-color: var(--color-raised-bg);
content: 'Or';
color: var(--color-base);
padding: var(--gap-sm);
position: relative;
top: -0.5rem;
}
.info {
display: flex;
flex-direction: row;
gap: 0.5rem;
align-items: center;
}
.modal-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: var(--gap-lg);
padding-bottom: 0;
}
.path-selection {
padding: var(--gap-xl);
background-color: var(--color-bg);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
gap: var(--gap-md);
h3 {
margin: 0;
}
.path-input {
display: flex;
align-items: center;
width: 100%;
flex-direction: row;
gap: var(--gap-sm);
.iconified-input {
flex-grow: 1;
:deep(input) {
width: 100%;
flex-basis: auto;
}
}
}
}
.table {
border: 1px solid var(--color-bg);
}
.table-row {
grid-template-columns: min-content auto;
}
.table-content {
max-height: calc(5 * (18px + 2rem));
height: calc(5 * (18px + 2rem));
overflow-y: auto;
}
.select-checkbox {
button.checkbox {
border: none;
}
}
.button-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: var(--gap-md);
.transparent {
padding: var(--gap-sm) 0;
}
}
.empty {
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: bolder;
color: var(--color-contrast);
}
.card-divider {
margin: var(--gap-md) var(--gap-lg) 0 var(--gap-lg);
}
</style>

View File

@@ -1,102 +0,0 @@
<template>
<Modal ref="detectJavaModal" header="Select java version" :noblur="!themeStore.advancedRendering">
<div class="auto-detect-modal">
<div class="table">
<div class="table-row table-head">
<div class="table-cell table-text">Version</div>
<div class="table-cell table-text">Path</div>
<div class="table-cell table-text">Actions</div>
</div>
<div v-for="javaInstall in chosenInstallOptions" :key="javaInstall.path" class="table-row">
<div class="table-cell table-text">
<span>{{ javaInstall.version }}</span>
</div>
<div v-tooltip="javaInstall.path" class="table-cell table-text">
<span>{{ javaInstall.path }}</span>
</div>
<div class="table-cell table-text manage">
<Button v-if="currentSelected.path === javaInstall.path" disabled
><CheckIcon /> Selected</Button
>
<Button v-else @click="setJavaInstall(javaInstall)"><PlusIcon /> Select</Button>
</div>
</div>
<div v-if="chosenInstallOptions.length === 0" class="table-row entire-row">
<div class="table-cell table-text">No java installations found!</div>
</div>
</div>
<div class="input-group push-right">
<Button @click="$refs.detectJavaModal.hide()">
<XIcon />
Cancel
</Button>
</div>
</div>
</Modal>
</template>
<script setup>
import { PlusIcon, CheckIcon, XIcon } from '@modrinth/assets'
import { Modal, Button } from '@modrinth/ui'
import { ref } from 'vue'
import { find_filtered_jres } from '@/helpers/jre.js'
import { handleError } from '@/store/notifications.js'
import { mixpanel_track } from '@/helpers/mixpanel'
import { useTheming } from '@/store/theme.js'
const themeStore = useTheming()
const chosenInstallOptions = ref([])
const detectJavaModal = ref(null)
const currentSelected = ref({})
defineExpose({
show: async (version, currentSelectedJava) => {
chosenInstallOptions.value = await find_filtered_jres(version).catch(handleError)
console.log(chosenInstallOptions.value)
console.log(version)
currentSelected.value = currentSelectedJava
if (!currentSelected.value) {
currentSelected.value = { path: '', version: '' }
}
detectJavaModal.value.show()
},
})
const emit = defineEmits(['submit'])
function setJavaInstall(javaInstall) {
emit('submit', javaInstall)
detectJavaModal.value.hide()
mixpanel_track('JavaAutoDetect', {
path: javaInstall.path,
version: javaInstall.version,
})
}
</script>
<style lang="scss" scoped>
.auto-detect-modal {
padding: 1rem;
.table {
.table-row {
grid-template-columns: 1fr 4fr min-content;
}
span {
display: inherit;
align-items: center;
justify-content: center;
}
padding: 0.5rem;
}
}
.manage {
display: flex;
gap: 0.5rem;
}
</style>

View File

@@ -1,217 +0,0 @@
<template>
<JavaDetectionModal ref="detectJavaModal" @submit="(val) => emit('update:modelValue', val)" />
<div class="toggle-setting" :class="{ compact }">
<input
autocomplete="off"
:disabled="props.disabled"
:value="props.modelValue ? props.modelValue.path : ''"
type="text"
class="installation-input"
:placeholder="placeholder ?? '/path/to/java'"
@input="
(val) => {
emit('update:modelValue', {
...props.modelValue,
path: val.target.value,
})
}
"
/>
<span class="installation-buttons">
<Button
v-if="props.version"
:disabled="props.disabled || installingJava"
@click="reinstallJava"
>
<DownloadIcon />
{{ installingJava ? 'Installing...' : 'Install recommended' }}
</Button>
<Button :disabled="props.disabled" @click="autoDetect">
<SearchIcon />
Auto detect
</Button>
<Button :disabled="props.disabled" @click="handleJavaFileInput()">
<FolderSearchIcon />
Browse
</Button>
<Button v-if="testingJava" disabled> Testing... </Button>
<Button v-else-if="testingJavaSuccess === true">
<CheckIcon class="test-success" />
Success
</Button>
<Button v-else-if="testingJavaSuccess === false">
<XIcon class="test-fail" />
Failed
</Button>
<Button v-else :disabled="props.disabled" @click="testJava">
<PlayIcon />
Test
</Button>
</span>
</div>
</template>
<script setup>
import {
SearchIcon,
PlayIcon,
CheckIcon,
XIcon,
FolderSearchIcon,
DownloadIcon,
} from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import { auto_install_java, find_filtered_jres, get_jre, test_jre } from '@/helpers/jre.js'
import { ref } from 'vue'
import { open } from '@tauri-apps/api/dialog'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import { mixpanel_track } from '@/helpers/mixpanel'
import { handleError } from '@/store/state.js'
const props = defineProps({
version: {
type: Number,
required: false,
default: null,
},
modelValue: {
type: Object,
default: () => ({
path: '',
version: '',
}),
},
disabled: {
type: Boolean,
required: false,
default: false,
},
placeholder: {
type: String,
required: false,
default: null,
},
compact: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const testingJava = ref(false)
const testingJavaSuccess = ref(null)
const installingJava = ref(false)
async function testJava() {
testingJava.value = true
testingJavaSuccess.value = await test_jre(
props.modelValue ? props.modelValue.path : '',
1,
props.version,
)
testingJava.value = false
mixpanel_track('JavaTest', {
path: props.modelValue ? props.modelValue.path : '',
success: testingJavaSuccess.value,
})
setTimeout(() => {
testingJavaSuccess.value = null
}, 2000)
}
async function handleJavaFileInput() {
let filePath = await open()
if (filePath) {
let result = await get_jre(filePath)
if (!result) {
result = {
path: filePath,
version: props.version.toString(),
architecture: 'x86',
}
}
mixpanel_track('JavaManualSelect', {
path: filePath,
version: props.version,
})
emit('update:modelValue', result)
}
}
const detectJavaModal = ref(null)
async function autoDetect() {
if (!props.compact) {
detectJavaModal.value.show(props.version, props.modelValue)
} else {
let versions = await find_filtered_jres(props.version).catch(handleError)
if (versions.length > 0) {
emit('update:modelValue', versions[0])
}
}
}
async function reinstallJava() {
installingJava.value = true
const path = await auto_install_java(props.version).catch(handleError)
let result = await get_jre(path)
console.log('java result ' + result)
if (!result) {
result = {
path: path,
version: props.version.toString(),
architecture: 'x86',
}
}
mixpanel_track('JavaReInstall', {
path: path,
version: props.version,
})
emit('update:modelValue', result)
installingJava.value = false
}
</script>
<style lang="scss" scoped>
.installation-input {
width: 100% !important;
flex-grow: 1;
}
.toggle-setting {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
&.compact {
flex-wrap: wrap;
}
}
.installation-buttons {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
margin: 0;
}
.test-success {
color: var(--color-green);
}
.test-fail {
color: var(--color-red);
}
</style>

View File

@@ -1,417 +0,0 @@
<script setup>
import {
DownloadIcon,
PlusIcon,
UploadIcon,
XIcon,
RightArrowIcon,
CheckIcon,
} from '@modrinth/assets'
import { Avatar, Modal, Button, Card } from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
add_project_from_version as installMod,
check_installed,
get,
list,
} from '@/helpers/profile'
import { open } from '@tauri-apps/api/dialog'
import { create } from '@/helpers/profile'
import { installVersionDependencies } from '@/helpers/utils'
import { handleError } from '@/store/notifications.js'
import { mixpanel_track } from '@/helpers/mixpanel'
import { useTheming } from '@/store/theme.js'
import { useRouter } from 'vue-router'
import { tauri } from '@tauri-apps/api'
const themeStore = useTheming()
const router = useRouter()
const versions = ref([])
const project = ref('')
const projectTitle = ref('')
const projectType = ref('')
const installModal = ref(null)
const searchFilter = ref('')
const showCreation = ref(false)
const icon = ref(null)
const name = ref(null)
const display_icon = ref(null)
const loader = ref(null)
const gameVersion = ref(null)
const creatingInstance = ref(false)
defineExpose({
show: async (projectId, selectedVersions, title, type) => {
project.value = projectId
versions.value = selectedVersions
projectTitle.value = title
projectType.value = type
installModal.value.show()
searchFilter.value = ''
profiles.value = await getData()
mixpanel_track('ProjectInstallStart', { source: 'ProjectInstallModal' })
},
})
const profiles = ref([])
async function install(instance) {
instance.installing = true
const version = versions.value.find((v) => {
return (
v.game_versions.includes(instance.metadata.game_version) &&
(v.loaders.includes(instance.metadata.loader) ||
v.loaders.includes('minecraft') ||
v.loaders.includes('iris') ||
v.loaders.includes('optifine'))
)
})
if (!version) {
instance.installing = false
handleError('No compatible version found')
return
}
await installMod(instance.path, version.id).catch(handleError)
await installVersionDependencies(instance, version)
instance.installedMod = true
instance.installing = false
mixpanel_track('ProjectInstall', {
loader: instance.metadata.loader,
game_version: instance.metadata.game_version,
id: project.value,
version_id: version.id,
project_type: projectType.value,
title: projectTitle.value,
source: 'ProjectInstallModal',
})
}
async function getData() {
const projects = await list(true).then(Object.values).catch(handleError)
const filtered = projects
.filter((profile) => {
return profile.metadata.name.toLowerCase().includes(searchFilter.value.toLowerCase())
})
.filter((profile) => {
return (
versions.value.flatMap((v) => v.game_versions).includes(profile.metadata.game_version) &&
versions.value
.flatMap((v) => v.loaders)
.some(
(value) =>
value === profile.metadata.loader ||
['minecraft', 'iris', 'optifine'].includes(value),
)
)
})
for (let profile of filtered) {
profile.installing = false
profile.installedMod = await check_installed(profile.path, project.value).catch(handleError)
}
return filtered
}
const alreadySentCreation = ref(false)
const toggleCreation = () => {
showCreation.value = !showCreation.value
name.value = null
icon.value = null
display_icon.value = null
gameVersion.value = null
loader.value = null
if (!alreadySentCreation.value) {
alreadySentCreation.value = false
mixpanel_track('InstanceCreateStart', { source: 'ProjectInstallModal' })
}
}
const upload_icon = async () => {
icon.value = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg'],
},
],
})
if (!icon.value) return
display_icon.value = tauri.convertFileSrc(icon.value)
}
const reset_icon = () => {
icon.value = null
display_icon.value = null
}
const createInstance = async () => {
creatingInstance.value = true
const loader =
versions.value[0].loaders[0] !== 'forge' &&
versions.value[0].loaders[0] !== 'fabric' &&
versions.value[0].loaders[0] !== 'quilt'
? 'vanilla'
: versions.value[0].loaders[0]
const id = await create(
name.value,
versions.value[0].game_versions[0],
loader,
'latest',
icon.value,
).catch(handleError)
await installMod(id, versions.value[0].id).catch(handleError)
await router.push(`/instance/${encodeURIComponent(id)}/`)
const instance = await get(id, true)
await installVersionDependencies(instance, versions.value[0])
mixpanel_track('InstanceCreate', {
profile_name: name.value,
game_version: versions.value[0].game_versions[0],
loader: loader,
loader_version: 'latest',
has_icon: !!icon.value,
source: 'ProjectInstallModal',
})
mixpanel_track('ProjectInstall', {
loader: loader,
game_version: versions.value[0].game_versions[0],
id: project.value,
version_id: versions.value[0].id,
project_type: projectType.value,
title: projectTitle.value,
source: 'ProjectInstallModal',
})
if (installModal.value) installModal.value.hide()
creatingInstance.value = false
}
const check_valid = computed(() => {
return name.value
})
</script>
<template>
<Modal
ref="installModal"
header="Install project to instance"
:noblur="!themeStore.advancedRendering"
>
<div class="modal-body">
<input
v-model="searchFilter"
autocomplete="off"
type="text"
class="search"
placeholder="Search for an instance"
/>
<div class="profiles" :class="{ 'hide-creation': !showCreation }">
<div v-for="profile in profiles" :key="profile.metadata.name" class="option">
<Button
color="raised"
class="profile-button"
@click="$router.push(`/instance/${encodeURIComponent(profile.path)}`)"
>
<Avatar
:src="
!profile.metadata.icon ||
(profile.metadata.icon && profile.metadata.icon.startsWith('http'))
? profile.metadata.icon
: tauri.convertFileSrc(profile.metadata?.icon)
"
class="profile-image"
/>
{{ profile.metadata.name }}
</Button>
<div
v-tooltip="
profile.metadata.linked_data?.locked && !profile.installedMod
? 'Unpair or unlock an instance to add mods.'
: ''
"
>
<Button
:disabled="
profile.installedMod || profile.installing || profile.metadata.linked_data?.locked
"
@click="install(profile)"
>
<DownloadIcon v-if="!profile.installedMod && !profile.installing" />
<CheckIcon v-else-if="profile.installedMod" />
{{
profile.installing
? 'Installing...'
: profile.installedMod
? 'Installed'
: profile.metadata.linked_data && profile.metadata.linked_data.locked
? 'Paired'
: 'Install'
}}
</Button>
</div>
</div>
</div>
<Card v-if="showCreation" class="creation-card">
<div class="creation-container">
<div class="creation-icon">
<Avatar size="md" class="icon" :src="display_icon" />
<div class="creation-icon__description">
<Button @click="upload_icon()">
<UploadIcon />
<span class="no-wrap"> Select icon </span>
</Button>
<Button :disabled="!display_icon" @click="reset_icon()">
<XIcon />
<span class="no-wrap"> Remove icon </span>
</Button>
</div>
</div>
<div class="creation-settings">
<input
v-model="name"
autocomplete="off"
type="text"
placeholder="Name"
class="creation-input"
/>
<Button :disabled="creatingInstance === true || !check_valid" @click="createInstance()">
<RightArrowIcon />
{{ creatingInstance ? 'Creating...' : 'Create' }}
</Button>
</div>
</div>
</Card>
<div class="input-group push-right">
<Button :color="showCreation ? '' : 'primary'" @click="toggleCreation()">
<PlusIcon />
{{ showCreation ? 'Hide New Instance' : 'Create new instance' }}
</Button>
<Button @click="installModal.hide()">Cancel</Button>
</div>
</div>
</Modal>
</template>
<style scoped lang="scss">
.creation-card {
display: flex;
flex-direction: column;
gap: 1rem;
margin: 0;
padding: 1rem;
background-color: var(--color-bg);
}
.creation-container {
display: flex;
flex-direction: row;
gap: 1rem;
}
.creation-icon {
display: flex;
flex-direction: row;
gap: 1rem;
align-items: center;
flex-grow: 1;
.creation-icon__description {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
}
.creation-input {
width: 100%;
}
.no-wrap {
white-space: nowrap;
}
.creation-dropdown {
width: min-content !important;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.creation-settings {
width: 100%;
margin-left: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
justify-content: center;
}
.modal-body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.profiles {
max-height: 12rem;
overflow-y: auto;
&.hide-creation {
max-height: 21rem;
}
}
.option {
width: calc(100%);
background: var(--color-raised-bg);
color: var(--color-base);
box-shadow: none;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 0.5rem;
gap: 0.5rem;
img {
margin-right: 0.5rem;
}
.name {
display: flex;
flex-direction: column;
justify-content: center;
}
.profile-button {
align-content: start;
padding: 0.5rem;
text-align: left;
}
}
.profile-image {
--size: 2rem !important;
}
</style>

View File

@@ -1,188 +0,0 @@
<script setup>
import { CheckIcon } from '@modrinth/assets'
import { Button, Modal, Badge } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { useTheming } from '@/store/theme'
import { update_managed_modrinth_version } from '@/helpers/profile'
import { releaseColor } from '@/helpers/utils'
import { SwapIcon } from '@/assets/icons/index.js'
const props = defineProps({
versions: {
type: Array,
required: true,
},
instance: {
type: Object,
default: null,
},
})
defineExpose({
show: () => {
modpackVersionModal.value.show()
},
})
const filteredVersions = computed(() => {
return props.versions
})
const modpackVersionModal = ref(null)
const installedVersion = computed(() => props.instance?.metadata?.linked_data?.version_id)
const installing = computed(() => props.instance.install_stage !== 'installed')
const inProgress = ref(false)
const themeStore = useTheming()
const switchVersion = async (versionId) => {
inProgress.value = true
await update_managed_modrinth_version(props.instance.path, versionId)
inProgress.value = false
}
</script>
<template>
<Modal
ref="modpackVersionModal"
class="modpack-version-modal"
header="Change modpack version"
:noblur="!themeStore.advancedRendering"
>
<div class="modal-body">
<Card v-if="instance.metadata.linked_data" class="mod-card">
<div class="table">
<div class="table-row with-columns table-head">
<div class="table-cell table-text download-cell" />
<div class="name-cell table-cell table-text">Name</div>
<div class="table-cell table-text">Supports</div>
</div>
<div class="scrollable">
<div
v-for="version in filteredVersions"
:key="version.id"
class="table-row with-columns selectable"
@click="$router.push(`/project/${$route.params.id}/version/${version.id}`)"
>
<div class="table-cell table-text">
<Button
:color="version.id === installedVersion ? '' : 'primary'"
icon-only
:disabled="inProgress || installing || version.id === installedVersion"
@click.stop="() => switchVersion(version.id)"
>
<SwapIcon v-if="version.id !== installedVersion" />
<CheckIcon v-else />
</Button>
</div>
<div class="name-cell table-cell table-text">
<div class="version-link">
{{ version.name.charAt(0).toUpperCase() + version.name.slice(1) }}
<div class="version-badge">
<div class="channel-indicator">
<Badge
:color="releaseColor(version.version_type)"
:type="
version.version_type.charAt(0).toUpperCase() +
version.version_type.slice(1)
"
/>
</div>
<div>
{{ version.version_number }}
</div>
</div>
</div>
</div>
<div class="table-cell table-text stacked-text">
<span>
{{
version.loaders
.map((str) => str.charAt(0).toUpperCase() + str.slice(1))
.join(', ')
}}
</span>
<span>
{{ version.game_versions.join(', ') }}
</span>
</div>
</div>
</div>
</div>
</Card>
</div>
</Modal>
</template>
<style scoped lang="scss">
.filter-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.with-columns {
grid-template-columns: min-content 1fr 1fr;
}
.scrollable {
overflow-y: auto;
max-height: 25rem;
}
.card-row {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--color-raised-bg);
}
.mod-card {
display: flex;
flex-direction: column;
gap: 1rem;
overflow: hidden;
margin-top: 0.5rem;
}
.version-link {
display: flex;
flex-direction: column;
gap: 0.25rem;
.version-badge {
display: flex;
flex-wrap: wrap;
.channel-indicator {
margin-right: 0.5rem;
}
}
}
.stacked-text {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: flex-start;
}
.download-cell {
width: 4rem;
padding: 1rem;
}
.modal-body {
padding: var(--gap-xl);
display: flex;
flex-direction: column;
gap: var(--gap-md);
}
.table {
border: 1px solid var(--color-bg);
}
</style>

View File

@@ -1,33 +0,0 @@
<template>
<div class="progress-bar">
<div class="progress-bar__fill" :style="{ width: `${progress}%` }"></div>
</div>
</template>
<script setup>
defineProps({
progress: {
type: Number,
required: true,
validator(value) {
return value >= 0 && value <= 100
},
},
})
</script>
<style scoped>
.progress-bar {
width: 100%;
height: 0.5rem;
background-color: var(--color-button-bg);
border-radius: var(--radius-lg);
overflow: hidden;
}
.progress-bar__fill {
height: 100%;
background-color: var(--color-brand);
transition: width 0.3s;
}
</style>

View File

@@ -1,282 +0,0 @@
<script setup>
import { Card, Avatar, Button } from '@modrinth/ui'
import { DownloadIcon, HeartIcon, CalendarIcon } from '@modrinth/assets'
import { formatNumber, formatCategory } from '@modrinth/utils'
import { computed, ref } from 'vue'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useRouter } from 'vue-router'
import { useFetch } from '@/helpers/fetch.js'
import { list } from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
import { install as pack_install } from '@/helpers/pack.js'
dayjs.extend(relativeTime)
const router = useRouter()
const installing = ref(false)
const props = defineProps({
project: {
type: Object,
default() {
return {}
},
},
confirmModal: {
type: Object,
default() {
return {}
},
},
modInstallModal: {
type: Object,
default() {
return {}
},
},
})
const toColor = computed(() => {
let color = props.project.color
color >>>= 0
const b = color & 0xff
const g = (color >>> 8) & 0xff
const r = (color >>> 16) & 0xff
return 'rgba(' + [r, g, b, 1].join(',') + ')'
})
const toTransparent = computed(() => {
let color = props.project.color
color >>>= 0
const b = color & 0xff
const g = (color >>> 8) & 0xff
const r = (color >>> 16) & 0xff
return (
'linear-gradient(rgba(' +
[r, g, b, 0.03].join(',') +
'), 65%, rgba(' +
[r, g, b, 0.3].join(',') +
'))'
)
})
const install = async (e) => {
e?.stopPropagation()
installing.value = true
const versions = await useFetch(
`https://api.modrinth.com/v2/project/${props.project.project_id}/version`,
'project versions',
)
if (props.project.project_type === 'modpack') {
const packs = Object.values(await list(true).catch(handleError))
if (
packs.length === 0 ||
!packs
.map((value) => value.metadata)
.find((pack) => pack.linked_data?.project_id === props.project.project_id)
) {
installing.value = true
await pack_install(
props.project.project_id,
versions[0].id,
props.project.title,
props.project.icon_url,
).catch(handleError)
installing.value = false
} else
props.confirmModal.show(
props.project.project_id,
versions[0].id,
props.project.title,
props.project.icon_url,
)
} else {
props.modInstallModal.show(props.project.project_id, versions)
}
installing.value = false
}
</script>
<template>
<div class="wrapper">
<Card class="project-card button-base" @click="router.push(`/project/${project.slug}`)">
<div
class="banner"
:style="{
'background-color': project.featured_gallery ?? project.gallery[0] ? null : toColor,
'background-image': `url(${
project.featured_gallery ??
project.gallery[0] ??
'https://launcher-files.modrinth.com/assets/maze-bg.png'
})`,
'no-image': !project.featured_gallery && !project.gallery[0],
}"
>
<div class="badges">
<div class="badge">
<DownloadIcon />
{{ formatNumber(project.downloads) }}
</div>
<div class="badge">
<HeartIcon />
{{ formatNumber(project.follows) }}
</div>
<div class="badge">
<CalendarIcon />
{{ formatCategory(dayjs(project.date_modified).fromNow()) }}
</div>
</div>
<div
class="badges-wrapper"
:class="{
'no-image': !project.featured_gallery && !project.gallery[0],
}"
:style="{
background: !project.featured_gallery && !project.gallery[0] ? toTransparent : null,
}"
></div>
</div>
<Avatar class="icon" size="sm" :src="project.icon_url" />
<div class="title">
<div class="title-text">
{{ project.title }}
</div>
<div class="author">by {{ project.author }}</div>
</div>
<div class="description">
{{ project.description }}
</div>
</Card>
<Button color="primary" class="install" :disabled="installing" @click="install">
<DownloadIcon />
{{ installing ? 'Installing' : 'Install' }}
</Button>
</div>
</template>
<style scoped lang="scss">
.wrapper {
position: relative;
aspect-ratio: 1;
&:hover {
.install:enabled {
opacity: 1;
}
}
}
.project-card {
display: grid;
grid-gap: 1rem;
grid-template:
'. . . .' 0
'. icon title .' 3rem
'banner banner banner banner' auto
'. description description .' 3.5rem
'. . . .' 0 / 0 3rem minmax(0, 1fr) 0;
max-width: 100%;
height: 100%;
padding: 0;
margin: 0;
.icon {
grid-area: icon;
}
.title {
max-width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
grid-area: title;
white-space: nowrap;
.title-text {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--font-size-md);
font-weight: bold;
}
}
.author {
font-size: var(--font-size-sm);
grid-area: author;
}
.banner {
grid-area: banner;
background-size: cover;
background-position: center;
position: relative;
.badges-wrapper {
width: 100%;
height: 100%;
display: flex;
position: absolute;
top: 0;
left: 0;
mix-blend-mode: hard-light;
}
.badges {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: var(--gap-sm);
gap: var(--gap-xs);
display: flex;
z-index: 10;
flex-direction: row;
justify-content: flex-end;
align-items: flex-end;
}
}
.description {
grid-area: description;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
}
.badge {
background-color: var(--color-raised-bg);
font-size: var(--font-size-xs);
padding: var(--gap-xs) var(--gap-sm);
border-radius: var(--radius-sm);
svg {
width: 1rem;
height: 1rem;
margin-right: var(--gap-xs);
}
}
.install {
position: absolute;
top: calc(5rem + var(--gap-sm));
right: var(--gap-sm);
z-index: 10;
opacity: 0;
transition: opacity 0.2s ease-in-out;
svg {
width: 1rem;
height: 1rem;
}
}
</style>

View File

@@ -1,475 +0,0 @@
<template>
<div class="action-groups">
<a href="https://support.modrinth.com" class="link">
<ChatIcon />
<span> Get support </span>
</a>
<Button
v-if="currentLoadingBars.length > 0"
ref="infoButton"
icon-only
class="icon-button show-card-icon"
@click="toggleCard()"
>
<DownloadIcon />
</Button>
<div v-if="offline" class="status">
<span class="circle stopped" />
<div class="running-text clickable" @click="refreshInternet()">
<span> Offline </span>
</div>
</div>
<div v-if="selectedProfile" class="status">
<span class="circle running" />
<div ref="profileButton" class="running-text">
<router-link :to="`/instance/${encodeURIComponent(selectedProfile.path)}`">
{{ selectedProfile.metadata.name }}
</router-link>
<div
v-if="currentProcesses.length > 1"
class="arrow button-base"
:class="{ rotate: showProfiles }"
@click="toggleProfiles()"
>
<DropdownIcon />
</div>
</div>
<Button v-tooltip="'Stop instance'" icon-only class="icon-button stop" @click="stop()">
<StopCircleIcon />
</Button>
<Button v-tooltip="'View logs'" icon-only class="icon-button" @click="goToTerminal()">
<TerminalSquareIcon />
</Button>
<Button
v-if="currentLoadingBars.length > 0"
ref="infoButton"
icon-only
class="icon-button show-card-icon"
@click="toggleCard()"
>
<DownloadIcon />
</Button>
</div>
<div v-else class="status">
<span class="circle stopped" />
<span class="running-text"> No instances running </span>
</div>
</div>
<transition name="download">
<Card v-if="showCard === true && currentLoadingBars.length > 0" ref="card" class="info-card">
<div v-for="loadingBar in currentLoadingBars" :key="loadingBar.id" class="info-text">
<h3 class="info-title">
{{ loadingBar.title }}
</h3>
<ProgressBar :progress="Math.floor((100 * loadingBar.current) / loadingBar.total)" />
<div class="row">
{{ Math.floor((100 * loadingBar.current) / loadingBar.total) }}% {{ loadingBar.message }}
</div>
</div>
</Card>
</transition>
<transition name="download">
<Card
v-if="showProfiles === true && currentProcesses.length > 0"
ref="profiles"
class="profile-card"
>
<Button
v-for="profile in currentProcesses"
:key="profile.id"
class="profile-button"
@click="selectProfile(profile)"
>
<div class="text"><span class="circle running" /> {{ profile.metadata.name }}</div>
<Button
v-tooltip="'Stop instance'"
icon-only
class="icon-button stop"
@click.stop="stop(profile.path)"
>
<StopCircleIcon />
</Button>
<Button
v-tooltip="'View logs'"
icon-only
class="icon-button"
@click.stop="goToTerminal(profile.path)"
>
<TerminalSquareIcon />
</Button>
</Button>
</Card>
</transition>
</template>
<script setup>
import { DownloadIcon, StopCircleIcon, TerminalSquareIcon, DropdownIcon } from '@modrinth/assets'
import { Button, Card } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import {
get_all_running_profiles as getRunningProfiles,
kill_by_uuid as killProfile,
get_uuids_by_profile_path as getProfileProcesses,
} from '@/helpers/process'
import { loading_listener, process_listener, offline_listener } from '@/helpers/events'
import { useRouter } from 'vue-router'
import { progress_bars_list } from '@/helpers/state.js'
import { refreshOffline, isOffline } from '@/helpers/utils.js'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { handleError } from '@/store/notifications.js'
import { mixpanel_track } from '@/helpers/mixpanel'
import { ChatIcon } from '@/assets/icons'
const router = useRouter()
const card = ref(null)
const profiles = ref(null)
const infoButton = ref(null)
const profileButton = ref(null)
const showCard = ref(false)
const showProfiles = ref(false)
const currentProcesses = ref(await getRunningProfiles().catch(handleError))
const selectedProfile = ref(currentProcesses.value[0])
const offline = ref(await isOffline().catch(handleError))
const refreshInternet = async () => {
offline.value = await refreshOffline().catch(handleError)
}
const unlistenProcess = await process_listener(async () => {
await refresh()
})
const unlistenRefresh = await offline_listener(async (b) => {
offline.value = b
await refresh()
})
const refresh = async () => {
currentProcesses.value = await getRunningProfiles().catch(handleError)
if (!currentProcesses.value.includes(selectedProfile.value)) {
selectedProfile.value = currentProcesses.value[0]
}
}
const stop = async (path) => {
try {
const processes = await getProfileProcesses(path ?? selectedProfile.value.path)
await killProfile(processes[0])
mixpanel_track('InstanceStop', {
loader: currentProcesses.value[0].metadata.loader,
game_version: currentProcesses.value[0].metadata.game_version,
source: 'AppBar',
})
} catch (e) {
console.error(e)
}
await refresh()
}
const goToTerminal = (path) => {
router.push(`/instance/${encodeURIComponent(path ?? selectedProfile.value.path)}/logs`)
}
const currentLoadingBars = ref([])
const refreshInfo = async () => {
const currentLoadingBarCount = currentLoadingBars.value.length
currentLoadingBars.value = Object.values(await progress_bars_list().catch(handleError)).map(
(x) => {
if (x.bar_type.type === 'java_download') {
x.title = 'Downloading Java ' + x.bar_type.version
}
if (x.bar_type.profile_name) {
x.title = x.bar_type.profile_name
}
if (x.bar_type.pack_name) {
x.title = x.bar_type.pack_name
}
return x
},
)
currentLoadingBars.value.sort((a, b) => {
if (a.loading_bar_uuid < b.loading_bar_uuid) {
return -1
}
if (a.loading_bar_uuid > b.loading_bar_uuid) {
return 1
}
return 0
})
if (currentLoadingBars.value.length === 0) {
showCard.value = false
} else if (currentLoadingBarCount < currentLoadingBars.value.length) {
showCard.value = true
}
}
await refreshInfo()
const unlistenLoading = await loading_listener(async () => {
await refreshInfo()
})
const selectProfile = (profile) => {
selectedProfile.value = profile
showProfiles.value = false
}
const handleClickOutsideCard = (event) => {
const elements = document.elementsFromPoint(event.clientX, event.clientY)
if (
card.value &&
card.value.$el !== event.target &&
!elements.includes(card.value.$el) &&
infoButton.value &&
!infoButton.value.contains(event.target)
) {
showCard.value = false
}
}
const handleClickOutsideProfile = (event) => {
const elements = document.elementsFromPoint(event.clientX, event.clientY)
if (
profiles.value &&
profiles.value.$el !== event.target &&
!elements.includes(profiles.value.$el) &&
!profileButton.value.contains(event.target)
) {
showProfiles.value = false
}
}
const toggleCard = async () => {
showCard.value = !showCard.value
showProfiles.value = false
await refreshInfo()
}
const toggleProfiles = async () => {
if (currentProcesses.value.length === 1) return
showProfiles.value = !showProfiles.value
showCard.value = false
}
onMounted(() => {
window.addEventListener('click', handleClickOutsideCard)
window.addEventListener('click', handleClickOutsideProfile)
})
onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutsideCard)
window.removeEventListener('click', handleClickOutsideProfile)
unlistenProcess()
unlistenLoading()
unlistenRefresh()
})
</script>
<style scoped lang="scss">
.action-groups {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-md);
}
.arrow {
transition: transform 0.2s ease-in-out;
display: flex;
align-items: center;
&.rotate {
transform: rotate(180deg);
}
}
.status {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
border-radius: var(--radius-md);
border: 1px solid var(--color-button-bg);
padding: var(--gap-sm) var(--gap-lg);
}
.running-text {
display: flex;
flex-direction: row;
gap: var(--gap-xs);
white-space: nowrap;
overflow: hidden;
-webkit-user-select: none; /* Safari */
-ms-user-select: none; /* IE 10 and IE 11 */
user-select: none;
&.clickable:hover {
cursor: pointer;
}
}
.circle {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
display: inline-block;
margin-right: 0.25rem;
&.running {
background-color: var(--color-brand);
}
&.stopped {
background-color: var(--color-base);
}
}
.icon-button {
background-color: rgba(0, 0, 0, 0);
box-shadow: none;
width: 1.25rem !important;
height: 1.25rem !important;
&.stop {
--text-color: var(--color-red) !important;
}
}
.info-card {
position: absolute;
top: 3.5rem;
right: 0.5rem;
z-index: 9;
width: 20rem;
background-color: var(--color-raised-bg);
box-shadow: var(--shadow-raised);
display: flex;
flex-direction: column;
gap: 1rem;
overflow: auto;
transition: all 0.2s ease-in-out;
border: 1px solid var(--color-button-bg);
&.hidden {
transform: translateY(-100%);
}
}
.loading-option {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
margin: 0;
padding: 0;
:hover {
background-color: var(--color-raised-bg-hover);
}
}
.loading-text {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
.row {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
}
.loading-icon {
width: 2.25rem;
height: 2.25rem;
display: block;
:deep(svg) {
left: 1rem;
width: 2.25rem;
height: 2.25rem;
}
}
.show-card-icon {
color: var(--color-brand);
}
.download-enter-active,
.download-leave-active {
transition: opacity 0.3s ease;
}
.download-enter-from,
.download-leave-to {
opacity: 0;
}
.progress-bar {
width: 100%;
}
.info-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
margin: 0;
padding: 0;
}
.info-title {
margin: 0;
}
.profile-button {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-sm);
width: 100%;
background-color: var(--color-raised-bg);
box-shadow: none;
.text {
margin-right: auto;
}
}
.profile-card {
position: absolute;
top: 3.5rem;
right: 0.5rem;
z-index: 9;
background-color: var(--color-raised-bg);
box-shadow: var(--shadow-raised);
display: flex;
flex-direction: column;
overflow: auto;
transition: all 0.2s ease-in-out;
border: 1px solid var(--color-button-bg);
padding: var(--gap-md);
&.hidden {
transform: translateY(-100%);
}
}
.link {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-sm);
margin: 0;
color: var(--color-text);
text-decoration: none;
}
</style>

View File

@@ -1,288 +0,0 @@
<template>
<Card
class="card button-base"
@click="
() => {
emits('open')
$router.push({
path: `/project/${project.project_id ?? project.id}/`,
query: { i: props.instance ? props.instance.path : undefined },
})
}
"
>
<div class="icon">
<Avatar :src="project.icon_url" size="md" class="search-icon" />
</div>
<div class="content-wrapper">
<div class="title joined-text">
<h2>{{ project.title }}</h2>
<span v-if="project.author">by {{ project.author }}</span>
</div>
<div class="description">
{{ project.description }}
</div>
<div class="tags">
<Categories :categories="categories" :type="project.project_type">
<EnvironmentIndicator
:type-only="project.moderation"
:client-side="project.client_side"
:server-side="project.server_side"
:type="project.project_type"
:search="true"
/>
</Categories>
</div>
</div>
<div class="stats button-group">
<div v-if="featured" class="badge">
<StarIcon />
Featured
</div>
<div class="badge">
<DownloadIcon />
{{ formatNumber(project.downloads) }}
</div>
<div class="badge">
<HeartIcon />
{{ formatNumber(project.follows ?? project.followers) }}
</div>
<div class="badge">
<CalendarIcon />
{{ formatCategory(dayjs(project.date_modified ?? project.updated).fromNow()) }}
</div>
</div>
<div v-if="project.author" class="install">
<Button color="primary" :disabled="installed || installing" @click.stop="install()">
<DownloadIcon v-if="!installed" />
<CheckIcon v-else />
{{ installing ? 'Installing' : installed ? 'Installed' : 'Install' }}
</Button>
</div>
</Card>
</template>
<script setup>
import { DownloadIcon, HeartIcon, CalendarIcon, CheckIcon, StarIcon } from '@modrinth/assets'
import { Avatar, Card, Categories, EnvironmentIndicator, Button } from '@modrinth/ui'
import { formatNumber, formatCategory } from '@modrinth/utils'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { ref } from 'vue'
import { add_project_from_version as installMod, list } from '@/helpers/profile.js'
import { install as packInstall } from '@/helpers/pack.js'
import { installVersionDependencies } from '@/helpers/utils.js'
import { useFetch } from '@/helpers/fetch.js'
import { handleError } from '@/store/notifications.js'
import { mixpanel_track } from '@/helpers/mixpanel'
dayjs.extend(relativeTime)
const props = defineProps({
backgroundImage: {
type: String,
default: null,
},
project: {
type: Object,
required: true,
},
categories: {
type: Array,
required: true,
},
instance: {
type: Object,
default: null,
},
confirmModal: {
type: Object,
default: null,
},
modInstallModal: {
type: Object,
default: null,
},
incompatibilityWarningModal: {
type: Object,
default: null,
},
featured: {
type: Boolean,
default: false,
},
installed: {
type: Boolean,
default: false,
},
})
const emits = defineEmits(['open'])
const installing = ref(false)
const installed = ref(props.installed)
async function install() {
installing.value = true
const versions = await useFetch(
`https://api.modrinth.com/v2/project/${props.project.project_id}/version`,
'project versions',
)
let queuedVersionData
if (!props.instance) {
queuedVersionData = versions[0]
} else {
queuedVersionData = versions.find(
(v) =>
v.game_versions.includes(props.instance.metadata.game_version) &&
(props.project.project_type !== 'mod' ||
v.loaders.includes(props.instance.metadata.loader)),
)
}
if (props.project.project_type === 'modpack') {
const packs = Object.values(await list().catch(handleError))
if (
packs.length === 0 ||
!packs
.map((value) => value.metadata)
.find((pack) => pack.linked_data?.project_id === props.project.project_id)
) {
await packInstall(
props.project.project_id,
queuedVersionData.id,
props.project.title,
props.project.icon_url,
).catch(handleError)
mixpanel_track('PackInstall', {
id: props.project.project_id,
version_id: queuedVersionData.id,
title: props.project.title,
source: 'SearchCard',
})
} else {
props.confirmModal.show(
props.project.project_id,
queuedVersionData.id,
props.project.title,
props.project.icon_url,
)
}
} else {
if (props.instance) {
if (!queuedVersionData) {
props.incompatibilityWarningModal.show(
props.instance,
props.project.title,
versions,
() => (installed.value = true),
props.project.project_id,
props.project.project_type,
)
installing.value = false
return
} else {
await installMod(props.instance.path, queuedVersionData.id).catch(handleError)
await installVersionDependencies(props.instance, queuedVersionData)
mixpanel_track('ProjectInstall', {
loader: props.instance.metadata.loader,
game_version: props.instance.metadata.game_version,
id: props.project.project_id,
project_type: props.project.project_type,
version_id: queuedVersionData.id,
title: props.project.title,
source: 'SearchCard',
})
}
} else {
props.modInstallModal.show(
props.project.project_id,
versions,
props.project.title,
props.project.project_type,
)
installing.value = false
return
}
if (props.instance) installed.value = true
}
installing.value = false
}
</script>
<style scoped lang="scss">
.icon {
grid-column: 1;
grid-row: 1;
align-self: center;
height: 6rem;
}
.content-wrapper {
display: flex;
justify-content: space-between;
grid-column: 2 / 4;
flex-direction: column;
grid-row: 1;
gap: 0.5rem;
.description {
word-wrap: break-word;
overflow-wrap: anywhere;
}
}
.stats {
grid-column: 1 / 3;
grid-row: 2;
justify-self: stretch;
align-self: start;
}
.install {
grid-column: 3 / 4;
grid-row: 2;
justify-self: end;
align-self: start;
}
.card {
margin-bottom: 0;
display: grid;
grid-template-columns: 6rem auto 7rem;
gap: 0.75rem;
padding: 1rem;
&:active:not(&:disabled) {
scale: 0.98 !important;
}
}
.joined-text {
display: inline-flex;
flex-wrap: wrap;
flex-direction: row;
column-gap: 0.5rem;
align-items: baseline;
overflow: hidden;
text-overflow: ellipsis;
h2 {
margin-bottom: 0 !important;
word-wrap: break-word;
overflow-wrap: anywhere;
}
}
.button-group {
display: inline-flex;
flex-direction: row;
gap: 0.5rem;
align-items: flex-start;
flex-wrap: wrap;
justify-content: flex-start;
}
</style>

View File

@@ -1,43 +0,0 @@
<template>
<div class="page-loading" :class="{ 'app-loading': appLoading }">
<AnimatedLogo class="initializing-icon" />
</div>
</template>
<script setup>
import { AnimatedLogo } from '@modrinth/ui'
defineProps({
appLoading: Boolean,
})
</script>
<style scoped lang="scss">
.page-loading {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
&.app-loading {
background-color: #16181c;
height: 100vh;
}
}
.initializing-icon {
width: 12rem;
height: 12rem;
:deep(svg),
svg {
width: 12rem;
height: 12rem;
fill: var(--color-brand);
color: var(--color-brand);
}
}
</style>

View File

@@ -1,136 +0,0 @@
<script setup>
import { Modal, Button } from '@modrinth/ui'
import { ref } from 'vue'
import { useFetch } from '@/helpers/fetch.js'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get_categories } from '@/helpers/tags.js'
import { handleError } from '@/store/notifications.js'
import { install as packInstall } from '@/helpers/pack.js'
import mixpanel from 'mixpanel-browser'
import ModInstallModal from '@/components/ui/ModInstallModal.vue'
const confirmModal = ref(null)
const project = ref(null)
const version = ref(null)
const categories = ref(null)
const installing = ref(false)
const modInstallModal = ref(null)
defineExpose({
async show(event) {
if (event.event === 'InstallVersion') {
version.value = await useFetch(
`https://api.modrinth.com/v2/version/${encodeURIComponent(event.id)}`,
'version',
)
project.value = await useFetch(
`https://api.modrinth.com/v2/project/${encodeURIComponent(version.value.project_id)}`,
'project',
)
} else {
project.value = await useFetch(
`https://api.modrinth.com/v2/project/${encodeURIComponent(event.id)}`,
'project',
)
version.value = await useFetch(
`https://api.modrinth.com/v2/version/${encodeURIComponent(project.value.versions[0])}`,
'version',
)
}
categories.value = (await get_categories().catch(handleError)).filter(
(cat) => project.value.categories.includes(cat.name) && cat.project_type === 'mod',
)
confirmModal.value.show()
categories.value = (await get_categories().catch(handleError)).filter(
(cat) => project.value.categories.includes(cat.name) && cat.project_type === 'mod',
)
confirmModal.value.show()
},
})
async function install() {
confirmModal.value.hide()
if (project.value.project_type === 'modpack') {
await packInstall(
project.value.id,
version.value.id,
project.value.title,
project.value.icon_url,
).catch(handleError)
mixpanel.track('PackInstall', {
id: project.value.id,
version_id: version.value.id,
title: project.value.title,
source: 'ProjectPage',
})
} else {
modInstallModal.value.show(
project.value.id,
[version.value],
project.value.title,
project.value.project_type,
)
}
}
</script>
<template>
<Modal ref="confirmModal" :header="`Install ${project?.title}`">
<div class="modal-body">
<SearchCard
:project="project"
class="project-card"
:categories="categories"
@open="confirmModal.hide()"
/>
<div class="button-row">
<div class="markdown-body">
<p>
Installing <code>{{ version.id }}</code> from Modrinth
</p>
</div>
<div class="button-group">
<Button :loading="installing" color="primary" @click="install">Install</Button>
</div>
</div>
</div>
</Modal>
<ModInstallModal ref="modInstallModal" />
</template>
<style scoped lang="scss">
.modal-body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--gap-md);
padding: var(--gap-lg);
}
.button-row {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: var(--gap-md);
}
.button-group {
display: flex;
flex-direction: row;
gap: var(--gap-sm);
}
.project-card {
background-color: var(--color-bg);
width: 100%;
:deep(.badge) {
border: 1px solid var(--color-raised-bg);
background-color: var(--color-accent-contrast);
}
}
</style>

File diff suppressed because one or more lines are too long

View File

@@ -1,140 +0,0 @@
<script setup>
import { LogInIcon } from '@modrinth/assets'
import { Button, Card } from '@modrinth/ui'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleError } from '@/store/notifications.js'
import mixpanel from 'mixpanel-browser'
import { ref } from 'vue'
import { handleSevereError } from '@/store/error.js'
const loading = ref(false)
const props = defineProps({
nextPage: {
type: Function,
required: true,
},
prevPage: {
type: Function,
required: true,
},
})
async function login() {
try {
loading.value = true
const loggedIn = await login_flow()
if (loggedIn) {
await set_default_user(loggedIn.id).catch(handleError)
}
await mixpanel.track('AccountLogIn')
loading.value = false
props.nextPage()
} catch (err) {
loading.value = false
handleSevereError(err)
}
}
</script>
<template>
<div class="login-card">
<img
src="https://launcher-files.modrinth.com/assets/default_profile.png"
class="logo"
alt="Minecraft art"
/>
<Card class="logging-in">
<h2>Sign into Minecraft</h2>
<p>
Sign in with your Microsoft account to launch Minecraft with your mods and modpacks. If you
don't have a Minecraft account, you can purchase the game on the
<a
href="https://www.minecraft.net/en-us/store/minecraft-java-bedrock-edition-pc"
class="link"
>
Minecraft website
</a>
</p>
<div class="action-row">
<Button class="transparent" large @click="prevPage"> Back </Button>
<div class="sign-in-pair">
<Button color="primary" large :disabled="loading" @click="login">
<LogInIcon />
{{ loading ? 'Loading...' : 'Sign in' }}
</Button>
</div>
<Button class="transparent" large @click="nextPage()"> Finish</Button>
</div>
</Card>
</div>
</template>
<style scoped lang="scss">
.login-card {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin: auto;
padding: var(--gap-lg);
width: 30rem;
img {
width: 100%;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
}
.logging-in {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
vertical-align: center;
gap: var(--gap-md);
background-color: var(--color-raised-bg);
width: 100%;
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
h2,
p {
margin: 0;
}
p {
text-align: center;
}
}
.link {
color: var(--color-blue);
text-decoration: underline;
}
.button-row {
display: flex;
flex-direction: row;
}
.action-row {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
gap: var(--gap-md);
margin-top: var(--gap-md);
.transparent {
padding: 0 var(--gap-md);
}
}
.sign-in-pair {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
align-items: center;
}
</style>

View File

@@ -1,326 +0,0 @@
<script setup>
import { UserIcon, LockIcon, MailIcon } from '@modrinth/assets'
import { Button, Card, Checkbox } from '@modrinth/ui'
import {
DiscordIcon,
GithubIcon,
MicrosoftIcon,
GoogleIcon,
SteamIcon,
GitLabIcon,
} from '@/assets/external'
import {
authenticate_begin_flow,
authenticate_await_completion,
login_2fa,
create_account,
login_pass,
} from '@/helpers/mr_auth.js'
import { handleError, useNotifications } from '@/store/state.js'
import { onMounted, ref } from 'vue'
const props = defineProps({
nextPage: {
type: Function,
required: true,
},
prevPage: {
type: Function,
required: true,
},
modal: {
type: Boolean,
required: true,
},
})
const loggingIn = ref(true)
const twoFactorFlow = ref(null)
const twoFactorCode = ref('')
const email = ref('')
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const subscribe = ref(true)
async function signInOauth(provider) {
const url = await authenticate_begin_flow(provider).catch(handleError)
await window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: url,
},
})
const creds = await authenticate_await_completion().catch(handleError)
if (creds && creds.type === 'two_factor_required') {
twoFactorFlow.value = creds.flow
} else if (creds && creds.session) {
props.nextPage()
}
}
async function signIn2fa() {
const creds = await login_2fa(twoFactorCode.value, twoFactorFlow.value).catch(handleError)
if (creds && creds.session) {
props.nextPage()
}
}
async function signIn() {
const creds = await login_pass(
username.value,
password.value,
window.turnstile.getResponse(),
).catch(handleError)
window.turnstile.reset()
if (creds && creds.type === 'two_factor_required') {
twoFactorFlow.value = creds.flow
} else if (creds && creds.session) {
props.nextPage()
}
}
async function createAccount() {
if (password.value !== confirmPassword.value) {
const notifs = useNotifications()
notifs.addNotification({
title: 'An error occurred',
text: 'Passwords do not match!',
type: 'error',
})
return
}
const creds = await create_account(
username.value,
email.value,
password.value,
window.turnstile.getResponse(),
subscribe.value,
).catch(handleError)
window.turnstile.reset()
if (creds && creds.session) {
props.nextPage()
}
}
async function goToNextPage() {
props.nextPage()
}
onMounted(() => {
if (window.turnstile === null || !window.turnstile) {
const script = document.createElement('script')
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
script.async = true
script.defer = true
document.head.appendChild(script)
}
})
</script>
<template>
<Card>
<div class="cf-turnstile" data-sitekey="0x4AAAAAAAHWfmKCm7cUG869"></div>
<template v-if="twoFactorFlow">
<h1>Enter two-factor code</h1>
<p>Please enter a two-factor code to proceed.</p>
<input v-model="twoFactorCode" maxlength="11" type="text" placeholder="Enter code..." />
</template>
<template v-else>
<h1 v-if="loggingIn">Login to Modrinth</h1>
<h1 v-else>Create an account</h1>
<div class="button-grid">
<Button class="discord" large @click="signInOauth('discord')">
<DiscordIcon />
Discord
</Button>
<Button class="github" large @click="signInOauth('github')">
<GithubIcon />
Github
</Button>
<Button class="white" large @click="signInOauth('microsoft')">
<MicrosoftIcon />
Microsoft
</Button>
<Button class="google" large @click="signInOauth('google')">
<GoogleIcon />
Google
</Button>
<Button class="white" large @click="signInOauth('steam')">
<SteamIcon />
Steam
</Button>
<Button class="gitlab" large @click="signInOauth('gitlab')">
<GitLabIcon />
GitLab
</Button>
</div>
<div class="divider">
<hr />
<p>Or</p>
</div>
<div v-if="!loggingIn" class="iconified-input username">
<MailIcon />
<input v-model="email" type="text" placeholder="Email" />
</div>
<div class="iconified-input username">
<UserIcon />
<input
v-model="username"
type="text"
:placeholder="loggingIn ? 'Email or username' : 'Username'"
/>
</div>
<div class="iconified-input" :class="{ username: !loggingIn }">
<LockIcon />
<input v-model="password" type="password" placeholder="Password" />
</div>
<div v-if="!loggingIn" class="iconified-input username">
<LockIcon />
<input v-model="confirmPassword" type="password" placeholder="Confirm password" />
</div>
<Checkbox
v-if="!loggingIn"
v-model="subscribe"
class="subscribe-btn"
label="Subscribe to updates about Modrinth"
/>
<div class="link-row">
<a v-if="loggingIn" class="button-base" @click="loggingIn = false"> Create account </a>
<a v-else class="button-base" @click="loggingIn = true">Sign in</a>
<a class="button-base" href="https://modrinth.com/auth/reset-password">
Forgot password?
</a>
</div>
</template>
<div class="button-row">
<Button class="transparent" large @click="prevPage"> {{ modal ? 'Close' : 'Back' }} </Button>
<Button v-if="twoFactorCode" color="primary" large @click="signIn2fa"> Login </Button>
<Button v-else-if="loggingIn" color="primary" large @click="signIn"> Login </Button>
<Button v-else color="primary" large @click="createAccount"> Create account </Button>
<Button v-if="!modal" class="transparent" large @click="goToNextPage"> Next </Button>
</div>
</Card>
</template>
<style scoped lang="scss">
.card {
width: 25rem;
}
.button-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: var(--gap-md);
.btn {
width: 100%;
justify-content: center;
}
.discord {
background-color: #5865f2;
color: var(--color-contrast);
}
.github {
background-color: #8740f1;
color: var(--color-contrast);
}
.white {
background-color: var(--color-contrast);
color: var(--color-accent-contrast);
}
.google {
background-color: #4285f4;
color: var(--color-contrast);
}
.gitlab {
background-color: #fc6d26;
color: var(--color-contrast);
}
}
.divider {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin: var(--gap-md) 0;
p {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--color-raised-bg);
padding: 0 1rem;
margin: 0;
}
hr {
border: none;
width: 100%;
border-top: 2px solid var(--color-button-bg);
}
}
.iconified-input {
width: 100%;
input {
width: 100%;
flex-basis: auto;
}
}
.username {
margin-bottom: var(--gap-sm);
}
.link-row {
display: flex;
justify-content: space-between;
margin: var(--gap-md) 0;
a {
color: var(--color-blue);
text-decoration: underline;
&:hover {
cursor: pointer;
}
}
}
.button-row {
display: flex;
justify-content: space-between;
.btn {
flex-basis: auto;
}
.transparent {
padding: var(--gap-md) 0;
}
}
:deep {
.checkbox {
border: none;
}
}
</style>

View File

@@ -1,284 +0,0 @@
<script setup>
import { Button } from '@modrinth/ui'
import { ref } from 'vue'
import { get, set } from '@/helpers/settings.js'
import mixpanel from 'mixpanel-browser'
import GalleryImage from '@/components/ui/tutorial/GalleryImage.vue'
import LoginCard from '@/components/ui/tutorial/LoginCard.vue'
import StickyTitleBar from '@/components/ui/tutorial/StickyTitleBar.vue'
const page = ref(1)
const props = defineProps({
finish: {
type: Function,
default: () => {},
},
})
const flow = ref('')
const nextPage = (newFlow) => {
page.value++
mixpanel.track('OnboardingPage', { page: page.value })
if (newFlow) {
flow.value = newFlow
}
}
const prevPage = () => {
page.value--
}
const finishOnboarding = async () => {
mixpanel.track('OnboardingFinish')
const settings = await get()
settings.fully_onboarded = true
await set(settings)
props.finish()
}
</script>
<template>
<div class="onboarding">
<StickyTitleBar />
<GalleryImage
v-if="page === 1"
:gallery="[
{
url: 'https://launcher-files.modrinth.com/onboarding/home.png',
title: 'Discovery',
subtitle: 'See the latest and greatest mods and modpacks to play with from Modrinth',
},
{
url: 'https://launcher-files.modrinth.com/onboarding/discover.png',
title: 'Profile Management',
subtitle:
'Play, manage and search through all the amazing profiles downloaded on your computer at any time, even offline!',
},
]"
logo
>
<Button color="primary" @click="nextPage"> Get started </Button>
</GalleryImage>
<LoginCard v-else-if="page === 2" :next-page="finishOnboarding" :prev-page="prevPage" />
</div>
</template>
<style scoped lang="scss">
.sleek-primary {
background-color: var(--color-brand-highlight);
transition: all ease-in-out 0.1s;
}
.navigation-controls {
flex-grow: 1;
width: min-content;
}
.window-controls {
z-index: 20;
display: none;
flex-direction: row;
align-items: center;
gap: 0.25rem;
.titlebar-button {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all ease-in-out 0.1s;
background-color: var(--color-raised-bg);
color: var(--color-base);
&.close {
&:hover,
&:active {
background-color: var(--color-red);
color: var(--color-accent-contrast);
}
}
&:hover,
&:active {
background-color: var(--color-button-bg);
color: var(--color-contrast);
}
}
}
.container {
--appbar-height: 3.25rem;
--sidebar-width: 4.5rem;
height: 100vh;
display: flex;
flex-direction: row;
overflow: hidden;
.view {
width: calc(100% - var(--sidebar-width));
.appbar {
display: flex;
align-items: center;
background: var(--color-raised-bg);
box-shadow: var(--shadow-inset-sm), var(--shadow-floating);
padding: var(--gap-md);
height: 3.25rem;
gap: var(--gap-sm);
user-select: none;
-webkit-user-select: none;
}
.router-view {
width: 100%;
height: calc(100% - 3.125rem);
overflow: auto;
overflow-x: hidden;
background-color: var(--color-bg);
}
}
}
.nav-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
height: 100%;
background-color: var(--color-raised-bg);
box-shadow: var(--shadow-inset-sm), var(--shadow-floating);
padding: var(--gap-md);
width: var(--sidebar-width);
max-width: var(--sidebar-width);
min-width: var(--sidebar-width);
--sidebar-width: 4.5rem;
}
.pages-list {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
gap: 0.5rem;
.btn {
background-color: var(--color-raised-bg);
height: 3rem !important;
width: 3rem !important;
padding: 0.75rem;
border-radius: var(--radius-md);
box-shadow: none;
svg {
width: 1.5rem !important;
height: 1.5rem !important;
max-width: 1.5rem !important;
max-height: 1.5rem !important;
}
&.active {
background-color: var(--color-button-bg);
box-shadow: var(--shadow-floating);
}
&.sleek-primary {
background-color: var(--color-brand-highlight);
transition: all ease-in-out 0.1s;
}
}
}
.nav-section {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
width: 100%;
height: 100%;
gap: 1rem;
}
.sticky-tip {
position: absolute;
bottom: 1rem;
right: 1rem;
z-index: 10;
}
.intro-card {
display: flex;
flex-direction: column;
padding: var(--gap-xl);
.app-logo {
width: 100%;
height: auto;
display: block;
margin: auto;
}
p {
color: var(--color-contrast);
text-align: left;
width: 100%;
}
.actions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: var(--gap-sm);
}
}
.final-tip {
position: absolute;
bottom: 50%;
right: 50%;
transform: translate(50%, 50%);
z-index: 10;
}
.onboarding {
background:
top linear-gradient(0deg, #31375f, rgba(8, 14, 55, 0)),
url(https://cdn.modrinth.com/landing-new/landing-lower.webp);
background-size: cover;
height: 100vh;
min-height: 100vh;
max-height: 100vh;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--gap-xl);
padding-top: calc(2.5rem + var(--gap-lg));
}
.first-tip {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
}
.whole-page-shadow {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100%;
backdrop-filter: brightness(0.5);
-webkit-backdrop-filter: brightness(0.5);
z-index: 9;
}
</style>

View File

@@ -1,81 +0,0 @@
<script setup>
import { Button } from '@modrinth/ui'
import { XIcon } from '@modrinth/assets'
import { appWindow } from '@tauri-apps/api/window'
import { saveWindowState, StateFlags } from 'tauri-plugin-window-state-api'
import { window } from '@tauri-apps/api'
import { MinimizeIcon, MaximizeIcon } from '@/assets/icons'
</script>
<template>
<div data-tauri-drag-region class="fake-appbar">
<section class="window-controls">
<Button class="titlebar-button" icon-only @click="() => appWindow.minimize()">
<MinimizeIcon />
</Button>
<Button class="titlebar-button" icon-only @click="() => appWindow.toggleMaximize()">
<MaximizeIcon />
</Button>
<Button
class="titlebar-button close"
icon-only
@click="
() => {
saveWindowState(StateFlags.ALL)
window.getCurrent().close()
}
"
>
<XIcon />
</Button>
</section>
</div>
</template>
<style scoped lang="scss">
.fake-appbar {
position: absolute;
width: 100vw;
top: 0;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
height: 2.25rem;
background-color: var(--color-raised-bg);
-webkit-app-region: drag;
z-index: 10000;
}
.window-controls {
display: none;
flex-direction: row;
align-items: center;
.titlebar-button {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all ease-in-out 0.1s;
background-color: var(--color-raised-bg);
color: var(--color-base);
border-radius: 0;
height: 2.25rem;
&.close {
&:hover,
&:active {
background-color: var(--color-red);
color: var(--color-accent-contrast);
}
}
&:hover,
&:active {
background-color: var(--color-button-bg);
color: var(--color-contrast);
}
}
}
</style>

18
apps/app/src/error.rs Normal file
View File

@@ -0,0 +1,18 @@
use tracing_error::ExtractSpanTrace;
pub fn display_tracing_error(err: &theseus::Error) {
match get_span_trace(err) {
Some(span_trace) => {
tracing::error!(error = %err, span_trace = %span_trace);
}
None => {
tracing::error!(error = %err);
}
}
}
pub fn get_span_trace<'a>(
error: &'a (dyn std::error::Error + 'static),
) -> Option<&'a tracing_error::SpanTrace> {
error.source().and_then(|e| e.span_trace())
}

View File

@@ -1,55 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/tauri'
// Example function:
// User goes to auth_url to complete flow, and when completed, authenticate_await_completion() returns the credentials
// export async function authenticate() {
// const auth_url = await authenticate_begin_flow()
// console.log(auth_url)
// await authenticate_await_completion()
// }
/// Authenticate a user with Hydra - part 1
/// This begins the authentication flow quasi-synchronously
/// This returns a DeviceLoginSuccess object, with two relevant fields:
/// - verification_uri: the URL to go to to complete the flow
/// - user_code: the code to enter on the verification_uri page
export async function login() {
return await invoke('auth_login')
}
/// Retrieves the default user
/// user is UUID
export async function get_default_user() {
return await invoke('plugin:auth|auth_get_default_user')
}
/// Updates the default user
/// user is UUID
export async function set_default_user(user) {
return await invoke('plugin:auth|auth_set_default_user', { user })
}
/// Remove a user account from the database
/// user is UUID
export async function remove_user(user) {
return await invoke('plugin:auth|auth_remove_user', { user })
}
/// Returns a list of users
/// Returns an Array of Credentials
export async function users() {
return await invoke('plugin:auth|auth_users')
}
// Get a user by UUID
// Prefer to use refresh() instead of this because it will refresh the credentials
// user is UUID
// Returns Credentials (of user)
export async function get_user(user) {
return await invoke('plugin:auth|auth_get_user', { user })
}

View File

@@ -1,107 +0,0 @@
/*
Event listeners for interacting with the Rust api
These are all async functions that return a promise that resolves to the payload object (whatever Rust is trying to deliver)
*/
/*
callback is a function that takes a single argument, which is the payload object (whatever Rust is trying to deliver)
You can call these to await any kind of emitted signal from Rust, and then do something with the payload object
An example place to put this is at the start of main.js before the state is initialized- that way
you can listen for any emitted signal from Rust and do something with it as the state is being initialized
Example:
import { loading_listener } from '@/helpers/events'
await loading_listener((event) => {
// event.event is the event name (useful if you want to use a single callback fn for multiple event types)
// event.payload is the payload object
console.log(event)
})
Putting that in a script will print any emitted signal from rust
*/
import { listen } from '@tauri-apps/api/event'
/// Payload for the 'loading' event
/*
LoadingPayload {
event: {
type: string, one of "StateInit", "PackDownload", etc
(Optional fields depending on event type)
pack_name: name of the pack
pack_id, optional, the id of the modpack
pack_version, optional, the version of the modpack
profile_name: name of the profile
profile_uuid: unique identification of the profile
}
loader_uuid: unique identification of the loading bar
fraction: number, (as a fraction of 1, how much we've loaded so far). If null, by convention, loading is finished
message: message to display to the user
}
*/
export async function loading_listener(callback) {
return await listen('loading', (event) => callback(event.payload))
}
/// Payload for the 'process' event
/*
ProcessPayload {
uuid: unique identification of the process in the state (currently identified by PID, but that will change)
pid: process ID
event: event type ("Launched", "Finished")
message: message to display to the user
}
*/
export async function process_listener(callback) {
return await listen('process', (event) => callback(event.payload))
}
/// Payload for the 'profile' event
/*
ProfilePayload {
uuid: unique identification of the process in the state (currently identified by path, but that will change)
name: name of the profile
profile_path: relative path to profile (used for path identification)
path: path to profile (used for opening the profile in the OS file explorer)
event: event type ("Created", "Added", "Edited", "Removed")
}
*/
export async function profile_listener(callback) {
return await listen('profile', (event) => callback(event.payload))
}
/// Payload for the 'command' event
/*
CommandPayload {
event: event type ("InstallMod", "InstallModpack", "InstallVersion"),
id: string id of the mod/modpack/version to install
}
*/
export async function command_listener(callback) {
return await listen('command', (event) => {
callback(event.payload)
})
}
/// Payload for the 'warning' event
/*
WarningPayload {
message: message to display to the user
}
*/
export async function warning_listener(callback) {
return await listen('warning', (event) => callback(event.payload))
}
/// Payload for the 'offline' event
/*
OfflinePayload {
offline: bool, true or false
}
*/
export async function offline_listener(callback) {
return await listen('offline', (event) => {
return callback(event.payload)
})
}

View File

@@ -1,18 +0,0 @@
import { ofetch } from 'ofetch'
import { handleError } from '@/store/state.js'
import { getVersion } from '@tauri-apps/api/app'
export const useFetch = async (url, item, isSilent) => {
try {
const version = await getVersion()
return await ofetch(url, {
headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` },
})
} catch (err) {
if (!isSilent) {
handleError({ message: `Error fetching ${item}` })
}
console.error(err)
}
}

View File

@@ -1,63 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/tauri'
import { create } from './profile'
/*
API for importing instances from other launchers
launcherType can be one of the following:
- MultiMC
- GDLauncher
- ATLauncher
- Curseforge
- PrismLauncher
- Unknown (shouldn't be used, but is used internally if the launcher type isn't recognized)
For each launcher type, we can get a guess of the default path for the launcher, and a list of importable instances
For most launchers, this will be the application's data directory, with two exceptions:
- MultiMC: this goes to the app directory (wherever the app is)
- Curseforge: this goes to the 'minecraft' subdirectory of the data directory, as Curseforge has multiple games
*/
/// Gets a list of importable instances from a launcher type and base path
/// eg: get_importable_instances("MultiMC", "C:/MultiMC")
/// returns ["Instance 1", "Instance 2"]
export async function get_importable_instances(launcherType, basePath) {
return await invoke('plugin:import|import_get_importable_instances', { launcherType, basePath })
}
/// Import an instance from a launcher type and base path
/// eg: import_instance("profile-name-to-go-to", "MultiMC", "C:/MultiMC", "Instance 1")
export async function import_instance(launcherType, basePath, instanceFolder) {
// create a basic, empty instance (most properties will be filled in by the import process)
// We do NOT watch the fs for changes to avoid duplicate events during installation
// fs watching will be enabled once the instance is imported
const profilePath = await create(instanceFolder, '1.19.4', 'vanilla', 'latest', null, true)
return await invoke('plugin:import|import_import_instance', {
profilePath,
launcherType,
basePath,
instanceFolder,
})
}
/// Checks if this instance is valid for importing, given a certain launcher type
/// eg: is_valid_importable_instance("C:/MultiMC/Instance 1", "MultiMC")
export async function is_valid_importable_instance(instanceFolder, launcherType) {
return await invoke('plugin:import|import_is_valid_importable_instance', {
instanceFolder,
launcherType,
})
}
/// Gets the default path for the given launcher type
/// null if it can't be found or doesn't exist
/// eg: get_default_launcher_path("MultiMC")
export async function get_default_launcher_path(launcherType) {
return await invoke('plugin:import|import_get_default_launcher_path', { launcherType })
}

View File

@@ -1,43 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/tauri'
/*
JavaVersion {
path: Path
version: String
}
*/
// Finds all the installation of Java 7, if it exists
// Returns [JavaVersion]
export async function find_filtered_jres(version) {
return await invoke('plugin:jre|jre_find_filtered_jres', { version })
}
// Gets java version from a specific path by trying to run 'java -version' on it.
// This also validates it, as it returns null if no valid java version is found at the path
export async function get_jre(path) {
return await invoke('plugin:jre|jre_get_jre', { path })
}
// Tests JRE version by running 'java -version' on it.
// Returns true if the version is valid, and matches given (after extraction)
export async function test_jre(path, majorVersion, minorVersion) {
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion, minorVersion })
}
// Automatically installs specified java version
export async function auto_install_java(javaVersion) {
return await invoke('plugin:jre|jre_auto_install_java', { javaVersion })
}
// Get max memory in KiB
export async function get_max_memory() {
return await invoke('plugin:jre|jre_get_max_memory')
}

View File

@@ -1,61 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/tauri'
/*
A log is a struct containing the filename string, stdout, and stderr, as follows:
pub struct Logs {
pub filename: String,
pub stdout: String,
pub stderr: String,
}
*/
/// Get all logs that exist for a given profile
/// This is returned as an array of Log objects, sorted by filename (the folder name, when the log was created)
export async function get_logs(profilePath, clearContents) {
return await invoke('plugin:logs|logs_get_logs', { profilePath, clearContents })
}
/// Get a profile's log by filename
export async function get_logs_by_filename(profilePath, logType, filename) {
return await invoke('plugin:logs|logs_get_logs_by_filename', { profilePath, logType, filename })
}
/// Get a profile's log text only by filename
export async function get_output_by_filename(profilePath, logType, filename) {
return await invoke('plugin:logs|logs_get_output_by_filename', { profilePath, logType, filename })
}
/// Delete a profile's log by filename
export async function delete_logs_by_filename(profilePath, logType, filename) {
return await invoke('plugin:logs|logs_delete_logs_by_filename', {
profilePath,
logType,
filename,
})
}
/// Delete all logs for a given profile
export async function delete_logs(profilePath) {
return await invoke('plugin:logs|logs_delete_logs', { profilePath })
}
/// Get the latest log for a given profile and cursor (startpoint to read withi nthe file)
/// Returns:
/*
{
cursor: u64
output: String
new_file: bool <- the cursor was too far, meaning that the file was likely rotated/reset. This signals to the frontend to clear the log and start over with this struct.
}
*/
// From latest.log directly
export async function get_latest_log_cursor(profilePath, cursor) {
return await invoke('plugin:logs|logs_get_latest_log_cursor', { profilePath, cursor })
}

View File

@@ -1,39 +0,0 @@
import { invoke } from '@tauri-apps/api/tauri'
/// Gets the game versions from daedalus
// Returns a VersionManifest
export async function get_game_versions() {
return await invoke('plugin:metadata|metadata_get_game_versions')
}
// Gets the fabric versions from daedalus
// Returns Manifest
export async function get_fabric_versions() {
const c = await invoke('plugin:metadata|metadata_get_fabric_versions')
console.log('Getting fabric versions', c)
return c
}
// Gets the forge versions from daedalus
// Returns Manifest
export async function get_forge_versions() {
const c = await invoke('plugin:metadata|metadata_get_forge_versions')
console.log('Getting forge versions', c)
return c
}
// Gets the quilt versions from daedalus
// Returns Manifest
export async function get_quilt_versions() {
const c = await invoke('plugin:metadata|metadata_get_quilt_versions')
console.log('Getting quilt versions', c)
return c
}
// Gets the neoforge versions from daedalus
// Returns Manifest
export async function get_neoforge_versions() {
const c = await invoke('plugin:metadata|metadata_get_neoforge_versions')
console.log('Getting neoforge versions', c)
return c
}

View File

@@ -1,57 +0,0 @@
import mixpanel from 'mixpanel-browser'
// mixpanel_track
function trackWrapper(originalTrack) {
return function (event_name, properties = {}) {
try {
originalTrack(event_name, properties)
} catch (e) {
console.error(e)
}
}
}
export const mixpanel_track = trackWrapper(mixpanel.track.bind(mixpanel))
// mixpanel_opt_out_tracking()
function optOutTrackingWrapper(originalOptOutTracking) {
return function () {
try {
originalOptOutTracking()
} catch (e) {
console.error(e)
}
}
}
export const mixpanel_opt_out_tracking = optOutTrackingWrapper(
mixpanel.opt_out_tracking.bind(mixpanel),
)
// mixpanel_opt_in_tracking()
function optInTrackingWrapper(originalOptInTracking) {
return function () {
try {
originalOptInTracking()
} catch (e) {
console.error(e)
}
}
}
export const mixpanel_opt_in_tracking = optInTrackingWrapper(
mixpanel.opt_in_tracking.bind(mixpanel),
)
// mixpanel_init
function initWrapper(originalInit) {
return function (token, config = {}) {
try {
originalInit(token, config)
} catch (e) {
console.error(e)
}
}
}
export const mixpanel_init = initWrapper(mixpanel.init.bind(mixpanel))
export const mixpanel_is_loaded = () => {
return mixpanel.__loaded
}

View File

@@ -1,47 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/tauri'
export async function authenticate_begin_flow(provider) {
return await invoke('plugin:mr_auth|authenticate_begin_flow', { provider })
}
export async function authenticate_await_completion() {
return await invoke('plugin:mr_auth|authenticate_await_completion')
}
export async function cancel_flow() {
return await invoke('plugin:mr_auth|cancel_flow')
}
export async function login_pass(username, password, challenge) {
return await invoke('plugin:mr_auth|login_pass', { username, password, challenge })
}
export async function login_2fa(code, flow) {
return await invoke('plugin:mr_auth|login_2fa', { code, flow })
}
export async function create_account(username, email, password, challenge, signUpNewsletter) {
return await invoke('plugin:mr_auth|create_account', {
username,
email,
password,
challenge,
signUpNewsletter,
})
}
export async function refresh() {
return await invoke('plugin:mr_auth|refresh')
}
export async function logout() {
return await invoke('plugin:mr_auth|logout')
}
export async function get() {
return await invoke('plugin:mr_auth|get')
}

View File

@@ -1,45 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/tauri'
import { create } from './profile'
// Installs pack from a version ID
export async function install(projectId, versionId, packTitle, iconUrl) {
const location = {
type: 'fromVersionId',
project_id: projectId,
version_id: versionId,
title: packTitle,
icon_url: iconUrl,
}
const profile_creator = await invoke('plugin:pack|pack_get_profile_from_pack', { location })
const profile = await create(
profile_creator.name,
profile_creator.gameVersion,
profile_creator.modloader,
profile_creator.loaderVersion,
profile_creator.icon,
)
return await invoke('plugin:pack|pack_install', { location, profile })
}
// Installs pack from a path
export async function install_from_file(path) {
const location = {
type: 'fromFile',
path: path,
}
const profile_creator = await invoke('plugin:pack|pack_get_profile_from_pack', { location })
const profile = await create(
profile_creator.name,
profile_creator.gameVersion,
profile_creator.modloader,
profile_creator.loaderVersion,
profile_creator.icon,
)
return await invoke('plugin:pack|pack_install', { location, profile })
}

View File

@@ -1,53 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/tauri'
/// Gets if a process has finished by UUID
/// Returns bool
export async function has_finished_by_uuid(uuid) {
return await invoke('plugin:process|process_has_finished_by_uuid', { uuid })
}
/// Gets process exit status by UUID
/// Returns u32
export async function get_exit_status_by_uuid(uuid) {
return await invoke('plugin:process|process_get_exit_status_by_uuid', { uuid })
}
/// Gets all process IDs
/// Returns [u32]
export async function get_all_uuids() {
return await invoke('plugin:process|process_get_all_uuids')
}
/// Gets all running process IDs
/// Returns [u32]
export async function get_all_running_uuids() {
return await invoke('plugin:process|process_get_all_running_uuids')
}
/// Gets all running process IDs with a given profile path
/// Returns [u32]
export async function get_uuids_by_profile_path(profilePath) {
return await invoke('plugin:process|process_get_uuids_by_profile_path', { profilePath })
}
/// Gets all running process IDs with a given profile path
/// Returns [u32]
export async function get_all_running_profile_paths(profilePath) {
return await invoke('plugin:process|process_get_all_running_profile_paths', { profilePath })
}
/// Gets all running process IDs with a given profile path
/// Returns [u32]
export async function get_all_running_profiles() {
return await invoke('plugin:process|process_get_all_running_profiles')
}
/// Kills a process by UUID
export async function kill_by_uuid(uuid) {
return await invoke('plugin:process|process_kill_by_uuid', { uuid })
}

View File

@@ -1,180 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/tauri'
/// Add instance
/*
name: String, // the name of the profile, and relative path to create
game_version: String, // the game version of the profile
modloader: ModLoader, // the modloader to use
- ModLoader is an enum, with the following variants: Vanilla, Forge, Fabric, Quilt
loader_version: String, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
icon: Path, // the icon for the profile
- icon is a path to an image file, which will be copied into the profile directory
*/
export async function create(name, gameVersion, modloader, loaderVersion, icon, noWatch) {
//Trim string name to avoid "Unable to find directory"
name = name.trim()
return await invoke('plugin:profile_create|profile_create', {
name,
gameVersion,
modloader,
loaderVersion,
icon,
noWatch,
})
}
// duplicate a profile
export async function duplicate(path) {
return await invoke('plugin:profile_create|profile_duplicate', { path })
}
// Remove a profile
export async function remove(path) {
return await invoke('plugin:profile|profile_remove', { path })
}
// Get a profile by path
// Returns a Profile
export async function get(path, clearProjects) {
return await invoke('plugin:profile|profile_get', { path, clearProjects })
}
// Get a profile's full fs path
// Returns a path
export async function get_full_path(path) {
return await invoke('plugin:profile|profile_get_full_path', { path })
}
// Get's a mod's full fs path
// Returns a path
export async function get_mod_full_path(path, projectPath) {
return await invoke('plugin:profile|profile_get_mod_full_path', { path, projectPath })
}
// Get optimal java version from profile
// Returns a java version
export async function get_optimal_jre_key(path) {
return await invoke('plugin:profile|profile_get_optimal_jre_key', { path })
}
// Get a copy of the profile set
// Returns hashmap of path -> Profile
export async function list(clearProjects) {
return await invoke('plugin:profile|profile_list', { clearProjects })
}
export async function check_installed(path, projectId) {
return await invoke('plugin:profile|profile_check_installed', { path, projectId })
}
// Installs/Repairs a profile
export async function install(path, force) {
return await invoke('plugin:profile|profile_install', { path, force })
}
// Updates all of a profile's projects
export async function update_all(path) {
return await invoke('plugin:profile|profile_update_all', { path })
}
// Updates a specified project
export async function update_project(path, projectPath) {
return await invoke('plugin:profile|profile_update_project', { path, projectPath })
}
// Add a project to a profile from a version
// Returns a path to the new project file
export async function add_project_from_version(path, versionId) {
return await invoke('plugin:profile|profile_add_project_from_version', { path, versionId })
}
// Add a project to a profile from a path + project_type
// Returns a path to the new project file
export async function add_project_from_path(path, projectPath, projectType) {
return await invoke('plugin:profile|profile_add_project_from_path', {
path,
projectPath,
projectType,
})
}
// Toggle disabling a project
export async function toggle_disable_project(path, projectPath) {
return await invoke('plugin:profile|profile_toggle_disable_project', { path, projectPath })
}
// Remove a project
export async function remove_project(path, projectPath) {
return await invoke('plugin:profile|profile_remove_project', { path, projectPath })
}
// Update a managed Modrinth profile to a specific version
export async function update_managed_modrinth_version(path, versionId) {
return await invoke('plugin:profile|profile_update_managed_modrinth_version', { path, versionId })
}
// Repair a managed Modrinth profile
export async function update_repair_modrinth(path) {
return await invoke('plugin:profile|profile_repair_managed_modrinth', { path })
}
// Export a profile to .mrpack
/// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs')
// Version id is optional (ie: 1.1.5)
export async function export_profile_mrpack(
path,
exportLocation,
includedOverrides,
versionId,
description,
name,
) {
return await invoke('plugin:profile|profile_export_mrpack', {
path,
exportLocation,
includedOverrides,
versionId,
description,
name,
})
}
// Given a folder path, populate an array of all the subfolders
// Intended to be used for finding potential override folders
// profile
// -- mods
// -- resourcepacks
// -- file1
// => [mods, resourcepacks]
// allows selection for 'included_overrides' in export_profile_mrpack
export async function get_pack_export_candidates(profilePath) {
return await invoke('plugin:profile|profile_get_pack_export_candidates', { profilePath })
}
// Run Minecraft using a pathed profile
// Returns PID of child
export async function run(path) {
return await invoke('plugin:profile|profile_run', { path })
}
// Run Minecraft using a pathed profile
// Waits for end
export async function run_wait(path) {
return await invoke('plugin:profile|profile_run_wait', { path })
}
// Edits a profile
export async function edit(path, editProfile) {
return await invoke('plugin:profile|profile_edit', { path, editProfile })
}
// Edits a profile's icon
export async function edit_icon(path, iconPath) {
return await invoke('plugin:profile|profile_edit_icon', { path, iconPath })
}

View File

@@ -1,49 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/tauri'
// Settings object
/*
Settings {
"memory": MemorySettings,
"game_resolution": [int int],
"custom_java_args": [String ...],
"custom_env_args" : [(string, string) ... ]>,
"java_globals": Hash of (string, Path),
"default_user": Uuid string (can be null),
"hooks": Hooks,
"max_concurrent_downloads": uint,
"version": u32,
"collapsed_navigation": bool,
}
Memorysettings {
"min": u32, can be null,
"max": u32,
}
*/
// Get full settings object
export async function get() {
return await invoke('plugin:settings|settings_get')
}
// Set full settings object
export async function set(settings) {
return await invoke('plugin:settings|settings_set', { settings })
}
// Changes the config dir
// Seizes the entire application state until its done
export async function change_config_dir(newConfigDir) {
return await invoke('plugin:settings|settings_change_config_dir', { newConfigDir })
}
export async function is_dir_writeable(newConfigDir) {
return await invoke('plugin:settings|settings_is_dir_writeable', { newConfigDir })
}

View File

@@ -1,35 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/tauri'
// Initialize the theseus API state
// This should be called during the initializion/opening of the launcher
export async function initialize_state() {
return await invoke('initialize_state')
}
// Gets active progress bars
export async function progress_bars_list() {
return await invoke('plugin:utils|progress_bars_list')
}
// Check if any safe loading bars are active
export async function check_safe_loading_bars_complete() {
return await invoke('plugin:utils|safety_check_safe_loading_bars')
}
// Get opening command
// For example, if a user clicks on an .mrpack to open the app.
// This should be called once and only when the app is done booting up and ready to receive a command
// Returns a Command struct- see events.js
export async function get_opening_command() {
return await invoke('plugin:utils|get_opening_command')
}
// Wait for settings to sync
export async function await_sync() {
return await invoke('plugin:utils|await_sync')
}

View File

@@ -1,36 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/tauri'
// Gets tag bundle of all tags
export async function get_tag_bundle() {
return await invoke('plugin:tags|tags_get_tag_bundle')
}
// Gets cached category tags
export async function get_categories() {
return await invoke('plugin:tags|tags_get_categories')
}
// Gets cached loaders tags
export async function get_loaders() {
return await invoke('plugin:tags|tags_get_loaders')
}
// Gets cached game_versions tags
export async function get_game_versions() {
return await invoke('plugin:tags|tags_get_game_versions')
}
// Gets cached donation_platforms tags
export async function get_donation_platforms() {
return await invoke('plugin:tags|tags_get_donation_platforms')
}
// Gets cached licenses tags
export async function get_report_types() {
return await invoke('plugin:tags|tags_get_report_types')
}

View File

@@ -1,103 +0,0 @@
import {
add_project_from_version as installMod,
check_installed,
get_full_path,
get_mod_full_path,
} from '@/helpers/profile'
import { useFetch } from '@/helpers/fetch.js'
import { handleError } from '@/store/notifications.js'
import { invoke } from '@tauri-apps/api/tauri'
export async function isDev() {
return await invoke('is_dev')
}
// One of 'Windows', 'Linux', 'MacOS'
export async function getOS() {
return await invoke('plugin:utils|get_os')
}
export async function showInFolder(path) {
return await invoke('plugin:utils|show_in_folder', { path })
}
export async function showLauncherLogsFolder() {
return await invoke('plugin:utils|show_launcher_logs_folder', {})
}
// Opens a profile's folder in the OS file explorer
export async function showProfileInFolder(path) {
const fullPath = await get_full_path(path)
return await showInFolder(fullPath)
}
export async function highlightModInProfile(profilePath, projectPath) {
const fullPath = await get_mod_full_path(profilePath, projectPath)
return await showInFolder(fullPath)
}
export const releaseColor = (releaseType) => {
switch (releaseType) {
case 'release':
return 'green'
case 'beta':
return 'orange'
case 'alpha':
return 'red'
default:
return ''
}
}
export const installVersionDependencies = async (profile, version) => {
for (const dep of version.dependencies) {
if (dep.dependency_type !== 'required') continue
// disallow fabric api install on quilt
if (dep.project_id === 'P7dR8mSH' && profile.metadata.loader === 'quilt') continue
if (dep.version_id) {
if (
dep.project_id &&
(await check_installed(profile.path, dep.project_id).catch(handleError))
)
continue
await installMod(profile.path, dep.version_id)
} else {
if (
dep.project_id &&
(await check_installed(profile.path, dep.project_id).catch(handleError))
)
continue
const depVersions = await useFetch(
`https://api.modrinth.com/v2/project/${dep.project_id}/version`,
'dependency versions',
)
const latest = depVersions.find(
(v) =>
v.game_versions.includes(profile.metadata.game_version) &&
v.loaders.includes(profile.metadata.loader),
)
if (latest) {
await installMod(profile.path, latest.id).catch(handleError)
}
}
}
}
export const openLink = (url) => {
window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: url,
},
})
}
export const refreshOffline = async () => {
return await invoke('plugin:utils|refresh_offline', {})
}
// returns true/false
export const isOffline = async () => {
return await invoke('plugin:utils|is_offline', {})
}

View File

@@ -0,0 +1,98 @@
use cocoa::{
base::{id, nil},
foundation::NSAutoreleasePool,
};
use objc::{
class,
declare::ClassDecl,
msg_send,
runtime::{Class, Object, Sel},
sel, sel_impl,
};
use once_cell::sync::OnceCell;
use crate::api::TheseusSerializableError;
type Callback = OnceCell<Box<dyn Fn(String) + Send + Sync + 'static>>;
static CALLBACK: Callback = OnceCell::new();
pub struct AppDelegateClass(pub *const Class);
unsafe impl Send for AppDelegateClass {}
unsafe impl Sync for AppDelegateClass {}
// Obj C class for the app delegate
// This inherits from the TaoAppDelegate (used by tauri) so we do not accidentally override any functionality
// The application_open_file method is the only method we override, as it is currently unimplemented in tauri
lazy_static::lazy_static! {
pub static ref THESEUS_APP_DELEGATE_CLASS: AppDelegateClass = unsafe {
let superclass = class!(TaoAppDelegate);
let mut decl = ClassDecl::new("TheseusAppDelegate", superclass).unwrap();
// Add the method to the class
decl.add_method(
sel!(application:openFile:),
application_open_file as extern "C" fn(&Object, Sel, id, id) -> bool,
);
// Other methods are inherited
AppDelegateClass(decl.register())
};
}
extern "C" fn application_open_file(
_: &Object,
_: Sel,
_: id,
file: id,
) -> bool {
let file = nsstring_to_string(file);
callback(file)
}
pub fn callback(file: String) -> bool {
if let Some(callback) = CALLBACK.get() {
callback(file);
true
} else {
false
}
}
pub fn register_open_file<T>(
callback: T,
) -> Result<(), TheseusSerializableError>
where
T: Fn(String) + Send + Sync + 'static,
{
unsafe {
// Modified from tao: https://github.com/tauri-apps/tao
// sets the current app delegate to be the inherited app delegate rather than the default tauri/tao one
let app: id = msg_send![class!(TaoApp), sharedApplication];
let delegate: id = msg_send![THESEUS_APP_DELEGATE_CLASS.0, new];
let pool = NSAutoreleasePool::new(nil);
let _: () = msg_send![app, setDelegate: delegate];
let _: () = msg_send![pool, drain];
}
CALLBACK.set(Box::new(callback)).map_err(|_| {
TheseusSerializableError::Callback("Callback already set".to_string())
})
}
/// Convert an NSString to a Rust `String`
/// From 'fruitbasket' https://github.com/mrmekon/fruitbasket/
#[allow(clippy::cmp_null)]
pub fn nsstring_to_string(nsstring: *mut Object) -> String {
unsafe {
let cstr: *const i8 = msg_send![nsstring, UTF8String];
if cstr != std::ptr::null() {
std::ffi::CStr::from_ptr(cstr)
.to_string_lossy()
.into_owned()
} else {
"".into()
}
}
}

View File

@@ -0,0 +1,2 @@
pub mod delegate;
pub mod window_ext;

View File

@@ -0,0 +1,70 @@
/// from: https://github.com/tauri-apps/tauri/issues/4789, full credit to haasal
#[cfg(target_os = "macos")]
use tauri::{Runtime, Window};
#[cfg(target_os = "macos")]
pub trait WindowExt {
fn set_transparent_titlebar(&self, transparent: bool);
fn position_traffic_lights(&self, x: f64, y: f64);
}
#[cfg(target_os = "macos")]
impl<R: Runtime> WindowExt for Window<R> {
fn set_transparent_titlebar(&self, transparent: bool) {
use cocoa::appkit::{NSWindow, NSWindowTitleVisibility};
let window = self.ns_window().unwrap() as cocoa::base::id;
unsafe {
window.setTitleVisibility_(
NSWindowTitleVisibility::NSWindowTitleHidden,
);
if transparent {
window.setTitlebarAppearsTransparent_(cocoa::base::YES);
} else {
window.setTitlebarAppearsTransparent_(cocoa::base::NO);
}
}
}
fn position_traffic_lights(&self, x: f64, y: f64) {
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
use cocoa::foundation::NSRect;
use objc::{msg_send, sel, sel_impl};
let window = self.ns_window().unwrap() as cocoa::base::id;
unsafe {
let close = window
.standardWindowButton_(NSWindowButton::NSWindowCloseButton);
let miniaturize = window.standardWindowButton_(
NSWindowButton::NSWindowMiniaturizeButton,
);
let zoom = window
.standardWindowButton_(NSWindowButton::NSWindowZoomButton);
let title_bar_container_view = close.superview().superview();
let close_rect: NSRect = msg_send![close, frame];
let button_height = close_rect.size.height;
let title_bar_frame_height = button_height + y;
let mut title_bar_rect = NSView::frame(title_bar_container_view);
title_bar_rect.size.height = title_bar_frame_height;
title_bar_rect.origin.y =
NSView::frame(window).size.height - title_bar_frame_height;
let _: () =
msg_send![title_bar_container_view, setFrame: title_bar_rect];
let window_buttons = vec![close, miniaturize, zoom];
let space_between = NSView::frame(miniaturize).origin.x
- NSView::frame(close).origin.x;
for (i, button) in window_buttons.into_iter().enumerate() {
let mut rect: NSRect = NSView::frame(button);
rect.origin.x = x + (i as f64 * space_between);
button.setFrameOrigin(rect.origin);
}
}
}
}

View File

@@ -1,83 +0,0 @@
import { createApp } from 'vue'
import router from '@/routes'
import App from '@/App.vue'
import { createPinia } from 'pinia'
import '@modrinth/assets/omorphia.scss'
import '@/assets/stylesheets/global.scss'
import 'floating-vue/dist/style.css'
import FloatingVue from 'floating-vue'
import { get_opening_command, initialize_state } from '@/helpers/state'
import loadCssMixin from './mixins/macCssFix.js'
import { get } from '@/helpers/settings'
import { invoke } from '@tauri-apps/api'
import { isDev } from './helpers/utils.js'
import { createPlugin } from '@vintl/vintl/plugin'
const VIntlPlugin = createPlugin({
controllerOpts: {
defaultLocale: 'en-US',
locale: 'en-US',
locales: [
{
tag: 'en-US',
meta: {
displayName: 'American English',
},
},
],
},
globalMixin: true,
injectInto: [],
})
const pinia = createPinia()
let app = createApp(App)
app.use(router)
app.use(pinia)
app.use(FloatingVue)
app.mixin(loadCssMixin)
app.use(VIntlPlugin)
const mountedApp = app.mount('#app')
const raw_invoke = async (plugin, fn, args) => {
if (plugin === '') {
await invoke(fn, args)
} else {
await invoke('plugin:' + plugin + '|' + fn, args)
}
}
isDev()
.then((dev) => {
if (dev) {
window.raw_invoke = raw_invoke
}
})
.catch((err) => {
console.error(err)
})
initialize_state()
.then(() => {
// First, redirect to other landing page if we have that setting
get()
.then((fetchSettings) => {
if (fetchSettings?.default_page && fetchSettings?.default_page !== 'Home') {
router.push({ name: fetchSettings?.default_page })
}
})
.catch((err) => {
console.error(err)
})
.finally(() => {
mountedApp.initialize()
get_opening_command().then((command) => {
console.log(JSON.stringify(command)) // change me to use whatever FE command handler is made
})
})
})
.catch((err) => {
console.error('Failed to initialize app', err)
mountedApp.failure(err)
})

Some files were not shown because too many files have changed in this diff Show More