Compare commits

...
44 changed files with 2572 additions and 480 deletions
Generated
+26
View File
@@ -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",
+1
View File
@@ -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"
@@ -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<InstallJobStatus>(['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<InstallJobSnapshot[]>([])
const iconUrls = ref<Record<string, string | null>>({})
const instanceNames = ref<Record<string, string>>({})
const copiedJobIds = ref<Set<string>>(new Set())
const jobOrder = new Map<string, number>()
let refreshRequest = 0
let metadataRequest = 0
let nextJobOrder = 0
const copiedResetTimeouts = new Map<string, number>()
const progressSnapshots = new Map<string, ProgressSnapshot>()
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<string>()
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<PopupNotificationProgressItem[]>(() =>
@@ -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()
},
}
}
+36 -1
View File
@@ -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<void>('plugin:install|install_job_dismiss', { jobId })
}
export async function install_job_support_details(jobId: string) {
return await invoke<string>('plugin:install|install_job_support_details', { jobId })
}
export function installJobInstanceId(job: InstallJobSnapshot): string | null {
return job.instance_id ?? job.target.instance_id ?? null
}
@@ -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"
},
@@ -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"
},
@@ -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"
},
+67 -13
View File
@@ -11,27 +11,81 @@
"app.action-bar.hide-more-running-instances": {
"message": "Hide more running instances"
},
"app.action-bar.install.copied-details": {
"message": "Copied"
},
"app.action-bar.install.copy-details": {
"message": "Copy details"
},
"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.failed-network": {
"message": "Installation failed due to a network error."
},
"app.action-bar.install.failed-unknown": {
"message": "Installation failed due to an unknown error."
},
"app.action-bar.install.open-instance": {
"message": "Open instance"
},
"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"
@@ -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"
},
@@ -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"
},
@@ -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"
},
@@ -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"
},
@@ -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"
},
@@ -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ę"
},
@@ -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"
},
@@ -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": "Открыть сборку"
},
@@ -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."
},
@@ -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"
},
@@ -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ç"
},
@@ -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": "打开实例"
},
@@ -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": "開啟實例"
},
+1
View File
@@ -157,6 +157,7 @@ fn main() {
"install_job_retry",
"install_job_cancel",
"install_job_dismiss",
"install_job_support_details",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
+6
View File
@@ -25,6 +25,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
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<InstallJobSnapshot> {
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<String> {
Ok(theseus::install::job_support_details(job_id).await?)
}
+1
View File
@@ -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 }
@@ -34,6 +34,7 @@ pub async fn get_optimal_jre_key(
loader_version.as_ref(),
None,
None,
None,
)
.await?;
+70 -13
View File
@@ -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::<Vec<Package>>(
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(),
+21 -1
View File
@@ -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,
+401 -126
View File
@@ -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<Box<dyn Future<Output = crate::Result<()>> + 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<String>,
pack_project_id: Option<String>,
reporter: InstallProgressReporter,
modpack_details: InstallPhaseDetails,
content_progress: Arc<AtomicU64>,
content_bytes_progress: Arc<AtomicU64>,
active_download_bytes: Arc<Mutex<HashMap<String, u64>>>,
file_infos_by_hash: Arc<HashMap<String, CachedFile>>,
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::<u64>()
}
}
enum MrpackZipReader {
Memory(async_zip::tokio::read::seek::ZipFileReader<Cursor<bytes::Bytes>>),
@@ -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::<u64>();
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::<String, u64>::new()));
let file_info_hashes = pack
.files
.iter()
.filter_map(|file| {
file.hashes.get(&PackFileHash::Sha1).map(String::as_str)
})
.collect::<Vec<_>>();
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::<HashMap<_, _>>(),
);
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::<PackFile, crate::Error>),
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<Box<dyn Future<Output = crate::Result<()>> + 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::<Vec<&str>>(),
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
};
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?;
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,
relative_override_file_path.as_str(),
&hash,
size,
project_type,
modpack_source_kind(version_id.as_deref()),
None,
None,
state,
)
let (size, hash) = reporter
.preserve_failure_context(override_context, extract_result)
.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(),
) {
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::<String, crate::Error>(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
+8
View File
@@ -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<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<String>,
}
#[derive(thiserror::Error, Debug)]
+667
View File
@@ -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<String> {
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<String> {
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::<Vec<_>>();
let skipped = events
.iter()
.filter_map(|event| match &event.kind {
InstallJobEventKind::ContentFileSkipped { path, reason } => {
Some((path.as_str(), reason.as_str()))
}
_ => None,
})
.collect::<Vec<_>>();
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<T: serde::Serialize>(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<Option<(PathBuf, String)>> {
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<String> {
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<String> {
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<Vec<String>> {
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::<Ipv4Addr>() {
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::<Ipv6Addr>() {
Ok(_) => ":::::::".to_string(),
_ => value.to_string(),
}
})
.into_owned()
}
+164 -9
View File
@@ -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<Mutex<InstallJobState>>,
state: Arc<Mutex<InstallProgressReporterState>>,
}
#[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,16 +42,156 @@ impl InstallProgressReporter {
progress: Option<InstallProgress>,
details: InstallPhaseDetails,
) -> crate::Result<()> {
let app_state = crate::State::get().await?;
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<InstallErrorContext>,
persist: bool,
) -> crate::Result<()> {
let app_state = if persist {
Some(crate::State::get().await?)
} else {
None
};
let mut state = self.state.lock().await;
state.progress.phase = phase;
state.progress.progress = progress;
state.progress.details = details;
state.job.set_context(context);
let Some(app_state) = app_state else {
return Ok(());
};
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();
emit_install_job(&record.snapshot()).await
}
pub async fn persist(&self) -> crate::Result<InstallJobSnapshot> {
let app_state = crate::State::get().await?;
let mut state = self.state.lock().await;
let record =
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<T>(
&self,
context: InstallErrorContext,
result: crate::Result<T>,
) -> crate::Result<T> {
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<InstallProgress>,
details: InstallPhaseDetails,
events: Vec<InstallJobEventKind>,
) -> 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)]
+8 -5
View File
@@ -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,
};
+204 -1
View File
@@ -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<InstallErrorContext>,
#[serde(default)]
pub events: Vec<InstallJobEvent>,
#[serde(default)]
pub display: Option<InstallJobDisplay>,
pub rollback: Option<InstallRollbackState>,
pub error: Option<InstallErrorView>,
#[serde(default)]
pub rollback_error: Option<InstallErrorView>,
}
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<InstallErrorContext>) {
self.context = context;
}
pub fn set_progress(
&mut self,
phase: InstallPhaseId,
progress: Option<InstallProgress>,
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<Utc>,
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<String>,
},
JobCanceled {
phase: InstallPhaseId,
},
PhaseStarted {
phase: InstallPhaseId,
details: InstallPhaseDetails,
},
ContentDownloadStarted {
files: u64,
bytes: Option<u64>,
},
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<PathBuf>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub target_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub file_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub entry_path: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub urls: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub expected_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_size: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub minecraft_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub loader: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub java_version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub os: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub arch: Option<String>,
}
#[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<InstallPhaseId>,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<InstallApiErrorDetails>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<InstallErrorContext>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct InstallApiErrorDetails {
pub error: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<String>,
}
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<InstallErrorContext>,
) -> 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<String>,
) -> 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<InstallJobDisplay>,
pub error: Option<InstallErrorView>,
pub rollback_error: Option<InstallErrorView>,
pub created: DateTime<Utc>,
pub modified: DateTime<Utc>,
pub finished: Option<DateTime<Utc>>,
+63 -39
View File
@@ -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<InstallJobDisplay> {
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(
+186 -25
View File
@@ -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<InstallJobSnapshot> {
Ok(store::get_required(job_id, &state).await?.snapshot())
}
pub async fn job_support_details(job_id: Uuid) -> crate::Result<String> {
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<InstallJobSnapshot> {
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<InstallJobSnapshot> {
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<InstallJobSnapshot> {
.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<InstallErrorContext>,
) -> 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"
}
},
_ => InstallErrorView {
code: "unknown_error".to_string(),
message: "unknown_error".to_string(),
ErrorKind::LauncherError(_) => match phase {
RunningLoaderProcessors => "processor_error",
PreparingJava => "java_error",
ResolvingLoader => "loader_error",
_ => "launcher_error",
},
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",
}
}
+1
View File
@@ -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,
+95 -3
View File
@@ -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<u64>,
progress: Option<MinecraftDownloadProgress>,
context: InstallErrorContext,
) -> crate::Result<bytes::Bytes> {
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<bool>,
loading_bar: Option<&LoadingBarId>,
reporter: Option<&InstallProgressReporter>,
) -> crate::Result<GameVersionInfo> {
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?;
+3
View File
@@ -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?;
}
+5
View File
@@ -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<Arc<State>> = 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,
+74 -2
View File
@@ -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<FetchFence> =
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<Bytes> {
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<T>(
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::<LabrinthError>().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<Bytes> {
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(
@@ -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)"
/>
</div>
</div>
@@ -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)
}
@@ -138,13 +138,17 @@
</div>
</div>
<div
v-if="type === 'instance-download' && actionLabel"
class="col-start-1 row-start-3 mt-2 flex min-w-0 items-center gap-2"
v-if="type === 'instance-download' && actions?.length"
class="col-start-1 col-end-3 row-start-3 mt-2 flex min-w-0 flex-wrap items-center gap-2"
>
<ButtonStyled color="brand">
<button @click="$emit('action')">
<component :is="actionIcon" v-if="actionIcon" />
{{ actionLabel }}
<ButtonStyled
v-for="(action, index) in actions"
:key="index"
:color="action.color || (index === 0 ? 'brand' : undefined)"
>
<button class="!shadow-none" @click="$emit('action', index)">
<component :is="action.icon" v-if="action.icon" />
{{ action.label }}
</button>
</ButtonStyled>
</div>
@@ -172,10 +176,10 @@
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { type Component, computed, ref } from 'vue'
import { computed, ref } from 'vue'
import { useFormatBytes, useFormatNumber } from '../../composables'
import type { PopupNotificationProgressType } from '../../providers'
import type { PopupNotificationButton, PopupNotificationProgressType } from '../../providers'
import { truncatedTooltip } from '../../utils/truncate'
import Avatar from '../base/Avatar.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
@@ -202,8 +206,7 @@ const props = withDefaults(
progressType?: PopupNotificationProgressType
progressCurrent?: number
progressTotal?: number
actionLabel?: string
actionIcon?: Component
actions?: PopupNotificationButton[]
}>(),
{
actorName: null,
@@ -221,7 +224,7 @@ defineEmits<{
accept: []
decline: []
dismiss: []
action: []
action: [index: number]
launch: []
'open-actor': []
'open-instance': []
@@ -4,7 +4,7 @@ import { createContext } from '.'
export interface PopupNotificationButton {
label: string
action: () => void
action: () => void | Promise<void>
icon?: Component
color?: 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'standard'
keepOpen?: boolean
@@ -1,7 +1,8 @@
import { MinecraftServerIcon } from '@modrinth/assets'
import { CopyIcon, MinecraftServerIcon, UpdatedIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { NotificationToast } from '../../components/notifications'
import type { PopupNotificationButton } from '../../providers'
const avatarUrl =
'https://cdn.modrinth.com/user/6Qo4A5QT/9d81be1a9fb1afd163b7f2f05a791955e7693c90.png'
@@ -205,3 +206,43 @@ export const DownloadProgressLabels: Story = {
`,
}),
}
export const FailedDownloadActions: Story = {
render: () => ({
components: { NotificationToast },
setup() {
return {
instanceIconUrl: MinecraftServerIcon,
actions: [
{
label: 'Retry',
icon: UpdatedIcon,
color: 'brand',
action: noop,
},
{
label: 'Copy details',
icon: CopyIcon,
color: 'standard',
action: noop,
},
] satisfies PopupNotificationButton[],
noop,
}
},
template: /* html */ `
<NotificationToast
type="instance-download"
entity-name="Cobblemon Official Modpack"
:entity-icon-url="instanceIconUrl"
status-text="Failed while downloading content."
:progress="0"
:show-progress="false"
wrap-text
:actions="actions"
@action="(index) => actions[index].action()"
@dismiss="noop"
/>
`,
}),
}