feat:移除了弹窗,服务器添加sls
This commit is contained in:
commit
6a295f9a7a
4082 files changed
+1322534
No files matched your search
@@ -0,0 +1,434 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
EyeIcon,
|
||||
FileArchiveIcon,
|
||||
PlusIcon,
|
||||
RestoreIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
add_core_jar_mod,
|
||||
import_mcarchive_modloader,
|
||||
install_mcarchive_modloader,
|
||||
list_core_components,
|
||||
move_core_component,
|
||||
preview_core_jar,
|
||||
remove_core_component,
|
||||
replace_core_jar,
|
||||
restore_core_component,
|
||||
set_core_component_enabled,
|
||||
} from '@/helpers/instance'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
const { instance } = injectInstanceSettings()
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
const busy = ref(false)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: computed(() => ['core-components', instance.value.id]),
|
||||
queryFn: () => list_core_components(instance.value.id),
|
||||
})
|
||||
|
||||
const activeComponents = computed(() =>
|
||||
(query.data.value ?? []).filter((component) => !component.removed),
|
||||
)
|
||||
const deletedComponents = computed(() =>
|
||||
(query.data.value ?? []).filter((component) => component.removed),
|
||||
)
|
||||
const canInstallModLoader = computed(() => {
|
||||
const version = instance.value.game_version.trim().replace(/^v/, '')
|
||||
if (/^(?:a|b|inf-)/.test(version)) return true
|
||||
const match = /^1\.(\d+)(?:\.(\d+))?$/.exec(version)
|
||||
if (!match) return false
|
||||
const minor = Number(match[1])
|
||||
const patch = Number(match[2] ?? 0)
|
||||
return minor <= 6 && (minor < 6 || patch <= 2)
|
||||
})
|
||||
const manualModLoader = ref<{
|
||||
fileName: string
|
||||
pageUrl: string | null
|
||||
expectedSha256: string | null
|
||||
} | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'instance.settings.tabs.core-components.title',
|
||||
defaultMessage: 'Core components',
|
||||
},
|
||||
add: {
|
||||
id: 'instance.settings.tabs.core-components.add',
|
||||
defaultMessage: 'Add to Minecraft.jar',
|
||||
},
|
||||
replace: {
|
||||
id: 'instance.settings.tabs.core-components.replace',
|
||||
defaultMessage: 'Replace Minecraft.jar',
|
||||
},
|
||||
pickJar: {
|
||||
id: 'instance.settings.tabs.core-components.pick-jar',
|
||||
defaultMessage: 'Choose core archive',
|
||||
},
|
||||
jarFilter: {
|
||||
id: 'instance.settings.tabs.core-components.jar-filter',
|
||||
defaultMessage: 'Minecraft archives',
|
||||
},
|
||||
moveUp: {
|
||||
id: 'instance.settings.tabs.core-components.move-up',
|
||||
defaultMessage: 'Move up',
|
||||
},
|
||||
moveDown: {
|
||||
id: 'instance.settings.tabs.core-components.move-down',
|
||||
defaultMessage: 'Move down',
|
||||
},
|
||||
remove: {
|
||||
id: 'instance.settings.tabs.core-components.remove',
|
||||
defaultMessage: 'Remove',
|
||||
},
|
||||
restore: {
|
||||
id: 'instance.settings.tabs.core-components.restore',
|
||||
defaultMessage: 'Restore',
|
||||
},
|
||||
jarMod: {
|
||||
id: 'instance.settings.tabs.core-components.jar-mod',
|
||||
defaultMessage: 'JAR mod',
|
||||
},
|
||||
replacement: {
|
||||
id: 'instance.settings.tabs.core-components.replacement',
|
||||
defaultMessage: 'Replacement JAR',
|
||||
},
|
||||
sha256: {
|
||||
id: 'instance.settings.tabs.core-components.sha256',
|
||||
defaultMessage: 'SHA-256',
|
||||
},
|
||||
failure: {
|
||||
id: 'instance.settings.tabs.core-components.failure',
|
||||
defaultMessage: 'Failure',
|
||||
},
|
||||
sha1: {
|
||||
id: 'instance.settings.tabs.core-components.sha1',
|
||||
defaultMessage: 'SHA-1',
|
||||
},
|
||||
source: {
|
||||
id: 'instance.settings.tabs.core-components.source',
|
||||
defaultMessage: 'Source',
|
||||
},
|
||||
targetVersion: {
|
||||
id: 'instance.settings.tabs.core-components.target-version',
|
||||
defaultMessage: 'Target Minecraft version',
|
||||
},
|
||||
preview: {
|
||||
id: 'instance.settings.tabs.core-components.preview',
|
||||
defaultMessage: 'Preview assembled JAR',
|
||||
},
|
||||
previewed: {
|
||||
id: 'instance.settings.tabs.core-components.previewed',
|
||||
defaultMessage: 'Assembled {components} components into {entries} entries',
|
||||
},
|
||||
modLoader: {
|
||||
id: 'instance.settings.tabs.core-components.modloader',
|
||||
defaultMessage: 'Install ModLoader',
|
||||
},
|
||||
modLoaderInstalled: {
|
||||
id: 'instance.settings.tabs.core-components.modloader-installed',
|
||||
defaultMessage: 'Installed ModLoader {fileName}',
|
||||
},
|
||||
modLoaderManual: {
|
||||
id: 'instance.settings.tabs.core-components.modloader-manual',
|
||||
defaultMessage:
|
||||
'{fileName} needs a manual download. Download it from the source page, then import the verified archive here.',
|
||||
},
|
||||
openSource: {
|
||||
id: 'instance.settings.tabs.core-components.open-source',
|
||||
defaultMessage: 'Open source page',
|
||||
},
|
||||
importModLoader: {
|
||||
id: 'instance.settings.tabs.core-components.import-modloader',
|
||||
defaultMessage: 'Import verified ModLoader archive',
|
||||
},
|
||||
pickModLoader: {
|
||||
id: 'instance.settings.tabs.core-components.pick-modloader',
|
||||
defaultMessage: 'Choose downloaded ModLoader archive',
|
||||
},
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
await queryClient.invalidateQueries({ queryKey: ['core-components', instance.value.id] })
|
||||
}
|
||||
|
||||
async function pick(kind: 'jar_mod' | 'replacement_jar') {
|
||||
const path = await open({
|
||||
multiple: false,
|
||||
title: formatMessage(messages.pickJar),
|
||||
filters: [{ name: formatMessage(messages.jarFilter), extensions: ['jar', 'zip'] }],
|
||||
})
|
||||
if (!path || Array.isArray(path)) return
|
||||
busy.value = true
|
||||
try {
|
||||
if (kind === 'jar_mod') {
|
||||
await add_core_jar_mod(instance.value.id, path, instance.value.game_version)
|
||||
} else {
|
||||
await replace_core_jar(instance.value.id, path, instance.value.game_version)
|
||||
}
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function run(action: () => Promise<unknown>) {
|
||||
busy.value = true
|
||||
try {
|
||||
await action()
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function preview() {
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await preview_core_jar(instance.value.id)
|
||||
if (result) {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.previewed, {
|
||||
components: result.componentCount,
|
||||
entries: result.entries,
|
||||
}),
|
||||
})
|
||||
}
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function installModLoader() {
|
||||
busy.value = true
|
||||
manualModLoader.value = null
|
||||
try {
|
||||
const result = await install_mcarchive_modloader(instance.value.id, instance.value.game_version)
|
||||
if (result.state === 'manual_download') {
|
||||
manualModLoader.value = result
|
||||
return
|
||||
}
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.modLoaderInstalled, {
|
||||
fileName: result.component.fileName,
|
||||
}),
|
||||
})
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function importModLoader() {
|
||||
if (!manualModLoader.value) return
|
||||
const path = await open({
|
||||
multiple: false,
|
||||
title: formatMessage(messages.pickModLoader),
|
||||
filters: [{ name: formatMessage(messages.jarFilter), extensions: ['jar', 'zip'] }],
|
||||
})
|
||||
if (!path || Array.isArray(path)) return
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await import_mcarchive_modloader(
|
||||
instance.value.id,
|
||||
instance.value.game_version,
|
||||
path,
|
||||
)
|
||||
if (result.state === 'manual_download') {
|
||||
manualModLoader.value = result
|
||||
return
|
||||
}
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.modLoaderInstalled, {
|
||||
fileName: result.component.fileName,
|
||||
}),
|
||||
})
|
||||
manualModLoader.value = null
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonStyled>
|
||||
<button :disabled="busy" @click="pick('jar_mod')">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.add) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button :disabled="busy" @click="pick('replacement_jar')">
|
||||
<FileArchiveIcon />
|
||||
{{ formatMessage(messages.replace) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="canInstallModLoader" type="outlined">
|
||||
<button :disabled="busy" @click="installModLoader">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.modLoader) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button :disabled="busy" @click="preview">
|
||||
<EyeIcon />
|
||||
{{ formatMessage(messages.preview) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="manualModLoader"
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-y border-surface-4 py-3"
|
||||
>
|
||||
<p class="m-0 min-w-0 flex-1 text-sm text-secondary">
|
||||
{{ formatMessage(messages.modLoaderManual, { fileName: manualModLoader.fileName }) }}
|
||||
</p>
|
||||
<ButtonStyled v-if="manualModLoader.pageUrl" type="outlined">
|
||||
<button @click="openUrl(manualModLoader!.pageUrl!)">
|
||||
<ExternalIcon />
|
||||
{{ formatMessage(messages.openSource) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="busy" @click="importModLoader">
|
||||
<FileArchiveIcon />
|
||||
{{ formatMessage(messages.importModLoader) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-surface-4">
|
||||
<div
|
||||
v-for="(component, index) in activeComponents"
|
||||
:key="component.id"
|
||||
class="flex items-center gap-3 border-b border-surface-4 px-3 py-3 last:border-b-0"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="component.enabled"
|
||||
:disabled="busy"
|
||||
@update:model-value="
|
||||
(enabled) => run(() => set_core_component_enabled(instance.id, component.id, enabled))
|
||||
"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-medium text-contrast">{{ component.fileName }}</div>
|
||||
<div class="text-xs text-secondary">
|
||||
{{
|
||||
formatMessage(
|
||||
component.kind === 'replacement_jar' ? messages.replacement : messages.jarMod,
|
||||
)
|
||||
}}
|
||||
<span v-if="component.targetGameVersion">
|
||||
· {{ formatMessage(messages.targetVersion) }} {{ component.targetGameVersion }}
|
||||
</span>
|
||||
<span v-if="component.source">
|
||||
· {{ formatMessage(messages.source) }} {{ component.source.provider }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="component.sha256 || component.sha1" class="truncate text-xs text-secondary">
|
||||
<span v-if="component.sha256"
|
||||
>{{ formatMessage(messages.sha256) }} {{ component.sha256 }}</span
|
||||
>
|
||||
<span v-if="component.sha256 && component.sha1"> · </span>
|
||||
<span v-if="component.sha1"
|
||||
>{{ formatMessage(messages.sha1) }} {{ component.sha1 }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="component.failureReason" class="text-xs text-red">
|
||||
{{ formatMessage(messages.failure) }}: {{ component.failureReason }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<ButtonStyled circular size="small" type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.moveUp)"
|
||||
:aria-label="formatMessage(messages.moveUp)"
|
||||
:disabled="busy || index === 0"
|
||||
@click="run(() => move_core_component(instance.id, component.id, -1))"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular size="small" type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.moveDown)"
|
||||
:aria-label="formatMessage(messages.moveDown)"
|
||||
:disabled="busy || index === activeComponents.length - 1"
|
||||
@click="run(() => move_core_component(instance.id, component.id, 1))"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular color="red" size="small" type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.remove)"
|
||||
:aria-label="formatMessage(messages.remove)"
|
||||
:disabled="busy"
|
||||
@click="run(() => remove_core_component(instance.id, component.id))"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="deletedComponents.length" class="overflow-hidden rounded-lg border border-surface-4">
|
||||
<div
|
||||
v-for="component in deletedComponents"
|
||||
:key="component.id"
|
||||
class="flex items-center gap-3 px-3 py-3"
|
||||
>
|
||||
<div class="min-w-0 flex-1 truncate text-secondary">{{ component.fileName }}</div>
|
||||
<ButtonStyled circular size="small" type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.restore)"
|
||||
:aria-label="formatMessage(messages.restore)"
|
||||
:disabled="busy"
|
||||
@click="run(() => restore_core_component(instance.id, component.id))"
|
||||
>
|
||||
<RestoreIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,553 @@
|
||||
<script setup lang="ts">
|
||||
import { CopyIcon, EditIcon, SpinnerIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Chips,
|
||||
defineMessages,
|
||||
injectFilePicker,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
RadioButtons,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { basename, dirname, join } from '@tauri-apps/api/path'
|
||||
import { computed, type Ref, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { GameInstance } from '../../../helpers/types'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const filePicker = injectFilePicker()
|
||||
const { formatMessage } = useVIntl()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const deleteConfirmModal = ref()
|
||||
|
||||
const { instance } = injectInstanceSettings()
|
||||
type ReleaseChannel = GameInstance['update_channel']
|
||||
const releaseChannelOptions: ReleaseChannel[] = ['release', 'beta', 'alpha']
|
||||
|
||||
const title = ref(instance.value.name)
|
||||
const icon: Ref<string | undefined> = ref(instance.value.icon_path)
|
||||
const savingReleaseChannel = ref(false)
|
||||
const selectedReleaseChannel = ref<ReleaseChannel>(instance.value.update_channel)
|
||||
const releaseChannelDisabledItems = computed<ReleaseChannel[]>(() =>
|
||||
savingReleaseChannel.value ? [...releaseChannelOptions] : [],
|
||||
)
|
||||
|
||||
const installing = computed(() => instance.value.install_stage !== 'installed')
|
||||
|
||||
async function duplicateInstance() {
|
||||
await install_duplicate_instance(instance.value.id).catch(handleError)
|
||||
trackEvent('InstanceDuplicate', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
|
||||
function formatReleaseChannelLabel(channel: ReleaseChannel) {
|
||||
switch (channel) {
|
||||
case 'release':
|
||||
return formatMessage(messages.updateChannelRelease)
|
||||
case 'beta':
|
||||
return formatMessage(messages.updateChannelBeta)
|
||||
case 'alpha':
|
||||
return formatMessage(messages.updateChannelAlpha)
|
||||
}
|
||||
}
|
||||
|
||||
function formatReleaseChannelDescription(channel: ReleaseChannel) {
|
||||
switch (channel) {
|
||||
case 'release':
|
||||
return formatMessage(messages.updateChannelReleaseDescription)
|
||||
case 'beta':
|
||||
return formatMessage(messages.updateChannelBetaDescription)
|
||||
case 'alpha':
|
||||
return formatMessage(messages.updateChannelAlphaDescription)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [instance.value.id, instance.value.update_channel] as const,
|
||||
() => {
|
||||
if (!savingReleaseChannel.value) {
|
||||
selectedReleaseChannel.value = instance.value.update_channel
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(selectedReleaseChannel, async (channel, previousChannel) => {
|
||||
const previousReleaseChannel = previousChannel ?? instance.value.update_channel
|
||||
if (channel === instance.value.update_channel) return
|
||||
|
||||
savingReleaseChannel.value = true
|
||||
const instanceId = instance.value.id
|
||||
await edit(instanceId, { update_channel: channel })
|
||||
.then(() => queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instanceId] }))
|
||||
.catch((error) => {
|
||||
selectedReleaseChannel.value = previousReleaseChannel
|
||||
handleError(error)
|
||||
})
|
||||
savingReleaseChannel.value = false
|
||||
})
|
||||
|
||||
async function resetIcon() {
|
||||
icon.value = undefined
|
||||
await edit_icon(instance.value.id, null).catch(handleError)
|
||||
trackEvent('InstanceRemoveIcon')
|
||||
}
|
||||
|
||||
async function setIcon() {
|
||||
try {
|
||||
const picked = await (filePicker.pickInstanceIcon?.() ?? filePicker.pickImage())
|
||||
if (!picked?.path) return
|
||||
|
||||
const previousIcon = icon.value
|
||||
icon.value = picked.path
|
||||
try {
|
||||
await edit_icon(instance.value.id, picked.path)
|
||||
trackEvent('InstanceSetIcon')
|
||||
} catch (error) {
|
||||
icon.value = previousIcon
|
||||
handleError(error)
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const gameDirOverride = ref(instance.value.game_dir_override)
|
||||
const savingGameDir = ref(false)
|
||||
const isDirectLinked = computed(() => !!instance.value.linked_dot_minecraft)
|
||||
const configuredExternalGameDir = computed(
|
||||
() => gameDirOverride.value ?? instance.value.linked_dot_minecraft ?? null,
|
||||
)
|
||||
const resolvedExternalGameDir = ref<string | null>(null)
|
||||
const externalGameDir = computed(
|
||||
() => resolvedExternalGameDir.value ?? configuredExternalGameDir.value,
|
||||
)
|
||||
|
||||
let pathResolutionRevision = 0
|
||||
async function refreshExternalGameDir() {
|
||||
const revision = ++pathResolutionRevision
|
||||
if (!configuredExternalGameDir.value) {
|
||||
resolvedExternalGameDir.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const path = await get_full_path(instance.value.id)
|
||||
if (revision === pathResolutionRevision) resolvedExternalGameDir.value = path
|
||||
} catch {
|
||||
// Keep the configured path visible while the external chain is unavailable.
|
||||
if (revision === pathResolutionRevision) resolvedExternalGameDir.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => instance.value.game_dir_override,
|
||||
(path) => {
|
||||
gameDirOverride.value = path
|
||||
void refreshExternalGameDir()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [instance.value.id, instance.value.linked_dot_minecraft] as const,
|
||||
() => void refreshExternalGameDir(),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// An external game dir is stored as a single path. Whether it is version
|
||||
// isolated is encoded in the path: `<root>/versions/<name>` vs the `.minecraft`
|
||||
// root itself. `isExternal` is false for built-in (managed) instances, which
|
||||
// expose no isolation option.
|
||||
const isExternal = computed(() => !!externalGameDir.value)
|
||||
|
||||
const gameDirInfo = ref<{ isolated: boolean; baseRoot: string | null }>({
|
||||
isolated: false,
|
||||
baseRoot: null,
|
||||
})
|
||||
|
||||
async function refreshGameDirInfo() {
|
||||
const path = configuredExternalGameDir.value
|
||||
if (!path || isDirectLinked.value) {
|
||||
gameDirInfo.value = { isolated: false, baseRoot: null }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const parent = await dirname(path)
|
||||
if ((await basename(parent)).toLowerCase() === 'versions') {
|
||||
gameDirInfo.value = { isolated: true, baseRoot: await dirname(parent) }
|
||||
} else {
|
||||
gameDirInfo.value = { isolated: false, baseRoot: path }
|
||||
}
|
||||
} catch {
|
||||
gameDirInfo.value = { isolated: false, baseRoot: path }
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [configuredExternalGameDir.value, isDirectLinked.value] as const,
|
||||
() => void refreshGameDirInfo(),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
type GameDirMode = 'isolated' | 'not-isolated'
|
||||
const gameDirMode = computed<GameDirMode>({
|
||||
get: () => (gameDirInfo.value.isolated ? 'isolated' : 'not-isolated'),
|
||||
set: (mode) => void setGameDirMode(mode),
|
||||
})
|
||||
const gameDirModeItems: GameDirMode[] = ['isolated', 'not-isolated']
|
||||
|
||||
function gameDirModeLabel(mode: GameDirMode) {
|
||||
return mode === 'isolated' ? messages.gameDirIsolated : messages.gameDirNotIsolated
|
||||
}
|
||||
|
||||
async function setGameDirMode(mode: GameDirMode) {
|
||||
const baseRoot = gameDirInfo.value.baseRoot
|
||||
if (!baseRoot) return
|
||||
const nextPath =
|
||||
mode === 'isolated' ? await join(baseRoot, 'versions', instance.value.name) : baseRoot
|
||||
if (nextPath === gameDirOverride.value) return
|
||||
|
||||
// The launcher only records the new override path; the user is responsible
|
||||
// for actually moving the mods/saves/config folders to match.
|
||||
const previous = gameDirOverride.value
|
||||
gameDirOverride.value = nextPath
|
||||
savingGameDir.value = true
|
||||
try {
|
||||
await edit(instance.value.id, { game_dir_override: nextPath })
|
||||
} catch (error) {
|
||||
gameDirOverride.value = previous
|
||||
handleError(error)
|
||||
} finally {
|
||||
savingGameDir.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const editInstanceObject = computed(() => ({
|
||||
name: title.value.trim().substring(0, 32) ?? 'Instance',
|
||||
}))
|
||||
|
||||
watch(
|
||||
title,
|
||||
async () => {
|
||||
if (removing.value) return
|
||||
await edit(instance.value.id, editInstanceObject.value).catch(handleError)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const removing = ref(false)
|
||||
async function removeInstance() {
|
||||
removing.value = true
|
||||
const path = instance.value.id
|
||||
|
||||
trackEvent('InstanceRemove', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
|
||||
await router.push({ path: '/' })
|
||||
await remove(path).catch(handleError)
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
icon: {
|
||||
id: 'instance.settings.tabs.general.icon',
|
||||
defaultMessage: 'Icon',
|
||||
},
|
||||
name: {
|
||||
id: 'instance.settings.tabs.general.name',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
editIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon',
|
||||
defaultMessage: 'Edit icon',
|
||||
},
|
||||
selectIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.select',
|
||||
defaultMessage: 'Select icon',
|
||||
},
|
||||
replaceIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.replace',
|
||||
defaultMessage: 'Replace icon',
|
||||
},
|
||||
removeIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.remove',
|
||||
defaultMessage: 'Remove icon',
|
||||
},
|
||||
duplicateInstance: {
|
||||
id: 'instance.settings.tabs.general.duplicate-instance',
|
||||
defaultMessage: 'Duplicate instance',
|
||||
},
|
||||
duplicateInstanceDescription: {
|
||||
id: 'instance.settings.tabs.general.duplicate-instance.description',
|
||||
defaultMessage: 'Creates a copy of this instance, including worlds, configs, mods, etc.',
|
||||
},
|
||||
duplicateButtonTooltipInstalling: {
|
||||
id: 'instance.settings.tabs.general.duplicate-button.tooltip.installing',
|
||||
defaultMessage: 'Cannot duplicate while installing.',
|
||||
},
|
||||
duplicateButton: {
|
||||
id: 'instance.settings.tabs.general.duplicate-button',
|
||||
defaultMessage: 'Duplicate',
|
||||
},
|
||||
gameDir: {
|
||||
id: 'instance.settings.tabs.general.game-dir',
|
||||
defaultMessage: 'Game directory',
|
||||
},
|
||||
gameDirDescription: {
|
||||
id: 'instance.settings.tabs.general.game-dir.description',
|
||||
defaultMessage:
|
||||
'Uses a separate folder as the working directory for this instance. The game reads mods, saves, configs, and resource packs from that folder instead of the managed instance folder.',
|
||||
},
|
||||
gameDirCurrent: {
|
||||
id: 'instance.settings.tabs.general.game-dir.current',
|
||||
defaultMessage: 'Current directory',
|
||||
},
|
||||
gameDirIsolated: {
|
||||
id: 'instance.settings.tabs.general.game-dir.isolated',
|
||||
defaultMessage: 'Version isolated (stored in versions/)',
|
||||
},
|
||||
gameDirNotIsolated: {
|
||||
id: 'instance.settings.tabs.general.game-dir.not-isolated',
|
||||
defaultMessage: 'Version shared (.minecraft/)',
|
||||
},
|
||||
gameDirMoveNote: {
|
||||
id: 'instance.settings.tabs.general.game-dir.move-note',
|
||||
defaultMessage:
|
||||
'Switching isolation only updates the launcher path. Move the mods, saves, and config folders yourself to match.',
|
||||
},
|
||||
gameDirManagedNote: {
|
||||
id: 'instance.settings.tabs.general.game-dir.managed-note',
|
||||
defaultMessage: 'This instance uses the Axolotl-managed folder.',
|
||||
},
|
||||
gameDirExternalNote: {
|
||||
id: 'instance.settings.tabs.general.game-dir.external-note',
|
||||
defaultMessage: 'This instance uses an external .minecraft folder managed in place.',
|
||||
},
|
||||
updateChannel: {
|
||||
id: 'instance.settings.tabs.general.update-channel',
|
||||
defaultMessage: 'Update channel',
|
||||
},
|
||||
updateChannelReleaseDescription: {
|
||||
id: 'instance.settings.tabs.general.update-channel.release.description',
|
||||
defaultMessage: 'Only release versions will be shown as available updates.',
|
||||
},
|
||||
updateChannelBetaDescription: {
|
||||
id: 'instance.settings.tabs.general.update-channel.beta.description',
|
||||
defaultMessage: 'Release and beta versions will be shown as available updates.',
|
||||
},
|
||||
updateChannelAlphaDescription: {
|
||||
id: 'instance.settings.tabs.general.update-channel.alpha.description',
|
||||
defaultMessage: 'Release, beta, and alpha versions will be shown as available updates.',
|
||||
},
|
||||
updateChannelRelease: {
|
||||
id: 'instance.settings.tabs.general.update-channel.release',
|
||||
defaultMessage: 'Release',
|
||||
},
|
||||
updateChannelBeta: {
|
||||
id: 'instance.settings.tabs.general.update-channel.beta',
|
||||
defaultMessage: 'Beta',
|
||||
},
|
||||
updateChannelAlpha: {
|
||||
id: 'instance.settings.tabs.general.update-channel.alpha',
|
||||
defaultMessage: 'Alpha',
|
||||
},
|
||||
selectUpdateChannelAriaLabel: {
|
||||
id: 'instance.settings.tabs.general.update-channel.select',
|
||||
defaultMessage: 'Select update channel',
|
||||
},
|
||||
deleteInstance: {
|
||||
id: 'instance.settings.tabs.general.delete',
|
||||
defaultMessage: 'Delete instance',
|
||||
},
|
||||
deleteInstanceDescription: {
|
||||
id: 'instance.settings.tabs.general.delete.description',
|
||||
defaultMessage:
|
||||
'Permanently deletes an instance from your device, including your worlds, configs, and all installed content. Be careful, as once you delete a instance there is no way to recover it.',
|
||||
},
|
||||
deleteInstanceButton: {
|
||||
id: 'instance.settings.tabs.general.delete.button',
|
||||
defaultMessage: 'Delete instance',
|
||||
},
|
||||
deletingInstanceButton: {
|
||||
id: 'instance.settings.tabs.general.deleting.button',
|
||||
defaultMessage: 'Deleting...',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDeleteInstanceModal
|
||||
ref="deleteConfirmModal"
|
||||
:symlink-target="instance.symlink_target"
|
||||
@delete="removeInstance"
|
||||
/>
|
||||
<div class="block">
|
||||
<div class="float-end ml-10 relative group w-fit">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.icon) }}
|
||||
</span>
|
||||
<div class="group relative w-fit">
|
||||
<OverflowMenu
|
||||
v-tooltip="formatMessage(messages.editIcon)"
|
||||
class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform"
|
||||
:options="[
|
||||
{
|
||||
id: 'select',
|
||||
action: () => setIcon(),
|
||||
},
|
||||
{
|
||||
id: 'remove',
|
||||
color: 'danger',
|
||||
action: () => resetIcon(),
|
||||
shown: !!icon,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<InstanceIcon
|
||||
:icon-path="icon"
|
||||
:instance-id="instance.id"
|
||||
:loader="instance.loader"
|
||||
size="108px"
|
||||
class="transition-[filter] group-hover:brightness-75"
|
||||
no-shadow
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 h-full w-full flex items-center justify-center opacity-0 transition-all group-hover:opacity-100"
|
||||
>
|
||||
<EditIcon aria-hidden="true" class="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<template #select>
|
||||
<UploadIcon />
|
||||
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
|
||||
</template>
|
||||
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label for="instance-name" class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.name) }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<StyledInput
|
||||
id="instance-name"
|
||||
v-model="title"
|
||||
autocomplete="off"
|
||||
:maxlength="80"
|
||||
wrapper-class="flex-grow"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="instance.install_stage == 'installed'">
|
||||
<div class="flex flex-col gap-2.5 mt-6">
|
||||
<h2 id="duplicate-instance-label" class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.duplicateInstance) }}
|
||||
</h2>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null"
|
||||
aria-labelledby="duplicate-instance-label"
|
||||
:disabled="installing"
|
||||
class="w-max !shadow-none"
|
||||
@click="duplicateInstance"
|
||||
>
|
||||
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.duplicateInstanceDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-2.5 mt-6">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.gameDir) }}
|
||||
</h2>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.gameDirDescription) }}
|
||||
</p>
|
||||
<template v-if="isExternal">
|
||||
<div v-if="!isDirectLinked" class="flex flex-col gap-1.5">
|
||||
<RadioButtons v-model="gameDirMode" :items="gameDirModeItems" force-selection>
|
||||
<template #default="{ item }">
|
||||
{{ formatMessage(gameDirModeLabel(item)) }}
|
||||
</template>
|
||||
</RadioButtons>
|
||||
</div>
|
||||
<p v-if="externalGameDir" class="m-0 text-secondary break-all">
|
||||
{{ formatMessage(messages.gameDirCurrent) }}:
|
||||
<code>{{ externalGameDir }}</code>
|
||||
</p>
|
||||
<p v-if="!isDirectLinked" class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.gameDirMoveNote) }}
|
||||
</p>
|
||||
<p v-else class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.gameDirExternalNote) }}
|
||||
</p>
|
||||
</template>
|
||||
<p v-else class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.gameDirManagedNote) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2.5 mt-6">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.updateChannel) }}
|
||||
</h2>
|
||||
<Chips
|
||||
v-model="selectedReleaseChannel"
|
||||
:items="releaseChannelOptions"
|
||||
:format-label="formatReleaseChannelLabel"
|
||||
:capitalize="false"
|
||||
:disabled-items="releaseChannelDisabledItems"
|
||||
:aria-label="formatMessage(messages.selectUpdateChannelAriaLabel)"
|
||||
/>
|
||||
<p class="m-0">
|
||||
{{ formatReleaseChannelDescription(selectedReleaseChannel) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5 mt-6">
|
||||
<h2 id="delete-instance-label" class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.deleteInstance) }}
|
||||
</h2>
|
||||
<ButtonStyled color="red">
|
||||
<button
|
||||
aria-labelledby="delete-instance-label"
|
||||
:disabled="removing"
|
||||
class="w-fit !shadow-none"
|
||||
@click="deleteConfirmModal.show()"
|
||||
>
|
||||
<SpinnerIcon v-if="removing" class="animate-spin" />
|
||||
<TrashIcon v-else />
|
||||
{{
|
||||
removing
|
||||
? formatMessage(messages.deletingInstanceButton)
|
||||
: formatMessage(messages.deleteInstanceButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.deleteInstanceDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.hovering-icon-shadow {
|
||||
box-shadow: var(--shadow-inset-sm), var(--shadow-raised);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { edit } from '@/helpers/instance'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { AppSettings, Hooks } from '../../../helpers/types'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { instance } = injectInstanceSettings()
|
||||
|
||||
const globalSettings = (await get().catch(handleError)) as AppSettings
|
||||
|
||||
const overrideHooks = ref(
|
||||
!!instance.value.hooks.pre_launch ||
|
||||
!!instance.value.hooks.wrapper ||
|
||||
!!instance.value.hooks.post_exit,
|
||||
)
|
||||
const hooks = ref(instance.value.hooks ?? globalSettings.hooks)
|
||||
const overrideLaunchPreparationTimeout = ref(instance.value.launch_preparation_timeout != null)
|
||||
const launchPreparationTimeout = ref(
|
||||
Math.min(600, Math.max(30, instance.value.launch_preparation_timeout ?? 60)),
|
||||
)
|
||||
|
||||
const editInstanceObject = computed(() => {
|
||||
const editInstancePatch: {
|
||||
hooks?: Hooks
|
||||
launch_preparation_timeout?: number | null
|
||||
} = {}
|
||||
|
||||
// When hooks are not overridden per-instance, we want to clear them
|
||||
editInstancePatch.hooks = overrideHooks.value ? hooks.value : {}
|
||||
editInstancePatch.launch_preparation_timeout = overrideLaunchPreparationTimeout.value
|
||||
? Math.min(600, Math.max(30, Math.round(launchPreparationTimeout.value || 60)))
|
||||
: null
|
||||
|
||||
return editInstancePatch
|
||||
})
|
||||
|
||||
watch(
|
||||
[overrideHooks, hooks, overrideLaunchPreparationTimeout, launchPreparationTimeout],
|
||||
async () => {
|
||||
await edit(instance.value.id, editInstanceObject.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
const messages = defineMessages({
|
||||
hooks: {
|
||||
id: 'instance.settings.tabs.hooks.title',
|
||||
defaultMessage: 'Launch preparation',
|
||||
},
|
||||
hooksDescription: {
|
||||
id: 'instance.settings.tabs.hooks.description',
|
||||
defaultMessage:
|
||||
'Configure the time allowed for launch preparation and optional commands that run before and after the game.',
|
||||
},
|
||||
launchPreparationTimeout: {
|
||||
id: 'instance.settings.tabs.hooks.launch-preparation-timeout',
|
||||
defaultMessage: 'Launch preparation timeout',
|
||||
},
|
||||
launchPreparationTimeoutDescription: {
|
||||
id: 'instance.settings.tabs.hooks.launch-preparation-timeout.description',
|
||||
defaultMessage: 'Maximum time to wait for launch preparation to finish, in seconds (30–600).',
|
||||
},
|
||||
customLaunchPreparationTimeout: {
|
||||
id: 'instance.settings.tabs.hooks.custom-launch-preparation-timeout',
|
||||
defaultMessage: 'Use a custom launch preparation timeout',
|
||||
},
|
||||
customHooks: {
|
||||
id: 'instance.settings.tabs.hooks.custom-hooks',
|
||||
defaultMessage: 'Custom launch hooks',
|
||||
},
|
||||
preLaunch: {
|
||||
id: 'instance.settings.tabs.hooks.pre-launch',
|
||||
defaultMessage: 'Pre-launch',
|
||||
},
|
||||
preLaunchDescription: {
|
||||
id: 'instance.settings.tabs.hooks.pre-launch.description',
|
||||
defaultMessage: 'Ran before the instance is launched.',
|
||||
},
|
||||
preLaunchEnter: {
|
||||
id: 'instance.settings.tabs.hooks.pre-launch.enter',
|
||||
defaultMessage: 'Enter pre-launch command...',
|
||||
},
|
||||
wrapper: {
|
||||
id: 'instance.settings.tabs.hooks.wrapper',
|
||||
defaultMessage: 'Wrapper',
|
||||
},
|
||||
wrapperDescription: {
|
||||
id: 'instance.settings.tabs.hooks.wrapper.description',
|
||||
defaultMessage: 'Wrapper command for launching Minecraft.',
|
||||
},
|
||||
wrapperEnter: {
|
||||
id: 'instance.settings.tabs.hooks.wrapper.enter',
|
||||
defaultMessage: 'Enter wrapper command...',
|
||||
},
|
||||
postExit: {
|
||||
id: 'instance.settings.tabs.hooks.post-exit',
|
||||
defaultMessage: 'Post-exit',
|
||||
},
|
||||
postExitDescription: {
|
||||
id: 'instance.settings.tabs.hooks.post-exit.description',
|
||||
defaultMessage: 'Ran after the game closes.',
|
||||
},
|
||||
postExitEnter: {
|
||||
id: 'instance.settings.tabs.hooks.post-exit.enter',
|
||||
defaultMessage: 'Enter post-exit command...',
|
||||
},
|
||||
})
|
||||
|
||||
function normalizeLaunchPreparationTimeout() {
|
||||
launchPreparationTimeout.value = Math.min(
|
||||
600,
|
||||
Math.max(30, Math.round(Number(launchPreparationTimeout.value) || 60)),
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="m-0 m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.hooks) }}
|
||||
</h2>
|
||||
<Checkbox v-model="overrideHooks" :label="formatMessage(messages.customHooks)" class="my-2.5" />
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.hooksDescription) }}
|
||||
</p>
|
||||
|
||||
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.launchPreparationTimeout) }}
|
||||
</h2>
|
||||
<Checkbox
|
||||
v-model="overrideLaunchPreparationTimeout"
|
||||
:label="formatMessage(messages.customLaunchPreparationTimeout)"
|
||||
class="my-2.5"
|
||||
/>
|
||||
<StyledInput
|
||||
id="launch-preparation-timeout"
|
||||
v-model="launchPreparationTimeout"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideLaunchPreparationTimeout"
|
||||
type="number"
|
||||
min="30"
|
||||
max="600"
|
||||
step="1"
|
||||
wrapper-class="w-full my-2.5"
|
||||
@blur="normalizeLaunchPreparationTimeout"
|
||||
/>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.launchPreparationTimeoutDescription) }}
|
||||
</p>
|
||||
|
||||
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.preLaunch) }}
|
||||
</h2>
|
||||
<StyledInput
|
||||
id="pre-launch"
|
||||
v-model="hooks.pre_launch"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideHooks"
|
||||
:placeholder="formatMessage(messages.preLaunchEnter)"
|
||||
wrapper-class="w-full my-2.5"
|
||||
/>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.preLaunchDescription) }}
|
||||
</p>
|
||||
|
||||
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.wrapper) }}
|
||||
</h2>
|
||||
<StyledInput
|
||||
id="wrapper"
|
||||
v-model="hooks.wrapper"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideHooks"
|
||||
:placeholder="formatMessage(messages.wrapperEnter)"
|
||||
wrapper-class="w-full my-2.5"
|
||||
/>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.wrapperDescription) }}
|
||||
</p>
|
||||
|
||||
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.postExit) }}
|
||||
</h2>
|
||||
<StyledInput
|
||||
id="post-exit"
|
||||
v-model="hooks.post_exit"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideHooks"
|
||||
:placeholder="formatMessage(messages.postExitEnter)"
|
||||
wrapper-class="w-full my-2.5"
|
||||
/>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.postExitDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,589 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
formatLoaderLabel,
|
||||
injectFilePicker,
|
||||
injectNotificationManager,
|
||||
InstallationSettingsLayout,
|
||||
instanceInstallablePlatforms,
|
||||
type LoaderMetadataStatus,
|
||||
loaderSupportState,
|
||||
loaderVersionsForGameVersion,
|
||||
provideAppBackup,
|
||||
provideInstallationSettings,
|
||||
scopedLoaderMetadataQueryKey,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { GameVersionTag } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_versions, get_version } from '@/helpers/cache'
|
||||
import { type CurseForgeFile, updateManagedCurseForgeModpack } from '@/helpers/curseforge'
|
||||
import {
|
||||
install_duplicate_instance,
|
||||
install_existing_instance,
|
||||
install_pack_to_existing_instance,
|
||||
installJobInstanceId,
|
||||
wait_for_install_job,
|
||||
} from '@/helpers/install'
|
||||
import {
|
||||
edit,
|
||||
get_linked_modpack_info,
|
||||
list,
|
||||
update_managed_modrinth_version,
|
||||
update_repair_modrinth,
|
||||
} from '@/helpers/instance'
|
||||
import { get_loader_versions } from '@/helpers/metadata'
|
||||
import { get_game_versions } from '@/helpers/tags'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
import type { Manifest } from '../../../helpers/types'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const filePicker = injectFilePicker()
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
const debug = useDebugLogger('AppInstallationSettings')
|
||||
const themeStore = useTheming()
|
||||
|
||||
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
|
||||
const skipNonEssentialWarnings = computed(() =>
|
||||
themeStore.getFeatureFlag('skip_non_essential_warnings'),
|
||||
)
|
||||
|
||||
debug('metadata load: start', {
|
||||
instanceId: instance.value.id,
|
||||
loader: instance.value.loader,
|
||||
gameVersion: instance.value.game_version,
|
||||
installStage: instance.value.install_stage,
|
||||
})
|
||||
|
||||
const gameVersionsQuery = useQuery({
|
||||
queryKey: ['instance-settings', 'game-versions'],
|
||||
queryFn: () => get_game_versions() as Promise<GameVersionTag[]>,
|
||||
})
|
||||
|
||||
const editingPlatform = ref(instance.value.loader)
|
||||
const editingGameVersion = ref(instance.value.game_version)
|
||||
const scopedLoader = computed(() =>
|
||||
editingPlatform.value === 'neoforge' ? 'neo' : editingPlatform.value,
|
||||
)
|
||||
const scopedLoaderQueryEnabled = computed(
|
||||
() => editingPlatform.value !== 'vanilla' && !!editingGameVersion.value,
|
||||
)
|
||||
const scopedLoaderVersionsQuery = useQuery({
|
||||
queryKey: computed(() =>
|
||||
scopedLoaderMetadataQueryKey('instance-settings', scopedLoader.value, editingGameVersion.value),
|
||||
),
|
||||
queryFn: ({ queryKey }) => get_loader_versions(queryKey[2], queryKey[3]) as Promise<Manifest>,
|
||||
enabled: scopedLoaderQueryEnabled,
|
||||
})
|
||||
const scopedLoaderMetadataStatus = computed<LoaderMetadataStatus>(() => {
|
||||
if (!scopedLoaderQueryEnabled.value) return 'unknown'
|
||||
if (scopedLoaderVersionsQuery.isPending.value || scopedLoaderVersionsQuery.isFetching.value) {
|
||||
return 'loading'
|
||||
}
|
||||
if (scopedLoaderVersionsQuery.isError.value) return 'error'
|
||||
return 'success'
|
||||
})
|
||||
const scopedLoaderVersionState = computed(() =>
|
||||
loaderSupportState(
|
||||
scopedLoaderMetadataStatus.value,
|
||||
scopedLoaderVersionsQuery.data.value,
|
||||
editingGameVersion.value,
|
||||
),
|
||||
)
|
||||
const metadataLoading = computed(() => gameVersionsQuery.isLoading.value)
|
||||
|
||||
debug('metadata queries configured', {
|
||||
instanceId: instance.value.id,
|
||||
loader: instance.value.loader,
|
||||
gameVersion: instance.value.game_version,
|
||||
})
|
||||
|
||||
const isModrinthLinkedModpack = computed(
|
||||
() =>
|
||||
instance.value.link?.type === 'modrinth_modpack' ||
|
||||
instance.value.link?.type === 'server_project_modpack',
|
||||
)
|
||||
const isCurseForgeLinkedModpack = computed(() => instance.value.link?.type === 'curseforge_modpack')
|
||||
const isLinkedManagedModpack = computed(
|
||||
() => isModrinthLinkedModpack.value || isCurseForgeLinkedModpack.value,
|
||||
)
|
||||
const isImportedModpack = computed(() => instance.value.link?.type === 'imported_modpack')
|
||||
|
||||
const modpackInfoQuery = useQuery({
|
||||
queryKey: computed(() => ['linkedModpackInfo', instance.value.id]),
|
||||
queryFn: () => get_linked_modpack_info(instance.value.id, 'must_revalidate'),
|
||||
enabled: computed(
|
||||
() => instance.value.install_stage === 'installed' && isLinkedManagedModpack.value && !offline,
|
||||
),
|
||||
})
|
||||
const modpackInfo = modpackInfoQuery.data
|
||||
|
||||
const repairing = ref(false)
|
||||
const reinstalling = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
loaderVersion: {
|
||||
id: 'instance.settings.tabs.installation.loader-version',
|
||||
defaultMessage: '{loader} version',
|
||||
},
|
||||
})
|
||||
|
||||
async function installLocalModpackFromPicker() {
|
||||
const picked = await filePicker.pickModpackFile({ readFile: false })
|
||||
if (!picked?.path) return false
|
||||
|
||||
const job = await install_pack_to_existing_instance(instance.value.id, {
|
||||
type: 'fromFile',
|
||||
path: picked.path,
|
||||
}).catch(handleError)
|
||||
if (!job) return false
|
||||
|
||||
const completed = await wait_for_install_job(job.job_id).catch(handleError)
|
||||
return !!completed
|
||||
}
|
||||
|
||||
provideAppBackup({
|
||||
async createBackup() {
|
||||
debug('createBackup: start', {
|
||||
instanceId: instance.value.id,
|
||||
instanceName: instance.value.name,
|
||||
})
|
||||
const allInstances = await list()
|
||||
const prefix = `${instance.value.name} - Backup #`
|
||||
const existingNums = allInstances
|
||||
.filter((p) => p.name.startsWith(prefix))
|
||||
.map((p) => parseInt(p.name.slice(prefix.length), 10))
|
||||
.filter((n) => !isNaN(n))
|
||||
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
|
||||
const job = await install_duplicate_instance(instance.value.id)
|
||||
const newInstanceId = installJobInstanceId(job)
|
||||
if (newInstanceId) {
|
||||
await edit(newInstanceId, { name: `${prefix}${nextNum}` })
|
||||
}
|
||||
debug('createBackup: done', { newInstanceId, backupName: `${prefix}${nextNum}` })
|
||||
},
|
||||
})
|
||||
|
||||
provideInstallationSettings({
|
||||
closeSettings: closeModal,
|
||||
loading: computed(() => metadataLoading.value || modpackInfoQuery.isLoading.value),
|
||||
installationInfo: computed(() => {
|
||||
const rows = [
|
||||
{
|
||||
label: formatMessage(commonMessages.platformLabel),
|
||||
value: formatLoaderLabel(instance.value.loader),
|
||||
},
|
||||
{
|
||||
label: formatMessage(commonMessages.gameVersionLabel),
|
||||
value: instance.value.game_version,
|
||||
},
|
||||
]
|
||||
if ((instance.value.loader_components ?? []).length > 1) {
|
||||
for (const component of instance.value.loader_components ?? []) {
|
||||
rows.push({
|
||||
label: formatMessage(messages.loaderVersion, {
|
||||
loader: formatLoaderLabel(component.kind),
|
||||
}),
|
||||
value: component.version ?? formatLoaderLabel(component.kind),
|
||||
})
|
||||
}
|
||||
} else if (instance.value.loader !== 'vanilla' && instance.value.loader_version) {
|
||||
rows.push({
|
||||
label: formatMessage(messages.loaderVersion, {
|
||||
loader: formatLoaderLabel(instance.value.loader),
|
||||
}),
|
||||
value: instance.value.loader_version,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}),
|
||||
isLinked: computed(() => isLinkedManagedModpack.value || isImportedModpack.value),
|
||||
isBusy: computed(
|
||||
() =>
|
||||
instance.value.install_stage !== 'installed' ||
|
||||
repairing.value ||
|
||||
reinstalling.value ||
|
||||
!!offline,
|
||||
),
|
||||
skipNonEssentialWarnings,
|
||||
modpack: computed(() => {
|
||||
if (isImportedModpack.value && instance.value.link?.type === 'imported_modpack') {
|
||||
return {
|
||||
iconUrl: instance.value.icon_path,
|
||||
title: instance.value.link.name ?? instance.value.name,
|
||||
versionNumber: instance.value.link.version_number ?? undefined,
|
||||
filename: instance.value.link.filename ?? undefined,
|
||||
}
|
||||
}
|
||||
if (modpackInfo.value) {
|
||||
return {
|
||||
iconUrl: modpackInfo.value.project.icon_url,
|
||||
title: modpackInfo.value.project.title,
|
||||
link: isCurseForgeLinkedModpack.value
|
||||
? `/project/curseforge/${String(modpackInfo.value.project.id).replace(/^curseforge:/, '')}`
|
||||
: `/project/${modpackInfo.value.project.slug ?? modpackInfo.value.project.id}`,
|
||||
versionNumber: modpackInfo.value.version?.version_number,
|
||||
}
|
||||
}
|
||||
// Fallback when linked metadata is temporarily unavailable so the
|
||||
// association controls still match Modrinth-linked packs.
|
||||
if (isCurseForgeLinkedModpack.value && instance.value.link?.type === 'curseforge_modpack') {
|
||||
return {
|
||||
iconUrl: instance.value.icon_path,
|
||||
title: instance.value.name,
|
||||
link: `/project/curseforge/${instance.value.link.project_id}`,
|
||||
versionNumber: instance.value.link.version_id,
|
||||
}
|
||||
}
|
||||
if (isModrinthLinkedModpack.value && instance.value.link) {
|
||||
const projectId =
|
||||
instance.value.link.type === 'server_project_modpack'
|
||||
? (instance.value.link.content_project_id ?? instance.value.link.project_id)
|
||||
: instance.value.link.project_id
|
||||
const versionId =
|
||||
instance.value.link.type === 'server_project_modpack'
|
||||
? instance.value.link.content_version_id
|
||||
: instance.value.link.version_id
|
||||
if (!projectId) return null
|
||||
return {
|
||||
iconUrl: instance.value.icon_path,
|
||||
title: instance.value.name,
|
||||
link: `/project/${projectId}`,
|
||||
versionNumber: versionId ?? undefined,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}),
|
||||
currentPlatform: computed(() => instance.value.loader),
|
||||
currentGameVersion: computed(() => instance.value.game_version),
|
||||
currentLoaderVersion: computed(() => instance.value.loader_version ?? ''),
|
||||
availablePlatforms: computed(() => [...instanceInstallablePlatforms]),
|
||||
editingPlatformRef: editingPlatform,
|
||||
editingGameVersionRef: editingGameVersion,
|
||||
loaderVersionState: scopedLoaderVersionState,
|
||||
|
||||
resolveGameVersions(loader, showSnapshots) {
|
||||
const versions = gameVersionsQuery.data.value ?? []
|
||||
const result = (
|
||||
showSnapshots ? versions : versions.filter((x) => x.version_type === 'release')
|
||||
).map((x) => ({ value: x.version, label: x.version }))
|
||||
debug('resolveGameVersions:', {
|
||||
loader,
|
||||
showSnapshots,
|
||||
totalVersions: versions.length,
|
||||
resultVersions: result.length,
|
||||
})
|
||||
return result
|
||||
},
|
||||
|
||||
resolveLoaderVersions(loader, gameVersion) {
|
||||
if (loader === 'vanilla' || !gameVersion) {
|
||||
debug('resolveLoaderVersions: skipped', { loader, gameVersion })
|
||||
return []
|
||||
}
|
||||
if (loader !== editingPlatform.value || gameVersion !== editingGameVersion.value) {
|
||||
debug('resolveLoaderVersions: stale selection', { loader, gameVersion })
|
||||
return []
|
||||
}
|
||||
if (scopedLoaderVersionState.value !== 'supported') return []
|
||||
const result = loaderVersionsForGameVersion(scopedLoaderVersionsQuery.data.value, gameVersion)
|
||||
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
|
||||
return result
|
||||
},
|
||||
|
||||
resolveHasSnapshots(loader) {
|
||||
const versions = gameVersionsQuery.data.value ?? []
|
||||
const result = versions.some((x) => x.version_type !== 'release')
|
||||
debug('resolveHasSnapshots:', {
|
||||
loader,
|
||||
totalVersions: versions.length,
|
||||
result,
|
||||
})
|
||||
return result
|
||||
},
|
||||
|
||||
async save(platform, gameVersion, loaderVersionId) {
|
||||
debug('save: called', {
|
||||
instanceId: instance.value.id,
|
||||
platform,
|
||||
gameVersion,
|
||||
loaderVersionId,
|
||||
})
|
||||
const editInstancePatch: Record<string, string | undefined> = {
|
||||
loader: platform,
|
||||
game_version: gameVersion,
|
||||
}
|
||||
if (platform !== 'vanilla' && loaderVersionId) {
|
||||
editInstancePatch.loader_version = loaderVersionId
|
||||
}
|
||||
await edit(instance.value.id, editInstancePatch).catch(handleError)
|
||||
debug('save: edit complete', { editInstancePatch })
|
||||
},
|
||||
|
||||
afterSave: async () => {
|
||||
debug('afterSave: installing', { instanceId: instance.value.id })
|
||||
await install_existing_instance(instance.value.id, false).catch(handleError)
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
debug('afterSave: done')
|
||||
},
|
||||
|
||||
async repair() {
|
||||
debug('repair: called', { instanceId: instance.value.id })
|
||||
repairing.value = true
|
||||
await install_existing_instance(instance.value.id, true).catch(handleError)
|
||||
repairing.value = false
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
debug('repair: done')
|
||||
},
|
||||
|
||||
async reinstallModpack() {
|
||||
debug('reinstallModpack: called', { instanceId: instance.value.id })
|
||||
reinstalling.value = true
|
||||
let shouldTrack = false
|
||||
try {
|
||||
if (isImportedModpack.value) {
|
||||
shouldTrack = await installLocalModpackFromPicker()
|
||||
} else if (isCurseForgeLinkedModpack.value) {
|
||||
const fileId = Number(instance.value.link?.version_id)
|
||||
if (!Number.isFinite(fileId)) {
|
||||
throw new Error('Invalid CurseForge file ID')
|
||||
}
|
||||
await updateManagedCurseForgeModpack(instance.value.id, fileId).catch(handleError)
|
||||
shouldTrack = true
|
||||
} else {
|
||||
await update_repair_modrinth(instance.value.id).catch(handleError)
|
||||
shouldTrack = true
|
||||
}
|
||||
} finally {
|
||||
reinstalling.value = false
|
||||
}
|
||||
if (shouldTrack) {
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
debug('reinstallModpack: done')
|
||||
},
|
||||
|
||||
async swapModpack() {
|
||||
debug('swapModpack: called', { instanceId: instance.value.id })
|
||||
reinstalling.value = true
|
||||
try {
|
||||
const installed = await installLocalModpackFromPicker()
|
||||
if (installed) {
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
reinstalling.value = false
|
||||
}
|
||||
debug('swapModpack: done')
|
||||
},
|
||||
|
||||
async unlinkModpack() {
|
||||
debug('unlinkModpack: called', { instanceId: instance.value.id })
|
||||
await edit(instance.value.id, {
|
||||
link: null as unknown as undefined,
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['linkedModpackInfo', instance.value.id],
|
||||
})
|
||||
onUnlinked()
|
||||
debug('unlinkModpack: done')
|
||||
},
|
||||
|
||||
getCachedModpackVersions: () => null,
|
||||
async fetchModpackVersions() {
|
||||
debug('fetchModpackVersions: called', {
|
||||
projectId: instance.value.link?.project_id,
|
||||
})
|
||||
if (isCurseForgeLinkedModpack.value) {
|
||||
const rawProjectId = instance.value.link?.project_id
|
||||
if (!rawProjectId) return []
|
||||
const projectId = Number(
|
||||
rawProjectId.startsWith('curseforge:')
|
||||
? rawProjectId.slice('curseforge:'.length)
|
||||
: rawProjectId,
|
||||
)
|
||||
if (!Number.isFinite(projectId)) return []
|
||||
const { getCurseForgeFile, getCurseForgeFiles } = await import('@/helpers/curseforge')
|
||||
const files: CurseForgeFile[] = []
|
||||
let index = 0
|
||||
while (true) {
|
||||
const response = await getCurseForgeFiles(projectId, {
|
||||
index,
|
||||
pageSize: 50,
|
||||
}).catch(handleError)
|
||||
if (!response) break
|
||||
files.push(...response.files)
|
||||
index += response.files.length
|
||||
if (
|
||||
response.files.length === 0 ||
|
||||
index >= (response.pagination?.totalCount ?? response.files.length)
|
||||
) {
|
||||
break
|
||||
}
|
||||
}
|
||||
const installedFileId = Number(instance.value.link?.version_id)
|
||||
if (Number.isFinite(installedFileId) && !files.some((file) => file.id === installedFileId)) {
|
||||
const installedFile = await getCurseForgeFile(projectId, installedFileId).catch(() => null)
|
||||
if (installedFile?.isAvailable) {
|
||||
files.push(installedFile)
|
||||
}
|
||||
}
|
||||
const versions = files
|
||||
.filter((file) => file.isAvailable)
|
||||
.map((file) => {
|
||||
const loaders = [
|
||||
...new Set(
|
||||
file.gameVersions
|
||||
.map((value) => {
|
||||
switch (value.toLowerCase().replaceAll(' ', '')) {
|
||||
case 'forge':
|
||||
return 'forge'
|
||||
case 'fabric':
|
||||
case 'fabricloader':
|
||||
return 'fabric'
|
||||
case 'quilt':
|
||||
return 'quilt'
|
||||
case 'neoforge':
|
||||
return 'neoforge'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter(Boolean),
|
||||
),
|
||||
] as string[]
|
||||
const gameVersions = file.gameVersions.filter((value) => {
|
||||
const normalized = value.toLowerCase().replaceAll(' ', '')
|
||||
return !['forge', 'fabric', 'fabricloader', 'quilt', 'neoforge'].includes(normalized)
|
||||
})
|
||||
return {
|
||||
id: file.id.toString(),
|
||||
project_id: `curseforge:${projectId}`,
|
||||
name: file.displayName,
|
||||
version_number: file.displayName,
|
||||
game_versions: gameVersions,
|
||||
loaders: loaders.length > 0 ? loaders : ['minecraft'],
|
||||
date_published: file.fileDate,
|
||||
version_type:
|
||||
file.releaseType === 1 ? 'release' : file.releaseType === 2 ? 'beta' : 'alpha',
|
||||
files: [
|
||||
{
|
||||
filename: file.fileName,
|
||||
url: file.downloadUrl ?? '',
|
||||
primary: true,
|
||||
size: file.fileLength,
|
||||
hashes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as Labrinth.Versions.v2.Version
|
||||
})
|
||||
debug('fetchModpackVersions: done', { count: versions.length })
|
||||
return versions
|
||||
}
|
||||
const versions = await get_project_versions(instance.value.link!.project_id!).catch(handleError)
|
||||
debug('fetchModpackVersions: done', { count: versions?.length ?? 0 })
|
||||
return (versions ?? []) as Labrinth.Versions.v2.Version[]
|
||||
},
|
||||
|
||||
async getVersionChangelog(versionId: string) {
|
||||
debug('getVersionChangelog: called', { versionId })
|
||||
if (isCurseForgeLinkedModpack.value) {
|
||||
const rawProjectId = instance.value.link?.project_id
|
||||
const fileId = Number(versionId)
|
||||
const projectId = rawProjectId
|
||||
? Number(
|
||||
rawProjectId.startsWith('curseforge:')
|
||||
? rawProjectId.slice('curseforge:'.length)
|
||||
: rawProjectId,
|
||||
)
|
||||
: NaN
|
||||
if (!Number.isFinite(projectId) || !Number.isFinite(fileId)) return null
|
||||
const { getCurseForgeChangelog } = await import('@/helpers/curseforge')
|
||||
const changelog = await getCurseForgeChangelog(projectId, fileId).catch(() => null)
|
||||
if (changelog == null) return null
|
||||
return { id: versionId, changelog } as unknown as Labrinth.Versions.v2.Version
|
||||
}
|
||||
return (await get_version(versionId, 'must_revalidate').catch(
|
||||
() => null,
|
||||
)) as Labrinth.Versions.v2.Version | null
|
||||
},
|
||||
|
||||
async onModpackVersionConfirm(version) {
|
||||
debug('onModpackVersionConfirm: called', {
|
||||
versionId: version.id,
|
||||
instanceId: instance.value.id,
|
||||
})
|
||||
try {
|
||||
if (isCurseForgeLinkedModpack.value) {
|
||||
const fileId = Number(version.id)
|
||||
if (!Number.isFinite(fileId)) {
|
||||
throw new Error('Invalid CurseForge file ID')
|
||||
}
|
||||
await updateManagedCurseForgeModpack(instance.value.id, fileId)
|
||||
} else {
|
||||
await update_managed_modrinth_version(instance.value.id, version.id)
|
||||
}
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['linkedModpackInfo', instance.value.id],
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(error as Error)
|
||||
}
|
||||
debug('onModpackVersionConfirm: done')
|
||||
},
|
||||
|
||||
updaterModalProps: computed(() => ({
|
||||
isApp: true,
|
||||
currentVersionId:
|
||||
modpackInfo.value?.update?.provider === 'modrinth'
|
||||
? modpackInfo.value.update.target_version_id
|
||||
: modpackInfo.value?.update?.provider === 'curseforge'
|
||||
? String(modpackInfo.value.update.target_file_id)
|
||||
: (instance.value.link?.version_id ?? ''),
|
||||
projectIconUrl: modpackInfo.value?.project?.icon_url,
|
||||
projectName: modpackInfo.value?.project?.title ?? 'Modpack',
|
||||
currentGameVersion: instance.value.game_version,
|
||||
currentLoader: instance.value.loader,
|
||||
})),
|
||||
|
||||
isServer: false,
|
||||
isApp: true,
|
||||
symlinkTarget: computed(() => instance.value.symlink_target),
|
||||
showModpackVersionActions: computed(
|
||||
() => isLinkedManagedModpack.value && !isMinecraftServer.value,
|
||||
),
|
||||
isLocalFile: isImportedModpack,
|
||||
repairing,
|
||||
reinstalling,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SymlinkInstanceWarning
|
||||
v-if="instance?.symlink_target"
|
||||
:symlink-target="instance.symlink_target"
|
||||
/>
|
||||
<InstallationSettingsLayout />
|
||||
</template>
|
||||
@@ -0,0 +1,291 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
Slider,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { computed, readonly, ref, watch } from 'vue'
|
||||
|
||||
import JavaArgumentsInput from '@/components/ui/JavaArgumentsInput.vue'
|
||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
||||
import MemoryAllocationDisplay from '@/components/ui/MemoryAllocationDisplay.vue'
|
||||
import useMemorySlider from '@/composables/useMemorySlider'
|
||||
import { collectGcContext, extractJavaMajorVersion } from '@/helpers/gc/context'
|
||||
import type { GcContext } from '@/helpers/gc/types'
|
||||
import { edit, get_content_snapshot, get_optimal_jre_key } from '@/helpers/instance'
|
||||
import { get } from '@/helpers/settings'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { AppSettings } from '../../../helpers/types'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
javaInstallation: {
|
||||
id: 'instance.settings.tabs.java.java-installation',
|
||||
defaultMessage: 'Java installation',
|
||||
},
|
||||
customJavaInstallation: {
|
||||
id: 'instance.settings.tabs.java.custom-java-installation',
|
||||
defaultMessage: 'Use a custom Java installation for this instance',
|
||||
},
|
||||
javaPathPlaceholder: {
|
||||
id: 'instance.settings.tabs.java.java-path-placeholder',
|
||||
defaultMessage: '/path/to/java',
|
||||
},
|
||||
javaMemory: {
|
||||
id: 'instance.settings.tabs.java.java-memory',
|
||||
defaultMessage: 'Memory allocated',
|
||||
},
|
||||
customMemoryAllocation: {
|
||||
id: 'instance.settings.tabs.java.custom-memory-allocation',
|
||||
defaultMessage: 'Custom memory allocation',
|
||||
},
|
||||
automaticMemory: {
|
||||
id: 'instance.settings.tabs.java.automatic-memory',
|
||||
defaultMessage: 'Automatically allocate memory at launch',
|
||||
},
|
||||
optimizeMemoryBeforeLaunch: {
|
||||
id: 'instance.settings.tabs.java.optimize-memory-before-launch',
|
||||
defaultMessage: 'Optimize memory before launching the game',
|
||||
},
|
||||
optimizeMemoryBeforeLaunchDescription: {
|
||||
id: 'instance.settings.tabs.java.optimize-memory-before-launch-description',
|
||||
defaultMessage: 'Waits for Windows memory optimization to finish before starting the game.',
|
||||
},
|
||||
javaArguments: {
|
||||
id: 'instance.settings.tabs.java.java-arguments',
|
||||
defaultMessage: 'Java arguments',
|
||||
},
|
||||
customJavaArguments: {
|
||||
id: 'instance.settings.tabs.java.custom-java-arguments',
|
||||
defaultMessage: 'Custom Java arguments',
|
||||
},
|
||||
enterJavaArguments: {
|
||||
id: 'instance.settings.tabs.java.enter-java-arguments',
|
||||
defaultMessage: 'Enter Java arguments...',
|
||||
},
|
||||
javaEnvironmentVariables: {
|
||||
id: 'instance.settings.tabs.java.environment-variables',
|
||||
defaultMessage: 'Environment variables',
|
||||
},
|
||||
customEnvironmentVariables: {
|
||||
id: 'instance.settings.tabs.java.custom-environment-variables',
|
||||
defaultMessage: 'Custom environment variables',
|
||||
},
|
||||
enterEnvironmentVariables: {
|
||||
id: 'instance.settings.tabs.java.enter-environment-variables',
|
||||
defaultMessage: 'Enter environmental variables...',
|
||||
},
|
||||
})
|
||||
|
||||
const { instance } = injectInstanceSettings()
|
||||
const supportsMemoryOptimization = (await platform()) === 'windows'
|
||||
|
||||
const globalSettings = (await get().catch(handleError)) as unknown as AppSettings
|
||||
const optimalJava = readonly(await get_optimal_jre_key(instance.value.id).catch(handleError))
|
||||
const requiredJavaVersion = optimalJava?.parsed_version ?? null
|
||||
|
||||
const overrideJavaInstall = ref(!!instance.value.java_path)
|
||||
const overrideJava = ref({
|
||||
...(optimalJava ?? {}),
|
||||
path: instance.value.java_path ?? optimalJava?.path ?? '',
|
||||
})
|
||||
const displayedJava = computed({
|
||||
get: () => (overrideJavaInstall.value ? overrideJava.value : (optimalJava ?? overrideJava.value)),
|
||||
set: (value) => {
|
||||
overrideJava.value = value
|
||||
},
|
||||
})
|
||||
|
||||
watch(overrideJavaInstall, (enabled) => {
|
||||
if (enabled && !overrideJava.value.path) {
|
||||
overrideJava.value = { ...(optimalJava ?? {}), path: optimalJava?.path ?? '' }
|
||||
}
|
||||
})
|
||||
|
||||
const overrideJavaArgs = ref((instance.value.extra_launch_args?.length ?? 0) > 0)
|
||||
const javaArgs = ref(
|
||||
(instance.value.extra_launch_args ?? globalSettings?.extra_launch_args ?? []).join(' '),
|
||||
)
|
||||
|
||||
const overrideEnvVars = ref((instance.value.custom_env_vars?.length ?? 0) > 0)
|
||||
const envVars = ref(
|
||||
(instance.value.custom_env_vars ?? globalSettings?.custom_env_vars ?? [])
|
||||
.map((x: string[]) => x.join('='))
|
||||
.join(' '),
|
||||
)
|
||||
|
||||
const defaultMemory = { maximum: 2048, automatic: true, optimize_before_launch: false }
|
||||
const overrideMemorySettings = ref(!!instance.value.memory)
|
||||
const memory = ref({
|
||||
...defaultMemory,
|
||||
...(instance.value.memory ?? globalSettings?.memory),
|
||||
})
|
||||
const effectiveMemory = computed(() =>
|
||||
overrideMemorySettings.value ? memory.value : { ...defaultMemory, ...globalSettings?.memory },
|
||||
)
|
||||
const memData = await useMemorySlider().catch(() => ({
|
||||
maxMemory: ref(4096),
|
||||
snapPoints: computed(() => []),
|
||||
}))
|
||||
const maxMemory = memData.maxMemory
|
||||
const snapPoints = memData.snapPoints
|
||||
|
||||
const gcContext = ref<GcContext | null>(null)
|
||||
|
||||
async function updateGcContext() {
|
||||
const javaMajorVersion = extractJavaMajorVersion(displayedJava.value?.parsed_version)
|
||||
let modCount = 0
|
||||
try {
|
||||
const snapshot = await get_content_snapshot(instance.value.id)
|
||||
modCount = snapshot.items.filter(
|
||||
(item) => item.projectType === 'mod' && item.materializationState === 'present',
|
||||
).length
|
||||
} catch {
|
||||
modCount = 0
|
||||
}
|
||||
gcContext.value = await collectGcContext(
|
||||
memory.value.maximum,
|
||||
instance.value.loader,
|
||||
javaMajorVersion,
|
||||
modCount,
|
||||
)
|
||||
}
|
||||
|
||||
await updateGcContext()
|
||||
|
||||
watch([memory, displayedJava, () => instance.value.loader], updateGcContext)
|
||||
|
||||
const editInstanceObject = computed(() => ({
|
||||
java_path:
|
||||
overrideJavaInstall.value && overrideJava.value.path
|
||||
? overrideJava.value.path.replace('java.exe', 'javaw.exe')
|
||||
: null,
|
||||
extra_launch_args: overrideJavaArgs.value
|
||||
? javaArgs.value.trim().split(/\s+/).filter(Boolean)
|
||||
: null,
|
||||
custom_env_vars: overrideEnvVars.value
|
||||
? envVars.value
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((x: string) => x.split('=').filter(Boolean))
|
||||
: null,
|
||||
memory: overrideMemorySettings.value ? memory.value : null,
|
||||
}))
|
||||
|
||||
watch(
|
||||
[
|
||||
overrideJavaInstall,
|
||||
overrideJava,
|
||||
overrideJavaArgs,
|
||||
javaArgs,
|
||||
overrideEnvVars,
|
||||
envVars,
|
||||
overrideMemorySettings,
|
||||
memory,
|
||||
],
|
||||
async () => {
|
||||
await edit(instance.value.id, editInstanceObject.value).catch(handleError)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="m-0 mb-2 block text-base font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.javaInstallation) }}
|
||||
</h2>
|
||||
<Checkbox
|
||||
v-model="overrideJavaInstall"
|
||||
:label="formatMessage(messages.customJavaInstallation)"
|
||||
class="mb-2"
|
||||
/>
|
||||
<JavaSelector
|
||||
v-model="displayedJava"
|
||||
:disabled="!overrideJavaInstall"
|
||||
:placeholder="formatMessage(messages.javaPathPlaceholder)"
|
||||
:version="requiredJavaVersion"
|
||||
select-all-versions
|
||||
/>
|
||||
|
||||
<h2 class="mb-1 mt-4 block text-base font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.javaMemory) }}
|
||||
</h2>
|
||||
<Checkbox
|
||||
v-model="overrideMemorySettings"
|
||||
:label="formatMessage(messages.customMemoryAllocation)"
|
||||
class="mb-2"
|
||||
/>
|
||||
<Checkbox
|
||||
v-if="overrideMemorySettings"
|
||||
v-model="memory.automatic"
|
||||
:label="formatMessage(messages.automaticMemory)"
|
||||
class="mb-2"
|
||||
/>
|
||||
<div
|
||||
v-if="supportsMemoryOptimization && overrideMemorySettings"
|
||||
class="mb-2 flex flex-col gap-1"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="memory.optimize_before_launch"
|
||||
:label="formatMessage(messages.optimizeMemoryBeforeLaunch)"
|
||||
/>
|
||||
<p class="m-0 text-xs leading-tight text-secondary">
|
||||
{{ formatMessage(messages.optimizeMemoryBeforeLaunchDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<Slider
|
||||
id="max-memory"
|
||||
v-model="memory.maximum"
|
||||
:disabled="!overrideMemorySettings || memory.automatic"
|
||||
:min="512"
|
||||
:max="maxMemory"
|
||||
:step="64"
|
||||
:snap-points="snapPoints"
|
||||
:snap-range="512"
|
||||
unit="MB"
|
||||
/>
|
||||
<MemoryAllocationDisplay :instance-id="instance.id" :memory="effectiveMemory" />
|
||||
|
||||
<h2 class="mb-1 mt-4 block text-base font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.javaArguments) }}
|
||||
</h2>
|
||||
<Checkbox
|
||||
v-model="overrideJavaArgs"
|
||||
:label="formatMessage(messages.customJavaArguments)"
|
||||
class="my-1"
|
||||
/>
|
||||
<JavaArgumentsInput
|
||||
id="java-args"
|
||||
v-model="javaArgs"
|
||||
:disabled="!overrideJavaArgs"
|
||||
:gc-context="gcContext"
|
||||
:show-auto-details="true"
|
||||
:placeholder="formatMessage(messages.enterJavaArguments)"
|
||||
/>
|
||||
|
||||
<h2 class="mb-1 mt-4 block text-base font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.javaEnvironmentVariables) }}
|
||||
</h2>
|
||||
<Checkbox
|
||||
v-model="overrideEnvVars"
|
||||
:label="formatMessage(messages.customEnvironmentVariables)"
|
||||
class="mb-2"
|
||||
/>
|
||||
<StyledInput
|
||||
id="env-vars"
|
||||
v-model="envVars"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideEnvVars"
|
||||
:placeholder="formatMessage(messages.enterEnvironmentVariables)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { computed, type Ref, ref, watch } from 'vue'
|
||||
|
||||
import { edit } from '@/helpers/instance'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { AppSettings } from '../../../helpers/types'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { instance } = injectInstanceSettings()
|
||||
const supportsMaximizeWindow = (await platform()) === 'windows'
|
||||
|
||||
const globalSettings = (await get().catch(handleError)) as AppSettings
|
||||
|
||||
const overrideWindowSettings = ref(
|
||||
!!instance.value.game_resolution ||
|
||||
!!instance.value.force_fullscreen ||
|
||||
!!instance.value.maximize_window,
|
||||
)
|
||||
const resolution: Ref<[number, number]> = ref(
|
||||
instance.value.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]),
|
||||
)
|
||||
const fullscreenSetting: Ref<boolean> = ref(
|
||||
instance.value.force_fullscreen ?? globalSettings.force_fullscreen,
|
||||
)
|
||||
const maximizeWindowSetting = ref(instance.value.maximize_window ?? globalSettings.maximize_window)
|
||||
|
||||
const editInstanceObject = computed(() => {
|
||||
if (!overrideWindowSettings.value) {
|
||||
return {
|
||||
force_fullscreen: null,
|
||||
maximize_window: null,
|
||||
game_resolution: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
force_fullscreen: fullscreenSetting.value,
|
||||
maximize_window: maximizeWindowSetting.value,
|
||||
game_resolution: fullscreenSetting.value ? null : resolution.value,
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
[overrideWindowSettings, resolution, fullscreenSetting, maximizeWindowSetting],
|
||||
async () => {
|
||||
await edit(instance.value.id, editInstanceObject.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
customWindowSettings: {
|
||||
id: 'instance.settings.tabs.window.custom-window-settings',
|
||||
defaultMessage: 'Custom window settings',
|
||||
},
|
||||
fullscreen: {
|
||||
id: 'instance.settings.tabs.window.fullscreen',
|
||||
defaultMessage: 'Fullscreen',
|
||||
},
|
||||
fullscreenDescription: {
|
||||
id: 'instance.settings.tabs.window.fullscreen.description',
|
||||
defaultMessage: 'Make the game start in full screen when launched (using options.txt).',
|
||||
},
|
||||
maximizeWindow: {
|
||||
id: 'instance.settings.tabs.window.maximize-window',
|
||||
defaultMessage: 'Maximize window',
|
||||
},
|
||||
maximizeWindowDescription: {
|
||||
id: 'instance.settings.tabs.window.maximize-window.description',
|
||||
defaultMessage: 'Maximize the Minecraft window when launched.',
|
||||
},
|
||||
maximizeWindowUnsupported: {
|
||||
id: 'instance.settings.tabs.window.maximize-window.unsupported',
|
||||
defaultMessage: 'Not supported on this operating system.',
|
||||
},
|
||||
width: {
|
||||
id: 'instance.settings.tabs.window.width',
|
||||
defaultMessage: 'Width',
|
||||
},
|
||||
widthDescription: {
|
||||
id: 'instance.settings.tabs.window.width.description',
|
||||
defaultMessage: 'The width of the game window when launched.',
|
||||
},
|
||||
enterWidth: {
|
||||
id: 'instance.settings.tabs.window.width.enter',
|
||||
defaultMessage: 'Enter width...',
|
||||
},
|
||||
height: {
|
||||
id: 'instance.settings.tabs.window.height',
|
||||
defaultMessage: 'Height',
|
||||
},
|
||||
heightDescription: {
|
||||
id: 'instance.settings.tabs.window.height.description',
|
||||
defaultMessage: 'The height of the game window when launched.',
|
||||
},
|
||||
enterHeight: {
|
||||
id: 'instance.settings.tabs.window.height.enter',
|
||||
defaultMessage: 'Enter height...',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Checkbox
|
||||
v-model="overrideWindowSettings"
|
||||
:label="formatMessage(messages.customWindowSettings)"
|
||||
/>
|
||||
<div class="flex items-center gap-4 justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.fullscreen) }}
|
||||
</h2>
|
||||
<p class="m-0" :class="{ 'text-secondary': !supportsMaximizeWindow }">
|
||||
{{ formatMessage(messages.fullscreenDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
id="fullscreen"
|
||||
:model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen"
|
||||
:disabled="!overrideWindowSettings"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
fullscreenSetting = e
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.maximizeWindow) }}
|
||||
</h2>
|
||||
<p class="m-0">
|
||||
{{
|
||||
formatMessage(
|
||||
supportsMaximizeWindow
|
||||
? messages.maximizeWindowDescription
|
||||
: messages.maximizeWindowUnsupported,
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
id="maximize-window"
|
||||
:model-value="
|
||||
overrideWindowSettings ? maximizeWindowSetting : globalSettings.maximize_window
|
||||
"
|
||||
:disabled="!overrideWindowSettings || fullscreenSetting || !supportsMaximizeWindow"
|
||||
@update:model-value="(value) => (maximizeWindowSetting = value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.width) }}
|
||||
</h2>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.widthDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="width"
|
||||
v-model="resolution[0]"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideWindowSettings || fullscreenSetting"
|
||||
type="number"
|
||||
:placeholder="formatMessage(messages.enterWidth)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.height) }}
|
||||
</h2>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.heightDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="height"
|
||||
v-model="resolution[1]"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideWindowSettings || fullscreenSetting"
|
||||
type="number"
|
||||
:placeholder="formatMessage(messages.enterHeight)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user