Merge branch 'main' into boris/search-index-fix

This commit is contained in:
aecsocket
2026-07-10 13:16:51 +01:00
89 changed files with 4124 additions and 972 deletions
Generated
+26
View File
@@ -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",
+1
View File
@@ -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()
},
} }
} }
+36 -1
View File
@@ -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
View File
@@ -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"
}, },
+67 -13
View File
@@ -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": "開啟實例"
}, },
+1
View File
@@ -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,
+6
View File
@@ -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,
() => { () => {
+4
View File
@@ -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
@@ -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"
}
@@ -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"
}
@@ -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"
} }
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
} }
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
} }
@@ -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"
}
@@ -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,
} }
+8
View File
@@ -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(),
+6 -1
View File
@@ -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
+12 -182
View File
@@ -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")?;
+5 -11
View File
@@ -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()),
+3 -6
View File
@@ -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?;
+3 -8
View File
@@ -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': '' },
+1
View File
@@ -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?;
+70 -13
View File
@@ -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(),
+3 -1
View File
@@ -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,
+21 -1
View File
@@ -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,
+401 -126
View File
@@ -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
+8
View File
@@ -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)]
+667
View File
@@ -0,0 +1,667 @@
use super::model::{
InstallCleanup, InstallInterruptReason, InstallJobEvent,
InstallJobEventKind, InstallJobSnapshot, InstallJobState, InstallJobStatus,
InstallPhaseDetails, InstallPhaseId, InstallProgress,
};
use super::store;
use crate::state::{ModrinthCredentials, State};
use regex::{Captures, Regex};
use sqlx::Row;
use std::fmt::Write as _;
use std::io::{Read, Seek, SeekFrom};
use std::net::{Ipv4Addr, Ipv6Addr};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
const INSTALL_SUPPORT_LOG_TAIL_BYTES: u64 = 128 * 1024;
pub async fn build_job_support_details(
job: &store::InstallJobRecord,
state: &State,
) -> crate::Result<String> {
let snapshot = job.snapshot();
let mut details = String::new();
let title = snapshot
.display
.as_ref()
.map(|display| display.title.as_str())
.unwrap_or("Unknown");
let _ = writeln!(details, "Install report: {title}");
let _ =
writeln!(details, "Result: {}", result_summary(&snapshot, &job.state));
let _ = writeln!(details, "Job ID: {}", snapshot.job_id);
let _ = writeln!(details, "Request: {}", json_string(&snapshot.kind));
let _ = writeln!(details, "Status: {}", json_string(&snapshot.status));
let _ = writeln!(details, "Current phase: {}", phase_label(snapshot.phase));
if let Some(progress) = &snapshot.progress {
let _ = writeln!(
details,
"Current progress: {}",
progress_summary(progress)
);
}
write_environment_details(&mut details);
write_timeline(&mut details, &job.state.events);
write_content_summary(&mut details, &job.state.events);
write_errors(&mut details, &snapshot);
write_raw_snapshot(&mut details, &snapshot);
write_latest_log(&mut details, state).await;
censor_support_text(details, state).await
}
fn result_summary(
snapshot: &InstallJobSnapshot,
state: &InstallJobState,
) -> String {
match snapshot.status {
InstallJobStatus::Queued => "queued".to_string(),
InstallJobStatus::Running => {
format!("running while {}", phase_label(snapshot.phase))
}
InstallJobStatus::Succeeded => "succeeded".to_string(),
InstallJobStatus::Canceled => snapshot
.error
.as_ref()
.and_then(|error| error.phase)
.map(|phase| format!("canceled while {}", phase_label(phase)))
.unwrap_or_else(|| "canceled".to_string()),
InstallJobStatus::Failed => snapshot
.error
.as_ref()
.and_then(|error| {
error.phase.map(|phase| {
format!(
"failed while {} ({})",
phase_label(phase),
error.code
)
})
})
.unwrap_or_else(|| "failed".to_string()),
InstallJobStatus::Interrupted => latest_interruption(&state.events)
.map(|(reason, phase)| match reason {
InstallInterruptReason::AppClosed => format!(
"interrupted because the app closed while {}",
phase_label(phase)
),
InstallInterruptReason::Unknown => {
format!("interrupted while {}", phase_label(phase))
}
})
.unwrap_or_else(|| "interrupted".to_string()),
}
}
fn write_environment_details(details: &mut String) {
let _ = writeln!(details);
let _ = writeln!(details, "Environment");
let _ = writeln!(details, "App version: {}", env!("CARGO_PKG_VERSION"));
let _ = writeln!(
details,
"OS: {}",
sysinfo::System::long_os_version()
.or_else(sysinfo::System::name)
.unwrap_or_else(|| std::env::consts::OS.to_string())
);
let _ = writeln!(details, "OS kind: {}", std::env::consts::OS);
let _ = writeln!(details, "OS family: {}", std::env::consts::FAMILY);
let _ = writeln!(details, "Architecture: {}", std::env::consts::ARCH);
if let Some(kernel_version) = sysinfo::System::kernel_version() {
let _ = writeln!(details, "Kernel: {kernel_version}");
}
}
fn latest_interruption(
events: &[InstallJobEvent],
) -> Option<(InstallInterruptReason, InstallPhaseId)> {
events.iter().rev().find_map(|event| match &event.kind {
InstallJobEventKind::Interrupted { reason, phase } => {
Some((*reason, *phase))
}
_ => None,
})
}
fn write_timeline(details: &mut String, events: &[InstallJobEvent]) {
let _ = writeln!(details);
let _ = writeln!(details, "Timeline");
let mut index = 1;
for event in events {
let Some(description) = timeline_event_description(event) else {
continue;
};
let _ = writeln!(
details,
"{index}. {} {description}",
event.at.to_rfc3339()
);
index += 1;
}
if index == 1 {
let _ = writeln!(details, "No install events were recorded.");
}
}
fn timeline_event_description(event: &InstallJobEvent) -> Option<String> {
match &event.kind {
InstallJobEventKind::JobQueued { kind } => {
Some(format!("Queued {} install", json_string(kind)))
}
InstallJobEventKind::JobStarted => {
Some("Started install job".to_string())
}
InstallJobEventKind::JobSucceeded { instance_id } => {
Some(match instance_id {
Some(instance_id) => {
format!("Finished install for instance {instance_id}")
}
None => "Finished install".to_string(),
})
}
InstallJobEventKind::JobCanceled { phase } => {
Some(format!("Canceled while {}", phase_label(*phase)))
}
InstallJobEventKind::PhaseStarted { phase, details } => Some(format!(
"Started {}{}",
phase_label(*phase),
phase_details_suffix(details)
)),
InstallJobEventKind::Interrupted { reason, phase } => {
Some(match reason {
InstallInterruptReason::AppClosed => {
format!("App closed while {}", phase_label(*phase))
}
InstallInterruptReason::Unknown => {
format!("Interrupted while {}", phase_label(*phase))
}
})
}
InstallJobEventKind::Failed {
phase,
code,
message,
} => Some(format!(
"Failed while {} ({code}): {message}",
phase_label(*phase)
)),
InstallJobEventKind::RollbackStarted { cleanup } => {
Some(format!("Started rollback ({})", cleanup_summary(cleanup)))
}
InstallJobEventKind::RollbackCompleted => {
Some("Rollback completed".to_string())
}
InstallJobEventKind::RollbackFailed { message } => {
Some(format!("Rollback failed: {message}"))
}
InstallJobEventKind::ContentDownloadStarted { .. }
| InstallJobEventKind::ContentFileSkipped { .. }
| InstallJobEventKind::ContentFileCompleted { .. } => None,
}
}
fn write_content_summary(details: &mut String, events: &[InstallJobEvent]) {
let started = events.iter().rev().find_map(|event| match &event.kind {
InstallJobEventKind::ContentDownloadStarted { files, bytes } => {
Some((*files, *bytes))
}
_ => None,
});
let completed = events
.iter()
.filter_map(|event| match &event.kind {
InstallJobEventKind::ContentFileCompleted { path, bytes } => {
Some((path.as_str(), *bytes))
}
_ => None,
})
.collect::<Vec<_>>();
let skipped = events
.iter()
.filter_map(|event| match &event.kind {
InstallJobEventKind::ContentFileSkipped { path, reason } => {
Some((path.as_str(), reason.as_str()))
}
_ => None,
})
.collect::<Vec<_>>();
if started.is_none() && completed.is_empty() && skipped.is_empty() {
return;
}
let _ = writeln!(details);
let _ = writeln!(details, "Content activity");
if let Some((files, bytes)) = started {
let _ = writeln!(
details,
"Completed files: {} / {files}, skipped files: {}",
completed.len(),
skipped.len()
);
if let Some(bytes) = bytes {
let _ = writeln!(
details,
"Expected content size: {}",
format_bytes(bytes)
);
}
} else {
let _ = writeln!(
details,
"Completed files: {}, skipped files: {}",
completed.len(),
skipped.len()
);
}
if !completed.is_empty() {
let _ = writeln!(details);
let _ = writeln!(details, "Recently completed files");
for (path, bytes) in completed.iter().rev().take(20) {
let _ = writeln!(details, "- {path} ({})", format_bytes(*bytes));
}
if completed.len() > 20 {
let _ = writeln!(details, "- ... {} more", completed.len() - 20);
}
}
if !skipped.is_empty() {
let _ = writeln!(details);
let _ = writeln!(details, "Skipped files");
for (path, reason) in skipped.iter().rev().take(20) {
let _ = writeln!(details, "- {path} ({reason})");
}
if skipped.len() > 20 {
let _ = writeln!(details, "- ... {} more", skipped.len() - 20);
}
}
}
fn write_errors(details: &mut String, snapshot: &InstallJobSnapshot) {
if let Some(error) = &snapshot.error {
let _ = writeln!(details);
let _ = writeln!(details, "Failure");
let _ = writeln!(details, "Code: {}", error.code);
if let Some(phase) = error.phase {
let _ = writeln!(details, "Phase: {}", phase_label(phase));
}
let _ = writeln!(details, "Message: {}", error.message);
write_api_error_details(details, error);
write_error_context(details, error);
}
if let Some(error) = &snapshot.rollback_error {
let _ = writeln!(details);
let _ = writeln!(details, "Rollback error");
let _ = writeln!(details, "Code: {}", error.code);
if let Some(phase) = error.phase {
let _ = writeln!(details, "Phase: {}", phase_label(phase));
}
let _ = writeln!(details, "Message: {}", error.message);
write_api_error_details(details, error);
write_error_context(details, error);
}
}
fn write_api_error_details(
details: &mut String,
error: &super::model::InstallErrorView,
) {
let Some(api) = &error.api else {
return;
};
let _ = writeln!(details, "API error: {}", api.error);
if let Some(status) = api.status {
let _ = writeln!(details, "HTTP status: {status}");
}
if api.method.is_some() || api.url.is_some() {
let method = api.method.as_deref().unwrap_or("unknown method");
let url = api.url.as_deref().unwrap_or("unknown URL");
let _ = writeln!(details, "Request: {method} {url}");
}
if let Some(route) = &api.route {
let _ = writeln!(details, "Route: {route}");
}
}
fn write_error_context(
details: &mut String,
error: &super::model::InstallErrorView,
) {
let Some(context) = &error.context else {
return;
};
let _ = writeln!(details, "Operation: {}", context.operation);
if let Some(source_path) = &context.source_path {
let _ = writeln!(details, "Source path: {source_path}");
}
if let Some(target_path) = &context.target_path {
let _ = writeln!(details, "Target path: {target_path}");
}
if let Some(file_path) = &context.file_path {
let _ = writeln!(details, "File path: {file_path}");
}
if let Some(entry_path) = &context.entry_path {
let _ = writeln!(details, "Archive entry: {entry_path}");
}
if !context.urls.is_empty() {
let _ = writeln!(details, "URLs:");
for url in &context.urls {
let _ = writeln!(details, "- {url}");
}
}
if let Some(expected_hash) = &context.expected_hash {
let _ = writeln!(details, "Expected hash: {expected_hash}");
}
if let Some(expected_size) = context.expected_size {
let _ =
writeln!(details, "Expected size: {}", format_bytes(expected_size));
}
if let Some(project_id) = &context.project_id {
let _ = writeln!(details, "Project ID: {project_id}");
}
if let Some(version_id) = &context.version_id {
let _ = writeln!(details, "Version ID: {version_id}");
}
if let Some(minecraft_version) = &context.minecraft_version {
let _ = writeln!(details, "Minecraft version: {minecraft_version}");
}
if let Some(loader) = &context.loader {
let _ = writeln!(details, "Loader: {loader}");
}
if let Some(java_version) = context.java_version {
let _ = writeln!(details, "Java version: {java_version}");
}
if let Some(os) = &context.os {
let _ = writeln!(details, "OS: {os}");
}
if let Some(arch) = &context.arch {
let _ = writeln!(details, "Architecture: {arch}");
}
}
fn write_raw_snapshot(details: &mut String, snapshot: &InstallJobSnapshot) {
let _ = writeln!(details);
let _ = writeln!(details, "Raw snapshot");
match serde_json::to_string_pretty(snapshot) {
Ok(snapshot_json) => {
let _ = writeln!(details, "{snapshot_json}");
}
Err(error) => {
let _ = writeln!(details, "Unable to serialize snapshot: {error}");
}
}
}
async fn write_latest_log(details: &mut String, state: &State) {
let _ = writeln!(details);
let _ = writeln!(details, "Latest launcher log excerpt");
match latest_launcher_log_tail(state).await {
Ok(Some((path, output))) => {
let _ = writeln!(details, "File: {}", path.display());
details.push_str(&output);
}
Ok(None) => {
let _ = writeln!(details, "No launcher log found.");
}
Err(error) => {
let _ = writeln!(details, "Unable to read launcher log: {error}");
}
}
}
fn phase_label(phase: InstallPhaseId) -> &'static str {
match phase {
InstallPhaseId::PreparingInstance => "preparing instance",
InstallPhaseId::ResolvingPack => "resolving pack",
InstallPhaseId::DownloadingPackFile => "downloading pack file",
InstallPhaseId::ReadingPackManifest => "reading pack manifest",
InstallPhaseId::DownloadingContent => "downloading content",
InstallPhaseId::ExtractingOverrides => "extracting overrides",
InstallPhaseId::ResolvingMinecraft => "resolving Minecraft",
InstallPhaseId::ResolvingLoader => "resolving loader",
InstallPhaseId::PreparingJava => "preparing Java",
InstallPhaseId::DownloadingMinecraft => "downloading Minecraft",
InstallPhaseId::RunningLoaderProcessors => "running loader processors",
InstallPhaseId::Finalizing => "finalizing",
InstallPhaseId::RollingBack => "rolling back",
}
}
fn phase_details_suffix(details: &InstallPhaseDetails) -> String {
match details {
InstallPhaseDetails::Empty => String::new(),
InstallPhaseDetails::Instance { name } => format!(" for {name}"),
InstallPhaseDetails::Minecraft {
game_version,
loader,
} => format!(
" for Minecraft {game_version} with {}",
json_string(loader)
),
InstallPhaseDetails::Java {
major_version,
step,
} => format!(": {} Java {major_version}", json_string(step)),
InstallPhaseDetails::Modpack {
project_id,
version_id,
title,
} => {
let mut value = title
.as_ref()
.map(|title| format!(" for {title}"))
.unwrap_or_default();
if let Some(project_id) = project_id {
let _ = write!(value, " project={project_id}");
}
if let Some(version_id) = version_id {
let _ = write!(value, " version={version_id}");
}
value
}
InstallPhaseDetails::Import {
launcher_type,
instance_folder,
} => format!(" from {launcher_type} instance {instance_folder}"),
}
}
fn cleanup_summary(cleanup: &InstallCleanup) -> String {
match cleanup {
InstallCleanup::DeleteNewInstance { instance_id } => {
match instance_id {
Some(instance_id) => {
format!("delete partially-created instance {instance_id}")
}
None => "delete partially-created instance".to_string(),
}
}
InstallCleanup::RestoreExistingInstance { instance_id } => {
format!("restore existing instance {instance_id}")
}
}
}
fn progress_summary(progress: &InstallProgress) -> String {
let mut value = format!("{} / {}", progress.current, progress.total);
if let Some(secondary) = &progress.secondary {
let _ = write!(
value,
" ({} / {})",
format_bytes(secondary.current),
format_bytes(secondary.total)
);
}
value
}
fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = UNITS[0];
for next_unit in UNITS.iter().skip(1) {
if value < 1024.0 {
break;
}
value /= 1024.0;
unit = next_unit;
}
if unit == "B" {
format!("{bytes} B")
} else {
format!("{value:.1} {unit}")
}
}
fn json_string<T: serde::Serialize>(value: &T) -> String {
serde_json::to_string(value)
.map(|value| value.trim_matches('"').to_string())
.unwrap_or_else(|_| "unknown".to_string())
}
async fn latest_launcher_log_tail(
state: &State,
) -> crate::Result<Option<(PathBuf, String)>> {
let Some(logs_dir) = state.directories.launcher_logs_dir() else {
return Ok(None);
};
let entries = match std::fs::read_dir(&logs_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(None);
}
Err(error) => return Err(error.into()),
};
let mut latest: Option<(PathBuf, SystemTime)> = None;
for entry in entries {
let entry = entry?;
let metadata = match entry.metadata() {
Ok(metadata) if metadata.is_file() => metadata,
_ => continue,
};
let modified = metadata
.modified()
.or_else(|_| metadata.created())
.unwrap_or(SystemTime::UNIX_EPOCH);
let path = entry.path();
match latest.as_ref() {
Some((_, latest_modified)) if modified <= *latest_modified => {}
_ => latest = Some((path, modified)),
}
}
let Some((path, _)) = latest else {
return Ok(None);
};
let output = read_file_tail(&path, INSTALL_SUPPORT_LOG_TAIL_BYTES)?;
Ok(Some((path, output)))
}
fn read_file_tail(path: &Path, max_bytes: u64) -> crate::Result<String> {
let mut file = std::fs::File::open(path)?;
let len = file.metadata()?.len();
let start = len.saturating_sub(max_bytes);
file.seek(SeekFrom::Start(start))?;
let mut buffer = Vec::with_capacity((len - start) as usize);
file.read_to_end(&mut buffer)?;
let mut output = String::from_utf8_lossy(&buffer).into_owned();
if start > 0 {
output = format!("[first {start} bytes omitted]\n{output}");
}
Ok(output)
}
async fn censor_support_text(
mut text: String,
state: &State,
) -> crate::Result<String> {
for credentials in ModrinthCredentials::get_all(&state.pool)
.await?
.into_iter()
.map(|credentials| credentials.1)
{
replace_nonempty(
&mut text,
&credentials.session,
"{MODRINTH_ACCESS_TOKEN}",
);
}
for token in minecraft_tokens(&state.pool).await? {
replace_nonempty(&mut text, &token, "{MINECRAFT_TOKEN}");
}
text = censor_ip_addresses(text);
Ok(text)
}
async fn minecraft_tokens(
pool: &sqlx::SqlitePool,
) -> crate::Result<Vec<String>> {
let rows =
sqlx::query("SELECT access_token, refresh_token FROM minecraft_users")
.fetch_all(pool)
.await?;
let mut tokens = Vec::with_capacity(rows.len() * 2);
for row in rows {
tokens.push(row.try_get("access_token")?);
tokens.push(row.try_get("refresh_token")?);
}
Ok(tokens)
}
fn replace_nonempty(text: &mut String, value: &str, replacement: &str) {
if !value.is_empty() {
*text = text.replace(value, replacement);
}
}
fn censor_ip_addresses(text: String) -> String {
let text = Regex::new(
r"\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b",
)
.expect("valid IPv4 regex")
.replace_all(&text, |captures: &Captures<'_>| {
let value = &captures[0];
match value.parse::<Ipv4Addr>() {
Ok(_) => "...".to_string(),
_ => value.to_string(),
}
})
.into_owned();
Regex::new(r"(?i)\b[0-9a-f:.%]{3,}\b")
.expect("valid IPv6 candidate regex")
.replace_all(&text, |captures: &Captures<'_>| {
let value = &captures[0];
if value.matches(':').count() < 2 {
return value.to_string();
}
let candidate = value.split('%').next().unwrap_or(value);
match candidate.parse::<Ipv6Addr>() {
Ok(_) => ":::::::".to_string(),
_ => value.to_string(),
}
})
.into_owned()
}
+164 -9
View File
@@ -1,23 +1,38 @@
use super::model::{ 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)]
+8 -5
View File
@@ -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,
}; };
+204 -1
View File
@@ -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>>,
+63 -39
View File
@@ -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(
+186 -25
View File
@@ -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",
} }
} }
+1
View File
@@ -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,
+95 -3
View File
@@ -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?;
+36 -14
View File
@@ -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?;
} }
+16 -9
View File
@@ -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()
} }
+5
View File
@@ -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,
+74 -2
View File
@@ -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(
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.4 KiB

+2
View File
@@ -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'
@@ -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"
/>
`,
}),
}