diff --git a/Cargo.lock b/Cargo.lock index 028da5aaf8..ad23da4124 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1628,6 +1628,31 @@ dependencies = [ "serde_with", ] +[[package]] +name = "bon" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +dependencies = [ + "darling 0.23.0", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.106", +] + [[package]] name = "borsh" version = "1.5.7" @@ -10849,6 +10874,7 @@ dependencies = [ "async-walkdir", "async_zip", "base64 0.22.1", + "bon", "bytemuck", "bytes", "chardetng", diff --git a/Cargo.toml b/Cargo.toml index a7a6d055b3..301bed803f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [ ] } base64 = "0.22.1" bitflags = "2.9.4" +bon = "3.9.3" bytemuck = "1.24.0" bytes = "1.10.1" censor = "0.3.0" diff --git a/apps/app-frontend/src/composables/browse/install-job-notifications.ts b/apps/app-frontend/src/composables/browse/install-job-notifications.ts index 2346f77002..8d7eaa832d 100644 --- a/apps/app-frontend/src/composables/browse/install-job-notifications.ts +++ b/apps/app-frontend/src/composables/browse/install-job-notifications.ts @@ -1,4 +1,4 @@ -import { UpdatedIcon } from '@modrinth/assets' +import { CheckIcon, CopyIcon, UpdatedIcon } from '@modrinth/assets' import { defineMessages, type PopupNotificationButton, @@ -15,9 +15,11 @@ 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' @@ -31,6 +33,14 @@ const messages = defineMessages({ 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', @@ -39,22 +49,6 @@ const messages = defineMessages({ id: 'app.action-bar.install.open-instance', defaultMessage: 'Open instance', }, - installFailed: { - id: 'app.action-bar.install.failed', - defaultMessage: 'Install failed', - }, - installFailedAppClosed: { - id: 'app.action-bar.install.failed-app-closed', - defaultMessage: 'Installation failed due to app closing.', - }, - installFailedNetwork: { - id: 'app.action-bar.install.failed-network', - defaultMessage: 'Installation failed due to a network error.', - }, - installFailedUnknown: { - id: 'app.action-bar.install.failed-unknown', - defaultMessage: 'Installation failed due to an unknown error.', - }, unknownInstance: { id: 'app.action-bar.install.unknown-instance', defaultMessage: 'Unknown instance', @@ -64,7 +58,7 @@ const messages = defineMessages({ const phaseMessages = defineMessages({ preparing_instance: { id: 'app.install.phase.preparing_instance', - defaultMessage: 'Preparing instance', + defaultMessage: 'Queued to install', }, resolving_pack: { id: 'app.install.phase.resolving_pack', @@ -139,7 +133,97 @@ const javaStepMessages = defineMessages({ }, }) +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(['queued', 'running', 'failed', 'interrupted']) +const copyDetailsStallMs = 30_000 + +interface ProgressSnapshot { + signature: string + changedAt: number + timeout: number | null +} function getDisplayIconUrl(icon: string | null | undefined): string | null { if (!icon) return null @@ -156,10 +240,13 @@ export async function useInstallJobNotifications(opts: { const jobs = ref([]) const iconUrls = ref>({}) const instanceNames = ref>({}) + const copiedJobIds = ref>(new Set()) const jobOrder = new Map() let refreshRequest = 0 let metadataRequest = 0 let nextJobOrder = 0 + const copiedResetTimeouts = new Map() + const progressSnapshots = new Map() function getTitle(job: InstallJobSnapshot): string { if (job.display?.title) return job.display.title @@ -174,13 +261,7 @@ export async function useInstallJobNotifications(opts: { function getText(job: InstallJobSnapshot): string { if (job.status === 'failed' || job.status === 'interrupted') { - if (job.error?.code === 'interrupted') { - return formatMessage(messages.installFailedAppClosed) - } - if (job.error?.code === 'network_error') { - return formatMessage(messages.installFailedNetwork) - } - return formatMessage(messages.installFailedUnknown) + return getFailureSummary(job) } if (job.phase === 'preparing_java' && job.details.type === 'java') { return formatMessage(javaStepMessages[job.details.step], { @@ -190,6 +271,104 @@ export async function useInstallJobNotifications(opts: { 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 ( @@ -235,11 +414,165 @@ export async function useInstallJobNotifications(opts: { return job.status === 'failed' || job.status === 'interrupted' } - function getTerminalButtons(job: InstallJobSnapshot): PopupNotificationButton[] | undefined { - if (!isTerminalJob(job)) return undefined + function canShowStalledProgressDetails(job: InstallJobSnapshot): boolean { + return ( + job.status === 'running' && + job.phase !== 'preparing_instance' && + job.phase !== 'finalizing' && + job.phase !== 'rolling_back' + ) + } + + function getJobSortRank(job: InstallJobSnapshot): number { + if (isTerminalJob(job)) return 0 + if (job.status === 'queued' || job.phase === 'preparing_instance') return 2 + return 1 + } + function progressSignature(job: InstallJobSnapshot): string { + const progress = job.progress + const secondary = progress?.secondary return [ - { + job.status, + job.phase, + JSON.stringify(job.details), + progress?.current ?? '', + progress?.total ?? '', + secondary?.current ?? '', + secondary?.total ?? '', + ].join(':') + } + + function clearCopied(jobId: string) { + if (!copiedJobIds.value.has(jobId)) { + return + } + + const timeout = copiedResetTimeouts.get(jobId) + if (timeout != null) { + window.clearTimeout(timeout) + copiedResetTimeouts.delete(jobId) + } + + const nextCopiedJobIds = new Set(copiedJobIds.value) + nextCopiedJobIds.delete(jobId) + copiedJobIds.value = nextCopiedJobIds + } + + function clearProgressSnapshot(jobId: string) { + const snapshot = progressSnapshots.get(jobId) + if (snapshot?.timeout != null) { + window.clearTimeout(snapshot.timeout) + } + progressSnapshots.delete(jobId) + } + + function scheduleStaleProgressRefresh(jobId: string) { + const snapshot = progressSnapshots.get(jobId) + if (!snapshot) { + return + } + + snapshot.timeout = window.setTimeout(() => { + const snapshot = progressSnapshots.get(jobId) + if (!snapshot) { + return + } + + snapshot.timeout = null + opts.onChange() + }, copyDetailsStallMs) + } + + function syncProgressSnapshots(nextJobs: InstallJobSnapshot[]) { + const trackedJobIds = new Set() + const now = Date.now() + + for (const job of nextJobs) { + if (!canShowStalledProgressDetails(job)) { + continue + } + + trackedJobIds.add(job.job_id) + const signature = progressSignature(job) + const snapshot = progressSnapshots.get(job.job_id) + if (snapshot?.signature === signature) { + continue + } + + clearProgressSnapshot(job.job_id) + clearCopied(job.job_id) + progressSnapshots.set(job.job_id, { + signature, + changedAt: now, + timeout: null, + }) + scheduleStaleProgressRefresh(job.job_id) + } + + for (const jobId of progressSnapshots.keys()) { + if (!trackedJobIds.has(jobId)) { + clearProgressSnapshot(jobId) + } + } + } + + function hasStalledProgress(job: InstallJobSnapshot): boolean { + const snapshot = progressSnapshots.get(job.job_id) + return !!snapshot && Date.now() - snapshot.changedAt >= copyDetailsStallMs + } + + function shouldShowCopyDetails(job: InstallJobSnapshot): boolean { + return isTerminalJob(job) || (canShowStalledProgressDetails(job) && hasStalledProgress(job)) + } + + 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', @@ -248,8 +581,23 @@ export async function useInstallJobNotifications(opts: { 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 setJobs(nextJobs: InstallJobSnapshot[]) { @@ -259,13 +607,15 @@ export async function useInstallJobNotifications(opts: { } } - jobs.value = nextJobs - .filter((job) => visibleJobStatuses.has(job.status)) - .sort( - (a, b) => - a.created.localeCompare(b.created) || - (jobOrder.get(a.job_id) ?? 0) - (jobOrder.get(b.job_id) ?? 0), - ) + const visibleJobs = nextJobs.filter((job) => visibleJobStatuses.has(job.status)) + syncProgressSnapshots(visibleJobs) + + 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 progressItems = computed(() => @@ -284,7 +634,7 @@ export async function useInstallJobNotifications(opts: { progressType: isTerminalJob(job) ? undefined : getProgressType(job), progressCurrent: isTerminalJob(job) ? undefined : progress?.current, progressTotal: isTerminalJob(job) ? undefined : progress?.total, - buttons: getTerminalButtons(job), + buttons: getButtons(job), onDismiss: isTerminalJob(job) ? async () => { await install_job_dismiss(job.job_id).catch(opts.handleError) @@ -382,6 +732,14 @@ export async function useInstallJobNotifications(opts: { progressItems, buttons, refresh, - dispose: () => unlisten(), + dispose: () => { + for (const timeout of copiedResetTimeouts.values()) { + window.clearTimeout(timeout) + } + for (const jobId of progressSnapshots.keys()) { + clearProgressSnapshot(jobId) + } + unlisten() + }, } } diff --git a/apps/app-frontend/src/helpers/install.ts b/apps/app-frontend/src/helpers/install.ts index 20ca0810a0..76fc45cfa4 100644 --- a/apps/app-frontend/src/helpers/install.ts +++ b/apps/app-frontend/src/helpers/install.ts @@ -85,6 +85,36 @@ export type InstallJavaStep = | 'extracting' | 'validating' +export interface InstallErrorView { + code: string + phase?: InstallPhaseId | null + message: string + api?: { + error: string + status?: number | null + method?: string | null + url?: string | null + route?: string | null + } | null + context?: { + operation: string + source_path?: string | null + target_path?: string | null + file_path?: string | null + entry_path?: string | null + urls?: string[] + expected_hash?: string | null + expected_size?: number | null + project_id?: string | null + version_id?: string | null + minecraft_version?: string | null + loader?: string | null + java_version?: number | null + os?: string | null + arch?: string | null + } | null +} + export interface InstallJobSnapshot { job_id: string instance_id?: string | null @@ -114,7 +144,8 @@ export interface InstallJobSnapshot { } | { type: 'import'; launcher_type: string; instance_folder: string } display?: { title: string; icon?: string | null } | null - error?: { code: string; message: string } | null + error?: InstallErrorView | null + rollback_error?: InstallErrorView | null created: string modified: string finished?: string | null @@ -197,6 +228,10 @@ export async function install_job_dismiss(jobId: string) { return await invoke('plugin:install|install_job_dismiss', { jobId }) } +export async function install_job_support_details(jobId: string) { + return await invoke('plugin:install|install_job_support_details', { jobId }) +} + export function installJobInstanceId(job: InstallJobSnapshot): string | null { return job.instance_id ?? job.target.instance_id ?? null } diff --git a/apps/app-frontend/src/locales/cs-CZ/index.json b/apps/app-frontend/src/locales/cs-CZ/index.json index 3996f59835..b5b00a972a 100644 --- a/apps/app-frontend/src/locales/cs-CZ/index.json +++ b/apps/app-frontend/src/locales/cs-CZ/index.json @@ -14,18 +14,6 @@ "app.action-bar.install.dismiss": { "message": "Zavřít" }, - "app.action-bar.install.failed": { - "message": "Instalace selhala" - }, - "app.action-bar.install.failed-app-closed": { - "message": "Instalace selhala kvůli zavření aplikace." - }, - "app.action-bar.install.failed-network": { - "message": "Instalace selhala kvůli chybě sítě." - }, - "app.action-bar.install.failed-unknown": { - "message": "Instalace selhala kvůli neznámé chybě." - }, "app.action-bar.install.open-instance": { "message": "Otevřít instanci" }, diff --git a/apps/app-frontend/src/locales/de-CH/index.json b/apps/app-frontend/src/locales/de-CH/index.json index 7b0b52ac5e..1d02989b17 100644 --- a/apps/app-frontend/src/locales/de-CH/index.json +++ b/apps/app-frontend/src/locales/de-CH/index.json @@ -14,18 +14,6 @@ "app.action-bar.install.dismiss": { "message": "Ignorieren" }, - "app.action-bar.install.failed": { - "message": "Installation fehlgeschlagen" - }, - "app.action-bar.install.failed-app-closed": { - "message": "Installation aufgrund des Schließens der App fehlgeschlagen." - }, - "app.action-bar.install.failed-network": { - "message": "Installation aufgrund eines Netzwerkfehlers fehlgeschlagen." - }, - "app.action-bar.install.failed-unknown": { - "message": "Installation aufgrund eines unbekannten Fehlers fehlgeschlagen." - }, "app.action-bar.install.open-instance": { "message": "Instanz öffnen" }, diff --git a/apps/app-frontend/src/locales/de-DE/index.json b/apps/app-frontend/src/locales/de-DE/index.json index ccbbe77b5f..1711ab355a 100644 --- a/apps/app-frontend/src/locales/de-DE/index.json +++ b/apps/app-frontend/src/locales/de-DE/index.json @@ -14,18 +14,6 @@ "app.action-bar.install.dismiss": { "message": "Ausblenden" }, - "app.action-bar.install.failed": { - "message": "Installation fehlgeschlagen" - }, - "app.action-bar.install.failed-app-closed": { - "message": "Installation aufgrund des Schließens der App fehlgeschlagen." - }, - "app.action-bar.install.failed-network": { - "message": "Installation aufgrund eines Netzwerkfehlers fehlgeschlagen." - }, - "app.action-bar.install.failed-unknown": { - "message": "Installation aufgrund eines unbekannten Fehlers fehlgeschlagen." - }, "app.action-bar.install.open-instance": { "message": "Instanz öffnen" }, diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index fea079df51..b6a092b1d4 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -11,20 +11,14 @@ "app.action-bar.hide-more-running-instances": { "message": "Hide more running instances" }, - "app.action-bar.install.dismiss": { - "message": "Dismiss" - }, - "app.action-bar.install.failed": { - "message": "Install failed" - }, - "app.action-bar.install.failed-app-closed": { - "message": "Installation failed due to app closing." + "app.action-bar.install.copied-details": { + "message": "Copied" }, - "app.action-bar.install.failed-network": { - "message": "Installation failed due to a network error." + "app.action-bar.install.copy-details": { + "message": "Copy details" }, - "app.action-bar.install.failed-unknown": { - "message": "Installation failed due to an unknown error." + "app.action-bar.install.dismiss": { + "message": "Dismiss" }, "app.action-bar.install.open-instance": { "message": "Open instance" @@ -32,6 +26,66 @@ "app.action-bar.install.retry": { "message": "Retry" }, + "app.action-bar.install.summary.app-closing": { + "message": "Canceled due to app closing" + }, + "app.action-bar.install.summary.bad-modpack-file": { + "message": "Couldn't read modpack" + }, + "app.action-bar.install.summary.canceled": { + "message": "Canceled" + }, + "app.action-bar.install.summary.cleanup-incomplete": { + "message": "Cleanup didn't finish" + }, + "app.action-bar.install.summary.content-download-failed": { + "message": "Couldn't download files" + }, + "app.action-bar.install.summary.corrupt-download": { + "message": "Downloaded file is corrupt" + }, + "app.action-bar.install.summary.could-not-save-files": { + "message": "Couldn't save files" + }, + "app.action-bar.install.summary.download-failed": { + "message": "Download couldn't finish" + }, + "app.action-bar.install.summary.instance-not-found": { + "message": "Instance couldn't be found" + }, + "app.action-bar.install.summary.invalid-file-path": { + "message": "File path is invalid" + }, + "app.action-bar.install.summary.invalid-modpack": { + "message": "Modpack data invalid" + }, + "app.action-bar.install.summary.invalid-modpack-files": { + "message": "Modpack files have invalid metadata" + }, + "app.action-bar.install.summary.java-setup-failed": { + "message": "Java setup couldn't finish" + }, + "app.action-bar.install.summary.loader-setup-failed": { + "message": "Loader setup failed" + }, + "app.action-bar.install.summary.local-data-error": { + "message": "Couldn't update local data" + }, + "app.action-bar.install.summary.minecraft-setup-failed": { + "message": "Minecraft setup failed" + }, + "app.action-bar.install.summary.modrinth-unreachable": { + "message": "Couldn't reach Modrinth" + }, + "app.action-bar.install.summary.no-write-permission": { + "message": "No permission to write" + }, + "app.action-bar.install.summary.pack-download-failed": { + "message": "Couldn't download pack" + }, + "app.action-bar.install.summary.unexpected-error": { + "message": "Something went wrong" + }, "app.action-bar.install.unknown-instance": { "message": "Unknown instance" }, @@ -231,7 +285,7 @@ "message": "Finalizing" }, "app.install.phase.preparing_instance": { - "message": "Preparing instance" + "message": "Queued to install" }, "app.install.phase.preparing_java": { "message": "Preparing Java" diff --git a/apps/app-frontend/src/locales/es-419/index.json b/apps/app-frontend/src/locales/es-419/index.json index 8b3bfc865c..af39db4297 100644 --- a/apps/app-frontend/src/locales/es-419/index.json +++ b/apps/app-frontend/src/locales/es-419/index.json @@ -14,18 +14,6 @@ "app.action-bar.install.dismiss": { "message": "Descartar" }, - "app.action-bar.install.failed": { - "message": "La instalación falló" - }, - "app.action-bar.install.failed-app-closed": { - "message": "La instalación falló porque la aplicación se cerró." - }, - "app.action-bar.install.failed-network": { - "message": "La instalación falló debido a un error de red." - }, - "app.action-bar.install.failed-unknown": { - "message": "La instalación falló debido a un error desconocido." - }, "app.action-bar.install.open-instance": { "message": "Abrir instancia" }, diff --git a/apps/app-frontend/src/locales/es-ES/index.json b/apps/app-frontend/src/locales/es-ES/index.json index 863c85f0fd..a1ff15b789 100644 --- a/apps/app-frontend/src/locales/es-ES/index.json +++ b/apps/app-frontend/src/locales/es-ES/index.json @@ -14,15 +14,6 @@ "app.action-bar.install.dismiss": { "message": "Descartar" }, - "app.action-bar.install.failed": { - "message": "La instalación falló" - }, - "app.action-bar.install.failed-app-closed": { - "message": "La instalación falló porque la aplicación se cerró." - }, - "app.action-bar.install.failed-network": { - "message": "La instalación falló debido a un error de red." - }, "app.action-bar.make-primary-instance": { "message": "Establecer como instancia principal" }, diff --git a/apps/app-frontend/src/locales/fi-FI/index.json b/apps/app-frontend/src/locales/fi-FI/index.json index 5f5020e903..dc3439adff 100644 --- a/apps/app-frontend/src/locales/fi-FI/index.json +++ b/apps/app-frontend/src/locales/fi-FI/index.json @@ -14,18 +14,6 @@ "app.action-bar.install.dismiss": { "message": "Hylkää" }, - "app.action-bar.install.failed": { - "message": "Asennus epäonnistui" - }, - "app.action-bar.install.failed-app-closed": { - "message": "Asennus epäonnistui sovelluksen sulkeutumisen vuoksi." - }, - "app.action-bar.install.failed-network": { - "message": "Asennus epäonnistui verkkovirheen vuoksi." - }, - "app.action-bar.install.failed-unknown": { - "message": "Asennus epäonnistui tuntemattoman virheen vuoksi." - }, "app.action-bar.install.open-instance": { "message": "Avaa instanssi" }, diff --git a/apps/app-frontend/src/locales/fr-FR/index.json b/apps/app-frontend/src/locales/fr-FR/index.json index 9461752870..11d89bcac8 100644 --- a/apps/app-frontend/src/locales/fr-FR/index.json +++ b/apps/app-frontend/src/locales/fr-FR/index.json @@ -14,18 +14,6 @@ "app.action-bar.install.dismiss": { "message": "Rejeter" }, - "app.action-bar.install.failed": { - "message": "Échec de l'installation" - }, - "app.action-bar.install.failed-app-closed": { - "message": "Échec de l'installation dû à la fermeture de l'app." - }, - "app.action-bar.install.failed-network": { - "message": "Échec de l'installation dû à une erreur réseau." - }, - "app.action-bar.install.failed-unknown": { - "message": "Échec de l'installation dû à une erreur inconnue." - }, "app.action-bar.install.open-instance": { "message": "Ouvrir l'instance" }, diff --git a/apps/app-frontend/src/locales/it-IT/index.json b/apps/app-frontend/src/locales/it-IT/index.json index 28adfc3dba..43021b2167 100644 --- a/apps/app-frontend/src/locales/it-IT/index.json +++ b/apps/app-frontend/src/locales/it-IT/index.json @@ -14,18 +14,6 @@ "app.action-bar.install.dismiss": { "message": "Ignora" }, - "app.action-bar.install.failed": { - "message": "Installazione fallita" - }, - "app.action-bar.install.failed-app-closed": { - "message": "Installazione fallita: l'app è stata chiusa." - }, - "app.action-bar.install.failed-network": { - "message": "Installazione fallita: errore di rete." - }, - "app.action-bar.install.failed-unknown": { - "message": "Installazione fallita: errore sconosciuto." - }, "app.action-bar.install.open-instance": { "message": "Apri istanza" }, diff --git a/apps/app-frontend/src/locales/pl-PL/index.json b/apps/app-frontend/src/locales/pl-PL/index.json index 0a52fa2be7..24d7824683 100644 --- a/apps/app-frontend/src/locales/pl-PL/index.json +++ b/apps/app-frontend/src/locales/pl-PL/index.json @@ -14,18 +14,6 @@ "app.action-bar.install.dismiss": { "message": "Odrzuć" }, - "app.action-bar.install.failed": { - "message": "Instalacja nie powiodła się" - }, - "app.action-bar.install.failed-app-closed": { - "message": "Instalacja nie powiodła się z powodu zamknięcia aplikacji." - }, - "app.action-bar.install.failed-network": { - "message": "Instalacja nie powiodła się z powodu problemów z połączeniem." - }, - "app.action-bar.install.failed-unknown": { - "message": "Instalacja nie powiodła się z nieznanych przyczyn." - }, "app.action-bar.install.open-instance": { "message": "Otwórz instancję" }, diff --git a/apps/app-frontend/src/locales/pt-BR/index.json b/apps/app-frontend/src/locales/pt-BR/index.json index 4ca721a2f6..c1ea5612fc 100644 --- a/apps/app-frontend/src/locales/pt-BR/index.json +++ b/apps/app-frontend/src/locales/pt-BR/index.json @@ -14,18 +14,6 @@ "app.action-bar.install.dismiss": { "message": "Dispensar" }, - "app.action-bar.install.failed": { - "message": "Instalação falha" - }, - "app.action-bar.install.failed-app-closed": { - "message": "A instalação falhou devido ao aplicativo ter fechado." - }, - "app.action-bar.install.failed-network": { - "message": "A instalação falhou devido a um erro na rede." - }, - "app.action-bar.install.failed-unknown": { - "message": "A instalação falhou devido a um erro desconhecido." - }, "app.action-bar.install.open-instance": { "message": "Abrir instância" }, diff --git a/apps/app-frontend/src/locales/ru-RU/index.json b/apps/app-frontend/src/locales/ru-RU/index.json index a722d690ee..8228ca52c5 100644 --- a/apps/app-frontend/src/locales/ru-RU/index.json +++ b/apps/app-frontend/src/locales/ru-RU/index.json @@ -14,18 +14,6 @@ "app.action-bar.install.dismiss": { "message": "Закрыть" }, - "app.action-bar.install.failed": { - "message": "Установка не удалась" - }, - "app.action-bar.install.failed-app-closed": { - "message": "Установка не удалась из-за закрытия приложения." - }, - "app.action-bar.install.failed-network": { - "message": "Установка не удалась из-за сетевой ошибки." - }, - "app.action-bar.install.failed-unknown": { - "message": "Установка не удалась из-за неизвестной ошибки." - }, "app.action-bar.install.open-instance": { "message": "Открыть сборку" }, diff --git a/apps/app-frontend/src/locales/sr-CS/index.json b/apps/app-frontend/src/locales/sr-CS/index.json index 69d8fcad39..a3a9c65270 100644 --- a/apps/app-frontend/src/locales/sr-CS/index.json +++ b/apps/app-frontend/src/locales/sr-CS/index.json @@ -1,10 +1,4 @@ { - "app.action-bar.install.failed": { - "message": "Neuspešna instalacija" - }, - "app.action-bar.install.failed-app-closed": { - "message": "Neuspešna instalacija zbog zatvaranja aplikacije." - }, "app.auth-servers.unreachable.body": { "message": "Minecraft serveri za autentifikaciju su možda trenutno nedostupni. Molimo vas da proverite vašu internet vezu i pokušajte ponovo kasnije." }, diff --git a/apps/app-frontend/src/locales/sv-SE/index.json b/apps/app-frontend/src/locales/sv-SE/index.json index 058ea2ba20..49a795670c 100644 --- a/apps/app-frontend/src/locales/sv-SE/index.json +++ b/apps/app-frontend/src/locales/sv-SE/index.json @@ -14,18 +14,6 @@ "app.action-bar.install.dismiss": { "message": "Avvisa" }, - "app.action-bar.install.failed": { - "message": "Installationen misslyckades" - }, - "app.action-bar.install.failed-app-closed": { - "message": "Installationen misslyckades för att appen stängdes." - }, - "app.action-bar.install.failed-network": { - "message": "Installationen misslyckades av ett nätverksfel." - }, - "app.action-bar.install.failed-unknown": { - "message": "Installationen misslyckades med ett okänt fel." - }, "app.action-bar.install.open-instance": { "message": "Öppna instans" }, diff --git a/apps/app-frontend/src/locales/tr-TR/index.json b/apps/app-frontend/src/locales/tr-TR/index.json index e39707996b..48e0b40a24 100644 --- a/apps/app-frontend/src/locales/tr-TR/index.json +++ b/apps/app-frontend/src/locales/tr-TR/index.json @@ -14,18 +14,6 @@ "app.action-bar.install.dismiss": { "message": "Reddet" }, - "app.action-bar.install.failed": { - "message": "Yükleme yapılamadı" - }, - "app.action-bar.install.failed-app-closed": { - "message": "Yükleme uygulama kapandığından yapılamadı." - }, - "app.action-bar.install.failed-network": { - "message": "Yükleme ağ hatasından dolayı yapılamadı." - }, - "app.action-bar.install.failed-unknown": { - "message": "Yükleme bilinmeyen bir hatadan yapılamadı." - }, "app.action-bar.install.open-instance": { "message": "Kurulumu aç" }, diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index 73c6437d42..c9965726fe 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -14,18 +14,6 @@ "app.action-bar.install.dismiss": { "message": "忽略" }, - "app.action-bar.install.failed": { - "message": "安装失败" - }, - "app.action-bar.install.failed-app-closed": { - "message": "由于应用关闭,安装失败。" - }, - "app.action-bar.install.failed-network": { - "message": "由于网络错误,安装失败。" - }, - "app.action-bar.install.failed-unknown": { - "message": "由于未知错误,安装失败。" - }, "app.action-bar.install.open-instance": { "message": "打开实例" }, diff --git a/apps/app-frontend/src/locales/zh-TW/index.json b/apps/app-frontend/src/locales/zh-TW/index.json index 10b15aca8f..f0e5896246 100644 --- a/apps/app-frontend/src/locales/zh-TW/index.json +++ b/apps/app-frontend/src/locales/zh-TW/index.json @@ -14,18 +14,6 @@ "app.action-bar.install.dismiss": { "message": "忽略" }, - "app.action-bar.install.failed": { - "message": "無法安裝" - }, - "app.action-bar.install.failed-app-closed": { - "message": "由於應用程式關閉,無法安裝。" - }, - "app.action-bar.install.failed-network": { - "message": "由於網路錯誤,無法安裝。" - }, - "app.action-bar.install.failed-unknown": { - "message": "由於未知錯誤,無法安裝。" - }, "app.action-bar.install.open-instance": { "message": "開啟實例" }, diff --git a/apps/app/build.rs b/apps/app/build.rs index 3bc241475d..8a8fe00c79 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -157,6 +157,7 @@ fn main() { "install_job_retry", "install_job_cancel", "install_job_dismiss", + "install_job_support_details", ]) .default_permission( DefaultPermissionRule::AllowAllCommands, diff --git a/apps/app/src/api/install.rs b/apps/app/src/api/install.rs index 393f4923c4..96d2f0481b 100644 --- a/apps/app/src/api/install.rs +++ b/apps/app/src/api/install.rs @@ -25,6 +25,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { install_job_retry, install_job_cancel, install_job_dismiss, + install_job_support_details, ]) .build() } @@ -169,3 +170,8 @@ pub async fn install_job_cancel(job_id: Uuid) -> Result { pub async fn install_job_dismiss(job_id: Uuid) -> Result<()> { Ok(theseus::install::dismiss_job(job_id).await?) } + +#[tauri::command] +pub async fn install_job_support_details(job_id: Uuid) -> Result { + Ok(theseus::install::job_support_details(job_id).await?) +} diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index 02af108160..b89ed98f6c 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -23,6 +23,7 @@ async_zip = { workspace = true, features = [ "zstd", ] } base64 = { workspace = true } +bon = { workspace = true } bytemuck = { workspace = true, features = ["extern_crate_alloc"] } bytes = { workspace = true, features = ["serde"] } chardetng = { workspace = true } diff --git a/packages/app-lib/src/api/instance/install.rs b/packages/app-lib/src/api/instance/install.rs index 8fb60cb580..ed308c0e9a 100644 --- a/packages/app-lib/src/api/instance/install.rs +++ b/packages/app-lib/src/api/instance/install.rs @@ -34,6 +34,7 @@ pub async fn get_optimal_jre_key( loader_version.as_ref(), None, None, + None, ) .await?; diff --git a/packages/app-lib/src/api/jre.rs b/packages/app-lib/src/api/jre.rs index cd9083031a..4944512591 100644 --- a/packages/app-lib/src/api/jre.rs +++ b/packages/app-lib/src/api/jre.rs @@ -1,8 +1,8 @@ //! Authentication flow interface use crate::event::emit::{emit_loading, init_loading}; use crate::install::{ - InstallJavaStep, InstallPhaseDetails, InstallPhaseId, InstallProgress, - InstallProgressReporter, + InstallErrorContext, InstallJavaStep, InstallPhaseDetails, InstallPhaseId, + InstallProgress, InstallProgressReporter, }; use crate::state::JavaVersion; use crate::util::fetch::{ @@ -148,23 +148,52 @@ async fn auto_install_java_inner( Some(java_step_progress(1)), ) .await?; + let metadata_url = format!( + "https://api.azul.com/metadata/v1/zulu/packages?arch={}&java_version={}&os={}&archive_type=zip&javafx_bundled=false&java_package_type=jre&page_size=1", + std::env::consts::ARCH, + java_version, + std::env::consts::OS + ); + if let Some(reporter) = &reporter { + reporter + .set_context( + InstallErrorContext::new("fetch Java package metadata") + .urls(vec![metadata_url.clone()]) + .java_version(java_version) + .os(std::env::consts::OS) + .arch(std::env::consts::ARCH) + .build(), + ) + .await?; + } let packages = fetch_json::>( - Method::GET, - &format!( - "https://api.azul.com/metadata/v1/zulu/packages?arch={}&java_version={}&os={}&archive_type=zip&javafx_bundled=false&java_package_type=jre&page_size=1", - std::env::consts::ARCH, java_version, std::env::consts::OS - ), - None, - None, - None, - &state.fetch_semaphore, - &state.pool, - ).await?; + Method::GET, + &metadata_url, + None, + None, + None, + &state.fetch_semaphore, + &state.pool, + ) + .await?; if let Some(loading_bar) = &loading_bar { emit_loading(loading_bar, 10.0, Some("Downloading java version"))?; } if let Some(download) = packages.first() { + if let Some(reporter) = &reporter { + reporter + .set_context( + InstallErrorContext::new("download Java archive") + .urls(vec![download.download_url.clone()]) + .file_path(download.name.display().to_string()) + .java_version(java_version) + .os(std::env::consts::OS) + .arch(std::env::consts::ARCH) + .build(), + ) + .await?; + } update_java_install_progress( reporter.as_ref(), java_version, @@ -237,6 +266,20 @@ async fn auto_install_java_inner( let path = state.directories.java_versions_dir(); + if let Some(reporter) = &reporter { + reporter + .set_context( + InstallErrorContext::new("read Java archive") + .urls(vec![download.download_url.clone()]) + .file_path(download.name.display().to_string()) + .target_path(path.display().to_string()) + .java_version(java_version) + .os(std::env::consts::OS) + .arch(std::env::consts::ARCH) + .build(), + ) + .await?; + } let mut archive = zip::ZipArchive::new(std::io::Cursor::new(file)) .map_err(|_| { crate::Error::from(crate::ErrorKind::InputError( @@ -265,6 +308,20 @@ async fn auto_install_java_inner( Some(java_step_progress(3)), ) .await?; + if let Some(reporter) = &reporter { + reporter + .set_context( + InstallErrorContext::new("extract Java archive") + .urls(vec![download.download_url.clone()]) + .file_path(download.name.display().to_string()) + .target_path(path.display().to_string()) + .java_version(java_version) + .os(std::env::consts::OS) + .arch(std::env::consts::ARCH) + .build(), + ) + .await?; + } archive.extract(&path).map_err(|_| { crate::Error::from(crate::ErrorKind::InputError( "Failed to extract java zip".to_string(), diff --git a/packages/app-lib/src/api/pack/install_from.rs b/packages/app-lib/src/api/pack/install_from.rs index eca55f2376..f721c7b92a 100644 --- a/packages/app-lib/src/api/pack/install_from.rs +++ b/packages/app-lib/src/api/pack/install_from.rs @@ -1,7 +1,7 @@ use crate::State; use crate::data::ModLoader; use crate::install::{ - InstallPhaseDetails, InstallPhaseId, InstallProgress, + InstallErrorContext, InstallPhaseDetails, InstallPhaseId, InstallProgress, InstallProgressReporter, }; use crate::state::{ @@ -343,6 +343,13 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter( }; let progress = Some(&mut progress as &mut FetchProgressFn<'_>); + let context = InstallErrorContext::new("download modpack file") + .urls(vec![url.clone()]) + .maybe_expected_hash(hash.cloned()) + .project_id(project_id.clone()) + .version_id(version_id.clone()) + .build(); + reporter.set_context(context).await?; let file = fetch_advanced_with_progress( Method::GET, &url, @@ -358,6 +365,10 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter( ) .await?; + reporter + .update(InstallPhaseId::ResolvingPack, None, details.clone()) + .await?; + let project = CachedEntry::get_project( &version.project_id, None, @@ -377,6 +388,15 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter( let icon = if has_icon_url { if let Some(icon_url) = project.icon_url { let state = State::get().await?; + reporter + .set_context( + InstallErrorContext::new("download modpack icon") + .urls(vec![icon_url.clone()]) + .project_id(project_id.clone()) + .version_id(version_id.clone()) + .build(), + ) + .await?; let icon_bytes = fetch( &icon_url, None, diff --git a/packages/app-lib/src/api/pack/install_mrpack.rs b/packages/app-lib/src/api/pack/install_mrpack.rs index 73b9912c77..5f77945f2b 100644 --- a/packages/app-lib/src/api/pack/install_mrpack.rs +++ b/packages/app-lib/src/api/pack/install_mrpack.rs @@ -1,23 +1,29 @@ use crate::State; use crate::event::emit::loading_try_for_each_concurrent; use crate::install::{ - InstallPhaseDetails, InstallPhaseId, InstallProgress, - InstallProgressReporter, InstallProgressSecondary, + InstallErrorContext, InstallJobEventKind, InstallPhaseDetails, + InstallPhaseId, InstallProgress, InstallProgressReporter, + InstallProgressSecondary, }; use crate::pack::install_from::{ EnvType, PackFile, PackFileHash, set_instance_information, }; use crate::state::instances::ContentSourceKind; use crate::state::{ - CachedEntry, EditInstance, InstanceInstallStage, SideType, cache_file_hash, + CachedEntry, CachedFile, EditInstance, InstanceInstallStage, SideType, + cache_file_hash, +}; +use crate::util::fetch::{ + DownloadMeta, DownloadReason, FetchProgressFn, fetch_mirrors_with_progress, + write, }; -use crate::util::fetch::{DownloadMeta, DownloadReason, fetch_mirrors, write}; use crate::util::io; use async_zip::base::read::seek::ZipFileReader as SeekZipFileReader; use async_zip::base::read::{WithEntry, ZipEntryReader}; use async_zip::tokio::read::fs::ZipFileReader as FsZipFileReader; use futures::StreamExt; use path_util::SafeRelativeUtf8UnixPathBuf; +use std::collections::HashMap; use std::future::Future; use std::pin::Pin; use std::sync::{ @@ -28,12 +34,80 @@ use std::sync::{ use super::install_from::{CreatePack, CreatePackFile, PackFormat}; use crate::data::ProjectType; use std::io::{Cursor, ErrorKind}; -use std::path::Path; +use std::path::{Path, PathBuf}; use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex; type ExtractProgressFn<'a> = dyn FnMut(u64) -> Pin> + Send + 'a>> + Send + 'a; +const MODPACK_CONTENT_DOWNLOAD_CONCURRENCY: usize = 4; + +#[derive(Clone)] +struct ModpackContentInstallContext { + instance_id: String, + instance_path: String, + instance_full_path: PathBuf, + download_meta: DownloadMeta, + pack_version_id: Option, + pack_project_id: Option, + reporter: InstallProgressReporter, + modpack_details: InstallPhaseDetails, + content_progress: Arc, + content_bytes_progress: Arc, + active_download_bytes: Arc>>, + file_infos_by_hash: Arc>, + num_files: usize, + content_total_bytes: u64, +} + +impl ModpackContentInstallContext { + async fn mark_downloaded( + &self, + file_size: u64, + event: InstallJobEventKind, + ) -> crate::Result<()> { + let current = self.content_progress.fetch_add(1, Ordering::Relaxed) + 1; + let current_bytes = self + .content_bytes_progress + .fetch_add(file_size, Ordering::Relaxed) + + file_size; + + self.reporter + .update_with_events( + InstallPhaseId::DownloadingContent, + Some(InstallProgress { + current, + total: self.num_files as u64, + secondary: (self.content_total_bytes > 0).then_some( + InstallProgressSecondary { + current: current_bytes + .min(self.content_total_bytes), + total: self.content_total_bytes, + }, + ), + }), + self.modpack_details.clone(), + vec![event], + ) + .await + } + + async fn remove_active_download(&self, path: &str) { + let mut active_download_bytes = self.active_download_bytes.lock().await; + active_download_bytes.remove(path); + } + + async fn update_active_download( + &self, + path: String, + downloaded: u64, + ) -> u64 { + let mut active_download_bytes = self.active_download_bytes.lock().await; + active_download_bytes.insert(path, downloaded); + active_download_bytes.values().sum::() + } +} enum MrpackZipReader { Memory(async_zip::tokio::read::seek::ZipFileReader>), @@ -246,7 +320,17 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter( let version_id = create_pack.description.version_id; let instance_id = create_pack.description.instance_id; let mut icon_exists = icon.is_some(); + let source_path = pack_source_path(&file); + reporter + .set_context( + InstallErrorContext::new("read modpack archive") + .maybe_project_id(project_id.clone()) + .maybe_version_id(version_id.clone()) + .source_path(source_path.clone()) + .build(), + ) + .await?; let mut zip_reader = MrpackZipReader::new(&file).await?; let instance_full_path = crate::api::instance::get_full_path(&instance_id).await?; @@ -262,6 +346,16 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter( modpack_details.clone(), ) .await?; + reporter + .set_context( + InstallErrorContext::new("read modpack manifest") + .maybe_project_id(project_id.clone()) + .maybe_version_id(version_id.clone()) + .source_path(source_path.clone()) + .entry_path("modrinth.index.json") + .build(), + ) + .await?; // Extract index of modrinth.index.json let Some(manifest_idx) = zip_reader.file().entries().iter().position(|f| { @@ -413,7 +507,7 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter( .map(|file| file.file_size as u64) .sum::(); reporter - .update( + .update_with_events( InstallPhaseId::DownloadingContent, Some(InstallProgress { current: 0, @@ -426,138 +520,276 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter( ), }), modpack_details.clone(), + vec![InstallJobEventKind::ContentDownloadStarted { + files: num_files as u64, + bytes: (content_total_bytes > 0).then_some(content_total_bytes), + }], ) .await?; let content_progress = Arc::new(AtomicU64::new(0)); let content_bytes_progress = Arc::new(AtomicU64::new(0)); + let active_download_bytes = + Arc::new(Mutex::new(HashMap::::new())); + let file_info_hashes = pack + .files + .iter() + .filter_map(|file| { + file.hashes.get(&PackFileHash::Sha1).map(String::as_str) + }) + .collect::>(); + let file_infos_by_hash = Arc::new( + CachedEntry::get_file_many( + &file_info_hashes, + None, + &state.pool, + &state.api_semaphore, + ) + .await? + .into_iter() + .map(|file| (file.hash.clone(), file)) + .collect::>(), + ); + let content_context = ModpackContentInstallContext { + instance_id: instance_id.clone(), + instance_path: instance_path.clone(), + instance_full_path: instance_full_path.clone(), + download_meta, + pack_version_id: version_id.clone(), + pack_project_id: project_id.clone(), + reporter: reporter.clone(), + modpack_details: modpack_details.clone(), + content_progress, + content_bytes_progress, + active_download_bytes, + file_infos_by_hash, + num_files, + content_total_bytes, + }; loading_try_for_each_concurrent( futures::stream::iter(pack.files).map(Ok::), - None, + Some(MODPACK_CONTENT_DOWNLOAD_CONCURRENCY), None, 70.0, num_files, None, |project| { - let instance_id = instance_id.clone(); - let instance_path = instance_path.clone(); - let instance_full_path = instance_full_path.clone(); - let download_meta = download_meta.clone(); - let pack_version_id = version_id.clone(); - let reporter = reporter.clone(); - let modpack_details = modpack_details.clone(); - let content_progress = content_progress.clone(); - let content_bytes_progress = content_bytes_progress.clone(); + let content_context = content_context.clone(); async move { - let mark_downloaded = |file_size: u64| { - let reporter = reporter.clone(); - let modpack_details = modpack_details.clone(); - let content_progress = content_progress.clone(); - let content_bytes_progress = content_bytes_progress.clone(); - async move { - let current = content_progress - .fetch_add(1, Ordering::Relaxed) - + 1; - let current_bytes = content_bytes_progress - .fetch_add(file_size, Ordering::Relaxed) - + file_size; - reporter - .update( - InstallPhaseId::DownloadingContent, - Some(InstallProgress { - current, - total: num_files as u64, - secondary: (content_total_bytes > 0) - .then_some(InstallProgressSecondary { - current: current_bytes - .min(content_total_bytes), - total: content_total_bytes, - }), - }), - modpack_details, - ) - .await?; - Ok::<(), crate::Error>(()) - } - }; + let project_size = project.file_size as u64; + let project_path = project.path.as_str().to_string(); + let target_path = content_context + .instance_full_path + .join(project.path.as_str()); //TODO: Future update: prompt user for optional files in a modpack - if let Some(env) = project.env + if let Some(env) = project.env.as_ref() && env .get(&EnvType::Client) .is_some_and(|x| x == &SideType::Unsupported) { - mark_downloaded(project.file_size as u64).await?; + content_context + .mark_downloaded( + project_size, + InstallJobEventKind::ContentFileSkipped { + path: project_path, + reason: "unsupported on client".to_string(), + }, + ) + .await?; return Ok(()); } - let file = fetch_mirrors( + let context = + InstallErrorContext::new("download modpack content file") + .maybe_project_id( + content_context.pack_project_id.clone(), + ) + .maybe_version_id( + content_context.pack_version_id.clone(), + ) + .file_path(project_path.clone()) + .target_path(target_path.display().to_string()) + .urls(project.downloads.clone()) + .maybe_expected_hash( + project.hashes.get(&PackFileHash::Sha1).cloned(), + ) + .expected_size(project_size) + .build(); + content_context + .reporter + .set_transient_context(context.clone()) + .await?; + + let progress_key = project_path.clone(); + let progress_context = content_context.clone(); + let min_download_progress_delta = + (project_size / 200).max(256 * 1024); + let mut last_reported_downloaded = 0_u64; + let mut report_download_progress = move |downloaded: u64, + _total_size: u64| + -> Pin> + Send>> { + if downloaded < project_size + && downloaded.saturating_sub(last_reported_downloaded) + < min_download_progress_delta + { + return Box::pin(async { Ok(()) }); + } + + last_reported_downloaded = downloaded; + let progress_context = progress_context.clone(); + let progress_key = progress_key.clone(); + Box::pin(async move { + let active_bytes = progress_context + .update_active_download(progress_key, downloaded) + .await; + let current_bytes = progress_context + .content_bytes_progress + .load(Ordering::Relaxed) + .saturating_add(active_bytes) + .min(progress_context.content_total_bytes); + progress_context + .reporter + .update( + InstallPhaseId::DownloadingContent, + Some(InstallProgress { + current: progress_context + .content_progress + .load(Ordering::Relaxed), + total: progress_context.num_files as u64, + secondary: (progress_context + .content_total_bytes + > 0) + .then_some(InstallProgressSecondary { + current: current_bytes, + total: progress_context + .content_total_bytes, + }), + }), + progress_context.modpack_details.clone(), + ) + .await?; + Ok(()) + }) + }; + let progress = + &mut report_download_progress as &mut FetchProgressFn<'_>; + let file = match fetch_mirrors_with_progress( &project .downloads .iter() .map(|x| &**x) .collect::>(), project.hashes.get(&PackFileHash::Sha1).map(|x| &**x), - Some(&download_meta), + Some(&content_context.download_meta), None, &state.fetch_semaphore, &state.pool, + Some(progress), ) - .await?; + .await + { + Ok(file) => { + content_context + .remove_active_download(&project_path) + .await; + file + } + Err(error) => { + content_context + .remove_active_download(&project_path) + .await; + content_context + .reporter + .persist_failure_context(context) + .await; + return Err(error); + } + }; + let downloaded_bytes = file.len() as u64; - let path = instance_full_path.join(project.path.as_str()); + let path = target_path; - cache_file_hash( - file.clone(), - &instance_path, - project.path.as_str(), - project.hashes.get(&PackFileHash::Sha1).map(|x| &**x), - ProjectType::get_from_parent_folder(&path), - None, - &state.pool, - ) - .await?; + { + let _permit = state.install_db_semaphore.acquire().await?; + content_context + .reporter + .preserve_failure_context( + context.clone(), + cache_file_hash( + file.clone(), + &content_context.instance_path, + project.path.as_str(), + project + .hashes + .get(&PackFileHash::Sha1) + .map(|x| &**x), + ProjectType::get_from_parent_folder(&path), + None, + &state.pool, + ) + .await, + ) + .await?; + } - write(&path, &file, &state.io_semaphore).await?; + content_context + .reporter + .preserve_failure_context( + context.clone(), + write(&path, &file, &state.io_semaphore).await, + ) + .await?; if let Some(project_type) = ProjectType::get_from_parent_folder(project.path.as_str()) { let hash = project.hashes.get(&PackFileHash::Sha1).map(|x| &**x); - let file_info = if let Some(hash) = hash { - CachedEntry::get_file_many( - &[hash], - None, - &state.pool, - &state.api_semaphore, - ) - .await? - .into_iter() - .next() - } else { - None - }; + let file_info = + hash.and_then(|hash| { + content_context.file_infos_by_hash.get(hash) + }); if let Some(hash) = hash { - crate::state::instances::commands::record_project_file( - &instance_id, - project.path.as_str(), - hash, - project.file_size as u64, - project_type, - modpack_source_kind(pack_version_id.as_deref()), - file_info - .as_ref() - .map(|file| file.project_id.as_str()), - file_info - .as_ref() - .map(|file| file.version_id.as_str()), - state, - ) - .await?; + let _permit = + state.install_db_semaphore.acquire().await?; + content_context + .reporter + .preserve_failure_context( + context.clone(), + crate::state::instances::commands::record_project_file( + &content_context.instance_id, + project.path.as_str(), + hash, + project.file_size as u64, + project_type, + modpack_source_kind( + content_context + .pack_version_id + .as_deref(), + ), + file_info.map(|file| { + file.project_id.as_str() + }), + file_info.map(|file| { + file.version_id.as_str() + }), + state, + ) + .await, + ) + .await?; } } - mark_downloaded(project.file_size as u64).await?; + content_context + .mark_downloaded( + project_size, + InstallJobEventKind::ContentFileCompleted { + path: project_path, + bytes: downloaded_bytes, + }, + ) + .await?; Ok(()) } }, @@ -646,7 +878,18 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter( let path = instance_full_path.join(relative_override_file_path.as_str()); - let (size, hash) = if override_total_bytes > 0 { + let override_context = + InstallErrorContext::new("extract modpack override") + .maybe_project_id(project_id.clone()) + .maybe_version_id(version_id.clone()) + .source_path(source_path.clone()) + .entry_path(file.filename().as_str().unwrap_or_default()) + .target_path(path.display().to_string()) + .build(); + reporter + .set_transient_context(override_context.clone()) + .await?; + let extract_result = if override_total_bytes > 0 { let progress = &mut report_override_progress as &mut ExtractProgressFn<'_>; zip_reader @@ -656,41 +899,65 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter( &state.io_semaphore, Some(progress), ) - .await? + .await } else { zip_reader .extract_entry(index, &path, &state.io_semaphore, None) - .await? + .await }; + let (size, hash) = reporter + .preserve_failure_context(override_context, extract_result) + .await?; - crate::state::cache_file_hash_metadata( - &instance_path, - relative_override_file_path.as_str(), - size, - hash.clone(), - ProjectType::get_from_parent_folder( - relative_override_file_path.as_str(), - ), - None, - &state.pool, - ) - .await?; + { + let _permit = state.install_db_semaphore.acquire().await?; + let record_context = + InstallErrorContext::new("record modpack override") + .maybe_project_id(project_id.clone()) + .maybe_version_id(version_id.clone()) + .source_path(source_path.clone()) + .entry_path(file.filename().as_str().unwrap_or_default()) + .target_path(path.display().to_string()) + .build(); + reporter + .preserve_failure_context( + record_context.clone(), + crate::state::cache_file_hash_metadata( + &instance_path, + relative_override_file_path.as_str(), + size, + hash.clone(), + ProjectType::get_from_parent_folder( + relative_override_file_path.as_str(), + ), + None, + &state.pool, + ) + .await, + ) + .await?; - if let Some(project_type) = ProjectType::get_from_parent_folder( - relative_override_file_path.as_str(), - ) { - crate::state::instances::commands::record_project_file( - &instance_id, + if let Some(project_type) = ProjectType::get_from_parent_folder( relative_override_file_path.as_str(), - &hash, - size, - project_type, - modpack_source_kind(version_id.as_deref()), - None, - None, - state, - ) - .await?; + ) { + reporter + .preserve_failure_context( + record_context, + crate::state::instances::commands::record_project_file( + &instance_id, + relative_override_file_path.as_str(), + &hash, + size, + project_type, + modpack_source_kind(version_id.as_deref()), + None, + None, + state, + ) + .await, + ) + .await?; + } } } @@ -705,13 +972,21 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter( crate::launcher::install_minecraft_for_instance_id_with_reporter( &instance_id, false, - Some(reporter), + Some(reporter.clone()), ) .await?; + reporter.clear_context().await?; Ok::(instance_id.clone()) } +fn pack_source_path(file: &CreatePackFile) -> String { + match file { + CreatePackFile::Bytes(_) => "downloaded mrpack bytes".to_string(), + CreatePackFile::Path(path) => path.display().to_string(), + } +} + fn modpack_source_kind(version_id: Option<&str>) -> ContentSourceKind { if version_id.is_some() { ContentSourceKind::ModrinthModpack diff --git a/packages/app-lib/src/error.rs b/packages/app-lib/src/error.rs index 84071718e2..334de2123b 100644 --- a/packages/app-lib/src/error.rs +++ b/packages/app-lib/src/error.rs @@ -12,6 +12,14 @@ use tracing_error::InstrumentError; pub struct LabrinthError { pub error: String, pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub route: Option, } #[derive(thiserror::Error, Debug)] diff --git a/packages/app-lib/src/install/diagnostics.rs b/packages/app-lib/src/install/diagnostics.rs new file mode 100644 index 0000000000..f014a0ac29 --- /dev/null +++ b/packages/app-lib/src/install/diagnostics.rs @@ -0,0 +1,667 @@ +use super::model::{ + InstallCleanup, InstallInterruptReason, InstallJobEvent, + InstallJobEventKind, InstallJobSnapshot, InstallJobState, InstallJobStatus, + InstallPhaseDetails, InstallPhaseId, InstallProgress, +}; +use super::store; +use crate::state::{ModrinthCredentials, State}; +use regex::{Captures, Regex}; +use sqlx::Row; +use std::fmt::Write as _; +use std::io::{Read, Seek, SeekFrom}; +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +const INSTALL_SUPPORT_LOG_TAIL_BYTES: u64 = 128 * 1024; + +pub async fn build_job_support_details( + job: &store::InstallJobRecord, + state: &State, +) -> crate::Result { + let snapshot = job.snapshot(); + let mut details = String::new(); + let title = snapshot + .display + .as_ref() + .map(|display| display.title.as_str()) + .unwrap_or("Unknown"); + + let _ = writeln!(details, "Install report: {title}"); + let _ = + writeln!(details, "Result: {}", result_summary(&snapshot, &job.state)); + let _ = writeln!(details, "Job ID: {}", snapshot.job_id); + let _ = writeln!(details, "Request: {}", json_string(&snapshot.kind)); + let _ = writeln!(details, "Status: {}", json_string(&snapshot.status)); + let _ = writeln!(details, "Current phase: {}", phase_label(snapshot.phase)); + if let Some(progress) = &snapshot.progress { + let _ = writeln!( + details, + "Current progress: {}", + progress_summary(progress) + ); + } + + write_environment_details(&mut details); + write_timeline(&mut details, &job.state.events); + write_content_summary(&mut details, &job.state.events); + write_errors(&mut details, &snapshot); + write_raw_snapshot(&mut details, &snapshot); + write_latest_log(&mut details, state).await; + + censor_support_text(details, state).await +} + +fn result_summary( + snapshot: &InstallJobSnapshot, + state: &InstallJobState, +) -> String { + match snapshot.status { + InstallJobStatus::Queued => "queued".to_string(), + InstallJobStatus::Running => { + format!("running while {}", phase_label(snapshot.phase)) + } + InstallJobStatus::Succeeded => "succeeded".to_string(), + InstallJobStatus::Canceled => snapshot + .error + .as_ref() + .and_then(|error| error.phase) + .map(|phase| format!("canceled while {}", phase_label(phase))) + .unwrap_or_else(|| "canceled".to_string()), + InstallJobStatus::Failed => snapshot + .error + .as_ref() + .and_then(|error| { + error.phase.map(|phase| { + format!( + "failed while {} ({})", + phase_label(phase), + error.code + ) + }) + }) + .unwrap_or_else(|| "failed".to_string()), + InstallJobStatus::Interrupted => latest_interruption(&state.events) + .map(|(reason, phase)| match reason { + InstallInterruptReason::AppClosed => format!( + "interrupted because the app closed while {}", + phase_label(phase) + ), + InstallInterruptReason::Unknown => { + format!("interrupted while {}", phase_label(phase)) + } + }) + .unwrap_or_else(|| "interrupted".to_string()), + } +} + +fn write_environment_details(details: &mut String) { + let _ = writeln!(details); + let _ = writeln!(details, "Environment"); + let _ = writeln!(details, "App version: {}", env!("CARGO_PKG_VERSION")); + let _ = writeln!( + details, + "OS: {}", + sysinfo::System::long_os_version() + .or_else(sysinfo::System::name) + .unwrap_or_else(|| std::env::consts::OS.to_string()) + ); + let _ = writeln!(details, "OS kind: {}", std::env::consts::OS); + let _ = writeln!(details, "OS family: {}", std::env::consts::FAMILY); + let _ = writeln!(details, "Architecture: {}", std::env::consts::ARCH); + if let Some(kernel_version) = sysinfo::System::kernel_version() { + let _ = writeln!(details, "Kernel: {kernel_version}"); + } +} + +fn latest_interruption( + events: &[InstallJobEvent], +) -> Option<(InstallInterruptReason, InstallPhaseId)> { + events.iter().rev().find_map(|event| match &event.kind { + InstallJobEventKind::Interrupted { reason, phase } => { + Some((*reason, *phase)) + } + _ => None, + }) +} + +fn write_timeline(details: &mut String, events: &[InstallJobEvent]) { + let _ = writeln!(details); + let _ = writeln!(details, "Timeline"); + + let mut index = 1; + for event in events { + let Some(description) = timeline_event_description(event) else { + continue; + }; + let _ = writeln!( + details, + "{index}. {} {description}", + event.at.to_rfc3339() + ); + index += 1; + } + + if index == 1 { + let _ = writeln!(details, "No install events were recorded."); + } +} + +fn timeline_event_description(event: &InstallJobEvent) -> Option { + match &event.kind { + InstallJobEventKind::JobQueued { kind } => { + Some(format!("Queued {} install", json_string(kind))) + } + InstallJobEventKind::JobStarted => { + Some("Started install job".to_string()) + } + InstallJobEventKind::JobSucceeded { instance_id } => { + Some(match instance_id { + Some(instance_id) => { + format!("Finished install for instance {instance_id}") + } + None => "Finished install".to_string(), + }) + } + InstallJobEventKind::JobCanceled { phase } => { + Some(format!("Canceled while {}", phase_label(*phase))) + } + InstallJobEventKind::PhaseStarted { phase, details } => Some(format!( + "Started {}{}", + phase_label(*phase), + phase_details_suffix(details) + )), + InstallJobEventKind::Interrupted { reason, phase } => { + Some(match reason { + InstallInterruptReason::AppClosed => { + format!("App closed while {}", phase_label(*phase)) + } + InstallInterruptReason::Unknown => { + format!("Interrupted while {}", phase_label(*phase)) + } + }) + } + InstallJobEventKind::Failed { + phase, + code, + message, + } => Some(format!( + "Failed while {} ({code}): {message}", + phase_label(*phase) + )), + InstallJobEventKind::RollbackStarted { cleanup } => { + Some(format!("Started rollback ({})", cleanup_summary(cleanup))) + } + InstallJobEventKind::RollbackCompleted => { + Some("Rollback completed".to_string()) + } + InstallJobEventKind::RollbackFailed { message } => { + Some(format!("Rollback failed: {message}")) + } + InstallJobEventKind::ContentDownloadStarted { .. } + | InstallJobEventKind::ContentFileSkipped { .. } + | InstallJobEventKind::ContentFileCompleted { .. } => None, + } +} + +fn write_content_summary(details: &mut String, events: &[InstallJobEvent]) { + let started = events.iter().rev().find_map(|event| match &event.kind { + InstallJobEventKind::ContentDownloadStarted { files, bytes } => { + Some((*files, *bytes)) + } + _ => None, + }); + let completed = events + .iter() + .filter_map(|event| match &event.kind { + InstallJobEventKind::ContentFileCompleted { path, bytes } => { + Some((path.as_str(), *bytes)) + } + _ => None, + }) + .collect::>(); + let skipped = events + .iter() + .filter_map(|event| match &event.kind { + InstallJobEventKind::ContentFileSkipped { path, reason } => { + Some((path.as_str(), reason.as_str())) + } + _ => None, + }) + .collect::>(); + + if started.is_none() && completed.is_empty() && skipped.is_empty() { + return; + } + + let _ = writeln!(details); + let _ = writeln!(details, "Content activity"); + if let Some((files, bytes)) = started { + let _ = writeln!( + details, + "Completed files: {} / {files}, skipped files: {}", + completed.len(), + skipped.len() + ); + if let Some(bytes) = bytes { + let _ = writeln!( + details, + "Expected content size: {}", + format_bytes(bytes) + ); + } + } else { + let _ = writeln!( + details, + "Completed files: {}, skipped files: {}", + completed.len(), + skipped.len() + ); + } + + if !completed.is_empty() { + let _ = writeln!(details); + let _ = writeln!(details, "Recently completed files"); + for (path, bytes) in completed.iter().rev().take(20) { + let _ = writeln!(details, "- {path} ({})", format_bytes(*bytes)); + } + if completed.len() > 20 { + let _ = writeln!(details, "- ... {} more", completed.len() - 20); + } + } + + if !skipped.is_empty() { + let _ = writeln!(details); + let _ = writeln!(details, "Skipped files"); + for (path, reason) in skipped.iter().rev().take(20) { + let _ = writeln!(details, "- {path} ({reason})"); + } + if skipped.len() > 20 { + let _ = writeln!(details, "- ... {} more", skipped.len() - 20); + } + } +} + +fn write_errors(details: &mut String, snapshot: &InstallJobSnapshot) { + if let Some(error) = &snapshot.error { + let _ = writeln!(details); + let _ = writeln!(details, "Failure"); + let _ = writeln!(details, "Code: {}", error.code); + if let Some(phase) = error.phase { + let _ = writeln!(details, "Phase: {}", phase_label(phase)); + } + let _ = writeln!(details, "Message: {}", error.message); + write_api_error_details(details, error); + write_error_context(details, error); + } + + if let Some(error) = &snapshot.rollback_error { + let _ = writeln!(details); + let _ = writeln!(details, "Rollback error"); + let _ = writeln!(details, "Code: {}", error.code); + if let Some(phase) = error.phase { + let _ = writeln!(details, "Phase: {}", phase_label(phase)); + } + let _ = writeln!(details, "Message: {}", error.message); + write_api_error_details(details, error); + write_error_context(details, error); + } +} + +fn write_api_error_details( + details: &mut String, + error: &super::model::InstallErrorView, +) { + let Some(api) = &error.api else { + return; + }; + + let _ = writeln!(details, "API error: {}", api.error); + if let Some(status) = api.status { + let _ = writeln!(details, "HTTP status: {status}"); + } + if api.method.is_some() || api.url.is_some() { + let method = api.method.as_deref().unwrap_or("unknown method"); + let url = api.url.as_deref().unwrap_or("unknown URL"); + let _ = writeln!(details, "Request: {method} {url}"); + } + if let Some(route) = &api.route { + let _ = writeln!(details, "Route: {route}"); + } +} + +fn write_error_context( + details: &mut String, + error: &super::model::InstallErrorView, +) { + let Some(context) = &error.context else { + return; + }; + + let _ = writeln!(details, "Operation: {}", context.operation); + if let Some(source_path) = &context.source_path { + let _ = writeln!(details, "Source path: {source_path}"); + } + if let Some(target_path) = &context.target_path { + let _ = writeln!(details, "Target path: {target_path}"); + } + if let Some(file_path) = &context.file_path { + let _ = writeln!(details, "File path: {file_path}"); + } + if let Some(entry_path) = &context.entry_path { + let _ = writeln!(details, "Archive entry: {entry_path}"); + } + if !context.urls.is_empty() { + let _ = writeln!(details, "URLs:"); + for url in &context.urls { + let _ = writeln!(details, "- {url}"); + } + } + if let Some(expected_hash) = &context.expected_hash { + let _ = writeln!(details, "Expected hash: {expected_hash}"); + } + if let Some(expected_size) = context.expected_size { + let _ = + writeln!(details, "Expected size: {}", format_bytes(expected_size)); + } + if let Some(project_id) = &context.project_id { + let _ = writeln!(details, "Project ID: {project_id}"); + } + if let Some(version_id) = &context.version_id { + let _ = writeln!(details, "Version ID: {version_id}"); + } + if let Some(minecraft_version) = &context.minecraft_version { + let _ = writeln!(details, "Minecraft version: {minecraft_version}"); + } + if let Some(loader) = &context.loader { + let _ = writeln!(details, "Loader: {loader}"); + } + if let Some(java_version) = context.java_version { + let _ = writeln!(details, "Java version: {java_version}"); + } + if let Some(os) = &context.os { + let _ = writeln!(details, "OS: {os}"); + } + if let Some(arch) = &context.arch { + let _ = writeln!(details, "Architecture: {arch}"); + } +} + +fn write_raw_snapshot(details: &mut String, snapshot: &InstallJobSnapshot) { + let _ = writeln!(details); + let _ = writeln!(details, "Raw snapshot"); + match serde_json::to_string_pretty(snapshot) { + Ok(snapshot_json) => { + let _ = writeln!(details, "{snapshot_json}"); + } + Err(error) => { + let _ = writeln!(details, "Unable to serialize snapshot: {error}"); + } + } +} + +async fn write_latest_log(details: &mut String, state: &State) { + let _ = writeln!(details); + let _ = writeln!(details, "Latest launcher log excerpt"); + match latest_launcher_log_tail(state).await { + Ok(Some((path, output))) => { + let _ = writeln!(details, "File: {}", path.display()); + details.push_str(&output); + } + Ok(None) => { + let _ = writeln!(details, "No launcher log found."); + } + Err(error) => { + let _ = writeln!(details, "Unable to read launcher log: {error}"); + } + } +} + +fn phase_label(phase: InstallPhaseId) -> &'static str { + match phase { + InstallPhaseId::PreparingInstance => "preparing instance", + InstallPhaseId::ResolvingPack => "resolving pack", + InstallPhaseId::DownloadingPackFile => "downloading pack file", + InstallPhaseId::ReadingPackManifest => "reading pack manifest", + InstallPhaseId::DownloadingContent => "downloading content", + InstallPhaseId::ExtractingOverrides => "extracting overrides", + InstallPhaseId::ResolvingMinecraft => "resolving Minecraft", + InstallPhaseId::ResolvingLoader => "resolving loader", + InstallPhaseId::PreparingJava => "preparing Java", + InstallPhaseId::DownloadingMinecraft => "downloading Minecraft", + InstallPhaseId::RunningLoaderProcessors => "running loader processors", + InstallPhaseId::Finalizing => "finalizing", + InstallPhaseId::RollingBack => "rolling back", + } +} + +fn phase_details_suffix(details: &InstallPhaseDetails) -> String { + match details { + InstallPhaseDetails::Empty => String::new(), + InstallPhaseDetails::Instance { name } => format!(" for {name}"), + InstallPhaseDetails::Minecraft { + game_version, + loader, + } => format!( + " for Minecraft {game_version} with {}", + json_string(loader) + ), + InstallPhaseDetails::Java { + major_version, + step, + } => format!(": {} Java {major_version}", json_string(step)), + InstallPhaseDetails::Modpack { + project_id, + version_id, + title, + } => { + let mut value = title + .as_ref() + .map(|title| format!(" for {title}")) + .unwrap_or_default(); + if let Some(project_id) = project_id { + let _ = write!(value, " project={project_id}"); + } + if let Some(version_id) = version_id { + let _ = write!(value, " version={version_id}"); + } + value + } + InstallPhaseDetails::Import { + launcher_type, + instance_folder, + } => format!(" from {launcher_type} instance {instance_folder}"), + } +} + +fn cleanup_summary(cleanup: &InstallCleanup) -> String { + match cleanup { + InstallCleanup::DeleteNewInstance { instance_id } => { + match instance_id { + Some(instance_id) => { + format!("delete partially-created instance {instance_id}") + } + None => "delete partially-created instance".to_string(), + } + } + InstallCleanup::RestoreExistingInstance { instance_id } => { + format!("restore existing instance {instance_id}") + } + } +} + +fn progress_summary(progress: &InstallProgress) -> String { + let mut value = format!("{} / {}", progress.current, progress.total); + if let Some(secondary) = &progress.secondary { + let _ = write!( + value, + " ({} / {})", + format_bytes(secondary.current), + format_bytes(secondary.total) + ); + } + value +} + +fn format_bytes(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut value = bytes as f64; + let mut unit = UNITS[0]; + for next_unit in UNITS.iter().skip(1) { + if value < 1024.0 { + break; + } + value /= 1024.0; + unit = next_unit; + } + + if unit == "B" { + format!("{bytes} B") + } else { + format!("{value:.1} {unit}") + } +} + +fn json_string(value: &T) -> String { + serde_json::to_string(value) + .map(|value| value.trim_matches('"').to_string()) + .unwrap_or_else(|_| "unknown".to_string()) +} + +async fn latest_launcher_log_tail( + state: &State, +) -> crate::Result> { + let Some(logs_dir) = state.directories.launcher_logs_dir() else { + return Ok(None); + }; + + let entries = match std::fs::read_dir(&logs_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(None); + } + Err(error) => return Err(error.into()), + }; + + let mut latest: Option<(PathBuf, SystemTime)> = None; + for entry in entries { + let entry = entry?; + let metadata = match entry.metadata() { + Ok(metadata) if metadata.is_file() => metadata, + _ => continue, + }; + let modified = metadata + .modified() + .or_else(|_| metadata.created()) + .unwrap_or(SystemTime::UNIX_EPOCH); + let path = entry.path(); + + match latest.as_ref() { + Some((_, latest_modified)) if modified <= *latest_modified => {} + _ => latest = Some((path, modified)), + } + } + + let Some((path, _)) = latest else { + return Ok(None); + }; + + let output = read_file_tail(&path, INSTALL_SUPPORT_LOG_TAIL_BYTES)?; + Ok(Some((path, output))) +} + +fn read_file_tail(path: &Path, max_bytes: u64) -> crate::Result { + let mut file = std::fs::File::open(path)?; + let len = file.metadata()?.len(); + let start = len.saturating_sub(max_bytes); + file.seek(SeekFrom::Start(start))?; + + let mut buffer = Vec::with_capacity((len - start) as usize); + file.read_to_end(&mut buffer)?; + + let mut output = String::from_utf8_lossy(&buffer).into_owned(); + if start > 0 { + output = format!("[first {start} bytes omitted]\n{output}"); + } + + Ok(output) +} + +async fn censor_support_text( + mut text: String, + state: &State, +) -> crate::Result { + for credentials in ModrinthCredentials::get_all(&state.pool) + .await? + .into_iter() + .map(|credentials| credentials.1) + { + replace_nonempty( + &mut text, + &credentials.session, + "{MODRINTH_ACCESS_TOKEN}", + ); + } + + for token in minecraft_tokens(&state.pool).await? { + replace_nonempty(&mut text, &token, "{MINECRAFT_TOKEN}"); + } + + text = censor_ip_addresses(text); + + Ok(text) +} + +async fn minecraft_tokens( + pool: &sqlx::SqlitePool, +) -> crate::Result> { + let rows = + sqlx::query("SELECT access_token, refresh_token FROM minecraft_users") + .fetch_all(pool) + .await?; + let mut tokens = Vec::with_capacity(rows.len() * 2); + + for row in rows { + tokens.push(row.try_get("access_token")?); + tokens.push(row.try_get("refresh_token")?); + } + + Ok(tokens) +} + +fn replace_nonempty(text: &mut String, value: &str, replacement: &str) { + if !value.is_empty() { + *text = text.replace(value, replacement); + } +} + +fn censor_ip_addresses(text: String) -> String { + let text = Regex::new( + r"\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b", + ) + .expect("valid IPv4 regex") + .replace_all(&text, |captures: &Captures<'_>| { + let value = &captures[0]; + match value.parse::() { + Ok(_) => "...".to_string(), + _ => value.to_string(), + } + }) + .into_owned(); + + Regex::new(r"(?i)\b[0-9a-f:.%]{3,}\b") + .expect("valid IPv6 candidate regex") + .replace_all(&text, |captures: &Captures<'_>| { + let value = &captures[0]; + if value.matches(':').count() < 2 { + return value.to_string(); + } + + let candidate = value.split('%').next().unwrap_or(value); + match candidate.parse::() { + Ok(_) => ":::::::".to_string(), + _ => value.to_string(), + } + }) + .into_owned() +} diff --git a/packages/app-lib/src/install/events.rs b/packages/app-lib/src/install/events.rs index 64fa4d6998..e95f67d238 100644 --- a/packages/app-lib/src/install/events.rs +++ b/packages/app-lib/src/install/events.rs @@ -1,23 +1,38 @@ use super::model::{ - InstallJobSnapshot, InstallJobState, InstallPhaseDetails, InstallPhaseId, - InstallProgress, + InstallErrorContext, InstallJobEventKind, InstallJobSnapshot, + InstallJobState, InstallPhaseDetails, InstallPhaseId, InstallProgress, }; use super::store; use std::sync::Arc; +use std::time::{Duration, Instant}; use tokio::sync::Mutex; use uuid::Uuid; +const PROGRESS_PERSIST_INTERVAL: Duration = Duration::from_millis(750); +const CONTENT_PROGRESS_PERSIST_STEPS: u64 = 25; + #[derive(Clone, Debug)] pub struct InstallProgressReporter { job_id: Uuid, - state: Arc>, + state: Arc>, +} + +#[derive(Debug)] +struct InstallProgressReporterState { + job: InstallJobState, + last_persisted_at: Instant, + last_persisted_progress: Option<(InstallPhaseId, u64)>, } impl InstallProgressReporter { pub fn new(job_id: Uuid, state: InstallJobState) -> Self { Self { job_id, - state: Arc::new(Mutex::new(state)), + state: Arc::new(Mutex::new(InstallProgressReporterState { + job: state, + last_persisted_at: Instant::now(), + last_persisted_progress: None, + })), } } @@ -27,18 +42,158 @@ impl InstallProgressReporter { progress: Option, details: InstallPhaseDetails, ) -> crate::Result<()> { + self.update_with_events(phase, progress, details, Vec::new()) + .await + } + + pub async fn set_context( + &self, + context: InstallErrorContext, + ) -> crate::Result<()> { + self.update_context(Some(context), true).await + } + + pub async fn set_transient_context( + &self, + context: InstallErrorContext, + ) -> crate::Result<()> { + self.update_context(Some(context), false).await + } + + pub async fn clear_context(&self) -> crate::Result<()> { + self.update_context(None, true).await + } + + async fn update_context( + &self, + context: Option, + persist: bool, + ) -> crate::Result<()> { + let app_state = if persist { + Some(crate::State::get().await?) + } else { + None + }; + let mut state = self.state.lock().await; + state.job.set_context(context); + + let Some(app_state) = app_state else { + return Ok(()); + }; + + let record = + store::update_state(self.job_id, &state.job, &app_state).await?; + state.mark_persisted(); + emit_install_job(&record.snapshot()).await + } + + pub async fn persist(&self) -> crate::Result { let app_state = crate::State::get().await?; let mut state = self.state.lock().await; - state.progress.phase = phase; - state.progress.progress = progress; - state.progress.details = details; let record = - store::update_state(self.job_id, &state, &app_state).await?; + store::update_state(self.job_id, &state.job, &app_state).await?; + state.mark_persisted(); + let snapshot = record.snapshot(); + emit_install_job(&snapshot).await?; + Ok(snapshot) + } + + pub async fn persist_failure_context(&self, context: InstallErrorContext) { + if let Err(error) = self.update_context(Some(context), true).await { + tracing::warn!( + "Failed to persist install context for failed operation: {error}" + ); + } + } + + pub async fn preserve_failure_context( + &self, + context: InstallErrorContext, + result: crate::Result, + ) -> crate::Result { + match result { + Ok(value) => Ok(value), + Err(error) => { + self.persist_failure_context(context).await; + Err(error) + } + } + } + + pub async fn update_with_events( + &self, + phase: InstallPhaseId, + progress: Option, + details: InstallPhaseDetails, + events: Vec, + ) -> crate::Result<()> { + let app_state = crate::State::get().await?; + let mut state = self.state.lock().await; + let phase_started = state.job.progress.phase != phase + || matches!( + &state.job.progress.details, + InstallPhaseDetails::Empty + ) && !matches!(&details, InstallPhaseDetails::Empty); + + state.job.set_progress(phase, progress, details); + for event in events { + state.job.record_event(event); + } + + if !state.should_persist(phase_started) { + return Ok(()); + } + + let record = + store::update_state(self.job_id, &state.job, &app_state).await?; + state.mark_persisted(); emit_install_job(&record.snapshot()).await } } +impl InstallProgressReporterState { + fn should_persist(&self, phase_started: bool) -> bool { + if phase_started { + return true; + } + + let Some(progress) = &self.job.progress.progress else { + return true; + }; + + if progress.current >= progress.total { + return true; + } + + let progressed_enough = + if self.job.progress.phase == InstallPhaseId::DownloadingContent { + self.last_persisted_progress + .map(|(phase, current)| { + phase != self.job.progress.phase + || progress.current.saturating_sub(current) + >= CONTENT_PROGRESS_PERSIST_STEPS + }) + .unwrap_or(true) + } else { + false + }; + + progressed_enough + || self.last_persisted_at.elapsed() >= PROGRESS_PERSIST_INTERVAL + } + + fn mark_persisted(&mut self) { + self.last_persisted_at = Instant::now(); + self.last_persisted_progress = self + .job + .progress + .progress + .as_ref() + .map(|progress| (self.job.progress.phase, progress.current)); + } +} + #[allow(unused_variables)] pub async fn emit_install_job( snapshot: &InstallJobSnapshot, diff --git a/packages/app-lib/src/install/mod.rs b/packages/app-lib/src/install/mod.rs index 96f9f26dfa..bc8ba02f8e 100644 --- a/packages/app-lib/src/install/mod.rs +++ b/packages/app-lib/src/install/mod.rs @@ -1,3 +1,4 @@ +mod diagnostics; pub mod events; pub mod model; pub mod recovery; @@ -6,13 +7,15 @@ pub mod store; pub use events::InstallProgressReporter; pub use model::{ - InstallErrorView, InstallJavaStep, InstallJobKind, InstallJobSnapshot, - InstallJobStatus, InstallModpackPreview, InstallPhaseDetails, - InstallPhaseId, InstallPostInstallEdit, InstallProgress, - InstallProgressSecondary, InstallRequest, + InstallErrorContext, InstallErrorView, InstallJavaStep, + InstallJobEventKind, InstallJobKind, InstallJobSnapshot, InstallJobStatus, + InstallModpackPreview, InstallPhaseDetails, InstallPhaseId, + InstallPostInstallEdit, InstallProgress, InstallProgressSecondary, + InstallRequest, }; pub use runner::{ cancel_job, create_instance, create_modpack_instance, dismiss_job, duplicate_instance, get_job, import_instance, install_existing_instance, - install_pack_to_existing_instance, list_jobs, retry_job, + install_pack_to_existing_instance, job_support_details, list_jobs, + retry_job, }; diff --git a/packages/app-lib/src/install/model.rs b/packages/app-lib/src/install/model.rs index 56b2039511..434654214b 100644 --- a/packages/app-lib/src/install/model.rs +++ b/packages/app-lib/src/install/model.rs @@ -18,16 +18,23 @@ pub struct InstallJobState { pub cleanup: InstallCleanup, pub progress: InstallProgressState, pub paths: InstallJobPaths, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(default)] + pub events: Vec, #[serde(default)] pub display: Option, pub rollback: Option, pub error: Option, + #[serde(default)] + pub rollback_error: Option, } impl InstallJobState { pub fn new(request: InstallRequest) -> Self { let target = request.target(); let cleanup = request.cleanup(); + let kind = request.kind(); let phase = InstallPhaseId::PreparingInstance; Self { @@ -41,11 +48,109 @@ impl InstallJobState { details: InstallPhaseDetails::Empty, }, paths: InstallJobPaths::default(), + context: None, + events: vec![InstallJobEvent { + at: Utc::now(), + kind: InstallJobEventKind::JobQueued { kind }, + }], display: None, rollback: None, error: None, + rollback_error: None, } } + + pub fn record_event(&mut self, kind: InstallJobEventKind) { + self.events.push(InstallJobEvent { + at: Utc::now(), + kind, + }); + } + + pub fn set_context(&mut self, context: Option) { + self.context = context; + } + + pub fn set_progress( + &mut self, + phase: InstallPhaseId, + progress: Option, + details: InstallPhaseDetails, + ) { + if self.progress.phase != phase + || matches!(&self.progress.details, InstallPhaseDetails::Empty) + && !matches!(&details, InstallPhaseDetails::Empty) + { + self.record_event(InstallJobEventKind::PhaseStarted { + phase, + details: details.clone(), + }); + } + + self.progress.phase = phase; + self.progress.progress = progress; + self.progress.details = details; + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct InstallJobEvent { + pub at: DateTime, + pub kind: InstallJobEventKind, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum InstallInterruptReason { + AppClosed, + Unknown, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum InstallJobEventKind { + JobQueued { + kind: InstallJobKind, + }, + JobStarted, + JobSucceeded { + instance_id: Option, + }, + JobCanceled { + phase: InstallPhaseId, + }, + PhaseStarted { + phase: InstallPhaseId, + details: InstallPhaseDetails, + }, + ContentDownloadStarted { + files: u64, + bytes: Option, + }, + ContentFileSkipped { + path: String, + reason: String, + }, + ContentFileCompleted { + path: String, + bytes: u64, + }, + Interrupted { + reason: InstallInterruptReason, + phase: InstallPhaseId, + }, + Failed { + phase: InstallPhaseId, + code: String, + message: String, + }, + RollbackStarted { + cleanup: InstallCleanup, + }, + RollbackCompleted, + RollbackFailed { + message: String, + }, } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -315,6 +420,51 @@ pub struct InstallJobPaths { pub final_instance_path: Option, } +#[derive(Serialize, Deserialize, Clone, Debug, bon::Builder)] +#[builder(start_fn = new)] +pub struct InstallErrorContext { + #[builder(start_fn, into)] + pub operation: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[builder(into)] + pub source_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[builder(into)] + pub target_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[builder(into)] + pub file_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[builder(into)] + pub entry_path: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[builder(default)] + pub urls: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[builder(into)] + pub expected_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_size: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[builder(into)] + pub minecraft_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[builder(into)] + pub loader: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub java_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[builder(into)] + pub os: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[builder(into)] + pub arch: Option, +} + #[derive(Serialize, Deserialize, Clone, Debug)] pub struct InstallJobDisplay { pub title: String, @@ -330,14 +480,66 @@ pub struct InstallRollbackState { #[derive(Serialize, Deserialize, Clone, Debug)] pub struct InstallErrorView { pub code: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub phase: Option, pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct InstallApiErrorDetails { + pub error: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub route: Option, } impl InstallErrorView { - pub fn from_error(code: &str, error: impl ToString) -> Self { + pub fn from_error( + code: &str, + phase: InstallPhaseId, + error: &crate::Error, + context: Option, + ) -> Self { Self { code: code.to_string(), + phase: Some(phase), message: error.to_string(), + api: match error.raw.as_ref() { + crate::ErrorKind::LabrinthError(error) => { + Some(InstallApiErrorDetails { + error: error.error.clone(), + status: error.status, + method: error.method.clone(), + url: error.url.clone(), + route: error.route.clone(), + }) + } + _ => None, + }, + context, + } + } + + pub fn from_message( + code: &str, + phase: InstallPhaseId, + message: impl Into, + ) -> Self { + Self { + code: code.to_string(), + phase: Some(phase), + message: message.into(), + api: None, + context: None, } } } @@ -354,6 +556,7 @@ pub struct InstallJobSnapshot { pub details: InstallPhaseDetails, pub display: Option, pub error: Option, + pub rollback_error: Option, pub created: DateTime, pub modified: DateTime, pub finished: Option>, diff --git a/packages/app-lib/src/install/recovery.rs b/packages/app-lib/src/install/recovery.rs index 30529bd80e..e74feb3237 100644 --- a/packages/app-lib/src/install/recovery.rs +++ b/packages/app-lib/src/install/recovery.rs @@ -1,8 +1,8 @@ use super::events::emit_install_job; use super::model::{ - InstallCleanup, InstallErrorView, InstallJobDisplay, InstallJobState, - InstallJobStatus, InstallPhaseDetails, InstallPhaseId, InstallRequest, - InstallTarget, + InstallCleanup, InstallErrorView, InstallInterruptReason, + InstallJobDisplay, InstallJobEventKind, InstallJobState, InstallJobStatus, + InstallPhaseDetails, InstallPhaseId, InstallRequest, InstallTarget, }; use super::store; use crate::event::InstancePayloadType; @@ -16,19 +16,41 @@ pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> { if job.state.display.is_none() { job.state.display = display_from_request(&job.state); } + let interrupted_phase = job.state.progress.phase; + job.state.record_event(InstallJobEventKind::Interrupted { + reason: InstallInterruptReason::AppClosed, + phase: interrupted_phase, + }); job.state.progress.phase = InstallPhaseId::RollingBack; job.state.progress.progress = None; job.state.progress.details = InstallPhaseDetails::Empty; - job.state.error = Some(InstallErrorView { - code: "interrupted".to_string(), - message: "interrupted".to_string(), - }); + job.state.error = Some(InstallErrorView::from_message( + "app_closed", + interrupted_phase, + "App closed while install was running", + )); + job.state + .record_event(InstallJobEventKind::RollbackStarted { + cleanup: job.state.cleanup.clone(), + }); if let Err(error) = apply_cleanup(&job.state, state).await { tracing::error!( "Error cleaning up interrupted install job {}: {error}", job.id ); + job.state.rollback_error = Some(InstallErrorView::from_error( + "rollback_error", + InstallPhaseId::RollingBack, + &error, + None, + )); + job.state.record_event(InstallJobEventKind::RollbackFailed { + message: error.to_string(), + }); + } else { + job.state + .record_event(InstallJobEventKind::RollbackCompleted); } clear_deleted_new_instance_id(&mut job.state); @@ -55,38 +77,40 @@ fn clear_deleted_new_instance_id(job_state: &mut InstallJobState) { fn display_from_request(state: &InstallJobState) -> Option { match &state.request { - InstallRequest::CreateInstance { name, icon_path, .. } => { - Some(InstallJobDisplay { - title: name.clone(), - icon: icon_path.clone(), - }) - } - InstallRequest::CreateModpackInstance { location, .. } => match location { - crate::api::pack::install_from::CreatePackLocation::FromVersionId { - title, - icon_url, - .. - } => Some(InstallJobDisplay { - title: title.clone(), - icon: icon_url.clone(), - }), - crate::api::pack::install_from::CreatePackLocation::FromFile { .. } => None, - }, - InstallRequest::ImportInstance { - instance_folder, .. - } => Some(InstallJobDisplay { - title: instance_folder.clone(), - icon: None, - }), - InstallRequest::DuplicateInstance { .. } - | InstallRequest::InstallExistingInstance { .. } - | InstallRequest::InstallPackToExistingInstance { .. } => { - state.rollback.as_ref().map(|rollback| InstallJobDisplay { - title: rollback.instance.instance.name.clone(), - icon: rollback.instance.instance.icon_path.clone(), - }) - } - } + InstallRequest::CreateInstance { name, icon_path, .. } => { + Some(InstallJobDisplay { + title: name.clone(), + icon: icon_path.clone(), + }) + } + InstallRequest::CreateModpackInstance { location, .. } => match location { + crate::api::pack::install_from::CreatePackLocation::FromVersionId { + title, + icon_url, + .. + } => Some(InstallJobDisplay { + title: title.clone(), + icon: icon_url.clone(), + }), + crate::api::pack::install_from::CreatePackLocation::FromFile { + .. + } => None, + }, + InstallRequest::ImportInstance { + instance_folder, .. + } => Some(InstallJobDisplay { + title: instance_folder.clone(), + icon: None, + }), + InstallRequest::DuplicateInstance { .. } + | InstallRequest::InstallExistingInstance { .. } + | InstallRequest::InstallPackToExistingInstance { .. } => { + state.rollback.as_ref().map(|rollback| InstallJobDisplay { + title: rollback.instance.instance.name.clone(), + icon: rollback.instance.instance.icon_path.clone(), + }) + } + } } pub async fn apply_cleanup( diff --git a/packages/app-lib/src/install/runner.rs b/packages/app-lib/src/install/runner.rs index 2517319144..f70a94f0fe 100644 --- a/packages/app-lib/src/install/runner.rs +++ b/packages/app-lib/src/install/runner.rs @@ -1,11 +1,11 @@ use super::events::{InstallProgressReporter, emit_install_job}; use super::model::{ - InstallCleanup, InstallErrorView, InstallJobDisplay, InstallJobSnapshot, - InstallJobState, InstallJobStatus, InstallPhaseDetails, InstallPhaseId, - InstallPostInstallEdit, InstallRequest, InstallRollbackState, - InstallTarget, + InstallCleanup, InstallErrorContext, InstallErrorView, InstallJobDisplay, + InstallJobEventKind, InstallJobSnapshot, InstallJobState, InstallJobStatus, + InstallPhaseDetails, InstallPhaseId, InstallPostInstallEdit, + InstallRequest, InstallRollbackState, InstallTarget, }; -use super::{recovery, store}; +use super::{diagnostics, recovery, store}; use crate::ErrorKind; use crate::api::pack::install_from::{ CreatePackLocation, generate_pack_from_file, @@ -108,6 +108,12 @@ pub async fn get_job(job_id: Uuid) -> crate::Result { Ok(store::get_required(job_id, &state).await?.snapshot()) } +pub async fn job_support_details(job_id: Uuid) -> crate::Result { + let state = State::get().await?; + let job = store::get_required(job_id, &state).await?; + diagnostics::build_job_support_details(&job, &state).await +} + pub async fn retry_job(job_id: Uuid) -> crate::Result { let state = State::get().await?; let mut job = store::get_required(job_id, &state).await?; @@ -127,10 +133,15 @@ pub async fn retry_job(job_id: Uuid) -> crate::Result { job.state.cleanup = job.state.request.cleanup(); job.state.rollback = None; job.state.error = None; + job.state.rollback_error = None; + job.state.context = None; job.state.progress.phase = InstallPhaseId::PreparingInstance; job.state.progress.progress = None; job.state.progress.details = InstallPhaseDetails::Empty; prepare_initial_instance(&mut job.state, &state).await?; + job.state.record_event(InstallJobEventKind::JobQueued { + kind: job.state.request.kind(), + }); let record = store::update_status( job_id, @@ -156,11 +167,35 @@ pub async fn cancel_job(job_id: Uuid) -> crate::Result { .into()); } - job.state.error = Some(InstallErrorView { - code: "canceled".to_string(), - message: "Install was canceled".to_string(), + let canceled_phase = job.state.progress.phase; + job.state.error = Some(InstallErrorView::from_message( + "canceled", + canceled_phase, + "Install was canceled", + )); + job.state.record_event(InstallJobEventKind::JobCanceled { + phase: canceled_phase, }); - recovery::apply_cleanup(&job.state, &state).await?; + job.state + .record_event(InstallJobEventKind::RollbackStarted { + cleanup: job.state.cleanup.clone(), + }); + match recovery::apply_cleanup(&job.state, &state).await { + Ok(()) => job + .state + .record_event(InstallJobEventKind::RollbackCompleted), + Err(error) => { + job.state.rollback_error = Some(InstallErrorView::from_error( + "rollback_error", + InstallPhaseId::RollingBack, + &error, + None, + )); + job.state.record_event(InstallJobEventKind::RollbackFailed { + message: error.to_string(), + }); + } + } clear_deleted_new_instance_id(&mut job.state); let record = store::update_status( job_id, @@ -328,13 +363,21 @@ fn spawn_job(job_id: Uuid) { async fn run_job(job_id: Uuid) -> crate::Result<()> { let state = State::get().await?; - let job = store::get_required(job_id, &state).await?; + let mut job = store::get_required(job_id, &state).await?; + + if job.status != InstallJobStatus::Queued { + return Ok(()); + } + + let _install_permit = state.install_job_semaphore.acquire().await?; + job = store::get_required(job_id, &state).await?; if job.status != InstallJobStatus::Queued { return Ok(()); } let mut job_state = job.state.clone(); + job_state.record_event(InstallJobEventKind::JobStarted); let record = store::update_status( job_id, InstallJobStatus::Running, @@ -345,16 +388,24 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> { emit_install_job(&record.snapshot()).await?; let result = run_request(job_id, &mut job_state, &state).await; + if let Ok(record) = store::get_required(job_id, &state).await { + job_state = record.state; + } match result { Ok(instance_id) => { if let Some(instance_id) = instance_id { set_instance_id(&mut job_state, instance_id); } + job_state.record_event(InstallJobEventKind::JobSucceeded { + instance_id: current_instance_id(&job_state), + }); job_state.progress.phase = InstallPhaseId::Finalizing; job_state.progress.progress = None; job_state.progress.details = InstallPhaseDetails::Empty; job_state.error = None; + job_state.rollback_error = None; + job_state.context = None; let record = store::update_status( job_id, InstallJobStatus::Succeeded, @@ -365,11 +416,41 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> { emit_install_job(&record.snapshot()).await?; } Err(error) => { + let failed_phase = job_state.progress.phase; + let error_view = install_error_view( + failed_phase, + &error, + job_state.context.clone(), + ); + job_state.record_event(InstallJobEventKind::Failed { + phase: failed_phase, + code: error_view.code.clone(), + message: error_view.message.clone(), + }); + job_state.error = Some(error_view); job_state.progress.phase = InstallPhaseId::RollingBack; job_state.progress.progress = None; job_state.progress.details = InstallPhaseDetails::Empty; - job_state.error = Some(install_error_view(&error)); - recovery::apply_cleanup(&job_state, &state).await?; + job_state.record_event(InstallJobEventKind::RollbackStarted { + cleanup: job_state.cleanup.clone(), + }); + if let Err(rollback_error) = + recovery::apply_cleanup(&job_state, &state).await + { + tracing::error!( + "Error rolling back failed install job {job_id}: {rollback_error}" + ); + job_state.rollback_error = Some(install_error_view( + InstallPhaseId::RollingBack, + &rollback_error, + None, + )); + job_state.record_event(InstallJobEventKind::RollbackFailed { + message: rollback_error.to_string(), + }); + } else { + job_state.record_event(InstallJobEventKind::RollbackCompleted); + } clear_deleted_new_instance_id(&mut job_state); let record = store::update_status( job_id, @@ -812,6 +893,14 @@ async fn install_pack( title, icon_url, } => { + reporter + .set_context( + InstallErrorContext::new("download modpack file") + .project_id(project_id.clone()) + .version_id(version_id.clone()) + .build(), + ) + .await?; generate_pack_from_version_id_with_reporter( project_id, version_id, @@ -824,6 +913,13 @@ async fn install_pack( .await? } CreatePackLocation::FromFile { path } => { + reporter + .set_context( + InstallErrorContext::new("read local modpack file") + .source_path(path.display().to_string()) + .build(), + ) + .await?; generate_pack_from_file(path, instance_id.clone()).await? } }; @@ -887,9 +983,7 @@ async fn update_progress( phase: InstallPhaseId, details: InstallPhaseDetails, ) -> crate::Result<()> { - job_state.progress.phase = phase; - job_state.progress.progress = None; - job_state.progress.details = details; + job_state.set_progress(phase, None, details); let record = store::update_state(job_id, job_state, state).await?; emit_install_job(&record.snapshot()).await?; Ok(()) @@ -934,19 +1028,86 @@ fn set_display( job_state.display = Some(InstallJobDisplay { title, icon }); } -fn install_error_view(error: &crate::Error) -> InstallErrorView { +fn install_error_view( + phase: InstallPhaseId, + error: &crate::Error, + context: Option, +) -> InstallErrorView { + InstallErrorView::from_error( + install_error_code(phase, error), + phase, + error, + context, + ) +} + +fn install_error_code( + phase: InstallPhaseId, + error: &crate::Error, +) -> &'static str { + use InstallPhaseId::*; + match error.raw.as_ref() { - ErrorKind::FetchError(_) - | ErrorKind::ApiIsDownError(_) - | ErrorKind::WSError(_) - | ErrorKind::WSClosedError(_) => InstallErrorView { - code: "network_error".to_string(), - message: "network_error".to_string(), + ErrorKind::InputError(_) => match phase { + PreparingInstance | Finalizing => "instance_error", + ResolvingPack | DownloadingPackFile | ReadingPackManifest => { + "pack_error" + } + DownloadingContent => "content_error", + ExtractingOverrides => "path_error", + PreparingJava => "java_error", + DownloadingMinecraft => "instance_error", + RollingBack => "rollback_error", + ResolvingMinecraft | ResolvingLoader | RunningLoaderProcessors => { + "launcher_error" + } + }, + ErrorKind::LauncherError(_) => match phase { + RunningLoaderProcessors => "processor_error", + PreparingJava => "java_error", + ResolvingLoader => "loader_error", + _ => "launcher_error", }, - _ => InstallErrorView { - code: "unknown_error".to_string(), - message: "unknown_error".to_string(), + ErrorKind::JREError(_) => "java_error", + ErrorKind::NoValueFor(_) | ErrorKind::MetadataError(_) => match phase { + ResolvingLoader => "loader_error", + PreparingJava => "java_error", + _ => "metadata_error", }, + ErrorKind::FetchError(_) | ErrorKind::ApiIsDownError(_) => { + "network_error" + } + ErrorKind::Any(_) + if matches!( + phase, + DownloadingPackFile + | DownloadingContent + | ResolvingMinecraft + | ResolvingLoader + | PreparingJava + | DownloadingMinecraft + ) => + { + "network_error" + } + ErrorKind::LabrinthError(_) => "api_error", + ErrorKind::HashError(_, _) => "hash_error", + ErrorKind::ZipError(_) => "archive_error", + ErrorKind::DeserializationError(_) | ErrorKind::StripPrefixError(_) => { + "path_error" + } + ErrorKind::FSError(_) + | ErrorKind::IOError(_) + | ErrorKind::StdIOError(_) + | ErrorKind::UTFError(_) => "filesystem_error", + ErrorKind::INIError(_) | ErrorKind::JSONError(_) => "parse_error", + ErrorKind::Sqlx(_) | ErrorKind::SqlxMigrate(_) => "database_error", + ErrorKind::JoinError(_) + | ErrorKind::RecvError(_) + | ErrorKind::AcquireError(_) + | ErrorKind::EventError(_) => "internal_error", + ErrorKind::OtherError(_) | ErrorKind::Any(_) => "internal_error", + _ => "unknown_error", } } diff --git a/packages/app-lib/src/install/store.rs b/packages/app-lib/src/install/store.rs index ac80c14170..d316ad41cd 100644 --- a/packages/app-lib/src/install/store.rs +++ b/packages/app-lib/src/install/store.rs @@ -44,6 +44,7 @@ impl InstallJobRecord { details: self.state.progress.details.clone(), display: self.state.display.clone(), error: self.state.error.clone(), + rollback_error: self.state.rollback_error.clone(), created: self.created, modified: self.modified, finished: self.finished, diff --git a/packages/app-lib/src/launcher/download.rs b/packages/app-lib/src/launcher/download.rs index 8b6e0ae5c9..acc00c3f73 100644 --- a/packages/app-lib/src/launcher/download.rs +++ b/packages/app-lib/src/launcher/download.rs @@ -1,7 +1,7 @@ //! Downloader for Minecraft data use crate::install::{ - InstallPhaseDetails, InstallPhaseId, InstallProgress, + InstallErrorContext, InstallPhaseDetails, InstallPhaseId, InstallProgress, InstallProgressReporter, }; use crate::instance::QuickPlayType; @@ -128,6 +128,17 @@ impl MinecraftDownloadProgress { ) .await } + + async fn set_context( + &self, + context: InstallErrorContext, + ) -> crate::Result<()> { + self.reporter.set_transient_context(context).await + } + + async fn persist_failure_context(&self, context: InstallErrorContext) { + self.reporter.persist_failure_context(context).await; + } } async fn fetch_minecraft_file( @@ -136,7 +147,16 @@ async fn fetch_minecraft_file( sha1: Option<&str>, expected_size: Option, progress: Option, + context: InstallErrorContext, ) -> crate::Result { + let mut context = context; + context.urls.push(url.to_string()); + context.expected_hash = sha1.map(str::to_string); + context.expected_size = expected_size; + if let Some(progress) = &progress { + progress.set_context(context.clone()).await?; + } + let Some(progress) = progress else { return fetch(url, sha1, None, None, &st.fetch_semaphore, &st.pool) .await; @@ -157,7 +177,7 @@ async fn fetch_minecraft_file( } }; - let bytes = fetch_advanced_with_progress( + let bytes = match fetch_advanced_with_progress( Method::GET, url, sha1, @@ -170,7 +190,14 @@ async fn fetch_minecraft_file( &st.pool, Some(&mut progress_fn as &mut FetchProgressFn<'_>), ) - .await?; + .await + { + Ok(bytes) => bytes, + Err(error) => { + progress.persist_failure_context(context).await; + return Err(error); + } + }; if let Some(expected_size) = expected_size { let downloaded = last_downloaded.load(Ordering::Relaxed); @@ -432,6 +459,7 @@ pub async fn download_version_info( loader: Option<&LoaderVersion>, force: Option, loading_bar: Option<&LoadingBarId>, + reporter: Option<&InstallProgressReporter>, ) -> crate::Result { let version_id = loader .map_or(version.id.clone(), |it| format!("{}-{}", version.id, it.id)); @@ -452,6 +480,19 @@ pub async fn download_version_info( &version.id, version.url ); + if let Some(reporter) = reporter { + reporter + .set_context( + InstallErrorContext::new( + "download Minecraft version metadata", + ) + .minecraft_version(version.id.clone()) + .urls(vec![version.url.clone()]) + .target_path(path.display().to_string()) + .build(), + ) + .await?; + } let mut info = fetch_json( Method::GET, &version.url, @@ -464,6 +505,19 @@ pub async fn download_version_info( .await?; if let Some(loader) = loader { + if let Some(reporter) = reporter { + reporter + .set_context( + InstallErrorContext::new( + "download loader version metadata", + ) + .minecraft_version(version.id.clone()) + .urls(vec![loader.url.clone()]) + .target_path(path.display().to_string()) + .build(), + ) + .await?; + } let partial: d::modded::PartialVersionInfo = fetch_json( Method::GET, &loader.url, @@ -523,6 +577,11 @@ pub async fn download_client( Some(&client_download.sha1), Some(client_download.size as u64), progress, + InstallErrorContext::new("download Minecraft client") + .minecraft_version(version.to_string()) + .file_path(format!("{version}.jar")) + .target_path(path.display().to_string()) + .build(), ) .await?; write(&path, &bytes, &st.io_semaphore).await?; @@ -563,6 +622,11 @@ pub async fn download_assets_index( None, Some(version.asset_index.size as u64), progress, + InstallErrorContext::new("download Minecraft assets index") + .minecraft_version(version.id.clone()) + .file_path(format!("{}.json", version.asset_index.id)) + .target_path(path.display().to_string()) + .build(), ) .await?; let index = serde_json::from_slice(&index)?; @@ -632,6 +696,10 @@ pub async fn download_assets( Some(hash), Some(asset.size as u64), fetch_progress.clone(), + InstallErrorContext::new("download Minecraft asset") + .file_path(name.clone()) + .target_path(resource_path.display().to_string()) + .build(), )) .await?; write(&resource_path, resource, &st.io_semaphore).await?; @@ -648,6 +716,10 @@ pub async fn download_assets( Some(hash), Some(asset.size as u64), fetch_progress.clone(), + InstallErrorContext::new("download Minecraft asset") + .file_path(name.clone()) + .target_path(legacy_resource_path.display().to_string()) + .build(), )) .await?; write(&legacy_resource_path, resource, &st.io_semaphore).await?; @@ -729,6 +801,16 @@ pub async fn download_libraries( Some(&native.sha1), Some(native.size as u64), progress.clone(), + InstallErrorContext::new("download Minecraft native library") + .minecraft_version(version.to_string()) + .file_path(library.name.clone()) + .target_path( + st.directories + .version_natives_dir(version) + .display() + .to_string(), + ) + .build(), ) .await?; @@ -774,6 +856,11 @@ pub async fn download_libraries( Some(&artifact.sha1), Some(artifact.size as u64), progress.clone(), + InstallErrorContext::new("download Minecraft library") + .minecraft_version(version.to_string()) + .file_path(library.name.clone()) + .target_path(path.display().to_string()) + .build(), ) .await?; write(&path, &bytes, &st.io_semaphore).await?; @@ -880,6 +967,11 @@ pub async fn download_log_config( Some(&log_download.sha1), Some(log_download.size as u64), progress, + InstallErrorContext::new("download Minecraft log config") + .minecraft_version(version_info.id.clone()) + .file_path(log_download.id.clone()) + .target_path(path.display().to_string()) + .build(), ) .await?; write(&path, &bytes, &st.io_semaphore).await?; diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs index a3d4a0a8b1..ad14b2df82 100644 --- a/packages/app-lib/src/launcher/mod.rs +++ b/packages/app-lib/src/launcher/mod.rs @@ -348,6 +348,7 @@ pub async fn install_minecraft_with_reporter( loader_version.as_ref(), Some(repairing), loading_bar.as_ref(), + reporter.as_ref(), ) .await?; @@ -748,6 +749,7 @@ pub async fn launch_minecraft( loader_version.as_ref(), None, None, + None, ) .await?; if version_info.logging.is_none() { @@ -764,6 +766,7 @@ pub async fn launch_minecraft( loader_version.as_ref(), Some(true), None, + None, ) .await?; } diff --git a/packages/app-lib/src/state/mod.rs b/packages/app-lib/src/state/mod.rs index 0852c31475..18fa7edbb8 100644 --- a/packages/app-lib/src/state/mod.rs +++ b/packages/app-lib/src/state/mod.rs @@ -57,6 +57,7 @@ pub mod server_join_log; // Global state // RwLock on state only has concurrent reads, except for config dir change which takes control of the State static LAUNCHER_STATE: OnceCell> = OnceCell::const_new(); +const MAX_CONCURRENT_INSTALL_JOBS: usize = 3; pub struct State { /// Information on the location of files used in the launcher pub directories: DirectoryInfo, @@ -68,6 +69,8 @@ pub struct State { /// Semaphore to limit concurrent API requests. This is separate from the fetch semaphore /// to keep API functionality while the app is performing intensive tasks. pub api_semaphore: FetchSemaphore, + pub(crate) install_job_semaphore: Semaphore, + pub(crate) install_db_semaphore: Semaphore, /// Discord RPC pub discord_rpc: DiscordGuard, @@ -205,6 +208,8 @@ impl State { fetch_semaphore, io_semaphore, api_semaphore, + install_job_semaphore: Semaphore::new(MAX_CONCURRENT_INSTALL_JOBS), + install_db_semaphore: Semaphore::new(1), discord_rpc, process_manager, friends_socket, diff --git a/packages/app-lib/src/util/fetch.rs b/packages/app-lib/src/util/fetch.rs index 85e7e48859..7185449778 100644 --- a/packages/app-lib/src/util/fetch.rs +++ b/packages/app-lib/src/util/fetch.rs @@ -1,8 +1,8 @@ //! Functions for fetching information from the Internet use super::io::{self, IOError}; -use crate::ErrorKind; use crate::event::LoadingBarId; use crate::event::emit::emit_loading; +use crate::{ErrorKind, LabrinthError}; use bytes::Bytes; use chrono::{DateTime, TimeDelta, Utc}; use eyre::{Context, eyre}; @@ -190,6 +190,8 @@ static GLOBAL_FETCH_FENCE: LazyLock = fn reqwest_client_builder() -> reqwest::ClientBuilder { reqwest::Client::builder() + .connect_timeout(time::Duration::from_secs(15)) + .read_timeout(time::Duration::from_secs(30)) .tcp_keepalive(Some(time::Duration::from_secs(10))) .user_agent(crate::launcher_user_agent()) } @@ -267,6 +269,34 @@ pub async fn fetch_with_client( .await } +#[tracing::instrument(skip(semaphore, progress))] +pub async fn fetch_with_client_progress( + url: &str, + sha1: Option<&str>, + download_meta: Option<&DownloadMeta>, + uri_path: Option<&'static str>, + semaphore: &FetchSemaphore, + exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, + client: &reqwest::Client, + progress: Option<&mut FetchProgressFn<'_>>, +) -> crate::Result { + fetch_advanced_with_client_and_progress( + Method::GET, + url, + sha1, + None, + None, + download_meta, + None, + uri_path, + semaphore, + exec, + client, + progress, + ) + .await +} + #[tracing::instrument(skip(json_body, semaphore))] pub async fn fetch_json( method: Method, @@ -466,8 +496,13 @@ async fn fetch_advanced_with_client_and_progress( if resp.status().is_client_error() || resp.status().is_server_error() { + let status = resp.status(); let backup_error = resp.error_for_status_ref().unwrap_err(); - if let Ok(error) = resp.json().await { + if let Ok(mut error) = resp.json::().await { + error.status = Some(status.as_u16()); + error.method = Some(method.as_str().to_string()); + error.url = Some(url.to_string()); + error.route = uri_path.map(str::to_string); return Err(ErrorKind::LabrinthError(error).into()); } return Err(backup_error.into()); @@ -599,6 +634,43 @@ pub async fn fetch_mirrors( unreachable!() } +#[tracing::instrument(skip(semaphore, progress))] +pub async fn fetch_mirrors_with_progress( + mirrors: &[&str], + sha1: Option<&str>, + download_meta: Option<&DownloadMeta>, + uri_path: Option<&'static str>, + semaphore: &FetchSemaphore, + exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, + mut progress: Option<&mut FetchProgressFn<'_>>, +) -> crate::Result { + if mirrors.is_empty() { + return Err( + ErrorKind::InputError("No mirrors provided!".to_string()).into() + ); + } + + for (index, mirror) in mirrors.iter().enumerate() { + let result = fetch_with_client_progress( + mirror, + sha1, + download_meta, + uri_path, + semaphore, + exec, + &REQWEST_CLIENT, + progress.as_deref_mut(), + ) + .await; + + if result.is_ok() || (result.is_err() && index == (mirrors.len() - 1)) { + return result; + } + } + + unreachable!() +} + /// Posts a JSON to a URL #[tracing::instrument(skip(json_body, semaphore))] pub async fn post_json( diff --git a/packages/ui/src/components/nav/PopupNotificationPanel.vue b/packages/ui/src/components/nav/PopupNotificationPanel.vue index 626cbff478..cf35be5109 100644 --- a/packages/ui/src/components/nav/PopupNotificationPanel.vue +++ b/packages/ui/src/components/nav/PopupNotificationPanel.vue @@ -50,10 +50,9 @@ :progress-type="progressItem.progressType" :progress-current="progressItem.progressCurrent" :progress-total="progressItem.progressTotal" - :action-label="progressItem.buttons?.[0]?.label" - :action-icon="progressItem.buttons?.[0]?.icon" + :actions="progressItem.buttons" @dismiss="handleProgressItemDismiss(item, progressItem)" - @action="handleProgressItemAction(progressItem)" + @action="(index) => handleProgressItemAction(progressItem, index)" /> @@ -233,8 +232,11 @@ async function handleProgressItemDismiss( dismiss(item.id) } -async function handleProgressItemAction(progressItem: PopupNotificationProgressItem) { - const button = progressItem.buttons?.[0] +async function handleProgressItemAction( + progressItem: PopupNotificationProgressItem, + index: number, +) { + const button = progressItem.buttons?.[index] if (button) { await handleProgressItemButtonClick(progressItem, button) } @@ -250,8 +252,8 @@ async function handleProgressItemButtonClick( } } -function handleButtonClick(id: string | number, btn: PopupNotificationButton) { - btn.action() +async function handleButtonClick(id: string | number, btn: PopupNotificationButton) { + await btn.action() if (!btn.keepOpen) { popupNotificationManager.removeNotification(id) } diff --git a/packages/ui/src/components/notifications/NotificationToast.vue b/packages/ui/src/components/notifications/NotificationToast.vue index ee3bad90c1..bca9f41f61 100644 --- a/packages/ui/src/components/notifications/NotificationToast.vue +++ b/packages/ui/src/components/notifications/NotificationToast.vue @@ -138,13 +138,17 @@
- -
@@ -172,10 +176,10 @@