Files
modrinth/apps/app-frontend/src/composables/browse/install-job-notifications.ts
T
Calum H.andtdgao 3e07267e10 feat: preferences syncing frontend (#7192)
* feat: prefs frontend

* fix: DI issue

* fix: lint

* feat: appearance settings cleanup + fix settings, remove pinia

* fix: sync btn when logged out

* fix: prepr

* fix: sidebar issue

* feat: language coverage + cleanup

* feat: cleanup lang settings

* fix: fmt

* fix: CI

* fix: ci

* fix: storybook

* feat: bring back loader/game version sort

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
2026-08-20 07:23:01 +00:00

656 lines
19 KiB
TypeScript

import { CheckIcon, CopyIcon, UpdatedIcon } from '@modrinth/assets'
import {
defineMessages,
type PopupNotificationButton,
type PopupNotificationProgressItem,
type PopupNotificationProgressType,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref } from 'vue'
import type { Router } from 'vue-router'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import {
install_job_dismiss,
install_job_list,
install_job_retry,
install_job_support_details,
installJobInstanceId,
type InstallJobSnapshot,
type InstallJobStatus,
type InstallPhaseId,
type InstallProgress,
} from '@/helpers/install'
import { get_many as getInstances } from '@/helpers/instance'
import { injectAppEvents } from '@/providers/app-events'
const messages = defineMessages({
installs: {
id: 'app.action-bar.installs',
defaultMessage: 'Installs',
},
retry: {
id: 'app.action-bar.install.retry',
defaultMessage: 'Retry',
},
copyDetails: {
id: 'app.action-bar.install.copy-details',
defaultMessage: 'Copy details',
},
copied: {
id: 'app.action-bar.install.copied-details',
defaultMessage: 'Copied',
},
dismiss: {
id: 'app.action-bar.install.dismiss',
defaultMessage: 'Dismiss',
},
openInstance: {
id: 'app.action-bar.install.open-instance',
defaultMessage: 'Open instance',
},
unknownInstance: {
id: 'app.action-bar.install.unknown-instance',
defaultMessage: 'Unknown instance',
},
updatingSharedContent: {
id: 'app.action-bar.install.updating-shared-content',
defaultMessage: 'Updating shared content',
},
})
const phaseMessages = defineMessages({
preparing_instance: {
id: 'app.install.phase.preparing_instance',
defaultMessage: 'Queued to install',
},
resolving_pack: {
id: 'app.install.phase.resolving_pack',
defaultMessage: 'Resolving content',
},
downloading_pack_file: {
id: 'app.install.phase.downloading_pack_file',
defaultMessage: 'Downloading pack file',
},
reading_pack_manifest: {
id: 'app.install.phase.reading_pack_manifest',
defaultMessage: 'Reading pack manifest',
},
downloading_content: {
id: 'app.install.phase.downloading_content',
defaultMessage: 'Downloading content',
},
extracting_overrides: {
id: 'app.install.phase.extracting_overrides',
defaultMessage: 'Extracting overrides',
},
resolving_minecraft: {
id: 'app.install.phase.resolving_minecraft',
defaultMessage: 'Resolving Minecraft',
},
resolving_loader: {
id: 'app.install.phase.resolving_loader',
defaultMessage: 'Resolving loader',
},
preparing_java: {
id: 'app.install.phase.preparing_java',
defaultMessage: 'Preparing Java',
},
downloading_minecraft: {
id: 'app.install.phase.downloading_minecraft',
defaultMessage: 'Downloading Minecraft',
},
running_loader_processors: {
id: 'app.install.phase.running_loader_processors',
defaultMessage: 'Running loader processors',
},
finalizing: {
id: 'app.install.phase.finalizing',
defaultMessage: 'Finalizing',
},
rolling_back: {
id: 'app.install.phase.rolling_back',
defaultMessage: 'Rolling back',
},
})
const javaStepMessages = defineMessages({
resolving: {
id: 'app.install.phase.preparing_java.resolving',
defaultMessage: 'Preparing Java {version}',
},
fetching_metadata: {
id: 'app.install.phase.preparing_java.fetching-metadata',
defaultMessage: 'Fetching Java {version}',
},
downloading: {
id: 'app.install.phase.preparing_java.downloading',
defaultMessage: 'Downloading Java {version}',
},
extracting: {
id: 'app.install.phase.preparing_java.extracting',
defaultMessage: 'Extracting Java {version}',
},
validating: {
id: 'app.install.phase.preparing_java.validating',
defaultMessage: 'Validating Java {version}',
},
})
const failureSummaryMessages = defineMessages({
canceled: {
id: 'app.action-bar.install.summary.canceled',
defaultMessage: 'Canceled',
},
appClosed: {
id: 'app.action-bar.install.summary.app-closing',
defaultMessage: 'Canceled due to app closing',
},
downloadFailed: {
id: 'app.action-bar.install.summary.download-failed',
defaultMessage: "Download couldn't finish",
},
modrinthUnreachable: {
id: 'app.action-bar.install.summary.modrinth-unreachable',
defaultMessage: "Couldn't reach Modrinth",
},
packDownloadFailed: {
id: 'app.action-bar.install.summary.pack-download-failed',
defaultMessage: "Couldn't download pack",
},
badModpackFile: {
id: 'app.action-bar.install.summary.bad-modpack-file',
defaultMessage: "Couldn't read modpack",
},
invalidModpack: {
id: 'app.action-bar.install.summary.invalid-modpack',
defaultMessage: 'Modpack data invalid',
},
contentDownloadFailed: {
id: 'app.action-bar.install.summary.content-download-failed',
defaultMessage: "Couldn't download files",
},
corruptDownload: {
id: 'app.action-bar.install.summary.corrupt-download',
defaultMessage: 'Downloaded file is corrupt',
},
invalidModpackFiles: {
id: 'app.action-bar.install.summary.invalid-modpack-files',
defaultMessage: 'Modpack files have invalid metadata',
},
noWritePermission: {
id: 'app.action-bar.install.summary.no-write-permission',
defaultMessage: 'No permission to write',
},
couldNotSaveFiles: {
id: 'app.action-bar.install.summary.could-not-save-files',
defaultMessage: "Couldn't save files",
},
invalidFilePath: {
id: 'app.action-bar.install.summary.invalid-file-path',
defaultMessage: 'File path is invalid',
},
instanceNotFound: {
id: 'app.action-bar.install.summary.instance-not-found',
defaultMessage: "Instance couldn't be found",
},
cleanupIncomplete: {
id: 'app.action-bar.install.summary.cleanup-incomplete',
defaultMessage: "Cleanup didn't finish",
},
javaSetupFailed: {
id: 'app.action-bar.install.summary.java-setup-failed',
defaultMessage: "Java setup couldn't finish",
},
minecraftSetupFailed: {
id: 'app.action-bar.install.summary.minecraft-setup-failed',
defaultMessage: 'Minecraft setup failed',
},
loaderSetupFailed: {
id: 'app.action-bar.install.summary.loader-setup-failed',
defaultMessage: 'Loader setup failed',
},
localDataError: {
id: 'app.action-bar.install.summary.local-data-error',
defaultMessage: "Couldn't update local data",
},
unexpectedError: {
id: 'app.action-bar.install.summary.unexpected-error',
defaultMessage: 'Something went wrong',
},
})
const visibleJobStatuses = new Set<InstallJobStatus>(['queued', 'running', 'failed', 'interrupted'])
function getDisplayIconUrl(icon: string | null | undefined): string | null {
if (!icon) return null
if (/^(https?:|data:|blob:|asset:|tauri:)/.test(icon)) return icon
return convertFileSrc(icon)
}
export async function useInstallJobNotifications(opts: {
router: Router
handleError: (err: unknown) => void
onChange: () => void
}) {
const appEvents = injectAppEvents()
const { formatMessage } = useVIntl()
const appSettings = useAppSettings()
const jobs = ref<InstallJobSnapshot[]>([])
const iconUrls = ref<Record<string, string | null>>({})
const instanceNames = ref<Record<string, string>>({})
const copiedJobIds = ref<Set<string>>(new Set())
const jobOrder = new Map<string, number>()
let refreshRequest = 0
let metadataRequest = 0
let nextJobOrder = 0
const copiedResetTimeouts = new Map<string, number>()
function getTitle(job: InstallJobSnapshot): string {
if (job.display?.title) return job.display.title
if (job.details.type === 'instance') return job.details.name
if (job.details.type === 'modpack' && job.details.title) return job.details.title
const instanceId = installJobInstanceId(job)
return (
(instanceId ? instanceNames.value[instanceId] : null) ??
formatMessage(messages.unknownInstance)
)
}
function getText(job: InstallJobSnapshot): string {
if (job.status === 'failed' || job.status === 'interrupted') {
return getFailureSummary(job)
}
if (job.phase === 'preparing_java' && job.details.type === 'java') {
return formatMessage(javaStepMessages[job.details.step], {
version: job.details.major_version,
})
}
if (job.kind === 'update_shared_instance' && job.phase === 'downloading_content') {
return formatMessage(messages.updatingSharedContent)
}
return formatMessage(phaseMessages[job.phase])
}
function getFailureSummary(job: InstallJobSnapshot): string {
const code = job.error?.code
const phase = job.error?.phase ?? job.phase
if (code === 'app_closed' || (job.status === 'interrupted' && code === 'interrupted')) {
return formatMessage(failureSummaryMessages.appClosed)
}
if (code === 'canceled') {
return formatMessage(failureSummaryMessages.canceled)
}
if (job.rollback_error || code === 'rollback_error') {
return formatMessage(failureSummaryMessages.cleanupIncomplete)
}
if (hasPermissionError(job)) {
return formatMessage(failureSummaryMessages.noWritePermission)
}
switch (code) {
case 'network_error':
return formatMessage(
phase === 'downloading_pack_file'
? failureSummaryMessages.packDownloadFailed
: failureSummaryMessages.downloadFailed,
)
case 'api_error':
return formatMessage(failureSummaryMessages.modrinthUnreachable)
case 'pack_error':
return formatMessage(
phase === 'downloading_pack_file'
? failureSummaryMessages.packDownloadFailed
: failureSummaryMessages.invalidModpack,
)
case 'archive_error':
return formatMessage(failureSummaryMessages.badModpackFile)
case 'parse_error':
return formatMessage(failureSummaryMessages.invalidModpack)
case 'content_error':
return formatMessage(failureSummaryMessages.invalidModpackFiles)
case 'hash_error':
return formatMessage(failureSummaryMessages.corruptDownload)
case 'filesystem_error':
return formatMessage(failureSummaryMessages.couldNotSaveFiles)
case 'path_error':
return formatMessage(failureSummaryMessages.invalidFilePath)
case 'instance_error':
return formatMessage(failureSummaryMessages.instanceNotFound)
case 'java_error':
return formatMessage(failureSummaryMessages.javaSetupFailed)
case 'loader_error':
case 'processor_error':
return formatMessage(failureSummaryMessages.loaderSetupFailed)
case 'database_error':
return formatMessage(failureSummaryMessages.localDataError)
case 'launcher_error':
case 'metadata_error':
return getFailureSummaryForPhase(phase)
default:
return getFailureSummaryForPhase(phase)
}
}
function getFailureSummaryForPhase(phase: InstallPhaseId): string {
switch (phase) {
case 'downloading_pack_file':
return formatMessage(failureSummaryMessages.packDownloadFailed)
case 'resolving_pack':
case 'reading_pack_manifest':
return formatMessage(failureSummaryMessages.invalidModpack)
case 'downloading_content':
return formatMessage(failureSummaryMessages.contentDownloadFailed)
case 'extracting_overrides':
return formatMessage(failureSummaryMessages.couldNotSaveFiles)
case 'resolving_minecraft':
case 'downloading_minecraft':
return formatMessage(failureSummaryMessages.minecraftSetupFailed)
case 'resolving_loader':
case 'running_loader_processors':
return formatMessage(failureSummaryMessages.loaderSetupFailed)
case 'preparing_java':
return formatMessage(failureSummaryMessages.javaSetupFailed)
case 'preparing_instance':
return formatMessage(failureSummaryMessages.instanceNotFound)
case 'rolling_back':
return formatMessage(failureSummaryMessages.cleanupIncomplete)
default:
return formatMessage(failureSummaryMessages.unexpectedError)
}
}
function hasPermissionError(job: InstallJobSnapshot): boolean {
const message = job.error?.message.toLowerCase() ?? ''
return (
message.includes('permission denied') ||
message.includes('access is denied') ||
message.includes('operation not permitted')
)
}
function getProgressType(job: InstallJobSnapshot): PopupNotificationProgressType | undefined {
if (!getEffectiveProgress(job)) return undefined
if (
job.phase === 'preparing_java' &&
job.details.type === 'java' &&
job.details.step === 'downloading'
) {
return 'bytes'
}
if (job.phase === 'downloading_content') {
return job.progress?.secondary ? 'bytes' : 'count'
}
if (
job.phase === 'downloading_pack_file' ||
job.phase === 'extracting_overrides' ||
job.phase === 'downloading_minecraft'
) {
return 'bytes'
}
if (job.phase === 'running_loader_processors') {
return 'count'
}
return 'percentage'
}
function getEffectiveProgress(job: InstallJobSnapshot): InstallProgress | null | undefined {
if (job.phase === 'downloading_content' && job.progress?.secondary) {
return job.progress.secondary
}
return job.progress
}
function getProgress(job: InstallJobSnapshot): number {
const progress = getEffectiveProgress(job)
if (!progress || progress.total <= 0) return 0
return Math.max(0, Math.min(1, progress.current / progress.total))
}
function isTerminalJob(job: InstallJobSnapshot): boolean {
return job.status === 'failed' || job.status === 'interrupted'
}
function getJobSortRank(job: InstallJobSnapshot): number {
if (isTerminalJob(job)) return 0
if (job.status === 'queued' || job.phase === 'preparing_instance') return 2
return 1
}
function shouldShowCopyDetails(job: InstallJobSnapshot): boolean {
return isTerminalJob(job) || appSettings.getFeatureFlag('always_show_copy_details')
}
function isCopied(job: InstallJobSnapshot): boolean {
return copiedJobIds.value.has(job.job_id)
}
function setCopied(job: InstallJobSnapshot) {
copiedJobIds.value = new Set([...copiedJobIds.value, job.job_id])
const existingTimeout = copiedResetTimeouts.get(job.job_id)
if (existingTimeout != null) {
window.clearTimeout(existingTimeout)
}
copiedResetTimeouts.set(
job.job_id,
window.setTimeout(() => {
copiedResetTimeouts.delete(job.job_id)
if (!copiedJobIds.value.has(job.job_id)) {
return
}
const nextCopiedJobIds = new Set(copiedJobIds.value)
nextCopiedJobIds.delete(job.job_id)
copiedJobIds.value = nextCopiedJobIds
opts.onChange()
}, 1_000),
)
opts.onChange()
}
async function copyJobDetails(job: InstallJobSnapshot) {
const details = await install_job_support_details(job.job_id).catch((error) => {
opts.handleError(error)
return null
})
if (!details) {
return
}
try {
await navigator.clipboard.writeText(details)
setCopied(job)
} catch (error) {
opts.handleError(error)
}
}
function getButtons(job: InstallJobSnapshot): PopupNotificationButton[] {
const buttons: PopupNotificationButton[] = []
if (isTerminalJob(job)) {
buttons.push({
label: formatMessage(messages.retry),
icon: UpdatedIcon,
color: 'brand',
keepOpen: true,
action: async () => {
await install_job_retry(job.job_id).catch(opts.handleError)
await refresh()
},
})
}
if (shouldShowCopyDetails(job)) {
const copied = isCopied(job)
buttons.push({
label: formatMessage(copied ? messages.copied : messages.copyDetails),
icon: copied ? CheckIcon : CopyIcon,
color: 'standard',
keepOpen: true,
action: async () => {
await copyJobDetails(job)
},
})
}
return buttons
}
function getDismissHandler(job: InstallJobSnapshot): (() => Promise<void>) | undefined {
if (isTerminalJob(job)) {
return async () => {
await install_job_dismiss(job.job_id).catch(opts.handleError)
await refresh()
}
}
return undefined
}
function setJobs(nextJobs: InstallJobSnapshot[]) {
for (const job of nextJobs) {
if (!jobOrder.has(job.job_id)) {
jobOrder.set(job.job_id, nextJobOrder++)
}
}
const visibleJobs = nextJobs.filter((job) => visibleJobStatuses.has(job.status))
jobs.value = visibleJobs.sort(
(a, b) =>
getJobSortRank(a) - getJobSortRank(b) ||
a.created.localeCompare(b.created) ||
(jobOrder.get(a.job_id) ?? 0) - (jobOrder.get(b.job_id) ?? 0),
)
}
const activeJobs = computed(() =>
jobs.value.filter((job) => job.status === 'queued' || job.status === 'running'),
)
const progressItems = computed<PopupNotificationProgressItem[]>(() =>
activeJobs.value.map((job) => {
const progress = getEffectiveProgress(job)
return {
id: job.job_id,
title: getTitle(job),
text: getText(job),
iconUrl: iconUrls.value[job.job_id] ?? null,
progress: getProgress(job),
waiting: !job.progress && job.status === 'running',
showProgress: job.status === 'running',
progressType: getProgressType(job),
progressCurrent: progress?.current,
progressTotal: progress?.total,
buttons: getButtons(job),
}
}),
)
const terminalNotifications = computed(() =>
jobs.value.filter(isTerminalJob).map((job) => ({
id: job.job_id,
title: getTitle(job),
text: getText(job),
type: job.status === 'failed' ? ('error' as const) : ('warning' as const),
buttons: getButtons(job),
onDismiss: getDismissHandler(job),
})),
)
async function refreshMetadata(notify = true) {
const request = ++metadataRequest
const sourceJobs = jobs.value
const instanceIds = Array.from(
new Set(
sourceJobs
.map((job) => installJobInstanceId(job))
.filter((instanceId): instanceId is string => !!instanceId),
),
)
const instances = instanceIds.length
? await getInstances(instanceIds).catch((error) => {
opts.handleError(error)
return []
})
: []
if (request !== metadataRequest) {
return
}
const instanceIconUrls = new Map(
instances.map((instance) => [instance.id, getDisplayIconUrl(instance.icon_path)]),
)
instanceNames.value = Object.fromEntries(
instances.map((instance) => [instance.id, instance.name]),
)
iconUrls.value = Object.fromEntries(
sourceJobs.map((job) => [
job.job_id,
getDisplayIconUrl(job.display?.icon) ??
instanceIconUrls.get(installJobInstanceId(job) ?? '') ??
null,
]),
)
if (notify) {
opts.onChange()
}
}
async function refresh(notify = true) {
const request = ++refreshRequest
const nextJobs = await install_job_list(false).catch((error) => {
opts.handleError(error)
return []
})
if (request !== refreshRequest) {
return
}
setJobs(nextJobs)
await refreshMetadata(false)
if (request !== refreshRequest) {
return
}
if (notify) {
opts.onChange()
}
}
function applyJobUpdate(job: InstallJobSnapshot) {
refreshRequest += 1
const existingJob = jobs.value.find((item) => item.job_id === job.job_id)
if (existingJob && existingJob.modified.localeCompare(job.modified) > 0) {
return
}
setJobs([...jobs.value.filter((item) => item.job_id !== job.job_id), job])
opts.onChange()
void refreshMetadata()
}
const unlisten = appEvents.on('install_job', applyJobUpdate)
await refresh(false)
return {
active: computed(() => activeJobs.value.length > 0),
title: computed(() => formatMessage(messages.installs)),
progressItems,
terminalNotifications,
refresh,
dispose: () => {
for (const timeout of copiedResetTimeouts.values()) {
window.clearTimeout(timeout)
}
unlisten()
},
}
}