feat:移除了弹窗,服务器添加sls

This commit is contained in:
AxTps committed 2026-09-08 22:39:45 +08:00
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>