diff --git a/Cargo.lock b/Cargo.lock index d40a959237..bec4759568 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8663,6 +8663,18 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "reflink-copy" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9dd7ab4af0363d5ccfd2838d782a28196cf32a5cc2e4fe3c5dc83f2be588b8b" +dependencies = [ + "cfg-if", + "libc", + "rustix 1.1.2", + "windows", +] + [[package]] name = "regex" version = "1.12.2" @@ -11398,9 +11410,11 @@ dependencies = [ "quartz_nbt", "quick-xml 0.38.3", "rand 0.8.5", + "reflink-copy", "regex", "reqwest 0.12.24", "rgb", + "same-file", "serde", "serde-binhum", "serde_ini", diff --git a/Cargo.toml b/Cargo.toml index a4a779c2dd..827fc81259 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -160,6 +160,7 @@ rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9 rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9 rdkafka = { version = "0.36.2", features = ["cmake-build"] } redis = "1.4.1" +reflink-copy = "0.1.30" regex = "1.12.2" reqwest = { version = "0.12.24", default-features = false } rgb = "0.8.52" @@ -174,6 +175,7 @@ rustls = "0.23.32" rustrict = { version = "0.7.39", default-features = false, features = ["censor"] } rusty-money = "0.4.1" scalar_api_reference = { version = "0.2.2", default-features = false } +same-file = "1.0.6" secrecy = "0.10.3" sentry = { version = "0.45.0", default-features = false, features = [ "backtrace", diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index 643a41d661..20e463fa59 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -37,6 +37,7 @@ "floating-vue": "^5.2.2", "fuse.js": "^6.6.2", "intl-messageformat": "^10.7.7", + "motion-v": "2.2.1", "ofetch": "^1.3.4", "overlayscrollbars": "^2.15.1", "posthog-js": "^1.158.2", diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 3277a2ea13..dd479eafcf 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -2288,15 +2288,16 @@ provideAppUpdateDownloadProgress(appUpdateDownload) - + + + + + - - -
+
([]) const currentLoadingBarIconUrls = ref>({}) const notificationId = ref(null) -const terminalNotificationIds = new Map() const dismissed = ref(false) function getLoadingBarKey(loadingBar: LoadingBar): string { @@ -373,79 +372,28 @@ function removeNotification(): void { notificationId.value = null } -function syncTerminalNotifications(): void { - const terminalNotifications = installJobNotifications.terminalNotifications.value - const currentJobIds = new Set(terminalNotifications.map((notification) => notification.id)) - - for (const terminal of terminalNotifications) { - const popupId = terminalNotificationIds.get(terminal.id) - let notification = popupId - ? popupNotificationManager - .getNotifications() - .find( - (candidate): candidate is PopupNotificationStandard => - candidate.id === popupId && candidate.contentType === 'standard', - ) - : undefined - - if (!notification) { - notification = popupNotificationManager.addPopupNotification({ - contentType: 'standard', - title: terminal.title, - text: terminal.text, - type: terminal.type, - buttons: terminal.buttons, - onDismiss: terminal.onDismiss, - autoCloseMs: null, - }) - terminalNotificationIds.set(terminal.id, notification.id) - continue - } - - notification.title = terminal.title - notification.text = terminal.text - notification.type = terminal.type - notification.buttons = terminal.buttons - notification.onDismiss = terminal.onDismiss - } - - for (const [jobId, popupId] of terminalNotificationIds) { - if (!currentJobIds.has(jobId)) { - popupNotificationManager.removeNotification(popupId) - terminalNotificationIds.delete(jobId) - } - } -} - function buildDownloadItems(): PopupNotificationProgressItem[] { - return [ - ...installJobNotifications.progressItems.value, - ...currentLoadingBars.value.map((bar) => ({ - id: getLoadingBarKey(bar), - title: bar.title ?? '', - text: getLoadingText(bar), - iconUrl: currentLoadingBarIconUrls.value[getLoadingBarKey(bar)] ?? null, - progress: getLoadingProgress(bar), - waiting: !bar.total || bar.total <= 0, - progressType: bar.bar_type?.type === 'pack_import' ? 'bytes' : 'percentage', - progressCurrent: bar.current, - progressTotal: bar.total, - })), - ] + return currentLoadingBars.value.map((bar) => ({ + id: getLoadingBarKey(bar), + title: bar.title ?? '', + text: getLoadingText(bar), + iconUrl: currentLoadingBarIconUrls.value[getLoadingBarKey(bar)] ?? null, + progress: getLoadingProgress(bar), + waiting: !bar.total || bar.total <= 0, + progressType: bar.bar_type?.type === 'pack_import' ? 'bytes' : 'percentage', + progressCurrent: bar.current, + progressTotal: bar.total, + })) } -const hasActiveLoadingBars = computed( - () => currentLoadingBars.value.length > 0 || installJobNotifications.active.value, -) +const hasActiveLoadingBars = computed(() => currentLoadingBars.value.length > 0) function updateNotification(resummon = false): void { - syncTerminalNotifications() - if (resummon) { dismissed.value = false } - if (currentLoadingBars.value.length === 0 && !installJobNotifications.active.value) { + if (currentLoadingBars.value.length === 0) { removeNotification() dismissed.value = false return @@ -464,9 +412,7 @@ function updateNotification(resummon = false): void { const progressItems = buildDownloadItems() if (notif) { - notif.title = installJobNotifications.active.value - ? installJobNotifications.title.value - : formatMessage(messages.downloads) + notif.title = formatMessage(messages.downloads) notif.text = undefined notif.progressItems = progressItems notif.progress = undefined @@ -474,9 +420,7 @@ function updateNotification(resummon = false): void { } else { const notification = popupNotificationManager.addPopupNotification({ contentType: 'standard', - title: installJobNotifications.active.value - ? installJobNotifications.title.value - : formatMessage(messages.downloads), + title: formatMessage(messages.downloads), type: 'download', autoCloseMs: null, progressItems, @@ -556,12 +500,6 @@ async function refreshLoadingBars() { updateNotification() } -const installJobNotifications = await useInstallJobNotifications({ - router, - handleError: (error) => handleError(toError(error)), - onChange: updateNotification, -}) - await refreshLoadingBars() useAppEvent('loading', async () => { @@ -578,11 +516,8 @@ function selectProcess(process: RunningProcess) { onBeforeUnmount(() => { removeNotification() - terminalNotificationIds.forEach((id) => popupNotificationManager.removeNotification(id)) - terminalNotificationIds.clear() dismissed.value = false window.removeEventListener('offline', handleOffline) window.removeEventListener('online', handleOnline) - installJobNotifications.dispose() }) diff --git a/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue b/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue index b0cdbe9b72..59695b1a26 100644 --- a/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue +++ b/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue @@ -36,6 +36,7 @@ const runningInstances = ref([]) const { formatMessage } = useVIntl() const container = ref() +const footer = ref() let resizeObserver const maxAuto = ref(0) const allInstances = computed(() => @@ -74,9 +75,10 @@ const updateMaxAuto = () => { const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) const dividerHeight = rem + 1 const gap = rem / 4 + const footerHeight = (footer.value?.clientHeight ?? 0) + gap maxAuto.value = Math.max( 0, - Math.floor((container.value.clientHeight - 2 * dividerHeight - gap) / (3 * rem + gap)), + Math.floor((container.value.clientHeight - footerHeight - 2 * dividerHeight - gap) / (3 * rem + gap)), ) } @@ -165,6 +167,7 @@ useAppEvent('process', checkProcesses) onMounted(() => { resizeObserver = new ResizeObserver(updateMaxAuto) resizeObserver.observe(container.value) + resizeObserver.observe(footer.value) updateMaxAuto() checkProcesses() }) @@ -325,6 +328,9 @@ function openContextMenu(event, instance) { " >
+
+ +
diff --git a/apps/app-frontend/src/components/ui/download-manager/download-manager-bar.vue b/apps/app-frontend/src/components/ui/download-manager/download-manager-bar.vue new file mode 100644 index 0000000000..baa5cd5677 --- /dev/null +++ b/apps/app-frontend/src/components/ui/download-manager/download-manager-bar.vue @@ -0,0 +1,405 @@ + + + + + diff --git a/apps/app-frontend/src/components/ui/download-manager/download-manager-job.vue b/apps/app-frontend/src/components/ui/download-manager/download-manager-job.vue new file mode 100644 index 0000000000..3a4b9316ce --- /dev/null +++ b/apps/app-frontend/src/components/ui/download-manager/download-manager-job.vue @@ -0,0 +1,219 @@ + + + + + diff --git a/apps/app-frontend/src/components/ui/download-manager/download-manager-panel.vue b/apps/app-frontend/src/components/ui/download-manager/download-manager-panel.vue new file mode 100644 index 0000000000..24b9a011c9 --- /dev/null +++ b/apps/app-frontend/src/components/ui/download-manager/download-manager-panel.vue @@ -0,0 +1,188 @@ + + + diff --git a/apps/app-frontend/src/components/ui/download-manager/download-transfer.ts b/apps/app-frontend/src/components/ui/download-manager/download-transfer.ts new file mode 100644 index 0000000000..42ae45bf04 --- /dev/null +++ b/apps/app-frontend/src/components/ui/download-manager/download-transfer.ts @@ -0,0 +1,68 @@ +import type { InstallJobSnapshot } from '@/helpers/install' + +interface TransferSample { + phase: string + current: number + total: number + at: number + rate: number | null +} + +function getDownloadProgress(job: InstallJobSnapshot) { + if (job.status !== 'running' || job.paused || job.canceling) return null + if (job.phase === 'downloading_content') return job.progress?.secondary ?? null + if ( + job.phase === 'downloading_pack_file' || + job.phase === 'downloading_minecraft' || + (job.phase === 'preparing_java' && + job.details.type === 'java' && + job.details.step === 'downloading') + ) { + return job.progress + } + return null +} + +export function createDownloadTransferTracker() { + const samples = new Map() + + function update(job: InstallJobSnapshot, now: number) { + const progress = getDownloadProgress(job) + if (!progress || progress.total <= 0 || progress.current >= progress.total) { + samples.delete(job.job_id) + return + } + + const previous = samples.get(job.job_id) + if ( + !previous || + previous.phase !== job.phase || + previous.total !== progress.total || + progress.current < previous.current + ) { + samples.set(job.job_id, { ...progress, phase: job.phase, at: now, rate: null }) + return + } + + const elapsed = now - previous.at + if (progress.current === previous.current || elapsed < 250) return + const rate = ((progress.current - previous.current) * 1000) / elapsed + samples.set(job.job_id, { + ...progress, + phase: job.phase, + at: now, + rate: previous.rate == null || elapsed >= 5000 ? rate : previous.rate * 0.6 + rate * 0.4, + }) + } + + function get(jobId: string, now: number) { + const sample = samples.get(jobId) + const rate = sample && now - sample.at < 5000 ? sample.rate : null + return { + rate, + eta: rate && sample ? (sample.total - sample.current) / rate : null, + } + } + + return { update, get, remove: (jobId: string) => samples.delete(jobId) } +} diff --git a/apps/app-frontend/src/components/ui/download-manager/index.vue b/apps/app-frontend/src/components/ui/download-manager/index.vue new file mode 100644 index 0000000000..fb2d83dc86 --- /dev/null +++ b/apps/app-frontend/src/components/ui/download-manager/index.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/apps/app-frontend/src/components/ui/download-manager/install-job-progress.ts b/apps/app-frontend/src/components/ui/download-manager/install-job-progress.ts new file mode 100644 index 0000000000..173d5ee00a --- /dev/null +++ b/apps/app-frontend/src/components/ui/download-manager/install-job-progress.ts @@ -0,0 +1,91 @@ +import type { InstallJobSnapshot, InstallPhaseId } from '@/helpers/install' + +type Stage = readonly [InstallPhaseId, number] + +const instanceStages: readonly Stage[] = [ + ['preparing_instance', 2], + ['resolving_minecraft', 3], + ['resolving_loader', 3], + ['preparing_java', 12], + ['downloading_minecraft', 70], + ['running_loader_processors', 8], + ['finalizing', 2], +] + +const packStages: readonly Stage[] = [ + ['preparing_instance', 1], + ['resolving_pack', 1], + ['downloading_pack_file', 5], + ['reading_pack_manifest', 1], + ['downloading_content', 60], + ['extracting_overrides', 7], + ['resolving_minecraft', 1], + ['resolving_loader', 1], + ['preparing_java', 5], + ['downloading_minecraft', 14], + ['running_loader_processors', 3], + ['finalizing', 1], +] + +const copyStages: readonly Stage[] = [ + ['preparing_instance', 35], + ['resolving_minecraft', 2], + ['resolving_loader', 2], + ['preparing_java', 8], + ['downloading_minecraft', 45], + ['running_loader_processors', 7], + ['finalizing', 1], +] + +const stagesByKind: Record = { + create_instance: instanceStages, + create_modpack_instance: packStages, + create_shared_instance: packStages, + import_instance: copyStages, + duplicate_instance: copyStages, + install_existing_instance: instanceStages, + install_pack_to_existing_instance: packStages, + update_shared_instance: packStages, +} + +/** Estimates whole-job progress from stage counters, preserving progress within an attempt. */ +export function createInstallJobProgressTracker() { + const jobs = new Map() + + function update(job: InstallJobSnapshot) { + const previous = jobs.get(job.job_id) + const continuing = previous?.status === 'running' || previous?.status === 'queued' + let progress = continuing ? previous.progress : 0 + + if (job.status === 'queued') { + progress = 0 + } else if (job.status === 'succeeded') { + progress = 1 + } else if (job.status !== 'running' || job.phase === 'rolling_back') { + progress = previous?.progress ?? 0 + } else if (job.phase !== 'downloading_minecraft' || job.progress) { + const counter = + job.phase === 'downloading_content' + ? (job.progress?.secondary ?? job.progress) + : job.progress + const fraction = + counter && counter.total > 0 ? Math.max(0, Math.min(1, counter.current / counter.total)) : 0 + let completedWeight = 0 + for (const [phase, weight] of stagesByKind[job.kind]) { + if (phase === job.phase) { + progress = Math.max(progress, Math.min(0.99, (completedWeight + weight * fraction) / 100)) + break + } + completedWeight += weight + } + } + + jobs.set(job.job_id, { status: job.status, progress }) + } + + return { + update, + get: (id: string) => jobs.get(id)?.progress ?? 0, + remove: (id: string) => jobs.delete(id), + } +} diff --git a/apps/app-frontend/src/components/ui/download-manager/store-verification.ts b/apps/app-frontend/src/components/ui/download-manager/store-verification.ts new file mode 100644 index 0000000000..458e7a2521 --- /dev/null +++ b/apps/app-frontend/src/components/ui/download-manager/store-verification.ts @@ -0,0 +1,65 @@ +import { Channel, invoke } from '@tauri-apps/api/core' +import { computed, ref } from 'vue' + +export interface StoreVerification { + checked: number + repaired: number + issues: { sha512: string; message: string }[] +} + +export const storeVerificationReport = ref(null) + +export const storeVerificationTask = ref<{ + id: string + status: 'running' | 'succeeded' | 'failed' + current: number + total: number + rate: number + lastRead: number +} | null>(null) + +export const verifyingStore = computed(() => storeVerificationTask.value?.status === 'running') + +export async function verifyStore(): Promise { + if (verifyingStore.value) throw new Error('Content verification is already running') + const task = { + id: `store-verification-${crypto.randomUUID()}`, + status: 'running' as const, + current: 0, + total: 0, + rate: 0, + lastRead: performance.now(), + } + storeVerificationTask.value = task + storeVerificationReport.value = null + let previousBytes = 0 + let previousTime = performance.now() + const onProgress = new Channel<[number, number]>() + onProgress.onmessage = ([current, total]) => { + const active = storeVerificationTask.value + if (!active || active.id !== task.id || active.status !== 'running') return + const now = performance.now() + active.current = current + active.total = total + if (current > previousBytes) { + active.rate = ((current - previousBytes) * 1000) / Math.max(1, now - previousTime) + active.lastRead = now + } + previousBytes = current + previousTime = now + } + try { + const report = await invoke('plugin:settings|store_verify', { + repair: true, + onProgress, + }) + storeVerificationReport.value = report + storeVerificationTask.value!.status = report.issues.length ? 'failed' : 'succeeded' + return report + } catch (error) { + storeVerificationTask.value!.status = 'failed' + throw error + } finally { + storeVerificationTask.value!.rate = 0 + } +} diff --git a/apps/app-frontend/src/components/ui/download-manager/use-download-bar-state.ts b/apps/app-frontend/src/components/ui/download-manager/use-download-bar-state.ts new file mode 100644 index 0000000000..dda6834b2f --- /dev/null +++ b/apps/app-frontend/src/components/ui/download-manager/use-download-bar-state.ts @@ -0,0 +1,82 @@ +import { onScopeDispose, type Ref, ref, shallowRef, watch } from 'vue' + +import type { DownloadManagerJob } from './use-download-manager' + +export function useDownloadBarState(options: { + activeJobs: Ref + attentionJobs: Ref + completedJobs: Ref + initialized: Ref +}) { + const task = shallowRef(null) + const completing = ref(false) + let hydrated = false + let completionTimer: ReturnType | undefined + let queuedAtCompletion = new Set() + + function clearCompletion() { + clearTimeout(completionTimer) + completionTimer = undefined + completing.value = false + } + + function selectTask() { + task.value = options.activeJobs.value[0] ?? options.attentionJobs.value[0] ?? null + } + + function reconcile() { + if (!options.initialized.value || !hydrated) { + hydrated = options.initialized.value + selectTask() + return + } + + const current = task.value + const active = options.activeJobs.value.find((job) => job.id === current?.id) + if (active) { + clearCompletion() + task.value = active + return + } + + if (completing.value) { + const hasNewTask = options.activeJobs.value.some((job) => !queuedAtCompletion.has(job.id)) + const completed = options.completedJobs.value.find((job) => job.id === current?.id) + if (hasNewTask || !completed || completed.status !== 'succeeded') { + clearCompletion() + selectTask() + } + return + } + + if (current?.status === 'running' || current?.status === 'queued') { + const completed = options.completedJobs.value.find( + (job) => job.id === current.id && job.status === 'succeeded', + ) + if (completed) { + task.value = completed + completing.value = true + queuedAtCompletion = new Set(options.activeJobs.value.map((job) => job.id)) + completionTimer = setTimeout( + () => { + clearCompletion() + selectTask() + }, + options.activeJobs.value.length ? 500 : 1000, + ) + return + } + } + + selectTask() + } + + watch( + [options.activeJobs, options.attentionJobs, options.completedJobs, options.initialized], + reconcile, + { immediate: true }, + ) + onScopeDispose(clearCompletion) + + return { task, completing } +} diff --git a/apps/app-frontend/src/components/ui/download-manager/use-download-manager.ts b/apps/app-frontend/src/components/ui/download-manager/use-download-manager.ts new file mode 100644 index 0000000000..13de40cd6e --- /dev/null +++ b/apps/app-frontend/src/components/ui/download-manager/use-download-manager.ts @@ -0,0 +1,392 @@ +import { defineMessages, injectNotificationManager, useFormatBytes, useVIntl } from '@modrinth/ui' +import { convertFileSrc } from '@tauri-apps/api/core' +import { computed, onMounted, onScopeDispose, ref, watch } from 'vue' + +import { useAppSettings } from '@/composables/use-app-settings' +import { toError } from '@/helpers/errors' +import { + install_job_cancel, + install_job_dismiss, + install_job_list, + install_job_pause, + install_job_resume, + install_job_retry, + install_job_support_details, + installJobInstanceId, + type InstallJobSnapshot, +} from '@/helpers/install' +import { get_many as getInstances } from '@/helpers/instance' +import { injectAppEvents } from '@/providers/app-events' + +import { createDownloadTransferTracker } from './download-transfer' +import { storeVerificationTask } from './store-verification' +import { createInstallJobProgressTracker } from './install-job-progress' +import { useInstallJobDisplay } from './use-install-job-display' + +export interface DownloadManagerJob { + id: string + instanceId: string | null + status: InstallJobSnapshot['status'] + paused: boolean + canceling: boolean + canPause: boolean + canCancel: boolean + title: string + iconUrl: string | null + text: string + progress: number + overallProgress: number + progressLabel: string + waiting: boolean + eta: string + canRetry?: boolean + canCopyDetails: boolean + copied: boolean + busy: boolean +} + +function getIconUrl(icon: string | null | undefined): string | null { + if (!icon) return null + return /^(https?:|data:|blob:|asset:|tauri:)/.test(icon) ? icon : convertFileSrc(icon) +} + +export function useDownloadManager() { + const events = injectAppEvents() + const { handleError } = injectNotificationManager() + const appSettings = useAppSettings() + const display = useInstallJobDisplay() + const { formatMessage } = useVIntl() + const formatBytes = useFormatBytes() + const verificationMessages = defineMessages({ + verifying: { id: 'app.download-manager.verifying', defaultMessage: 'Verifying' }, + title: { id: 'app.settings.resource-management.store.title', defaultMessage: 'Content storage' }, + complete: { + id: 'app.settings.resource-management.store.verified', + defaultMessage: 'Verification complete', + }, + failed: { + id: 'app.settings.resource-management.store.attention', + defaultMessage: 'Some files still need attention', + }, + }) + const verificationRow = computed(() => { + const task = storeVerificationTask.value + if (!task) return [] + const progress = task.total ? Math.min(0.99, task.current / task.total) : 0 + const rate = task.status === 'running' && now.value - task.lastRead < 2000 ? task.rate : 0 + return [ + { + id: task.id, + instanceId: null, + status: task.status, + paused: false, + canceling: false, + canPause: false, + canCancel: false, + canRetry: false, + title: formatMessage(verificationMessages.title), + iconUrl: null, + text: formatMessage( + task.status === 'running' + ? verificationMessages.verifying + : task.status === 'succeeded' + ? verificationMessages.complete + : verificationMessages.failed, + ), + progress, + overallProgress: task.status === 'succeeded' ? 1 : progress, + progressLabel: + task.status === 'running' + ? `${formatBytes(task.current)} / ${formatBytes(task.total)} ยท ${display.formatRate(rate) || '0 B/s'}` + : '', + waiting: task.total === 0 || task.current >= task.total, + eta: '', + canCopyDetails: false, + copied: false, + busy: false, + }, + ] + }) + const jobs = ref(new Map()) + const initialized = ref(false) + const instances = ref(new Map()) + const busyJobs = ref(new Set()) + const copiedJobs = ref(new Set()) + const dismissedJobs = new Set() + const copiedTimeouts = new Map>() + const transfer = createDownloadTransferTracker() + const overallProgress = createInstallJobProgressTracker() + const now = ref(performance.now()) + const revisions = new Map() + let revision = 0 + let refreshRequest = 0 + let metadataRequest = 0 + let disposed = false + let clock: ReturnType | undefined + + function reportError(error: unknown) { + if (!disposed) handleError(toError(error)) + } + + function applyJobUpdate(job: InstallJobSnapshot) { + if (disposed || dismissedJobs.has(job.job_id)) return + const previous = jobs.value.get(job.job_id) + if (previous && previous.modified > job.modified) return + revisions.set(job.job_id, ++revision) + transfer.update(job, performance.now()) + overallProgress.update(job) + jobs.value.set(job.job_id, job) + } + + async function refresh() { + const request = ++refreshRequest + const startedAtRevision = revision + try { + const snapshots = await install_job_list(true) + if (disposed || request !== refreshRequest) return + const nextJobs = new Map() + for (const job of snapshots) { + if (dismissedJobs.has(job.job_id)) continue + const previous = jobs.value.get(job.job_id) + if ( + previous && + ((revisions.get(job.job_id) ?? 0) > startedAtRevision || previous.modified > job.modified) + ) { + nextJobs.set(job.job_id, previous) + } else { + transfer.update(job, performance.now()) + overallProgress.update(job) + nextJobs.set(job.job_id, job) + } + } + for (const [id, job] of jobs.value) { + if ((revisions.get(id) ?? 0) > startedAtRevision && !dismissedJobs.has(id)) { + nextJobs.set(id, job) + } + if (!nextJobs.has(id)) { + transfer.remove(id) + overallProgress.remove(id) + } + } + jobs.value = nextJobs + } catch (error) { + reportError(error) + } finally { + if (!disposed) initialized.value = true + } + } + + const instanceIds = computed(() => + Array.from( + new Set( + [...jobs.value.values()].map(installJobInstanceId).filter((id): id is string => !!id), + ), + ).sort(), + ) + + async function refreshMetadata() { + const request = ++metadataRequest + try { + const metadata = instanceIds.value.length ? await getInstances(instanceIds.value) : [] + if (disposed || request !== metadataRequest) return + instances.value = new Map( + metadata.map((instance) => [ + instance.id, + { name: instance.name, icon: getIconUrl(instance.icon_path) }, + ]), + ) + } catch (error) { + reportError(error) + } + } + + watch(() => instanceIds.value.join('\n'), refreshMetadata) + + const rows = computed(() => + [...jobs.value.values()].map((job): DownloadManagerJob => { + const instanceId = installJobInstanceId(job) + const instance = instanceId ? instances.value.get(instanceId) : undefined + const progress = display.getEffectiveProgress(job) + return { + id: job.job_id, + instanceId: instance && instanceId ? instanceId : null, + status: job.status, + paused: job.paused, + canceling: job.canceling, + canPause: job.can_pause, + canCancel: job.can_cancel, + title: display.getTitle(job, instance?.name), + iconUrl: getIconUrl(job.display?.icon) ?? instance?.icon ?? null, + text: display.getText(job), + progress: display.getProgress(job), + overallProgress: overallProgress.get(job.job_id), + progressLabel: display.getProgressLabel(job), + waiting: !progress || progress.total <= 0, + eta: + job.paused || job.canceling + ? '' + : display.formatEta(transfer.get(job.job_id, now.value).eta), + canCopyDetails: + job.status === 'failed' || + job.status === 'interrupted' || + appSettings.getFeatureFlag('always_show_copy_details'), + copied: copiedJobs.value.has(job.job_id), + busy: busyJobs.value.has(job.job_id), + } + }), + ) + + function newestFirst(a: DownloadManagerJob, b: DownloadManagerJob) { + const first = jobs.value.get(a.id)! + const second = jobs.value.get(b.id)! + if (!first || !second) return Number(!second) - Number(!first) + return (second.finished ?? second.modified).localeCompare(first.finished ?? first.modified) + } + + const activeJobs = computed(() => + [...rows.value, ...verificationRow.value] + .filter((job) => job.status === 'queued' || job.status === 'running') + .sort( + (a, b) => + Number(a.status === 'queued') - Number(b.status === 'queued') || + (jobs.value.get(a.id)?.created ?? '').localeCompare(jobs.value.get(b.id)?.created ?? ''), + ), + ) + const attentionJobs = computed(() => + [...rows.value, ...verificationRow.value] + .filter((job) => job.status === 'failed' || job.status === 'interrupted') + .sort(newestFirst), + ) + const completedJobs = computed(() => + [...rows.value, ...verificationRow.value] + .filter((job) => job.status === 'succeeded' || job.status === 'canceled') + .sort(newestFirst), + ) + const rate = computed(() => + display.formatRate( + activeJobs.value.reduce( + (total, job) => total + (transfer.get(job.id, now.value).rate ?? 0), + 0, + ), + ), + ) + + watch( + () => activeJobs.value.length > 0, + (active) => { + if (clock) clearInterval(clock) + clock = active + ? setInterval(() => { + now.value = performance.now() + }, 1000) + : undefined + }, + { immediate: true }, + ) + + async function runAction(id: string, action: () => Promise) { + if (disposed || busyJobs.value.has(id)) return + busyJobs.value.add(id) + try { + await action() + } catch (error) { + reportError(error) + } finally { + busyJobs.value.delete(id) + } + } + + async function retry(id: string) { + await runAction(id, async () => { + const before = revision + const job = await install_job_retry(id) + if ((revisions.get(id) ?? 0) <= before) applyJobUpdate(job) + }) + } + + async function cancel(id: string) { + if (!jobs.value.get(id)?.can_cancel) return + await runAction(id, async () => { + const before = revision + const job = await install_job_cancel(id) + if ((revisions.get(id) ?? 0) <= before) applyJobUpdate(job) + }) + } + + async function togglePause(id: string) { + const current = jobs.value.get(id) + if (!current?.can_pause) return + await runAction(id, async () => { + const before = revision + const job = await (current.paused ? install_job_resume(id) : install_job_pause(id)) + if ((revisions.get(id) ?? 0) <= before) applyJobUpdate(job) + }) + } + + async function dismiss(id: string) { + if (storeVerificationTask.value?.id === id) { + if (storeVerificationTask.value.status !== 'running') storeVerificationTask.value = null + return + } + const job = jobs.value.get(id) + if (!job || job.status === 'queued' || job.status === 'running') return + await runAction(id, async () => { + await install_job_dismiss(id) + dismissedJobs.add(id) + jobs.value.delete(id) + transfer.remove(id) + overallProgress.remove(id) + }) + } + + async function clearCompleted() { + await Promise.all(completedJobs.value.map((job) => dismiss(job.id))) + } + + async function copyDetails(id: string) { + await runAction(id, async () => { + const details = await install_job_support_details(id) + if (disposed) return + await navigator.clipboard.writeText(details) + if (disposed) return + copiedJobs.value.add(id) + clearTimeout(copiedTimeouts.get(id)) + copiedTimeouts.set( + id, + setTimeout(() => { + copiedJobs.value.delete(id) + copiedTimeouts.delete(id) + }, 1500), + ) + }) + } + + const unlisten = events.on('install_job', applyJobUpdate) + const unlistenInstance = events.on('instance', () => { + void refreshMetadata() + }) + onMounted(() => { + void refresh() + }) + onScopeDispose(() => { + disposed = true + unlisten() + unlistenInstance() + if (clock) clearInterval(clock) + for (const timeout of copiedTimeouts.values()) clearTimeout(timeout) + }) + + return { + activeJobs, + attentionJobs, + completedJobs, + initialized, + rate, + retry, + cancel, + togglePause, + dismiss, + clearCompleted, + copyDetails, + } +} diff --git a/apps/app-frontend/src/composables/browse/install-job-notifications.ts b/apps/app-frontend/src/components/ui/download-manager/use-install-job-display.ts similarity index 54% rename from apps/app-frontend/src/composables/browse/install-job-notifications.ts rename to apps/app-frontend/src/components/ui/download-manager/use-install-job-display.ts index c45a61cfff..6ee424eb08 100644 --- a/apps/app-frontend/src/composables/browse/install-job-notifications.ts +++ b/apps/app-frontend/src/components/ui/download-manager/use-install-job-display.ts @@ -1,55 +1,11 @@ -import { CheckIcon, CopyIcon, UpdatedIcon } from '@modrinth/assets' -import { - defineMessages, - type PopupNotificationButton, - type PopupNotificationProgressItem, - type PopupNotificationProgressType, - useVIntl, -} from '@modrinth/ui' -import { convertFileSrc } from '@tauri-apps/api/core' -import { computed, ref } from 'vue' -import type { Router } from 'vue-router' +import { defineMessages, useFormatNumber, useVIntl } from '@modrinth/ui' +import { computed } from 'vue' -import { useAppSettings } from '@/composables/use-app-settings.ts' -import { - install_job_dismiss, - install_job_list, - install_job_retry, - install_job_support_details, - installJobInstanceId, - type InstallJobSnapshot, - type InstallJobStatus, - type InstallPhaseId, - type InstallProgress, -} from '@/helpers/install' -import { get_many as getInstances } from '@/helpers/instance' -import { injectAppEvents } from '@/providers/app-events' +import type { InstallJobSnapshot, InstallPhaseId, InstallProgress } from '@/helpers/install' const messages = defineMessages({ - installs: { - id: 'app.action-bar.installs', - defaultMessage: 'Installs', - }, - retry: { - id: 'app.action-bar.install.retry', - defaultMessage: 'Retry', - }, - copyDetails: { - id: 'app.action-bar.install.copy-details', - defaultMessage: 'Copy details', - }, - copied: { - id: 'app.action-bar.install.copied-details', - defaultMessage: 'Copied', - }, - dismiss: { - id: 'app.action-bar.install.dismiss', - defaultMessage: 'Dismiss', - }, - openInstance: { - id: 'app.action-bar.install.open-instance', - defaultMessage: 'Open instance', - }, + paused: { id: 'app.download-manager.paused', defaultMessage: 'Paused' }, + canceling: { id: 'app.download-manager.canceling', defaultMessage: 'Canceling installationโ€ฆ' }, unknownInstance: { id: 'app.action-bar.install.unknown-instance', defaultMessage: 'Unknown instance', @@ -60,6 +16,35 @@ const messages = defineMessages({ }, }) +const kindMessages = defineMessages({ + create_instance: { id: 'app.download-manager.new-instance', defaultMessage: 'New instance' }, + create_modpack_instance: { id: 'app.download-manager.modpack', defaultMessage: 'Modpack' }, + create_shared_instance: { + id: 'app.download-manager.shared-instance', + defaultMessage: 'Shared instance', + }, + import_instance: { + id: 'app.download-manager.imported-instance', + defaultMessage: 'Imported instance', + }, + duplicate_instance: { + id: 'app.download-manager.duplicated-instance', + defaultMessage: 'Duplicated instance', + }, + install_existing_instance: { + id: 'app.download-manager.instance-installation', + defaultMessage: 'Instance installation', + }, + install_pack_to_existing_instance: { + id: 'app.download-manager.modpack-installation', + defaultMessage: 'Modpack installation', + }, + update_shared_instance: { + id: 'app.download-manager.shared-instance-update', + defaultMessage: 'Shared instance update', + }, +}) + const phaseMessages = defineMessages({ preparing_instance: { id: 'app.install.phase.preparing_instance', @@ -221,47 +206,64 @@ const failureSummaryMessages = defineMessages({ }, }) -const visibleJobStatuses = new Set(['queued', 'running', 'failed', 'interrupted']) - -function getDisplayIconUrl(icon: string | null | undefined): string | null { - if (!icon) return null - if (/^(https?:|data:|blob:|asset:|tauri:)/.test(icon)) return icon - return convertFileSrc(icon) -} - -export async function useInstallJobNotifications(opts: { - router: Router - handleError: (err: unknown) => void - onChange: () => void -}) { - const appEvents = injectAppEvents() - const { formatMessage } = useVIntl() - const appSettings = useAppSettings() - const jobs = ref([]) - const iconUrls = ref>({}) - const instanceNames = ref>({}) - const copiedJobIds = ref>(new Set()) - const jobOrder = new Map() - let refreshRequest = 0 - let metadataRequest = 0 - let nextJobOrder = 0 - const copiedResetTimeouts = new Map() +export function useInstallJobDisplay() { + const { formatMessage, locale } = useVIntl() + const formatNumber = useFormatNumber() + const decimalFormat = computed( + () => new Intl.NumberFormat(locale.value, { maximumFractionDigits: 1 }), + ) + const percentFormat = computed(() => new Intl.NumberFormat(locale.value, { style: 'percent' })) + const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte'] + const byteFormats = computed(() => + units.map( + (unit) => + new Intl.NumberFormat(locale.value, { + style: 'unit', + unit, + unitDisplay: 'short', + maximumFractionDigits: 1, + }), + ), + ) + const rateFormats = computed(() => + units.map( + (unit) => + new Intl.NumberFormat(locale.value, { + style: 'unit', + unit: `${unit}-per-second`, + unitDisplay: 'short', + minimumSignificantDigits: 3, + maximumSignificantDigits: 3, + }), + ), + ) + const timeFormats = computed(() => + ['second', 'minute', 'hour'].map( + (unit) => + new Intl.NumberFormat(locale.value, { + style: 'unit', + unit, + unitDisplay: 'narrow', + maximumFractionDigits: 0, + }), + ), + ) - function getTitle(job: InstallJobSnapshot): string { + function getTitle(job: InstallJobSnapshot, instanceName?: string): string { if (job.display?.title) return job.display.title if (job.details.type === 'instance') return job.details.name if (job.details.type === 'modpack' && job.details.title) return job.details.title - const instanceId = installJobInstanceId(job) - return ( - (instanceId ? instanceNames.value[instanceId] : null) ?? - formatMessage(messages.unknownInstance) - ) + return instanceName ?? formatMessage(messages.unknownInstance) } function getText(job: InstallJobSnapshot): string { + if (job.status === 'succeeded') return formatMessage(kindMessages[job.kind]) + if (job.status === 'canceled') return formatMessage(failureSummaryMessages.canceled) if (job.status === 'failed' || job.status === 'interrupted') { return getFailureSummary(job) } + if (job.canceling) return formatMessage(messages.canceling) + if (job.paused) return formatMessage(messages.paused) if (job.phase === 'preparing_java' && job.details.type === 'java') { return formatMessage(javaStepMessages[job.details.step], { version: job.details.major_version, @@ -280,12 +282,12 @@ export async function useInstallJobNotifications(opts: { 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 (code === 'canceled') { + return formatMessage(failureSummaryMessages.canceled) + } if (hasPermissionError(job)) { return formatMessage(failureSummaryMessages.noWritePermission) } @@ -371,7 +373,7 @@ export async function useInstallJobNotifications(opts: { ) } - function getProgressType(job: InstallJobSnapshot): PopupNotificationProgressType | undefined { + function getProgressType(job: InstallJobSnapshot): 'bytes' | 'count' | 'percentage' | undefined { if (!getEffectiveProgress(job)) return undefined if ( job.phase === 'preparing_java' && @@ -410,246 +412,44 @@ export async function useInstallJobNotifications(opts: { return Math.max(0, Math.min(1, progress.current / progress.total)) } - function isTerminalJob(job: InstallJobSnapshot): boolean { - return job.status === 'failed' || job.status === 'interrupted' - } - - function getJobSortRank(job: InstallJobSnapshot): number { - if (isTerminalJob(job)) return 0 - if (job.status === 'queued' || job.phase === 'preparing_instance') return 2 - return 1 - } - - function shouldShowCopyDetails(job: InstallJobSnapshot): boolean { - return isTerminalJob(job) || appSettings.getFeatureFlag('always_show_copy_details') - } - - function isCopied(job: InstallJobSnapshot): boolean { - return copiedJobIds.value.has(job.job_id) - } - - function setCopied(job: InstallJobSnapshot) { - copiedJobIds.value = new Set([...copiedJobIds.value, job.job_id]) - const existingTimeout = copiedResetTimeouts.get(job.job_id) - if (existingTimeout != null) { - window.clearTimeout(existingTimeout) - } - copiedResetTimeouts.set( - job.job_id, - window.setTimeout(() => { - copiedResetTimeouts.delete(job.job_id) - if (!copiedJobIds.value.has(job.job_id)) { - return - } - const nextCopiedJobIds = new Set(copiedJobIds.value) - nextCopiedJobIds.delete(job.job_id) - copiedJobIds.value = nextCopiedJobIds - opts.onChange() - }, 1_000), - ) - opts.onChange() - } - - async function copyJobDetails(job: InstallJobSnapshot) { - const details = await install_job_support_details(job.job_id).catch((error) => { - opts.handleError(error) - return null - }) - if (!details) { - return - } - try { - await navigator.clipboard.writeText(details) - setCopied(job) - } catch (error) { - opts.handleError(error) - } - } - - function getButtons(job: InstallJobSnapshot): PopupNotificationButton[] { - const buttons: PopupNotificationButton[] = [] - - if (isTerminalJob(job)) { - buttons.push({ - label: formatMessage(messages.retry), - icon: UpdatedIcon, - color: 'brand', - keepOpen: true, - action: async () => { - await install_job_retry(job.job_id).catch(opts.handleError) - await refresh() - }, - }) - } - - if (shouldShowCopyDetails(job)) { - const copied = isCopied(job) - buttons.push({ - label: formatMessage(copied ? messages.copied : messages.copyDetails), - icon: copied ? CheckIcon : CopyIcon, - color: 'standard', - keepOpen: true, - action: async () => { - await copyJobDetails(job) - }, - }) - } - - return buttons - } - - function getDismissHandler(job: InstallJobSnapshot): (() => Promise) | undefined { - if (isTerminalJob(job)) { - return async () => { - await install_job_dismiss(job.job_id).catch(opts.handleError) - await refresh() - } - } - return undefined + function getUnitIndex(bytes: number): number { + return Math.min(units.length - 1, Math.floor(Math.log10(Math.max(1, bytes)) / 3)) } - function setJobs(nextJobs: InstallJobSnapshot[]) { - for (const job of nextJobs) { - if (!jobOrder.has(job.job_id)) { - jobOrder.set(job.job_id, nextJobOrder++) - } - } - - const visibleJobs = nextJobs.filter((job) => visibleJobStatuses.has(job.status)) - - jobs.value = visibleJobs.sort( - (a, b) => - getJobSortRank(a) - getJobSortRank(b) || - a.created.localeCompare(b.created) || - (jobOrder.get(a.job_id) ?? 0) - (jobOrder.get(b.job_id) ?? 0), - ) - } - - const activeJobs = computed(() => - jobs.value.filter((job) => job.status === 'queued' || job.status === 'running'), - ) - - const progressItems = computed(() => - activeJobs.value.map((job) => { - const progress = getEffectiveProgress(job) - - return { - id: job.job_id, - title: getTitle(job), - text: getText(job), - iconUrl: iconUrls.value[job.job_id] ?? null, - progress: getProgress(job), - waiting: !job.progress && job.status === 'running', - showProgress: job.status === 'running', - progressType: getProgressType(job), - progressCurrent: progress?.current, - progressTotal: progress?.total, - buttons: getButtons(job), - } - }), - ) - - const terminalNotifications = computed(() => - jobs.value.filter(isTerminalJob).map((job) => ({ - id: job.job_id, - title: getTitle(job), - text: getText(job), - type: job.status === 'failed' ? ('error' as const) : ('warning' as const), - buttons: getButtons(job), - onDismiss: getDismissHandler(job), - })), - ) - - async function refreshMetadata(notify = true) { - const request = ++metadataRequest - const sourceJobs = jobs.value - const instanceIds = Array.from( - new Set( - sourceJobs - .map((job) => installJobInstanceId(job)) - .filter((instanceId): instanceId is string => !!instanceId), - ), - ) - const instances = instanceIds.length - ? await getInstances(instanceIds).catch((error) => { - opts.handleError(error) - return [] - }) - : [] - - if (request !== metadataRequest) { - return + function getProgressLabel(job: InstallJobSnapshot): string { + const progress = getEffectiveProgress(job) + if (!progress || progress.total <= 0) return '' + const current = Math.max(0, Math.min(progress.current, progress.total)) + if (getProgressType(job) === 'bytes') { + const unit = getUnitIndex(progress.total) + return `${decimalFormat.value.format(current / 1000 ** unit)} / ${byteFormats.value[unit].format(progress.total / 1000 ** unit)}` } - - const instanceIconUrls = new Map( - instances.map((instance) => [instance.id, getDisplayIconUrl(instance.icon_path)]), - ) - instanceNames.value = Object.fromEntries( - instances.map((instance) => [instance.id, instance.name]), - ) - iconUrls.value = Object.fromEntries( - sourceJobs.map((job) => [ - job.job_id, - getDisplayIconUrl(job.display?.icon) ?? - instanceIconUrls.get(installJobInstanceId(job) ?? '') ?? - null, - ]), - ) - - if (notify) { - opts.onChange() + if (getProgressType(job) === 'count') { + return `${formatNumber(current)} / ${formatNumber(progress.total)}` } + return percentFormat.value.format(getProgress(job)) } - async function refresh(notify = true) { - const request = ++refreshRequest - const nextJobs = await install_job_list(false).catch((error) => { - opts.handleError(error) - return [] - }) - - if (request !== refreshRequest) { - return - } - - setJobs(nextJobs) - await refreshMetadata(false) - - if (request !== refreshRequest) { - return - } - - if (notify) { - opts.onChange() - } + function formatRate(bytesPerSecond: number | null): string { + if (bytesPerSecond == null || bytesPerSecond <= 0) return '' + const roundedRate = Number(bytesPerSecond.toPrecision(3)) + const unit = getUnitIndex(roundedRate) + return rateFormats.value[unit].format(roundedRate / 1000 ** unit) } - function applyJobUpdate(job: InstallJobSnapshot) { - refreshRequest += 1 - const existingJob = jobs.value.find((item) => item.job_id === job.job_id) - if (existingJob && existingJob.modified.localeCompare(job.modified) > 0) { - return - } - - setJobs([...jobs.value.filter((item) => item.job_id !== job.job_id), job]) - opts.onChange() - void refreshMetadata() + function formatEta(seconds: number | null): string { + if (seconds == null || seconds <= 0) return '' + const unit = seconds < 60 ? 0 : seconds < 3600 ? 1 : 2 + return timeFormats.value[unit].format(Math.ceil(seconds / [1, 60, 3600][unit])) } - const unlisten = appEvents.on('install_job', applyJobUpdate) - await refresh(false) - return { - active: computed(() => activeJobs.value.length > 0), - title: computed(() => formatMessage(messages.installs)), - progressItems, - terminalNotifications, - refresh, - dispose: () => { - for (const timeout of copiedResetTimeouts.values()) { - window.clearTimeout(timeout) - } - unlisten() - }, + getTitle, + getText, + getProgress, + getProgressLabel, + getEffectiveProgress, + formatRate, + formatEta, } } diff --git a/apps/app-frontend/src/components/ui/settings/instances/ContentStorageSettings.vue b/apps/app-frontend/src/components/ui/settings/instances/ContentStorageSettings.vue new file mode 100644 index 0000000000..6e564259c6 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/ContentStorageSettings.vue @@ -0,0 +1,373 @@ + + + diff --git a/apps/app-frontend/src/components/ui/settings/instances/ResourceManagementSettings.vue b/apps/app-frontend/src/components/ui/settings/instances/ResourceManagementSettings.vue index d19cd56d7f..9ce5445b57 100644 --- a/apps/app-frontend/src/components/ui/settings/instances/ResourceManagementSettings.vue +++ b/apps/app-frontend/src/components/ui/settings/instances/ResourceManagementSettings.vue @@ -14,6 +14,7 @@ import { open } from '@tauri-apps/plugin-dialog' import { ref, watch } from 'vue' import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue' +import ContentStorageSettings from '@/components/ui/settings/instances/ContentStorageSettings.vue' import { useAppSettings } from '@/composables/use-app-settings.ts' import { purge_cache_types } from '@/helpers/cache.js' import { get, set } from '@/helpers/settings.ts' @@ -172,6 +173,7 @@ async function findLauncherDir() {