mirror of
https://github.com/modrinth/code.git
synced 2026-09-04 22:10:15 +00:00
Merge branch 'main' into boris/search-index-fix
This commit is contained in:
Generated
+26
@@ -1628,6 +1628,31 @@ dependencies = [
|
|||||||
"serde_with",
|
"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]]
|
[[package]]
|
||||||
name = "borsh"
|
name = "borsh"
|
||||||
version = "1.5.7"
|
version = "1.5.7"
|
||||||
@@ -10849,6 +10874,7 @@ dependencies = [
|
|||||||
"async-walkdir",
|
"async-walkdir",
|
||||||
"async_zip",
|
"async_zip",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"bon",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chardetng",
|
"chardetng",
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [
|
|||||||
] }
|
] }
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
bitflags = "2.9.4"
|
bitflags = "2.9.4"
|
||||||
|
bon = "3.9.3"
|
||||||
bytemuck = "1.24.0"
|
bytemuck = "1.24.0"
|
||||||
bytes = "1.10.1"
|
bytes = "1.10.1"
|
||||||
censor = "0.3.0"
|
censor = "0.3.0"
|
||||||
|
|||||||
@@ -269,16 +269,31 @@ provideInstallationSettings({
|
|||||||
debug('resolveLoaderVersions: no manifest', { loader, gameVersion })
|
debug('resolveLoaderVersions: no manifest', { loader, gameVersion })
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
if (loader === 'fabric' || loader === 'quilt') {
|
const entry = manifest.gameVersions?.find((item) => item.id === gameVersion)
|
||||||
const result = manifest.gameVersions[0]?.loaders ?? []
|
if (entry?.versionGroup) {
|
||||||
debug('resolveLoaderVersions: fabric/quilt result', {
|
const result =
|
||||||
|
manifest.versionGroups?.find((group) => group.id === entry.versionGroup)?.loaders ?? []
|
||||||
|
debug('resolveLoaderVersions: version group result', {
|
||||||
|
loader,
|
||||||
|
gameVersion,
|
||||||
|
versionGroup: entry.versionGroup,
|
||||||
|
count: result.length,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
const placeholder = manifest.gameVersions?.find((item) => item.id === '${modrinth.gameVersion}')
|
||||||
|
if (placeholder) {
|
||||||
|
const result = manifest.gameVersions?.some((item) => item.id === gameVersion)
|
||||||
|
? placeholder.loaders
|
||||||
|
: []
|
||||||
|
debug('resolveLoaderVersions: placeholder result', {
|
||||||
loader,
|
loader,
|
||||||
gameVersion,
|
gameVersion,
|
||||||
count: result.length,
|
count: result.length,
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
const result = manifest.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
|
const result = entry?.loaders ?? []
|
||||||
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
|
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { UpdatedIcon } from '@modrinth/assets'
|
import { CheckIcon, CopyIcon, UpdatedIcon } from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
defineMessages,
|
defineMessages,
|
||||||
type PopupNotificationButton,
|
type PopupNotificationButton,
|
||||||
@@ -15,9 +15,11 @@ import {
|
|||||||
install_job_dismiss,
|
install_job_dismiss,
|
||||||
install_job_list,
|
install_job_list,
|
||||||
install_job_retry,
|
install_job_retry,
|
||||||
|
install_job_support_details,
|
||||||
installJobInstanceId,
|
installJobInstanceId,
|
||||||
type InstallJobSnapshot,
|
type InstallJobSnapshot,
|
||||||
type InstallJobStatus,
|
type InstallJobStatus,
|
||||||
|
type InstallPhaseId,
|
||||||
type InstallProgress,
|
type InstallProgress,
|
||||||
} from '@/helpers/install'
|
} from '@/helpers/install'
|
||||||
import { get_many as getInstances } from '@/helpers/instance'
|
import { get_many as getInstances } from '@/helpers/instance'
|
||||||
@@ -31,6 +33,14 @@ const messages = defineMessages({
|
|||||||
id: 'app.action-bar.install.retry',
|
id: 'app.action-bar.install.retry',
|
||||||
defaultMessage: '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: {
|
dismiss: {
|
||||||
id: 'app.action-bar.install.dismiss',
|
id: 'app.action-bar.install.dismiss',
|
||||||
defaultMessage: 'Dismiss',
|
defaultMessage: 'Dismiss',
|
||||||
@@ -39,22 +49,6 @@ const messages = defineMessages({
|
|||||||
id: 'app.action-bar.install.open-instance',
|
id: 'app.action-bar.install.open-instance',
|
||||||
defaultMessage: '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: {
|
unknownInstance: {
|
||||||
id: 'app.action-bar.install.unknown-instance',
|
id: 'app.action-bar.install.unknown-instance',
|
||||||
defaultMessage: 'Unknown instance',
|
defaultMessage: 'Unknown instance',
|
||||||
@@ -64,7 +58,7 @@ const messages = defineMessages({
|
|||||||
const phaseMessages = defineMessages({
|
const phaseMessages = defineMessages({
|
||||||
preparing_instance: {
|
preparing_instance: {
|
||||||
id: 'app.install.phase.preparing_instance',
|
id: 'app.install.phase.preparing_instance',
|
||||||
defaultMessage: 'Preparing instance',
|
defaultMessage: 'Queued to install',
|
||||||
},
|
},
|
||||||
resolving_pack: {
|
resolving_pack: {
|
||||||
id: 'app.install.phase.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 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 {
|
function getDisplayIconUrl(icon: string | null | undefined): string | null {
|
||||||
if (!icon) return null
|
if (!icon) return null
|
||||||
@@ -156,10 +240,13 @@ export async function useInstallJobNotifications(opts: {
|
|||||||
const jobs = ref<InstallJobSnapshot[]>([])
|
const jobs = ref<InstallJobSnapshot[]>([])
|
||||||
const iconUrls = ref<Record<string, string | null>>({})
|
const iconUrls = ref<Record<string, string | null>>({})
|
||||||
const instanceNames = ref<Record<string, string>>({})
|
const instanceNames = ref<Record<string, string>>({})
|
||||||
|
const copiedJobIds = ref<Set<string>>(new Set())
|
||||||
const jobOrder = new Map<string, number>()
|
const jobOrder = new Map<string, number>()
|
||||||
let refreshRequest = 0
|
let refreshRequest = 0
|
||||||
let metadataRequest = 0
|
let metadataRequest = 0
|
||||||
let nextJobOrder = 0
|
let nextJobOrder = 0
|
||||||
|
const copiedResetTimeouts = new Map<string, number>()
|
||||||
|
const progressSnapshots = new Map<string, ProgressSnapshot>()
|
||||||
|
|
||||||
function getTitle(job: InstallJobSnapshot): string {
|
function getTitle(job: InstallJobSnapshot): string {
|
||||||
if (job.display?.title) return job.display.title
|
if (job.display?.title) return job.display.title
|
||||||
@@ -174,13 +261,7 @@ export async function useInstallJobNotifications(opts: {
|
|||||||
|
|
||||||
function getText(job: InstallJobSnapshot): string {
|
function getText(job: InstallJobSnapshot): string {
|
||||||
if (job.status === 'failed' || job.status === 'interrupted') {
|
if (job.status === 'failed' || job.status === 'interrupted') {
|
||||||
if (job.error?.code === 'interrupted') {
|
return getFailureSummary(job)
|
||||||
return formatMessage(messages.installFailedAppClosed)
|
|
||||||
}
|
|
||||||
if (job.error?.code === 'network_error') {
|
|
||||||
return formatMessage(messages.installFailedNetwork)
|
|
||||||
}
|
|
||||||
return formatMessage(messages.installFailedUnknown)
|
|
||||||
}
|
}
|
||||||
if (job.phase === 'preparing_java' && job.details.type === 'java') {
|
if (job.phase === 'preparing_java' && job.details.type === 'java') {
|
||||||
return formatMessage(javaStepMessages[job.details.step], {
|
return formatMessage(javaStepMessages[job.details.step], {
|
||||||
@@ -190,6 +271,104 @@ export async function useInstallJobNotifications(opts: {
|
|||||||
return formatMessage(phaseMessages[job.phase])
|
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 {
|
function getProgressType(job: InstallJobSnapshot): PopupNotificationProgressType | undefined {
|
||||||
if (!getEffectiveProgress(job)) return undefined
|
if (!getEffectiveProgress(job)) return undefined
|
||||||
if (
|
if (
|
||||||
@@ -235,11 +414,165 @@ export async function useInstallJobNotifications(opts: {
|
|||||||
return job.status === 'failed' || job.status === 'interrupted'
|
return job.status === 'failed' || job.status === 'interrupted'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTerminalButtons(job: InstallJobSnapshot): PopupNotificationButton[] | undefined {
|
function canShowStalledProgressDetails(job: InstallJobSnapshot): boolean {
|
||||||
if (!isTerminalJob(job)) return undefined
|
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 [
|
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),
|
label: formatMessage(messages.retry),
|
||||||
icon: UpdatedIcon,
|
icon: UpdatedIcon,
|
||||||
color: 'brand',
|
color: 'brand',
|
||||||
@@ -248,8 +581,23 @@ export async function useInstallJobNotifications(opts: {
|
|||||||
await install_job_retry(job.job_id).catch(opts.handleError)
|
await install_job_retry(job.job_id).catch(opts.handleError)
|
||||||
await refresh()
|
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[]) {
|
function setJobs(nextJobs: InstallJobSnapshot[]) {
|
||||||
@@ -259,13 +607,15 @@ export async function useInstallJobNotifications(opts: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jobs.value = nextJobs
|
const visibleJobs = nextJobs.filter((job) => visibleJobStatuses.has(job.status))
|
||||||
.filter((job) => visibleJobStatuses.has(job.status))
|
syncProgressSnapshots(visibleJobs)
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
jobs.value = visibleJobs.sort(
|
||||||
a.created.localeCompare(b.created) ||
|
(a, b) =>
|
||||||
(jobOrder.get(a.job_id) ?? 0) - (jobOrder.get(b.job_id) ?? 0),
|
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[]>(() =>
|
const progressItems = computed<PopupNotificationProgressItem[]>(() =>
|
||||||
@@ -284,7 +634,7 @@ export async function useInstallJobNotifications(opts: {
|
|||||||
progressType: isTerminalJob(job) ? undefined : getProgressType(job),
|
progressType: isTerminalJob(job) ? undefined : getProgressType(job),
|
||||||
progressCurrent: isTerminalJob(job) ? undefined : progress?.current,
|
progressCurrent: isTerminalJob(job) ? undefined : progress?.current,
|
||||||
progressTotal: isTerminalJob(job) ? undefined : progress?.total,
|
progressTotal: isTerminalJob(job) ? undefined : progress?.total,
|
||||||
buttons: getTerminalButtons(job),
|
buttons: getButtons(job),
|
||||||
onDismiss: isTerminalJob(job)
|
onDismiss: isTerminalJob(job)
|
||||||
? async () => {
|
? async () => {
|
||||||
await install_job_dismiss(job.job_id).catch(opts.handleError)
|
await install_job_dismiss(job.job_id).catch(opts.handleError)
|
||||||
@@ -382,6 +732,14 @@ export async function useInstallJobNotifications(opts: {
|
|||||||
progressItems,
|
progressItems,
|
||||||
buttons,
|
buttons,
|
||||||
refresh,
|
refresh,
|
||||||
dispose: () => unlisten(),
|
dispose: () => {
|
||||||
|
for (const timeout of copiedResetTimeouts.values()) {
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
for (const jobId of progressSnapshots.keys()) {
|
||||||
|
clearProgressSnapshot(jobId)
|
||||||
|
}
|
||||||
|
unlisten()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,36 @@ export type InstallJavaStep =
|
|||||||
| 'extracting'
|
| 'extracting'
|
||||||
| 'validating'
|
| '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 {
|
export interface InstallJobSnapshot {
|
||||||
job_id: string
|
job_id: string
|
||||||
instance_id?: string | null
|
instance_id?: string | null
|
||||||
@@ -114,7 +144,8 @@ export interface InstallJobSnapshot {
|
|||||||
}
|
}
|
||||||
| { type: 'import'; launcher_type: string; instance_folder: string }
|
| { type: 'import'; launcher_type: string; instance_folder: string }
|
||||||
display?: { title: string; icon?: string | null } | null
|
display?: { title: string; icon?: string | null } | null
|
||||||
error?: { code: string; message: string } | null
|
error?: InstallErrorView | null
|
||||||
|
rollback_error?: InstallErrorView | null
|
||||||
created: string
|
created: string
|
||||||
modified: string
|
modified: string
|
||||||
finished?: string | null
|
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 })
|
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 {
|
export function installJobInstanceId(job: InstallJobSnapshot): string | null {
|
||||||
return job.instance_id ?? job.target.instance_id ?? null
|
return job.instance_id ?? job.target.instance_id ?? null
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -133,11 +133,18 @@ type Hooks = {
|
|||||||
|
|
||||||
type Manifest = {
|
type Manifest = {
|
||||||
gameVersions: ManifestGameVersion[]
|
gameVersions: ManifestGameVersion[]
|
||||||
|
versionGroups?: ManifestVersionGroup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ManifestGameVersion = {
|
type ManifestGameVersion = {
|
||||||
id: string
|
id: string
|
||||||
stable: boolean
|
stable: boolean
|
||||||
|
versionGroup?: string
|
||||||
|
loaders: ManifestLoaderVersion[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManifestVersionGroup = {
|
||||||
|
id: string
|
||||||
loaders: ManifestLoaderVersion[]
|
loaders: ManifestLoaderVersion[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,18 +14,6 @@
|
|||||||
"app.action-bar.install.dismiss": {
|
"app.action-bar.install.dismiss": {
|
||||||
"message": "Zavřít"
|
"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": {
|
"app.action-bar.install.open-instance": {
|
||||||
"message": "Otevřít instanci"
|
"message": "Otevřít instanci"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,18 +14,6 @@
|
|||||||
"app.action-bar.install.dismiss": {
|
"app.action-bar.install.dismiss": {
|
||||||
"message": "Ignorieren"
|
"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": {
|
"app.action-bar.install.open-instance": {
|
||||||
"message": "Instanz öffnen"
|
"message": "Instanz öffnen"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,18 +14,6 @@
|
|||||||
"app.action-bar.install.dismiss": {
|
"app.action-bar.install.dismiss": {
|
||||||
"message": "Ausblenden"
|
"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": {
|
"app.action-bar.install.open-instance": {
|
||||||
"message": "Instanz öffnen"
|
"message": "Instanz öffnen"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,27 +11,81 @@
|
|||||||
"app.action-bar.hide-more-running-instances": {
|
"app.action-bar.hide-more-running-instances": {
|
||||||
"message": "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": {
|
"app.action-bar.install.dismiss": {
|
||||||
"message": "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": {
|
"app.action-bar.install.open-instance": {
|
||||||
"message": "Open instance"
|
"message": "Open instance"
|
||||||
},
|
},
|
||||||
"app.action-bar.install.retry": {
|
"app.action-bar.install.retry": {
|
||||||
"message": "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": {
|
"app.action-bar.install.unknown-instance": {
|
||||||
"message": "Unknown instance"
|
"message": "Unknown instance"
|
||||||
},
|
},
|
||||||
@@ -231,7 +285,7 @@
|
|||||||
"message": "Finalizing"
|
"message": "Finalizing"
|
||||||
},
|
},
|
||||||
"app.install.phase.preparing_instance": {
|
"app.install.phase.preparing_instance": {
|
||||||
"message": "Preparing instance"
|
"message": "Queued to install"
|
||||||
},
|
},
|
||||||
"app.install.phase.preparing_java": {
|
"app.install.phase.preparing_java": {
|
||||||
"message": "Preparing Java"
|
"message": "Preparing Java"
|
||||||
|
|||||||
@@ -14,18 +14,6 @@
|
|||||||
"app.action-bar.install.dismiss": {
|
"app.action-bar.install.dismiss": {
|
||||||
"message": "Descartar"
|
"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": {
|
"app.action-bar.install.open-instance": {
|
||||||
"message": "Abrir instancia"
|
"message": "Abrir instancia"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,15 +14,6 @@
|
|||||||
"app.action-bar.install.dismiss": {
|
"app.action-bar.install.dismiss": {
|
||||||
"message": "Descartar"
|
"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": {
|
"app.action-bar.make-primary-instance": {
|
||||||
"message": "Establecer como instancia principal"
|
"message": "Establecer como instancia principal"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,18 +14,6 @@
|
|||||||
"app.action-bar.install.dismiss": {
|
"app.action-bar.install.dismiss": {
|
||||||
"message": "Hylkää"
|
"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": {
|
"app.action-bar.install.open-instance": {
|
||||||
"message": "Avaa instanssi"
|
"message": "Avaa instanssi"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,18 +14,6 @@
|
|||||||
"app.action-bar.install.dismiss": {
|
"app.action-bar.install.dismiss": {
|
||||||
"message": "Rejeter"
|
"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": {
|
"app.action-bar.install.open-instance": {
|
||||||
"message": "Ouvrir l'instance"
|
"message": "Ouvrir l'instance"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,18 +14,6 @@
|
|||||||
"app.action-bar.install.dismiss": {
|
"app.action-bar.install.dismiss": {
|
||||||
"message": "Ignora"
|
"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": {
|
"app.action-bar.install.open-instance": {
|
||||||
"message": "Apri istanza"
|
"message": "Apri istanza"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,18 +14,6 @@
|
|||||||
"app.action-bar.install.dismiss": {
|
"app.action-bar.install.dismiss": {
|
||||||
"message": "Odrzuć"
|
"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": {
|
"app.action-bar.install.open-instance": {
|
||||||
"message": "Otwórz instancję"
|
"message": "Otwórz instancję"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,18 +14,6 @@
|
|||||||
"app.action-bar.install.dismiss": {
|
"app.action-bar.install.dismiss": {
|
||||||
"message": "Dispensar"
|
"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": {
|
"app.action-bar.install.open-instance": {
|
||||||
"message": "Abrir instância"
|
"message": "Abrir instância"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,18 +14,6 @@
|
|||||||
"app.action-bar.install.dismiss": {
|
"app.action-bar.install.dismiss": {
|
||||||
"message": "Закрыть"
|
"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": {
|
"app.action-bar.install.open-instance": {
|
||||||
"message": "Открыть сборку"
|
"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": {
|
"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."
|
"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": {
|
"app.action-bar.install.dismiss": {
|
||||||
"message": "Avvisa"
|
"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": {
|
"app.action-bar.install.open-instance": {
|
||||||
"message": "Öppna instans"
|
"message": "Öppna instans"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,18 +14,6 @@
|
|||||||
"app.action-bar.install.dismiss": {
|
"app.action-bar.install.dismiss": {
|
||||||
"message": "Reddet"
|
"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": {
|
"app.action-bar.install.open-instance": {
|
||||||
"message": "Kurulumu aç"
|
"message": "Kurulumu aç"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,18 +14,6 @@
|
|||||||
"app.action-bar.install.dismiss": {
|
"app.action-bar.install.dismiss": {
|
||||||
"message": "忽略"
|
"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": {
|
"app.action-bar.install.open-instance": {
|
||||||
"message": "打开实例"
|
"message": "打开实例"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,18 +14,6 @@
|
|||||||
"app.action-bar.install.dismiss": {
|
"app.action-bar.install.dismiss": {
|
||||||
"message": "忽略"
|
"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": {
|
"app.action-bar.install.open-instance": {
|
||||||
"message": "開啟實例"
|
"message": "開啟實例"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ fn main() {
|
|||||||
"install_job_retry",
|
"install_job_retry",
|
||||||
"install_job_cancel",
|
"install_job_cancel",
|
||||||
"install_job_dismiss",
|
"install_job_dismiss",
|
||||||
|
"install_job_support_details",
|
||||||
])
|
])
|
||||||
.default_permission(
|
.default_permission(
|
||||||
DefaultPermissionRule::AllowAllCommands,
|
DefaultPermissionRule::AllowAllCommands,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
install_job_retry,
|
install_job_retry,
|
||||||
install_job_cancel,
|
install_job_cancel,
|
||||||
install_job_dismiss,
|
install_job_dismiss,
|
||||||
|
install_job_support_details,
|
||||||
])
|
])
|
||||||
.build()
|
.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<()> {
|
pub async fn install_job_dismiss(job_id: Uuid) -> Result<()> {
|
||||||
Ok(theseus::install::dismiss_job(job_id).await?)
|
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?)
|
||||||
|
}
|
||||||
|
|||||||
@@ -126,6 +126,9 @@
|
|||||||
@download="emit('download')"
|
@download="emit('download')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="showNoCompatibleVersions" class="pl-1 text-base text-primary" role="status">
|
||||||
|
{{ noCompatibleVersionsDescription }}
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -174,6 +177,7 @@ const props = withDefaults(
|
|||||||
versions?: Labrinth.Versions.v3.Version[]
|
versions?: Labrinth.Versions.v3.Version[]
|
||||||
dependencyDownloadFiles?: DownloadableFile[]
|
dependencyDownloadFiles?: DownloadableFile[]
|
||||||
downloadDataLoaded?: boolean
|
downloadDataLoaded?: boolean
|
||||||
|
versionsLoaded?: boolean
|
||||||
downloadReason?: CdnDownloadReason
|
downloadReason?: CdnDownloadReason
|
||||||
initialGameVersion?: string | null
|
initialGameVersion?: string | null
|
||||||
initialPlatform?: string | null
|
initialPlatform?: string | null
|
||||||
@@ -185,6 +189,7 @@ const props = withDefaults(
|
|||||||
versions: () => [],
|
versions: () => [],
|
||||||
dependencyDownloadFiles: () => [],
|
dependencyDownloadFiles: () => [],
|
||||||
downloadDataLoaded: false,
|
downloadDataLoaded: false,
|
||||||
|
versionsLoaded: false,
|
||||||
downloadReason: 'standalone',
|
downloadReason: 'standalone',
|
||||||
initialGameVersion: null,
|
initialGameVersion: null,
|
||||||
initialPlatform: null,
|
initialPlatform: null,
|
||||||
@@ -409,6 +414,25 @@ const compatibleVersions = computed<Labrinth.Versions.v3.Version[]>(() => {
|
|||||||
return [defaultSelectedVersion.value, ...suggestedPreReleaseVersions.value]
|
return [defaultSelectedVersion.value, ...suggestedPreReleaseVersions.value]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showNoCompatibleVersions = computed(() => {
|
||||||
|
return (
|
||||||
|
props.versionsLoaded &&
|
||||||
|
compatibleVersions.value.length === 0 &&
|
||||||
|
!!currentGameVersion.value &&
|
||||||
|
!!currentPlatform.value
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const noCompatibleVersionsDescription = computed(() => {
|
||||||
|
const gameVersion = currentGameVersion.value
|
||||||
|
if (!gameVersion || !currentPlatform.value) return ''
|
||||||
|
|
||||||
|
return formatMessage(messages.noVersionsAvailable, {
|
||||||
|
gameVersion,
|
||||||
|
platform: currentPlatformText.value,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const selectedVersion = computed<Labrinth.Versions.v3.Version | null>(() => {
|
const selectedVersion = computed<Labrinth.Versions.v3.Version | null>(() => {
|
||||||
return (
|
return (
|
||||||
compatibleVersions.value.find(
|
compatibleVersions.value.find(
|
||||||
@@ -636,6 +660,10 @@ const messages = defineMessages({
|
|||||||
id: 'project.download.no-game-versions-found',
|
id: 'project.download.no-game-versions-found',
|
||||||
defaultMessage: 'No game versions found',
|
defaultMessage: 'No game versions found',
|
||||||
},
|
},
|
||||||
|
noVersionsAvailable: {
|
||||||
|
id: 'project.download.no-versions-available',
|
||||||
|
defaultMessage: 'No versions available for {gameVersion} and {platform}.',
|
||||||
|
},
|
||||||
platformUnsupportedTooltip: {
|
platformUnsupportedTooltip: {
|
||||||
id: 'project.download.platform-unsupported-tooltip',
|
id: 'project.download.platform-unsupported-tooltip',
|
||||||
defaultMessage: '{title} does not support {platform} for {gameVersion}',
|
defaultMessage: '{title} does not support {platform} for {gameVersion}',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
:versions="versions"
|
:versions="versions"
|
||||||
:dependency-download-files="dependencyDownloadFiles"
|
:dependency-download-files="dependencyDownloadFiles"
|
||||||
:download-data-loaded="downloadRowsLoaded"
|
:download-data-loaded="downloadRowsLoaded"
|
||||||
|
:versions-loaded="versionsLoaded"
|
||||||
:download-reason="downloadReason"
|
:download-reason="downloadReason"
|
||||||
:initial-game-version="initialGameVersion"
|
:initial-game-version="initialGameVersion"
|
||||||
:initial-platform="initialPlatform"
|
:initial-platform="initialPlatform"
|
||||||
@@ -255,6 +256,9 @@ const { data: versionsV3, isFetching: versionsV3Loading } = useQuery({
|
|||||||
const versions = computed<Labrinth.Versions.v3.Version[]>(() =>
|
const versions = computed<Labrinth.Versions.v3.Version[]>(() =>
|
||||||
normalizeVersionsForDownload(versionsV3.value ?? []),
|
normalizeVersionsForDownload(versionsV3.value ?? []),
|
||||||
)
|
)
|
||||||
|
const versionsLoaded = computed(
|
||||||
|
() => versionsEnabled.value && !versionsV3Loading.value && Array.isArray(versionsV3.value),
|
||||||
|
)
|
||||||
|
|
||||||
const initialGameVersion = computed(() => {
|
const initialGameVersion = computed(() => {
|
||||||
const version = route.query.version
|
const version = route.query.version
|
||||||
|
|||||||
@@ -55,7 +55,18 @@
|
|||||||
{{ formatTraceCount(trace.local_trace_count) }}
|
{{ formatTraceCount(trace.local_trace_count) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge :type="trace.verdict" />
|
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
<Badge :type="trace.verdict" />
|
||||||
|
<ButtonStyled color="red">
|
||||||
|
<button
|
||||||
|
:disabled="removingTraceKeys.has(trace.detail_key)"
|
||||||
|
@click="removeGlobalTrace(trace)"
|
||||||
|
>
|
||||||
|
<TrashIcon aria-hidden="true" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="getPreviewLocalTraces(trace).length > 0" class="flex flex-col gap-2">
|
<div v-if="getPreviewLocalTraces(trace).length > 0" class="flex flex-col gap-2">
|
||||||
@@ -97,12 +108,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Labrinth } from '@modrinth/api-client'
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
import { HashIcon, ListIcon, SearchIcon } from '@modrinth/assets'
|
import { HashIcon, ListIcon, SearchIcon, TrashIcon } from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
|
injectNotificationManager,
|
||||||
Pagination,
|
Pagination,
|
||||||
StyledInput,
|
StyledInput,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
@@ -110,6 +122,7 @@ import {
|
|||||||
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
|
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
|
||||||
|
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
|
const { addNotification } = injectNotificationManager()
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const activeQuery = ref<string | null>(null)
|
const activeQuery = ref<string | null>(null)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
@@ -119,6 +132,7 @@ const itemsPerPage = 20
|
|||||||
const localTracePreviewLimit = 10
|
const localTracePreviewLimit = 10
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const traces = ref<Labrinth.TechReview.Internal.GlobalIssueDetail[]>([])
|
const traces = ref<Labrinth.TechReview.Internal.GlobalIssueDetail[]>([])
|
||||||
|
const removingTraceKeys = reactive<Set<string>>(new Set())
|
||||||
|
|
||||||
const pageCount = computed(() => Math.max(Math.ceil(total.value / itemsPerPage), 1))
|
const pageCount = computed(() => Math.max(Math.ceil(total.value / itemsPerPage), 1))
|
||||||
const pageStart = computed(() =>
|
const pageStart = computed(() =>
|
||||||
@@ -176,5 +190,37 @@ async function switchPage(page: number) {
|
|||||||
await loadTraces()
|
await loadTraces()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeGlobalTrace(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
|
||||||
|
if (removingTraceKeys.has(trace.detail_key)) return
|
||||||
|
|
||||||
|
removingTraceKeys.add(trace.detail_key)
|
||||||
|
try {
|
||||||
|
await client.labrinth.tech_review_internal.updateGlobalIssueDetails([
|
||||||
|
{ detail_key: trace.detail_key, verdict: 'pending' },
|
||||||
|
])
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Global trace removed',
|
||||||
|
text: 'The global verdict for this trace key has been removed.',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (traces.value.length === 1 && currentPage.value > 1) {
|
||||||
|
currentPage.value--
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadTraces()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove global trace', error)
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Failed to remove global trace',
|
||||||
|
text: 'An error occurred while removing the global trace verdict.',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
removingTraceKeys.delete(trace.detail_key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadTraces)
|
onMounted(loadTraces)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Labrinth } from '@modrinth/api-client'
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
import {
|
import {
|
||||||
|
BanIcon,
|
||||||
BugIcon,
|
BugIcon,
|
||||||
|
CheckCheckIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
EyeOffIcon,
|
EyeOffIcon,
|
||||||
LoaderCircleIcon,
|
LoaderCircleIcon,
|
||||||
ScaleIcon,
|
ScaleIcon,
|
||||||
|
ShieldAlertIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
SpinnerIcon,
|
SpinnerIcon,
|
||||||
TimerIcon,
|
TimerIcon,
|
||||||
@@ -207,15 +210,31 @@ async function updateIssueDetails(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateGlobalIssueDetail(
|
||||||
|
detailKey: string,
|
||||||
|
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||||
|
) {
|
||||||
|
await client.labrinth.tech_review_internal.updateGlobalIssueDetails([
|
||||||
|
{ detail_key: detailKey, verdict },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
const severityOrder = { severe: 3, high: 2, medium: 1, low: 0 } as Record<string, number>
|
const severityOrder = { severe: 3, high: 2, medium: 1, low: 0 } as Record<string, number>
|
||||||
|
|
||||||
type DetailDecision = 'safe' | 'malware'
|
type DetailDecision = 'safe' | 'malware' | 'pending'
|
||||||
|
type DetailDecisionScope = 'local' | 'global'
|
||||||
|
|
||||||
const detailDecisions = reactive<Map<string, DetailDecision>>(new Map())
|
const detailDecisions = reactive<Map<string, DetailDecision>>(new Map())
|
||||||
|
const detailDecisionScopes = reactive<Map<string, DetailDecisionScope>>(new Map())
|
||||||
const updatingDetails = reactive<Set<string>>(new Set())
|
const updatingDetails = reactive<Set<string>>(new Set())
|
||||||
|
const updatingGlobalDetailKeys = reactive<Set<string>>(new Set())
|
||||||
|
|
||||||
function verdictToDecision(verdict: 'safe' | 'unsafe'): DetailDecision {
|
function verdictToDecision(
|
||||||
return verdict === 'safe' ? 'safe' : 'malware'
|
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||||
|
): DetailDecision {
|
||||||
|
if (verdict === 'safe') return 'safe'
|
||||||
|
if (verdict === 'unsafe') return 'malware'
|
||||||
|
return 'pending'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAllDetails(): Labrinth.TechReview.Internal.ReportIssueDetail[] {
|
function getAllDetails(): Labrinth.TechReview.Internal.ReportIssueDetail[] {
|
||||||
@@ -225,6 +244,7 @@ function getAllDetails(): Labrinth.TechReview.Internal.ReportIssueDetail[] {
|
|||||||
function applyDecisionToRelatedDetails(
|
function applyDecisionToRelatedDetails(
|
||||||
detailIds: string[],
|
detailIds: string[],
|
||||||
decision: DetailDecision,
|
decision: DetailDecision,
|
||||||
|
scope: DetailDecisionScope,
|
||||||
): { otherMatchedCount: number } {
|
): { otherMatchedCount: number } {
|
||||||
const allDetails = getAllDetails()
|
const allDetails = getAllDetails()
|
||||||
const selectedDetailIds = new Set(detailIds)
|
const selectedDetailIds = new Set(detailIds)
|
||||||
@@ -242,12 +262,14 @@ function applyDecisionToRelatedDetails(
|
|||||||
|
|
||||||
if (matchingDetails.length === 0) {
|
if (matchingDetails.length === 0) {
|
||||||
detailDecisions.set(detailId, decision)
|
detailDecisions.set(detailId, decision)
|
||||||
|
detailDecisionScopes.set(detailId, scope)
|
||||||
updatedDetailIds.add(detailId)
|
updatedDetailIds.add(detailId)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const matchingDetail of matchingDetails) {
|
for (const matchingDetail of matchingDetails) {
|
||||||
detailDecisions.set(matchingDetail.id, decision)
|
detailDecisions.set(matchingDetail.id, decision)
|
||||||
|
detailDecisionScopes.set(matchingDetail.id, scope)
|
||||||
updatedDetailIds.add(matchingDetail.id)
|
updatedDetailIds.add(matchingDetail.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,6 +280,98 @@ function applyDecisionToRelatedDetails(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusMatchesDecision(
|
||||||
|
status: Labrinth.TechReview.Internal.DelphiReportIssueStatus | null,
|
||||||
|
decision: DetailDecision,
|
||||||
|
): boolean {
|
||||||
|
if (status === 'safe') return decision === 'safe'
|
||||||
|
if (status === 'unsafe') return decision === 'malware'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDetailActionSelected(
|
||||||
|
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||||
|
decision: DetailDecision,
|
||||||
|
scope: DetailDecisionScope,
|
||||||
|
): boolean {
|
||||||
|
const localDecision = detailDecisions.get(detail.id)
|
||||||
|
const localScope = detailDecisionScopes.get(detail.id)
|
||||||
|
if (localDecision && localScope) {
|
||||||
|
if (localDecision === 'pending') {
|
||||||
|
if (localScope === 'local') {
|
||||||
|
if (scope === 'local') return false
|
||||||
|
return statusMatchesDecision(detail.global_status, decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === 'global') return false
|
||||||
|
return statusMatchesDecision(detail.local_status, decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
return localDecision === decision && localScope === scope
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === 'global') {
|
||||||
|
return statusMatchesDecision(detail.global_status, decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.global_status) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusMatchesDecision(detail.local_status, decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
function decisionToVerdict(
|
||||||
|
decision: Exclude<DetailDecision, 'pending'>,
|
||||||
|
): Labrinth.TechReview.Internal.DelphiReportIssueStatus {
|
||||||
|
return decision === 'safe' ? 'safe' : 'unsafe'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToggledDetailVerdict(
|
||||||
|
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||||
|
decision: Exclude<DetailDecision, 'pending'>,
|
||||||
|
scope: DetailDecisionScope,
|
||||||
|
): Labrinth.TechReview.Internal.DelphiReportIssueStatus {
|
||||||
|
return isDetailActionSelected(detail, decision, scope) ? 'pending' : decisionToVerdict(decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDetailActionTooltip(
|
||||||
|
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||||
|
decision: Exclude<DetailDecision, 'pending'>,
|
||||||
|
scope: DetailDecisionScope,
|
||||||
|
): string {
|
||||||
|
const action = decision === 'safe' ? 'pass' : 'fail'
|
||||||
|
const scopeLabel = scope === 'global' ? 'Global' : 'Local'
|
||||||
|
|
||||||
|
if (scope === 'global' && !canUpdateGlobalDetail(detail)) {
|
||||||
|
return 'Global verdict unavailable for generated trace keys'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDetailActionSelected(detail, decision, scope)) {
|
||||||
|
return `Unset ${scopeLabel.toLowerCase()} ${action}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${scopeLabel} ${action}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLocalDetailAction(
|
||||||
|
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||||
|
decision: Exclude<DetailDecision, 'pending'>,
|
||||||
|
) {
|
||||||
|
return updateDetailStatus(detail.id, getToggledDetailVerdict(detail, decision, 'local'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGlobalDetailAction(
|
||||||
|
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||||
|
decision: Exclude<DetailDecision, 'pending'>,
|
||||||
|
) {
|
||||||
|
return updateGlobalDetailStatus(detail, getToggledDetailVerdict(detail, decision, 'global'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUpdateGlobalDetail(detail: Labrinth.TechReview.Internal.ReportIssueDetail): boolean {
|
||||||
|
return detail.key.length > 0 && !detail.key.startsWith('<no-key-')
|
||||||
|
}
|
||||||
|
|
||||||
function getFileHighestSeverity(
|
function getFileHighestSeverity(
|
||||||
file: FlattenedFileReport,
|
file: FlattenedFileReport,
|
||||||
): Labrinth.TechReview.Internal.DelphiSeverity {
|
): Labrinth.TechReview.Internal.DelphiSeverity {
|
||||||
@@ -495,6 +609,18 @@ const remainingUnmarkedCount = computed(() => {
|
|||||||
return getFileDetailCount(selectedFile.value) - getFileMarkedCount(selectedFile.value)
|
return getFileDetailCount(selectedFile.value) - getFileMarkedCount(selectedFile.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function getJarFlags(jarGroup: JarGroup): ClassGroup['flags'] {
|
||||||
|
return jarGroup.classes.flatMap((classItem) => classItem.flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJarMarkedCount(jarGroup: JarGroup): number {
|
||||||
|
return getMarkedFlagsCount(getJarFlags(jarGroup))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJarRemainingUnmarkedCount(jarGroup: JarGroup): number {
|
||||||
|
return getJarFlags(jarGroup).length - getJarMarkedCount(jarGroup)
|
||||||
|
}
|
||||||
|
|
||||||
const isBatchUpdating = ref(false)
|
const isBatchUpdating = ref(false)
|
||||||
|
|
||||||
async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
|
async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
|
||||||
@@ -518,7 +644,7 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
|
|||||||
try {
|
try {
|
||||||
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
|
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
|
||||||
|
|
||||||
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict))
|
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict), 'local')
|
||||||
|
|
||||||
addNotification({
|
addNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
@@ -548,7 +674,54 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe') {
|
async function batchMarkRemainingInJar(jarGroup: JarGroup, verdict: 'safe' | 'unsafe') {
|
||||||
|
if (isBatchUpdating.value) return
|
||||||
|
|
||||||
|
const detailIds = getJarFlags(jarGroup)
|
||||||
|
.filter((flag) => getDetailDecision(flag.detail.id, flag.detail.status) === 'pending')
|
||||||
|
.map((flag) => flag.detail.id)
|
||||||
|
|
||||||
|
if (detailIds.length === 0) return
|
||||||
|
|
||||||
|
isBatchUpdating.value = true
|
||||||
|
try {
|
||||||
|
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
|
||||||
|
|
||||||
|
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict), 'local')
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: `Marked ${detailIds.length} traces as ${verdict}`,
|
||||||
|
text: `All remaining traces in this JAR have been marked as ${
|
||||||
|
verdict === 'safe' ? 'false positives' : 'malicious'
|
||||||
|
}.`,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (selectedFile.value) {
|
||||||
|
const markedCount = getFileMarkedCount(selectedFile.value)
|
||||||
|
const totalCount = getFileDetailCount(selectedFile.value)
|
||||||
|
if (markedCount === totalCount) {
|
||||||
|
backToFileList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('refetch')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to batch update JAR traces:', error)
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Batch update failed',
|
||||||
|
text: 'An error occurred while updating JAR traces.',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isBatchUpdating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateDetailStatus(
|
||||||
|
detailId: string,
|
||||||
|
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||||
|
) {
|
||||||
let priorDecision: 'safe' | 'malware' | 'pending' = 'pending'
|
let priorDecision: 'safe' | 'malware' | 'pending' = 'pending'
|
||||||
outer: for (const report of props.item.reports) {
|
outer: for (const report of props.item.reports) {
|
||||||
for (const issue of report.issues) {
|
for (const issue of report.issues) {
|
||||||
@@ -568,10 +741,11 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
|||||||
const { otherMatchedCount } = applyDecisionToRelatedDetails(
|
const { otherMatchedCount } = applyDecisionToRelatedDetails(
|
||||||
[detailId],
|
[detailId],
|
||||||
verdictToDecision(verdict),
|
verdictToDecision(verdict),
|
||||||
|
'local',
|
||||||
)
|
)
|
||||||
|
|
||||||
// Only collapse if the prior state was 'pending' (new decision, not updating existing)
|
// Only collapse if the prior state was 'pending' (new decision, not updating existing)
|
||||||
if (priorDecision === 'pending') {
|
if (verdict !== 'pending' && priorDecision === 'pending') {
|
||||||
for (const classGroup of groupedByClass.value) {
|
for (const classGroup of groupedByClass.value) {
|
||||||
const hasThisDetail = classGroup.flags.some((f) => f.detail.id === detailId)
|
const hasThisDetail = classGroup.flags.some((f) => f.detail.id === detailId)
|
||||||
if (hasThisDetail && getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
|
if (hasThisDetail && getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
|
||||||
@@ -582,7 +756,7 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Jump back to Files tab when all flags in the current file are marked
|
// Jump back to Files tab when all flags in the current file are marked
|
||||||
if (selectedFile.value) {
|
if (verdict !== 'pending' && selectedFile.value) {
|
||||||
const markedCount = getFileMarkedCount(selectedFile.value)
|
const markedCount = getFileMarkedCount(selectedFile.value)
|
||||||
const totalCount = getFileDetailCount(selectedFile.value)
|
const totalCount = getFileDetailCount(selectedFile.value)
|
||||||
if (markedCount === totalCount) {
|
if (markedCount === totalCount) {
|
||||||
@@ -595,7 +769,13 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
|||||||
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked)`
|
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked)`
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
if (verdict === 'safe') {
|
if (verdict === 'pending') {
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Local trace verdict unset',
|
||||||
|
text: `The project-local verdict has been removed.${otherText}`,
|
||||||
|
})
|
||||||
|
} else if (verdict === 'safe') {
|
||||||
addNotification({
|
addNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: 'Issue marked as pass',
|
title: 'Issue marked as pass',
|
||||||
@@ -622,6 +802,82 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateGlobalDetailStatus(
|
||||||
|
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||||
|
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||||
|
) {
|
||||||
|
if (!canUpdateGlobalDetail(detail)) {
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Global update unavailable',
|
||||||
|
text: 'Generated trace keys cannot be marked globally.',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatingGlobalDetailKeys.add(detail.key)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateGlobalIssueDetail(detail.key, verdict)
|
||||||
|
|
||||||
|
const { otherMatchedCount } = applyDecisionToRelatedDetails(
|
||||||
|
[detail.id],
|
||||||
|
verdictToDecision(verdict),
|
||||||
|
'global',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (verdict !== 'pending') {
|
||||||
|
for (const classGroup of groupedByClass.value) {
|
||||||
|
if (getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
|
||||||
|
expandedClasses.delete(classGroup.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verdict !== 'pending' && selectedFile.value) {
|
||||||
|
const markedCount = getFileMarkedCount(selectedFile.value)
|
||||||
|
const totalCount = getFileDetailCount(selectedFile.value)
|
||||||
|
if (markedCount === totalCount) {
|
||||||
|
backToFileList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherText =
|
||||||
|
otherMatchedCount > 0
|
||||||
|
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked in this project)`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
if (verdict === 'pending') {
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Global trace verdict unset',
|
||||||
|
text: `The global verdict for this trace key has been removed.${otherText}`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title:
|
||||||
|
verdict === 'safe' ? 'Trace globally marked as pass' : 'Trace globally marked as fail',
|
||||||
|
text:
|
||||||
|
verdict === 'safe'
|
||||||
|
? `This trace key has been marked as a global false positive.${otherText}`
|
||||||
|
: `This trace key has been globally flagged as malicious.${otherText}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('refetch')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update global detail status:', error)
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Failed to update global trace',
|
||||||
|
text: 'An error occurred while updating the global trace status.',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
updatingGlobalDetailKeys.delete(detail.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const expandedClasses = reactive<Set<string>>(new Set())
|
const expandedClasses = reactive<Set<string>>(new Set())
|
||||||
const autoExpandedFileIds = reactive<Set<string>>(new Set())
|
const autoExpandedFileIds = reactive<Set<string>>(new Set())
|
||||||
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
|
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
|
||||||
@@ -1352,26 +1608,49 @@ function copyId() {
|
|||||||
v-if="jarGroup.segments.length > 0"
|
v-if="jarGroup.segments.length > 0"
|
||||||
class="border-b border-solid border-surface-1 px-4 py-3"
|
class="border-b border-solid border-surface-1 px-4 py-3"
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-center gap-1">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<template
|
<div class="flex flex-wrap items-center gap-1">
|
||||||
v-for="(segment, index) in jarGroup.segments"
|
<template
|
||||||
:key="`${jarGroup.key}-${index}`"
|
v-for="(segment, index) in jarGroup.segments"
|
||||||
>
|
:key="`${jarGroup.key}-${index}`"
|
||||||
<span
|
|
||||||
class="font-mono text-sm"
|
|
||||||
:class="
|
|
||||||
index === jarGroup.segments.length - 1
|
|
||||||
? 'font-semibold text-contrast'
|
|
||||||
: 'text-secondary'
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
{{ segment }}
|
<span
|
||||||
</span>
|
class="font-mono text-sm"
|
||||||
<ChevronRightIcon
|
:class="
|
||||||
v-if="index < jarGroup.segments.length - 1"
|
index === jarGroup.segments.length - 1
|
||||||
class="size-4 text-secondary"
|
? 'font-semibold text-contrast'
|
||||||
/>
|
: 'text-secondary'
|
||||||
</template>
|
"
|
||||||
|
>
|
||||||
|
{{ segment }}
|
||||||
|
</span>
|
||||||
|
<ChevronRightIcon
|
||||||
|
v-if="index < jarGroup.segments.length - 1"
|
||||||
|
class="size-4 text-secondary"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="getJarRemainingUnmarkedCount(jarGroup) > 0" class="flex gap-2">
|
||||||
|
<ButtonStyled color="brand" size="small">
|
||||||
|
<button
|
||||||
|
:disabled="isBatchUpdating"
|
||||||
|
@click="batchMarkRemainingInJar(jarGroup, 'safe')"
|
||||||
|
>
|
||||||
|
<CheckCircleIcon class="size-4" />
|
||||||
|
Remaining safe ({{ getJarRemainingUnmarkedCount(jarGroup) }})
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled color="red" size="small">
|
||||||
|
<button
|
||||||
|
:disabled="isBatchUpdating"
|
||||||
|
@click="batchMarkRemainingInJar(jarGroup, 'unsafe')"
|
||||||
|
>
|
||||||
|
<TriangleAlertIcon class="size-4" />
|
||||||
|
Remaining malware ({{ getJarRemainingUnmarkedCount(jarGroup) }})
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1470,38 +1749,94 @@ function copyId() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex w-40 items-center justify-center gap-2">
|
<div class="detail-verdict-action-groups">
|
||||||
<ButtonStyled
|
<div
|
||||||
color="brand"
|
class="detail-verdict-buttons"
|
||||||
:type="
|
role="group"
|
||||||
getDetailDecision(flag.detail.id, flag.detail.status) === 'safe'
|
aria-label="Trace verdict actions"
|
||||||
? undefined
|
|
||||||
: 'outlined'
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
:disabled="updatingDetails.has(flag.detail.id)"
|
v-tooltip="getDetailActionTooltip(flag.detail, 'safe', 'global')"
|
||||||
@click="updateDetailStatus(flag.detail.id, 'safe')"
|
class="detail-verdict-button detail-verdict-button--safe"
|
||||||
|
:class="{
|
||||||
|
'detail-verdict-button--selected': isDetailActionSelected(
|
||||||
|
flag.detail,
|
||||||
|
'safe',
|
||||||
|
'global',
|
||||||
|
),
|
||||||
|
}"
|
||||||
|
aria-label="Global pass"
|
||||||
|
:disabled="
|
||||||
|
!canUpdateGlobalDetail(flag.detail) ||
|
||||||
|
updatingGlobalDetailKeys.has(flag.detail.key) ||
|
||||||
|
updatingDetails.has(flag.detail.id)
|
||||||
|
"
|
||||||
|
@click="updateGlobalDetailAction(flag.detail, 'safe')"
|
||||||
>
|
>
|
||||||
Pass
|
<CheckCheckIcon aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
|
||||||
|
|
||||||
<ButtonStyled
|
|
||||||
color="red"
|
|
||||||
:type="
|
|
||||||
getDetailDecision(flag.detail.id, flag.detail.status) === 'malware'
|
|
||||||
? undefined
|
|
||||||
: 'outlined'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
:disabled="updatingDetails.has(flag.detail.id)"
|
v-tooltip="getDetailActionTooltip(flag.detail, 'safe', 'local')"
|
||||||
@click="updateDetailStatus(flag.detail.id, 'unsafe')"
|
class="detail-verdict-button detail-verdict-button--safe"
|
||||||
|
:class="{
|
||||||
|
'detail-verdict-button--selected': isDetailActionSelected(
|
||||||
|
flag.detail,
|
||||||
|
'safe',
|
||||||
|
'local',
|
||||||
|
),
|
||||||
|
}"
|
||||||
|
aria-label="Local pass"
|
||||||
|
:disabled="
|
||||||
|
updatingDetails.has(flag.detail.id) ||
|
||||||
|
updatingGlobalDetailKeys.has(flag.detail.key)
|
||||||
|
"
|
||||||
|
@click="updateLocalDetailAction(flag.detail, 'safe')"
|
||||||
>
|
>
|
||||||
Fail
|
<CheckIcon aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
|
||||||
|
<button
|
||||||
|
v-tooltip="getDetailActionTooltip(flag.detail, 'malware', 'local')"
|
||||||
|
class="detail-verdict-button detail-verdict-button--unsafe"
|
||||||
|
:class="{
|
||||||
|
'detail-verdict-button--selected': isDetailActionSelected(
|
||||||
|
flag.detail,
|
||||||
|
'malware',
|
||||||
|
'local',
|
||||||
|
),
|
||||||
|
}"
|
||||||
|
aria-label="Local fail"
|
||||||
|
:disabled="
|
||||||
|
updatingDetails.has(flag.detail.id) ||
|
||||||
|
updatingGlobalDetailKeys.has(flag.detail.key)
|
||||||
|
"
|
||||||
|
@click="updateLocalDetailAction(flag.detail, 'malware')"
|
||||||
|
>
|
||||||
|
<BanIcon aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-tooltip="getDetailActionTooltip(flag.detail, 'malware', 'global')"
|
||||||
|
class="detail-verdict-button detail-verdict-button--unsafe"
|
||||||
|
:class="{
|
||||||
|
'detail-verdict-button--selected': isDetailActionSelected(
|
||||||
|
flag.detail,
|
||||||
|
'malware',
|
||||||
|
'global',
|
||||||
|
),
|
||||||
|
}"
|
||||||
|
aria-label="Global fail"
|
||||||
|
:disabled="
|
||||||
|
!canUpdateGlobalDetail(flag.detail) ||
|
||||||
|
updatingGlobalDetailKeys.has(flag.detail.key) ||
|
||||||
|
updatingDetails.has(flag.detail.id)
|
||||||
|
"
|
||||||
|
@click="updateGlobalDetailAction(flag.detail, 'malware')"
|
||||||
|
>
|
||||||
|
<ShieldAlertIcon aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -1609,4 +1944,90 @@ pre {
|
|||||||
.fade-leave-to {
|
.fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-verdict-action-groups {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-inline-end: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-verdict-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--surface-5);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-verdict-button {
|
||||||
|
display: flex;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-left: 1px solid var(--surface-5);
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s ease-in-out,
|
||||||
|
filter 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-verdict-button:first-child {
|
||||||
|
border-left: 0;
|
||||||
|
border-start-start-radius: calc(var(--radius-md) - 1px);
|
||||||
|
border-end-start-radius: calc(var(--radius-md) - 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-verdict-button:last-child {
|
||||||
|
border-start-end-radius: calc(var(--radius-md) - 1px);
|
||||||
|
border-end-end-radius: calc(var(--radius-md) - 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-verdict-button:hover,
|
||||||
|
.detail-verdict-button:focus-visible {
|
||||||
|
background: var(--surface-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-verdict-button--selected {
|
||||||
|
background: var(--color-green-bg);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-verdict-button--selected:hover,
|
||||||
|
.detail-verdict-button--selected:focus-visible {
|
||||||
|
background: var(--color-green-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-verdict-button:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 0 0 2px var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-verdict-button--selected:focus-visible {
|
||||||
|
box-shadow: inset 0 0 0 2px var(--color-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-verdict-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-verdict-button svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-verdict-button--safe {
|
||||||
|
color: var(--color-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-verdict-button--unsafe {
|
||||||
|
color: var(--color-red);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -54,15 +54,21 @@
|
|||||||
class="message__icon backed-svg circle moderation-color"
|
class="message__icon backed-svg circle moderation-color"
|
||||||
:class="{
|
:class="{
|
||||||
raised: raised,
|
raised: raised,
|
||||||
'system-message-icon': ['tech_review_entered', 'tech_review_exit_file_deleted'].includes(
|
'system-message-icon': [
|
||||||
message.body.type,
|
'tech_review_entered',
|
||||||
),
|
'tech_review_exited',
|
||||||
|
'tech_review_exit_file_deleted',
|
||||||
|
].includes(message.body.type),
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<ScaleIcon />
|
<ScaleIcon />
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
v-if="!['tech_review_entered', 'tech_review_exit_file_deleted'].includes(message.body.type)"
|
v-if="
|
||||||
|
!['tech_review_entered', 'tech_review_exited', 'tech_review_exit_file_deleted'].includes(
|
||||||
|
message.body.type,
|
||||||
|
)
|
||||||
|
"
|
||||||
class="message__author moderation-color"
|
class="message__author moderation-color"
|
||||||
>
|
>
|
||||||
Moderator
|
Moderator
|
||||||
@@ -100,6 +106,9 @@
|
|||||||
<span v-else-if="message.body.type === 'tech_review_entered'">
|
<span v-else-if="message.body.type === 'tech_review_entered'">
|
||||||
The project has entered the technical review queue.
|
The project has entered the technical review queue.
|
||||||
</span>
|
</span>
|
||||||
|
<span v-else-if="message.body.type === 'tech_review_exited'">
|
||||||
|
The project has left the technical review queue as all pending traces have been resolved.
|
||||||
|
</span>
|
||||||
<span v-else-if="message.body.type === 'tech_review_exit_file_deleted'">
|
<span v-else-if="message.body.type === 'tech_review_exit_file_deleted'">
|
||||||
The project has left the technical review queue as all files pending review were deleted by
|
The project has left the technical review queue as all files pending review were deleted by
|
||||||
the user.
|
the user.
|
||||||
@@ -214,9 +223,12 @@ const timeSincePosted = ref(formatRelativeTime(props.message.created))
|
|||||||
const isPrivateMessage = computed(() => {
|
const isPrivateMessage = computed(() => {
|
||||||
return (
|
return (
|
||||||
props.message.body.private ||
|
props.message.body.private ||
|
||||||
['tech_review', 'tech_review_entered', 'tech_review_exit_file_deleted'].includes(
|
[
|
||||||
props.message.body.type,
|
'tech_review',
|
||||||
)
|
'tech_review_entered',
|
||||||
|
'tech_review_exited',
|
||||||
|
'tech_review_exit_file_deleted',
|
||||||
|
].includes(props.message.body.type)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3362,6 +3362,9 @@
|
|||||||
"project.download.no-game-versions-found": {
|
"project.download.no-game-versions-found": {
|
||||||
"message": "No game versions found"
|
"message": "No game versions found"
|
||||||
},
|
},
|
||||||
|
"project.download.no-versions-available": {
|
||||||
|
"message": "No versions available for {gameVersion} and {platform}."
|
||||||
|
},
|
||||||
"project.download.platform-unsupported-tooltip": {
|
"project.download.platform-unsupported-tooltip": {
|
||||||
"message": "{title} does not support {platform} for {gameVersion}"
|
"message": "{title} does not support {platform} for {gameVersion}"
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -33,7 +33,15 @@
|
|||||||
{{ pageStart }}-{{ pageEnd }} of {{ trace.local_trace_count }} local traces
|
{{ pageStart }}-{{ pageEnd }} of {{ trace.local_trace_count }} local traces
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge :type="trace.verdict" />
|
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
<Badge :type="trace.verdict" />
|
||||||
|
<ButtonStyled color="red">
|
||||||
|
<button :disabled="isRemoving" @click="removeGlobalTrace">
|
||||||
|
<TrashIcon aria-hidden="true" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -63,13 +71,22 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Labrinth } from '@modrinth/api-client'
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
import { ArrowLeftIcon, HashIcon } from '@modrinth/assets'
|
import { ArrowLeftIcon, HashIcon, TrashIcon } from '@modrinth/assets'
|
||||||
import { Badge, ButtonStyled, EmptyState, injectModrinthClient, Pagination } from '@modrinth/ui'
|
import {
|
||||||
|
Badge,
|
||||||
|
ButtonStyled,
|
||||||
|
EmptyState,
|
||||||
|
injectModrinthClient,
|
||||||
|
injectNotificationManager,
|
||||||
|
Pagination,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
|
||||||
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
|
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
|
||||||
|
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
|
const { addNotification } = injectNotificationManager()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const detailKey = computed(() => {
|
const detailKey = computed(() => {
|
||||||
const key = route.params.key
|
const key = route.params.key
|
||||||
@@ -80,6 +97,7 @@ useHead({ title: () => `Global trace - ${detailKey.value} - Modrinth` })
|
|||||||
|
|
||||||
const localTracePageSize = 20
|
const localTracePageSize = 20
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
const isRemoving = ref(false)
|
||||||
const loadError = ref(false)
|
const loadError = ref(false)
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageStartCursors = ref<(string | null)[]>([null])
|
const pageStartCursors = ref<(string | null)[]>([null])
|
||||||
@@ -140,6 +158,34 @@ async function switchPage(page: number) {
|
|||||||
await loadPage(page)
|
await loadPage(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeGlobalTrace() {
|
||||||
|
if (isRemoving.value) return
|
||||||
|
|
||||||
|
isRemoving.value = true
|
||||||
|
try {
|
||||||
|
await client.labrinth.tech_review_internal.updateGlobalIssueDetails([
|
||||||
|
{ detail_key: detailKey.value, verdict: 'pending' },
|
||||||
|
])
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Global trace removed',
|
||||||
|
text: 'The global verdict for this trace key has been removed.',
|
||||||
|
})
|
||||||
|
|
||||||
|
await router.push('/moderation/global-traces')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove global detail trace', error)
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Failed to remove global trace',
|
||||||
|
text: 'An error occurred while removing the global trace verdict.',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isRemoving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
detailKey,
|
detailKey,
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -534,3 +534,7 @@ Xandr.com, 2398, DIRECT
|
|||||||
#Rubicon
|
#Rubicon
|
||||||
rubiconproject.com, 24584, DIRECT, 0bfd66d529a55807
|
rubiconproject.com, 24584, DIRECT, 0bfd66d529a55807
|
||||||
rubiconproject.com, 24586, DIRECT, 0bfd66d529a55807
|
rubiconproject.com, 24586, DIRECT, 0bfd66d529a55807
|
||||||
|
|
||||||
|
copper6.com, 916020, Reseller
|
||||||
|
openx.com, 563905670, RESELLER, 6a698e2ec38604c6
|
||||||
|
xandr.com, 16546, RESELLER
|
||||||
|
|||||||
Generated
+29
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT\n (\n SELECT t.id\n FROM threads t\n WHERE t.mod_id = $1\n ORDER BY t.id\n LIMIT 1\n ) AS \"thread_id: DBThreadId\",\n (\n SELECT tm.body->>'type'\n FROM threads t\n INNER JOIN threads_messages tm ON tm.thread_id = t.id\n WHERE\n t.mod_id = $1\n AND tm.body->>'type' = ANY($2::text[])\n ORDER BY tm.created DESC, tm.id DESC\n LIMIT 1\n ) AS \"last_tech_review_message_type\"\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "thread_id: DBThreadId",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "last_tech_review_message_type",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8",
|
||||||
|
"TextArray"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "080e2f0068c0c1c248dcfacf39798aefed70d9a61610fc7f4f1c8f291706fa4a"
|
||||||
|
}
|
||||||
Generated
-28
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\n SELECT\n EXISTS(\n SELECT 1 FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = $1 AND didws.status = 'pending'\n ) AS \"pending_issue_details_exist!\",\n t.id AS \"thread_id: DBThreadId\"\n FROM mods m\n INNER JOIN threads t ON t.mod_id = $1\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "pending_issue_details_exist!",
|
|
||||||
"type_info": "Bool"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "thread_id: DBThreadId",
|
|
||||||
"type_info": "Int8"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Int8"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
null,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "2d9e36c76a1e214c53d9dc2aa3debe1d03998be169a306b63a0ca1beaa07397f"
|
|
||||||
}
|
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n SELECT DISTINCT ON (dr.id)\n to_jsonb(dr)\n || jsonb_build_object(\n 'report_id', dr.id,\n 'file_id', to_base62(f.id),\n 'version_id', to_base62(v.id),\n 'project_id', to_base62(v.mod_id),\n 'file_name', f.filename,\n 'file_size', f.size,\n 'flag_reason', 'delphi',\n 'download_url', f.url,\n -- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t'issues', (\n\t\t\t\t\tSELECT coalesce(json_agg(\n\t\t\t\t\t\tto_jsonb(dri)\n\t\t\t\t\t\t|| jsonb_build_object(\n\t\t\t\t\t\t\t-- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t\t\t\t'details', (\n\t\t\t\t\t\t\t\tSELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n\t\t\t\t\t\t)\n\t\t\t\t\t), '[]'::json)\n\t\t\t\t\tFROM delphi_report_issues dri\n\t\t\t\t\tWHERE\n\t\t\t\t\t\tdri.report_id = dr.id\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n )\n ) AS \"data!: sqlx::types::Json<FileReport>\"\n FROM delphi_reports dr\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n WHERE dr.id = $1\n ",
|
"query": "\n SELECT DISTINCT ON (dr.id)\n to_jsonb(dr)\n || jsonb_build_object(\n 'report_id', dr.id,\n 'file_id', to_base62(f.id),\n 'version_id', to_base62(v.id),\n 'project_id', to_base62(v.mod_id),\n 'file_name', f.filename,\n 'file_size', f.size,\n 'flag_reason', 'delphi',\n 'download_url', f.url,\n -- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t'issues', (\n\t\t\t\t\tSELECT coalesce(json_agg(\n\t\t\t\t\t\tto_jsonb(dri)\n\t\t\t\t\t\t|| jsonb_build_object(\n\t\t\t\t\t\t\t-- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t\t\t\t'details', (\n\t\t\t\t\t\t\t\tSELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'local_status', didws.local_status,\n 'global_status', didws.global_status,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n\t\t\t\t\t\t)\n\t\t\t\t\t), '[]'::json)\n\t\t\t\t\tFROM delphi_report_issues dri\n\t\t\t\t\tWHERE\n\t\t\t\t\t\tdri.report_id = dr.id\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n )\n ) AS \"data!: sqlx::types::Json<FileReport>\"\n FROM delphi_reports dr\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n WHERE dr.id = $1\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -18,5 +18,5 @@
|
|||||||
null
|
null
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "fe4ff6ab40fe3dc3d474c0c23c9dba514c66ac30e573fc5f4e44ed7ff360d3d6"
|
"hash": "48dfc2f2bcf8917f110b7b2b142167f1525cdea2ace9e6165e5c5f596cbd9fb3"
|
||||||
}
|
}
|
||||||
Generated
-22
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\n SELECT id AS \"thread_id: DBThreadId\"\n FROM threads\n WHERE mod_id = $1\n LIMIT 1\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "thread_id: DBThreadId",
|
|
||||||
"type_info": "Int8"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Int8"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "555342b0ec9fb808f05a18aaeaf06fb61e968fb3379c9d0c7ad82c8747bd4256"
|
|
||||||
}
|
|
||||||
Generated
-15
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\n WITH incoming AS (\n SELECT *\n FROM unnest($1::text[], $2::text[]) WITH ORDINALITY\n AS u(detail_key, verdict, ord)\n )\n INSERT INTO delphi_global_detail_verdicts (\n detail_key,\n verdict\n )\n SELECT DISTINCT ON (detail_key)\n detail_key,\n verdict::delphi_report_issue_status\n FROM incoming\n ORDER BY detail_key, ord DESC\n ON CONFLICT (detail_key)\n DO UPDATE SET verdict = EXCLUDED.verdict\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"TextArray",
|
|
||||||
"TextArray"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "6402f359bbf733a9a2173f23c2ea222611fde49f692406123145dc6acd3dcd6a"
|
|
||||||
}
|
|
||||||
Generated
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n WITH incoming AS (\n SELECT *\n FROM unnest($1::text[], $2::text[]) WITH ORDINALITY\n AS u(detail_key, verdict, ord)\n ),\n latest AS (\n SELECT DISTINCT ON (detail_key)\n detail_key,\n verdict\n FROM incoming\n ORDER BY detail_key, ord DESC\n ),\n deleted AS (\n DELETE FROM delphi_global_detail_verdicts dgdv\n USING latest\n WHERE\n dgdv.detail_key = latest.detail_key\n AND latest.verdict = 'pending'\n RETURNING 1\n )\n INSERT INTO delphi_global_detail_verdicts (\n detail_key,\n verdict\n )\n SELECT\n detail_key,\n verdict::delphi_report_issue_status\n FROM latest\n WHERE verdict != 'pending'\n ON CONFLICT (detail_key)\n DO UPDATE SET verdict = EXCLUDED.verdict\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"TextArray",
|
||||||
|
"TextArray"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "7ca85bcc45e7d53106ea75b606ce2219bd37be7bb33588179cca6f1a26e5bb23"
|
||||||
|
}
|
||||||
Generated
-22
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\n SELECT EXISTS(\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n didws.project_id = $1\n AND didws.status = 'pending'\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n ) AS \"is_in_tech_review!\"\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "is_in_tech_review!",
|
|
||||||
"type_info": "Bool"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Int8"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
null
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "8c80f3158fb5772adc8542cdf5419437bb8cd65723a32e587022d0c8decba68d"
|
|
||||||
}
|
|
||||||
+36
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n SELECT\n didws.id AS \"id!: DelphiReportIssueDetailsId\",\n didws.issue_id AS \"issue_id!: DelphiReportIssueId\",\n didws.key AS \"key!: String\",\n didws.jar AS \"jar?: String\",\n didws.file_path AS \"file_path!: String\",\n didws.data AS \"data!: sqlx::types::Json<HashMap<String, serde_json::Value>>\",\n didws.severity AS \"severity!: DelphiSeverity\",\n didws.status AS \"status!: DelphiStatus\"\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = ANY($1::bigint[])\n ",
|
"query": "\n SELECT\n didws.id AS \"id!: DelphiReportIssueDetailsId\",\n didws.issue_id AS \"issue_id!: DelphiReportIssueId\",\n didws.key AS \"key!: String\",\n didws.jar AS \"jar?: String\",\n didws.file_path AS \"file_path!: String\",\n didws.data AS \"data!: sqlx::types::Json<HashMap<String, serde_json::Value>>\",\n didws.severity AS \"severity!: DelphiSeverity\",\n didws.local_status AS \"local_status?: DelphiStatus\",\n didws.global_status AS \"global_status?: DelphiStatus\",\n didws.status AS \"status!: DelphiStatus\"\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = ANY($1::bigint[])\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -52,6 +52,38 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
|
"name": "local_status?: DelphiStatus",
|
||||||
|
"type_info": {
|
||||||
|
"Custom": {
|
||||||
|
"name": "delphi_report_issue_status",
|
||||||
|
"kind": {
|
||||||
|
"Enum": [
|
||||||
|
"pending",
|
||||||
|
"safe",
|
||||||
|
"unsafe"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "global_status?: DelphiStatus",
|
||||||
|
"type_info": {
|
||||||
|
"Custom": {
|
||||||
|
"name": "delphi_report_issue_status",
|
||||||
|
"kind": {
|
||||||
|
"Enum": [
|
||||||
|
"pending",
|
||||||
|
"safe",
|
||||||
|
"unsafe"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
"name": "status!: DelphiStatus",
|
"name": "status!: DelphiStatus",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"Custom": {
|
"Custom": {
|
||||||
@@ -80,8 +112,10 @@
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "951700c659d040068dae8e2f91b82b6a3af676439b47ff76d72000ddeca0e334"
|
"hash": "9070b1b6a5b1e93eb1fd1838c522fa6084ad6a5c86fafbfe8448ea7cc86f38ba"
|
||||||
}
|
}
|
||||||
Generated
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT DISTINCT didws.project_id AS \"project_id!: DBProjectId\"\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.key = ANY($1::text[])\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "project_id!: DBProjectId",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"TextArray"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "ba7585f55df2a4596caae265ac956eb23268f26aef664234b898b17d13c09f91"
|
||||||
|
}
|
||||||
Generated
-23
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\n SELECT EXISTS(\n SELECT 1\n FROM unnest($2::text[]) AS incoming(detail_key)\n LEFT JOIN delphi_global_detail_verdicts dgdv\n ON dgdv.detail_key = incoming.detail_key\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON didv.project_id = $1 AND didv.detail_key = incoming.detail_key\n WHERE dgdv.detail_key IS NULL AND didv.project_id IS NULL\n ) AS \"has_unflagged_issue_details!\"\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "has_unflagged_issue_details!",
|
|
||||||
"type_info": "Bool"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Int8",
|
|
||||||
"TextArray"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
null
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "bab0f71c84902bac7589a30923e95cbcd76d340c21ac6116d9bd27878786bd26"
|
|
||||||
}
|
|
||||||
Generated
+60
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n WITH project_ids AS (\n SELECT unnest($1::bigint[]) AS project_id\n )\n SELECT\n p.project_id AS \"project_id!: DBProjectId\",\n EXISTS(\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n didws.project_id = p.project_id\n AND didws.status = 'pending'\n AND dri.issue_type != $3\n ) AS \"has_pending_detail!\",\n EXISTS(\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n didws.project_id = p.project_id\n AND didws.status = 'unsafe'\n AND dri.issue_type != $3\n ) AS \"has_unsafe_detail!\",\n EXISTS(\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n didws.project_id = p.project_id\n AND didws.status = 'pending'\n AND dri.issue_type = $3\n ) AS \"has_dummy!\",\n (\n SELECT t.id\n FROM threads t\n WHERE t.mod_id = p.project_id\n ORDER BY t.id\n LIMIT 1\n ) AS \"thread_id: DBThreadId\",\n (\n SELECT tm.body->>'type'\n FROM threads t\n INNER JOIN threads_messages tm ON tm.thread_id = t.id\n WHERE\n t.mod_id = p.project_id\n AND tm.body->>'type' = ANY($2::text[])\n ORDER BY tm.created DESC, tm.id DESC\n LIMIT 1\n ) AS \"last_tech_review_message_type\",\n (\n SELECT dr.id\n FROM versions v\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n WHERE v.mod_id = p.project_id\n ORDER BY dr.created DESC, dr.id DESC\n LIMIT 1\n ) AS \"report_id: DelphiReportId\"\n FROM project_ids p\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "project_id!: DBProjectId",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "has_pending_detail!",
|
||||||
|
"type_info": "Bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "has_unsafe_detail!",
|
||||||
|
"type_info": "Bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "has_dummy!",
|
||||||
|
"type_info": "Bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "thread_id: DBThreadId",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "last_tech_review_message_type",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "report_id: DelphiReportId",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8Array",
|
||||||
|
"TextArray",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "bb94018c84f3809b9ad89f35ff08501addc8f203a0a152346aaebd32db52be30"
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n SELECT\n to_jsonb(dri)\n || jsonb_build_object(\n -- TODO: replace with `json_array` in Postgres 16\n 'details', (\n SELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n ) AS \"data!: sqlx::types::Json<FileIssue>\"\n FROM delphi_report_issues dri\n WHERE dri.id = $1\n ",
|
"query": "\n SELECT\n to_jsonb(dri)\n || jsonb_build_object(\n -- TODO: replace with `json_array` in Postgres 16\n 'details', (\n SELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'local_status', didws.local_status,\n 'global_status', didws.global_status,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n ) AS \"data!: sqlx::types::Json<FileIssue>\"\n FROM delphi_report_issues dri\n WHERE dri.id = $1\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -18,5 +18,5 @@
|
|||||||
null
|
null
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "112bc4904c32afc9ffba30528a9c0e7b7fe259c58411afa5ef86a7ae4bde2703"
|
"hash": "c3598ed9f64f7151b83d47a15fabfdcf015bda1b3c0a7f2358c7f284feb532c3"
|
||||||
}
|
}
|
||||||
Generated
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT DISTINCT didws.project_id AS \"project_id!: DBProjectId\"\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n didws.id = ANY($1::bigint[])\n AND dri.issue_type != '__dummy'\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "project_id!: DBProjectId",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8Array"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "e3c356bd41074ba4b3474dfab5cc0349636abea5b21faf5fc18d9f69fd21db1d"
|
||||||
|
}
|
||||||
Generated
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n WITH dummy_issue AS (\n INSERT INTO delphi_report_issues (report_id, issue_type)\n VALUES ($1, $2)\n ON CONFLICT (report_id, issue_type)\n DO UPDATE SET issue_type = EXCLUDED.issue_type\n RETURNING id\n )\n INSERT INTO delphi_report_issue_details (\n issue_id,\n key,\n jar,\n file_path,\n decompiled_source,\n data,\n severity\n )\n SELECT\n id,\n '',\n NULL,\n '',\n NULL,\n '{}'::jsonb,\n 'low'::delphi_severity\n FROM dummy_issue\n WHERE NOT EXISTS (\n SELECT 1\n FROM delphi_report_issue_details drid\n WHERE drid.issue_id = dummy_issue.id\n )\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "fc6c20348de487e4039ee7d53212ecd1856bde7aa4be6e366c98cef1321a7279"
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
DROP VIEW delphi_issue_details_with_statuses;
|
||||||
|
|
||||||
|
CREATE VIEW delphi_issue_details_with_statuses AS
|
||||||
|
SELECT
|
||||||
|
drid.*,
|
||||||
|
m.id AS project_id,
|
||||||
|
didv.verdict AS local_status,
|
||||||
|
dgdv.verdict AS global_status,
|
||||||
|
COALESCE(dgdv.verdict, didv.verdict, 'pending') AS status
|
||||||
|
FROM delphi_report_issue_details drid
|
||||||
|
INNER JOIN delphi_report_issues dri ON dri.id = drid.issue_id
|
||||||
|
INNER JOIN delphi_reports dr ON dr.id = dri.report_id
|
||||||
|
INNER JOIN files f ON f.id = dr.file_id
|
||||||
|
INNER JOIN versions v ON v.id = f.version_id
|
||||||
|
INNER JOIN mods m ON m.id = v.mod_id
|
||||||
|
LEFT JOIN delphi_global_detail_verdicts dgdv
|
||||||
|
ON drid.key = dgdv.detail_key
|
||||||
|
LEFT JOIN delphi_issue_detail_verdicts didv
|
||||||
|
ON m.id = didv.project_id
|
||||||
|
AND drid.key = didv.detail_key;
|
||||||
@@ -254,6 +254,10 @@ pub struct ReportIssueDetail {
|
|||||||
pub data: HashMap<String, serde_json::Value>,
|
pub data: HashMap<String, serde_json::Value>,
|
||||||
/// How important is this issue, as flagged by Delphi?
|
/// How important is this issue, as flagged by Delphi?
|
||||||
pub severity: DelphiSeverity,
|
pub severity: DelphiSeverity,
|
||||||
|
/// Project-local verdict for this detail, if one exists.
|
||||||
|
pub local_status: Option<DelphiStatus>,
|
||||||
|
/// Global verdict for this detail's key, if one exists.
|
||||||
|
pub global_status: Option<DelphiStatus>,
|
||||||
/// Has this issue detail been marked as safe or unsafe?
|
/// Has this issue detail been marked as safe or unsafe?
|
||||||
pub status: DelphiStatus,
|
pub status: DelphiStatus,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,14 @@ impl From<crate::models::v3::threads::MessageBody> for LegacyMessageBody {
|
|||||||
associated_images: Vec::new(),
|
associated_images: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
crate::models::v3::threads::MessageBody::TechReviewExited => {
|
||||||
|
LegacyMessageBody::Text {
|
||||||
|
body: "(legacy) Exited technical review".into(),
|
||||||
|
private: true,
|
||||||
|
replying_to: None,
|
||||||
|
associated_images: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
crate::models::v3::threads::MessageBody::TechReviewExitFileDeleted => {
|
crate::models::v3::threads::MessageBody::TechReviewExitFileDeleted => {
|
||||||
LegacyMessageBody::Text {
|
LegacyMessageBody::Text {
|
||||||
body: "(legacy) Exited technical review because file was deleted".into(),
|
body: "(legacy) Exited technical review because file was deleted".into(),
|
||||||
|
|||||||
@@ -28,8 +28,11 @@ pub struct ThreadMessage {
|
|||||||
pub hide_identity: bool,
|
pub hide_identity: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(
|
||||||
|
Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, strum::AsRefStr,
|
||||||
|
)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
#[strum(serialize_all = "snake_case")]
|
||||||
pub enum MessageBody {
|
pub enum MessageBody {
|
||||||
Text {
|
Text {
|
||||||
body: String,
|
body: String,
|
||||||
@@ -47,6 +50,7 @@ pub enum MessageBody {
|
|||||||
verdict: DelphiVerdict,
|
verdict: DelphiVerdict,
|
||||||
},
|
},
|
||||||
TechReviewEntered,
|
TechReviewEntered,
|
||||||
|
TechReviewExited,
|
||||||
TechReviewExitFileDeleted,
|
TechReviewExitFileDeleted,
|
||||||
ThreadClosure,
|
ThreadClosure,
|
||||||
ThreadReopen,
|
ThreadReopen,
|
||||||
@@ -62,6 +66,7 @@ impl MessageBody {
|
|||||||
Self::Text { private, .. } | Self::Deleted { private } => *private,
|
Self::Text { private, .. } | Self::Deleted { private } => *private,
|
||||||
Self::TechReview { .. }
|
Self::TechReview { .. }
|
||||||
| Self::TechReviewEntered
|
| Self::TechReviewEntered
|
||||||
|
| Self::TechReviewExited
|
||||||
| Self::TechReviewExitFileDeleted => true,
|
| Self::TechReviewExitFileDeleted => true,
|
||||||
Self::StatusChange { .. }
|
Self::StatusChange { .. }
|
||||||
| Self::ThreadClosure
|
| Self::ThreadClosure
|
||||||
|
|||||||
@@ -13,20 +13,18 @@ use crate::{
|
|||||||
auth::check_is_moderator_from_headers,
|
auth::check_is_moderator_from_headers,
|
||||||
database::{
|
database::{
|
||||||
models::{
|
models::{
|
||||||
DBFileId, DBProjectId, DBThreadId, DelphiReportId,
|
DBFileId, DBProjectId, DelphiReportId, DelphiReportIssueDetailsId,
|
||||||
DelphiReportIssueDetailsId, DelphiReportIssueId,
|
DelphiReportIssueId,
|
||||||
delphi_report_item::{
|
delphi_report_item::{
|
||||||
DBDelphiReport, DBDelphiReportIssue, DelphiSeverity,
|
DBDelphiReport, DBDelphiReportIssue, DelphiSeverity,
|
||||||
DelphiStatus, ReportIssueDetail,
|
DelphiStatus, ReportIssueDetail,
|
||||||
},
|
},
|
||||||
thread_item::ThreadMessageBuilder,
|
|
||||||
},
|
},
|
||||||
redis::RedisPool,
|
redis::RedisPool,
|
||||||
},
|
},
|
||||||
models::{
|
models::{
|
||||||
ids::{ProjectId, VersionId},
|
ids::{ProjectId, VersionId},
|
||||||
pats::Scopes,
|
pats::Scopes,
|
||||||
threads::MessageBody,
|
|
||||||
},
|
},
|
||||||
queue::session::AuthQueue,
|
queue::session::AuthQueue,
|
||||||
routes::ApiError,
|
routes::ApiError,
|
||||||
@@ -34,6 +32,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub mod rescan;
|
pub mod rescan;
|
||||||
|
pub mod tech_review_sync;
|
||||||
|
|
||||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||||
cfg.service(
|
cfg.service(
|
||||||
@@ -211,107 +210,6 @@ async fn ingest_report_deserialized(
|
|||||||
"Delphi found issues in file",
|
"Delphi found issues in file",
|
||||||
);
|
);
|
||||||
|
|
||||||
let record = sqlx::query!(
|
|
||||||
r#"
|
|
||||||
SELECT
|
|
||||||
EXISTS(
|
|
||||||
SELECT 1 FROM delphi_issue_details_with_statuses didws
|
|
||||||
WHERE didws.project_id = $1 AND didws.status = 'pending'
|
|
||||||
) AS "pending_issue_details_exist!",
|
|
||||||
t.id AS "thread_id: DBThreadId"
|
|
||||||
FROM mods m
|
|
||||||
INNER JOIN threads t ON t.mod_id = $1
|
|
||||||
"#,
|
|
||||||
DBProjectId::from(report.project_id) as _,
|
|
||||||
)
|
|
||||||
.fetch_one(&mut transaction)
|
|
||||||
.await
|
|
||||||
.wrap_internal_err("failed to check if pending issue details exist")?;
|
|
||||||
|
|
||||||
let issue_detail_keys = report
|
|
||||||
.issues
|
|
||||||
.values()
|
|
||||||
.flatten()
|
|
||||||
.map(|issue_detail| issue_detail.key.0.clone())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let has_unflagged_issue_details = sqlx::query!(
|
|
||||||
r#"
|
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1
|
|
||||||
FROM unnest($2::text[]) AS incoming(detail_key)
|
|
||||||
LEFT JOIN delphi_global_detail_verdicts dgdv
|
|
||||||
ON dgdv.detail_key = incoming.detail_key
|
|
||||||
LEFT JOIN delphi_issue_detail_verdicts didv
|
|
||||||
ON didv.project_id = $1 AND didv.detail_key = incoming.detail_key
|
|
||||||
WHERE dgdv.detail_key IS NULL AND didv.project_id IS NULL
|
|
||||||
) AS "has_unflagged_issue_details!"
|
|
||||||
"#,
|
|
||||||
DBProjectId::from(report.project_id) as _,
|
|
||||||
&issue_detail_keys
|
|
||||||
)
|
|
||||||
.fetch_one(&mut transaction)
|
|
||||||
.await
|
|
||||||
.wrap_internal_err("failed to check if report has unflagged issue details")?;
|
|
||||||
|
|
||||||
let should_enter_tech_review = !record.pending_issue_details_exist
|
|
||||||
&& has_unflagged_issue_details.has_unflagged_issue_details;
|
|
||||||
|
|
||||||
if should_enter_tech_review {
|
|
||||||
info!("File's project is entering tech review queue");
|
|
||||||
|
|
||||||
ThreadMessageBuilder {
|
|
||||||
author_id: None,
|
|
||||||
body: MessageBody::TechReviewEntered,
|
|
||||||
thread_id: record.thread_id,
|
|
||||||
hide_identity: false,
|
|
||||||
}
|
|
||||||
.insert(&mut transaction)
|
|
||||||
.await
|
|
||||||
.wrap_internal_err("failed to add entering tech review message")?;
|
|
||||||
} else {
|
|
||||||
info!(
|
|
||||||
"File's project is not entering tech review queue (already pending or no new unflagged issue details)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Currently, the way we determine if an issue is in tech review or not
|
|
||||||
// is if it has any issue details which are pending.
|
|
||||||
// If you mark all issue details are safe or not safe - even if you don't
|
|
||||||
// submit the final report - the project will be taken out of tech review
|
|
||||||
// queue, and into moderation queue.
|
|
||||||
//
|
|
||||||
// This is undesirable, but we can't rework the database schema to fix it
|
|
||||||
// right now. As a hack, we add a dummy report issue which blocks the
|
|
||||||
// project from exiting the tech review queue.
|
|
||||||
if should_enter_tech_review {
|
|
||||||
let dummy_issue_id = DBDelphiReportIssue {
|
|
||||||
id: DelphiReportIssueId(0), // This will be set by the database
|
|
||||||
report_id,
|
|
||||||
issue_type: "__dummy".into(),
|
|
||||||
}
|
|
||||||
.upsert(&mut transaction)
|
|
||||||
.await
|
|
||||||
.wrap_internal_err("failed to upsert dummy Delphi report issue")?;
|
|
||||||
|
|
||||||
ReportIssueDetail {
|
|
||||||
id: DelphiReportIssueDetailsId(0), // This will be set by the database
|
|
||||||
issue_id: dummy_issue_id,
|
|
||||||
key: "".into(),
|
|
||||||
jar: None,
|
|
||||||
file_path: "".into(),
|
|
||||||
decompiled_source: None,
|
|
||||||
data: HashMap::new(),
|
|
||||||
severity: DelphiSeverity::Low,
|
|
||||||
status: DelphiStatus::Pending,
|
|
||||||
}
|
|
||||||
.insert(&mut transaction)
|
|
||||||
.await
|
|
||||||
.wrap_internal_err(
|
|
||||||
"failed to insert dummy Delphi report issue detail",
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (issue_type, issue_details) in report.issues {
|
for (issue_type, issue_details) in report.issues {
|
||||||
let issue_id = DBDelphiReportIssue {
|
let issue_id = DBDelphiReportIssue {
|
||||||
id: DelphiReportIssueId(0), // This will be set by the database
|
id: DelphiReportIssueId(0), // This will be set by the database
|
||||||
@@ -340,6 +238,8 @@ async fn ingest_report_deserialized(
|
|||||||
decompiled_source: decompiled_source.cloned().flatten(),
|
decompiled_source: decompiled_source.cloned().flatten(),
|
||||||
data: issue_detail.data,
|
data: issue_detail.data,
|
||||||
severity: issue_detail.severity,
|
severity: issue_detail.severity,
|
||||||
|
local_status: None,
|
||||||
|
global_status: None,
|
||||||
status: DelphiStatus::Pending,
|
status: DelphiStatus::Pending,
|
||||||
}
|
}
|
||||||
.insert(&mut transaction)
|
.insert(&mut transaction)
|
||||||
@@ -348,6 +248,13 @@ async fn ingest_report_deserialized(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tech_review_sync::sync_project_tech_review_state(
|
||||||
|
&[DBProjectId::from(report.project_id)],
|
||||||
|
tech_review_sync::TechReviewExitReason::Resolved,
|
||||||
|
&mut transaction,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
transaction
|
transaction
|
||||||
.commit()
|
.commit()
|
||||||
.await
|
.await
|
||||||
@@ -397,83 +304,6 @@ pub async fn run(
|
|||||||
Ok(HttpResponse::NoContent().finish())
|
Ok(HttpResponse::NoContent().finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_project_in_tech_review(
|
|
||||||
project_id: DBProjectId,
|
|
||||||
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
|
|
||||||
) -> Result<bool, ApiError> {
|
|
||||||
let row = sqlx::query!(
|
|
||||||
r#"
|
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1
|
|
||||||
FROM delphi_issue_details_with_statuses didws
|
|
||||||
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
|
|
||||||
WHERE
|
|
||||||
didws.project_id = $1
|
|
||||||
AND didws.status = 'pending'
|
|
||||||
-- see delphi.rs todo comment
|
|
||||||
AND dri.issue_type != '__dummy'
|
|
||||||
) AS "is_in_tech_review!"
|
|
||||||
"#,
|
|
||||||
project_id as _,
|
|
||||||
)
|
|
||||||
.fetch_one(exec)
|
|
||||||
.await
|
|
||||||
.wrap_internal_err("failed to fetch project tech review state")?;
|
|
||||||
|
|
||||||
Ok(row.is_in_tech_review)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send_tech_review_exit_file_deleted_message(
|
|
||||||
project_id: DBProjectId,
|
|
||||||
txn: &mut crate::database::PgTransaction<'_>,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
let thread = sqlx::query!(
|
|
||||||
r#"
|
|
||||||
SELECT id AS "thread_id: DBThreadId"
|
|
||||||
FROM threads
|
|
||||||
WHERE mod_id = $1
|
|
||||||
LIMIT 1
|
|
||||||
"#,
|
|
||||||
project_id as _,
|
|
||||||
)
|
|
||||||
.fetch_optional(&mut *txn)
|
|
||||||
.await
|
|
||||||
.wrap_internal_err("failed to fetch thread for tech review exit message")?;
|
|
||||||
|
|
||||||
if let Some(thread) = thread {
|
|
||||||
ThreadMessageBuilder {
|
|
||||||
author_id: None,
|
|
||||||
body: MessageBody::TechReviewExitFileDeleted,
|
|
||||||
thread_id: thread.thread_id,
|
|
||||||
hide_identity: false,
|
|
||||||
}
|
|
||||||
.insert(txn)
|
|
||||||
.await
|
|
||||||
.wrap_internal_err("failed to add tech review exit message")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send_tech_review_exit_file_deleted_message_if_exited(
|
|
||||||
project_id: DBProjectId,
|
|
||||||
was_in_tech_review: bool,
|
|
||||||
txn: &mut crate::database::PgTransaction<'_>,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
if !was_in_tech_review {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let is_still_in_tech_review =
|
|
||||||
is_project_in_tech_review(project_id, &mut *txn).await?;
|
|
||||||
|
|
||||||
if !is_still_in_tech_review {
|
|
||||||
send_tech_review_exit_file_deleted_message(project_id, txn).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run Delphi.
|
/// Run Delphi.
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
context_path = "/delphi",
|
context_path = "/delphi",
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
//! Synchronizes moderation thread messages and dummy queue blockers with the
|
||||||
|
//! current computed tech review state of affected projects.
|
||||||
|
//!
|
||||||
|
//! When a project has a Delphi report submitted for it, or when a moderator
|
||||||
|
//! updates one of its issue details' rows (like flagging a detail as *globally*
|
||||||
|
//! safe or unsafe), we will need to recheck if the project still belongs in the
|
||||||
|
//! tech review queue or if it needs to exit now.
|
||||||
|
//!
|
||||||
|
//! Side-note: "entering the queue" or "exiting the queue" right now just means
|
||||||
|
//! adding a new message to the project's moderation thread which indicates if
|
||||||
|
//! it entered/exited. In the future this should be replaced with a more proper
|
||||||
|
//! audit log table, or "is project currently in tech review" table.
|
||||||
|
//!
|
||||||
|
//! A project is considered to need tech review when it has at least one
|
||||||
|
//! non-dummy issue detail whose effective status is pending or unsafe, or when
|
||||||
|
//! it already has a dummy pending detail blocking the final review submission.
|
||||||
|
//! Effective status is just the local detail's verdict (from
|
||||||
|
//! `delphi_issue_detail_verdicts`), or if it's null then the global verdict for
|
||||||
|
//! the same `drid.key` (from `delphi_global_detail_verdicts`).
|
||||||
|
//!
|
||||||
|
//! Some examples of how this behavior manifests: let's assume you have projects
|
||||||
|
//! _A_ and _B_ currently in tech review. They each have one (unresolved) issue
|
||||||
|
//! detail with key _K_.
|
||||||
|
//! - If you mark _K_ on _A_ as locally safe/unsafe, then _A_ is fully resolved,
|
||||||
|
//! but we still have the `__dummy` detail, which means it's still in the
|
||||||
|
//! queue until the moderator submits the actual report. _B_ is entirely
|
||||||
|
//! unaffected.
|
||||||
|
//! - If you mark _K_ on _A_ as globally safe, then _A_ and _B_ both get fully
|
||||||
|
//! resolved, but both still have the `__dummy` detail, so they also still
|
||||||
|
//! need the final report to be submitted by the moderator.
|
||||||
|
//!
|
||||||
|
//! In practice, this means that some projects may have e.g. "100/100 traces
|
||||||
|
//! are safe" reported, but they will just be waiting for final moderator
|
||||||
|
//! approval.
|
||||||
|
//!
|
||||||
|
//! The logic for checking whether a project is now in tech review or not, and
|
||||||
|
//! sending the appropriate message, is complex! That's why this module exists:
|
||||||
|
//! to act as a single chokepoint which (correctly) syncs all the state, instead
|
||||||
|
//! of having each mutation run its own ad-hoc update logic.
|
||||||
|
|
||||||
|
use itertools::Itertools;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
database::{
|
||||||
|
PgTransaction,
|
||||||
|
models::{
|
||||||
|
DBProjectId, DBThreadId, DelphiReportId,
|
||||||
|
delphi_report_item::DelphiVerdict,
|
||||||
|
thread_item::ThreadMessageBuilder,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
models::threads::MessageBody,
|
||||||
|
routes::ApiError,
|
||||||
|
util::error::Context,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DUMMY_ISSUE_TYPE: &str = "__dummy";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum TechReviewExitReason {
|
||||||
|
Resolved,
|
||||||
|
FileDeleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectTechReviewState {
|
||||||
|
has_pending_detail: bool,
|
||||||
|
has_unsafe_detail: bool,
|
||||||
|
has_dummy: bool,
|
||||||
|
thread_id: Option<DBThreadId>,
|
||||||
|
report_id: Option<DelphiReportId>,
|
||||||
|
last_tech_review_message_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sync_project_tech_review_state(
|
||||||
|
project_ids: &[DBProjectId],
|
||||||
|
exit_reason: TechReviewExitReason,
|
||||||
|
txn: &mut PgTransaction<'_>,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let project_ids = project_ids.iter().copied().unique().collect::<Vec<_>>();
|
||||||
|
if project_ids.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let project_ids_raw = project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
|
||||||
|
let tech_review_message_types = tech_review_message_types();
|
||||||
|
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
WITH project_ids AS (
|
||||||
|
SELECT unnest($1::bigint[]) AS project_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p.project_id AS "project_id!: DBProjectId",
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM delphi_issue_details_with_statuses didws
|
||||||
|
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
|
||||||
|
WHERE
|
||||||
|
didws.project_id = p.project_id
|
||||||
|
AND didws.status = 'pending'
|
||||||
|
AND dri.issue_type != $3
|
||||||
|
) AS "has_pending_detail!",
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM delphi_issue_details_with_statuses didws
|
||||||
|
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
|
||||||
|
WHERE
|
||||||
|
didws.project_id = p.project_id
|
||||||
|
AND didws.status = 'unsafe'
|
||||||
|
AND dri.issue_type != $3
|
||||||
|
) AS "has_unsafe_detail!",
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM delphi_issue_details_with_statuses didws
|
||||||
|
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
|
||||||
|
WHERE
|
||||||
|
didws.project_id = p.project_id
|
||||||
|
AND didws.status = 'pending'
|
||||||
|
AND dri.issue_type = $3
|
||||||
|
) AS "has_dummy!",
|
||||||
|
(
|
||||||
|
SELECT t.id
|
||||||
|
FROM threads t
|
||||||
|
WHERE t.mod_id = p.project_id
|
||||||
|
ORDER BY t.id
|
||||||
|
LIMIT 1
|
||||||
|
) AS "thread_id: DBThreadId",
|
||||||
|
(
|
||||||
|
SELECT tm.body->>'type'
|
||||||
|
FROM threads t
|
||||||
|
INNER JOIN threads_messages tm ON tm.thread_id = t.id
|
||||||
|
WHERE
|
||||||
|
t.mod_id = p.project_id
|
||||||
|
AND tm.body->>'type' = ANY($2::text[])
|
||||||
|
ORDER BY tm.created DESC, tm.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS "last_tech_review_message_type",
|
||||||
|
(
|
||||||
|
SELECT dr.id
|
||||||
|
FROM versions v
|
||||||
|
INNER JOIN files f ON f.version_id = v.id
|
||||||
|
INNER JOIN delphi_reports dr ON dr.file_id = f.id
|
||||||
|
WHERE v.mod_id = p.project_id
|
||||||
|
ORDER BY dr.created DESC, dr.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS "report_id: DelphiReportId"
|
||||||
|
FROM project_ids p
|
||||||
|
"#,
|
||||||
|
&project_ids_raw,
|
||||||
|
&tech_review_message_types,
|
||||||
|
DUMMY_ISSUE_TYPE,
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *txn)
|
||||||
|
.await
|
||||||
|
.wrap_internal_err("failed to fetch project tech review state")?;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let state = ProjectTechReviewState {
|
||||||
|
has_pending_detail: row.has_pending_detail,
|
||||||
|
has_unsafe_detail: row.has_unsafe_detail,
|
||||||
|
has_dummy: row.has_dummy,
|
||||||
|
thread_id: row.thread_id,
|
||||||
|
report_id: row.report_id,
|
||||||
|
last_tech_review_message_type: row.last_tech_review_message_type,
|
||||||
|
};
|
||||||
|
|
||||||
|
sync_one_project_tech_review_state(state, exit_reason, txn).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sync_detail_key_tech_review_state(
|
||||||
|
detail_keys: &[String],
|
||||||
|
exit_reason: TechReviewExitReason,
|
||||||
|
txn: &mut PgTransaction<'_>,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let detail_keys = detail_keys.iter().cloned().unique().collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if detail_keys.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT DISTINCT didws.project_id AS "project_id!: DBProjectId"
|
||||||
|
FROM delphi_issue_details_with_statuses didws
|
||||||
|
WHERE didws.key = ANY($1::text[])
|
||||||
|
"#,
|
||||||
|
&detail_keys,
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *txn)
|
||||||
|
.await
|
||||||
|
.wrap_internal_err("failed to fetch projects affected by detail keys")?;
|
||||||
|
|
||||||
|
let project_ids = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| row.project_id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
sync_project_tech_review_state(&project_ids, exit_reason, txn).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sync_deleted_project_tech_review_exit(
|
||||||
|
project_id: DBProjectId,
|
||||||
|
txn: &mut PgTransaction<'_>,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let tech_review_message_types = tech_review_message_types();
|
||||||
|
|
||||||
|
let row = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
(
|
||||||
|
SELECT t.id
|
||||||
|
FROM threads t
|
||||||
|
WHERE t.mod_id = $1
|
||||||
|
ORDER BY t.id
|
||||||
|
LIMIT 1
|
||||||
|
) AS "thread_id: DBThreadId",
|
||||||
|
(
|
||||||
|
SELECT tm.body->>'type'
|
||||||
|
FROM threads t
|
||||||
|
INNER JOIN threads_messages tm ON tm.thread_id = t.id
|
||||||
|
WHERE
|
||||||
|
t.mod_id = $1
|
||||||
|
AND tm.body->>'type' = ANY($2::text[])
|
||||||
|
ORDER BY tm.created DESC, tm.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS "last_tech_review_message_type"
|
||||||
|
"#,
|
||||||
|
project_id as DBProjectId,
|
||||||
|
&tech_review_message_types,
|
||||||
|
)
|
||||||
|
.fetch_one(&mut *txn)
|
||||||
|
.await
|
||||||
|
.wrap_internal_err("failed to fetch deleted project tech review state")?;
|
||||||
|
|
||||||
|
if let Some(thread_id) = row.thread_id
|
||||||
|
&& should_send_exit(row.last_tech_review_message_type.as_deref())
|
||||||
|
{
|
||||||
|
insert_exit_message(thread_id, TechReviewExitReason::FileDeleted, txn)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_one_project_tech_review_state(
|
||||||
|
state: ProjectTechReviewState,
|
||||||
|
exit_reason: TechReviewExitReason,
|
||||||
|
txn: &mut PgTransaction<'_>,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let needs_tech_review =
|
||||||
|
state.has_pending_detail || state.has_unsafe_detail || state.has_dummy;
|
||||||
|
|
||||||
|
if needs_tech_review {
|
||||||
|
if (state.has_pending_detail || state.has_unsafe_detail)
|
||||||
|
&& !state.has_dummy
|
||||||
|
&& let Some(report_id) = state.report_id
|
||||||
|
{
|
||||||
|
// TODO: Currently, the queue query determines whether a project is
|
||||||
|
// in tech review by checking whether it has any pending issue
|
||||||
|
// details. If all visible issue details are marked safe or unsafe
|
||||||
|
// before the final report is submitted, the project would otherwise
|
||||||
|
// leave the tech review queue without a final tech review verdict
|
||||||
|
// message.
|
||||||
|
//
|
||||||
|
// This should be replaced with explicit tech review state, such as
|
||||||
|
// an append-only project tech review event table where the latest
|
||||||
|
// enter/exit event is the current state. Until then, this dummy
|
||||||
|
// issue detail acts as the pending queue blocker.
|
||||||
|
ensure_dummy_issue_detail(report_id, txn).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(thread_id) = state.thread_id
|
||||||
|
&& state.last_tech_review_message_type.as_deref()
|
||||||
|
!= Some(MessageBody::TechReviewEntered.as_ref())
|
||||||
|
{
|
||||||
|
ThreadMessageBuilder {
|
||||||
|
author_id: None,
|
||||||
|
body: MessageBody::TechReviewEntered,
|
||||||
|
thread_id,
|
||||||
|
hide_identity: false,
|
||||||
|
}
|
||||||
|
.insert(txn)
|
||||||
|
.await
|
||||||
|
.wrap_internal_err("failed to add entering tech review message")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(exit_reason, TechReviewExitReason::Resolved)
|
||||||
|
&& state.last_tech_review_message_type.as_deref()
|
||||||
|
== Some(MessageBody::TechReviewEntered.as_ref())
|
||||||
|
{
|
||||||
|
if let Some(report_id) = state.report_id {
|
||||||
|
ensure_dummy_issue_detail(report_id, txn).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(thread_id) = state.thread_id
|
||||||
|
&& should_send_exit(state.last_tech_review_message_type.as_deref())
|
||||||
|
{
|
||||||
|
insert_exit_message(thread_id, exit_reason, txn).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_send_exit(last_tech_review_message_type: Option<&str>) -> bool {
|
||||||
|
matches!(last_tech_review_message_type, Some(message_type) if !matches!(
|
||||||
|
message_type,
|
||||||
|
message_type if message_type == MessageBody::TechReviewExited.as_ref()
|
||||||
|
|| message_type == MessageBody::TechReviewExitFileDeleted.as_ref()
|
||||||
|
|| message_type == tech_review_completed_message_type()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_exit_message(
|
||||||
|
thread_id: DBThreadId,
|
||||||
|
exit_reason: TechReviewExitReason,
|
||||||
|
txn: &mut PgTransaction<'_>,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let body = match exit_reason {
|
||||||
|
TechReviewExitReason::Resolved => MessageBody::TechReviewExited,
|
||||||
|
TechReviewExitReason::FileDeleted => {
|
||||||
|
MessageBody::TechReviewExitFileDeleted
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ThreadMessageBuilder {
|
||||||
|
author_id: None,
|
||||||
|
body,
|
||||||
|
thread_id,
|
||||||
|
hide_identity: false,
|
||||||
|
}
|
||||||
|
.insert(txn)
|
||||||
|
.await
|
||||||
|
.wrap_internal_err("failed to add exiting tech review message")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_dummy_issue_detail(
|
||||||
|
report_id: DelphiReportId,
|
||||||
|
txn: &mut PgTransaction<'_>,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
WITH dummy_issue AS (
|
||||||
|
INSERT INTO delphi_report_issues (report_id, issue_type)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (report_id, issue_type)
|
||||||
|
DO UPDATE SET issue_type = EXCLUDED.issue_type
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
INSERT INTO delphi_report_issue_details (
|
||||||
|
issue_id,
|
||||||
|
key,
|
||||||
|
jar,
|
||||||
|
file_path,
|
||||||
|
decompiled_source,
|
||||||
|
data,
|
||||||
|
severity
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
'',
|
||||||
|
NULL,
|
||||||
|
'',
|
||||||
|
NULL,
|
||||||
|
'{}'::jsonb,
|
||||||
|
'low'::delphi_severity
|
||||||
|
FROM dummy_issue
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM delphi_report_issue_details drid
|
||||||
|
WHERE drid.issue_id = dummy_issue.id
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
report_id as DelphiReportId,
|
||||||
|
DUMMY_ISSUE_TYPE,
|
||||||
|
)
|
||||||
|
.execute(&mut *txn)
|
||||||
|
.await
|
||||||
|
.wrap_internal_err("failed to ensure dummy Delphi report issue detail")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tech_review_message_types() -> Vec<String> {
|
||||||
|
[
|
||||||
|
MessageBody::TechReviewEntered.as_ref(),
|
||||||
|
MessageBody::TechReviewExited.as_ref(),
|
||||||
|
MessageBody::TechReviewExitFileDeleted.as_ref(),
|
||||||
|
tech_review_completed_message_type(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|message_type| message_type.to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tech_review_completed_message_type() -> &'static str {
|
||||||
|
MessageBody::TechReview {
|
||||||
|
verdict: DelphiVerdict::Safe,
|
||||||
|
}
|
||||||
|
.as_ref()
|
||||||
|
}
|
||||||
@@ -31,7 +31,13 @@ use crate::{
|
|||||||
threads::{MessageBody, Thread},
|
threads::{MessageBody, Thread},
|
||||||
},
|
},
|
||||||
queue::session::AuthQueue,
|
queue::session::AuthQueue,
|
||||||
routes::{ApiError, internal::moderation::Ownership},
|
routes::{
|
||||||
|
ApiError,
|
||||||
|
internal::{
|
||||||
|
delphi::tech_review_sync::{self, TechReviewExitReason},
|
||||||
|
moderation::Ownership,
|
||||||
|
},
|
||||||
|
},
|
||||||
search::SearchState,
|
search::SearchState,
|
||||||
util::error::Context,
|
util::error::Context,
|
||||||
};
|
};
|
||||||
@@ -238,6 +244,8 @@ pub async fn get_issue(
|
|||||||
'decompiled_source', didws.decompiled_source,
|
'decompiled_source', didws.decompiled_source,
|
||||||
'data', didws.data,
|
'data', didws.data,
|
||||||
'severity', didws.severity,
|
'severity', didws.severity,
|
||||||
|
'local_status', didws.local_status,
|
||||||
|
'global_status', didws.global_status,
|
||||||
'status', didws.status
|
'status', didws.status
|
||||||
)
|
)
|
||||||
), '[]'::jsonb)
|
), '[]'::jsonb)
|
||||||
@@ -313,6 +321,8 @@ pub async fn get_report(
|
|||||||
'decompiled_source', didws.decompiled_source,
|
'decompiled_source', didws.decompiled_source,
|
||||||
'data', didws.data,
|
'data', didws.data,
|
||||||
'severity', didws.severity,
|
'severity', didws.severity,
|
||||||
|
'local_status', didws.local_status,
|
||||||
|
'global_status', didws.global_status,
|
||||||
'status', didws.status
|
'status', didws.status
|
||||||
)
|
)
|
||||||
), '[]'::jsonb)
|
), '[]'::jsonb)
|
||||||
@@ -513,6 +523,8 @@ async fn fetch_project_reports(
|
|||||||
didws.file_path AS "file_path!: String",
|
didws.file_path AS "file_path!: String",
|
||||||
didws.data AS "data!: sqlx::types::Json<HashMap<String, serde_json::Value>>",
|
didws.data AS "data!: sqlx::types::Json<HashMap<String, serde_json::Value>>",
|
||||||
didws.severity AS "severity!: DelphiSeverity",
|
didws.severity AS "severity!: DelphiSeverity",
|
||||||
|
didws.local_status AS "local_status?: DelphiStatus",
|
||||||
|
didws.global_status AS "global_status?: DelphiStatus",
|
||||||
didws.status AS "status!: DelphiStatus"
|
didws.status AS "status!: DelphiStatus"
|
||||||
FROM delphi_issue_details_with_statuses didws
|
FROM delphi_issue_details_with_statuses didws
|
||||||
WHERE didws.issue_id = ANY($1::bigint[])
|
WHERE didws.issue_id = ANY($1::bigint[])
|
||||||
@@ -571,6 +583,8 @@ async fn fetch_project_reports(
|
|||||||
decompiled_source: None,
|
decompiled_source: None,
|
||||||
data: d.data.0,
|
data: d.data.0,
|
||||||
severity: d.severity,
|
severity: d.severity,
|
||||||
|
local_status: d.local_status,
|
||||||
|
global_status: d.global_status,
|
||||||
status: d.status,
|
status: d.status,
|
||||||
})
|
})
|
||||||
.into_group_map_by(|d| d.issue_id);
|
.into_group_map_by(|d| d.issue_id);
|
||||||
@@ -1177,7 +1191,9 @@ pub struct UpdateGlobalIssue {
|
|||||||
/// Key of the issue detail to update globally.
|
/// Key of the issue detail to update globally.
|
||||||
pub detail_key: String,
|
pub detail_key: String,
|
||||||
/// What the moderator has decided the outcome of this issue is globally.
|
/// What the moderator has decided the outcome of this issue is globally.
|
||||||
pub verdict: DelphiVerdict,
|
///
|
||||||
|
/// `pending` removes the global verdict for this issue detail key.
|
||||||
|
pub verdict: DelphiStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update technical review issue details.
|
/// Update technical review issue details.
|
||||||
@@ -1297,6 +1313,35 @@ pub async fn update_issue_details(
|
|||||||
return Err(ApiError::Request(eyre!("issue detail does not exist")));
|
return Err(ApiError::Request(eyre!("issue detail does not exist")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let affected_projects = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT DISTINCT didws.project_id AS "project_id!: DBProjectId"
|
||||||
|
FROM delphi_issue_details_with_statuses didws
|
||||||
|
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
|
||||||
|
WHERE
|
||||||
|
didws.id = ANY($1::bigint[])
|
||||||
|
AND dri.issue_type != '__dummy'
|
||||||
|
"#,
|
||||||
|
&detail_ids,
|
||||||
|
)
|
||||||
|
.fetch_all(&mut txn)
|
||||||
|
.await
|
||||||
|
.wrap_internal_err(
|
||||||
|
"failed to fetch projects affected by issue detail updates",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let affected_project_ids = affected_projects
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| row.project_id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
tech_review_sync::sync_project_tech_review_state(
|
||||||
|
&affected_project_ids,
|
||||||
|
TechReviewExitReason::Resolved,
|
||||||
|
&mut txn,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
txn.commit()
|
txn.commit()
|
||||||
.await
|
.await
|
||||||
.wrap_internal_err("failed to commit transaction")?;
|
.wrap_internal_err("failed to commit transaction")?;
|
||||||
@@ -1306,7 +1351,8 @@ pub async fn update_issue_details(
|
|||||||
|
|
||||||
/// Update global technical review issue detail verdicts.
|
/// Update global technical review issue detail verdicts.
|
||||||
///
|
///
|
||||||
/// This marks every issue detail with a matching key as safe or unsafe.
|
/// This marks every issue detail with a matching key as safe or unsafe, or
|
||||||
|
/// unsets the global verdict with `pending`.
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
context_path = "/moderation/tech-review",
|
context_path = "/moderation/tech-review",
|
||||||
tag = "moderation",
|
tag = "moderation",
|
||||||
@@ -1347,8 +1393,9 @@ pub async fn update_global_issue_details(
|
|||||||
let verdicts = updates
|
let verdicts = updates
|
||||||
.iter()
|
.iter()
|
||||||
.map(|u| match u.verdict {
|
.map(|u| match u.verdict {
|
||||||
DelphiVerdict::Safe => "safe".to_string(),
|
DelphiStatus::Safe => "safe".to_string(),
|
||||||
DelphiVerdict::Unsafe => "unsafe".to_string(),
|
DelphiStatus::Unsafe => "unsafe".to_string(),
|
||||||
|
DelphiStatus::Pending => "pending".to_string(),
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
@@ -1363,16 +1410,31 @@ pub async fn update_global_issue_details(
|
|||||||
SELECT *
|
SELECT *
|
||||||
FROM unnest($1::text[], $2::text[]) WITH ORDINALITY
|
FROM unnest($1::text[], $2::text[]) WITH ORDINALITY
|
||||||
AS u(detail_key, verdict, ord)
|
AS u(detail_key, verdict, ord)
|
||||||
|
),
|
||||||
|
latest AS (
|
||||||
|
SELECT DISTINCT ON (detail_key)
|
||||||
|
detail_key,
|
||||||
|
verdict
|
||||||
|
FROM incoming
|
||||||
|
ORDER BY detail_key, ord DESC
|
||||||
|
),
|
||||||
|
deleted AS (
|
||||||
|
DELETE FROM delphi_global_detail_verdicts dgdv
|
||||||
|
USING latest
|
||||||
|
WHERE
|
||||||
|
dgdv.detail_key = latest.detail_key
|
||||||
|
AND latest.verdict = 'pending'
|
||||||
|
RETURNING 1
|
||||||
)
|
)
|
||||||
INSERT INTO delphi_global_detail_verdicts (
|
INSERT INTO delphi_global_detail_verdicts (
|
||||||
detail_key,
|
detail_key,
|
||||||
verdict
|
verdict
|
||||||
)
|
)
|
||||||
SELECT DISTINCT ON (detail_key)
|
SELECT
|
||||||
detail_key,
|
detail_key,
|
||||||
verdict::delphi_report_issue_status
|
verdict::delphi_report_issue_status
|
||||||
FROM incoming
|
FROM latest
|
||||||
ORDER BY detail_key, ord DESC
|
WHERE verdict != 'pending'
|
||||||
ON CONFLICT (detail_key)
|
ON CONFLICT (detail_key)
|
||||||
DO UPDATE SET verdict = EXCLUDED.verdict
|
DO UPDATE SET verdict = EXCLUDED.verdict
|
||||||
"#,
|
"#,
|
||||||
@@ -1383,6 +1445,13 @@ pub async fn update_global_issue_details(
|
|||||||
.await
|
.await
|
||||||
.wrap_internal_err("failed to update global issue details")?;
|
.wrap_internal_err("failed to update global issue details")?;
|
||||||
|
|
||||||
|
tech_review_sync::sync_detail_key_tech_review_state(
|
||||||
|
&detail_keys,
|
||||||
|
TechReviewExitReason::Resolved,
|
||||||
|
&mut txn,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
txn.commit()
|
txn.commit()
|
||||||
.await
|
.await
|
||||||
.wrap_internal_err("failed to commit transaction")?;
|
.wrap_internal_err("failed to commit transaction")?;
|
||||||
|
|||||||
@@ -2781,17 +2781,11 @@ pub async fn project_delete_internal(
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.wrap_internal_err("failed to start transaction")?;
|
.wrap_internal_err("failed to start transaction")?;
|
||||||
let was_in_tech_review =
|
delphi::tech_review_sync::sync_deleted_project_tech_review_exit(
|
||||||
delphi::is_project_in_tech_review(project.inner.id, &mut transaction)
|
project.inner.id,
|
||||||
.await?;
|
&mut transaction,
|
||||||
|
)
|
||||||
if was_in_tech_review {
|
.await?;
|
||||||
delphi::send_tech_review_exit_file_deleted_message(
|
|
||||||
project.inner.id,
|
|
||||||
&mut transaction,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let context = ImageContext::Project {
|
let context = ImageContext::Project {
|
||||||
project_id: Some(project.inner.id.into()),
|
project_id: Some(project.inner.id.into()),
|
||||||
|
|||||||
@@ -910,9 +910,6 @@ pub async fn delete_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut transaction = pool.begin().await?;
|
let mut transaction = pool.begin().await?;
|
||||||
let was_in_tech_review =
|
|
||||||
delphi::is_project_in_tech_review(row.project_id, &mut transaction)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"
|
"
|
||||||
@@ -937,9 +934,9 @@ pub async fn delete_file(
|
|||||||
database::models::version_item::cleanup_unused_attribution_files_and_groups(&mut transaction)
|
database::models::version_item::cleanup_unused_attribution_files_and_groups(&mut transaction)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
delphi::send_tech_review_exit_file_deleted_message_if_exited(
|
delphi::tech_review_sync::sync_project_tech_review_state(
|
||||||
row.project_id,
|
&[row.project_id],
|
||||||
was_in_tech_review,
|
delphi::tech_review_sync::TechReviewExitReason::FileDeleted,
|
||||||
&mut transaction,
|
&mut transaction,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -1218,11 +1218,6 @@ pub async fn version_delete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut transaction = pool.begin().await?;
|
let mut transaction = pool.begin().await?;
|
||||||
let was_in_tech_review = delphi::is_project_in_tech_review(
|
|
||||||
version.inner.project_id,
|
|
||||||
&mut transaction,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let context = ImageContext::Version {
|
let context = ImageContext::Version {
|
||||||
version_id: Some(version.inner.id.into()),
|
version_id: Some(version.inner.id.into()),
|
||||||
@@ -1243,9 +1238,9 @@ pub async fn version_delete(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
delphi::send_tech_review_exit_file_deleted_message_if_exited(
|
delphi::tech_review_sync::sync_project_tech_review_state(
|
||||||
version.inner.project_id,
|
&[version.inner.project_id],
|
||||||
was_in_tech_review,
|
delphi::tech_review_sync::TechReviewExitReason::FileDeleted,
|
||||||
&mut transaction,
|
&mut transaction,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -1833,6 +1833,19 @@ export namespace Labrinth {
|
|||||||
new_status: Projects.v2.ProjectStatus
|
new_status: Projects.v2.ProjectStatus
|
||||||
old_status: Projects.v2.ProjectStatus
|
old_status: Projects.v2.ProjectStatus
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'tech_review'
|
||||||
|
verdict: 'safe' | 'unsafe'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'tech_review_entered'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'tech_review_exited'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'tech_review_exit_file_deleted'
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'thread_closure'
|
type: 'thread_closure'
|
||||||
}
|
}
|
||||||
@@ -2234,7 +2247,7 @@ export namespace Labrinth {
|
|||||||
|
|
||||||
export type UpdateGlobalIssueRequest = {
|
export type UpdateGlobalIssueRequest = {
|
||||||
detail_key: string
|
detail_key: string
|
||||||
verdict: 'safe' | 'unsafe'
|
verdict: DelphiReportIssueStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SearchGlobalIssueDetailsRequest = {
|
export type SearchGlobalIssueDetailsRequest = {
|
||||||
@@ -2343,6 +2356,8 @@ export namespace Labrinth {
|
|||||||
decompiled_source: string | null
|
decompiled_source: string | null
|
||||||
data: Record<string, unknown>
|
data: Record<string, unknown>
|
||||||
severity: DelphiSeverity
|
severity: DelphiSeverity
|
||||||
|
local_status: DelphiReportIssueStatus | null
|
||||||
|
global_status: DelphiReportIssueStatus | null
|
||||||
status: DelphiReportIssueStatus
|
status: DelphiReportIssueStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2391,6 +2406,19 @@ export namespace Labrinth {
|
|||||||
new_status: Projects.v2.ProjectStatus
|
new_status: Projects.v2.ProjectStatus
|
||||||
old_status: Projects.v2.ProjectStatus
|
old_status: Projects.v2.ProjectStatus
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'tech_review'
|
||||||
|
verdict: 'safe' | 'unsafe'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'tech_review_entered'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'tech_review_exited'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'tech_review_exit_file_deleted'
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'thread_closure'
|
type: 'thread_closure'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,25 @@ export namespace LauncherMeta {
|
|||||||
export namespace v0 {
|
export namespace v0 {
|
||||||
export type LoaderVersion = {
|
export type LoaderVersion = {
|
||||||
id: string
|
id: string
|
||||||
|
url: string
|
||||||
stable: boolean
|
stable: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GameVersionEntry = {
|
export type GameVersionEntry = {
|
||||||
|
id: string
|
||||||
|
stable: boolean
|
||||||
|
versionGroup?: string
|
||||||
|
loaders: LoaderVersion[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VersionGroup = {
|
||||||
id: string
|
id: string
|
||||||
loaders: LoaderVersion[]
|
loaders: LoaderVersion[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Manifest = {
|
export type Manifest = {
|
||||||
gameVersions: GameVersionEntry[]
|
gameVersions: GameVersionEntry[]
|
||||||
|
versionGroups?: VersionGroup[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ export class LauncherMetaManifestV0Module extends AbstractModule {
|
|||||||
*
|
*
|
||||||
* @param loader - Loader platform (fabric, forge, quilt, neo)
|
* @param loader - Loader platform (fabric, forge, quilt, neo)
|
||||||
*/
|
*/
|
||||||
public async getManifest(loader: string): Promise<LauncherMeta.Manifest.v0.Manifest> {
|
public async getManifest(
|
||||||
|
loader: string,
|
||||||
|
formatVersion = 0,
|
||||||
|
): Promise<LauncherMeta.Manifest.v0.Manifest> {
|
||||||
return this.client.request<LauncherMeta.Manifest.v0.Manifest>('/manifest.json', {
|
return this.client.request<LauncherMeta.Manifest.v0.Manifest>('/manifest.json', {
|
||||||
api: LAUNCHER_META_BASE_URL,
|
api: LAUNCHER_META_BASE_URL,
|
||||||
version: `${loader}/v0`,
|
version: `${loader}/v${formatVersion}`,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
skipAuth: true,
|
skipAuth: true,
|
||||||
headers: { 'Content-Type': '' },
|
headers: { 'Content-Type': '' },
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ async_zip = { workspace = true, features = [
|
|||||||
"zstd",
|
"zstd",
|
||||||
] }
|
] }
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
|
bon = { workspace = true }
|
||||||
bytemuck = { workspace = true, features = ["extern_crate_alloc"] }
|
bytemuck = { workspace = true, features = ["extern_crate_alloc"] }
|
||||||
bytes = { workspace = true, features = ["serde"] }
|
bytes = { workspace = true, features = ["serde"] }
|
||||||
chardetng = { workspace = true }
|
chardetng = { workspace = true }
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ pub async fn get_optimal_jre_key(
|
|||||||
loader_version.as_ref(),
|
loader_version.as_ref(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Authentication flow interface
|
//! Authentication flow interface
|
||||||
use crate::event::emit::{emit_loading, init_loading};
|
use crate::event::emit::{emit_loading, init_loading};
|
||||||
use crate::install::{
|
use crate::install::{
|
||||||
InstallJavaStep, InstallPhaseDetails, InstallPhaseId, InstallProgress,
|
InstallErrorContext, InstallJavaStep, InstallPhaseDetails, InstallPhaseId,
|
||||||
InstallProgressReporter,
|
InstallProgress, InstallProgressReporter,
|
||||||
};
|
};
|
||||||
use crate::state::JavaVersion;
|
use crate::state::JavaVersion;
|
||||||
use crate::util::fetch::{
|
use crate::util::fetch::{
|
||||||
@@ -148,23 +148,52 @@ async fn auto_install_java_inner(
|
|||||||
Some(java_step_progress(1)),
|
Some(java_step_progress(1)),
|
||||||
)
|
)
|
||||||
.await?;
|
.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>>(
|
let packages = fetch_json::<Vec<Package>>(
|
||||||
Method::GET,
|
Method::GET,
|
||||||
&format!(
|
&metadata_url,
|
||||||
"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",
|
None,
|
||||||
std::env::consts::ARCH, java_version, std::env::consts::OS
|
None,
|
||||||
),
|
None,
|
||||||
None,
|
&state.fetch_semaphore,
|
||||||
None,
|
&state.pool,
|
||||||
None,
|
)
|
||||||
&state.fetch_semaphore,
|
.await?;
|
||||||
&state.pool,
|
|
||||||
).await?;
|
|
||||||
if let Some(loading_bar) = &loading_bar {
|
if let Some(loading_bar) = &loading_bar {
|
||||||
emit_loading(loading_bar, 10.0, Some("Downloading java version"))?;
|
emit_loading(loading_bar, 10.0, Some("Downloading java version"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(download) = packages.first() {
|
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(
|
update_java_install_progress(
|
||||||
reporter.as_ref(),
|
reporter.as_ref(),
|
||||||
java_version,
|
java_version,
|
||||||
@@ -237,6 +266,20 @@ async fn auto_install_java_inner(
|
|||||||
|
|
||||||
let path = state.directories.java_versions_dir();
|
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))
|
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(file))
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
crate::Error::from(crate::ErrorKind::InputError(
|
crate::Error::from(crate::ErrorKind::InputError(
|
||||||
@@ -265,6 +308,20 @@ async fn auto_install_java_inner(
|
|||||||
Some(java_step_progress(3)),
|
Some(java_step_progress(3)),
|
||||||
)
|
)
|
||||||
.await?;
|
.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(|_| {
|
archive.extract(&path).map_err(|_| {
|
||||||
crate::Error::from(crate::ErrorKind::InputError(
|
crate::Error::from(crate::ErrorKind::InputError(
|
||||||
"Failed to extract java zip".to_string(),
|
"Failed to extract java zip".to_string(),
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ pub async fn get_minecraft_versions() -> crate::Result<VersionManifest> {
|
|||||||
// #[tracing::instrument]
|
// #[tracing::instrument]
|
||||||
pub async fn get_loader_versions(loader: &str) -> crate::Result<Manifest> {
|
pub async fn get_loader_versions(loader: &str) -> crate::Result<Manifest> {
|
||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
|
let cache_key =
|
||||||
|
daedalus::modded::loader_manifest_metadata(loader).cache_key;
|
||||||
let loaders = CachedEntry::get_loader_manifest(
|
let loaders = CachedEntry::get_loader_manifest(
|
||||||
loader,
|
&cache_key,
|
||||||
None,
|
None,
|
||||||
&state.pool,
|
&state.pool,
|
||||||
&state.api_semaphore,
|
&state.api_semaphore,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::State;
|
use crate::State;
|
||||||
use crate::data::ModLoader;
|
use crate::data::ModLoader;
|
||||||
use crate::install::{
|
use crate::install::{
|
||||||
InstallPhaseDetails, InstallPhaseId, InstallProgress,
|
InstallErrorContext, InstallPhaseDetails, InstallPhaseId, InstallProgress,
|
||||||
InstallProgressReporter,
|
InstallProgressReporter,
|
||||||
};
|
};
|
||||||
use crate::state::{
|
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 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(
|
let file = fetch_advanced_with_progress(
|
||||||
Method::GET,
|
Method::GET,
|
||||||
&url,
|
&url,
|
||||||
@@ -358,6 +365,10 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
reporter
|
||||||
|
.update(InstallPhaseId::ResolvingPack, None, details.clone())
|
||||||
|
.await?;
|
||||||
|
|
||||||
let project = CachedEntry::get_project(
|
let project = CachedEntry::get_project(
|
||||||
&version.project_id,
|
&version.project_id,
|
||||||
None,
|
None,
|
||||||
@@ -377,6 +388,15 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter(
|
|||||||
let icon = if has_icon_url {
|
let icon = if has_icon_url {
|
||||||
if let Some(icon_url) = project.icon_url {
|
if let Some(icon_url) = project.icon_url {
|
||||||
let state = State::get().await?;
|
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(
|
let icon_bytes = fetch(
|
||||||
&icon_url,
|
&icon_url,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
use crate::State;
|
use crate::State;
|
||||||
use crate::event::emit::loading_try_for_each_concurrent;
|
use crate::event::emit::loading_try_for_each_concurrent;
|
||||||
use crate::install::{
|
use crate::install::{
|
||||||
InstallPhaseDetails, InstallPhaseId, InstallProgress,
|
InstallErrorContext, InstallJobEventKind, InstallPhaseDetails,
|
||||||
InstallProgressReporter, InstallProgressSecondary,
|
InstallPhaseId, InstallProgress, InstallProgressReporter,
|
||||||
|
InstallProgressSecondary,
|
||||||
};
|
};
|
||||||
use crate::pack::install_from::{
|
use crate::pack::install_from::{
|
||||||
EnvType, PackFile, PackFileHash, set_instance_information,
|
EnvType, PackFile, PackFileHash, set_instance_information,
|
||||||
};
|
};
|
||||||
use crate::state::instances::ContentSourceKind;
|
use crate::state::instances::ContentSourceKind;
|
||||||
use crate::state::{
|
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 crate::util::io;
|
||||||
use async_zip::base::read::seek::ZipFileReader as SeekZipFileReader;
|
use async_zip::base::read::seek::ZipFileReader as SeekZipFileReader;
|
||||||
use async_zip::base::read::{WithEntry, ZipEntryReader};
|
use async_zip::base::read::{WithEntry, ZipEntryReader};
|
||||||
use async_zip::tokio::read::fs::ZipFileReader as FsZipFileReader;
|
use async_zip::tokio::read::fs::ZipFileReader as FsZipFileReader;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use path_util::SafeRelativeUtf8UnixPathBuf;
|
use path_util::SafeRelativeUtf8UnixPathBuf;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
@@ -28,12 +34,80 @@ use std::sync::{
|
|||||||
use super::install_from::{CreatePack, CreatePackFile, PackFormat};
|
use super::install_from::{CreatePack, CreatePackFile, PackFormat};
|
||||||
use crate::data::ProjectType;
|
use crate::data::ProjectType;
|
||||||
use std::io::{Cursor, ErrorKind};
|
use std::io::{Cursor, ErrorKind};
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
type ExtractProgressFn<'a> = dyn FnMut(u64) -> Pin<Box<dyn Future<Output = crate::Result<()>> + Send + 'a>>
|
type ExtractProgressFn<'a> = dyn FnMut(u64) -> Pin<Box<dyn Future<Output = crate::Result<()>> + Send + 'a>>
|
||||||
+ Send
|
+ Send
|
||||||
+ 'a;
|
+ '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 {
|
enum MrpackZipReader {
|
||||||
Memory(async_zip::tokio::read::seek::ZipFileReader<Cursor<bytes::Bytes>>),
|
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 version_id = create_pack.description.version_id;
|
||||||
let instance_id = create_pack.description.instance_id;
|
let instance_id = create_pack.description.instance_id;
|
||||||
let mut icon_exists = icon.is_some();
|
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 mut zip_reader = MrpackZipReader::new(&file).await?;
|
||||||
let instance_full_path =
|
let instance_full_path =
|
||||||
crate::api::instance::get_full_path(&instance_id).await?;
|
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(),
|
modpack_details.clone(),
|
||||||
)
|
)
|
||||||
.await?;
|
.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
|
// Extract index of modrinth.index.json
|
||||||
let Some(manifest_idx) = zip_reader.file().entries().iter().position(|f| {
|
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)
|
.map(|file| file.file_size as u64)
|
||||||
.sum::<u64>();
|
.sum::<u64>();
|
||||||
reporter
|
reporter
|
||||||
.update(
|
.update_with_events(
|
||||||
InstallPhaseId::DownloadingContent,
|
InstallPhaseId::DownloadingContent,
|
||||||
Some(InstallProgress {
|
Some(InstallProgress {
|
||||||
current: 0,
|
current: 0,
|
||||||
@@ -426,138 +520,276 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter(
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
modpack_details.clone(),
|
modpack_details.clone(),
|
||||||
|
vec![InstallJobEventKind::ContentDownloadStarted {
|
||||||
|
files: num_files as u64,
|
||||||
|
bytes: (content_total_bytes > 0).then_some(content_total_bytes),
|
||||||
|
}],
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let content_progress = Arc::new(AtomicU64::new(0));
|
let content_progress = Arc::new(AtomicU64::new(0));
|
||||||
let content_bytes_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(
|
loading_try_for_each_concurrent(
|
||||||
futures::stream::iter(pack.files).map(Ok::<PackFile, crate::Error>),
|
futures::stream::iter(pack.files).map(Ok::<PackFile, crate::Error>),
|
||||||
None,
|
Some(MODPACK_CONTENT_DOWNLOAD_CONCURRENCY),
|
||||||
None,
|
None,
|
||||||
70.0,
|
70.0,
|
||||||
num_files,
|
num_files,
|
||||||
None,
|
None,
|
||||||
|project| {
|
|project| {
|
||||||
let instance_id = instance_id.clone();
|
let content_context = content_context.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();
|
|
||||||
async move {
|
async move {
|
||||||
let mark_downloaded = |file_size: u64| {
|
let project_size = project.file_size as u64;
|
||||||
let reporter = reporter.clone();
|
let project_path = project.path.as_str().to_string();
|
||||||
let modpack_details = modpack_details.clone();
|
let target_path = content_context
|
||||||
let content_progress = content_progress.clone();
|
.instance_full_path
|
||||||
let content_bytes_progress = content_bytes_progress.clone();
|
.join(project.path.as_str());
|
||||||
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>(())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
//TODO: Future update: prompt user for optional files in a modpack
|
//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
|
&& env
|
||||||
.get(&EnvType::Client)
|
.get(&EnvType::Client)
|
||||||
.is_some_and(|x| x == &SideType::Unsupported)
|
.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(());
|
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
|
&project
|
||||||
.downloads
|
.downloads
|
||||||
.iter()
|
.iter()
|
||||||
.map(|x| &**x)
|
.map(|x| &**x)
|
||||||
.collect::<Vec<&str>>(),
|
.collect::<Vec<&str>>(),
|
||||||
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x),
|
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x),
|
||||||
Some(&download_meta),
|
Some(&content_context.download_meta),
|
||||||
None,
|
None,
|
||||||
&state.fetch_semaphore,
|
&state.fetch_semaphore,
|
||||||
&state.pool,
|
&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(),
|
let _permit = state.install_db_semaphore.acquire().await?;
|
||||||
&instance_path,
|
content_context
|
||||||
project.path.as_str(),
|
.reporter
|
||||||
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x),
|
.preserve_failure_context(
|
||||||
ProjectType::get_from_parent_folder(&path),
|
context.clone(),
|
||||||
None,
|
cache_file_hash(
|
||||||
&state.pool,
|
file.clone(),
|
||||||
)
|
&content_context.instance_path,
|
||||||
.await?;
|
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) =
|
if let Some(project_type) =
|
||||||
ProjectType::get_from_parent_folder(project.path.as_str())
|
ProjectType::get_from_parent_folder(project.path.as_str())
|
||||||
{
|
{
|
||||||
let hash =
|
let hash =
|
||||||
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x);
|
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x);
|
||||||
let file_info = if let Some(hash) = hash {
|
let file_info =
|
||||||
CachedEntry::get_file_many(
|
hash.and_then(|hash| {
|
||||||
&[hash],
|
content_context.file_infos_by_hash.get(hash)
|
||||||
None,
|
});
|
||||||
&state.pool,
|
|
||||||
&state.api_semaphore,
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
if let Some(hash) = hash {
|
if let Some(hash) = hash {
|
||||||
crate::state::instances::commands::record_project_file(
|
let _permit =
|
||||||
&instance_id,
|
state.install_db_semaphore.acquire().await?;
|
||||||
project.path.as_str(),
|
content_context
|
||||||
hash,
|
.reporter
|
||||||
project.file_size as u64,
|
.preserve_failure_context(
|
||||||
project_type,
|
context.clone(),
|
||||||
modpack_source_kind(pack_version_id.as_deref()),
|
crate::state::instances::commands::record_project_file(
|
||||||
file_info
|
&content_context.instance_id,
|
||||||
.as_ref()
|
project.path.as_str(),
|
||||||
.map(|file| file.project_id.as_str()),
|
hash,
|
||||||
file_info
|
project.file_size as u64,
|
||||||
.as_ref()
|
project_type,
|
||||||
.map(|file| file.version_id.as_str()),
|
modpack_source_kind(
|
||||||
state,
|
content_context
|
||||||
)
|
.pack_version_id
|
||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -646,7 +878,18 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter(
|
|||||||
|
|
||||||
let path =
|
let path =
|
||||||
instance_full_path.join(relative_override_file_path.as_str());
|
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 =
|
let progress =
|
||||||
&mut report_override_progress as &mut ExtractProgressFn<'_>;
|
&mut report_override_progress as &mut ExtractProgressFn<'_>;
|
||||||
zip_reader
|
zip_reader
|
||||||
@@ -656,41 +899,65 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter(
|
|||||||
&state.io_semaphore,
|
&state.io_semaphore,
|
||||||
Some(progress),
|
Some(progress),
|
||||||
)
|
)
|
||||||
.await?
|
.await
|
||||||
} else {
|
} else {
|
||||||
zip_reader
|
zip_reader
|
||||||
.extract_entry(index, &path, &state.io_semaphore, None)
|
.extract_entry(index, &path, &state.io_semaphore, None)
|
||||||
.await?
|
.await
|
||||||
};
|
};
|
||||||
|
let (size, hash) = reporter
|
||||||
crate::state::cache_file_hash_metadata(
|
.preserve_failure_context(override_context, extract_result)
|
||||||
&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,
|
|
||||||
)
|
|
||||||
.await?;
|
.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(
|
crate::launcher::install_minecraft_for_instance_id_with_reporter(
|
||||||
&instance_id,
|
&instance_id,
|
||||||
false,
|
false,
|
||||||
Some(reporter),
|
Some(reporter.clone()),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
reporter.clear_context().await?;
|
||||||
|
|
||||||
Ok::<String, crate::Error>(instance_id.clone())
|
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 {
|
fn modpack_source_kind(version_id: Option<&str>) -> ContentSourceKind {
|
||||||
if version_id.is_some() {
|
if version_id.is_some() {
|
||||||
ContentSourceKind::ModrinthModpack
|
ContentSourceKind::ModrinthModpack
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ use tracing_error::InstrumentError;
|
|||||||
pub struct LabrinthError {
|
pub struct LabrinthError {
|
||||||
pub error: String,
|
pub error: String,
|
||||||
pub description: 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)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,23 +1,38 @@
|
|||||||
use super::model::{
|
use super::model::{
|
||||||
InstallJobSnapshot, InstallJobState, InstallPhaseDetails, InstallPhaseId,
|
InstallErrorContext, InstallJobEventKind, InstallJobSnapshot,
|
||||||
InstallProgress,
|
InstallJobState, InstallPhaseDetails, InstallPhaseId, InstallProgress,
|
||||||
};
|
};
|
||||||
use super::store;
|
use super::store;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const PROGRESS_PERSIST_INTERVAL: Duration = Duration::from_millis(750);
|
||||||
|
const CONTENT_PROGRESS_PERSIST_STEPS: u64 = 25;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct InstallProgressReporter {
|
pub struct InstallProgressReporter {
|
||||||
job_id: Uuid,
|
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 {
|
impl InstallProgressReporter {
|
||||||
pub fn new(job_id: Uuid, state: InstallJobState) -> Self {
|
pub fn new(job_id: Uuid, state: InstallJobState) -> Self {
|
||||||
Self {
|
Self {
|
||||||
job_id,
|
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>,
|
progress: Option<InstallProgress>,
|
||||||
details: InstallPhaseDetails,
|
details: InstallPhaseDetails,
|
||||||
) -> crate::Result<()> {
|
) -> 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;
|
let mut state = self.state.lock().await;
|
||||||
state.progress.phase = phase;
|
state.job.set_context(context);
|
||||||
state.progress.progress = progress;
|
|
||||||
state.progress.details = details;
|
let Some(app_state) = app_state else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
let record =
|
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
|
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)]
|
#[allow(unused_variables)]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod diagnostics;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod recovery;
|
pub mod recovery;
|
||||||
@@ -6,13 +7,15 @@ pub mod store;
|
|||||||
|
|
||||||
pub use events::InstallProgressReporter;
|
pub use events::InstallProgressReporter;
|
||||||
pub use model::{
|
pub use model::{
|
||||||
InstallErrorView, InstallJavaStep, InstallJobKind, InstallJobSnapshot,
|
InstallErrorContext, InstallErrorView, InstallJavaStep,
|
||||||
InstallJobStatus, InstallModpackPreview, InstallPhaseDetails,
|
InstallJobEventKind, InstallJobKind, InstallJobSnapshot, InstallJobStatus,
|
||||||
InstallPhaseId, InstallPostInstallEdit, InstallProgress,
|
InstallModpackPreview, InstallPhaseDetails, InstallPhaseId,
|
||||||
InstallProgressSecondary, InstallRequest,
|
InstallPostInstallEdit, InstallProgress, InstallProgressSecondary,
|
||||||
|
InstallRequest,
|
||||||
};
|
};
|
||||||
pub use runner::{
|
pub use runner::{
|
||||||
cancel_job, create_instance, create_modpack_instance, dismiss_job,
|
cancel_job, create_instance, create_modpack_instance, dismiss_job,
|
||||||
duplicate_instance, get_job, import_instance, install_existing_instance,
|
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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,16 +18,23 @@ pub struct InstallJobState {
|
|||||||
pub cleanup: InstallCleanup,
|
pub cleanup: InstallCleanup,
|
||||||
pub progress: InstallProgressState,
|
pub progress: InstallProgressState,
|
||||||
pub paths: InstallJobPaths,
|
pub paths: InstallJobPaths,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub context: Option<InstallErrorContext>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub events: Vec<InstallJobEvent>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub display: Option<InstallJobDisplay>,
|
pub display: Option<InstallJobDisplay>,
|
||||||
pub rollback: Option<InstallRollbackState>,
|
pub rollback: Option<InstallRollbackState>,
|
||||||
pub error: Option<InstallErrorView>,
|
pub error: Option<InstallErrorView>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rollback_error: Option<InstallErrorView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InstallJobState {
|
impl InstallJobState {
|
||||||
pub fn new(request: InstallRequest) -> Self {
|
pub fn new(request: InstallRequest) -> Self {
|
||||||
let target = request.target();
|
let target = request.target();
|
||||||
let cleanup = request.cleanup();
|
let cleanup = request.cleanup();
|
||||||
|
let kind = request.kind();
|
||||||
let phase = InstallPhaseId::PreparingInstance;
|
let phase = InstallPhaseId::PreparingInstance;
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -41,11 +48,109 @@ impl InstallJobState {
|
|||||||
details: InstallPhaseDetails::Empty,
|
details: InstallPhaseDetails::Empty,
|
||||||
},
|
},
|
||||||
paths: InstallJobPaths::default(),
|
paths: InstallJobPaths::default(),
|
||||||
|
context: None,
|
||||||
|
events: vec![InstallJobEvent {
|
||||||
|
at: Utc::now(),
|
||||||
|
kind: InstallJobEventKind::JobQueued { kind },
|
||||||
|
}],
|
||||||
display: None,
|
display: None,
|
||||||
rollback: None,
|
rollback: None,
|
||||||
error: 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)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
@@ -315,6 +420,51 @@ pub struct InstallJobPaths {
|
|||||||
pub final_instance_path: Option<PathBuf>,
|
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)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
pub struct InstallJobDisplay {
|
pub struct InstallJobDisplay {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
@@ -330,14 +480,66 @@ pub struct InstallRollbackState {
|
|||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
pub struct InstallErrorView {
|
pub struct InstallErrorView {
|
||||||
pub code: String,
|
pub code: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub phase: Option<InstallPhaseId>,
|
||||||
pub message: String,
|
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 {
|
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 {
|
Self {
|
||||||
code: code.to_string(),
|
code: code.to_string(),
|
||||||
|
phase: Some(phase),
|
||||||
message: error.to_string(),
|
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 details: InstallPhaseDetails,
|
||||||
pub display: Option<InstallJobDisplay>,
|
pub display: Option<InstallJobDisplay>,
|
||||||
pub error: Option<InstallErrorView>,
|
pub error: Option<InstallErrorView>,
|
||||||
|
pub rollback_error: Option<InstallErrorView>,
|
||||||
pub created: DateTime<Utc>,
|
pub created: DateTime<Utc>,
|
||||||
pub modified: DateTime<Utc>,
|
pub modified: DateTime<Utc>,
|
||||||
pub finished: Option<DateTime<Utc>>,
|
pub finished: Option<DateTime<Utc>>,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use super::events::emit_install_job;
|
use super::events::emit_install_job;
|
||||||
use super::model::{
|
use super::model::{
|
||||||
InstallCleanup, InstallErrorView, InstallJobDisplay, InstallJobState,
|
InstallCleanup, InstallErrorView, InstallInterruptReason,
|
||||||
InstallJobStatus, InstallPhaseDetails, InstallPhaseId, InstallRequest,
|
InstallJobDisplay, InstallJobEventKind, InstallJobState, InstallJobStatus,
|
||||||
InstallTarget,
|
InstallPhaseDetails, InstallPhaseId, InstallRequest, InstallTarget,
|
||||||
};
|
};
|
||||||
use super::store;
|
use super::store;
|
||||||
use crate::event::InstancePayloadType;
|
use crate::event::InstancePayloadType;
|
||||||
@@ -16,19 +16,41 @@ pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
|
|||||||
if job.state.display.is_none() {
|
if job.state.display.is_none() {
|
||||||
job.state.display = display_from_request(&job.state);
|
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.phase = InstallPhaseId::RollingBack;
|
||||||
job.state.progress.progress = None;
|
job.state.progress.progress = None;
|
||||||
job.state.progress.details = InstallPhaseDetails::Empty;
|
job.state.progress.details = InstallPhaseDetails::Empty;
|
||||||
job.state.error = Some(InstallErrorView {
|
job.state.error = Some(InstallErrorView::from_message(
|
||||||
code: "interrupted".to_string(),
|
"app_closed",
|
||||||
message: "interrupted".to_string(),
|
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 {
|
if let Err(error) = apply_cleanup(&job.state, state).await {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
"Error cleaning up interrupted install job {}: {error}",
|
"Error cleaning up interrupted install job {}: {error}",
|
||||||
job.id
|
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);
|
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> {
|
fn display_from_request(state: &InstallJobState) -> Option<InstallJobDisplay> {
|
||||||
match &state.request {
|
match &state.request {
|
||||||
InstallRequest::CreateInstance { name, icon_path, .. } => {
|
InstallRequest::CreateInstance { name, icon_path, .. } => {
|
||||||
Some(InstallJobDisplay {
|
Some(InstallJobDisplay {
|
||||||
title: name.clone(),
|
title: name.clone(),
|
||||||
icon: icon_path.clone(),
|
icon: icon_path.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
InstallRequest::CreateModpackInstance { location, .. } => match location {
|
InstallRequest::CreateModpackInstance { location, .. } => match location {
|
||||||
crate::api::pack::install_from::CreatePackLocation::FromVersionId {
|
crate::api::pack::install_from::CreatePackLocation::FromVersionId {
|
||||||
title,
|
title,
|
||||||
icon_url,
|
icon_url,
|
||||||
..
|
..
|
||||||
} => Some(InstallJobDisplay {
|
} => Some(InstallJobDisplay {
|
||||||
title: title.clone(),
|
title: title.clone(),
|
||||||
icon: icon_url.clone(),
|
icon: icon_url.clone(),
|
||||||
}),
|
}),
|
||||||
crate::api::pack::install_from::CreatePackLocation::FromFile { .. } => None,
|
crate::api::pack::install_from::CreatePackLocation::FromFile {
|
||||||
},
|
..
|
||||||
InstallRequest::ImportInstance {
|
} => None,
|
||||||
instance_folder, ..
|
},
|
||||||
} => Some(InstallJobDisplay {
|
InstallRequest::ImportInstance {
|
||||||
title: instance_folder.clone(),
|
instance_folder, ..
|
||||||
icon: None,
|
} => Some(InstallJobDisplay {
|
||||||
}),
|
title: instance_folder.clone(),
|
||||||
InstallRequest::DuplicateInstance { .. }
|
icon: None,
|
||||||
| InstallRequest::InstallExistingInstance { .. }
|
}),
|
||||||
| InstallRequest::InstallPackToExistingInstance { .. } => {
|
InstallRequest::DuplicateInstance { .. }
|
||||||
state.rollback.as_ref().map(|rollback| InstallJobDisplay {
|
| InstallRequest::InstallExistingInstance { .. }
|
||||||
title: rollback.instance.instance.name.clone(),
|
| InstallRequest::InstallPackToExistingInstance { .. } => {
|
||||||
icon: rollback.instance.instance.icon_path.clone(),
|
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(
|
pub async fn apply_cleanup(
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use super::events::{InstallProgressReporter, emit_install_job};
|
use super::events::{InstallProgressReporter, emit_install_job};
|
||||||
use super::model::{
|
use super::model::{
|
||||||
InstallCleanup, InstallErrorView, InstallJobDisplay, InstallJobSnapshot,
|
InstallCleanup, InstallErrorContext, InstallErrorView, InstallJobDisplay,
|
||||||
InstallJobState, InstallJobStatus, InstallPhaseDetails, InstallPhaseId,
|
InstallJobEventKind, InstallJobSnapshot, InstallJobState, InstallJobStatus,
|
||||||
InstallPostInstallEdit, InstallRequest, InstallRollbackState,
|
InstallPhaseDetails, InstallPhaseId, InstallPostInstallEdit,
|
||||||
InstallTarget,
|
InstallRequest, InstallRollbackState, InstallTarget,
|
||||||
};
|
};
|
||||||
use super::{recovery, store};
|
use super::{diagnostics, recovery, store};
|
||||||
use crate::ErrorKind;
|
use crate::ErrorKind;
|
||||||
use crate::api::pack::install_from::{
|
use crate::api::pack::install_from::{
|
||||||
CreatePackLocation, generate_pack_from_file,
|
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())
|
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> {
|
pub async fn retry_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
|
||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
let mut job = store::get_required(job_id, &state).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.cleanup = job.state.request.cleanup();
|
||||||
job.state.rollback = None;
|
job.state.rollback = None;
|
||||||
job.state.error = None;
|
job.state.error = None;
|
||||||
|
job.state.rollback_error = None;
|
||||||
|
job.state.context = None;
|
||||||
job.state.progress.phase = InstallPhaseId::PreparingInstance;
|
job.state.progress.phase = InstallPhaseId::PreparingInstance;
|
||||||
job.state.progress.progress = None;
|
job.state.progress.progress = None;
|
||||||
job.state.progress.details = InstallPhaseDetails::Empty;
|
job.state.progress.details = InstallPhaseDetails::Empty;
|
||||||
prepare_initial_instance(&mut job.state, &state).await?;
|
prepare_initial_instance(&mut job.state, &state).await?;
|
||||||
|
job.state.record_event(InstallJobEventKind::JobQueued {
|
||||||
|
kind: job.state.request.kind(),
|
||||||
|
});
|
||||||
|
|
||||||
let record = store::update_status(
|
let record = store::update_status(
|
||||||
job_id,
|
job_id,
|
||||||
@@ -156,11 +167,35 @@ pub async fn cancel_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
job.state.error = Some(InstallErrorView {
|
let canceled_phase = job.state.progress.phase;
|
||||||
code: "canceled".to_string(),
|
job.state.error = Some(InstallErrorView::from_message(
|
||||||
message: "Install was canceled".to_string(),
|
"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);
|
clear_deleted_new_instance_id(&mut job.state);
|
||||||
let record = store::update_status(
|
let record = store::update_status(
|
||||||
job_id,
|
job_id,
|
||||||
@@ -328,13 +363,21 @@ fn spawn_job(job_id: Uuid) {
|
|||||||
|
|
||||||
async fn run_job(job_id: Uuid) -> crate::Result<()> {
|
async fn run_job(job_id: Uuid) -> crate::Result<()> {
|
||||||
let state = State::get().await?;
|
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 {
|
if job.status != InstallJobStatus::Queued {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut job_state = job.state.clone();
|
let mut job_state = job.state.clone();
|
||||||
|
job_state.record_event(InstallJobEventKind::JobStarted);
|
||||||
let record = store::update_status(
|
let record = store::update_status(
|
||||||
job_id,
|
job_id,
|
||||||
InstallJobStatus::Running,
|
InstallJobStatus::Running,
|
||||||
@@ -345,16 +388,24 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
|
|||||||
emit_install_job(&record.snapshot()).await?;
|
emit_install_job(&record.snapshot()).await?;
|
||||||
|
|
||||||
let result = run_request(job_id, &mut job_state, &state).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 {
|
match result {
|
||||||
Ok(instance_id) => {
|
Ok(instance_id) => {
|
||||||
if let Some(instance_id) = instance_id {
|
if let Some(instance_id) = instance_id {
|
||||||
set_instance_id(&mut job_state, 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.phase = InstallPhaseId::Finalizing;
|
||||||
job_state.progress.progress = None;
|
job_state.progress.progress = None;
|
||||||
job_state.progress.details = InstallPhaseDetails::Empty;
|
job_state.progress.details = InstallPhaseDetails::Empty;
|
||||||
job_state.error = None;
|
job_state.error = None;
|
||||||
|
job_state.rollback_error = None;
|
||||||
|
job_state.context = None;
|
||||||
let record = store::update_status(
|
let record = store::update_status(
|
||||||
job_id,
|
job_id,
|
||||||
InstallJobStatus::Succeeded,
|
InstallJobStatus::Succeeded,
|
||||||
@@ -365,11 +416,41 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
|
|||||||
emit_install_job(&record.snapshot()).await?;
|
emit_install_job(&record.snapshot()).await?;
|
||||||
}
|
}
|
||||||
Err(error) => {
|
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.phase = InstallPhaseId::RollingBack;
|
||||||
job_state.progress.progress = None;
|
job_state.progress.progress = None;
|
||||||
job_state.progress.details = InstallPhaseDetails::Empty;
|
job_state.progress.details = InstallPhaseDetails::Empty;
|
||||||
job_state.error = Some(install_error_view(&error));
|
job_state.record_event(InstallJobEventKind::RollbackStarted {
|
||||||
recovery::apply_cleanup(&job_state, &state).await?;
|
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);
|
clear_deleted_new_instance_id(&mut job_state);
|
||||||
let record = store::update_status(
|
let record = store::update_status(
|
||||||
job_id,
|
job_id,
|
||||||
@@ -812,6 +893,14 @@ async fn install_pack(
|
|||||||
title,
|
title,
|
||||||
icon_url,
|
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(
|
generate_pack_from_version_id_with_reporter(
|
||||||
project_id,
|
project_id,
|
||||||
version_id,
|
version_id,
|
||||||
@@ -824,6 +913,13 @@ async fn install_pack(
|
|||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
CreatePackLocation::FromFile { path } => {
|
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?
|
generate_pack_from_file(path, instance_id.clone()).await?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -887,9 +983,7 @@ async fn update_progress(
|
|||||||
phase: InstallPhaseId,
|
phase: InstallPhaseId,
|
||||||
details: InstallPhaseDetails,
|
details: InstallPhaseDetails,
|
||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
job_state.progress.phase = phase;
|
job_state.set_progress(phase, None, details);
|
||||||
job_state.progress.progress = None;
|
|
||||||
job_state.progress.details = details;
|
|
||||||
let record = store::update_state(job_id, job_state, state).await?;
|
let record = store::update_state(job_id, job_state, state).await?;
|
||||||
emit_install_job(&record.snapshot()).await?;
|
emit_install_job(&record.snapshot()).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -934,19 +1028,86 @@ fn set_display(
|
|||||||
job_state.display = Some(InstallJobDisplay { title, icon });
|
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() {
|
match error.raw.as_ref() {
|
||||||
ErrorKind::FetchError(_)
|
ErrorKind::InputError(_) => match phase {
|
||||||
| ErrorKind::ApiIsDownError(_)
|
PreparingInstance | Finalizing => "instance_error",
|
||||||
| ErrorKind::WSError(_)
|
ResolvingPack | DownloadingPackFile | ReadingPackManifest => {
|
||||||
| ErrorKind::WSClosedError(_) => InstallErrorView {
|
"pack_error"
|
||||||
code: "network_error".to_string(),
|
}
|
||||||
message: "network_error".to_string(),
|
DownloadingContent => "content_error",
|
||||||
|
ExtractingOverrides => "path_error",
|
||||||
|
PreparingJava => "java_error",
|
||||||
|
DownloadingMinecraft => "instance_error",
|
||||||
|
RollingBack => "rollback_error",
|
||||||
|
ResolvingMinecraft | ResolvingLoader | RunningLoaderProcessors => {
|
||||||
|
"launcher_error"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
_ => InstallErrorView {
|
ErrorKind::LauncherError(_) => match phase {
|
||||||
code: "unknown_error".to_string(),
|
RunningLoaderProcessors => "processor_error",
|
||||||
message: "unknown_error".to_string(),
|
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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ impl InstallJobRecord {
|
|||||||
details: self.state.progress.details.clone(),
|
details: self.state.progress.details.clone(),
|
||||||
display: self.state.display.clone(),
|
display: self.state.display.clone(),
|
||||||
error: self.state.error.clone(),
|
error: self.state.error.clone(),
|
||||||
|
rollback_error: self.state.rollback_error.clone(),
|
||||||
created: self.created,
|
created: self.created,
|
||||||
modified: self.modified,
|
modified: self.modified,
|
||||||
finished: self.finished,
|
finished: self.finished,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Downloader for Minecraft data
|
//! Downloader for Minecraft data
|
||||||
|
|
||||||
use crate::install::{
|
use crate::install::{
|
||||||
InstallPhaseDetails, InstallPhaseId, InstallProgress,
|
InstallErrorContext, InstallPhaseDetails, InstallPhaseId, InstallProgress,
|
||||||
InstallProgressReporter,
|
InstallProgressReporter,
|
||||||
};
|
};
|
||||||
use crate::instance::QuickPlayType;
|
use crate::instance::QuickPlayType;
|
||||||
@@ -128,6 +128,17 @@ impl MinecraftDownloadProgress {
|
|||||||
)
|
)
|
||||||
.await
|
.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(
|
async fn fetch_minecraft_file(
|
||||||
@@ -136,7 +147,16 @@ async fn fetch_minecraft_file(
|
|||||||
sha1: Option<&str>,
|
sha1: Option<&str>,
|
||||||
expected_size: Option<u64>,
|
expected_size: Option<u64>,
|
||||||
progress: Option<MinecraftDownloadProgress>,
|
progress: Option<MinecraftDownloadProgress>,
|
||||||
|
context: InstallErrorContext,
|
||||||
) -> crate::Result<bytes::Bytes> {
|
) -> 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 {
|
let Some(progress) = progress else {
|
||||||
return fetch(url, sha1, None, None, &st.fetch_semaphore, &st.pool)
|
return fetch(url, sha1, None, None, &st.fetch_semaphore, &st.pool)
|
||||||
.await;
|
.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,
|
Method::GET,
|
||||||
url,
|
url,
|
||||||
sha1,
|
sha1,
|
||||||
@@ -170,7 +190,14 @@ async fn fetch_minecraft_file(
|
|||||||
&st.pool,
|
&st.pool,
|
||||||
Some(&mut progress_fn as &mut FetchProgressFn<'_>),
|
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 {
|
if let Some(expected_size) = expected_size {
|
||||||
let downloaded = last_downloaded.load(Ordering::Relaxed);
|
let downloaded = last_downloaded.load(Ordering::Relaxed);
|
||||||
@@ -432,6 +459,7 @@ pub async fn download_version_info(
|
|||||||
loader: Option<&LoaderVersion>,
|
loader: Option<&LoaderVersion>,
|
||||||
force: Option<bool>,
|
force: Option<bool>,
|
||||||
loading_bar: Option<&LoadingBarId>,
|
loading_bar: Option<&LoadingBarId>,
|
||||||
|
reporter: Option<&InstallProgressReporter>,
|
||||||
) -> crate::Result<GameVersionInfo> {
|
) -> crate::Result<GameVersionInfo> {
|
||||||
let version_id = loader
|
let version_id = loader
|
||||||
.map_or(version.id.clone(), |it| format!("{}-{}", version.id, it.id));
|
.map_or(version.id.clone(), |it| format!("{}-{}", version.id, it.id));
|
||||||
@@ -452,6 +480,19 @@ pub async fn download_version_info(
|
|||||||
&version.id,
|
&version.id,
|
||||||
version.url
|
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(
|
let mut info = fetch_json(
|
||||||
Method::GET,
|
Method::GET,
|
||||||
&version.url,
|
&version.url,
|
||||||
@@ -464,6 +505,19 @@ pub async fn download_version_info(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some(loader) = loader {
|
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(
|
let partial: d::modded::PartialVersionInfo = fetch_json(
|
||||||
Method::GET,
|
Method::GET,
|
||||||
&loader.url,
|
&loader.url,
|
||||||
@@ -523,6 +577,11 @@ pub async fn download_client(
|
|||||||
Some(&client_download.sha1),
|
Some(&client_download.sha1),
|
||||||
Some(client_download.size as u64),
|
Some(client_download.size as u64),
|
||||||
progress,
|
progress,
|
||||||
|
InstallErrorContext::new("download Minecraft client")
|
||||||
|
.minecraft_version(version.to_string())
|
||||||
|
.file_path(format!("{version}.jar"))
|
||||||
|
.target_path(path.display().to_string())
|
||||||
|
.build(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
write(&path, &bytes, &st.io_semaphore).await?;
|
write(&path, &bytes, &st.io_semaphore).await?;
|
||||||
@@ -563,6 +622,11 @@ pub async fn download_assets_index(
|
|||||||
None,
|
None,
|
||||||
Some(version.asset_index.size as u64),
|
Some(version.asset_index.size as u64),
|
||||||
progress,
|
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?;
|
.await?;
|
||||||
let index = serde_json::from_slice(&index)?;
|
let index = serde_json::from_slice(&index)?;
|
||||||
@@ -632,6 +696,10 @@ pub async fn download_assets(
|
|||||||
Some(hash),
|
Some(hash),
|
||||||
Some(asset.size as u64),
|
Some(asset.size as u64),
|
||||||
fetch_progress.clone(),
|
fetch_progress.clone(),
|
||||||
|
InstallErrorContext::new("download Minecraft asset")
|
||||||
|
.file_path(name.clone())
|
||||||
|
.target_path(resource_path.display().to_string())
|
||||||
|
.build(),
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
write(&resource_path, resource, &st.io_semaphore).await?;
|
write(&resource_path, resource, &st.io_semaphore).await?;
|
||||||
@@ -648,6 +716,10 @@ pub async fn download_assets(
|
|||||||
Some(hash),
|
Some(hash),
|
||||||
Some(asset.size as u64),
|
Some(asset.size as u64),
|
||||||
fetch_progress.clone(),
|
fetch_progress.clone(),
|
||||||
|
InstallErrorContext::new("download Minecraft asset")
|
||||||
|
.file_path(name.clone())
|
||||||
|
.target_path(legacy_resource_path.display().to_string())
|
||||||
|
.build(),
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
write(&legacy_resource_path, resource, &st.io_semaphore).await?;
|
write(&legacy_resource_path, resource, &st.io_semaphore).await?;
|
||||||
@@ -729,6 +801,16 @@ pub async fn download_libraries(
|
|||||||
Some(&native.sha1),
|
Some(&native.sha1),
|
||||||
Some(native.size as u64),
|
Some(native.size as u64),
|
||||||
progress.clone(),
|
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?;
|
.await?;
|
||||||
|
|
||||||
@@ -774,6 +856,11 @@ pub async fn download_libraries(
|
|||||||
Some(&artifact.sha1),
|
Some(&artifact.sha1),
|
||||||
Some(artifact.size as u64),
|
Some(artifact.size as u64),
|
||||||
progress.clone(),
|
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?;
|
.await?;
|
||||||
write(&path, &bytes, &st.io_semaphore).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.sha1),
|
||||||
Some(log_download.size as u64),
|
Some(log_download.size as u64),
|
||||||
progress,
|
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?;
|
.await?;
|
||||||
write(&path, &bytes, &st.io_semaphore).await?;
|
write(&path, &bytes, &st.io_semaphore).await?;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use crate::{State, get_resource_file, process};
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use daedalus as d;
|
use daedalus as d;
|
||||||
use daedalus::minecraft::{LoggingSide, RuleAction, VersionInfo};
|
use daedalus::minecraft::{LoggingSide, RuleAction, VersionInfo};
|
||||||
use daedalus::modded::LoaderVersion;
|
use daedalus::modded::{LoaderVersion, Manifest};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
@@ -175,19 +175,18 @@ pub async fn get_loader_version_from_profile(
|
|||||||
let versions =
|
let versions =
|
||||||
crate::api::metadata::get_loader_versions(loader.as_meta_str()).await?;
|
crate::api::metadata::get_loader_versions(loader.as_meta_str()).await?;
|
||||||
|
|
||||||
let loaders = versions.game_versions.into_iter().find(|x| {
|
if let Some(loaders) =
|
||||||
x.id.replace(daedalus::modded::DUMMY_REPLACE_STRING, game_version)
|
loader_versions_for_game_version(&versions, game_version)
|
||||||
== game_version
|
{
|
||||||
});
|
let loader_version =
|
||||||
|
loaders
|
||||||
if let Some(loaders) = loaders {
|
.iter()
|
||||||
let loader_version = loaders.loaders.iter().find(|x| filter(x)).or(
|
.find(|x| filter(x))
|
||||||
if version == "stable" {
|
.or(if version == "stable" {
|
||||||
loaders.loaders.first()
|
loaders.first()
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
Ok(loader_version.cloned())
|
Ok(loader_version.cloned())
|
||||||
} else {
|
} else {
|
||||||
@@ -195,6 +194,26 @@ pub async fn get_loader_version_from_profile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn loader_versions_for_game_version<'a>(
|
||||||
|
manifest: &'a Manifest,
|
||||||
|
game_version: &str,
|
||||||
|
) -> Option<&'a [LoaderVersion]> {
|
||||||
|
let version = manifest.game_versions.iter().find(|x| {
|
||||||
|
x.id.replace(daedalus::modded::DUMMY_REPLACE_STRING, game_version)
|
||||||
|
== game_version
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(version_group) = &version.version_group {
|
||||||
|
manifest
|
||||||
|
.version_groups
|
||||||
|
.iter()
|
||||||
|
.find(|group| group.id == *version_group)
|
||||||
|
.map(|group| group.loaders.as_slice())
|
||||||
|
} else {
|
||||||
|
Some(version.loaders.as_slice())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolves the Minecraft version manifest and finds the index for the given
|
/// Resolves the Minecraft version manifest and finds the index for the given
|
||||||
/// game version. If the version isn't found in the cache, forces a manifest
|
/// game version. If the version isn't found in the cache, forces a manifest
|
||||||
/// refresh to pick up newly-released versions.
|
/// refresh to pick up newly-released versions.
|
||||||
@@ -348,6 +367,7 @@ pub async fn install_minecraft_with_reporter(
|
|||||||
loader_version.as_ref(),
|
loader_version.as_ref(),
|
||||||
Some(repairing),
|
Some(repairing),
|
||||||
loading_bar.as_ref(),
|
loading_bar.as_ref(),
|
||||||
|
reporter.as_ref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -748,6 +768,7 @@ pub async fn launch_minecraft(
|
|||||||
loader_version.as_ref(),
|
loader_version.as_ref(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if version_info.logging.is_none() {
|
if version_info.logging.is_none() {
|
||||||
@@ -764,6 +785,7 @@ pub async fn launch_minecraft(
|
|||||||
loader_version.as_ref(),
|
loader_version.as_ref(),
|
||||||
Some(true),
|
Some(true),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1396,19 +1396,25 @@ impl CachedEntry {
|
|||||||
let fetch_urls = keys
|
let fetch_urls = keys
|
||||||
.iter()
|
.iter()
|
||||||
.map(|x| {
|
.map(|x| {
|
||||||
|
let metadata =
|
||||||
|
daedalus::modded::loader_manifest_metadata_from_cache_key(
|
||||||
|
&x.key().to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
(
|
(
|
||||||
x.key().to_string(),
|
metadata.cache_key,
|
||||||
|
metadata.loader,
|
||||||
format!(
|
format!(
|
||||||
"{}{}/v0/manifest.json",
|
"{}{}",
|
||||||
env!("MODRINTH_LAUNCHER_META_URL"),
|
env!("MODRINTH_LAUNCHER_META_URL"),
|
||||||
x.key()
|
metadata.path,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
futures::future::try_join_all(fetch_urls.iter().map(
|
futures::future::try_join_all(fetch_urls.iter().map(
|
||||||
|(_, url)| {
|
|(_, _, url)| {
|
||||||
fetch_json(
|
fetch_json(
|
||||||
Method::GET,
|
Method::GET,
|
||||||
url,
|
url,
|
||||||
@@ -1424,14 +1430,15 @@ impl CachedEntry {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(index, metadata)| {
|
.map(|(index, metadata)| {
|
||||||
(
|
let mut entry =
|
||||||
CacheValue::LoaderManifest(CachedLoaderManifest {
|
CacheValue::LoaderManifest(CachedLoaderManifest {
|
||||||
loader: fetch_urls[index].0.to_string(),
|
loader: fetch_urls[index].1.to_string(),
|
||||||
manifest: metadata,
|
manifest: metadata,
|
||||||
})
|
})
|
||||||
.get_entry(),
|
.get_entry();
|
||||||
true,
|
entry.id.clone_from(&fetch_urls[index].0);
|
||||||
)
|
|
||||||
|
(entry, true)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ pub mod server_join_log;
|
|||||||
// Global state
|
// Global state
|
||||||
// RwLock on state only has concurrent reads, except for config dir change which takes control of the 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();
|
static LAUNCHER_STATE: OnceCell<Arc<State>> = OnceCell::const_new();
|
||||||
|
const MAX_CONCURRENT_INSTALL_JOBS: usize = 3;
|
||||||
pub struct State {
|
pub struct State {
|
||||||
/// Information on the location of files used in the launcher
|
/// Information on the location of files used in the launcher
|
||||||
pub directories: DirectoryInfo,
|
pub directories: DirectoryInfo,
|
||||||
@@ -68,6 +69,8 @@ pub struct State {
|
|||||||
/// Semaphore to limit concurrent API requests. This is separate from the fetch semaphore
|
/// Semaphore to limit concurrent API requests. This is separate from the fetch semaphore
|
||||||
/// to keep API functionality while the app is performing intensive tasks.
|
/// to keep API functionality while the app is performing intensive tasks.
|
||||||
pub api_semaphore: FetchSemaphore,
|
pub api_semaphore: FetchSemaphore,
|
||||||
|
pub(crate) install_job_semaphore: Semaphore,
|
||||||
|
pub(crate) install_db_semaphore: Semaphore,
|
||||||
|
|
||||||
/// Discord RPC
|
/// Discord RPC
|
||||||
pub discord_rpc: DiscordGuard,
|
pub discord_rpc: DiscordGuard,
|
||||||
@@ -205,6 +208,8 @@ impl State {
|
|||||||
fetch_semaphore,
|
fetch_semaphore,
|
||||||
io_semaphore,
|
io_semaphore,
|
||||||
api_semaphore,
|
api_semaphore,
|
||||||
|
install_job_semaphore: Semaphore::new(MAX_CONCURRENT_INSTALL_JOBS),
|
||||||
|
install_db_semaphore: Semaphore::new(1),
|
||||||
discord_rpc,
|
discord_rpc,
|
||||||
process_manager,
|
process_manager,
|
||||||
friends_socket,
|
friends_socket,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Functions for fetching information from the Internet
|
//! Functions for fetching information from the Internet
|
||||||
use super::io::{self, IOError};
|
use super::io::{self, IOError};
|
||||||
use crate::ErrorKind;
|
|
||||||
use crate::event::LoadingBarId;
|
use crate::event::LoadingBarId;
|
||||||
use crate::event::emit::emit_loading;
|
use crate::event::emit::emit_loading;
|
||||||
|
use crate::{ErrorKind, LabrinthError};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use chrono::{DateTime, TimeDelta, Utc};
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
use eyre::{Context, eyre};
|
use eyre::{Context, eyre};
|
||||||
@@ -190,6 +190,8 @@ static GLOBAL_FETCH_FENCE: LazyLock<FetchFence> =
|
|||||||
|
|
||||||
fn reqwest_client_builder() -> reqwest::ClientBuilder {
|
fn reqwest_client_builder() -> reqwest::ClientBuilder {
|
||||||
reqwest::Client::builder()
|
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)))
|
.tcp_keepalive(Some(time::Duration::from_secs(10)))
|
||||||
.user_agent(crate::launcher_user_agent())
|
.user_agent(crate::launcher_user_agent())
|
||||||
}
|
}
|
||||||
@@ -267,6 +269,34 @@ pub async fn fetch_with_client(
|
|||||||
.await
|
.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))]
|
#[tracing::instrument(skip(json_body, semaphore))]
|
||||||
pub async fn fetch_json<T>(
|
pub async fn fetch_json<T>(
|
||||||
method: Method,
|
method: Method,
|
||||||
@@ -466,8 +496,13 @@ async fn fetch_advanced_with_client_and_progress(
|
|||||||
if resp.status().is_client_error()
|
if resp.status().is_client_error()
|
||||||
|| resp.status().is_server_error()
|
|| resp.status().is_server_error()
|
||||||
{
|
{
|
||||||
|
let status = resp.status();
|
||||||
let backup_error = resp.error_for_status_ref().unwrap_err();
|
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(ErrorKind::LabrinthError(error).into());
|
||||||
}
|
}
|
||||||
return Err(backup_error.into());
|
return Err(backup_error.into());
|
||||||
@@ -599,6 +634,43 @@ pub async fn fetch_mirrors(
|
|||||||
unreachable!()
|
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
|
/// Posts a JSON to a URL
|
||||||
#[tracing::instrument(skip(json_body, semaphore))]
|
#[tracing::instrument(skip(json_body, semaphore))]
|
||||||
pub async fn post_json(
|
pub async fn post_json(
|
||||||
|
|||||||
Vendored
+7
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.4 KiB |
@@ -64,6 +64,7 @@ import _WindowsIcon from './external/windows.svg?component'
|
|||||||
import _YouTubeIcon from './external/youtube.svg?component'
|
import _YouTubeIcon from './external/youtube.svg?component'
|
||||||
import _YouTubeGaming from './external/youtubegaming.svg?component'
|
import _YouTubeGaming from './external/youtubegaming.svg?component'
|
||||||
import _YouTubeShortsIcon from './external/youtubeshorts.svg?component'
|
import _YouTubeShortsIcon from './external/youtubeshorts.svg?component'
|
||||||
|
import _LinuxIcon from './external/linux.svg?component'
|
||||||
// Tag icon helpers - import maps from generated-icons
|
// Tag icon helpers - import maps from generated-icons
|
||||||
import type { IconComponent } from './generated-icons'
|
import type { IconComponent } from './generated-icons'
|
||||||
import { categoryIconMap, loaderIconMap } from './generated-icons'
|
import { categoryIconMap, loaderIconMap } from './generated-icons'
|
||||||
@@ -135,6 +136,7 @@ export const USDCColorIcon = _USDCColorIcon
|
|||||||
export const VisaIcon = _VisaIcon
|
export const VisaIcon = _VisaIcon
|
||||||
export const IntercomBubbleIcon = _IntercomBubbleIcon
|
export const IntercomBubbleIcon = _IntercomBubbleIcon
|
||||||
export const MinecraftServerIcon = _MinecraftServerIcon
|
export const MinecraftServerIcon = _MinecraftServerIcon
|
||||||
|
export const LinuxIcon = _LinuxIcon
|
||||||
|
|
||||||
export * from './generated-icons'
|
export * from './generated-icons'
|
||||||
export { default as ClassicPlayerModel } from './models/classic-player.gltf?url'
|
export { default as ClassicPlayerModel } from './models/classic-player.gltf?url'
|
||||||
|
|||||||
+22
-7
@@ -370,11 +370,13 @@ const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
|
|||||||
const manifest = ctx.loaderVersionsCache.value[apiLoader]
|
const manifest = ctx.loaderVersionsCache.value[apiLoader]
|
||||||
if (!manifest) return []
|
if (!manifest) return []
|
||||||
|
|
||||||
const hasPlaceholder = manifest.some((x) => x.id === '${modrinth.gameVersion}')
|
const hasPlaceholder = manifest.gameVersions.some((x) => x.id === '${modrinth.gameVersion}')
|
||||||
const supportedVersions = new Set(
|
const supportedVersions = new Set(
|
||||||
manifest
|
manifest.gameVersions
|
||||||
.filter(
|
.filter(
|
||||||
(x) => x.id !== '${modrinth.gameVersion}' && (hasPlaceholder || x.loaders.length > 0),
|
(x) =>
|
||||||
|
x.id !== '${modrinth.gameVersion}' &&
|
||||||
|
(hasPlaceholder || x.loaders.length > 0 || !!x.versionGroup),
|
||||||
)
|
)
|
||||||
.map((x) => x.id),
|
.map((x) => x.id),
|
||||||
)
|
)
|
||||||
@@ -466,14 +468,14 @@ function getLoaderVersionsForGameVersion(
|
|||||||
apiLoader,
|
apiLoader,
|
||||||
gameVersion,
|
gameVersion,
|
||||||
hasManifest: !!manifest,
|
hasManifest: !!manifest,
|
||||||
manifestLength: manifest?.length,
|
manifestLength: manifest?.gameVersions.length,
|
||||||
})
|
})
|
||||||
if (!manifest) return []
|
if (!manifest) return []
|
||||||
|
|
||||||
// Some loaders (e.g. Fabric) list all versions under a placeholder entry
|
// Some loaders (e.g. Fabric) list all versions under a placeholder entry
|
||||||
const placeholder = manifest.find((x) => x.id === '${modrinth.gameVersion}')
|
const placeholder = manifest.gameVersions.find((x) => x.id === '${modrinth.gameVersion}')
|
||||||
if (placeholder) {
|
if (placeholder) {
|
||||||
if (!manifest.some((x) => x.id === gameVersion)) return []
|
if (!manifest.gameVersions.some((x) => x.id === gameVersion)) return []
|
||||||
debug(
|
debug(
|
||||||
'getLoaderVersionsForGameVersion: using placeholder, loaders:',
|
'getLoaderVersionsForGameVersion: using placeholder, loaders:',
|
||||||
placeholder.loaders.length,
|
placeholder.loaders.length,
|
||||||
@@ -481,7 +483,20 @@ function getLoaderVersionsForGameVersion(
|
|||||||
return placeholder.loaders
|
return placeholder.loaders
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = manifest.find((x) => x.id === gameVersion)
|
const entry = manifest.gameVersions.find((x) => x.id === gameVersion)
|
||||||
|
if (entry?.versionGroup) {
|
||||||
|
const loaders =
|
||||||
|
manifest.versionGroups?.find((group) => group.id === entry.versionGroup)?.loaders ?? []
|
||||||
|
debug(
|
||||||
|
'getLoaderVersionsForGameVersion: version group for',
|
||||||
|
gameVersion,
|
||||||
|
':',
|
||||||
|
entry.versionGroup,
|
||||||
|
loaders.length + ' loaders',
|
||||||
|
)
|
||||||
|
return loaders
|
||||||
|
}
|
||||||
|
|
||||||
debug(
|
debug(
|
||||||
'getLoaderVersionsForGameVersion: entry for',
|
'getLoaderVersionsForGameVersion: entry for',
|
||||||
gameVersion,
|
gameVersion,
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export type Gamemode = 'survival' | 'creative' | 'hardcore'
|
|||||||
export type Difficulty = 'peaceful' | 'easy' | 'normal' | 'hard'
|
export type Difficulty = 'peaceful' | 'easy' | 'normal' | 'hard'
|
||||||
export type LoaderVersionType = 'stable' | 'latest' | 'other'
|
export type LoaderVersionType = 'stable' | 'latest' | 'other'
|
||||||
export type GeneratorSettingsMode = 'default' | 'flat' | 'custom'
|
export type GeneratorSettingsMode = 'default' | 'flat' | 'custom'
|
||||||
export type LoaderManifestResolver = (loader: string) => Promise<LauncherMeta.Manifest.v0.Manifest>
|
export type LoaderManifest = LauncherMeta.Manifest.v0.Manifest
|
||||||
|
export type LoaderManifestResolver = (loader: string) => Promise<LoaderManifest>
|
||||||
export interface LoaderVersionEntry {
|
export interface LoaderVersionEntry {
|
||||||
id: string
|
id: string
|
||||||
stable: boolean
|
stable: boolean
|
||||||
@@ -160,7 +161,7 @@ export interface CreationFlowContextValue {
|
|||||||
hideLoaderChips: ComputedRef<boolean>
|
hideLoaderChips: ComputedRef<boolean>
|
||||||
hideLoaderVersion: ComputedRef<boolean>
|
hideLoaderVersion: ComputedRef<boolean>
|
||||||
showSnapshots: Ref<boolean>
|
showSnapshots: Ref<boolean>
|
||||||
loaderVersionsCache: Ref<Record<string, { id: string; loaders: LoaderVersionEntry[] }[]>>
|
loaderVersionsCache: Ref<Record<string, LoaderManifest>>
|
||||||
paperSupportedVersions: Ref<Set<string> | null>
|
paperSupportedVersions: Ref<Set<string> | null>
|
||||||
purpurSupportedVersions: Ref<Set<string> | null>
|
purpurSupportedVersions: Ref<Set<string> | null>
|
||||||
|
|
||||||
@@ -295,9 +296,7 @@ export function createCreationFlowContext(
|
|||||||
const loaderVersionType = ref<LoaderVersionType>('stable')
|
const loaderVersionType = ref<LoaderVersionType>('stable')
|
||||||
const selectedLoaderVersion = ref<string | null>(null)
|
const selectedLoaderVersion = ref<string | null>(null)
|
||||||
const showSnapshots = ref(false)
|
const showSnapshots = ref(false)
|
||||||
const loaderVersionsCache = ref<Record<string, { id: string; loaders: LoaderVersionEntry[] }[]>>(
|
const loaderVersionsCache = ref<Record<string, LoaderManifest>>({})
|
||||||
{},
|
|
||||||
)
|
|
||||||
const paperSupportedVersions = ref<Set<string> | null>(null)
|
const paperSupportedVersions = ref<Set<string> | null>(null)
|
||||||
const purpurSupportedVersions = ref<Set<string> | null>(null)
|
const purpurSupportedVersions = ref<Set<string> | null>(null)
|
||||||
|
|
||||||
@@ -364,11 +363,11 @@ export function createCreationFlowContext(
|
|||||||
(await client.launchermeta.manifest_v0.getManifest(apiLoader)),
|
(await client.launchermeta.manifest_v0.getManifest(apiLoader)),
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
})
|
})
|
||||||
loaderVersionsCache.value[apiLoader] = data.gameVersions
|
loaderVersionsCache.value[apiLoader] = data
|
||||||
debug('fetchLoaderManifest: loaded', apiLoader, 'gameVersions:', data.gameVersions.length)
|
debug('fetchLoaderManifest: loaded', apiLoader, 'gameVersions:', data.gameVersions.length)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug('fetchLoaderManifest: failed', apiLoader, error)
|
debug('fetchLoaderManifest: failed', apiLoader, error)
|
||||||
loaderVersionsCache.value[apiLoader] = []
|
loaderVersionsCache.value[apiLoader] = { gameVersions: [] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,10 +50,9 @@
|
|||||||
:progress-type="progressItem.progressType"
|
:progress-type="progressItem.progressType"
|
||||||
:progress-current="progressItem.progressCurrent"
|
:progress-current="progressItem.progressCurrent"
|
||||||
:progress-total="progressItem.progressTotal"
|
:progress-total="progressItem.progressTotal"
|
||||||
:action-label="progressItem.buttons?.[0]?.label"
|
:actions="progressItem.buttons"
|
||||||
:action-icon="progressItem.buttons?.[0]?.icon"
|
|
||||||
@dismiss="handleProgressItemDismiss(item, progressItem)"
|
@dismiss="handleProgressItemDismiss(item, progressItem)"
|
||||||
@action="handleProgressItemAction(progressItem)"
|
@action="(index) => handleProgressItemAction(progressItem, index)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,8 +232,11 @@ async function handleProgressItemDismiss(
|
|||||||
dismiss(item.id)
|
dismiss(item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleProgressItemAction(progressItem: PopupNotificationProgressItem) {
|
async function handleProgressItemAction(
|
||||||
const button = progressItem.buttons?.[0]
|
progressItem: PopupNotificationProgressItem,
|
||||||
|
index: number,
|
||||||
|
) {
|
||||||
|
const button = progressItem.buttons?.[index]
|
||||||
if (button) {
|
if (button) {
|
||||||
await handleProgressItemButtonClick(progressItem, button)
|
await handleProgressItemButtonClick(progressItem, button)
|
||||||
}
|
}
|
||||||
@@ -250,8 +252,8 @@ async function handleProgressItemButtonClick(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleButtonClick(id: string | number, btn: PopupNotificationButton) {
|
async function handleButtonClick(id: string | number, btn: PopupNotificationButton) {
|
||||||
btn.action()
|
await btn.action()
|
||||||
if (!btn.keepOpen) {
|
if (!btn.keepOpen) {
|
||||||
popupNotificationManager.removeNotification(id)
|
popupNotificationManager.removeNotification(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,13 +138,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="type === 'instance-download' && actionLabel"
|
v-if="type === 'instance-download' && actions?.length"
|
||||||
class="col-start-1 row-start-3 mt-2 flex min-w-0 items-center gap-2"
|
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">
|
<ButtonStyled
|
||||||
<button @click="$emit('action')">
|
v-for="(action, index) in actions"
|
||||||
<component :is="actionIcon" v-if="actionIcon" />
|
:key="index"
|
||||||
{{ actionLabel }}
|
: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>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,10 +176,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { XIcon } from '@modrinth/assets'
|
import { XIcon } from '@modrinth/assets'
|
||||||
import { type Component, computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import { useFormatBytes, useFormatNumber } from '../../composables'
|
import { useFormatBytes, useFormatNumber } from '../../composables'
|
||||||
import type { PopupNotificationProgressType } from '../../providers'
|
import type { PopupNotificationButton, PopupNotificationProgressType } from '../../providers'
|
||||||
import { truncatedTooltip } from '../../utils/truncate'
|
import { truncatedTooltip } from '../../utils/truncate'
|
||||||
import Avatar from '../base/Avatar.vue'
|
import Avatar from '../base/Avatar.vue'
|
||||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||||
@@ -202,8 +206,7 @@ const props = withDefaults(
|
|||||||
progressType?: PopupNotificationProgressType
|
progressType?: PopupNotificationProgressType
|
||||||
progressCurrent?: number
|
progressCurrent?: number
|
||||||
progressTotal?: number
|
progressTotal?: number
|
||||||
actionLabel?: string
|
actions?: PopupNotificationButton[]
|
||||||
actionIcon?: Component
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
actorName: null,
|
actorName: null,
|
||||||
@@ -221,7 +224,7 @@ defineEmits<{
|
|||||||
accept: []
|
accept: []
|
||||||
decline: []
|
decline: []
|
||||||
dismiss: []
|
dismiss: []
|
||||||
action: []
|
action: [index: number]
|
||||||
launch: []
|
launch: []
|
||||||
'open-actor': []
|
'open-actor': []
|
||||||
'open-instance': []
|
'open-instance': []
|
||||||
|
|||||||
@@ -376,12 +376,17 @@ function getLoaderVersionsForGameVersion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const manifest = manifestQuery.data.value?.gameVersions
|
const manifest = manifestQuery.data.value?.gameVersions
|
||||||
|
const versionGroups = manifestQuery.data.value?.versionGroups
|
||||||
if (!manifest) return []
|
if (!manifest) return []
|
||||||
|
|
||||||
const placeholder = manifest.find((x) => x.id === '${modrinth.gameVersion}')
|
const placeholder = manifest.find((x) => x.id === '${modrinth.gameVersion}')
|
||||||
if (placeholder) return placeholder.loaders
|
if (placeholder) return placeholder.loaders
|
||||||
|
|
||||||
const entry = manifest.find((x) => x.id === gameVersion)
|
const entry = manifest.find((x) => x.id === gameVersion)
|
||||||
|
if (entry?.versionGroup) {
|
||||||
|
return versionGroups?.find((group) => group.id === entry.versionGroup)?.loaders ?? []
|
||||||
|
}
|
||||||
|
|
||||||
return entry?.loaders ?? []
|
return entry?.loaders ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,7 +510,7 @@ provideInstallationSettings({
|
|||||||
const hasPlaceholder = manifest.some((x) => x.id === '${modrinth.gameVersion}')
|
const hasPlaceholder = manifest.some((x) => x.id === '${modrinth.gameVersion}')
|
||||||
if (!hasPlaceholder) {
|
if (!hasPlaceholder) {
|
||||||
const supportedVersions = new Set(
|
const supportedVersions = new Set(
|
||||||
manifest.filter((x) => x.loaders.length > 0).map((x) => x.id),
|
manifest.filter((x) => x.loaders.length > 0 || !!x.versionGroup).map((x) => x.id),
|
||||||
)
|
)
|
||||||
return versions
|
return versions
|
||||||
.filter((v) => supportedVersions.has(v.version))
|
.filter((v) => supportedVersions.has(v.version))
|
||||||
@@ -547,7 +552,9 @@ provideInstallationSettings({
|
|||||||
if (hasPlaceholder) {
|
if (hasPlaceholder) {
|
||||||
return tags.gameVersions.value.some((v) => v.version_type !== 'release')
|
return tags.gameVersions.value.some((v) => v.version_type !== 'release')
|
||||||
}
|
}
|
||||||
const supportedVersions = new Set(manifest.filter((x) => x.loaders.length > 0).map((x) => x.id))
|
const supportedVersions = new Set(
|
||||||
|
manifest.filter((x) => x.loaders.length > 0 || !!x.versionGroup).map((x) => x.id),
|
||||||
|
)
|
||||||
const supported = tags.gameVersions.value.filter((v) => supportedVersions.has(v.version))
|
const supported = tags.gameVersions.value.filter((v) => supportedVersions.has(v.version))
|
||||||
return supported.some((v) => v.version_type !== 'release')
|
return supported.some((v) => v.version_type !== 'release')
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createContext } from '.'
|
|||||||
|
|
||||||
export interface PopupNotificationButton {
|
export interface PopupNotificationButton {
|
||||||
label: string
|
label: string
|
||||||
action: () => void
|
action: () => void | Promise<void>
|
||||||
icon?: Component
|
icon?: Component
|
||||||
color?: 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'standard'
|
color?: 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'standard'
|
||||||
keepOpen?: boolean
|
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 type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
|
||||||
import { NotificationToast } from '../../components/notifications'
|
import { NotificationToast } from '../../components/notifications'
|
||||||
|
import type { PopupNotificationButton } from '../../providers'
|
||||||
|
|
||||||
const avatarUrl =
|
const avatarUrl =
|
||||||
'https://cdn.modrinth.com/user/6Qo4A5QT/9d81be1a9fb1afd163b7f2f05a791955e7693c90.png'
|
'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"
|
||||||
|
/>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user