Bugs again (#703)

* initial

* more fixes

* logs

* more fixes

* working rescuer

* minor log display fix

* mac fixes

* minor fix

* libsselinux1

* linux error

* actions test

* more bugs. Modpack page! BIG changes

* changed minimum 64 -> 8

* removed modpack page moved to modal

* removed unnecessary css

* mac compile

* many revs

* Merge colorful logs (#725)

* make implementation not dumb

* run prettier

* null -> true

* Add line numbers & make errors more robust.

* improvments

* changes; virtual scroll

---------

Co-authored-by: qtchaos <72168435+qtchaos@users.noreply.github.com>

* omorphia colors, comments fix

* fixes; _JAVA_OPTIONS

* revs

* mac specific

* more mac

* some fixes

* quick fix

* add java reinstall option

---------

Co-authored-by: qtchaos <72168435+qtchaos@users.noreply.github.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
This commit is contained in:
Wyatt Verchere
2023-09-12 09:27:03 -07:00
committed by GitHub
parent bc02192d80
commit 1e8852b540
63 changed files with 2677 additions and 719 deletions

View File

@@ -7,9 +7,11 @@ import {
LibraryIcon,
PlusIcon,
SettingsIcon,
FileIcon,
Button,
Notifications,
XIcon,
Card,
} from 'omorphia'
import { useLoading, useTheming } from '@/store/state'
import AccountsCard from '@/components/ui/AccountsCard.vue'
@@ -19,12 +21,12 @@ import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import ModrinthLoadingIndicator from '@/components/modrinth-loading-indicator'
import { useNotifications } from '@/store/notifications.js'
import { handleError, useNotifications } from '@/store/notifications.js'
import { offline_listener, command_listener, warning_listener } from '@/helpers/events.js'
import { MinimizeIcon, MaximizeIcon } from '@/assets/icons'
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 } from '@/helpers/utils.js'
import { isDev, getOS, isOffline, showLauncherLogsFolder } from '@/helpers/utils.js'
import {
mixpanel_track,
mixpanel_init,
@@ -40,6 +42,7 @@ import { confirm } from '@tauri-apps/api/dialog'
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
import StickyTitleBar from '@/components/ui/tutorial/StickyTitleBar.vue'
import OnboardingScreen from '@/components/ui/tutorial/OnboardingScreen.vue'
import { install_from_file } from './helpers/pack'
const themeStore = useTheming()
const urlModal = ref(null)
@@ -51,14 +54,17 @@ const showOnboarding = ref(false)
const onboardingVideo = ref()
const failureText = ref(null)
const os = ref('')
defineExpose({
initialize: async () => {
isLoading.value = false
const { theme, opt_out_analytics, collapsed_navigation, advanced_rendering, fully_onboarded } =
await get()
const os = await getOS()
// video should play if the user is not on linux, and has not onboarded
videoPlaying.value = !fully_onboarded && os !== 'Linux'
os.value = await getOS()
videoPlaying.value = !fully_onboarded && os.value !== 'Linux'
const dev = await isDev()
const version = await getVersion()
showOnboarding.value = !fully_onboarded
@@ -98,6 +104,11 @@ defineExpose({
onboardingVideo.value.play()
}
},
failure: async (e) => {
isLoading.value = false
failureText.value = e
os.value = await getOS()
},
})
const confirmClose = async () => {
@@ -112,6 +123,10 @@ const confirmClose = async () => {
}
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)
@@ -129,6 +144,16 @@ const handleClose = async () => {
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()
})
@@ -193,9 +218,19 @@ document.querySelector('body').addEventListener('auxclick', function (e) {
const accounts = ref(null)
command_listener((e) => {
console.log(e)
urlModal.value.show(e)
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>
@@ -209,6 +244,46 @@ command_listener((e) => {
autoplay
@ended="videoPlaying = false"
/>
<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="!videoPlaying && isLoading" app-loading />
<OnboardingScreen v-else-if="showOnboarding" :finish="() => (showOnboarding = false)" />
<div v-else class="container">
@@ -393,6 +468,53 @@ command_listener((e) => {
}
}
.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;
@@ -522,4 +644,15 @@ command_listener((e) => {
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>

View File

@@ -20,7 +20,7 @@ import {
import ContextMenu from '@/components/ui/ContextMenu.vue'
import dayjs from 'dayjs'
import { useTheming } from '@/store/theme.js'
import { remove } from '@/helpers/profile.js'
import { duplicate, remove } from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
const props = defineProps({
@@ -51,11 +51,17 @@ async function deleteProfile() {
}
}
const handleRightClick = (event, item) => {
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' },
@@ -100,6 +106,10 @@ const handleOptionsClick = async (args) => {
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
@@ -131,7 +141,7 @@ const filteredResults = computed(() => {
if (sortBy.value === 'Game version') {
instances.sort((a, b) => {
return a.metadata.name.localeCompare(b.metadata.game_version)
return a.metadata.game_version.localeCompare(b.metadata.game_version)
})
}
@@ -285,11 +295,11 @@ const filteredResults = computed(() => {
</div>
<section class="instances">
<Instance
v-for="(instance, index) in instanceSection.value"
v-for="instance in instanceSection.value"
ref="instanceComponents"
:key="instance.path"
:key="instance.path + instance.install_stage"
:instance="instance"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instanceComponents[index])"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
/>
</section>
</div>
@@ -298,6 +308,7 @@ const filteredResults = computed(() => {
<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>

View File

@@ -25,7 +25,7 @@ import {
kill_by_uuid,
} from '@/helpers/process.js'
import { handleError } from '@/store/notifications.js'
import { remove, run } from '@/helpers/profile.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'
@@ -70,11 +70,16 @@ async function deleteProfile() {
}
}
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' },
@@ -150,6 +155,9 @@ const handleOptionsClick = async (args) => {
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()
@@ -237,7 +245,7 @@ onUnmounted(() => {
<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"
:key="(instance?.project_id || instance?.id) + instance.install_stage"
:instance="instance"
@contextmenu.prevent.stop="(event) => handleInstanceRightClick(event, instance)"
/>
@@ -263,6 +271,7 @@ onUnmounted(() => {
<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>

View File

@@ -105,7 +105,7 @@ import {
GlobeIcon,
ClipboardCopyIcon,
} from 'omorphia'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import {
users,
remove_user,
@@ -116,6 +116,7 @@ import { get, set } from '@/helpers/settings'
import { handleError } from '@/store/state.js'
import { mixpanel_track } from '@/helpers/mixpanel'
import QrcodeVue from 'qrcode.vue'
import { process_listener } from '@/helpers/events'
defineProps({
mode: {
@@ -214,6 +215,12 @@ const handleClickOutside = (event) => {
}
}
const unlisten = await process_listener(async (e) => {
if (e.event === 'launched') {
await refreshValues()
}
})
onMounted(() => {
window.addEventListener('click', handleClickOutside)
})
@@ -221,6 +228,10 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside)
})
onUnmounted(() => {
unlisten()
})
</script>
<style scoped lang="scss">

View File

@@ -1,5 +1,5 @@
<script setup>
import { Button, Checkbox, Modal, SendIcon, XIcon } from 'omorphia'
import { Button, Checkbox, Modal, XIcon, PlusIcon } from 'omorphia'
import { PackageIcon, VersionIcon } from '@/assets/icons'
import { ref } from 'vue'
import { export_profile_mrpack, get_potential_override_folders } from '@/helpers/profile.js'
@@ -24,9 +24,11 @@ defineExpose({
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()
@@ -93,7 +95,9 @@ const exportPack = async () => {
props.instance.path,
outputPath + `/${nameInput.value} ${versionInput.value}.mrpack`,
filesToExport,
versionInput.value
versionInput.value,
exportDescription.value,
nameInput.value
).catch((err) => handleError(err))
exportModal.value.hide()
}
@@ -123,11 +127,31 @@ const exportPack = async () => {
</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">Select files and folders to include in pack</div>
<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 class="table-content">
<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">
@@ -177,10 +201,6 @@ const exportPack = async () => {
<XIcon />
Cancel
</Button>
<Button disabled>
<SendIcon />
Share
</Button>
<Button color="primary" @click="exportPack">
<PackageIcon />
Export
@@ -261,4 +281,22 @@ const exportPack = async () => {
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

@@ -18,6 +18,14 @@
"
/>
<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
@@ -44,8 +52,22 @@
</template>
<script setup>
import { Button, SearchIcon, PlayIcon, CheckIcon, XIcon, FolderSearchIcon } from 'omorphia'
import { find_jre_17_jres, get_jre } from '@/helpers/jre.js'
import {
Button,
SearchIcon,
PlayIcon,
CheckIcon,
XIcon,
FolderSearchIcon,
DownloadIcon,
} from 'omorphia'
import {
auto_install_java,
find_jre_17_jres,
find_jre_8_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'
@@ -82,15 +104,21 @@ const emit = defineEmits(['update:modelValue'])
const testingJava = ref(false)
const testingJavaSuccess = ref(null)
const installingJava = ref(false)
async function testJava() {
testingJava.value = true
let result = await get_jre(props.modelValue ? props.modelValue.path : '')
testingJavaSuccess.value = await test_jre(
props.modelValue ? props.modelValue.path : '',
1,
props.version
)
testingJava.value = false
testingJavaSuccess.value = !!result
mixpanel_track('JavaTest', {
path: props.modelValue ? props.modelValue.path : '',
success: !!result,
success: testingJavaSuccess.value,
})
setTimeout(() => {
@@ -109,13 +137,13 @@ async function handleJavaFileInput() {
version: props.version.toString(),
architecture: 'x86',
}
mixpanel_track('JavaManualSelect', {
path: filePath,
version: props.version,
})
}
mixpanel_track('JavaManualSelect', {
path: filePath,
version: props.version,
})
emit('update:modelValue', result)
}
}
@@ -125,12 +153,43 @@ async function autoDetect() {
if (!props.compact) {
detectJavaModal.value.show(props.version, props.modelValue)
} else {
let versions = await find_jre_17_jres().catch(handleError)
if (versions.length > 0) {
emit('update:modelValue', versions[0])
if (props.version == 8) {
let versions = await find_jre_8_jres().catch(handleError)
if (versions.length > 0) {
emit('update:modelValue', versions[0])
}
} else {
let versions = await find_jre_17_jres().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)
console.log('java path: ' + path)
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>

View File

@@ -0,0 +1,187 @@
<script setup>
import { Button, Modal, CheckIcon, Badge } from 'omorphia'
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

@@ -176,7 +176,7 @@ defineProps({
</Card>
</aside>
<div ref="searchWrapper" class="search">
<Promotion class="promotion" query-param="?r=launcher" />
<Promotion class="promotion" :external="false" query-param="?r=launcher" />
<Card class="project-type-container">
<NavRow :links="selectableProjectTypes" />
</Card>

View File

@@ -22,8 +22,9 @@ const pageOptions = ['Home', 'Library']
id="theme"
name="Theme dropdown"
:options="['Dark']"
:disabled="true"
:default-value="'dark'"
class="theme-dropdown"
class="theme-dropdown disable-children"
/>
</div>
<div class="adjacent-input">
@@ -33,7 +34,7 @@ const pageOptions = ['Home', 'Library']
>Change the style of the side navigation bar to a compact version.</span
>
</label>
<Toggle id="collapsed-nav" :checked="false" />
<Toggle id="collapsed-nav" :checked="false" :disabled="true" />
</div>
<div class="adjacent-input">
<label for="advanced-rendering">
@@ -43,7 +44,7 @@ const pageOptions = ['Home', 'Library']
without hardware-accelerated rendering.
</span>
</label>
<Toggle id="advanced-rendering" :checked="true" />
<Toggle id="advanced-rendering" :checked="true" :disabled="true" />
</div>
<div class="adjacent-input">
<label for="minimize-launcher">
@@ -52,7 +53,7 @@ const pageOptions = ['Home', 'Library']
>Minimize the launcher when a Minecraft process starts.</span
>
</label>
<Toggle id="minimize-launcher" :checked="false" />
<Toggle id="minimize-launcher" :checked="false" :disabled="true" />
</div>
<div class="opening-page">
<label for="opening-page">
@@ -65,6 +66,7 @@ const pageOptions = ['Home', 'Library']
:options="pageOptions"
default-value="Home"
class="opening-page"
:disabled="true"
/>
</div>
</Card>
@@ -82,7 +84,7 @@ const pageOptions = ['Home', 'Library']
lower value if you have a poor internet connection.</span
>
</label>
<Slider id="max-downloads" :min="1" :max="10" :step="1" />
<Slider id="max-downloads" :min="1" :max="10" :step="1" :disabled="true" />
</div>
<div class="adjacent-input">
@@ -93,7 +95,7 @@ const pageOptions = ['Home', 'Library']
lower value if you are frequently getting I/O errors.</span
>
</label>
<Slider id="max-writes" :min="1" :max="50" :step="1" />
<Slider id="max-writes" :min="1" :max="50" :step="1" :disabled="true" />
</div>
</Card>
<Card>
@@ -110,7 +112,7 @@ const pageOptions = ['Home', 'Library']
customize your experience. Opting out will disable this data collection.
</span>
</label>
<Toggle id="opt-out-analytics" />
<Toggle id="opt-out-analytics" :disabled="true" />
</div>
</Card>
<Card>
@@ -122,11 +124,11 @@ const pageOptions = ['Home', 'Library']
<label for="java-17">
<span class="label__title">Java 17 location</span>
</label>
<JavaSelector id="java-17" :version="17" model-value="" />
<JavaSelector id="java-17" :version="17" model-value="" :disabled="true" />
<label for="java-8">
<span class="label__title">Java 8 location</span>
</label>
<JavaSelector id="java-8" :version="8" model-value="" />
<JavaSelector id="java-8" :version="8" model-value="" :disabled="true" />
<hr class="card-divider" />
<label for="java-args">
<span class="label__title">Java arguments</span>
@@ -137,6 +139,7 @@ const pageOptions = ['Home', 'Library']
type="text"
class="installation-input"
placeholder="Enter java arguments..."
:disabled="true"
/>
<label for="env-vars">
<span class="label__title">Environmental variables</span>
@@ -147,6 +150,7 @@ const pageOptions = ['Home', 'Library']
type="text"
class="installation-input"
placeholder="Enter environmental variables..."
:disabled="true"
/>
<hr class="card-divider" />
<div class="adjacent-input">
@@ -156,7 +160,7 @@ const pageOptions = ['Home', 'Library']
The memory allocated to each instance when it is ran.
</span>
</label>
<Slider id="max-memory" :min="256" :max="10256" :step="1" unit="mb" />
<Slider id="max-memory" :min="256" :max="10256" :step="1" unit="mb" :disabled="true" />
</div>
</Card>
<Card>
@@ -175,6 +179,7 @@ const pageOptions = ['Home', 'Library']
autocomplete="off"
type="text"
placeholder="Enter pre-launch command..."
:disabled="true"
/>
</div>
<div class="adjacent-input">
@@ -182,7 +187,13 @@ const pageOptions = ['Home', 'Library']
<span class="label__title">Wrapper</span>
<span class="label__description"> Wrapper command for launching Minecraft. </span>
</label>
<input id="wrapper" autocomplete="off" type="text" placeholder="Enter wrapper command..." />
<input
id="wrapper"
autocomplete="off"
type="text"
placeholder="Enter wrapper command..."
:disabled="true"
/>
</div>
<div class="adjacent-input">
<label for="post-exit">
@@ -194,6 +205,7 @@ const pageOptions = ['Home', 'Library']
autocomplete="off"
type="text"
placeholder="Enter post-exit command..."
:disabled="true"
/>
</div>
</Card>
@@ -208,7 +220,13 @@ const pageOptions = ['Home', 'Library']
<span class="label__title">Width</span>
<span class="label__description"> The width of the game window when launched. </span>
</label>
<input id="width" autocomplete="off" type="number" placeholder="Enter width..." />
<input
id="width"
autocomplete="off"
type="number"
placeholder="Enter width..."
:disabled="true"
/>
</div>
<div class="adjacent-input">
<label for="height">
@@ -221,6 +239,7 @@ const pageOptions = ['Home', 'Library']
type="number"
class="input"
placeholder="Enter height..."
:disabled="true"
/>
</div>
</Card>
@@ -244,4 +263,8 @@ const pageOptions = ['Home', 'Library']
.card-divider {
margin: 1rem 0;
}
.disable-children * {
pointer-events: none;
}
</style>

View File

@@ -206,9 +206,7 @@ onMounted(() => {
<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 class="transparent" large @click="goToNextPage">
{{ modal ? 'Continue' : 'Next' }}
</Button>
<Button v-if="!modal" class="transparent" large @click="goToNextPage"> Next </Button>
</div>
</Card>
</template>

View File

@@ -295,7 +295,7 @@ onMounted(async () => {
:previous-function="prevPhase"
:progress="phase"
title="Settings"
description="You can view and change the settings for the Modrinth App here. You can change the appearance, set and download new Java versions, and more."
description="You will be able to view and change the settings for the Modrinth App here. You can change the appearance, set and download new Java versions, and more."
/>
<TutorialTip
v-if="phase === 9"

View File

@@ -59,6 +59,12 @@ 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 })
}
// Autodetect Java globals, by searching the users computer.
// Returns a *NEW* JavaGlobals that can be put into Settings
export async function autodetect_java_globals() {

View File

@@ -6,37 +6,50 @@
import { invoke } from '@tauri-apps/api/tauri'
/*
A log is a struct containing the datetime string, stdout, and stderr, as follows:
A log is a struct containing the filename string, stdout, and stderr, as follows:
pub struct Logs {
pub datetime_string: String,
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 datetime_string (the folder name, when the log was created)
/// 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 datetime_string (the folder name, when the log was created)
export async function get_logs_by_datetime(profilePath, datetimeString) {
return await invoke('plugin:logs|logs_get_logs_by_datetime', { profilePath, datetimeString })
/// Get a profile's log by filename
export async function get_logs_by_filename(profilePath, filename) {
return await invoke('plugin:logs|logs_get_logs_by_filename', { profilePath, filename })
}
/// Get a profile's stdout only by datetime_string (the folder name, when the log was created)
export async function get_output_by_datetime(profilePath, datetimeString) {
return await invoke('plugin:logs|logs_get_output_by_datetime', { profilePath, datetimeString })
/// Get a profile's log text only by filename
export async function get_output_by_filename(profilePath, filename) {
return await invoke('plugin:logs|logs_get_output_by_filename', { profilePath, filename })
}
/// Delete a profile's log by datetime_string (the folder name, when the log was created)
export async function delete_logs_by_datetime(profilePath, datetimeString) {
return await invoke('plugin:logs|logs_delete_logs_by_datetime', { profilePath, datetimeString })
/// Delete a profile's log by filename
export async function delete_logs_by_filename(profilePath, filename) {
return await invoke('plugin:logs|logs_delete_logs_by_filename', { profilePath, 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.
}
*/
export async function get_latest_log_cursor(profilePath, cursor) {
return await invoke('plugin:logs|logs_get_latest_log_cursor', { profilePath, cursor })
}

View File

@@ -47,12 +47,6 @@ export async function get_all_running_profiles() {
return await invoke('plugin:process|process_get_all_running_profiles')
}
/// Gets process stdout by UUID
/// Returns String
export async function get_output_by_uuid(uuid) {
return await invoke('plugin:process|process_get_output_by_uuid', { uuid })
}
/// Kills a process by UUID
export async function kill_by_uuid(uuid) {
return await invoke('plugin:process|process_kill_by_uuid', { uuid })

View File

@@ -27,6 +27,11 @@ export async function create(name, gameVersion, modloader, loaderVersion, icon,
})
}
// 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 })
@@ -44,6 +49,12 @@ 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) {
@@ -101,9 +112,9 @@ export async function remove_project(path, projectPath) {
return await invoke('plugin:profile|profile_remove_project', { path, projectPath })
}
// Update a managed Modrinth profile
export async function update_managed_modrinth(path) {
return await invoke('plugin:profile|profile_update_managed_modrinth', { path })
// 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
@@ -114,12 +125,21 @@ export async function update_repair_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) {
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,
})
}

View File

@@ -2,6 +2,7 @@ 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'
@@ -20,12 +21,21 @@ 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':

View File

@@ -59,5 +59,6 @@ initialize_state()
})
})
.catch((err) => {
console.error(err)
console.error('Failed to initialize app', err)
mountedApp.failure(err)
})

View File

@@ -133,11 +133,7 @@ async function refreshDir() {
class="login-screen-modal"
:noblur="!themeStore.advancedRendering"
>
<ModrinthLoginScreen
:modal="true"
:prev-page="$refs.loginScreenModal.show()"
:next-page="signInAfter"
/>
<ModrinthLoginScreen :modal="true" :prev-page="signInAfter" :next-page="signInAfter" />
</Modal>
<div class="adjacent-input">
<label for="theme">
@@ -323,6 +319,21 @@ async function refreshDir() {
"
/>
</div>
<div class="adjacent-input">
<label for="disable-discord-rpc">
<span class="label__title">Disable Discord RPC</span>
<span class="label__description">
Disables the Discord Rich Presence integration. 'Modrinth' will no longer show up as a
game or app you are using on your Discord profile. This does not disable any
instance-specific Discord Rich Presence integrations, such as those added by mods.
</span>
</label>
<Toggle
id="disable-discord-rpc"
v-model="settings.disable_discord_rpc"
:checked="settings.disable_discord_rpc"
/>
</div>
</Card>
<Card>
<div class="label">
@@ -372,7 +383,7 @@ async function refreshDir() {
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="256"
:min="8"
:max="maxMemory"
:step="1"
unit="mb"

View File

@@ -75,7 +75,7 @@
</Card>
</div>
<div class="content">
<Promotion query-param="?r=launcher" />
<Promotion :external="false" query-param="?r=launcher" />
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense @pending="loadingBar.startLoading()" @resolve="loadingBar.stopLoading()">
@@ -84,6 +84,9 @@
:instance="instance"
:options="options"
:offline="offline"
:playing="playing"
:versions="modrinthVersions"
:installed="instance.install_stage !== 'installed'"
></component>
</Suspense>
</template>
@@ -149,6 +152,7 @@ import { isOffline, showProfileInFolder } from '@/helpers/utils.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import { mixpanel_track } from '@/helpers/mixpanel'
import { convertFileSrc } from '@tauri-apps/api/tauri'
import { useFetch } from '@/helpers/fetch'
const route = useRoute()
@@ -197,6 +201,15 @@ const checkProcess = async () => {
uuid.value = null
}
// Get information on associated modrinth versions, if any
const modrinthVersions = ref([])
if (!(await isOffline()) && instance.value.metadata.linked_data) {
modrinthVersions.value = await useFetch(
`https://api.modrinth.com/v2/project/${instance.value.metadata.linked_data.project_id}/version`,
'project'
)
}
await checkProcess()
const stopInstance = async (context) => {

View File

@@ -20,6 +20,15 @@
Share
</Button>
<Button
v-if="logs[selectedLogIndex] && logs[selectedLogIndex].live === true"
@click="clearLiveLog()"
>
<TrashIcon />
Clear
</Button>
<Button
v-else
:disabled="!logs[selectedLogIndex] || logs[selectedLogIndex].live === true"
color="danger"
@click="deleteLog()"
@@ -29,14 +38,43 @@
</Button>
</div>
</div>
<div ref="logContainer" class="log-text">
<span
v-for="(line, index) in logs[selectedLogIndex]?.stdout.split('\n')"
:key="index"
class="no-wrap"
<div class="button-row">
<input
id="text-filter"
v-model="searchFilter"
autocomplete="off"
type="text"
class="text-filter"
placeholder="Type to filter logs..."
/>
<div class="filter-group">
<Checkbox
v-for="level in levels"
:key="level.toLowerCase()"
v-model="levelFilters[level.toLowerCase()]"
class="filter-checkbox"
>
{{ level }}</Checkbox
>
</div>
</div>
<div class="log-text">
<RecycleScroller
v-slot="{ item }"
ref="logContainer"
class="scroller"
:items="displayProcessedLogs"
direction="vertical"
:item-size="20"
key-field="id"
>
{{ line }} <br />
</span>
<div class="user no-wrap">
<span :style="{ color: item.prefixColor, 'font-weight': item.weight }">{{
item.prefix
}}</span>
<span :style="{ color: item.textColor }">{{ item.text }}</span>
</div>
</RecycleScroller>
</div>
<ShareModal
ref="shareModal"
@@ -56,20 +94,31 @@ import {
ClipboardCopyIcon,
DropdownSelect,
ShareIcon,
Checkbox,
TrashIcon,
ShareModal,
} from 'omorphia'
import { delete_logs_by_datetime, get_logs, get_output_by_datetime } from '@/helpers/logs.js'
import { nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
import {
delete_logs_by_filename,
get_logs,
get_output_by_filename,
get_latest_log_cursor,
} from '@/helpers/logs.js'
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
import dayjs from 'dayjs'
import calendar from 'dayjs/plugin/calendar'
import { get_output_by_uuid, get_uuids_by_profile_path } from '@/helpers/process.js'
import isToday from 'dayjs/plugin/isToday'
import isYesterday from 'dayjs/plugin/isYesterday'
import { get_uuids_by_profile_path } from '@/helpers/process.js'
import { useRoute } from 'vue-router'
import { process_listener } from '@/helpers/events.js'
import { handleError } from '@/store/notifications.js'
import { ofetch } from 'ofetch'
dayjs.extend(calendar)
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
dayjs.extend(isToday)
dayjs.extend(isYesterday)
const route = useRoute()
@@ -82,11 +131,21 @@ const props = defineProps({
type: Boolean,
default: false,
},
playing: {
type: Boolean,
default: false,
},
})
const currentLiveLog = ref(null)
const currentLiveLogCursor = ref(0)
const emptyText = ['No live game detected.', 'Start your game to proceed']
const logs = ref([])
await setLogs()
const logsColored = true
const selectedLogIndex = ref(0)
const copied = ref(false)
const logContainer = ref(null)
@@ -95,16 +154,86 @@ const userScrolled = ref(false)
const isAutoScrolling = ref(false)
const shareModal = ref(null)
const levels = ['Comment', 'Error', 'Warn', 'Info', 'Debug', 'Trace']
const levelFilters = ref({})
levels.forEach((level) => {
levelFilters.value[level.toLowerCase()] = true
})
const searchFilter = ref('')
function shouldDisplay(processedLine) {
if (!processedLine.level) {
return true
}
if (!levelFilters.value[processedLine.level.toLowerCase()]) {
return false
}
if (searchFilter.value !== '') {
if (!processedLine.text.toLowerCase().includes(searchFilter.value.toLowerCase())) {
return false
}
}
return true
}
// Selects from the processed logs which ones should be displayed (shouldDisplay)
// In addition, splits each line by \n. Each split line is given the same properties as the original line
const displayProcessedLogs = computed(() => {
return processedLogs.value.filter((l) => shouldDisplay(l))
})
const processedLogs = computed(() => {
// split based on newline and timestamp lookahead
// (not just newline because of multiline messages)
const splitPattern = /\n(?=(?:#|\[\d\d:\d\d:\d\d\]))/
const lines = logs.value[selectedLogIndex.value]?.stdout.split(splitPattern) || []
const processed = []
let id = 0
for (let i = 0; i < lines.length; i++) {
// Then split off of \n.
// Lines that are not the first have prefix = null
const text = getLineText(lines[i])
const prefix = getLinePrefix(lines[i])
const prefixColor = getLineColor(lines[i], true)
const textColor = getLineColor(lines[i], false)
const weight = getLineWeight(lines[i])
const level = getLineLevel(lines[i])
text.split('\n').forEach((line, index) => {
processed.push({
id: id,
text: line,
prefix: index === 0 ? prefix : null,
prefixColor: prefixColor,
textColor: textColor,
weight: weight,
level: level,
})
id += 1
})
}
return processed
})
async function getLiveLog() {
if (route.params.id) {
const uuids = await get_uuids_by_profile_path(route.params.id).catch(handleError)
let returnValue
if (uuids.length === 0) {
returnValue = 'No live game detected. \nStart your game to proceed'
returnValue = emptyText.join('\n')
} else {
returnValue = await get_output_by_uuid(uuids[0]).catch(handleError)
const logCursor = await get_latest_log_cursor(
props.instance.path,
currentLiveLogCursor.value
).catch(handleError)
if (logCursor.new_file) {
currentLiveLog.value = ''
}
currentLiveLog.value = currentLiveLog.value + logCursor.output
currentLiveLogCursor.value = logCursor.cursor
returnValue = currentLiveLog.value
}
return { name: 'Live Log', stdout: returnValue, live: true }
}
return null
@@ -112,9 +241,25 @@ async function getLiveLog() {
async function getLogs() {
return (await get_logs(props.instance.path, true).catch(handleError)).reverse().map((log) => {
log.name = dayjs(
log.datetime_string.slice(0, 8) + 'T' + log.datetime_string.slice(9)
).calendar()
if (log.filename == 'latest.log') {
log.name = 'Latest Log'
} else {
let filename = log.filename.split('.')[0]
let day = dayjs(filename.slice(0, 10))
if (day.isValid()) {
if (day.isToday()) {
log.name = 'Today'
} else if (day.isYesterday()) {
log.name = 'Yesterday'
} else {
log.name = day.format('MMMM D, YYYY')
}
// Displays as "Today-1", "Today-2", etc, matching minecraft log naming but with the date
log.name = log.name + filename.slice(10)
} else {
log.name = filename
}
}
log.stdout = 'Loading...'
return log
})
@@ -152,29 +297,127 @@ watch(selectedLogIndex, async (newIndex) => {
if (logs.value.length > 1 && newIndex !== 0) {
logs.value[newIndex].stdout = 'Loading...'
logs.value[newIndex].stdout = await get_output_by_datetime(
logs.value[newIndex].stdout = await get_output_by_filename(
props.instance.path,
logs.value[newIndex].datetime_string
logs.value[newIndex].filename
).catch(handleError)
}
})
if (logs.value.length >= 1) {
if (logs.value.length > 1 && !props.playing) {
selectedLogIndex.value = 1
} else {
selectedLogIndex.value = 0
}
const deleteLog = async () => {
if (logs.value[selectedLogIndex.value] && selectedLogIndex.value !== 0) {
let deleteIndex = selectedLogIndex.value
selectedLogIndex.value = deleteIndex - 1
await delete_logs_by_datetime(
props.instance.path,
logs.value[deleteIndex].datetime_string
).catch(handleError)
await delete_logs_by_filename(props.instance.path, logs.value[deleteIndex].filename).catch(
handleError
)
await setLogs()
}
}
const clearLiveLog = async () => {
currentLiveLog.value = ''
// does not reset cursor
}
const isLineLevel = (text, level) => {
if ((text.includes('/INFO') || text.includes('[System] [CHAT]')) && level === 'info') {
return true
}
if (text.includes('/WARN') && level === 'warn') {
return true
}
if (text.includes('/DEBUG') && level === 'debug') {
return true
}
if (text.includes('/TRACE') && level === 'trace') {
return true
}
const errorTriggers = ['/ERROR', 'Exception:', ':?]', 'Error', '[thread', ' at']
if (level === 'error') {
for (const trigger of errorTriggers) {
if (text.includes(trigger)) return true
}
}
if (text.trim()[0] === '#' && level === 'comment') {
return true
}
return false
}
const getLineWeight = (text) => {
if (
!logsColored ||
isLineLevel(text, 'info') ||
isLineLevel(text, 'debug') ||
isLineLevel(text, 'trace')
) {
return 'normal'
}
if (isLineLevel(text, 'error') || isLineLevel(text, 'warn')) {
return 'bold'
}
}
const getLineLevel = (text) => {
for (const level of levels) {
if (isLineLevel(text, level.toLowerCase())) {
return level
}
}
}
const getLineColor = (text, prefix) => {
if (isLineLevel(text, 'comment')) {
return 'var(--color-green)'
}
if (!logsColored || text.includes('[System] [CHAT]')) {
return 'var(--color-white)'
}
if (
(isLineLevel(text, 'info') || isLineLevel(text, 'debug') || isLineLevel(text, 'trace')) &&
prefix
) {
return 'var(--color-blue)'
}
if (isLineLevel(text, 'warn')) {
return 'var(--color-orange)'
}
if (isLineLevel(text, 'error')) {
return 'var(--color-red)'
}
}
const getLinePrefix = (text) => {
if (text.includes(']:')) {
return text.split(']:')[0] + ']:'
}
}
const getLineText = (text) => {
if (text.includes(']:')) {
if (text.split(']:').length > 2) {
return text.split(']:').slice(1).join(']:')
}
return text.split(']:')[1]
} else {
return text
}
}
function handleUserScroll() {
if (!isAutoScrolling.value) {
userScrolled.value = true
@@ -185,19 +428,14 @@ interval.value = setInterval(async () => {
if (logs.value.length > 0) {
logs.value[0] = await getLiveLog()
const scroll = logContainer.value.getScroll()
// Allow resetting of userScrolled if the user scrolls to the bottom
if (selectedLogIndex.value === 0) {
if (
logContainer.value.scrollTop + logContainer.value.offsetHeight >=
logContainer.value.scrollHeight - 10
)
userScrolled.value = false
if (scroll.end >= logContainer.value.$el.scrollHeight - 10) userScrolled.value = false
if (!userScrolled.value) {
await nextTick()
isAutoScrolling.value = true
logContainer.value.scrollTop =
logContainer.value.scrollHeight - logContainer.value.offsetHeight
logContainer.value.scrollToItem(displayProcessedLogs.value.length - 1)
setTimeout(() => (isAutoScrolling.value = false), 50)
}
}
@@ -206,9 +444,13 @@ interval.value = setInterval(async () => {
const unlistenProcesses = await process_listener(async (e) => {
if (e.event === 'launched') {
currentLiveLog.value = ''
currentLiveLogCursor.value = 0
selectedLogIndex.value = 0
}
if (e.event === 'finished') {
currentLiveLog.value = ''
currentLiveLogCursor.value = 0
userScrolled.value = false
await setLogs()
selectedLogIndex.value = 1
@@ -216,11 +458,11 @@ const unlistenProcesses = await process_listener(async (e) => {
})
onMounted(() => {
logContainer.value.addEventListener('scroll', handleUserScroll)
logContainer.value.$el.addEventListener('scroll', handleUserScroll)
})
onBeforeUnmount(() => {
logContainer.value.removeEventListener('scroll', handleUserScroll)
logContainer.value.$el.removeEventListener('scroll', handleUserScroll)
})
onUnmounted(() => {
clearInterval(interval.value)
@@ -257,7 +499,9 @@ onUnmounted(() => {
color: var(--color-contrast);
border-radius: var(--radius-lg);
padding: 1.5rem;
overflow: auto;
overflow-x: auto; /* Enables horizontal scrolling */
overflow-y: hidden; /* Disables vertical scrolling on this wrapper */
white-space: nowrap; /* Keeps content on a single line */
white-space: normal;
color-scheme: dark;
@@ -265,4 +509,37 @@ onUnmounted(() => {
white-space: pre;
}
}
.filter-checkbox {
margin-bottom: 0.3rem;
font-size: 1rem;
svg {
display: flex;
align-self: center;
justify-self: center;
}
}
.filter-group {
display: flex;
padding: 0.6rem;
flex-direction: row;
gap: 0.5rem;
}
:deep(.vue-recycle-scroller__item-wrapper) {
overflow: visible; /* Enables horizontal scrolling */
}
.scroller {
height: 100%;
}
.user {
height: 32%;
padding: 0 12px;
display: flex;
align-items: center;
}
</style>

View File

@@ -26,21 +26,25 @@
</div>
</div>
<Button
v-if="isPackLinked"
v-tooltip="'Modpack is up to date'"
:disabled="updatingModpack || !canUpdatePack"
v-if="canUpdatePack"
:disabled="installing"
color="secondary"
@click="updateModpack"
@click="modpackVersionModal.show()"
>
<UpdatedIcon />
{{ updatingModpack ? 'Updating' : 'Update modpack' }}
{{ installing ? 'Updating' : 'Update modpack' }}
</Button>
<Button v-else @click="exportModal.show()">
<Button v-else-if="!isPackLocked" @click="exportModal.show()">
<PackageIcon />
Export modpack
</Button>
<Button v-if="!isPackLocked && projects.some((m) => m.outdated)" @click="updateAll">
<UpdatedIcon />
Update all
</Button>
<DropdownButton
v-if="!isPackLinked"
v-if="!isPackLocked"
:options="['search', 'from_file']"
default-value="search"
name="add-content-dropdown"
@@ -107,9 +111,9 @@
<ShareIcon />
Share
</Button>
<div v-tooltip="isPackLinked ? 'Unpair this instance to remove mods' : ''">
<div v-tooltip="isPackLocked ? 'Unlock this instance to remove mods' : ''">
<Button
:disabled="isPackLinked"
:disabled="isPackLocked"
class="transparent trash"
@click="deleteWarning.show()"
@mouseover="selectedOption = 'Delete'"
@@ -118,20 +122,20 @@
Delete
</Button>
</div>
<div v-tooltip="isPackLinked ? 'Unpair this instance to update mods' : ''">
<div v-tooltip="isPackLocked ? 'Unlock this instance to update mods' : ''">
<Button
:disabled="isPackLinked || offline"
:disabled="isPackLocked || offline"
class="transparent update"
@click="updateAll()"
@click="updateSelected()"
@mouseover="selectedOption = 'Update'"
>
<UpdatedIcon />
Update
</Button>
</div>
<div v-tooltip="isPackLinked ? 'Unpair this instance to toggle mods' : ''">
<div v-tooltip="isPackLocked ? 'Unlock this instance to toggle mods' : ''">
<Button
:disabled="isPackLinked"
:disabled="isPackLocked"
class="transparent"
@click="toggleSelected()"
@mouseover="selectedOption = 'Toggle'"
@@ -232,21 +236,18 @@
<span v-tooltip="`${mod.version}`">{{ mod.version }}</span>
</div>
<div class="table-cell table-text manage">
<div v-tooltip="isPackLinked ? 'Unpair this instance to remove mods.' : ''">
<Button
v-tooltip="'Remove project'"
:disabled="isPackLinked"
icon-only
@click="removeMod(mod)"
>
<div v-tooltip="isPackLocked ? 'Unlock this instance to remove mods.' : 'Remove project'">
<Button :disabled="isPackLocked" icon-only @click="removeMod(mod)">
<TrashIcon />
</Button>
</div>
<AnimatedLogo v-if="mod.updating" class="btn icon-only updating-indicator"></AnimatedLogo>
<div v-tooltip="isPackLinked ? 'Unpair this instance to update mods.' : ''">
<div
v-else
v-tooltip="isPackLocked ? 'Unlock this instance to update mods.' : 'Update project'"
>
<Button
v-tooltip="'Update project'"
:disabled="!mod.outdated || offline || isPackLinked"
:disabled="!mod.outdated || offline || isPackLocked"
icon-only
@click="updateProject(mod)"
>
@@ -254,10 +255,10 @@
<CheckIcon v-else />
</Button>
</div>
<div v-tooltip="isPackLinked ? 'Unpair this instance to toggle mods.' : ''">
<div v-tooltip="isPackLocked ? 'Unlock this instance to toggle mods.' : ''">
<input
id="switch-1"
:disabled="isPackLinked"
:disabled="isPackLocked"
autocomplete="off"
type="checkbox"
class="switch stylized-toggle"
@@ -268,7 +269,7 @@
<Button
v-tooltip="`Show ${mod.file_name}`"
icon-only
@click="showProfileInFolder(mod.path)"
@click="highlightModInProfile(instance.path, mod.path)"
>
<FolderOpenIcon />
</Button>
@@ -301,6 +302,14 @@
</DropdownButton>
</div>
</div>
<Pagination
v-if="projects.length > 0"
:page="currentPage"
:count="Math.ceil(search.length / 20)"
class="pagination-after"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
<Modal ref="deleteWarning" header="Are you sure?">
<div class="modal-body">
<div class="markdown-body">
@@ -349,6 +358,12 @@
share-text="Check out the projects I'm using in my modpack!"
/>
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
<ModpackVersionModal
v-if="instance.metadata.linked_data"
ref="modpackVersionModal"
:instance="instance"
:versions="props.versions"
/>
</template>
<script setup>
import {
@@ -385,7 +400,6 @@ import {
remove_project,
toggle_disable_project,
update_all,
update_managed_modrinth,
update_project,
} from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
@@ -393,9 +407,10 @@ import { mixpanel_track } from '@/helpers/mixpanel'
import { open } from '@tauri-apps/api/dialog'
import { listen } from '@tauri-apps/api/event'
import { convertFileSrc } from '@tauri-apps/api/tauri'
import { showProfileInFolder } from '@/helpers/utils.js'
import { highlightModInProfile } from '@/helpers/utils.js'
import { MenuIcon, ToggleIcon, TextInputIcon, AddProjectImage, PackageIcon } from '@/assets/icons'
import ExportModal from '@/components/ui/ExportModal.vue'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
const router = useRouter()
@@ -418,20 +433,24 @@ const props = defineProps({
return false
},
},
versions: {
type: Array,
required: true,
},
})
const projects = ref([])
const selectionMap = ref(new Map())
const showingOptions = ref(false)
const isPackLinked = computed(() => {
return props.instance.metadata.linked_data
const isPackLocked = computed(() => {
return props.instance.metadata.linked_data && props.instance.metadata.linked_data.locked
})
const canUpdatePack = computed(() => {
if (!props.instance.metadata.linked_data) return false
return props.instance.metadata.linked_data.version_id !== props.instance.modrinth_update_version
})
const exportModal = ref(null)
console.log(props.instance)
const initProjects = (initInstance) => {
projects.value = []
if (!initInstance || !initInstance.projects) return
@@ -508,6 +527,9 @@ watch(
}
)
const modpackVersionModal = ref(null)
const installing = computed(() => props.instance.install_stage !== 'installed')
const searchFilter = ref('')
const selectAll = ref(false)
const selectedProjectType = ref('All')
@@ -661,6 +683,7 @@ const selectUpdatable = () => {
const updateProject = async (mod) => {
mod.updating = true
await new Promise((resolve) => setTimeout(resolve, 0))
mod.path = await update_project(props.instance.path, mod.path).catch(handleError)
mod.updating = false
@@ -779,6 +802,14 @@ const toggleSelected = async () => {
}
}
const updateSelected = async () => {
const promises = []
for (const project of functionValues.value) {
if (project.outdated) promises.push(updateProject(project))
}
await Promise.all(promises).catch(handleError)
}
const enableAll = async () => {
for (const project of functionValues.value) {
if (project.disabled) {
@@ -828,13 +859,6 @@ const handleContentOptionClick = async (args) => {
}
}
const updatingModpack = ref(false)
const updateModpack = async () => {
updatingModpack.value = true
await update_managed_modrinth(props.instance.path).catch(handleError)
updatingModpack.value = false
}
watch(selectAll, () => {
for (const [key, value] of Array.from(selectionMap.value)) {
if (value !== selectAll.value) {
@@ -1152,4 +1176,8 @@ onUnmounted(() => {
height: 2.5rem;
}
}
.pagination-after {
margin-bottom: 5rem;
}
</style>

View File

@@ -8,6 +8,56 @@
:noblur="!themeStore.advancedRendering"
@proceed="removeProfile"
/>
<Modal
ref="modalConfirmUnlock"
header="Are you sure you want to unlock this instance?"
:noblur="!themeStore.advancedRendering"
>
<div class="modal-delete">
<div
class="markdown-body"
v-html="
'If you proceed, you will not be able to re-lock it without using the `Reinstall modpack` button.'
"
/>
<div class="input-group push-right">
<button class="btn" @click="$refs.modalConfirmUnlock.hide()">
<XIcon />
Cancel
</button>
<button class="btn btn-danger" :disabled="action_disabled" @click="unlockProfile">
<LockIcon />
Unlock
</button>
</div>
</div>
</Modal>
<Modal
ref="modalConfirmUnpair"
header="Are you sure you want to unpair this instance?"
:noblur="!themeStore.advancedRendering"
>
<div class="modal-delete">
<div
class="markdown-body"
v-html="
'If you proceed, you will not be able to re-pair it without creating an entirely new instance.'
"
/>
<div class="input-group push-right">
<button class="btn" @click="$refs.modalConfirmUnpair.hide()">
<XIcon />
Cancel
</button>
<button class="btn btn-danger" :disabled="action_disabled" @click="unpairProfile">
<XIcon />
Unpair
</button>
</div>
</div>
</Modal>
<Modal
ref="changeVersionsModal"
header="Change instance versions"
@@ -191,7 +241,7 @@
<Slider
v-model="memory.maximum"
:disabled="!overrideMemorySettings"
:min="256"
:min="8"
:max="maxMemory"
:step="1"
unit="mb"
@@ -298,22 +348,110 @@
/>
</div>
</Card>
<Card v-if="instance.metadata.linked_data">
<div class="label">
<h3>
<span class="label__title size-card-header">Modpack</span>
</h3>
</div>
<div class="adjacent-input">
<label for="general-modpack-info">
<span class="label__description">
<strong>Modpack: </strong> {{ instance.metadata.name }}
</span>
<span class="label__description">
<strong>Version: </strong>
{{
installedVersionData.name.charAt(0).toUpperCase() + installedVersionData.name.slice(1)
}}
</span>
</label>
</div>
<div v-if="!isPackLocked" class="adjacent-input">
<Card class="unlocked-instance">
This is an unlocked instance. There may be unexpected behaviour unintended by the modpack
creator.
</Card>
</div>
<div v-else class="adjacent-input">
<label for="unlock-profile">
<span class="label__title">Unlock instance</span>
<span class="label__description">
Allows modifications to the instance, which allows you to add projects to the modpack. The
pack will remain linked, and you can still change versions. Only mods listed in the
modpack will be modified on version changes.
</span>
</label>
<Button id="unlock-profile" @click="$refs.modalConfirmUnlock.show()">
<LockIcon /> Unlock
</Button>
</div>
<div class="adjacent-input">
<label for="unpair-profile">
<span class="label__title">Unpair instance</span>
<span class="label__description">
Removes the link to an external Modrinth modpack on the instance. This allows you to edit
modpacks you download through the browse page but you will not be able to update the
instance from a new version of a modpack if you do this.
</span>
</label>
<Button id="unpair-profile" @click="$refs.modalConfirmUnpair.show()">
<XIcon /> Unpair
</Button>
</div>
<div class="adjacent-input">
<label for="change-modpack-version">
<span class="label__title">Change modpack version</span>
<span class="label__description">
Changes to another version of the modpack, allowing upgrading or downgrading. This will
replace all files marked as relevant to the modpack.
</span>
</label>
<Button
id="change-modpack-version"
:disabled="inProgress || installing"
@click="modpackVersionModal.show()"
>
<SwapIcon />
Change modpack version
</Button>
</div>
<div class="adjacent-input">
<label for="repair-modpack">
<span class="label__title">Reinstall modpack</span>
<span class="label__description">
Removes all projects and reinstalls Modrinth modpack. Use this to fix unexpected behaviour
if your instance is diverging from the Modrinth modpack. This also re-locks the instance.
</span>
</label>
<Button id="repair-modpack" color="highlight" :disabled="offline" @click="repairModpack">
<DownloadIcon /> Reinstall
</Button>
</div>
</Card>
<Card>
<div class="label">
<h3>
<span class="label__title size-card-header">Instance management</span>
</h3>
</div>
<div v-if="instance.metadata.linked_data" class="adjacent-input">
<label for="repair-profile">
<span class="label__title">Unpair instance</span>
<div v-if="instance.install_stage == 'installed'" class="adjacent-input">
<label for="duplicate-profile">
<span class="label__title">Duplicate instance</span>
<span class="label__description">
Removes the link to an external modpack on the instance. This allows you to edit modpacks
you download through the browse page but you will not be able to update the instance from
a new version of a modpack if you do this.
Creates another copy of the instance, including saves, configs, mods, and everything.
</span>
</label>
<Button id="repair-profile" @click="unpairProfile"> <XIcon /> Unpair </Button>
<Button
id="repair-profile"
:disabled:="installing || inProgress || offline"
@click="duplicateProfile"
>
<ClipboardCopyIcon /> Duplicate
</Button>
</div>
<div class="adjacent-input">
<label for="repair-profile">
@@ -326,29 +464,12 @@
<Button
id="repair-profile"
color="highlight"
:disabled="repairing || offline"
:disabled="installing || inProgress || repairing || offline"
@click="repairProfile"
>
<HammerIcon /> Repair
</Button>
</div>
<div v-if="props.instance.modrinth_update_version" class="adjacent-input">
<label for="repair-profile">
<span class="label__title">Reinstall modpack</span>
<span class="label__description">
Reinstalls Modrinth modpack and checks for corruption. Use this if your game is not
launching due to your instance diverging from the Modrinth modpack.
</span>
</label>
<Button
id="repair-profile"
color="highlight"
:disabled="repairing || offline"
@click="repairModpack"
>
<DownloadIcon /> Reinstall
</Button>
</div>
<div class="adjacent-input">
<label for="delete-profile">
<span class="label__title">Delete instance</span>
@@ -367,6 +488,12 @@
</Button>
</div>
</Card>
<ModpackVersionModal
v-if="instance.metadata.linked_data"
ref="modpackVersionModal"
:instance="instance"
:versions="props.versions"
/>
</template>
<script setup>
@@ -383,14 +510,19 @@ import {
DropdownSelect,
XIcon,
SaveIcon,
LockIcon,
HammerIcon,
DownloadIcon,
ModalConfirm,
DownloadIcon,
ClipboardCopyIcon,
Button,
} from 'omorphia'
import { SwapIcon } from '@/assets/icons'
import { Multiselect } from 'vue-multiselect'
import { useRouter } from 'vue-router'
import {
duplicate,
edit,
edit_icon,
get_optimal_jre_key,
@@ -415,6 +547,7 @@ import { get_game_versions, get_loaders } from '@/helpers/tags.js'
import { handleError } from '@/store/notifications.js'
import { mixpanel_track } from '@/helpers/mixpanel'
import { useTheming } from '@/store/theme.js'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
const router = useRouter()
@@ -427,6 +560,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
versions: {
type: Array,
required: true,
},
})
const themeStore = useTheming()
@@ -435,6 +572,8 @@ const title = ref(props.instance.metadata.name)
const icon = ref(props.instance.metadata.icon)
const groups = ref(props.instance.metadata.groups)
const modpackVersionModal = ref(null)
const instancesList = Object.values(await list(true))
const availableGroups = ref([
...instancesList.reduce((acc, obj) => {
@@ -469,6 +608,9 @@ async function setIcon() {
const globalSettings = await get().catch(handleError)
const modalConfirmUnlock = ref(null)
const modalConfirmUnpair = ref(null)
const javaSettings = props.instance.java ?? {}
const overrideJavaInstall = ref(!!javaSettings.override_version)
@@ -496,6 +638,13 @@ const fullscreenSetting = ref(!!props.instance.fullscreen)
const unlinkModpack = ref(false)
const inProgress = ref(false)
const installing = computed(() => props.instance.install_stage !== 'installed')
const installedVersion = computed(() => props.instance?.metadata?.linked_data?.version_id)
const installedVersionData = computed(() =>
props.versions.find((version) => version.id === installedVersion.value)
)
watch(
[
title,
@@ -517,71 +666,78 @@ watch(
unlinkModpack,
],
async () => {
const editProfile = {
metadata: {
name: title.value.trim().substring(0, 32) ?? 'Instance',
groups: groups.value.map((x) => x.trim().substring(0, 32)).filter((x) => x.length > 0),
loader_version: props.instance.metadata.loader_version,
linked_data: props.instance.metadata.linked_data,
},
java: {},
}
if (overrideJavaInstall.value) {
if (javaInstall.value.path !== '') {
editProfile.java.override_version = javaInstall.value
editProfile.java.override_version.path = editProfile.java.override_version.path.replace(
'java.exe',
'javaw.exe'
)
}
}
if (overrideJavaArgs.value) {
if (javaArgs.value !== '') {
editProfile.java.extra_arguments = javaArgs.value.trim().split(/\s+/).filter(Boolean)
}
}
if (overrideEnvVars.value) {
if (envVars.value !== '') {
editProfile.java.custom_env_args = envVars.value
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x) => x.split('=').filter(Boolean))
}
}
if (overrideMemorySettings.value) {
editProfile.memory = memory.value
}
if (overrideWindowSettings.value) {
editProfile.fullscreen = fullscreenSetting.value
if (!fullscreenSetting.value) {
editProfile.resolution = resolution.value
}
}
if (overrideHooks.value) {
editProfile.hooks = hooks.value
}
if (unlinkModpack.value) {
editProfile.metadata.linked_data = null
}
await edit(props.instance.path, editProfile)
await edit(props.instance.path, editProfileObject.value)
},
{ deep: true }
)
const editProfileObject = computed(() => {
const editProfile = {
metadata: {
name: title.value.trim().substring(0, 32) ?? 'Instance',
groups: groups.value.map((x) => x.trim().substring(0, 32)).filter((x) => x.length > 0),
loader_version: props.instance.metadata.loader_version,
linked_data: props.instance.metadata.linked_data,
},
java: {},
}
if (overrideJavaInstall.value) {
if (javaInstall.value.path !== '') {
editProfile.java.override_version = javaInstall.value
editProfile.java.override_version.path = editProfile.java.override_version.path.replace(
'java.exe',
'javaw.exe'
)
}
}
if (overrideJavaArgs.value) {
if (javaArgs.value !== '') {
editProfile.java.extra_arguments = javaArgs.value.trim().split(/\s+/).filter(Boolean)
}
}
if (overrideEnvVars.value) {
if (envVars.value !== '') {
editProfile.java.custom_env_args = envVars.value
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x) => x.split('=').filter(Boolean))
}
}
if (overrideMemorySettings.value) {
editProfile.memory = memory.value
}
if (overrideWindowSettings.value) {
editProfile.fullscreen = fullscreenSetting.value
if (!fullscreenSetting.value) {
editProfile.resolution = resolution.value
}
}
if (overrideHooks.value) {
editProfile.hooks = hooks.value
}
if (unlinkModpack.value) {
editProfile.metadata.linked_data = null
}
return editProfile
})
const repairing = ref(false)
async function unpairProfile() {
unlinkModpack.value = true
async function duplicateProfile() {
await duplicate(props.instance.path).catch(handleError)
mixpanel_track('InstanceDuplicate', {
loader: props.instance.metadata.loader,
game_version: props.instance.metadata.game_version,
})
}
async function repairProfile() {
@@ -595,10 +751,30 @@ async function repairProfile() {
})
}
async function unpairProfile() {
const editProfile = props.instance
editProfile.metadata.linked_data = null
await edit(props.instance.path, editProfile)
installedVersion.value = null
installedVersionData.value = null
modalConfirmUnpair.value.hide()
}
async function unlockProfile() {
const editProfile = props.instance
editProfile.metadata.linked_data.locked = false
await edit(props.instance.path, editProfile)
modalConfirmUnlock.value.hide()
}
const isPackLocked = computed(() => {
return props.instance.metadata.linked_data && props.instance.metadata.linked_data.locked
})
async function repairModpack() {
repairing.value = true
inProgress.value = true
await update_repair_modrinth(props.instance.path).catch(handleError)
repairing.value = false
inProgress.value = false
mixpanel_track('InstanceRepair', {
loader: props.instance.metadata.loader,
@@ -711,12 +887,9 @@ const editing = ref(false)
async function saveGvLoaderEdits() {
editing.value = true
const editProfile = {
metadata: {
game_version: gameVersion.value,
loader: loader.value,
},
}
let editProfile = editProfileObject.value
editProfile.metadata.loader = loader.value
editProfile.metadata.game_version = gameVersion.value
if (loader.value !== 'vanilla') {
editProfile.metadata.loader_version = selectableLoaderVersions.value[loaderVersionIndex.value]
@@ -772,4 +945,39 @@ async function saveGvLoaderEdits() {
:deep(button.checkbox) {
border: none;
}
.unlocked-instance {
background-color: var(--color-bg);
}
.modal-delete {
padding: var(--gap-lg);
display: flex;
flex-direction: column;
.markdown-body {
margin-bottom: 1rem;
}
.confirmation-label {
margin-bottom: 0.5rem;
}
.confirmation-text {
padding-right: 0.25ch;
margin: 0 0.25rem;
}
.confirmation-input {
input {
width: 20rem;
max-width: 100%;
}
}
.button-group {
margin-left: auto;
margin-top: 1.5rem;
}
}
</style>

View File

@@ -168,7 +168,7 @@
</Card>
</div>
<div v-if="data" class="content-container">
<Promotion query-param="?r=launcher" />
<Promotion :external="false" query-param="?r=launcher" />
<Card class="tabs">
<NavRow
v-if="data.gallery.length > 0"