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,51 @@
<script setup lang="ts">
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
defineProps({
onFlowCancel: {
type: Function,
default() {
return async () => {}
},
},
})
const modal = ref()
const { formatMessage } = useVIntl()
const messages = defineMessages({
signInBrowser: {
id: 'app.auth.sign-in-browser',
defaultMessage: 'Please sign in in the browser window that just opened to continue.',
},
})
function show() {
modal.value.show()
}
function hide() {
modal.value.hide()
}
defineExpose({ show, hide })
</script>
<template>
<ModalWrapper ref="modal" @hide="onFlowCancel">
<template #title>
<span class="items-center gap-2 text-lg font-extrabold text-contrast">
<LogInIcon /> {{ formatMessage(commonMessages.signInButton) }}
</span>
</template>
<div class="flex justify-center gap-2">
<SpinnerIcon class="w-12 h-12 animate-spin" />
</div>
<p class="text-sm text-secondary">
{{ formatMessage(messages.signInBrowser) }}
</p>
</ModalWrapper>
</template>
@@ -0,0 +1,179 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="standard" max-width="500px">
<p class="m-0 text-secondary">
{{ formatMessage(messages.description, { count: instanceIds.length }) }}
</p>
<div class="flex flex-col gap-3 mt-4">
<RadioButtons v-model="selectedGroup" :items="groupOptions">
<template #default="{ item }">
<span class="flex items-center justify-between flex-1 leading-none">
<span>{{ item || formatMessage(messages.noGroup) }}</span>
<button
v-if="item"
class="bg-transparent border-none cursor-pointer text-secondary hover:text-red rounded flex items-center justify-center w-6 h-6"
@click.stop="deleteGroup(item)"
>
<TrashIcon class="w-4 h-4" />
</button>
<span v-else class="w-6 h-6" />
</span>
</template>
</RadioButtons>
<div class="flex gap-2 items-center">
<StyledInput
v-model="newGroupInput"
:placeholder="formatMessage(messages.enterGroupName)"
class="w-full max-w-[300px]"
@submit="addNewGroup"
/>
<ButtonStyled>
<button class="w-fit !shadow-none" @click="addNewGroup">
<PlusIcon /> {{ formatMessage(messages.createGroup) }}
</button>
</ButtonStyled>
</div>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="confirm">
<CheckIcon />
{{ formatMessage(messages.applyButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { CheckIcon, PlusIcon, TrashIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
NewModal,
RadioButtons,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { edit, list } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
const { formatMessage } = useVIntl()
const props = defineProps<{
instanceIds: string[]
}>()
const emit = defineEmits<{
(e: 'applied'): void
}>()
const messages = defineMessages({
header: {
id: 'app.instances.batch-edit-groups.header',
defaultMessage: 'Edit groups',
},
description: {
id: 'app.instances.batch-edit-groups.description',
defaultMessage: 'Select groups to apply to {count} instance(s).',
},
enterGroupName: {
id: 'app.instances.batch-edit-groups.enter-group-name',
defaultMessage: 'Enter group name',
},
createGroup: {
id: 'app.instances.batch-edit-groups.create-group',
defaultMessage: 'Create new group',
},
applyButton: {
id: 'app.instances.batch-edit-groups.apply',
defaultMessage: 'Apply',
},
noGroup: {
id: 'app.instances.group.ungrouped',
defaultMessage: 'No group',
},
})
const modal = ref<InstanceType<typeof NewModal>>()
const selectedGroup = ref('')
const newGroupInput = ref('')
const allInstances = ref<GameInstance[]>([])
const availableGroups = computed(() => {
const groups = new Set<string>()
for (const instance of allInstances.value) {
for (const group of instance.groups) {
groups.add(group)
}
}
return [...groups]
})
const groupOptions = computed(() => ['', ...availableGroups.value])
function show() {
selectedGroup.value = ''
newGroupInput.value = ''
list().then((instances) => {
allInstances.value = instances as GameInstance[]
})
modal.value?.show()
}
function addNewGroup() {
const text = newGroupInput.value.trim()
if (text.length > 0) {
const groupName = text.substring(0, 32)
allInstances.value.push({ groups: [groupName] } as GameInstance)
selectedGroup.value = groupName
newGroupInput.value = ''
}
}
async function deleteGroup(group: string) {
for (const instance of allInstances.value) {
if (instance.groups.includes(group)) {
const newGroups = instance.groups.filter((g) => g !== group)
await edit(instance.id, { groups: newGroups }).catch(() => {})
instance.groups = newGroups
}
}
if (selectedGroup.value === group) {
selectedGroup.value = ''
}
}
async function confirm() {
if (newGroupInput.value.trim().length > 0) {
addNewGroup()
}
modal.value?.hide()
const groups = selectedGroup.value ? [selectedGroup.value.trim().substring(0, 32)] : []
for (const instanceId of props.instanceIds) {
await edit(instanceId, { groups }).catch(() => {})
}
emit('applied')
}
defineExpose({
show,
})
</script>
@@ -0,0 +1,129 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(count > 1 ? messages.batchHeader : messages.header)"
fade="danger"
max-width="500px"
>
<Admonition
v-if="!symlinkTarget && count <= 1"
type="critical"
:header="formatMessage(messages.admonitionHeader)"
>
{{ formatMessage(messages.admonitionBody) }}
</Admonition>
<Admonition
v-else-if="!symlinkTarget"
type="critical"
:header="formatMessage(messages.admonitionHeader)"
>
{{ formatMessage(messages.batchAdmonitionBody, { count }) }}
</Admonition>
<Admonition v-else type="critical">
{{ formatMessage(messages.symlinkDeleteWarning, { path: symlinkTarget }) }}
</Admonition>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="confirm">
<TrashIcon />
{{
formatMessage(count > 1 ? messages.batchDeleteButton : messages.deleteButton, {
count,
})
}}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { TrashIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
withDefaults(
defineProps<{
symlinkTarget?: string | null
count?: number
}>(),
{
count: 1,
},
)
const messages = defineMessages({
header: {
id: 'app.instance.confirm-delete.header',
defaultMessage: 'Delete instance',
},
batchHeader: {
id: 'app.instance.confirm-delete.batch-header',
defaultMessage: 'Delete instances',
},
admonitionHeader: {
id: 'app.instance.confirm-delete.admonition-header',
defaultMessage: 'This action cannot be undone',
},
admonitionBody: {
id: 'app.instance.confirm-delete.admonition-body',
defaultMessage:
'All data for your instance will be permanently deleted, including your worlds, configs, and all installed content.',
},
batchAdmonitionBody: {
id: 'app.instance.confirm-delete.batch-admonition-body',
defaultMessage:
'{count, plural, one {# instance} other {# instances}} will be permanently deleted, including worlds, configs, and all installed content.',
},
symlinkDeleteWarning: {
id: 'app.instance.confirm-delete.symlink-warning',
defaultMessage:
'This is a shared instance linked to "{path}". Only the link will be removed; the original files will not be deleted.',
},
deleteButton: {
id: 'app.instance.confirm-delete.delete-button',
defaultMessage: 'Delete instance',
},
batchDeleteButton: {
id: 'app.instance.confirm-delete.batch-delete-button',
defaultMessage: 'Delete {count, plural, one {# instance} other {# instances}}',
},
})
const emit = defineEmits<{
(e: 'delete'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
function show() {
modal.value?.show()
}
function confirm() {
modal.value?.hide()
emit('delete')
}
defineExpose({
show,
})
</script>
@@ -0,0 +1,78 @@
<!-- @deprecated Use ConfirmModal from @modrinth/ui directly. Ads/noblur now handled by injectModalBehavior. -->
<script setup lang="ts">
import { ConfirmModal } from '@modrinth/ui'
import { useTemplateRef } from 'vue'
defineProps({
confirmationText: {
type: String,
default: '',
},
hasToType: {
type: Boolean,
default: false,
},
title: {
type: String,
default: 'No title defined',
required: true,
},
description: {
type: String,
default: 'No description defined',
required: true,
},
proceedIcon: {
type: Object,
default: undefined,
},
proceedLabel: {
type: String,
default: 'Proceed',
},
danger: {
type: Boolean,
default: true,
},
/** @deprecated No longer used — ads are handled by provideModalBehavior */
showAdOnClose: {
type: Boolean,
default: true,
},
markdown: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['proceed'])
const modal = useTemplateRef('modal')
defineExpose({
show: () => {
modal.value?.show()
},
hide: () => {
modal.value?.hide()
},
})
function proceed() {
emit('proceed')
}
</script>
<template>
<ConfirmModal
ref="modal"
:confirmation-text="confirmationText"
:has-to-type="hasToType"
:title="title"
:description="description"
:proceed-icon="proceedIcon"
:proceed-label="proceedLabel"
:danger="danger"
:markdown="markdown"
@proceed="proceed"
/>
</template>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
IntlFormatted,
MinecraftFormattedText,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { autoCleanToText } from '@sfirew/minecraft-motd-parser'
import { ref } from 'vue'
export interface ContentToggleDependencyItem {
title: string
iconUrl?: string | null
versionNumber?: string
}
export interface ContentToggleDependenciesData {
enabling: boolean
bulk: boolean
primaryTitle: string
related: ContentToggleDependencyItem[]
}
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'app.instance.mods.toggle-dependencies.title',
defaultMessage: 'Confirm toggle',
},
singleBody: {
id: 'app.instance.mods.toggle-dependencies.single-body',
defaultMessage:
'Toggling {project} will affect {count, plural, one {# other item} other {# other items}}.',
},
bulkBody: {
id: 'app.instance.mods.toggle-dependencies.bulk-body',
defaultMessage:
'Toggling the selected content will affect {count, plural, one {# other item} other {# other items}}.',
},
willEnable: {
id: 'app.instance.mods.toggle-dependencies.will-enable',
defaultMessage: 'The following content will be enabled:',
},
willDisable: {
id: 'app.instance.mods.toggle-dependencies.will-disable',
defaultMessage: 'The following content will be disabled:',
},
warning: {
id: 'app.instance.mods.toggle-dependencies.warning',
defaultMessage:
'Do you want to apply these related changes automatically? Ignoring them may break the game.',
},
apply: {
id: 'app.instance.mods.toggle-dependencies.apply',
defaultMessage: 'Toggle related content',
},
selectedOnly: {
id: 'app.instance.mods.toggle-dependencies.selected-only',
defaultMessage: 'Only toggle selected',
},
})
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const data = ref<ContentToggleDependenciesData | null>(null)
let settled = false
let resolveShow: ((choice: 'apply' | 'selected' | 'cancel') => void) | null = null
function finish(choice: 'apply' | 'selected' | 'cancel') {
if (settled) return
settled = true
const resolve = resolveShow
resolveShow = null
if (resolve) resolve(choice)
modal.value?.hide()
}
function show(value: ContentToggleDependenciesData): Promise<'apply' | 'selected' | 'cancel'> {
data.value = value
settled = false
modal.value?.show()
return new Promise((resolve) => {
resolveShow = resolve
})
}
defineExpose({ show })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
scrollable
max-content-height="70vh"
max-width="36rem"
:on-hide="() => finish('cancel')"
>
<div v-if="data" class="flex flex-col gap-4">
<p class="m-0 text-primary">
<IntlFormatted
:message-id="data.bulk ? messages.bulkBody : messages.singleBody"
:values="{ count: data.related.length }"
>
<template #project>
<MinecraftFormattedText :text="data.primaryTitle" />
</template>
</IntlFormatted>
</p>
<div v-if="data.related.length > 0" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
{{ formatMessage(data.enabling ? messages.willEnable : messages.willDisable) }}
</span>
<div
v-for="item in data.related"
:key="item.title"
class="flex items-center gap-3 rounded-xl border border-solid border-surface-4 bg-surface-2 p-3"
>
<Avatar
:src="item.iconUrl"
:alt="autoCleanToText(item.title)"
size="2.5rem"
:tint-by="item.title"
no-shadow
/>
<div class="flex min-w-0 flex-col gap-0.5">
<MinecraftFormattedText
:text="item.title"
class="truncate font-semibold text-contrast"
/>
<span v-if="item.versionNumber" class="truncate text-sm text-secondary">
{{ item.versionNumber }}
</span>
</div>
</div>
</div>
<p class="m-0 text-secondary">{{ formatMessage(messages.warning) }}</p>
</div>
<template #actions>
<div class="flex items-center justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="finish('cancel')">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button @click="finish('apply')">{{ formatMessage(messages.apply) }}</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="finish('selected')">{{ formatMessage(messages.selectedOnly) }}</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,535 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
:fade="remainingCount > 0 ? 'warning' : 'standard'"
:on-hide="stopScanning"
max-width="680px"
scrollable
>
<div class="flex flex-col gap-4">
<p class="m-0 text-secondary">
{{
installed == null
? formatMessage(messages.existingBody, { manual: remainingCount })
: formatMessage(messages.body, {
installed,
manual: remainingCount,
})
}}
</p>
<Admonition
:type="remainingCount === 0 ? 'success' : 'info'"
:header="
formatMessage(remainingCount === 0 ? messages.allImported : messages.automaticImport)
"
>
<template #icon>
<CheckIcon v-if="remainingCount === 0" class="size-5 shrink-0" aria-hidden="true" />
<SpinnerIcon
v-else-if="scanning"
class="size-5 shrink-0 animate-spin"
aria-hidden="true"
/>
<FolderSearchIcon v-else class="size-5 shrink-0" aria-hidden="true" />
</template>
<span class="min-w-0 break-words text-secondary">{{ scannerStatus }}</span>
</Admonition>
<div class="max-h-72 overflow-y-auto rounded-lg border border-surface-5 bg-surface-2">
<div
v-for="item in items"
:key="itemKey(item)"
class="flex min-h-16 items-center justify-between gap-3 border-0 border-b border-solid border-surface-5 px-3 py-2 last:border-b-0"
>
<div class="flex min-w-0 items-center gap-3">
<div class="flex size-8 shrink-0 items-center justify-center rounded-full bg-surface-4">
<CheckIcon v-if="isImported(item)" class="size-5 text-green" aria-hidden="true" />
<FolderSearchIcon v-else class="size-5 text-secondary" aria-hidden="true" />
</div>
<div class="min-w-0 flex flex-col gap-0.5">
<span class="truncate font-medium text-contrast">{{ item.fileName }}</span>
<span class="truncate text-sm text-secondary">
{{
formatMessage(messages.projectFile, {
projectId: item.projectId,
fileId: item.fileId,
})
}}
</span>
<span class="text-sm" :class="isImported(item) ? 'text-green' : 'text-secondary'">
{{ itemStatus(item) }}
</span>
</div>
</div>
<div v-if="!isImported(item)" class="flex shrink-0 flex-wrap justify-end gap-2">
<ButtonStyled type="outlined" size="small">
<button :disabled="busyKeys.has(itemKey(item))" @click="openOne(item)">
<ExternalIcon aria-hidden="true" />
{{ formatMessage(messages.open) }}
</button>
</ButtonStyled>
<ButtonStyled type="outlined" size="small">
<button :disabled="busyKeys.has(itemKey(item))" @click="chooseLocalFile(item)">
<SpinnerIcon v-if="busyKeys.has(itemKey(item))" class="animate-spin" />
<UploadIcon v-else aria-hidden="true" />
{{ formatMessage(messages.chooseFile) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
<template #actions>
<div class="flex flex-wrap justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="hide">
{{ formatMessage(commonMessages.closeButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="instanceId" type="outlined">
<button @click="goToInstance">
{{ formatMessage(messages.viewInstance) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="remainingCount > 0" color="orange">
<button @click="openAll">
<ExternalIcon aria-hidden="true" />
{{ formatMessage(messages.openAll) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import {
CheckIcon,
ExternalIcon,
FolderSearchIcon,
SpinnerIcon,
UploadIcon,
} from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import {
classifyCurseForgeManualDownloadImportError,
type CurseForgeManualDownloadImport,
type CurseForgeManualDownloadScanResult,
importCurseForgeManualDownloads,
importPendingCurseForgeManualDownloadFile,
listPendingCurseForgeManualDownloads,
} from '@/helpers/curseforge'
import {
type CurseForgeManualDownloadItem,
getCurseForgeManualDownloadUrl,
} from '@/helpers/curseforge-manual'
import { getMissingContentScannerSettings } from '@/helpers/downloads-scanner'
import { instance_listener } from '@/helpers/events.js'
import { get_content_snapshot } from '@/helpers/instance'
import { get_instance_worlds } from '@/helpers/worlds'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
header: {
id: 'app.curseforge.manual-downloads.header',
defaultMessage: 'Complete CurseForge downloads',
},
body: {
id: 'app.curseforge.manual-downloads.body',
defaultMessage:
'Installed {installed, number} files automatically. {manual, number} files require browser download.',
},
existingBody: {
id: 'app.curseforge.manual-downloads.existing-body',
defaultMessage:
'{manual, number} files still require browser download. Downloaded files will be verified and imported automatically.',
},
projectFile: {
id: 'app.curseforge.manual-downloads.project-file',
defaultMessage: 'Project {projectId} / File {fileId}',
},
open: {
id: 'app.curseforge.manual-downloads.open',
defaultMessage: 'Download',
},
openAll: {
id: 'app.curseforge.manual-downloads.open-all',
defaultMessage: 'Open missing',
},
chooseFile: {
id: 'app.curseforge.manual-downloads.choose-file',
defaultMessage: 'Choose local file',
},
viewInstance: {
id: 'app.curseforge.manual-downloads.view-instance',
defaultMessage: 'View instance',
},
automaticImport: {
id: 'app.curseforge.manual-downloads.automatic-import',
defaultMessage: 'Automatic import',
},
allImported: {
id: 'app.curseforge.manual-downloads.all-imported',
defaultMessage: 'All files imported',
},
checkingDownloads: {
id: 'app.curseforge.manual-downloads.checking-downloads',
defaultMessage: 'Checking the monitored import folder...',
},
watchingDownloads: {
id: 'app.curseforge.manual-downloads.watching-downloads',
defaultMessage: 'Watching {path}. Files are verified before import.',
},
downloadsUnavailable: {
id: 'app.curseforge.manual-downloads.downloads-unavailable',
defaultMessage: 'The monitored import folder is unavailable.',
},
automaticImportDisabled: {
id: 'app.curseforge.manual-downloads.automatic-import-disabled',
defaultMessage: 'Automatic import is disabled in Resource Management settings.',
},
scannerFailed: {
id: 'app.curseforge.manual-downloads.scanner-failed',
defaultMessage: 'Automatic import check failed; retrying.',
},
importComplete: {
id: 'app.curseforge.manual-downloads.import-complete',
defaultMessage: 'Imported into the instance',
},
waiting: {
id: 'app.curseforge.manual-downloads.waiting',
defaultMessage: 'Waiting for download',
},
retrying: {
id: 'app.curseforge.manual-downloads.retrying',
defaultMessage: 'File verification failed. Choose the required file.',
},
stateChanged: {
id: 'app.curseforge.manual-downloads.state-changed',
defaultMessage: 'Download state changed. Waiting for synchronization.',
},
})
const emit = defineEmits<{
(e: 'view-instance', instanceId: string): void
(e: 'imported', instanceId: string, imports: CurseForgeManualDownloadImport[]): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const items = ref<CurseForgeManualDownloadItem[]>([])
const candidateItems = ref<CurseForgeManualDownloadItem[]>([])
const installed = ref<number | null>(null)
const instanceId = ref<string | null>(null)
const scanning = ref(false)
const scanError = ref(false)
const downloadDirectory = ref<string | null>(null)
const scannerEnabled = ref(true)
const scanDirectory = ref<string | null>(null)
const importedKeys = ref(new Set<string>())
const inconsistentKeys = ref(new Set<string>())
const errorKeys = ref(new Set<string>())
const busyKeys = ref(new Set<string>())
let scannerActive = false
let scanGeneration = 0
let reconciliationGeneration = 0
let scanInFlight: Promise<CurseForgeManualDownloadScanResult> | undefined
let scanInterval: ReturnType<typeof setInterval> | null = null
let unlistenInstances: (() => void) | null = null
const remainingCount = computed(
() => items.value.filter((item) => !importedKeys.value.has(itemKey(item))).length,
)
const scannerStatus = computed(() => {
if (remainingCount.value === 0) return formatMessage(messages.importComplete)
if (!scannerEnabled.value) return formatMessage(messages.automaticImportDisabled)
if (scanError.value) return formatMessage(messages.scannerFailed)
if (downloadDirectory.value) {
return formatMessage(messages.watchingDownloads, { path: downloadDirectory.value })
}
if (scanning.value) return formatMessage(messages.checkingDownloads)
return formatMessage(messages.downloadsUnavailable)
})
function itemKey(item: Pick<CurseForgeManualDownloadItem, 'projectId' | 'fileId'>) {
return `${item.projectId}:${item.fileId}`
}
function isImported(item: CurseForgeManualDownloadItem) {
return importedKeys.value.has(itemKey(item))
}
function itemStatus(item: CurseForgeManualDownloadItem) {
if (isImported(item)) return formatMessage(messages.importComplete)
if (errorKeys.value.has(itemKey(item))) return formatMessage(messages.retrying)
if (inconsistentKeys.value.has(itemKey(item))) return formatMessage(messages.stateChanged)
return formatMessage(messages.waiting)
}
function show(payload: {
items: CurseForgeManualDownloadItem[]
installed?: number
instanceId?: string | null
}) {
stopScanning()
scannerActive = true
const seededItems = [...new Map(payload.items.map((item) => [itemKey(item), item])).values()]
candidateItems.value = seededItems
items.value = seededItems
installed.value = payload.installed ?? null
instanceId.value = payload.instanceId ?? null
downloadDirectory.value = null
scanError.value = false
importedKeys.value = new Set()
inconsistentKeys.value = new Set()
errorKeys.value = new Set()
busyKeys.value = new Set()
const scannerSettings = getMissingContentScannerSettings()
scannerEnabled.value = scannerSettings.enabled
scanDirectory.value = scannerSettings.directory
modal.value?.show()
void reconcileManualDownloadState().catch(handleError)
if (scannerEnabled.value) {
void scanDownloads()
scanInterval = setInterval(() => {
if (scannerActive) void scanDownloads()
}, 3000)
}
}
async function reconcileManualDownloadState() {
const currentInstanceId = instanceId.value
if (!scannerActive || !currentInstanceId) return
const generation = ++reconciliationGeneration
const hasWorldCandidates = candidateItems.value.some((item) => item.projectType === 'world')
const [pending, snapshot, worlds] = await Promise.all([
listPendingCurseForgeManualDownloads(currentInstanceId),
get_content_snapshot(currentInstanceId),
hasWorldCandidates ? get_instance_worlds(currentInstanceId) : Promise.resolve([]),
])
if (
!scannerActive ||
currentInstanceId !== instanceId.value ||
generation !== reconciliationGeneration
) {
return
}
const pendingByKey = new Map(pending.map((item) => [itemKey(item), item]))
const candidateByKey = new Map(candidateItems.value.map((item) => [itemKey(item), item]))
for (const [key, item] of pendingByKey) candidateByKey.set(key, item)
const nextCandidates = [...candidateByKey.values()]
const nextItems = nextCandidates.map((item) => pendingByKey.get(itemKey(item)) ?? item)
const materializedByKey = new Map<string, string>()
for (const item of snapshot.items) {
if (
item.provider === 'curseforge' &&
item.providerProjectId != null &&
item.providerReleaseId != null &&
item.materializationState === 'present' &&
item.content != null
) {
materializedByKey.set(
`${item.providerProjectId}:${item.providerReleaseId}`,
item.expectedRelativePath,
)
}
}
const nextImported = new Set<string>()
const nextInconsistent = new Set<string>()
const newlyImported: CurseForgeManualDownloadImport[] = []
for (const item of nextItems) {
const key = itemKey(item)
if (pendingByKey.has(key)) continue
if (item.projectType === 'world') {
const worldName = item.fileName.replace(/\.zip$/i, '')
const importedWorld = worlds.some(
(world) => world.type === 'singleplayer' && world.path === worldName,
)
if (importedWorld) {
nextImported.add(key)
if (!importedKeys.value.has(key)) {
newlyImported.push({
projectId: item.projectId,
fileId: item.fileId,
relativePath: `saves/${worldName}`,
})
}
continue
}
}
const relativePath = materializedByKey.get(key)
if (relativePath == null) {
nextInconsistent.add(key)
continue
}
nextImported.add(key)
if (!importedKeys.value.has(key)) {
newlyImported.push({ projectId: item.projectId, fileId: item.fileId, relativePath })
}
}
candidateItems.value = nextCandidates
items.value = nextItems
importedKeys.value = nextImported
inconsistentKeys.value = nextInconsistent
errorKeys.value = new Set([...errorKeys.value].filter((key) => pendingByKey.has(key)))
if (newlyImported.length > 0) emit('imported', currentInstanceId, newlyImported)
}
function hide() {
modal.value?.hide()
}
function stopScanning() {
scannerActive = false
scanGeneration += 1
reconciliationGeneration += 1
if (scanInterval != null) {
clearInterval(scanInterval)
scanInterval = null
}
}
async function scanDownloads(): Promise<void> {
if (!scannerEnabled.value) return
if (scanInFlight) {
await scanInFlight.catch(() => undefined)
return
}
const currentInstanceId = instanceId.value
if (!currentInstanceId) return
const generation = scanGeneration
if (remainingCount.value === 0) return
scanning.value = true
const operation = importCurseForgeManualDownloads(currentInstanceId, scanDirectory.value)
scanInFlight = operation
try {
const result = await operation
if (generation !== scanGeneration) return
scanError.value = false
downloadDirectory.value = result.downloadDirectory ?? null
errorKeys.value = new Set(result.errors.map((item) => `${item.projectId}:${item.fileId}`))
await reconcileManualDownloadState().catch(handleError)
} catch {
if (generation !== scanGeneration) return
scanError.value = true
errorKeys.value = new Set(
items.value
.filter((item) => !isImported(item) && !inconsistentKeys.value.has(itemKey(item)))
.map((item) => itemKey(item)),
)
} finally {
if (scanInFlight === operation) scanInFlight = undefined
if (generation === scanGeneration) {
scanning.value = false
} else if (!scanInFlight) {
scanning.value = false
}
}
}
async function openOne(item: CurseForgeManualDownloadItem) {
await openUrl(getCurseForgeManualDownloadUrl(item))
}
async function chooseLocalFile(item: CurseForgeManualDownloadItem) {
const currentInstanceId = instanceId.value
const key = itemKey(item)
if (!currentInstanceId || busyKeys.value.has(key)) return
const selected = await open({ multiple: false })
const sourcePath = typeof selected === 'string' ? selected : null
if (!sourcePath) return
busyKeys.value = new Set(busyKeys.value).add(key)
try {
await importPendingCurseForgeManualDownloadFile(
currentInstanceId,
item.projectId,
item.fileId,
sourcePath,
)
await reconcileManualDownloadState().catch(handleError)
} catch (error) {
const errorKind = classifyCurseForgeManualDownloadImportError(error)
if (errorKind === 'verification_failed') {
const nextErrors = new Set(errorKeys.value)
nextErrors.add(key)
errorKeys.value = nextErrors
const nextInconsistent = new Set(inconsistentKeys.value)
nextInconsistent.delete(key)
inconsistentKeys.value = nextInconsistent
} else if (errorKind === 'not_pending') {
const nextErrors = new Set(errorKeys.value)
nextErrors.delete(key)
errorKeys.value = nextErrors
await reconcileManualDownloadState().catch(handleError)
} else {
handleError(error)
}
} finally {
const nextBusy = new Set(busyKeys.value)
nextBusy.delete(key)
busyKeys.value = nextBusy
}
}
async function openAll() {
for (const item of items.value) {
if (isImported(item)) continue
await openOne(item)
}
}
function goToInstance() {
if (!instanceId.value) return
hide()
emit('view-instance', instanceId.value)
}
onMounted(() => {
void instance_listener(async (event: { event: string; instance_id: string }) => {
if (
event.event === 'content_changed' &&
event.instance_id === instanceId.value &&
scannerActive
) {
await reconcileManualDownloadState().catch(() => {
scanError.value = true
})
}
})
.then((unlisten) => {
unlistenInstances = unlisten
})
.catch(() => undefined)
})
onUnmounted(() => {
stopScanning()
unlistenInstances?.()
})
defineExpose({
show,
hide,
})
</script>
@@ -0,0 +1,274 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true">
<div v-if="requiredContentProject" class="flex flex-col gap-6 max-w-[500px]">
<Admonition type="info" :header="formatMessage(messages.contentRequired)">
{{ formatMessage(messages.serverRequiresMods) }}
</Admonition>
<div class="flex flex-col gap-1">
<div class="flex justify-between items-center">
<span class="font-semibold text-contrast">{{
formatMessage(messages.requiredModpack)
}}</span>
<ButtonStyled type="transparent">
<button @click="openViewContents">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3">
<Avatar
:src="requiredContentProject.icon_url"
:alt="requiredContentProject.title"
size="48px"
/>
<div class="flex flex-col gap-0.5">
<span class="font-semibold text-contrast">
<template v-if="usingCustomModpack && modpackVersion">
{{ modpackVersion.name }}
</template>
<template v-else>
{{ requiredContentProject.title }}
</template>
</span>
<span class="text-sm text-secondary">
{{ loaderDisplay }} {{ requiredContentProject.game_versions?.[0] }}
<template v-if="modCount">
· {{ formatMessage(messages.modCount, { count: modCount }) }}
</template>
</span>
</div>
</div>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled>
<button @click="handleDecline">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleAccept">
<DownloadIcon />
{{ formatMessage(messages.installButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
<ModpackContentModal
ref="modpackContentModal"
:modpack-name="project?.name ?? ''"
:modpack-icon-url="project?.icon_url ?? undefined"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, EyeIcon, XIcon } from '@modrinth/assets'
import type { ContentItem } from '@modrinth/ui'
import {
Admonition,
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
formatLoader,
ModpackContentModal,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { injectServerInstall } from '@/providers/server-install'
const modal = ref<InstanceType<typeof NewModal>>()
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Labrinth.Versions.v2.Version | null>(null)
const project = ref<Labrinth.Projects.v3.Project | null>(null)
const requiredContentProject = ref<Labrinth.Projects.v2.Project | null>(null)
const onInstallComplete = ref<() => void>(() => {})
const { formatMessage } = useVIntl()
const { installServerProject, startInstallingServer, stopInstallingServer } = injectServerInstall()
const usingCustomModpack = computed(() => {
return requiredContentProject.value?.id === project.value?.id
})
const loaderDisplay = computed(() => {
const loader = requiredContentProject.value?.loaders?.[0]
if (!loader) return ''
return formatLoader(formatMessage, loader)
})
const modCount = computed(() => modpackVersion.value?.dependencies?.length)
async function fetchData(versionId: string) {
// cache is making version null for some reason so bypassing for now
modpackVersion.value = await get_version(versionId, 'bypass')
if (modpackVersion.value?.project_id) {
requiredContentProject.value = await get_project(modpackVersion.value.project_id, 'bypass')
}
}
async function handleAccept() {
hide()
const serverProjectId = project.value?.id
startInstallingServer(serverProjectId)
try {
await installServerProject(serverProjectId)
onInstallComplete.value()
} catch (error) {
console.error('Failed to install server project from InstallToPlayModal:', error)
} finally {
stopInstallingServer(serverProjectId)
}
}
function handleDecline() {
hide()
}
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
async function openViewContents() {
modpackContentModal.value?.showLoading()
try {
// Ensure version data is available — the useQuery may not have resolved yet
const versionId = modpackVersionId.value
const version =
modpackVersion.value ?? (versionId ? await get_version(versionId, 'must_revalidate') : null)
const deps = version?.dependencies ?? []
const projectIds = deps
.map((d: { project_id?: string }) => d.project_id)
.filter((id: string | undefined): id is string => !!id)
const versionIds = deps
.map((d: { version_id?: string }) => d.version_id)
.filter((id: string | undefined): id is string => !!id)
const projects: Labrinth.Projects.v2.Project[] =
projectIds.length > 0 ? await get_project_many(projectIds, 'must_revalidate') : []
const versions: Labrinth.Versions.v2.Version[] =
versionIds.length > 0 ? await get_version_many(versionIds, 'must_revalidate') : []
const projectMap = new Map(projects.map((p: Labrinth.Projects.v2.Project) => [p.id, p]))
const contentItems: ContentItem[] = deps.map(
(dep: Labrinth.Versions.v2.Dependency): ContentItem => {
const depProject = dep.project_id ? projectMap.get(dep.project_id) : null
// @ts-expect-error - version_id is missing from the type for some reason
const depVersion = dep.version_id
? // @ts-expect-error - version_id is missing from the type for some reason
versions.find((v: Labrinth.Versions.v2.Version) => v.id === dep.version_id)
: null
return {
id: dep.file_name ?? dep.project_id ?? 'unknown',
file_name: dep.file_name ?? depProject?.title ?? 'Unknown',
project_type: depProject?.project_type ?? 'mod',
update: null,
origin_provider: null,
provider_refs: [],
enabled: true,
project: {
id: depProject?.id ?? dep.project_id ?? dep.file_name ?? 'unknown',
slug: depProject?.slug ?? dep.project_id ?? 'unknown',
title: depProject?.title ?? dep.file_name ?? 'Unknown',
icon_url: depProject?.icon_url ?? undefined,
},
...(depVersion
? {
version: {
id: depVersion.id,
file_name: depVersion.files?.[0]?.filename ?? dep.file_name,
version_number: depVersion.version_number ?? undefined,
date_published: depVersion.date_published ?? undefined,
},
}
: {}),
}
},
)
modpackContentModal.value?.show(contentItems)
} catch (err) {
console.error('Failed to load modpack contents:', err)
modpackContentModal.value?.show([])
}
}
async function show(
projectVal: Labrinth.Projects.v3.Project,
modpackVersionIdVal: string | null = null,
callback: () => void = () => {},
e?: MouseEvent,
) {
project.value = projectVal
modpackVersionId.value = modpackVersionIdVal
modpackVersion.value = null
requiredContentProject.value = null
onInstallComplete.value = callback
if (modpackVersionIdVal) await fetchData(modpackVersionIdVal)
modal.value?.show(e)
}
function hide() {
modal.value?.hide()
}
const messages = defineMessages({
installToPlay: {
id: 'app.modal.install-to-play.header',
defaultMessage: 'Install to play',
},
sharedServerInstance: {
id: 'app.modal.install-to-play.shared-server-instance',
defaultMessage: 'Shared server instance',
},
contentRequired: {
id: 'app.modal.install-to-play.content-required',
defaultMessage: 'Content required',
},
serverRequiresMods: {
id: 'app.modal.install-to-play.server-requires-mods',
defaultMessage:
'This server requires mods to play. Click Install to set up the required files from Modrinth, then launch directly into the server.',
},
requiredModpack: {
id: 'app.modal.install-to-play.required-modpack',
defaultMessage: 'Required modpack',
},
sharedInstance: {
id: 'app.modal.install-to-play.shared-instance',
defaultMessage: 'Shared instance',
},
modCount: {
id: 'app.modal.install-to-play.mod-count',
defaultMessage: '{count, plural, one {# mod} other {# mods}}',
},
installButton: {
id: 'app.modal.install-to-play.install-button',
defaultMessage: 'Install',
},
viewContents: {
id: 'app.modal.install-to-play.view-contents',
defaultMessage: 'View contents',
},
})
defineExpose({ show, hide })
</script>
@@ -0,0 +1,374 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
:on-hide="handleHide"
width="min(760px, calc(100vw - 2rem))"
max-width="760px"
scrollable
>
<div class="grid gap-6 md:grid-cols-[220px_minmax(0,1fr)]">
<div class="flex flex-col items-center gap-4">
<div
class="flex aspect-square w-44 items-center justify-center overflow-hidden rounded-3xl shadow-lg"
:style="selectedBackground.style"
>
<img :src="selectedIcon.url" alt="" class="h-[72%] w-[72%] object-contain" />
</div>
<p class="m-0 text-center text-sm text-secondary">
{{ formatMessage(messages.description) }}
</p>
<ButtonStyled type="outlined">
<button :disabled="saving" @click="surpriseMe">
<RefreshCwIcon />
{{ formatMessage(messages.surpriseMe) }}
</button>
</ButtonStyled>
</div>
<div class="flex min-w-0 flex-col gap-5">
<section class="flex flex-col gap-2.5">
<h2 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.background) }}
</h2>
<div class="flex flex-wrap gap-2">
<button
v-for="background in backgrounds"
:key="background.id"
type="button"
class="h-10 w-10 cursor-pointer rounded-xl border-2 border-solid transition-transform hover:scale-105 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand disabled:cursor-wait disabled:opacity-60"
:class="
background.id === selectedBackgroundId
? 'border-contrast shadow-md'
: 'border-transparent'
"
:style="background.style"
:aria-label="formatMessage(background.name)"
:aria-pressed="background.id === selectedBackgroundId"
:disabled="saving"
@click="selectedBackgroundId = background.id"
/>
</div>
</section>
<section v-for="group in iconGroups" :key="group.id" class="flex min-w-0 flex-col gap-2.5">
<h2 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(group.name) }}
</h2>
<div class="grid grid-cols-4 gap-2 sm:grid-cols-6">
<button
v-for="icon in group.icons"
:key="icon.id"
type="button"
class="group flex min-w-0 cursor-pointer flex-col items-center gap-1.5 rounded-xl border border-solid bg-surface-2 p-2 text-secondary transition-colors hover:border-brand hover:bg-brand-highlight hover:text-contrast focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand disabled:cursor-wait disabled:opacity-60"
:class="
icon.id === selectedIconId
? 'border-brand bg-brand-highlight text-contrast'
: 'border-surface-5'
"
:aria-label="formatMessage(icon.name)"
:aria-pressed="icon.id === selectedIconId"
:disabled="saving"
@click="selectedIconId = icon.id"
>
<img :src="icon.url" alt="" class="aspect-square w-full object-contain" />
<span class="w-full truncate text-center text-xs font-semibold">
{{ formatMessage(icon.name) }}
</span>
</button>
</div>
</section>
</div>
</div>
<template #actions>
<div class="flex w-full items-center justify-between gap-2">
<ButtonStyled type="outlined">
<button :disabled="saving" @click="selectUploadedIcon">
<UploadIcon />
{{ formatMessage(messages.upload) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="saving" @click="saveGeneratedIcon">
<SpinnerIcon v-if="saving" class="animate-spin" />
<SaveIcon v-else />
{{ formatMessage(saving ? messages.saving : messages.useIcon) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { RefreshCwIcon, SaveIcon, SpinnerIcon, UploadIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessage,
defineMessages,
injectNotificationManager,
type MessageDescriptor,
NewModal,
type PickedFile,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref, useTemplateRef } from 'vue'
import { cache_icon } from '@/helpers/instance'
import { builtInInstanceIcons, modrinth3DInstanceIcons } from '@/helpers/instance-icons'
import { pickImage } from '@/providers/setup/file-picker'
interface IconBackground {
id: string
name: MessageDescriptor
colors: [string, string] | null
style: Record<string, string>
}
function background(
id: string,
name: MessageDescriptor,
top: string,
bottom: string,
): IconBackground {
return {
id,
name,
colors: [top, bottom],
style: { backgroundImage: `linear-gradient(145deg, ${top}, ${bottom})` },
}
}
const backgroundNames = defineMessages({
transparent: {
id: 'app.instance.icon-picker.background.transparent',
defaultMessage: 'Transparent',
},
grass: { id: 'app.instance.icon-picker.background.grass', defaultMessage: 'Grass' },
ocean: { id: 'app.instance.icon-picker.background.ocean', defaultMessage: 'Ocean' },
amethyst: { id: 'app.instance.icon-picker.background.amethyst', defaultMessage: 'Amethyst' },
sunset: { id: 'app.instance.icon-picker.background.sunset', defaultMessage: 'Sunset' },
cherry: { id: 'app.instance.icon-picker.background.cherry', defaultMessage: 'Cherry' },
nether: { id: 'app.instance.icon-picker.background.nether', defaultMessage: 'Nether' },
slime: { id: 'app.instance.icon-picker.background.slime', defaultMessage: 'Slime' },
deepDark: { id: 'app.instance.icon-picker.background.deep-dark', defaultMessage: 'Deep Dark' },
stone: { id: 'app.instance.icon-picker.background.stone', defaultMessage: 'Stone' },
midnight: { id: 'app.instance.icon-picker.background.midnight', defaultMessage: 'Midnight' },
})
const backgrounds = [
{
id: 'transparent',
name: backgroundNames.transparent,
colors: null,
style: {
backgroundColor: '#ffffff',
backgroundImage:
'linear-gradient(45deg, #d7d9de 25%, transparent 25%), linear-gradient(-45deg, #d7d9de 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d7d9de 75%), linear-gradient(-45deg, transparent 75%, #d7d9de 75%)',
backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0',
backgroundSize: '16px 16px',
},
} satisfies IconBackground,
background('grass', backgroundNames.grass, '#7fc95b', '#2f7d32'),
background('ocean', backgroundNames.ocean, '#55b7ff', '#3157c8'),
background('amethyst', backgroundNames.amethyst, '#c084fc', '#6d28d9'),
background('sunset', backgroundNames.sunset, '#ffba52', '#e94b64'),
background('cherry', backgroundNames.cherry, '#ff9ec4', '#b93670'),
background('nether', backgroundNames.nether, '#ef5a46', '#6d1717'),
background('slime', backgroundNames.slime, '#b6ee55', '#44972f'),
background('deep-dark', backgroundNames.deepDark, '#245369', '#0c1f2b'),
background('stone', backgroundNames.stone, '#aeb5bd', '#525b66'),
background('midnight', backgroundNames.midnight, '#4d5f8f', '#171b2d'),
]
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const iconOptions = [...builtInInstanceIcons, ...modrinth3DInstanceIcons]
const iconGroups = [
{
id: 'original',
name: defineMessage({
id: 'app.instance.icon-picker.group.original',
defaultMessage: 'Original icons',
}),
icons: builtInInstanceIcons,
},
{
id: 'modrinth-3d',
name: defineMessage({
id: 'app.instance.icon-picker.group.modrinth-3d',
defaultMessage: 'Modrinth 3D icons',
}),
icons: modrinth3DInstanceIcons,
},
]
const selectedBackgroundId = ref(backgrounds[0].id)
const selectedIconId = ref(builtInInstanceIcons[0].id)
const saving = ref(false)
let resolveSelection: ((selection: PickedFile | null) => void) | null = null
const selectedBackground = computed(
() => backgrounds.find((item) => item.id === selectedBackgroundId.value) ?? backgrounds[0],
)
const selectedIcon = computed(
() => iconOptions.find((icon) => icon.id === selectedIconId.value) ?? builtInInstanceIcons[0],
)
const messages = defineMessages({
title: {
id: 'app.instance.icon-picker.title',
defaultMessage: 'Create an instance icon',
},
description: {
id: 'app.instance.icon-picker.description',
defaultMessage: 'Combine a background and a Minecraft element, or upload your own image.',
},
background: {
id: 'app.instance.icon-picker.background',
defaultMessage: 'Background',
},
surpriseMe: {
id: 'app.instance.icon-picker.surprise-me',
defaultMessage: 'Surprise me',
},
upload: {
id: 'app.instance.icon-picker.upload',
defaultMessage: 'Upload image',
},
useIcon: {
id: 'app.instance.icon-picker.use-icon',
defaultMessage: 'Use this icon',
},
saving: {
id: 'app.instance.icon-picker.saving',
defaultMessage: 'Saving...',
},
loadError: {
id: 'app.instance.icon-picker.load-error',
defaultMessage: 'Failed to load the bundled icon.',
},
})
function finish(selection: PickedFile | null) {
const resolve = resolveSelection
resolveSelection = null
modal.value?.hide()
resolve?.(selection)
}
function handleHide() {
saving.value = false
if (resolveSelection) {
resolveSelection(null)
resolveSelection = null
}
}
function surpriseMe() {
if (backgrounds.length > 1) {
const candidates = backgrounds.filter((item) => item.id !== selectedBackgroundId.value)
selectedBackgroundId.value = candidates[Math.floor(Math.random() * candidates.length)].id
}
if (iconOptions.length > 1) {
const candidates = iconOptions.filter((icon) => icon.id !== selectedIconId.value)
selectedIconId.value = candidates[Math.floor(Math.random() * candidates.length)].id
}
}
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error(formatMessage(messages.loadError)))
image.src = url
})
}
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob)
else reject(new Error(formatMessage(messages.loadError)))
}, 'image/png')
})
}
async function renderGeneratedIcon(): Promise<Blob> {
if (!selectedBackground.value.colors) {
const response = await fetch(selectedIcon.value.url)
if (!response.ok) throw new Error(formatMessage(messages.loadError))
return await response.blob()
}
const size = 512
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const context = canvas.getContext('2d')
if (!context) throw new Error(formatMessage(messages.loadError))
if (selectedBackground.value.colors) {
const gradient = context.createLinearGradient(0, 0, size, size)
gradient.addColorStop(0, selectedBackground.value.colors[0])
gradient.addColorStop(1, selectedBackground.value.colors[1])
context.fillStyle = gradient
context.fillRect(0, 0, size, size)
}
const symbol = await loadImage(selectedIcon.value.url)
const maxSymbolSize = size * 0.72
const scale = Math.min(maxSymbolSize / symbol.naturalWidth, maxSymbolSize / symbol.naturalHeight)
const width = symbol.naturalWidth * scale
const height = symbol.naturalHeight * scale
context.drawImage(symbol, (size - width) / 2, (size - height) / 2, width, height)
return await canvasToBlob(canvas)
}
async function saveGeneratedIcon() {
if (saving.value) return
saving.value = true
try {
const blob = await renderGeneratedIcon()
const fileName = `generated-${selectedBackgroundId.value}-${selectedIconId.value}.png`
const bytes = Array.from(new Uint8Array(await blob.arrayBuffer()))
const path = await cache_icon(fileName, bytes)
finish({
file: new File([blob], fileName, { type: 'image/png' }),
path,
previewUrl: convertFileSrc(path),
frameless: selectedBackgroundId.value === 'transparent',
})
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
async function selectUploadedIcon() {
try {
const selection = await pickImage()
if (selection) finish(selection)
} catch (error) {
handleError(error)
}
}
function show(): Promise<PickedFile | null> {
resolveSelection?.(null)
const modalInstance = modal.value
if (!modalInstance) return Promise.resolve(null)
return new Promise((resolve) => {
resolveSelection = resolve
modalInstance.show()
})
}
defineExpose({ show })
</script>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import type { GameInstance } from '@/helpers/types'
defineProps<{
instance: GameInstance
}>()
</script>
<template>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
<InstanceIcon
:icon-path="instance.icon_path"
:instance-id="instance.id"
:loader="instance.loader"
size="24px"
/>
{{ instance.name }} <ChevronRightIcon />
</span>
</template>
@@ -0,0 +1,208 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
ChevronRightIcon,
CodeIcon,
CoffeeIcon,
FileArchiveIcon,
InfoIcon,
MonitorIcon,
WrenchIcon,
} from '@modrinth/assets'
import {
commonMessages,
defineMessage,
TabbedModal,
type TabbedModalTab,
useVIntl,
} from '@modrinth/ui'
import type { PlatformTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref, watch } from 'vue'
import CoreComponentsSettings from '@/components/ui/instance_settings/CoreComponentsSettings.vue'
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { get_project_v3 } from '@/helpers/cache'
import { get_linked_modpack_info } from '@/helpers/instance'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import { provideInstanceSettings } from '@/providers/instance-settings'
import type { GameInstance } from '../../../helpers/types'
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const props = defineProps<{
instance: GameInstance
offline?: boolean
}>()
const emit = defineEmits<{
unlinked: []
}>()
const isMinecraftServer = ref(false)
const handleUnlinked = () => emit('unlinked')
let serverMetadataGeneration = 0
const instanceRef = computed(() => props.instance)
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
function hide() {
tabbedModal.value?.hide()
}
provideInstanceSettings({
instance: instanceRef,
offline: props.offline,
isMinecraftServer,
onUnlinked: handleUnlinked,
closeModal: hide,
})
watch(
() => props.instance,
(instance) => {
const generation = ++serverMetadataGeneration
isMinecraftServer.value = false
if (instance.install_stage === 'installed' && instance.link?.project_id) {
const instanceId = instance.id
get_project_v3(instance.link.project_id, 'must_revalidate')
.then((project: Labrinth.Projects.v3.Project | undefined) => {
if (
generation === serverMetadataGeneration &&
props.instance.id === instanceId &&
props.instance.install_stage === 'installed' &&
project?.minecraft_server != null
) {
isMinecraftServer.value = true
}
})
.catch(() => {})
}
},
{ immediate: true },
)
const tabs = computed<TabbedModalTab[]>(() => [
{
name: defineMessage({
id: 'instance.settings.tabs.general',
defaultMessage: 'General',
}),
icon: InfoIcon,
content: GeneralSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.installation',
defaultMessage: 'Installation',
}),
icon: WrenchIcon,
content: InstallationSettings,
},
{
// Core component editing is instance-specific and advanced, so it is intentionally excluded from first-run onboarding.
name: defineMessage({
id: 'instance.settings.tabs.core-components',
defaultMessage: 'Core components',
}),
icon: FileArchiveIcon,
content: CoreComponentsSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.window',
defaultMessage: 'Window',
}),
icon: MonitorIcon,
content: WindowSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.java',
defaultMessage: 'Java and memory',
}),
icon: CoffeeIcon,
content: JavaSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.hooks',
defaultMessage: 'Launch preparation',
}),
icon: CodeIcon,
content: HooksSettings,
},
])
function getSupportedModpackLoaders() {
return get_loaders().then((value: PlatformTag[]) =>
value
.filter((item) => item.supported_project_types.includes('modpack') || item.name === 'vanilla')
.sort((a, b) => (a.name === 'vanilla' ? -1 : b.name === 'vanilla' ? 1 : 0)),
)
}
// Preload metadata that is not scoped to an editable Minecraft version.
useQuery({
queryKey: ['instance-settings', 'game-versions'],
queryFn: get_game_versions,
})
useQuery({
queryKey: ['instance-settings', 'loaders', 'modpack'],
queryFn: getSupportedModpackLoaders,
})
useQuery({
queryKey: computed(() => ['linkedModpackInfo', props.instance.id]),
queryFn: () => get_linked_modpack_info(props.instance.id, 'stale_while_revalidate'),
enabled: computed(
() =>
props.instance.install_stage === 'installed' &&
!!props.instance.link?.project_id &&
!props.offline,
),
})
function show(tabIndex?: number) {
if (props.instance.install_stage === 'installed' && props.instance.link?.project_id) {
queryClient.prefetchQuery({
queryKey: ['linkedModpackInfo', props.instance.id],
queryFn: () => get_linked_modpack_info(props.instance.id, 'stale_while_revalidate'),
})
}
tabbedModal.value?.show()
if (tabIndex !== undefined) {
nextTick(() => tabbedModal.value?.setTab(tabIndex))
}
}
defineExpose({ show, hide })
</script>
<template>
<TabbedModal
ref="tabbedModal"
:tabs="tabs"
:max-width="'min(928px, calc(95vw - 10rem))'"
:width="'min(928px, calc(95vw - 10rem))'"
>
<template #title>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
<InstanceIcon
:icon-path="instance.icon_path"
:instance-id="props.instance.id"
:loader="instance.loader"
size="24px"
/>
{{ instance.name }} <ChevronRightIcon />
<span class="font-extrabold text-contrast">{{
formatMessage(commonMessages.settingsLabel)
}}</span>
</span>
</template>
</TabbedModal>
</template>
@@ -0,0 +1,147 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title, { version })"
:on-hide="handleHide"
:disable-close="responding"
fade="warning"
max-width="520px"
>
<div class="flex flex-col gap-3">
<p class="m-0 leading-relaxed text-contrast">
{{ formatMessage(messages.body, { version }) }}
</p>
<p class="m-0 leading-relaxed text-secondary">
{{ formatMessage(messages.laterDescription, { version }) }}
</p>
</div>
<template #actions>
<div class="flex flex-wrap justify-end gap-2">
<ButtonStyled type="outlined">
<button type="button" :disabled="responding" @click="modal?.hide()">
<ClockIcon aria-hidden="true" />
{{ formatMessage(messages.setUpLater) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" :disabled="responding" @click="confirmDownload">
<SpinnerIcon v-if="responding" class="animate-spin" aria-hidden="true" />
<DownloadIcon v-else aria-hidden="true" />
{{ formatMessage(messages.download) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { ClockIcon, DownloadIcon, SpinnerIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { onUnmounted, ref, useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
import { respond_to_java_download_confirmation } from '@/helpers/jre'
interface JavaDownloadConfirmationRequest {
requestId: string
version: number
}
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const router = useRouter()
const messages = defineMessages({
title: {
id: 'app.java-download-confirmation.title',
defaultMessage: 'Download Java {version}?',
},
body: {
id: 'app.java-download-confirmation.body',
defaultMessage:
'No compatible Java {version} installation was found. You can download it now or postpone Java setup while the remaining game resources continue downloading.',
},
laterDescription: {
id: 'app.java-download-confirmation.later-description',
defaultMessage:
'The instance will finish installing, but Java {version} must be configured before you can play.',
},
setUpLater: {
id: 'app.java-download-confirmation.set-up-later',
defaultMessage: 'Set up later',
},
download: {
id: 'app.java-download-confirmation.download',
defaultMessage: 'Download Java',
},
postponedTitle: {
id: 'app.java-download-confirmation.postponed-title',
defaultMessage: 'Java setup postponed',
},
postponedBody: {
id: 'app.java-download-confirmation.postponed-body',
defaultMessage:
'The instance will finish installing. Configure Java {version} before launching it.',
},
})
const modal = useTemplateRef('modal')
const request = ref<JavaDownloadConfirmationRequest | null>(null)
const version = ref(0)
const responding = ref(false)
let decisionSent = false
function show(payload: JavaDownloadConfirmationRequest) {
request.value = payload
version.value = payload.version
responding.value = false
decisionSent = false
modal.value?.show()
}
function handleHide() {
const pendingRequest = request.value
request.value = null
responding.value = false
if (!pendingRequest || decisionSent) return
decisionSent = true
void respond_to_java_download_confirmation(pendingRequest.requestId, false)
addNotification({
title: formatMessage(messages.postponedTitle),
text: formatMessage(messages.postponedBody, { version: pendingRequest.version }),
type: 'warning',
})
}
async function confirmDownload() {
const pendingRequest = request.value
if (!pendingRequest || responding.value) return
responding.value = true
decisionSent = true
const response = respond_to_java_download_confirmation(pendingRequest.requestId, true)
modal.value?.hide()
await router.push('/downloads')
await response
}
onUnmounted(() => {
const pendingRequest = request.value
if (!pendingRequest || decisionSent) return
decisionSent = true
void respond_to_java_download_confirmation(pendingRequest.requestId, false)
})
defineExpose({ show })
</script>
@@ -0,0 +1,602 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
:fade="remaining > 0 ? 'warning' : 'standard'"
:on-hide="stopScanning"
max-width="760px"
scrollable
>
<div class="flex flex-col gap-4">
<Admonition
:type="continuing || remaining === 0 ? 'success' : 'warning'"
:header="
continuing
? formatMessage(messages.continuing)
: formatMessage(messages.remaining, { count: remaining })
"
>
<template #icon>
<SpinnerIcon v-if="loading || continuing" class="size-5 animate-spin" />
<CheckIcon v-else-if="remaining === 0" class="size-5 text-green" />
<DownloadIcon v-else class="size-5" />
</template>
{{ continuing ? formatMessage(messages.continuingBody) : formatMessage(messages.body) }}
</Admonition>
<Admonition
v-if="!continuing && remaining > 0"
:type="scannerPresentation.phase === 'rejected' ? 'warning' : 'info'"
:header="scannerHeader"
>
<template #icon>
<SpinnerIcon
v-if="
scannerPresentation.phase === 'importing' ||
scannerPresentation.phase === 'verifying' ||
scannerPresentation.phase === 'waiting_for_stability'
"
class="size-5 animate-spin"
/>
<FolderSearchIcon v-else class="size-5" />
</template>
{{ scannerStatus }}
</Admonition>
<div
v-if="files.length"
class="flex flex-col overflow-hidden rounded-lg border border-surface-5"
>
<div
v-for="file in files"
:key="file.itemId"
class="flex flex-col gap-3 border-0 border-b border-solid border-surface-5 bg-surface-2 p-4 last:border-b-0"
>
<div class="flex min-w-0 items-start justify-between gap-3">
<div class="min-w-0">
<div class="break-all font-medium text-contrast">{{ file.path }}</div>
<div class="mt-1 flex flex-wrap gap-2 text-sm text-secondary">
<span>{{
formatMessage(messages.expectedSize, { size: formatBytes(file.expectedSize) })
}}</span>
<span>·</span>
<span>{{ attemptText(file) }}</span>
<span v-if="file.browserUrls.length > 1">·</span>
<span v-if="file.browserUrls.length > 1">
{{ formatMessage(messages.fallbacks, { count: file.browserUrls.length - 1 }) }}
</span>
</div>
<div v-if="file.browserUrls[0]" class="mt-1 truncate text-xs text-secondary">
<code v-tooltip="file.browserUrls[0]">{{ file.browserUrls[0] }}</code>
</div>
<div v-if="file.lastError" class="mt-1 text-sm text-red">
{{ file.lastError }}
</div>
<div v-if="scannerItemStatus(file.itemId)" class="mt-1 text-sm text-orange">
{{ scannerItemStatus(file.itemId) }}
</div>
</div>
<Badge :color="statusColor(file.status)" :type="statusLabel(file.status)" />
</div>
<div class="flex flex-wrap gap-2">
<ButtonStyled color="brand" size="small">
<button :disabled="isBusy(file.itemId)" @click="retryOne(file.itemId)">
<SpinnerIcon v-if="isBusy(file.itemId)" class="animate-spin" />
<RefreshCwIcon v-else />
{{ formatMessage(messages.retry) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="file.browserUrls.length" type="outlined" size="small">
<button :disabled="isBusy(file.itemId)" @click="openBrowser(file.browserUrls[0])">
<ExternalIcon />{{ formatMessage(messages.browserDownload) }}
</button>
</ButtonStyled>
<ButtonStyled type="outlined" size="small">
<button :disabled="isBusy(file.itemId)" @click="selectLocal(file.itemId)">
<UploadIcon />{{ formatMessage(messages.chooseFile) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
<template #actions>
<div class="flex flex-wrap justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="modal?.hide()">{{ formatMessage(commonMessages.closeButton) }}</button>
</ButtonStyled>
<ButtonStyled v-if="remaining > 0" color="brand">
<button :disabled="loading || busy.size > 0" @click="retryAll">
<RefreshCwIcon />{{ formatMessage(messages.retryAll) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import {
CheckIcon,
DownloadIcon,
ExternalIcon,
FolderSearchIcon,
RefreshCwIcon,
SpinnerIcon,
UploadIcon,
} from '@modrinth/assets'
import {
Admonition,
Badge,
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
useFormatBytes,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, onUnmounted, ref } from 'vue'
import {
createDownloadsScanLoop,
createDownloadsScannerPresentationState,
getMissingContentScannerSettings,
reduceDownloadsScannerPresentation,
} from '@/helpers/downloads-scanner'
import { install_job_listener } from '@/helpers/events'
import {
install_job_get,
install_job_import_missing_file,
install_job_missing_files,
install_job_resume,
install_job_retry_missing_file,
install_job_scan_missing_files,
type InstallJobSnapshot,
type MissingModpackContentView,
} from '@/helpers/install'
type MissingFile = MissingModpackContentView['files'][number]
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const { handleError } = injectNotificationManager()
const modal = ref<InstanceType<typeof NewModal>>()
const jobId = ref<string | null>(null)
const content = ref<MissingModpackContentView>({ remaining: 0, files: [] })
const loading = ref(false)
const continuing = ref(false)
const busy = ref(new Set<string>())
const scannerPresentation = ref(createDownloadsScannerPresentationState())
const scannerErrors = ref(new Map<string, string>())
const scannerEnabled = ref(true)
const scanDirectory = ref<string | null>(null)
let unlisten: (() => void) | null = null
const files = computed(() => content.value.files)
const remaining = computed(() => content.value.remaining)
const scannerHeader = computed(() => {
if (!scannerEnabled.value) return formatMessage(messages.automaticImportDisabled)
if (scannerPresentation.value.phase === 'rejected') {
return formatMessage(messages.fileMismatchTitle)
}
if (scannerPresentation.value.phase === 'verifying') {
return formatMessage(messages.verifyingCandidate)
}
if (scannerPresentation.value.phase === 'importing') {
return formatMessage(messages.importingCandidate)
}
if (
scannerPresentation.value.phase === 'monitoring' ||
scannerPresentation.value.phase === 'idle'
) {
return formatMessage(messages.watchingDownloadsTitle)
}
return formatMessage(messages.automaticImport)
})
const scannerStatus = computed(() => {
if (!scannerEnabled.value) return formatMessage(messages.automaticImportDisabledBody)
const state = scannerPresentation.value
if (state.phase === 'importing') return formatMessage(messages.importingCandidateBody)
if (state.phase === 'verifying') return formatMessage(messages.verifyingCandidateBody)
if (state.phase === 'waiting_for_stability') return formatMessage(messages.waitingForCompletion)
if (state.phase === 'rejected') return formatMessage(messages.fileMismatchBody)
if (state.phase === 'imported') {
return formatMessage(messages.importedAutomatically, {
count: state.importedCount,
remaining: remaining.value,
})
}
if (state.phase === 'error') return formatMessage(messages.scannerFailed)
if (state.phase === 'monitoring' && state.downloadDirectory) {
return formatMessage(messages.watchingDirectory, { path: state.downloadDirectory })
}
if (state.phase === 'idle') {
return formatMessage(messages.watchingDownloadsBody)
}
return formatMessage(messages.downloadsUnavailable)
})
const messages = defineMessages({
header: { id: 'app.downloads.missing-content.header', defaultMessage: 'Complete missing files' },
body: {
id: 'app.downloads.missing-content.body',
defaultMessage: 'Each local file is verified before it can replace the required instance file.',
},
remaining: {
id: 'app.downloads.missing-content.remaining',
defaultMessage:
'{count, plural, one {# file still needs to be completed} other {# files still need to be completed}}',
},
continuing: {
id: 'app.downloads.missing-content.continuing',
defaultMessage: 'Continuing installation',
},
continuingBody: {
id: 'app.downloads.missing-content.continuing-body',
defaultMessage:
'All required files are ready. The launcher is verifying them again and continuing installation.',
},
expectedSize: {
id: 'app.downloads.missing-content.expected-size',
defaultMessage: 'Expected size: {size}',
},
fallbacks: {
id: 'app.downloads.missing-content.fallbacks',
defaultMessage: '{count} fallback links',
},
retry: { id: 'app.downloads.missing-content.retry', defaultMessage: 'Retry download' },
retryAll: {
id: 'app.downloads.missing-content.retry-all',
defaultMessage: 'Retry all missing files',
},
browserDownload: {
id: 'app.downloads.missing-content.browser-download',
defaultMessage: 'Browser download',
},
chooseFile: {
id: 'app.downloads.missing-content.choose-file',
defaultMessage: 'Choose local file',
},
attempts: {
id: 'app.downloads.missing-content.attempts',
defaultMessage: 'Attempt {attempt}/{max}',
},
noAttempts: {
id: 'app.downloads.missing-content.no-attempts',
defaultMessage: 'No attempt information',
},
automaticImport: {
id: 'app.downloads.missing-content.automatic-import',
defaultMessage: 'Automatic import from monitored folder',
},
automaticImportDisabled: {
id: 'app.downloads.missing-content.automatic-import-disabled',
defaultMessage: 'Automatic import is disabled',
},
automaticImportDisabledBody: {
id: 'app.downloads.missing-content.automatic-import-disabled-body',
defaultMessage: 'Retry downloading or choose each missing file manually.',
},
verifyingCandidate: {
id: 'app.downloads.missing-content.verifying-candidate',
defaultMessage: 'Verifying downloaded file',
},
verifyingCandidateBody: {
id: 'app.downloads.missing-content.verifying-candidate-body',
defaultMessage: 'Checking that the candidate matches the file required by the modpack.',
},
importingCandidate: {
id: 'app.downloads.missing-content.importing-candidate',
defaultMessage: 'Importing verified file',
},
importingCandidateBody: {
id: 'app.downloads.missing-content.importing-candidate-body',
defaultMessage: 'Adding the verified file to the instance...',
},
watchingDownloadsTitle: {
id: 'app.downloads.missing-content.watching-downloads-title',
defaultMessage: 'Watching the import folder',
},
watchingDownloadsBody: {
id: 'app.downloads.missing-content.watching-downloads-body',
defaultMessage: 'Completed downloads will be verified and imported automatically.',
},
watchingDirectory: {
id: 'app.downloads.missing-content.watching-downloads',
defaultMessage: 'Watching {path}. Matching files are verified before import.',
},
waitingForCompletion: {
id: 'app.downloads.missing-content.waiting-for-completion',
defaultMessage: 'Waiting for a browser download to finish writing...',
},
downloadsUnavailable: {
id: 'app.downloads.missing-content.downloads-unavailable',
defaultMessage: 'The monitored folder is unavailable. Choose a local file instead.',
},
scannerFailed: {
id: 'app.downloads.missing-content.scanner-failed',
defaultMessage: 'Automatic checking failed. Manual file selection is still available.',
},
fileMismatch: {
id: 'app.downloads.missing-content.file-mismatch',
defaultMessage: 'A similarly named file was found, but it did not match this required file.',
},
fileMismatchTitle: {
id: 'app.downloads.missing-content.file-mismatch-title',
defaultMessage: 'A same-named file was found, but file verification failed',
},
fileMismatchBody: {
id: 'app.downloads.missing-content.file-mismatch-body',
defaultMessage:
'The launcher will keep waiting for the correct file. You can also choose a local file manually.',
},
importedAutomatically: {
id: 'app.downloads.missing-content.imported-automatically',
defaultMessage:
'{count, plural, one {# file imported automatically} other {# files imported automatically}}. {remaining, plural, one {# file still needs to be completed} other {# files still need to be completed}}.',
},
})
const statusMessages = defineMessages({
failed: { id: 'app.downloads.item-status.failed', defaultMessage: 'Failed' },
worker_started: { id: 'app.downloads.item-status.worker-started', defaultMessage: 'Starting' },
connecting: { id: 'app.downloads.item-status.connecting', defaultMessage: 'Connecting' },
verifying: { id: 'app.downloads.item-status.verifying', defaultMessage: 'Verifying' },
writing: { id: 'app.downloads.item-status.writing', defaultMessage: 'Writing' },
finalizing: { id: 'app.downloads.item-status.finalizing', defaultMessage: 'Finalizing' },
metadata: { id: 'app.downloads.item-status.metadata', defaultMessage: 'Loading metadata' },
waiting_for_database: {
id: 'app.downloads.item-status.waiting-for-database',
defaultMessage: 'Waiting for database',
},
completed: { id: 'app.downloads.item-status.completed', defaultMessage: 'Completed' },
downloading: { id: 'app.downloads.item-status.downloading', defaultMessage: 'Downloading' },
waiting_for_resource: {
id: 'app.downloads.item-status.waiting-for-resource',
defaultMessage: 'Waiting for download resources',
},
queued: { id: 'app.downloads.status.queued', defaultMessage: 'Queued' },
})
const scanner = createDownloadsScanLoop({
scan: async () => {
if (!jobId.value) throw new Error('Missing install job ID')
return await install_job_scan_missing_files(jobId.value, scanDirectory.value)
},
onResult: (result) => {
content.value = result.content
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
type: 'scan_result',
downloadDirectory: result.downloadDirectory ?? null,
importedItemIds: result.importedItemIds,
rejectedItemIds: result.rejectedItemIds,
pendingCandidates: result.pendingCandidates,
hasErrors: result.errors.length > 0,
items: result.content.files.map((file) => ({ id: file.itemId, status: file.status })),
})
scannerErrors.value = new Map(result.errors.map((error) => [error.itemId, error.message]))
for (const itemId of result.importedItemIds) {
scannerErrors.value.delete(itemId)
}
if (result.job.status !== 'waiting_for_user') {
continuing.value = true
stopScanning()
}
},
onError: () => {
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
type: 'scan_failed',
})
},
intervalMs: 3000,
})
async function show(job: InstallJobSnapshot) {
stopScanning()
const scannerSettings = getMissingContentScannerSettings()
scannerEnabled.value = scannerSettings.enabled
scanDirectory.value = scannerSettings.directory
jobId.value = job.job_id
continuing.value = false
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
type: 'reset',
})
scannerErrors.value = new Map()
content.value = {
remaining: job.items.filter((item) => item.status === 'failed').length,
files: [],
}
modal.value?.show()
await refresh()
if (!unlisten) {
unlisten = await install_job_listener((update: InstallJobSnapshot) => {
if (update.job_id !== jobId.value) return
if (update.status === 'waiting_for_user') {
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
type: 'items_updated',
items: update.items,
})
void refresh()
} else if (update.status === 'queued' || update.status === 'running') {
continuing.value = true
stopScanning()
}
})
}
if (scannerEnabled.value) scanner.start()
}
function stopScanning() {
scanner.stop()
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
type: 'reset',
})
}
async function refresh() {
if (!jobId.value || continuing.value) return
loading.value = true
try {
const nextContent = await install_job_missing_files(jobId.value)
content.value = nextContent
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
type: 'items_updated',
items: nextContent.files.map((file) => ({ id: file.itemId, status: file.status })),
})
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
}
async function runItem(itemId: string, action: () => Promise<InstallJobSnapshot>) {
busy.value = new Set([...busy.value, itemId])
try {
const job = await action()
await applyItemResult(itemId, job)
} catch (error) {
const latest = jobId.value ? await install_job_get(jobId.value).catch(() => null) : null
if (
latest &&
(isContinuingStatus(latest.status) ||
latest.items.some(
(item) => item.id === itemId && ['completed', 'skipped'].includes(item.status),
))
) {
await applyItemResult(itemId, latest)
} else {
handleError(error)
await refresh()
}
} finally {
const next = new Set(busy.value)
next.delete(itemId)
busy.value = next
}
}
async function applyItemResult(itemId: string, job: InstallJobSnapshot) {
if (
job.status !== 'waiting_for_user' ||
job.items.some((item) => item.id === itemId && ['completed', 'skipped'].includes(item.status))
) {
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
type: 'items_resolved',
itemIds: [itemId],
})
scannerErrors.value.delete(itemId)
}
if (job.status === 'waiting_for_user') await refresh()
else if (isContinuingStatus(job.status)) {
continuing.value = true
stopScanning()
}
}
function isContinuingStatus(status: InstallJobSnapshot['status']) {
return status === 'queued' || status === 'running' || status === 'succeeded'
}
async function retryOne(itemId: string) {
if (!jobId.value) return
await runItem(itemId, () => install_job_retry_missing_file(jobId.value!, itemId))
}
async function selectLocal(itemId: string) {
if (!jobId.value) return
const selected = await open({ multiple: false })
const path = selectedPath(selected)
if (!path) return
await runItem(itemId, () => install_job_import_missing_file(jobId.value!, itemId, path))
}
function selectedPath(selected: unknown) {
if (typeof selected === 'string') return selected
if (
selected &&
typeof selected === 'object' &&
'path' in selected &&
typeof selected.path === 'string'
) {
return selected.path
}
return null
}
async function retryAll() {
if (!jobId.value) return
loading.value = true
stopScanning()
try {
await install_job_resume(jobId.value)
continuing.value = true
} catch (error) {
const latest = await install_job_get(jobId.value).catch(() => null)
if (latest && isContinuingStatus(latest.status)) {
continuing.value = true
} else {
handleError(error)
if (latest?.status === 'waiting_for_user' && scannerEnabled.value) scanner.start()
}
} finally {
loading.value = false
}
}
async function openBrowser(url: string) {
try {
await openUrl(url)
} catch (error) {
handleError(error)
}
}
function isBusy(itemId: string) {
return busy.value.has(itemId)
}
function scannerItemStatus(itemId: string) {
if (scannerErrors.value.has(itemId)) return scannerErrors.value.get(itemId)
if (scannerPresentation.value.rejectedItemIds.includes(itemId)) {
return formatMessage(messages.fileMismatch)
}
return null
}
function attemptText(file: MissingFile) {
if (file.attempt == null || file.maxAttempts == null) return formatMessage(messages.noAttempts)
return formatMessage(messages.attempts, { attempt: file.attempt, max: file.maxAttempts })
}
function statusLabel(status: MissingFile['status']) {
return status in statusMessages
? formatMessage(statusMessages[status as keyof typeof statusMessages])
: status
}
function statusColor(status: MissingFile['status']): 'green' | 'red' | 'orange' | 'blue' | 'gray' {
if (status === 'completed') return 'green'
if (status === 'failed') return 'red'
if (
status === 'verifying' ||
status === 'writing' ||
status === 'finalizing' ||
status === 'metadata' ||
status === 'waiting_for_database'
)
return 'orange'
return 'blue'
}
onUnmounted(() => {
stopScanning()
unlisten?.()
})
defineExpose({ show })
</script>
@@ -0,0 +1,56 @@
<!-- @deprecated Use NewModal from @modrinth/ui directly. Ads/noblur now handled by injectModalBehavior. -->
<script setup lang="ts">
import { NewModal as Modal } from '@modrinth/ui'
import { useTemplateRef } from 'vue'
const props = defineProps({
header: {
type: String,
default: null,
},
hideHeader: {
type: Boolean,
default: false,
},
closable: {
type: Boolean,
default: true,
},
onHide: {
type: Function,
default() {
return () => {}
},
},
/** @deprecated No longer used — ads are handled by provideModalBehavior */
showAdOnClose: {
type: Boolean,
default: true,
},
})
const modal = useTemplateRef('modal')
defineExpose({
show: (e?: MouseEvent) => {
modal.value?.show(e)
},
hide: () => {
modal.value?.hide()
},
})
</script>
<template>
<Modal
ref="modal"
:header="header"
:closable="closable"
:hide-header="hideHeader"
:on-hide="() => props.onHide?.()"
>
<template #title>
<slot name="title" />
</template>
<slot />
</Modal>
</template>
@@ -0,0 +1,116 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
fade="warning"
max-width="500px"
:on-hide="handleHide"
>
<p class="m-0 text-secondary">
<IntlFormatted :message-id="messages.body" :values="{ instanceName }">
<template #bold="{ children }">
<span class="font-medium text-contrast"><component :is="() => children" /></span>
</template>
</IntlFormatted>
</p>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="handleGoToInstance">
{{ formatMessage(messages.instance) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button @click="handleCreateAnyway">
<PlusIcon />
{{ formatMessage(messages.create) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { PlusIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
IntlFormatted,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'app.instance.modpack-already-installed.header',
defaultMessage: 'Modpack already installed',
},
body: {
id: 'app.instance.modpack-already-installed.body',
defaultMessage:
'This modpack is already installed in the <bold>{instanceName}</bold> instance. Are you sure you want to duplicate it?',
},
instance: {
id: 'app.instance.modpack-already-installed.instance',
defaultMessage: 'Instance',
},
create: {
id: 'app.instance.modpack-already-installed.create',
defaultMessage: 'Create',
},
})
const emit = defineEmits<{
(e: 'go-to-instance', instanceId: string): void
(e: 'create-anyway' | 'cancel'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const instanceName = ref('')
const instanceId = ref('')
const accepted = ref(false)
function show(name: string, id: string) {
instanceName.value = name
instanceId.value = id
accepted.value = false
modal.value?.show()
}
function handleCancel() {
modal.value?.hide()
}
function handleGoToInstance() {
accepted.value = true
modal.value?.hide()
emit('go-to-instance', instanceId.value)
}
function handleCreateAnyway() {
accepted.value = true
modal.value?.hide()
emit('create-anyway')
}
function handleHide() {
if (!accepted.value) emit('cancel')
}
defineExpose({
show,
})
</script>
@@ -0,0 +1,243 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { CheckIcon, DownloadIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
Badge,
ButtonStyled,
Combobox,
commonMessages,
defineMessages,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { releaseColor } from '@/helpers/utils'
export interface ModpackInstallExistingInstance {
id: string
name: string
}
export interface ModpackInstallModalData {
project: Pick<Labrinth.Projects.v2.Project, 'id' | 'title' | 'icon_url'>
versions: Labrinth.Versions.v2.Version[]
initialVersionId?: string | null
existingInstancesByVersion: Record<string, ModpackInstallExistingInstance[]>
instancePaths: string[]
}
const emit = defineEmits<{
install: [versionId: string, name: string]
cancel: []
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const data = ref<ModpackInstallModalData | null>(null)
const selectedVersionId = ref('')
const instanceName = ref('')
const submitting = ref(false)
const submitted = ref(false)
const messages = defineMessages({
title: {
id: 'app.modpack-install.title',
defaultMessage: 'Install modpack',
},
version: {
id: 'app.modpack-install.version',
defaultMessage: 'Version',
},
instanceName: {
id: 'app.modpack-install.instance-name',
defaultMessage: 'Instance name',
},
instanceNamePlaceholder: {
id: 'app.modpack-install.instance-name-placeholder',
defaultMessage: 'My modpack instance',
},
installed: {
id: 'app.modpack-install.already-installed',
defaultMessage: 'This version is already installed',
},
installedDescription: {
id: 'app.modpack-install.already-installed-description',
defaultMessage: 'Creating another instance will not change these existing instances: {names}',
},
install: {
id: 'app.modpack-install.install',
defaultMessage: 'Install',
},
selectVersion: {
id: 'app.modpack-install.select-version',
defaultMessage: 'Select a version',
},
folderName: {
id: 'app.modpack-install.folder-name',
defaultMessage: 'Instance folder: {name}',
},
folderNameConflict: {
id: 'app.modpack-install.folder-name-conflict',
defaultMessage: 'A folder with this name already exists. The instance folder will be: {name}',
},
})
const selectedVersion = computed(() =>
data.value?.versions.find((version) => version.id === selectedVersionId.value),
)
const versionOptions = computed(() =>
(data.value?.versions ?? []).map((version) => ({
value: version.id,
label: versionLabel(version),
})),
)
const existingInstances = computed(
() => data.value?.existingInstancesByVersion[selectedVersionId.value] ?? [],
)
const folderName = computed(() => {
const baseName = instanceName.value.trim().replace(/[\\/?*:'"|<>!]/g, '_')
let candidate = baseName
let index = 1
while (candidate && data.value?.instancePaths.includes(candidate)) {
candidate = `${baseName} (${index++})`
}
return candidate
})
const hasFolderNameConflict = computed(() => folderName.value !== instanceName.value.trim())
const canInstall = computed(
() => !!selectedVersion.value && instanceName.value.trim().length > 0 && !submitting.value,
)
function versionLabel(version: Labrinth.Versions.v2.Version) {
return [version.version_number || version.name, version.game_versions.join(', ')].filter(Boolean).join(' · ')
}
function show(nextData: ModpackInstallModalData) {
data.value = nextData
selectedVersionId.value =
nextData.versions.find((version) => version.id === nextData.initialVersionId)?.id ??
nextData.versions[0]?.id ??
''
instanceName.value = nextData.project.title
submitting.value = false
submitted.value = false
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function submit() {
if (!canInstall.value) return
submitting.value = true
submitted.value = true
emit('install', selectedVersionId.value, instanceName.value.trim())
modal.value?.hide()
}
function handleHide() {
if (!submitted.value) emit('cancel')
}
defineExpose({ show, hide })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
width="min(36rem, calc(95vw - 10rem))"
max-width="36rem"
:on-hide="handleHide"
>
<div v-if="data" class="flex min-w-0 flex-col gap-4">
<div class="flex min-w-0 items-center gap-3">
<Avatar
:src="data.project.icon_url"
:alt="data.project.title"
size="3rem"
:tint-by="data.project.title"
no-shadow
/>
<span class="min-w-0 truncate text-lg font-semibold text-contrast">{{ data.project.title }}</span>
</div>
<label class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.version) }}</span>
<Combobox
v-model="selectedVersionId"
:options="versionOptions"
:name="formatMessage(messages.version)"
:display-value="selectedVersion ? versionLabel(selectedVersion) : formatMessage(messages.selectVersion)"
/>
</label>
<div v-if="selectedVersion" class="flex flex-wrap items-center gap-2 text-sm text-secondary">
<Badge :color="releaseColor(selectedVersion.version_type)" :type="selectedVersion.version_type" />
<span v-if="selectedVersion.loaders.length">{{ selectedVersion.loaders.join(', ') }}</span>
<span v-if="selectedVersion.game_versions.length">{{ selectedVersion.game_versions.join(', ') }}</span>
</div>
<label class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.instanceName) }}</span>
<StyledInput
v-model="instanceName"
:placeholder="formatMessage(messages.instanceNamePlaceholder)"
autocomplete="off"
wrapper-class="w-full"
/>
</label>
<p v-if="folderName" class="m-0 text-sm text-secondary">
{{
formatMessage(hasFolderNameConflict ? messages.folderNameConflict : messages.folderName, {
name: folderName,
})
}}
</p>
<div
v-if="existingInstances.length"
class="flex items-start gap-2 rounded-lg border border-warning bg-warning-bg p-3"
>
<CheckIcon class="mt-0.5 shrink-0" />
<div class="min-w-0">
<p class="m-0 font-semibold text-contrast">{{ formatMessage(messages.installed) }}</p>
<p class="mt-1 mb-0 text-sm text-secondary">
{{
formatMessage(messages.installedDescription, {
names: existingInstances.map((instance) => instance.name).join(', '),
})
}}
</p>
</div>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button :disabled="submitting" @click="hide">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canInstall" @click="submit">
<DownloadIcon />
{{ formatMessage(messages.install) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,145 @@
<script setup lang="ts">
import { ExternalIcon, ShieldIcon, SpinnerIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
NewModal,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ref } from 'vue'
import { type PrivacySettings, savePrivacySettings } from '@/helpers/settings'
const emit = defineEmits<{
saved: [privacy: PrivacySettings]
}>()
const CONSENT_VERSION = 1
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const modal = ref<InstanceType<typeof NewModal>>()
const telemetry = ref(true)
const discordRpc = ref(true)
const saving = ref(false)
const messages = defineMessages({
title: {
id: 'app.privacy-consent.title',
defaultMessage: 'Privacy & security',
},
intro: {
id: 'app.privacy-consent.intro',
defaultMessage:
'Choose what Axolotl may send or display. Nothing is sent until you confirm these choices.',
},
telemetry: {
id: 'app.privacy-consent.telemetry',
defaultMessage: 'Allow anonymous telemetry',
},
telemetryDescription: {
id: 'app.privacy-consent.telemetry-description',
defaultMessage:
'Helps count opted-in installations and daily active users. Full Minecraft logs and account credentials are never uploaded.',
},
discordRpc: {
id: 'app.privacy-consent.discord-rpc',
defaultMessage: 'Discord Rich Presence',
},
discordRpcDescription: {
id: 'app.privacy-consent.discord-rpc-description',
defaultMessage:
'Shows your current launcher or game activity in Discord when Discord is running.',
},
privacyPolicy: {
id: 'app.privacy-consent.privacy-policy',
defaultMessage: 'Read the privacy policy',
},
continue: {
id: 'app.privacy-consent.continue',
defaultMessage: 'Save and continue',
},
})
function show(current: PrivacySettings) {
telemetry.value = true
discordRpc.value = current.discord_rpc
modal.value?.show()
}
async function save() {
if (saving.value) return
saving.value = true
try {
const privacy = await savePrivacySettings({
telemetry: telemetry.value,
discord_rpc: discordRpc.value,
consent_version: CONSENT_VERSION,
})
modal.value?.hide()
emit('saved', privacy)
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" :closable="false" max-width="600px">
<div class="flex flex-col gap-6">
<div class="flex items-start gap-3">
<ShieldIcon class="mt-0.5 size-6 shrink-0 text-brand" />
<p class="m-0 leading-relaxed text-primary">
{{ formatMessage(messages.intro) }}
</p>
</div>
<div class="flex items-center justify-between gap-5">
<div class="min-w-0">
<label for="consent-telemetry" class="font-semibold text-contrast">
{{ formatMessage(messages.telemetry) }}
</label>
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.telemetryDescription) }}
</p>
</div>
<Toggle id="consent-telemetry" v-model="telemetry" :disabled="saving" />
</div>
<div class="flex items-center justify-between gap-5">
<div class="min-w-0">
<label for="consent-discord-rpc" class="font-semibold text-contrast">
{{ formatMessage(messages.discordRpc) }}
</label>
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.discordRpcDescription) }}
</p>
</div>
<Toggle id="consent-discord-rpc" v-model="discordRpc" :disabled="saving" />
</div>
</div>
<template #actions>
<div class="flex items-center justify-between gap-4">
<ButtonStyled type="transparent">
<button type="button" :disabled="saving" @click="openUrl('https://axlmc.org/privacy')">
<ExternalIcon />
{{ formatMessage(messages.privacyPolicy) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" :disabled="saving" @click="save">
<SpinnerIcon v-if="saving" class="animate-spin" />
{{ formatMessage(messages.continue) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,50 @@
<!-- @deprecated Use ShareModal from @modrinth/ui directly. Ads/noblur now handled by injectModalBehavior. -->
<script setup lang="ts">
import { ShareModal } from '@modrinth/ui'
import { ref } from 'vue'
defineProps({
header: {
type: String,
default: 'Share',
},
shareTitle: {
type: String,
default: 'Modrinth',
},
shareText: {
type: String,
default: null,
},
link: {
type: Boolean,
default: false,
},
openInNewTab: {
type: Boolean,
default: true,
},
})
const modal = ref(null)
defineExpose({
show: (passedContent) => {
modal.value.show(passedContent)
},
hide: () => {
modal.value.hide()
},
})
</script>
<template>
<ShareModal
ref="modal"
:header="header"
:share-title="shareTitle"
:share-text="shareText"
:link="link"
:open-in-new-tab="openInNewTab"
/>
</template>
@@ -0,0 +1,314 @@
<template>
<SymlinkInstanceWarning
v-if="instance?.symlink_target"
:symlink-target="instance.symlink_target"
/>
<ContentDiffModal
ref="diffModal"
:header="formatMessage(messages.updateToPlay)"
:admonition-header="formatMessage(messages.updateRequired)"
:description="
instance ? formatMessage(messages.updateRequiredDescription, { name: instance.name }) : ''
"
:diffs="normalizedDiffs"
:confirm-label="formatMessage(commonMessages.updateButton)"
:confirm-icon="DownloadIcon"
:show-report-button="true"
@confirm="handleUpdate"
@cancel="handleDecline"
@report="handleReport"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon } from '@modrinth/assets'
import {
commonMessages,
type ContentDiffItem,
ContentDiffModal,
defineMessages,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { wait_for_install_job } from '@/helpers/install'
import { update_managed_modrinth_version } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { injectServerInstall } from '@/providers/server-install'
defineOptions({
inheritAttrs: false,
})
type Dependency = Labrinth.Versions.v3.Dependency
type Version = Labrinth.Versions.v2.Version
interface BaseDiff {
project_id: string
project?: {
title: string
icon_url?: string
slug: string
}
currentVersionId?: string
newVersionId?: string
currentVersion?: Version
newVersion?: Version
fileName?: string
}
interface AddedDiff extends BaseDiff {
type: 'added'
newVersionId: string
}
interface RemovedDiff extends BaseDiff {
type: 'removed'
}
interface UpdatedDiff extends BaseDiff {
type: 'updated'
currentVersionId: string
newVersionId: string
}
type DependencyDiff = AddedDiff | RemovedDiff | UpdatedDiff
type ProjectInfo = {
id: string
title: string
icon_url?: string
slug: string
}
const { formatMessage } = useVIntl()
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
type UpdateCompleteCallback = () => void | Promise<void>
const diffModal = ref<InstanceType<typeof ContentDiffModal>>()
const instance = ref<GameInstance | null>(null)
const onUpdateComplete = ref<UpdateCompleteCallback>(() => {})
const diffs = ref<DependencyDiff[]>([])
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Version | null>(null)
const normalizedDiffs = computed<ContentDiffItem[]>(() =>
diffs.value.map((diff) => ({
type: diff.type,
projectName: diff.project?.title,
fileName: diff.fileName,
currentVersionName: diff.currentVersion?.version_number,
newVersionName: diff.newVersion?.version_number,
})),
)
async function computeDependencyDiffs(
currentDeps: Dependency[],
latestDeps: Dependency[],
): Promise<DependencyDiff[]> {
console.log('Computing dependency diffs', { currentDeps, latestDeps })
// Separate deps with project_id from file_name-only deps
const currentWithProject = currentDeps.filter((d) => d.project_id)
const latestWithProject = latestDeps.filter((d) => d.project_id)
const currentFileOnly = currentDeps.filter((d) => !d.project_id && d.file_name)
const latestFileOnly = latestDeps.filter((d) => !d.project_id && d.file_name)
const currentByProject = new Map<string, Dependency>(
currentWithProject.map((d) => [d.project_id!, d]),
)
const latestByProject = new Map<string, Dependency>(
latestWithProject.map((d) => [d.project_id!, d]),
)
const currentFilenames = new Set(currentFileOnly.map((d) => d.file_name!))
const latestFilenames = new Set(latestFileOnly.map((d) => d.file_name!))
const diffs: DependencyDiff[] = []
// Find added and updated dependencies (by project_id)
latestByProject.forEach((latestDep, projectId) => {
const currentDep = currentByProject.get(projectId)
if (!currentDep && latestDep.version_id) {
diffs.push({ type: 'added', project_id: projectId, newVersionId: latestDep.version_id })
} else if (
currentDep?.version_id &&
latestDep?.version_id &&
currentDep?.version_id !== latestDep.version_id
) {
diffs.push({
type: 'updated',
project_id: projectId,
currentVersionId: currentDep.version_id,
newVersionId: latestDep.version_id,
})
}
})
// Find removed dependencies (by project_id)
currentByProject.forEach((currentDep, projectId) => {
if (!latestByProject.has(projectId)) {
diffs.push({
type: 'removed',
project_id: projectId,
currentVersionId: currentDep.version_id,
})
}
})
// Find added/removed file_name-only dependencies
// ideally in future, this should use the hash of the file instead of filename, but since version dependencies don't include file hashes, we'll use filename as a best effort approach
for (const fileName of latestFilenames) {
if (!currentFilenames.has(fileName)) {
diffs.push({ type: 'added', project_id: '', newVersionId: '' as string, fileName })
}
}
for (const fileName of currentFilenames) {
if (!latestFilenames.has(fileName)) {
diffs.push({ type: 'removed', project_id: '', fileName })
}
}
// Fetch projects and versions of diffs
const allProjectIds = [...new Set(diffs.map((d) => d.project_id).filter(Boolean))]
const allVersionIds = [
...new Set(
[...diffs.map((d) => d.newVersionId), ...diffs.map((d) => d.currentVersionId)].filter(
Boolean,
),
),
] as string[]
const [projects, versions] = await Promise.all([
get_project_many(allProjectIds, 'bypass'),
get_version_many(allVersionIds, 'bypass'),
])
const projectMap = new Map<string, ProjectInfo>(projects.map((p: ProjectInfo) => [p.id, p]))
const versionMap = new Map<string, Version>(versions.map((v: Version) => [v.id, v]))
const mappedDiffs = diffs
.map((diff) => {
const project = projectMap.get(diff.project_id)
return {
...diff,
project: project
? { title: project.title, icon_url: project.icon_url, slug: project.slug }
: undefined,
currentVersion: diff.currentVersionId ? versionMap.get(diff.currentVersionId) : undefined,
newVersion: diff.newVersionId ? versionMap.get(diff.newVersionId) : undefined,
}
})
.sort((a, b) => {
const typeOrder = { added: 0, updated: 1, removed: 2 }
const typeCompare = typeOrder[a.type] - typeOrder[b.type]
if (typeCompare !== 0) return typeCompare
const aDate = a.newVersion?.date_published || a.currentVersion?.date_published || ''
const bDate = b.newVersion?.date_published || b.currentVersion?.date_published || ''
return dayjs(bDate).valueOf() - dayjs(aDate).valueOf()
})
.filter((d) => d.project || d.fileName) // filter out any diffs that couldn't be matched to a project or file
return mappedDiffs
}
async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[] | null> {
if (!inst.link) return null
if (!modpackVersionId.value || !inst.link.version_id) return null
try {
// For server projects, link.project_id is the server project but
// link.version_id references a content modpack version from a different project.
// Detect this by comparing the version's project_id with link.project_id.
modpackVersion.value = await get_version(modpackVersionId.value, 'bypass')
const instanceModpackVersion = await get_version(inst.link.version_id, 'bypass')
// Compute dependency diffs between current and latest version
if (instanceModpackVersion && modpackVersion.value) {
return await computeDependencyDiffs(
instanceModpackVersion.dependencies || [],
modpackVersion.value.dependencies || [],
)
}
} catch (error) {
console.error('Error checking for updates:', error)
return null
}
return null
}
watch(
() => instance.value,
async (newInstance) => {
if (!newInstance) return
const result = await checkUpdateAvailable(newInstance)
diffs.value = result || []
},
{ immediate: true, deep: true },
)
async function handleUpdate() {
hide()
const serverProjectId = instance.value?.link?.project_id
if (serverProjectId) startInstallingServer(serverProjectId)
try {
if (modpackVersionId.value && instance.value) {
const job = await update_managed_modrinth_version(instance.value.id, modpackVersionId.value)
await wait_for_install_job(job.job_id)
await onUpdateComplete.value()
}
} catch (error) {
console.error('Error updating instance:', error)
} finally {
if (serverProjectId) stopInstallingServer(serverProjectId)
}
}
function handleReport() {
if (instance.value?.link?.project_id) {
openUrl(`https://modrinth.com/report?item=project&itemID=${instance.value.link.project_id}`)
}
}
function handleDecline() {
hide()
}
function show(
instanceVal: GameInstance,
modpackVersionIdVal: string | null = null,
callback: UpdateCompleteCallback = () => {},
e?: MouseEvent,
) {
instance.value = instanceVal
modpackVersionId.value = modpackVersionIdVal
onUpdateComplete.value = callback
diffModal.value?.show(e)
}
function hide() {
diffModal.value?.hide()
}
const messages = defineMessages({
updateToPlay: {
id: 'app.modal.update-to-play.header',
defaultMessage: 'Update to play',
},
updateRequired: {
id: 'app.modal.update-to-play.update-required',
defaultMessage: 'Update required',
},
updateRequiredDescription: {
id: 'app.modal.update-to-play.update-required-description',
defaultMessage:
'An update is required to play {name}. Please update to the latest version to launch the game.',
},
})
const hasUpdate = computed(() => {
if (!instance.value?.link) return false
return modpackVersionId.value != null && modpackVersionId.value !== instance.value.link.version_id
})
defineExpose({ show, hide, hasUpdate })
</script>