Files
Starlight_Lancher/apps/app-frontend/src/pages/Downloads.vue
T
Disy a47d309103
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
fix: 修复整合包失败后的完整重试流程
2026-09-19 00:13:43 +08:00

1344 lines
44 KiB
Vue

<template>
<div class="box-border flex min-h-full w-full flex-col gap-3 p-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div data-onboarding-id="downloads-tabs">
<NavTabs
:active-index="tab === 'active' ? 0 : 1"
:links="downloadTabs"
mode="local"
@tab-click="selectTab"
/>
</div>
<ButtonStyled color="brand">
<button @click="router.push('/create')">
<PlusIcon />
{{ formatMessage(messages.newDownload) }}
</button>
</ButtonStyled>
</div>
<div class="flex flex-wrap items-center gap-2">
<StyledInput
v-model="query"
:icon="SearchIcon"
:placeholder="formatMessage(messages.search)"
clearable
wrapper-class="flex-1 min-w-0"
/>
<DropdownSelect
v-model="provider"
class="!w-44"
name="download-provider"
:options="providerOptions"
:display-name="providerFilterLabel"
/>
<DropdownSelect
v-if="tab === 'history'"
v-model="historyStatus"
class="!w-44"
name="download-status"
:options="historyStatusOptions"
:display-name="historyStatusLabel"
/>
<ButtonStyled v-if="tab === 'history' && historyJobs.length" class="ml-auto" type="outlined">
<button @click="clearHistoryModal?.show()">
<TrashIcon />
{{ formatMessage(messages.clearHistory) }}
</button>
</ButtonStyled>
</div>
<div
v-if="visibleJobs.length || (tab === 'active' && legacyDownloads.length)"
class="flex flex-col gap-3"
>
<Card
v-for="bar in tab === 'active' ? legacyDownloads : []"
:key="String(bar.loading_bar_uuid ?? bar.id)"
class="!p-4"
>
<div class="flex items-center gap-3">
<img
v-if="bar.bar_type?.icon"
:src="displayIcon(bar.bar_type.icon)"
alt=""
class="size-12 rounded-xl object-cover"
/>
<div
v-else
class="flex size-12 items-center justify-center rounded-xl bg-brand-highlight text-brand"
>
<DownloadIcon />
</div>
<div class="min-w-0 flex-grow">
<div class="truncate font-semibold text-contrast">{{ bar.title || bar.message }}</div>
<div class="break-words text-sm text-secondary">{{ bar.message }}</div>
</div>
<TagItem
v-if="['hosted_pack_sync', 'hosted_mod_download'].includes(bar.bar_type?.type ?? '')"
>StarLight</TagItem
>
<TagItem v-else>
<component :is="providerIcon(legacyProvider(bar))" />
{{ providerLabel(legacyProvider(bar)) }}
</TagItem>
<Badge
:color="bar.bar_type?.error ? 'red' : 'orange'"
:type="statusLabel(bar.bar_type?.error ? 'failed' : 'running')"
/>
</div>
<ProgressBar
v-if="!bar.bar_type?.error"
class="mt-4"
full-width
:progress="legacyPercent(bar)"
:max="100"
:label="formatMessage(messages.progress)"
:waiting="!bar.total"
:show-progress="Boolean(bar.total)"
/>
<RouterLink
v-else
:to="hostedRetryRoute(bar.bar_type?.instance_id ?? '')"
class="mt-3 inline-block text-brand hover:underline"
>
{{ formatMessage(messages.hostedRetry) }}
</RouterLink>
</Card>
<Card
v-for="job in visibleJobs"
:key="job.job_id"
:data-install-job-id="job.job_id"
class="!p-0"
>
<div class="flex flex-wrap items-center gap-4 p-4">
<img
v-if="job.display?.icon"
:src="displayIcon(job.display.icon)"
alt=""
class="size-12 rounded-xl object-cover"
/>
<div
v-else
class="flex size-12 items-center justify-center rounded-xl bg-brand-highlight text-brand"
>
<DownloadIcon />
</div>
<div class="min-w-48 flex-grow">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 truncate text-lg font-semibold text-contrast">
{{ jobTitle(job) }}
</h2>
<TagItem>
<component :is="jobTypeIcon(job)" />
{{ jobTypeLabel(job) }}
</TagItem>
<Badge :color="statusColor(job.status)" :type="statusLabel(job.status)" />
<Badge
v-if="job.instance_deleted"
color="gray"
:type="formatMessage(messages.instanceDeleted)"
/>
</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-sm text-secondary">
<span>{{ jobPhaseLabel(job) }}</span>
<BulletDivider />
<span>{{ formatDate(job.finished ?? job.modified) }}</span>
<template v-if="job.instance_id">
<BulletDivider />
<span>{{
formatMessage(messages.instanceTarget, { instance: job.instance_id })
}}</span>
</template>
</div>
<div
v-if="downloadDetails(job).length"
class="mt-1 flex flex-wrap items-center gap-2 text-sm text-secondary"
>
<template v-for="(metric, index) in downloadDetails(job)" :key="`${index}-${metric}`">
<BulletDivider v-if="index > 0" />
<span>{{ metric }}</span>
</template>
</div>
<div v-if="activeRequestItems(job).length" class="mt-2 flex flex-col gap-1">
<div
v-for="item in activeRequestItems(job).slice(0, 3)"
:key="`${item.id}-${item.request_url}`"
class="flex min-w-0 items-center gap-2 text-xs text-secondary"
>
<GlobeIcon class="size-3.5 shrink-0" />
<span v-if="item.source" class="shrink-0">
{{ downloadSourceLabel(item.source) }}
</span>
<code v-tooltip="item.request_url" class="min-w-0 flex-1 truncate">{{
item.request_url
}}</code>
</div>
<div v-if="activeRequestItems(job).length > 3" class="text-xs text-secondary">
{{
formatMessage(messages.moreActiveRequests, {
count: activeRequestItems(job).length - 3,
})
}}
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<ButtonStyled v-if="canCancel(job)" color="red" type="outlined" size="small">
<button :disabled="busy.has(job.job_id)" @click="cancel(job)">
<XIcon />{{ formatMessage(messages.cancel) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="canRetry(job)" color="brand" size="small">
<button :disabled="busy.has(job.job_id)" @click="retry(job)">
<RefreshCwIcon />{{ formatMessage(messages.retry) }}
</button>
</ButtonStyled>
<ButtonStyled
v-if="canSkipMissingContent(job)"
color="orange"
type="outlined"
size="small"
>
<button :disabled="busy.has(job.job_id)" @click="skipMissingContent(job)">
<ArrowBigRightDashIcon />{{ formatMessage(messages.skipMissingFiles) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="job.status === 'waiting_for_user'" color="brand" size="small">
<button :disabled="busy.has(job.job_id)" @click="resolveMissing(job)">
<DownloadIcon />{{ formatMessage(messages.completeMissingFiles) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="job.error" type="outlined" size="small">
<button :disabled="busy.has(job.job_id)" @click="copyDiagnostics(job)">
<ClipboardCopyIcon />{{ formatMessage(messages.copyDiagnostics) }}
</button>
</ButtonStyled>
<ButtonStyled
v-if="tab === 'history' && isSuccessfulUpgradeJob(job)"
color="brand"
size="small"
>
<button @click="router.push(upgradeResultLocation(job))">
<CheckCircleIcon />{{ formatMessage(messages.viewUpgradeResult) }}
</button>
</ButtonStyled>
<ButtonStyled
v-if="job.instance_id && !job.instance_deleted && job.status !== 'waiting_for_user'"
type="outlined"
size="small"
>
<button @click="router.push(`/instance/${encodeURIComponent(job.instance_id!)}`)">
<ExternalIcon />{{ formatMessage(messages.openInstance) }}
</button>
</ButtonStyled>
<ButtonStyled
v-if="job.items.length > 0 || job.error || job.rollback_error"
type="transparent"
size="small"
>
<button @click="toggleExpanded(job.job_id)">
<ChevronDownIcon :class="expanded.has(job.job_id) ? 'rotate-180' : ''" />
{{
expanded.has(job.job_id)
? formatMessage(messages.hideDetails)
: formatMessage(messages.details)
}}
</button>
</ButtonStyled>
<ButtonStyled v-if="tab === 'history'" circular type="transparent" size="small">
<button
v-tooltip="formatMessage(messages.deleteRecord)"
:aria-label="formatMessage(messages.deleteRecord)"
:disabled="busy.has(job.job_id)"
@click="remove(job)"
>
<TrashIcon />
</button>
</ButtonStyled>
</div>
</div>
<div v-if="showProgress(job)" class="px-4 pb-4">
<ProgressBar
full-width
:progress="jobPercent(job)"
:max="100"
:color="progressColor(job)"
:label="progressText(job)"
:waiting="!isFinished(job) && (job.status === 'queued' || !hasDeterminateProgress(job))"
show-progress
>
<template #progress-icon>
<CheckCircleIcon
v-if="isMainTrackComplete(job)"
class="size-5 text-green"
aria-hidden="true"
/>
<SpinnerIcon v-else class="size-5 animate-spin" aria-hidden="true" />
</template>
</ProgressBar>
<div v-if="job.parallel" class="mt-2">
<ProgressBar
full-width
:progress="parallelPercent(job)"
:max="100"
:color="parallelProgressColor(job)"
:label="parallelProgressText(job)"
:waiting="
!isFinished(job) &&
(job.status === 'queued' || !hasDeterminateParallelProgress(job))
"
show-progress
>
<template #progress-icon>
<CheckCircleIcon
v-if="isParallelTrackComplete(job)"
class="size-5 text-green"
aria-hidden="true"
/>
<SpinnerIcon v-else class="size-5 animate-spin" aria-hidden="true" />
</template>
</ProgressBar>
</div>
</div>
<div v-if="job.status === 'waiting_for_user'" class="px-4 pb-4">
<Admonition type="warning" :header="formatMessage(messages.actionNeeded)">
{{
formatMessage(messages.missingRequiredContent, {
completed: completedRequiredFiles(job),
total: totalRequiredFiles(job),
missing: missingRequiredFiles(job),
})
}}
</Admonition>
</div>
<div
v-if="expanded.has(job.job_id)"
class="border-0 border-t border-solid border-divider p-4"
>
<Admonition
v-if="job.rollback_error || job.error"
class="mb-4"
type="critical"
:header="
formatMessage(job.rollback_error ? messages.cleanupIncomplete : messages.errorDetails)
"
>
{{ job.rollback_error?.message ?? job.error?.message }}
</Admonition>
<Table
v-if="job.items.length"
:key="`${job.job_id}-details`"
:columns="itemColumns"
:data="reorderJobItems(job)"
row-key="id"
table-min-width="42rem"
virtualized
class="max-h-80 overflow-y-auto"
>
<template #cell-name="{ row }">
<div class="min-w-0 py-2">
<div class="truncate font-medium text-contrast">{{ row.name }}</div>
<div
v-if="row.project_id && row.version_id"
class="truncate text-xs text-secondary"
>
{{
formatMessage(messages.projectFile, {
projectId: row.project_id,
fileId: row.version_id,
})
}}
</div>
<div v-if="row.error" class="truncate text-xs text-red">
{{ itemError(row) }}
</div>
<div
v-if="row.request_url"
class="flex min-w-0 items-center gap-1.5 text-xs text-secondary"
>
<GlobeIcon class="size-3 shrink-0" />
<span v-if="row.source" class="shrink-0">
{{ downloadSourceLabel(row.source) }}
</span>
<code v-tooltip="row.request_url" class="min-w-0 flex-1 truncate">{{
row.request_url
}}</code>
</div>
<ButtonStyled v-if="row.manual_url" type="transparent" size="small">
<button class="!px-0" @click.stop="openManualDownload(row)">
<ExternalIcon />{{ formatMessage(messages.manualDownload) }}
</button>
</ButtonStyled>
</div>
</template>
<template #cell-status="{ row }">
<Badge
:color="itemStatusColor(row.status)"
:type="itemStatusLabel(job, row.status)"
/>
</template>
<template #cell-attempts="{ row }">
<span>{{ itemAttempts(row) }}</span>
</template>
<template #cell-progress="{ row }">
<span>{{ itemProgress(row) }}</span>
</template>
</Table>
<EmptyState
v-else
type="no-documents"
:heading="formatMessage(messages.noFileDetailsTitle)"
:description="formatMessage(messages.noFileDetails)"
/>
</div>
</Card>
</div>
<Card v-else class="flex flex-1">
<EmptyState
class="my-auto"
:type="hasFilters ? 'no-search-result' : 'no-tasks'"
:heading="formatMessage(hasFilters ? messages.noResultsTitle : messages.emptyTitle)"
:description="
formatMessage(hasFilters ? messages.noResultsDescription : messages.emptyDescription)
"
/>
</Card>
</div>
<ConfirmModal
ref="clearHistoryModal"
:danger="true"
:markdown="false"
:title="formatMessage(messages.clearHistoryTitle)"
:description="formatMessage(messages.confirmClear)"
:proceed-label="formatMessage(messages.clearHistory)"
@proceed="clearHistory"
/>
<MissingModpackContentModal ref="missingContentModal" />
</template>
<script setup lang="ts">
import {
ArrowBigRightDashIcon,
CheckCircleIcon,
ChevronDownIcon,
ClipboardCopyIcon,
ClockIcon,
CurseForgeIcon,
DownloadIcon,
ExternalIcon,
GlobeIcon,
ModrinthIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
SpinnerIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import {
Admonition,
Badge,
BulletDivider,
ButtonStyled,
Card,
ConfirmModal,
defineMessages,
DropdownSelect,
EmptyState,
injectNotificationManager,
type MessageDescriptor,
NavTabs,
ProgressBar,
StyledInput,
Table,
type TableColumn,
TagItem,
useFormatBytes,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MissingModpackContentModal from '@/components/ui/modal/MissingModpackContentModal.vue'
import { listPendingCurseForgeManualDownloads } from '@/helpers/curseforge'
import type { CurseForgeManualDownloadItem } from '@/helpers/curseforge-manual'
import { hostedRetryRoute } from '@/helpers/hosted-install-retry'
import {
download_job_support_details,
type InstallJobSnapshot,
type InstallJobStatus,
type InstallPhaseId,
} from '@/helpers/install'
import {
effectiveInstallProgress,
effectiveParallelProgress,
hasDeterminateInstallProgress,
installProgressTextSource,
} from '@/helpers/install-progress'
import type { LoadingBar } from '@/helpers/state'
import { injectContentInstall } from '@/providers/content-install'
import { injectDownloadManager } from '@/providers/download-manager'
import {
createDownloadFocusState,
focusedDownloadJobId,
reconcileDownloadFocus,
stopDownloadFocusAutoFollow,
} from './download-focus'
import { isSuccessfulUpgradeJob, upgradeResultLocation } from './instance/upgrade/result'
type DownloadItem = InstallJobSnapshot['items'][number]
const manager = injectDownloadManager()
const { showCurseForgeManualDownloads } = injectContentInstall()
const route = useRoute()
const router = useRouter()
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const tab = ref<'active' | 'history'>('active')
const query = ref('')
const provider = ref('all')
const historyStatus = ref('all')
const expanded = ref(new Set<string>())
const busy = ref(new Set<string>())
const clearHistoryModal = ref<InstanceType<typeof ConfirmModal>>()
const missingContentModal = ref<InstanceType<typeof MissingModpackContentModal>>()
const focusedJobId = computed(() => focusedDownloadJobId(route.query.job))
const focusState = ref(createDownloadFocusState(focusedJobId.value))
const messages = defineMessages({
hostedRetry: {
id: 'app.hosted-packs.progress.retry',
defaultMessage: 'Return to the instance to retry',
},
newDownload: { id: 'app.downloads.new-download', defaultMessage: 'New download' },
inProgress: { id: 'app.downloads.in-progress', defaultMessage: 'In progress' },
history: { id: 'app.downloads.history', defaultMessage: 'History' },
search: { id: 'app.downloads.search', defaultMessage: 'Search downloads' },
allSources: { id: 'app.downloads.all-sources', defaultMessage: 'All sources' },
allStatuses: { id: 'app.downloads.all-statuses', defaultMessage: 'All statuses' },
application: { id: 'app.downloads.application', defaultMessage: 'Application' },
local: { id: 'app.downloads.local', defaultMessage: 'Local' },
clearHistory: { id: 'app.downloads.clear-history', defaultMessage: 'Clear history' },
clearHistoryTitle: {
id: 'app.downloads.clear-history-title',
defaultMessage: 'Clear download history?',
},
cancel: { id: 'app.downloads.cancel', defaultMessage: 'Cancel' },
retry: { id: 'app.downloads.retry', defaultMessage: 'Retry' },
skipMissingFiles: {
id: 'app.downloads.skip-missing-files',
defaultMessage: 'Skip',
},
completeMissingFiles: {
id: 'app.downloads.complete-missing-files',
defaultMessage: 'Complete missing files',
},
actionNeeded: { id: 'app.downloads.action-needed', defaultMessage: 'Action needed' },
missingRequiredContent: {
id: 'app.downloads.missing-required-content',
defaultMessage:
'{completed} / {total} required files are ready. {missing, plural, one {# file still needs to be downloaded.} other {# files still need to be downloaded.}}',
},
copyDiagnostics: { id: 'app.downloads.copy-diagnostics', defaultMessage: 'Copy diagnostics' },
upgrade: { id: 'app.downloads.operation.upgrade', defaultMessage: 'Upgrade' },
viewUpgradeResult: {
id: 'app.downloads.view-upgrade-result',
defaultMessage: 'View upgrade result',
},
openInstance: { id: 'app.downloads.open-instance', defaultMessage: 'Open instance' },
instanceDeleted: { id: 'app.downloads.instance-deleted', defaultMessage: 'Instance deleted' },
details: { id: 'app.downloads.details', defaultMessage: 'Details' },
hideDetails: { id: 'app.downloads.hide-details', defaultMessage: 'Hide details' },
deleteRecord: { id: 'app.downloads.delete-record', defaultMessage: 'Delete record' },
errorDetails: { id: 'app.downloads.error-details', defaultMessage: 'Download failed' },
cleanupIncomplete: {
id: 'app.action-bar.install.summary.cleanup-incomplete',
defaultMessage: "Cleanup didn't finish",
},
progress: { id: 'app.downloads.progress', defaultMessage: 'Progress' },
noFileDetailsTitle: {
id: 'app.downloads.no-file-details-title',
defaultMessage: 'No file details',
},
noFileDetails: {
id: 'app.downloads.no-file-details',
defaultMessage: 'No file details were recorded for this download.',
},
emptyTitle: { id: 'app.downloads.empty-title', defaultMessage: 'No downloads yet' },
emptyDescription: {
id: 'app.downloads.empty-description',
defaultMessage: 'New downloads and installation progress will appear here.',
},
noResultsTitle: { id: 'app.downloads.no-results-title', defaultMessage: 'No matching downloads' },
noResultsDescription: {
id: 'app.downloads.no-results-description',
defaultMessage: 'Try changing your search or filters.',
},
confirmClear: {
id: 'app.downloads.confirm-clear',
defaultMessage:
'Completed, failed, interrupted, and canceled records will be permanently deleted.',
},
instanceTarget: { id: 'app.downloads.instance-target', defaultMessage: 'Instance: {instance}' },
notAvailable: { id: 'app.downloads.not-available', defaultMessage: '—' },
itemName: { id: 'app.downloads.item-name', defaultMessage: 'File' },
itemStatus: { id: 'app.downloads.item-status', defaultMessage: 'Status' },
itemAttempts: { id: 'app.downloads.item-attempts', defaultMessage: 'Attempts' },
attemptProgress: {
id: 'app.downloads.item-attempt-progress',
defaultMessage: '{attempt}/{maxAttempts}',
},
itemProgress: { id: 'app.downloads.item-progress', defaultMessage: 'Downloaded' },
manualDownload: {
id: 'app.curseforge.manual-downloads.open',
defaultMessage: 'Open',
},
manualDownloadRequired: {
id: 'app.downloads.manual-download-required',
defaultMessage: 'CurseForge requires this file to be downloaded manually.',
},
projectFile: {
id: 'app.curseforge.manual-downloads.project-file',
defaultMessage: 'Project {projectId} · File {fileId}',
},
downloadSource: { id: 'app.downloads.download-source', defaultMessage: 'Source: {source}' },
verifyingDownloadedFiles: {
id: 'app.downloads.phase.verifying-downloaded-files',
defaultMessage: 'Verifying files and continuing installation',
},
pendingVerification: {
id: 'app.downloads.item-status.pending-verification',
defaultMessage: 'Pending verification',
},
downloadSourceOfficial: { id: 'app.downloads.source.official', defaultMessage: 'Official' },
downloadSourceBmclapi: { id: 'app.downloads.source.bmclapi', defaultMessage: 'OpenBMCLAPI' },
downloadSourceMcim: { id: 'app.downloads.source.mcim', defaultMessage: 'MCIM' },
downloadSourceTianpao: { id: 'app.downloads.source.tianpao', defaultMessage: 'Tianpao' },
downloadSourceAlternate: {
id: 'app.downloads.source.alternate',
defaultMessage: 'Alternate source',
},
downloadSpeed: { id: 'app.downloads.download-speed', defaultMessage: 'Speed: {speed}/s' },
downloadEtaSeconds: {
id: 'app.downloads.download-eta-seconds',
defaultMessage: '{seconds}s remaining',
},
downloadEtaMinutes: {
id: 'app.downloads.download-eta-minutes',
defaultMessage: '{minutes}m remaining',
},
downloadEtaHours: {
id: 'app.downloads.download-eta-hours',
defaultMessage: '{hours}h {minutes}m remaining',
},
downloadFallbacks: {
id: 'app.downloads.download-fallbacks',
defaultMessage: '{count} fallbacks',
},
moreActiveRequests: {
id: 'app.downloads.more-active-requests',
defaultMessage: '+{count} active requests',
},
unknownPhase: { id: 'app.downloads.phase.unknown', defaultMessage: 'Working' },
})
const statusMessages = defineMessages({
queued: { id: 'app.downloads.status.queued', defaultMessage: 'Queued' },
running: { id: 'app.downloads.status.running', defaultMessage: 'Running' },
canceling: { id: 'app.downloads.status.canceling', defaultMessage: 'Canceling' },
waiting_for_user: {
id: 'app.downloads.status.waiting-for-user',
defaultMessage: 'Action needed',
},
succeeded: { id: 'app.downloads.status.succeeded', defaultMessage: 'Completed' },
failed: { id: 'app.downloads.status.failed', defaultMessage: 'Failed' },
interrupted: { id: 'app.downloads.status.interrupted', defaultMessage: 'Interrupted' },
canceled: { id: 'app.downloads.status.canceled', defaultMessage: 'Canceled' },
completed: { id: 'app.downloads.item-status.completed', defaultMessage: 'Completed' },
skipped: { id: 'app.downloads.item-status.skipped', defaultMessage: 'Skipped' },
worker_started: { id: 'app.downloads.item-status.worker-started', defaultMessage: 'Starting' },
downloading: { id: 'app.downloads.item-status.downloading', defaultMessage: 'Downloading' },
connecting: { id: 'app.downloads.item-status.connecting', defaultMessage: 'Connecting' },
waiting_for_resource: {
id: 'app.downloads.item-status.waiting-for-resource',
defaultMessage: 'Waiting for download resources',
},
verifying: { id: 'app.downloads.item-status.verifying', defaultMessage: 'Verifying' },
writing: { id: 'app.downloads.item-status.writing', defaultMessage: 'Writing' },
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',
},
finalizing: { id: 'app.downloads.item-status.finalizing', defaultMessage: 'Finalizing' },
})
const phaseMessages = defineMessages({
preparing_instance: {
id: 'app.downloads.phase.preparing-instance',
defaultMessage: 'Preparing instance',
},
resolving_pack: { id: 'app.downloads.phase.resolving-pack', defaultMessage: 'Resolving modpack' },
downloading_pack_file: {
id: 'app.downloads.phase.downloading-pack-file',
defaultMessage: 'Downloading modpack',
},
reading_pack_manifest: {
id: 'app.downloads.phase.reading-pack-manifest',
defaultMessage: 'Reading manifest',
},
downloading_content: {
id: 'app.downloads.phase.downloading-content',
defaultMessage: 'Downloading content',
},
extracting_overrides: {
id: 'app.downloads.phase.extracting-overrides',
defaultMessage: 'Extracting overrides',
},
resolving_minecraft: {
id: 'app.downloads.phase.resolving-minecraft',
defaultMessage: 'Resolving Minecraft',
},
resolving_loader: {
id: 'app.downloads.phase.resolving-loader',
defaultMessage: 'Resolving loader',
},
preparing_java: { id: 'app.downloads.phase.preparing-java', defaultMessage: 'Preparing Java' },
downloading_minecraft: {
id: 'app.downloads.phase.downloading-minecraft',
defaultMessage: 'Downloading Minecraft',
},
running_loader_processors: {
id: 'app.downloads.phase.running-loader-processors',
defaultMessage: 'Installing loader',
},
creating_backup: {
id: 'app.downloads.phase.creating-backup',
defaultMessage: 'Creating backup',
},
staging_content: {
id: 'app.downloads.phase.staging-content',
defaultMessage: 'Staging content',
},
applying_content: {
id: 'app.downloads.phase.applying-content',
defaultMessage: 'Applying content',
},
updating_loader: {
id: 'app.downloads.phase.updating-loader',
defaultMessage: 'Updating loader',
},
verifying: { id: 'app.downloads.phase.verifying', defaultMessage: 'Verifying' },
finalizing: { id: 'app.downloads.phase.finalizing', defaultMessage: 'Finalizing' },
completed: { id: 'app.downloads.phase.completed', defaultMessage: 'Completed' },
rolling_back: { id: 'app.downloads.phase.rolling-back', defaultMessage: 'Rolling back changes' },
} satisfies Record<InstallPhaseId, MessageDescriptor>)
const legacyDownloads = computed(() => {
const normalized = query.value.trim().toLowerCase()
return manager.legacyDownloads.value.filter((bar) => {
if (provider.value !== 'all' && legacyProvider(bar) !== provider.value) return false
return (
!normalized ||
[bar.title, bar.message, bar.bar_type?.instance_name, bar.bar_type?.file_name].some((value) =>
value?.toLowerCase().includes(normalized),
)
)
})
})
const historyJobs = manager.historyJobs
const providerOptions = [
'all',
'starlight',
'modrinth',
'curse_forge',
'minecraft',
'java',
'application',
'local',
]
const historyStatusOptions = ['all', 'succeeded', 'failed', 'interrupted', 'canceled']
const hasFilters = computed(
() =>
Boolean(query.value.trim()) ||
provider.value !== 'all' ||
(tab.value === 'history' && historyStatus.value !== 'all'),
)
const downloadTabs = computed(() => [
{
href: 'active',
label: formatMessage(messages.inProgress),
icon: DownloadIcon,
},
{ href: 'history', label: formatMessage(messages.history), icon: ClockIcon },
])
const itemColumns = computed<TableColumn[]>(() => [
{ key: 'name', label: formatMessage(messages.itemName), width: '48%' },
{ key: 'status', label: formatMessage(messages.itemStatus), width: '18%' },
{ key: 'attempts', label: formatMessage(messages.itemAttempts), width: '16%' },
{ key: 'progress', label: formatMessage(messages.itemProgress), width: '18%', align: 'right' },
])
const sourceJobs = computed(() =>
tab.value === 'active' ? manager.activeJobs.value : manager.historyJobs.value,
)
const visibleJobs = computed(() => {
const normalized = query.value.trim().toLowerCase()
return sourceJobs.value.filter((job) => {
if (provider.value !== 'all' && job.provider !== provider.value) return false
if (
tab.value === 'history' &&
historyStatus.value !== 'all' &&
job.status !== historyStatus.value
)
return false
return (
!normalized ||
jobTitle(job).toLowerCase().includes(normalized) ||
job.job_id.includes(normalized)
)
})
})
function jobTitle(job: InstallJobSnapshot) {
return (
job.display?.title ||
(job.details.type === 'instance' ? job.details.name : null) ||
(job.details.type === 'modpack' ? job.details.title : null) ||
job.job_id
)
}
function displayIcon(icon: string) {
return /^(https?:|data:|blob:|asset:|tauri:)/.test(icon) ? icon : convertFileSrc(icon)
}
function providerLabel(value: InstallJobSnapshot['provider'] | 'starlight') {
return {
starlight: 'StarLight',
modrinth: 'Modrinth',
curse_forge: 'CurseForge',
minecraft: 'Minecraft',
java: 'Java',
application: formatMessage(messages.application),
local: formatMessage(messages.local),
}[value]
}
function providerFilterLabel(value: string) {
return value === 'all'
? formatMessage(messages.allSources)
: providerLabel(value as InstallJobSnapshot['provider'] | 'starlight')
}
function providerIcon(value: InstallJobSnapshot['provider'] | 'starlight') {
return value === 'curse_forge'
? CurseForgeIcon
: value === 'modrinth'
? ModrinthIcon
: DownloadIcon
}
function jobTypeLabel(job: InstallJobSnapshot) {
return job.kind === 'upgrade_unmanaged_instance'
? formatMessage(messages.upgrade)
: providerLabel(job.provider)
}
function jobTypeIcon(job: InstallJobSnapshot) {
return job.kind === 'upgrade_unmanaged_instance' ? RefreshCwIcon : providerIcon(job.provider)
}
function legacyProvider(bar: LoadingBar): InstallJobSnapshot['provider'] | 'starlight' {
if (['hosted_pack_sync', 'hosted_mod_download'].includes(bar.bar_type?.type ?? ''))
return 'starlight'
if (bar.bar_type?.type === 'pack_download' || bar.bar_type?.type === 'pack_file_download')
return 'curse_forge'
if (bar.bar_type?.type === 'minecraft_download') return 'minecraft'
if (bar.bar_type?.type === 'java_download') return 'java'
if (bar.bar_type?.type === 'launcher_update') return 'application'
return 'local'
}
function historyStatusLabel(value: string) {
return value === 'all' ? formatMessage(messages.allStatuses) : statusLabel(value)
}
function statusLabel(status: string) {
return status in statusMessages
? formatMessage(statusMessages[status as keyof typeof statusMessages])
: status
}
function phaseLabel(phase: InstallPhaseId) {
const message = (phaseMessages as Partial<Record<string, MessageDescriptor>>)[phase]
return formatMessage(message ?? messages.unknownPhase)
}
function isRecoveryValidation(job: InstallJobSnapshot) {
return job.execution_mode === 'recovery_validation'
}
function isLocalRecoveryValidation(job: InstallJobSnapshot) {
return isRecoveryValidation(job) && activeRequestItems(job).length === 0
}
function jobPhaseLabel(job: InstallJobSnapshot) {
if (job.rollback_error) return formatMessage(messages.cleanupIncomplete)
// Older queued content jobs were initialized with the generic instance phase.
// Keep their label meaningful while they are resumed or waiting for the worker.
if (job.kind === 'install_content' && job.phase === 'preparing_instance') {
return formatMessage(phaseMessages.downloading_content)
}
return isLocalRecoveryValidation(job)
? formatMessage(messages.verifyingDownloadedFiles)
: phaseLabel(job.phase)
}
function itemStatusLabel(job: InstallJobSnapshot, status: DownloadItem['status']) {
if (isRecoveryValidation(job) && status === 'queued') {
return formatMessage(messages.pendingVerification)
}
return statusLabel(status)
}
function statusColor(status: InstallJobStatus): 'green' | 'red' | 'orange' | 'blue' | 'gray' {
if (status === 'succeeded') return 'green'
if (status === 'failed' || status === 'interrupted' || status === 'canceled') return 'red'
if (status === 'running' || status === 'waiting_for_user' || status === 'canceling')
return 'orange'
return 'blue'
}
function itemStatusColor(
status: DownloadItem['status'],
): 'green' | 'red' | 'orange' | 'blue' | 'gray' {
if (status === 'completed') return 'green'
if (status === 'failed' || status === 'canceled') return 'red'
if (status === 'waiting_for_user') return 'orange'
if (status === 'skipped') return 'gray'
return 'blue'
}
function canCancel(job: InstallJobSnapshot) {
return ['queued', 'running', 'waiting_for_user'].includes(job.status)
}
function canRetry(job: InstallJobSnapshot) {
return job.status === 'failed' || job.status === 'interrupted' || job.status === 'canceled'
}
function canSkipMissingContent(job: InstallJobSnapshot) {
if (job.provider === 'curse_forge' && job.kind === 'install_pack_to_existing_instance') {
return false
}
return (
job.status === 'waiting_for_user' &&
job.pause_reason?.type === 'missing_required_content' &&
job.pause_reason.failed_files > 0
)
}
function showProgress(job: InstallJobSnapshot) {
return ['queued', 'running', 'canceling', 'waiting_for_user'].includes(job.status)
}
function jobPercent(job: InstallJobSnapshot) {
if (isMainTrackComplete(job)) return 100
if (job.status === 'waiting_for_user') {
const total = totalRequiredFiles(job)
if (!total) return 0
return Math.min(99, (completedRequiredFiles(job) / total) * 100)
}
const progress = effectiveInstallProgress(job)
if (hasDeterminateInstallProgress(progress)) {
return Math.min(99, Math.max(0, (progress.current / progress.total) * 100))
}
if (job.summary.bytes_total && job.summary.bytes_total > 0) {
return Math.min(99, Math.max(0, (job.summary.bytes_downloaded / job.summary.bytes_total) * 100))
}
return 0
}
function hasDeterminateProgress(job: InstallJobSnapshot) {
if (hasDeterminateInstallProgress(effectiveInstallProgress(job))) return true
return !!(job.summary.bytes_total && job.summary.bytes_total > 0)
}
function parallelPercent(job: InstallJobSnapshot) {
if (isParallelTrackComplete(job)) return 100
const progress = effectiveParallelProgress(job)
if (!hasDeterminateInstallProgress(progress)) return 0
return Math.min(100, Math.max(0, (progress.current / progress.total) * 100))
}
function isFinished(job: InstallJobSnapshot) {
return job.status === 'succeeded'
}
function isMainTrackComplete(job: InstallJobSnapshot) {
if (isFinished(job)) return true
const progress = effectiveInstallProgress(job)
if (job.phase === 'downloading_content' && progress?.total === 0) return true
return hasDeterminateInstallProgress(progress) && progress.current >= progress.total
}
function isParallelTrackComplete(job: InstallJobSnapshot) {
if (isFinished(job)) return true
const progress = effectiveParallelProgress(job)
if (progress?.total === 0) return true
return hasDeterminateInstallProgress(progress) && progress.current >= progress.total
}
function progressColor(job: InstallJobSnapshot) {
return isMainTrackComplete(job) ? 'green' : 'brand'
}
function parallelProgressColor(job: InstallJobSnapshot) {
return isParallelTrackComplete(job) ? 'green' : 'brand'
}
function hasDeterminateParallelProgress(job: InstallJobSnapshot) {
return hasDeterminateInstallProgress(effectiveParallelProgress(job))
}
function parallelProgressText(job: InstallJobSnapshot) {
if (isParallelTrackComplete(job)) return statusLabel('completed')
const progress = effectiveParallelProgress(job)
if (!hasDeterminateInstallProgress(progress)) {
return job.parallel ? phaseLabel(job.parallel.phase) : ''
}
return `${phaseLabel(job.parallel!.phase)}: ${formatBytes(progress.current)} / ${formatBytes(progress.total)}`
}
function progressText(job: InstallJobSnapshot) {
if (isMainTrackComplete(job)) return statusLabel('completed')
const textSource = installProgressTextSource(job)
if (textSource.type === 'required_files') {
return `${completedRequiredFiles(job)} / ${totalRequiredFiles(job)}`
}
if (isLocalRecoveryValidation(job)) {
const progress = effectiveInstallProgress(job)
if (hasDeterminateInstallProgress(progress)) {
const checked = job.progress?.secondary
? `${formatBytes(progress.current)} / ${formatBytes(progress.total)}`
: `${progress.current} / ${progress.total}`
return `${formatMessage(messages.verifyingDownloadedFiles)}: ${checked}`
}
return formatMessage(messages.verifyingDownloadedFiles)
}
const finalStage = job.items.find(
(item) =>
item.status === 'waiting_for_resource' ||
item.status === 'worker_started' ||
item.status === 'connecting' ||
item.status === 'writing' ||
item.status === 'verifying' ||
item.status === 'metadata' ||
item.status === 'waiting_for_database' ||
item.status === 'finalizing',
)
if (finalStage) return statusLabel(finalStage.status)
const progress = effectiveInstallProgress(job)
if (
job.status === 'running' &&
hasDeterminateInstallProgress(progress) &&
progress.current >= progress.total &&
activeRequestItems(job).length === 0
)
return statusLabel('verifying')
if (textSource.type === 'bytes')
return `${formatBytes(textSource.current)} / ${formatBytes(textSource.total)}`
if (textSource.type === 'items') return `${textSource.current} / ${textSource.total}`
return phaseLabel(job.phase)
}
function isResolvedRequiredFile(item: DownloadItem) {
return item.status === 'completed' || (item.status === 'skipped' && item.manual_url == null)
}
function isUnresolvedRequiredFile(item: DownloadItem) {
return item.status === 'failed' || (item.status === 'skipped' && item.manual_url != null)
}
function completedRequiredFiles(job: InstallJobSnapshot) {
return job.items.filter(isResolvedRequiredFile).length
}
function hasCurrentRequiredFileResolutionState(job: InstallJobSnapshot) {
return (
job.items.some(isUnresolvedRequiredFile) ||
(job.items.length > 0 && job.items.every(isResolvedRequiredFile))
)
}
function missingRequiredFiles(job: InstallJobSnapshot) {
if (hasCurrentRequiredFileResolutionState(job)) {
return job.items.filter(isUnresolvedRequiredFile).length
}
if (job.pause_reason?.type === 'missing_required_content') {
return job.pause_reason.failed_files
}
return 0
}
function totalRequiredFiles(job: InstallJobSnapshot) {
const completed = completedRequiredFiles(job)
const missing = missingRequiredFiles(job)
return Math.max(job.summary.files_total ?? 0, job.items.length, completed + missing)
}
function downloadDetails(job: InstallJobSnapshot) {
const summary = job.summary
const metrics: string[] = []
if (summary.source && !isRecoveryValidation(job)) {
metrics.push(
formatMessage(messages.downloadSource, {
source: downloadSourceLabel(summary.source),
}),
)
}
if (summary.speed_bytes_per_second && summary.speed_bytes_per_second > 0) {
metrics.push(
formatMessage(messages.downloadSpeed, {
speed: formatBytes(summary.speed_bytes_per_second),
}),
)
}
if (summary.eta_seconds != null) {
metrics.push(formatDownloadEta(summary.eta_seconds))
}
if (summary.fallback_count > 0 && !isRecoveryValidation(job)) {
metrics.push(formatMessage(messages.downloadFallbacks, { count: summary.fallback_count }))
}
return metrics
}
function downloadSourceLabel(source: string) {
switch (source) {
case 'official':
return formatMessage(messages.downloadSourceOfficial)
case 'bmclapi':
return formatMessage(messages.downloadSourceBmclapi)
case 'mcim':
return formatMessage(messages.downloadSourceMcim)
case 'tianpao':
return formatMessage(messages.downloadSourceTianpao)
default:
return formatMessage(messages.downloadSourceAlternate)
}
}
const STATUS_ORDER: Record<string, number> = {
failed: 0,
skipped: 1,
waiting_for_user: 2,
downloading: 3,
verifying: 3,
writing: 3,
queued: 4,
canceled: 5,
completed: 6,
}
function compareItemStatus(a: DownloadItem, b: DownloadItem) {
return (STATUS_ORDER[a.status] ?? 9) - (STATUS_ORDER[b.status] ?? 9)
}
const itemsCache = new Map<string, { items: InstallJobSnapshot['items']; result: DownloadItem[] }>()
function reorderJobItems(job: InstallJobSnapshot): DownloadItem[] {
const seen = itemsCache.get(job.job_id)
if (seen && seen.items === job.items) return seen.result
const result = [...job.items].sort(compareItemStatus)
itemsCache.set(job.job_id, { items: job.items, result })
return result
}
function activeRequestItems(job: InstallJobSnapshot) {
return job.items.filter((item) => item.status === 'downloading' && item.request_url)
}
function formatDownloadEta(seconds: number) {
const clamped = Math.max(0, Math.round(seconds))
if (clamped < 60) {
return formatMessage(messages.downloadEtaSeconds, { seconds: clamped })
}
const minutes = Math.floor(clamped / 60)
if (minutes < 60) {
return formatMessage(messages.downloadEtaMinutes, { minutes })
}
return formatMessage(messages.downloadEtaHours, {
hours: Math.floor(minutes / 60),
minutes: minutes % 60,
})
}
function itemProgress(item: DownloadItem) {
if (!item.bytes_total) return formatMessage(messages.notAvailable)
return `${formatBytes(item.bytes_downloaded)} / ${formatBytes(item.bytes_total)}`
}
function itemAttempts(item: DownloadItem) {
if (item.attempt == null || item.max_attempts == null) return formatMessage(messages.notAvailable)
return formatMessage(messages.attemptProgress, {
attempt: item.attempt,
maxAttempts: item.max_attempts,
})
}
function itemError(item: DownloadItem) {
if (item.error?.includes('requires manual download')) {
return formatMessage(messages.manualDownloadRequired)
}
return item.error ?? ''
}
async function openManualDownload(item: DownloadItem) {
if (item.manual_url) await openUrl(item.manual_url)
}
function legacyPercent(bar: LoadingBar) {
if (!bar.total) return 0
return Math.min(100, Math.max(0, ((bar.current ?? 0) / bar.total) * 100))
}
function formatDate(value: string) {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(
new Date(value),
)
}
function selectTab(index: number) {
focusState.value = stopDownloadFocusAutoFollow(focusState.value)
tab.value = index === 0 ? 'active' : 'history'
}
function focusedJobElement(jobId: string): HTMLElement | null {
return (
[...document.querySelectorAll<HTMLElement>('[data-install-job-id]')].find(
(element) => element.dataset.installJobId === jobId,
) ?? null
)
}
watch(
[focusedJobId, manager.jobs],
async ([jobId, jobs]) => {
if (focusState.value.jobId !== jobId) {
focusState.value = createDownloadFocusState(jobId)
}
const effect = reconcileDownloadFocus(
focusState.value,
jobId ? (jobs.find((job) => job.job_id === jobId) ?? null) : null,
)
focusState.value = effect.state
if (effect.tab) tab.value = effect.tab
if (effect.expand && jobId) {
expanded.value = new Set([...expanded.value, jobId])
}
if (effect.scroll && jobId) {
await nextTick()
focusedJobElement(jobId)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
},
{ immediate: true },
)
function toggleExpanded(jobId: string) {
const next = new Set(expanded.value)
if (next.has(jobId)) {
next.delete(jobId)
} else {
next.add(jobId)
}
expanded.value = next
}
async function withBusy(jobId: string, action: () => Promise<void>) {
busy.value = new Set([...busy.value, jobId])
try {
await action()
} catch (error) {
handleError(error)
} finally {
const next = new Set(busy.value)
next.delete(jobId)
busy.value = next
}
}
async function cancel(job: InstallJobSnapshot) {
await withBusy(job.job_id, () => manager.cancel(job.job_id))
}
async function retry(job: InstallJobSnapshot) {
await withBusy(job.job_id, () => manager.retry(job.job_id))
}
async function skipMissingContent(job: InstallJobSnapshot) {
await withBusy(job.job_id, () => manager.skipMissingContent(job.job_id))
}
async function resolveMissing(job: InstallJobSnapshot) {
await withBusy(job.job_id, async () => {
const fallbackCurseForgeItems = job.items
.filter(
(item) =>
item.status === 'skipped' && item.manual_url && item.project_id && item.version_id,
)
.map((item): CurseForgeManualDownloadItem => ({
projectId: Number(item.project_id),
fileId: Number(item.version_id),
fileName: item.name,
websiteUrl: item.manual_url ?? undefined,
}))
const instanceId = job.instance_id
const hasGeneralMissingItems = job.items.some((item) => item.status === 'failed')
if (instanceId && (job.provider === 'curse_forge' || fallbackCurseForgeItems.length > 0)) {
try {
const pending = await listPendingCurseForgeManualDownloads(instanceId)
if (pending.length > 0) {
showCurseForgeManualDownloads(instanceId, pending)
return
}
await manager.refresh()
if (!hasGeneralMissingItems) return
} catch (error) {
handleError(error)
if (fallbackCurseForgeItems.length > 0) {
showCurseForgeManualDownloads(instanceId, fallbackCurseForgeItems)
return
}
if (!hasGeneralMissingItems) return
}
}
await missingContentModal.value?.show(job)
})
}
async function remove(job: InstallJobSnapshot) {
await withBusy(job.job_id, () => manager.remove(job.job_id))
}
async function copyDiagnostics(job: InstallJobSnapshot) {
await withBusy(job.job_id, async () =>
navigator.clipboard.writeText(await download_job_support_details(job.job_id)),
)
}
async function clearHistory() {
try {
await manager.clearHistory()
} catch (error) {
handleError(error)
}
}
</script>