From 3b885cdb0f8f16e39bd1b06dfd396e63c97248f2 Mon Sep 17 00:00:00 2001 From: Wyvest Date: Mon, 30 Mar 2026 05:47:24 -0400 Subject: [PATCH] Improve modpack download resiliency, onboarding Track and surface partial modpack download failures across core and frontend. Adds a PartialModpackInstall error and counts failed/attempted downloads in the mrpack flow, returning an error if some files failed. Frontend download pipeline now runs parallel downloads that return per-item results, reports/logs failures, and exposes retry/continue UI in onboarding. Backend robustness improvements: deduplicate concurrent package inserts by reusing existing row on unique-hash constraint, detect hard-link collisions and verify hashes before skipping or erroring, and return meaningful errors when partial installs occur. Other changes: propagate richer error context when stamping migrated cluster created_at, invalidate relevant queries after migration, make settings writes awaitable and update the query cache optimistically, add queryClient usage in version-migration hook, and various type/bindings updates to reflect the new PartialModpackInstall error. These changes improve reliability and UX during batch package installs and onboarding downloads. --- apps/oneclient/desktop/src/api/commands.rs | 9 +- apps/oneclient/frontend/src/bindings.gen.ts | 12 +- .../frontend/src/components/DownloadMods.tsx | 74 +++- .../src/components/overlay/MigrationModal.tsx | 13 + .../frontend/src/hooks/useSettings.ts | 6 +- .../frontend/src/hooks/useVersionMigration.ts | 30 +- .../frontend/src/routes/app/index.tsx | 34 +- .../src/routes/onboarding/downloading.tsx | 322 ++++++++++++++---- .../preferences/versionCategory.tsx | 62 +++- .../frontend/src/routes/onboarding/route.tsx | 9 +- .../frontend/src/stores/downloadStore.ts | 18 +- packages/core/src/api/packages/mod.rs | 61 +++- .../core/src/api/packages/modpack/mrpack.rs | 36 +- 13 files changed, 578 insertions(+), 108 deletions(-) diff --git a/apps/oneclient/desktop/src/api/commands.rs b/apps/oneclient/desktop/src/api/commands.rs index bf3eb4e8..f1ae40fc 100644 --- a/apps/oneclient/desktop/src/api/commands.rs +++ b/apps/oneclient/desktop/src/api/commands.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use anyhow::Context; use onelauncher_core::api; use onelauncher_core::api::cluster::dao::ClusterId; use onelauncher_core::api::packages::modpack::data::{ModpackArchive, ModpackFileKind}; @@ -361,7 +362,13 @@ impl OneClientApi for OneClientApiImpl { // Stamp the target cluster's created_at with the actual migration time so the // home page can correctly identify it as a newly-migrated cluster. - onelauncher_core::api::cluster::dao::touch_cluster_created_at(target_id).await?; + onelauncher_core::api::cluster::dao::touch_cluster_created_at(target_id) + .await + .with_context(|| { + format!( + "failed to stamp migrated cluster created_at for target cluster {target_id}" + ) + })?; Ok(CopyClusterResult { fallback_files: fallback_files.into_iter().collect(), diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index 57de4a91..e047434d 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -192,7 +192,7 @@ export type PackageDonationPlatform = "patreon" | "buymeacoffee" | "paypal" | "g export type PackageDonationUrl = { id: PackageDonationPlatform; url: string } -export type PackageError = { type: "NoPrimaryFile"; data: string } | { type: "IsNotModPack"; data: string } | { type: "Incompatible"; data: IncompatiblePackageType } | { type: "MissingApiKey"; data: string } | { type: "UnsupportedBodyType"; data: string } | { type: "UnsupportedAuthorType"; data: string } | { type: "UnsupportedModpackFormat"; data: string } +export type PackageError = { type: "NoPrimaryFile"; data: string } | { type: "IsNotModPack"; data: string } | { type: "PartialModpackInstall"; data: string } | { type: "Incompatible"; data: IncompatiblePackageType } | { type: "MissingApiKey"; data: string } | { type: "UnsupportedBodyType"; data: string } | { type: "UnsupportedAuthorType"; data: string } | { type: "UnsupportedModpackFormat"; data: string } export type PackageGallery = { url: string; thumbnail_url: string; title: string | null; description: string | null; featured: boolean | null } @@ -325,11 +325,8 @@ export type VersionType = */ "old_beta" -const ARGS_MAP = { 'oneclient':'{"refreshArt":["path"],"getVersions":[],"getBundlesFor":["cluster_id"],"updateBundlePackages":["cluster_id"],"copyClusterContent":["source_id","target_id"],"isBundleSyncing":[],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"checkForUpdate":[],"installUpdate":[],"getClustersGroupedByMajor":[],"cacheArt":["path"],"extractBundleOverrides":["bundle_path","cluster_id"]}', 'core':'{"searchPackages":["provider","query"],"updateClusterById":["id","request"],"fetchMinecraftProfile":["uuid"],"getLogs":["id"],"launchCluster":["id","uuid","search_for_java"],"getClusters":[],"getProcessLogTail":["id","max_lines"],"killProcess":["pid"],"getRunningProcesses":[],"setClusterStage":["id","stage"],"getGlobalProfile":[],"getLoadersForVersion":["mc_version"],"getGameVersions":[],"getMultiplePackages":["provider","slugs"],"createSettingsProfile":["name"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getPackage":["provider","slug"],"syncCluster":["cluster_id"],"getLinkedPackages":["cluster_id"],"open":["input"],"readSettings":[],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"refreshAccounts":[],"getClusterById":["id"],"removeUser":["uuid"],"getDefaultUser":["fallback"],"removeCluster":["id"],"createCluster":["options"],"getWorlds":["id"],"getLogByName":["id","name"],"updateClusterProfile":["name","profile"],"setDefaultUser":["uuid"],"getUsers":[],"getPackageBody":["provider","body"],"removePackage":["cluster_id","package_hash"],"fetchLoggedInProfile":["access_token"],"setDiscordRPCMessage":["message"],"getUsersFromAuthor":["provider","author"],"isClusterRunning":["cluster_id"],"refreshAccount":["uuid"],"writeSettings":["setting"],"installModpack":["modpack","cluster_id"],"getProfileOrDefault":["name"],"removeCape":["access_token"],"getRunningProcessesByClusterId":["cluster_id"],"togglePackage":["cluster_id","package_hash"],"getScreenshots":["id"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"getUser":["uuid"],"openMsaLogin":[],"changeSkin":["access_token","skin_url","skin_variant"],"convertUsernameUUID":["username_uuid"],"changeCape":["access_token","cape_uuid"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'events':'{"message":["event"],"process":["event"],"ingress":["event"]}', 'debug':'{"getType":[],"getArch":[],"getFullDebugInfoParsedString":[],"getGitCommitHash":[],"getFamily":[],"getFullDebugInfo":[],"openDevTools":[],"getOsVersion":[],"getPackageVersion":[],"getPlatform":[],"getFullDebugInfoParsed":[],"isInDev":[],"getBuildTimestamp":[],"getLocale":[],"hasDevSettings":[],"getOsDistro":[]}' } -export type Router = { 'events': { ingress: (event: IngressPayload) => Promise, -message: (event: MessagePayload) => Promise, -process: (event: ProcessPayload) => Promise }, -'core': { getClusters: () => Promise, +const ARGS_MAP = { 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}', 'oneclient':'{"cacheArt":["path"],"refreshArt":["path"],"copyClusterContent":["source_id","target_id"],"checkForUpdate":[],"getBundlesFor":["cluster_id"],"getClustersGroupedByMajor":[],"installUpdate":[],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"updateBundlePackages":["cluster_id"],"extractBundleOverrides":["bundle_path","cluster_id"],"getVersions":[],"isBundleSyncing":[]}', 'events':'{"ingress":["event"],"message":["event"],"process":["event"]}', 'debug':'{"isInDev":[],"getFamily":[],"getLocale":[],"getArch":[],"getBuildTimestamp":[],"hasDevSettings":[],"getFullDebugInfoParsed":[],"getFullDebugInfo":[],"getFullDebugInfoParsedString":[],"getType":[],"getGitCommitHash":[],"openDevTools":[],"getPackageVersion":[],"getPlatform":[],"getOsVersion":[],"getOsDistro":[]}', 'core':'{"changeCape":["access_token","cape_uuid"],"getPackage":["provider","slug"],"installModpack":["modpack","cluster_id"],"convertUsernameUUID":["username_uuid"],"getLoadersForVersion":["mc_version"],"removeCape":["access_token"],"openMsaLogin":[],"getClusterById":["id"],"removeUser":["uuid"],"getUser":["uuid"],"refreshAccount":["uuid"],"readSettings":[],"getPackageBody":["provider","body"],"changeSkin":["access_token","skin_url","skin_variant"],"getScreenshots":["id"],"refreshAccounts":[],"launchCluster":["id","uuid","search_for_java"],"getGameVersions":[],"removeCluster":["id"],"getUsers":[],"getRunningProcessesByClusterId":["cluster_id"],"setClusterStage":["id","stage"],"getLinkedPackages":["cluster_id"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"open":["input"],"createCluster":["options"],"searchPackages":["provider","query"],"setDiscordRPCMessage":["message"],"createSettingsProfile":["name"],"setDefaultUser":["uuid"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"getRunningProcesses":[],"getProcessLogTail":["id","max_lines"],"getClusters":[],"getLogByName":["id","name"],"getProfileOrDefault":["name"],"killProcess":["pid"],"updateClusterById":["id","request"],"getGlobalProfile":[],"isClusterRunning":["cluster_id"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getUsersFromAuthor":["provider","author"],"updateClusterProfile":["name","profile"],"getLogs":["id"],"writeSettings":["setting"],"getMultiplePackages":["provider","slugs"],"syncCluster":["cluster_id"],"togglePackage":["cluster_id","package_hash"],"fetchMinecraftProfile":["uuid"],"fetchLoggedInProfile":["access_token"],"getWorlds":["id"],"getDefaultUser":["fallback"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"removePackage":["cluster_id","package_hash"]}' } +export type Router = { 'core': { getClusters: () => Promise, getClusterById: (id: number) => Promise, removeCluster: (id: number) => Promise, createCluster: (options: CreateCluster) => Promise, @@ -383,6 +380,9 @@ removeCape: (accessToken: string) => Promise, convertUsernameUUID: (usernameUuid: string) => Promise, setDiscordRPCMessage: (message: string) => Promise, open: (input: string) => Promise }, +'events': { ingress: (event: IngressPayload) => Promise, +message: (event: MessagePayload) => Promise, +process: (event: ProcessPayload) => Promise }, 'folders': { fromCluster: (folderName: string) => Promise, openCluster: (folderName: string) => Promise }, 'debug': { openDevTools: () => Promise, diff --git a/apps/oneclient/frontend/src/components/DownloadMods.tsx b/apps/oneclient/frontend/src/components/DownloadMods.tsx index 503ea00d..26ce0c5f 100644 --- a/apps/oneclient/frontend/src/components/DownloadMods.tsx +++ b/apps/oneclient/frontend/src/components/DownloadMods.tsx @@ -33,6 +33,19 @@ export interface ExternalModData extends BaseModData { export type ModData = ManagedModData | ExternalModData; export type ModDataArray = Array; +export type DownloadModsParallelResult = + | { + ok: true; + index: number; + mod: ModData; + } + | { + ok: false; + index: number; + mod: ModData; + error: unknown; + }; + export function isManagedMod(mod: ModData): mod is ManagedModData { return mod.managed === true; } @@ -137,15 +150,50 @@ export function buildModDataArray(modsPerCluster: Record Promise) { +export async function downloadModsParallel( + items: ModDataArray, + limit: number, + fn: (mod: ModData, index: number) => Promise, +): Promise> { + if (items.length === 0) + return []; + let index = 0; - const workers = Array.from({ length: limit }).fill(null).map(async () => { + const workerCount = Math.max(1, Math.min(limit, items.length)); + const results: Array = []; + + const workers = Array.from({ length: workerCount }).fill(null).map(async () => { while (index < items.length) { - const i = index++; - await fn(items[i], i); + const i = index; + index += 1; + + if (i >= items.length) + return; + + const mod = items[i]; + try { + await fn(mod, i); + results.push({ + ok: true, + index: i, + mod, + }); + } + catch (error) { + results.push({ + ok: false, + index: i, + mod, + error, + }); + } } }); - return Promise.all(workers); + + await Promise.all(workers); + + results.sort((left, right) => left.index - right.index); + return results; } function DownloadingMods({ mods, setOpen, nextPath }: { mods: ModDataArray; setOpen: React.Dispatch>; nextPath: string }) { @@ -194,7 +242,7 @@ function DownloadingMods({ mods, setOpen, nextPath }: { mods: ModDataArray; setO useEffect(() => { const downloadAll = async () => { - await downloadModsParallel(mods, 10, async (mod) => { + const results = await downloadModsParallel(mods, 10, async (mod) => { setModName(mod.name); try { await download.mutateAsync(mod); @@ -203,9 +251,21 @@ function DownloadingMods({ mods, setOpen, nextPath }: { mods: ModDataArray; setO setDownloadedMods(prev => prev + 1); } }); + + const failed = results.filter(result => !result.ok); + if (failed.length > 0) + console.error('[DownloadMods] Some mods failed to download:', { + failed: failed.length, + total: mods.length, + entries: failed.map(result => ({ + index: result.index, + name: result.mod.name, + error: result.error, + })), + }); }; - downloadAll(); + void downloadAll(); // eslint-disable-next-line react-hooks/exhaustive-deps -- download is not stable, adding it would cause infinite loops }, [mods]); diff --git a/apps/oneclient/frontend/src/components/overlay/MigrationModal.tsx b/apps/oneclient/frontend/src/components/overlay/MigrationModal.tsx index 538b3f83..db0697dd 100644 --- a/apps/oneclient/frontend/src/components/overlay/MigrationModal.tsx +++ b/apps/oneclient/frontend/src/components/overlay/MigrationModal.tsx @@ -7,6 +7,7 @@ import { useToast } from '@/utils/toast'; import { getOnlineClusterForVersion } from '@/utils/versionMap'; import { useCommand } from '@onelauncher/common'; import { Dropdown } from '@onelauncher/common/components'; +import { useQueryClient } from '@tanstack/react-query'; import { Announcement01Icon } from '@untitled-theme/icons-react'; import { useEffect, useRef, useState } from 'react'; @@ -51,6 +52,7 @@ export function MigrationModal({ const [fallbackFiles, setFallbackFiles] = useState>([]); const toast = useToast(); + const queryClient = useQueryClient(); const { data: manifest } = useCommand( ['getVersions'], @@ -87,6 +89,17 @@ export function MigrationModal({ setIsMigrating(true); try { const result = await bindings.oneclient.copyClusterContent(sourceId, effectiveTargetId); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ['getClusters'], + }), + queryClient.invalidateQueries({ + queryKey: ['getClusters', 'sortedByLastPlayed'], + }), + queryClient.invalidateQueries({ + queryKey: ['getClustersGroupedByMajor'], + }), + ]); if (result.fallback_files.length > 0) setFallbackFiles(result.fallback_files); else diff --git a/apps/oneclient/frontend/src/hooks/useSettings.ts b/apps/oneclient/frontend/src/hooks/useSettings.ts index c30ca4db..14725db9 100644 --- a/apps/oneclient/frontend/src/hooks/useSettings.ts +++ b/apps/oneclient/frontend/src/hooks/useSettings.ts @@ -12,12 +12,12 @@ export function useSettings() { }, [settings]); const setSetting = useMemo(() => { - return function setSetting(key: TKey, value: Settings[TKey]) { - bindings.core.writeSettings({ + return async function setSetting(key: TKey, value: Settings[TKey]) { + await bindings.core.writeSettings({ ...settings.data, [key]: value, }); - settings.refetch(); + await settings.refetch(); }; }, [settings]); diff --git a/apps/oneclient/frontend/src/hooks/useVersionMigration.ts b/apps/oneclient/frontend/src/hooks/useVersionMigration.ts index 202064a8..fb52e4c4 100644 --- a/apps/oneclient/frontend/src/hooks/useVersionMigration.ts +++ b/apps/oneclient/frontend/src/hooks/useVersionMigration.ts @@ -1,6 +1,7 @@ import { bindings } from '@/main'; import useMigrationStore from '@/stores/migrationStore'; import { useCommandSuspense } from '@onelauncher/common'; +import { useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; const DEFAULT_SEEN_VERSIONS = ['1.21.1', '1.21.10', '1.21.11'] as const; @@ -24,6 +25,7 @@ function areClustersEqual( } export function useVersionMigration() { + const queryClient = useQueryClient(); const { data: grouped } = useCommandSuspense( ['getClustersGroupedByMajor'], bindings.oneclient.getClustersGroupedByMajor, @@ -40,11 +42,7 @@ export function useVersionMigration() { const openForDebug = useMigrationStore(state => state.openForDebug); useEffect(() => { - type SettingsWithSeenVersions = typeof settingsData & { - seen_versions?: Array; - }; - - const settings = settingsData as SettingsWithSeenVersions; + const settings = settingsData; const storedSeenVersions = Array.isArray(settings.seen_versions) ? settings.seen_versions : []; const seenVersions = [ ...new Set([ @@ -68,10 +66,12 @@ export function useVersionMigration() { if (!areClustersEqual(migrationState.allClusters, nextAllClusters)) setAllClusters(nextAllClusters); - if ( - !areStringArraysEqual(migrationState.newVersions, unseenUnplayed) - || !areClustersEqual(migrationState.sourceClusters, sources) - ) + const migrationCandidatesChanged + = !areStringArraysEqual(migrationState.newVersions, unseenUnplayed) + || !areClustersEqual(migrationState.sourceClusters, sources); + + // Keep active modal data stable while it is open. + if (migrationCandidatesChanged && !migrationState.isOpen) setMigrationCandidates(unseenUnplayed, sources); if (unseenUnplayed.length > 0 && sources.length > 0 && !migrationState.isOpen) @@ -82,12 +82,16 @@ export function useVersionMigration() { const hasSeenVersionsChanged = !areStringArraysEqual(nextSeenVersions, normalizedStoredSeenVersions); - if (hasSeenVersionsChanged) - void bindings.core.writeSettings({ + if (hasSeenVersionsChanged) { + const nextSettings = { ...settings, seen_versions: nextSeenVersions, - } as typeof settingsData); - }, [grouped, setAllClusters, setIsOpen, setMigrationCandidates, settingsData]); + } as typeof settingsData; + + queryClient.setQueryData(['readSettings'], nextSettings); + void bindings.core.writeSettings(nextSettings); + } + }, [grouped, queryClient, setAllClusters, setIsOpen, setMigrationCandidates, settingsData]); return { isOpen, isDebugPreview, setIsOpen, newVersions, sourceClusters, allClusters, openForDebug }; } diff --git a/apps/oneclient/frontend/src/routes/app/index.tsx b/apps/oneclient/frontend/src/routes/app/index.tsx index a18805a7..964e1f85 100644 --- a/apps/oneclient/frontend/src/routes/app/index.tsx +++ b/apps/oneclient/frontend/src/routes/app/index.tsx @@ -20,6 +20,28 @@ export const Route = createFileRoute('/app/')({ component: RouteComponent, }); +function getCreatedAtMs(createdAt: string): number | null { + const parsed = Date.parse(createdAt); + if (Number.isFinite(parsed)) + return parsed; + + // Normalize high-precision RFC3339 fractions for stricter engines. + const normalized = createdAt.replace(/\.(\d{3})\d+(?=(?:Z|[+-]\d{2}:\d{2})$)/, '.$1'); + const normalizedParsed = Date.parse(normalized); + return Number.isFinite(normalizedParsed) ? normalizedParsed : null; +} + +function getBoostedCreatedAtMs(cluster: ClusterModel, now: number, boostWindowMs: number): number | null { + if (cluster.last_played !== null) + return null; + + const createdAtMs = getCreatedAtMs(cluster.created_at); + if (createdAtMs === null) + return null; + + return (now - createdAtMs) < boostWindowMs ? createdAtMs : null; +} + function RouteComponent() { // Preload the clusters for version page useCommandSuspense(['getClustersGroupedByMajor'], bindings.oneclient.getClustersGroupedByMajor); @@ -37,12 +59,16 @@ function RouteComponent() { const BOOST_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; const now = Date.now(); const displayClusters = [...lastPlayedClusters].sort((a, b) => { - const aIsNew = a.last_played === null && (now - new Date(a.created_at).getTime()) < BOOST_WINDOW_MS; - const bIsNew = b.last_played === null && (now - new Date(b.created_at).getTime()) < BOOST_WINDOW_MS; - if (aIsNew && !bIsNew) + const aBoostedCreatedAtMs = getBoostedCreatedAtMs(a, now, BOOST_WINDOW_MS); + const bBoostedCreatedAtMs = getBoostedCreatedAtMs(b, now, BOOST_WINDOW_MS); + + if (aBoostedCreatedAtMs !== null && bBoostedCreatedAtMs === null) return -1; - if (bIsNew && !aIsNew) + if (bBoostedCreatedAtMs !== null && aBoostedCreatedAtMs === null) return 1; + if (aBoostedCreatedAtMs !== null && bBoostedCreatedAtMs !== null) + return bBoostedCreatedAtMs - aBoostedCreatedAtMs; + return 0; }).slice(0, 3); diff --git a/apps/oneclient/frontend/src/routes/onboarding/downloading.tsx b/apps/oneclient/frontend/src/routes/onboarding/downloading.tsx index c2285e88..f26b7ec0 100644 --- a/apps/oneclient/frontend/src/routes/onboarding/downloading.tsx +++ b/apps/oneclient/frontend/src/routes/onboarding/downloading.tsx @@ -4,9 +4,10 @@ import { useSettings } from '@/hooks/useSettings'; import { bindings } from '@/main'; import useDownloadStore from '@/stores/downloadStore'; import { getCachedOnboardingTips, loadOnboardingTips } from '@/utils/onboardingFunFacts'; -import { useCommandMut } from '@onelauncher/common'; +import { useToast } from '@/utils/toast'; +import { getMessageFromError, isLauncherError, useCommandSuspense } from '@onelauncher/common'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; export const Route = createFileRoute('/onboarding/downloading')({ component: RouteComponent, @@ -17,13 +18,45 @@ const NAVBAR_HEIGHT = 80; // How long the fade-out takes before navigating to /app const FADE_DURATION_MS = 700; +interface DownloadFailure { + mod: ModData; + reason: string; + index: number; + clusterVersion: string; +} + +function getErrorMessage(error: unknown): string { + if (isLauncherError(error)) + return getMessageFromError(error); + + if (error instanceof Error) + return error.message; + + if (typeof error === 'string') + return error; + + try { + return JSON.stringify(error); + } + catch { + return 'Unknown download error'; + } +} + function RouteComponent() { const navigate = useNavigate(); - const { modsPerCluster } = useDownloadStore(); + const { modsPerCluster, isFinishingOnboarding } = useDownloadStore(); const clearStore = useDownloadStore(s => s.clear); + const markOnboardingFinishing = useDownloadStore(s => s.markOnboardingFinishing); const { setSetting } = useSettings(); + const toast = useToast(); + const { data: clusters } = useCommandSuspense(['getClusters'], () => bindings.core.getClusters()); const [tips, setTips] = useState>(getCachedOnboardingTips); + const clusterVersionById = useMemo(() => { + return new Map(clusters.map(cluster => [cluster.id, cluster.mc_version])); + }, [clusters]); + const tip = useMemo(() => tips[Math.floor(Math.random() * tips.length)], [tips]); useEffect(() => { @@ -52,31 +85,124 @@ function RouteComponent() { const initiallyEmpty = useRef(Object.keys(modsPerCluster).length === 0); useEffect(() => { - if (initiallyEmpty.current) - navigate({ to: '/onboarding/preferences/versionCategory' }); - }, [navigate]); + if (!initiallyEmpty.current) + return; + + if (isFinishingOnboarding) + return; + + navigate({ to: '/onboarding/preferences/versionCategory' }); + }, [isFinishingOnboarding, navigate]); const mods = useMemo(() => buildModDataArray(modsPerCluster), [modsPerCluster]); + const bundledManagedIdsByCluster = useMemo(() => { + const map: Map> = new Map(); - const [downloadedMods, setDownloadedMods] = useState(0); + for (const mod of mods) { + if (!isManagedMod(mod) || !mod.bundleName) + continue; + + const knownManagedIds: Set = map.get(mod.clusterId) ?? new Set(); + knownManagedIds.add(mod.id); + map.set(mod.clusterId, knownManagedIds); + } + + return map; + }, [mods]); + + const [processedMods, setProcessedMods] = useState(0); + const [successfulMods, setSuccessfulMods] = useState(0); + const [failedMods, setFailedMods] = useState>([]); + const [batchError, setBatchError] = useState(null); + const [retryQueue, setRetryQueue] = useState>([]); + const [currentBatchTotal, setCurrentBatchTotal] = useState(0); + const [isRunningBatch, setIsRunningBatch] = useState(false); const [isComplete, setIsComplete] = useState(false); const [isLeaving, setIsLeaving] = useState(false); const hasStarted = useRef(false); + const finishTimerRef = useRef(null); - const percentage = mods.length > 0 ? Math.round((downloadedMods / mods.length) * 100) : 0; + const percentage = currentBatchTotal > 0 ? Math.round((processedMods / currentBatchTotal) * 100) : 0; + const hasBundledDependency = useCallback((clusterId: number, dependencyProjectId: string): boolean => { + return bundledManagedIdsByCluster.get(clusterId)?.has(dependencyProjectId) ?? false; + }, [bundledManagedIdsByCluster]); - const download = useCommandMut(async (mod: ModData) => { + const finishOnboarding = useCallback(() => { + if (isLeaving) + return; + + markOnboardingFinishing(); + + setIsLeaving(true); + + if (finishTimerRef.current !== null) + window.clearTimeout(finishTimerRef.current); + + finishTimerRef.current = window.setTimeout(() => { + void (async () => { + try { + await setSetting('seen_onboarding', true); + } + catch (error) { + console.error('[onboarding/downloading] Failed to persist seen_onboarding before continue; navigating anyway.', error); + } + + navigate({ to: '/app', replace: true }); + clearStore(); + })(); + }, FADE_DURATION_MS); + }, [clearStore, isLeaving, markOnboardingFinishing, navigate, setSetting]); + + useEffect(() => { + return () => { + if (finishTimerRef.current !== null) + window.clearTimeout(finishTimerRef.current); + }; + }, []); + + const downloadMod = async (mod: ModData): Promise => { if (isManagedMod(mod)) { if (mod.dependencies.length > 0) for (const dependency of mod.dependencies) { const cluster = await bindings.core.getClusterById(mod.clusterId); if (!cluster) - continue; + throw new Error(`Cluster ${mod.clusterId} not found while downloading dependency for '${mod.name}'`); + if (dependency.dependency_type === 'required') { - const slug = dependency.project_id ?? ''; - const versions = await bindings.core.getPackageVersions(mod.provider, slug, cluster.mc_version, cluster.mc_loader, 0, 1); - if (versions.items.length !== 0) - await bindings.core.downloadPackage(mod.provider, slug, versions.items[0].version_id, cluster.id, null); + const dependencyProjectId = dependency.project_id ?? ''; + if (!dependencyProjectId) + throw new Error(`Required dependency for '${mod.name}' is missing a project id`); + + const dependencySatisfiedByBundle + = mod.bundleName !== null && hasBundledDependency(cluster.id, dependencyProjectId); + + const versions = await bindings.core.getPackageVersions( + mod.provider, + dependencyProjectId, + cluster.mc_version, + cluster.mc_loader, + 0, + 1, + ); + + if (versions.items.length === 0) { + if (dependencySatisfiedByBundle) { + console.warn( + `[onboarding/downloading] Skipping dependency '${dependencyProjectId}' compatibility lookup for '${mod.name}' in cluster ${cluster.id} (MC ${cluster.mc_version}) because it is present in the selected bundle.`, + ); + continue; + } + + throw new Error(`No compatible dependency versions found for '${dependencyProjectId}' in cluster ${cluster.id}`); + } + + await bindings.core.downloadPackage( + mod.provider, + dependencyProjectId, + versions.items[0].version_id, + cluster.id, + null, + ); } } @@ -91,64 +217,95 @@ function RouteComponent() { else await bindings.core.downloadExternalPackage(mod.package, mod.clusterId, null, null); } - }); + }; + + const startDownloadBatch = async (batchMods: Array) => { + setIsRunningBatch(true); + setBatchError(null); + setFailedMods([]); + setRetryQueue([]); + setIsComplete(false); + setProcessedMods(0); + setSuccessfulMods(0); + setCurrentBatchTotal(batchMods.length); + + try { + const results = await downloadModsParallel(batchMods, 10, async (mod) => { + try { + await downloadMod(mod); + setSuccessfulMods(prev => prev + 1); + } + finally { + setProcessedMods(prev => prev + 1); + } + }); + + const failures = results + .filter(result => !result.ok) + .map((result) => { + return { + mod: result.mod, + reason: getErrorMessage(result.error), + index: result.index, + clusterVersion: clusterVersionById.get(result.mod.clusterId) ?? 'Unknown version', + }; + }); + + if (failures.length > 0) { + setFailedMods(failures); + setRetryQueue(failures.map(failure => failure.mod)); + toast({ + type: 'error', + title: 'Some mods failed to download', + message: `${failures.length} of ${batchMods.length} failed. Review the summary and retry.`, + }); + } + } + catch (error) { + const message = getErrorMessage(error); + setBatchError(message); + setRetryQueue(batchMods); + toast({ + type: 'error', + title: 'Download batch crashed', + message, + }); + } + finally { + setIsRunningBatch(false); + setIsComplete(true); + } + }; useEffect(() => { if (hasStarted.current || mods.length === 0) return; hasStarted.current = true; - - const downloadAll = async () => { - await downloadModsParallel(mods, 10, async (mod) => { - try { - await download.mutateAsync(mod); - } - catch (e) { - // Log the full Tauri error object so we can see what went wrong - console.error( - `[OneClient] Failed to download mod "${mod.name}" (cluster ${mod.clusterId}):`, - JSON.stringify(e), - e, - ); - } - finally { - // Always increment even on error so progress still moves forward - setDownloadedMods(prev => prev + 1); - } - }); - setIsComplete(true); - }; - - // Catch-all: if downloadAll itself rejects for any unexpected reason, - // still mark complete so the user isn't stuck on this screen. - downloadAll().catch((e) => { - console.error('[OneClient] Unexpected error in downloadAll, forcing completion:', JSON.stringify(e), e); - setIsComplete(true); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps -- download is not stable + void startDownloadBatch(mods); + // eslint-disable-next-line react-hooks/exhaustive-deps -- startDownloadBatch closes over mutable progress state; this effect intentionally runs once per mods snapshot. }, [mods]); useEffect(() => { - if (!isComplete) + if (!isComplete || failedMods.length > 0 || batchError !== null) return; // Brief pause at 100%, then fade out, then navigate straight to /app. const pauseTimer = setTimeout(() => { - setIsLeaving(true); - - const fadeTimer = setTimeout(() => { - // Mark onboarding as seen (previously done by finished.tsx) - setSetting('seen_onboarding', true); - // Navigate first, then clear the store to avoid the empty-store redirect firing - navigate({ to: '/app' }); - clearStore(); - }, FADE_DURATION_MS); - - return () => clearTimeout(fadeTimer); + finishOnboarding(); }, 400); return () => clearTimeout(pauseTimer); - }, [isComplete, clearStore, navigate, setSetting]); + }, [batchError, failedMods.length, finishOnboarding, isComplete]); + + const handleRetryFailed = () => { + if (retryQueue.length === 0 || isRunningBatch) + return; + + void startDownloadBatch(retryQueue); + }; + + const hasFailures = failedMods.length > 0 || batchError !== null; + const heading = hasFailures ? 'Download issues found' : 'Downloading...'; return (
{/* Top left: title */}

- Downloading... + {heading}

+ {hasFailures && isComplete + ? ( +
+

Some mods could not be downloaded.

+

+ Successful: {successfulMods} / {currentBatchTotal} +

+ {batchError + ?

Batch error: {batchError}

+ : null} + {failedMods.length > 0 + ? ( +
+ {failedMods.slice(0, 8).map((failure) => { + return ( +

+ {failure.mod.name} (MC {failure.clusterVersion}, cluster {failure.mod.clusterId}): {failure.reason} +

+ ); + })} + {failedMods.length > 8 + ?

+{failedMods.length - 8} more failures

+ : null} +
+ ) + : null} +
+ + +
+
+ ) + : null} + {/* Spacer — pushes bottom content to the bottom */}
@@ -179,6 +382,9 @@ function RouteComponent() { {percentage} % + + Processed: {processedMods} / {currentBatchTotal} +
mods.length > 0); const setDownloadData = useDownloadStore(s => s.setDownloadData); + const [isPreparingNext, setIsPreparingNext] = useState(false); + const [overrideFailures, setOverrideFailures] = useState>([]); + + function toErrorMessage(error: unknown): string { + if (error instanceof Error) + return error.message; + + if (typeof error === 'string') + return error; + + try { + return JSON.stringify(error); + } + catch { + return 'Unknown override extraction error'; + } + } + + const handleBeforeNext = async (): Promise => { + setIsPreparingNext(true); + setOverrideFailures([]); + const failures: Array = []; - const handleBeforeNext = async () => { for (const [clusterId, bundles] of Object.entries(bundlesPerCluster)) for (const bundle of bundles) { const selectedMods = modsPerCluster[clusterId] ?? []; const hasSelectedBundleContent = selectedMods.some(mod => mod.bundleName === bundle.manifest.name); if (!hasSelectedBundleContent) continue; + try { await bindings.oneclient.extractBundleOverrides(bundle.path, Number(clusterId)); } catch (e) { console.error(`Failed to extract overrides for bundle ${bundle.manifest.name}:`, e); + failures.push({ + clusterId: Number(clusterId), + bundleName: bundle.manifest.name, + reason: toErrorMessage(e), + }); } } + + setIsPreparingNext(false); + if (failures.length > 0) { + setOverrideFailures(failures); + return false; + } + setDownloadData(modsPerCluster, bundlesPerCluster); + return true; }; return ( @@ -193,6 +234,23 @@ function RouteComponent() {

Choose which mod bundles to install. Your selection will be applied across all available versions.

+ {overrideFailures.length > 0 + ? ( +
+

Some bundle overrides failed to extract.

+

Fix or retry before continuing.

+
+ {overrideFailures.map((failure) => { + return ( +

+ Cluster {failure.clusterId} / {failure.bundleName}: {failure.reason} +

+ ); + })} +
+
+ ) + : null}
{unifiedBundles.map((ub) => { @@ -222,7 +280,7 @@ function RouteComponent() {
- + ); } diff --git a/apps/oneclient/frontend/src/routes/onboarding/route.tsx b/apps/oneclient/frontend/src/routes/onboarding/route.tsx index fd55bac0..0f6da41d 100644 --- a/apps/oneclient/frontend/src/routes/onboarding/route.tsx +++ b/apps/oneclient/frontend/src/routes/onboarding/route.tsx @@ -251,7 +251,7 @@ function BackgroundGradient() { ); } -export function OnboardingNavigation({ disableNext, onBeforeNext }: { disableNext?: boolean; onBeforeNext?: () => Promise | void }) { +export function OnboardingNavigation({ disableNext, onBeforeNext }: { disableNext?: boolean; onBeforeNext?: () => Promise | boolean | void }) { const navigate = useNavigate(); const { isFirstStep, previousPath, nextPath } = Route.useLoaderData(); @@ -259,8 +259,11 @@ export function OnboardingNavigation({ disableNext, onBeforeNext }: { disableNex if (disableNext) return; - if (onBeforeNext) - await onBeforeNext(); + if (onBeforeNext) { + const shouldContinue = await onBeforeNext(); + if (shouldContinue === false) + return; + } navigate({ to: nextPath ?? '/app' }); } diff --git a/apps/oneclient/frontend/src/stores/downloadStore.ts b/apps/oneclient/frontend/src/stores/downloadStore.ts index 0bd9bbdd..57056b3d 100644 --- a/apps/oneclient/frontend/src/stores/downloadStore.ts +++ b/apps/oneclient/frontend/src/stores/downloadStore.ts @@ -5,19 +5,33 @@ import { create } from 'zustand'; export interface DownloadStore { modsPerCluster: Record>; bundlesPerCluster: Record>; + isFinishingOnboarding: boolean; } export interface DownloadStoreActions { setDownloadData: (modsPerCluster: Record>, bundlesPerCluster: Record>) => void; + markOnboardingFinishing: () => void; + resetOnboardingFinishing: () => void; clear: () => void; } export const useDownloadStore = create()(set => ({ modsPerCluster: {}, bundlesPerCluster: {}, + isFinishingOnboarding: false, - setDownloadData: (modsPerCluster, bundlesPerCluster) => set({ modsPerCluster, bundlesPerCluster }), - clear: () => set({ modsPerCluster: {}, bundlesPerCluster: {} }), + setDownloadData: (modsPerCluster, bundlesPerCluster) => set({ + modsPerCluster, + bundlesPerCluster, + isFinishingOnboarding: false, + }), + markOnboardingFinishing: () => set({ isFinishingOnboarding: true }), + resetOnboardingFinishing: () => set({ isFinishingOnboarding: false }), + clear: () => set(state => ({ + modsPerCluster: {}, + bundlesPerCluster: {}, + isFinishingOnboarding: state.isFinishingOnboarding, + })), })); export default useDownloadStore; diff --git a/packages/core/src/api/packages/mod.rs b/packages/core/src/api/packages/mod.rs index 2db6199c..1fc10bd7 100644 --- a/packages/core/src/api/packages/mod.rs +++ b/packages/core/src/api/packages/mod.rs @@ -28,6 +28,8 @@ pub enum PackageError { NoPrimaryFile, #[error("package is not a modpack")] IsNotModPack, + #[error("modpack install completed with partial failures ({failed} of {total} files failed)")] + PartialModpackInstall { failed: u64, total: u64 }, #[error(transparent)] Incompatible(#[from] IncompatiblePackageType), #[error("missing API key for provider '{0}'")] @@ -208,7 +210,23 @@ async fn persist_downloaded_package( update_existing_package: bool, ) -> LauncherResult { if !update_existing_package { - return dao::insert_package(model.into()).await; + let model_hash = model.hash.clone(); + return match dao::insert_package(model.into()).await { + Ok(inserted) => Ok(inserted), + Err(LauncherError::DbError(err)) if is_unique_package_hash_violation(&err) => { + (dao::get_package_by_hash(model_hash.clone()).await?).map_or_else( + || Err(LauncherError::DbError(err)), + |existing| { + tracing::debug!( + "package '{}' was inserted concurrently; reusing existing row", + model_hash, + ); + Ok(existing) + }, + ) + } + Err(error) => Err(error), + }; } let hash = model.hash.clone(); @@ -241,6 +259,14 @@ async fn persist_downloaded_package( .await } +fn is_unique_package_hash_violation(error: &sea_orm::DbErr) -> bool { + let message = error.to_string(); + message.contains("UNIQUE constraint failed: packages.hash") + || (message.contains("duplicate key value") + && message.contains("packages") + && message.contains("hash")) +} + /// Links a package to a cluster on the file system and in database. /// * `skip_compatibility` - Checks whether the `mc_loader` and `mc_version` is compatible. Default is false #[tracing::instrument(level = "debug", skip_all)] @@ -332,9 +358,13 @@ pub async fn link_many_packages_to_cluster( hard_link(package, cluster).await?; } - dao::link_many_packages_to_cluster(packages, cluster).await?; + if compatible_packages.is_empty() { + return Ok(0); + } - Ok(0) + let linked = dao::link_many_packages_to_cluster(&compatible_packages, cluster).await?; + + Ok(linked) } #[tracing::instrument(level = "debug", skip_all)] @@ -353,9 +383,28 @@ async fn hard_link(package: &packages::Model, cluster: &clusters::Model) -> Laun io::create_dir_all(&dest_dir).await?; let dest_pkg = dest_dir.join(package.file_name.as_str()); - tokio::fs::hard_link(src_pkg, dest_pkg) - .await - .map_err(IOError::from)?; + match tokio::fs::hard_link(&src_pkg, &dest_pkg).await { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + let existing_hash = HashAlgorithm::Sha1.hash_file(&dest_pkg).await?; + if existing_hash == package.hash { + tracing::debug!( + "package '{}' is already present at '{}' with matching hash; skipping hard-link", + package.display_name, + dest_pkg.display() + ); + } else { + return Err(anyhow::anyhow!( + "destination '{}' already exists with mismatched hash (existing: {}, expected: {})", + dest_pkg.display(), + existing_hash, + package.hash, + ) + .into()); + } + } + Err(err) => return Err(IOError::from(err).into()), + } Ok(()) } diff --git a/packages/core/src/api/packages/modpack/mrpack.rs b/packages/core/src/api/packages/modpack/mrpack.rs index d4d8a57c..e8acbe51 100644 --- a/packages/core/src/api/packages/modpack/mrpack.rs +++ b/packages/core/src/api/packages/modpack/mrpack.rs @@ -10,6 +10,7 @@ use serde::Deserialize; use tokio::sync::OnceCell; use crate::api::cluster::ClusterError; +use crate::api::packages::PackageError; use crate::api::packages::data::{ExternalPackage, ManagedVersion, PackageOverrides, PackageSide}; use crate::api::packages::modpack::data::{ ModpackArchive, ModpackFile, ModpackFileKind, ModpackManifest, @@ -208,6 +209,8 @@ pub(super) async fn download_and_link_packages( // if cluster.mc_loader_version.is_some_and(|v| v ) let mut packages_to_link = Vec::new(); + let mut failed_downloads = 0u64; + let mut attempted_downloads = 0u64; // TODO: Ingress for file in &manifest.files { @@ -215,12 +218,21 @@ pub(super) async fn download_and_link_packages( continue; } + attempted_downloads += 1; + match &file.kind { ModpackFileKind::Managed(managed) => { let (package, version) = &**managed; match api::packages::download_package(package, version, None, None).await { Ok(model) => packages_to_link.push(model), - Err(e) => tracing::error!("failed to download package: {e:?}"), + Err(e) => { + failed_downloads += 1; + tracing::error!( + "failed to download package '{}' version '{}': {e:?}", + package.id, + version.version_id, + ); + } } } ModpackFileKind::External(package) => { @@ -234,8 +246,17 @@ pub(super) async fn download_and_link_packages( .await { Ok(Some(model)) => packages_to_link.push(model), - Ok(None) => {} - Err(e) => tracing::error!("failed to download external package: {e:?}"), + Ok(None) => { + failed_downloads += 1; + tracing::error!("external package '{}' was not downloaded", package.url); + } + Err(e) => { + failed_downloads += 1; + tracing::error!( + "failed to download external package '{}': {e:?}", + package.url + ); + } } } } @@ -250,6 +271,15 @@ pub(super) async fn download_and_link_packages( if linked < packages_to_link.len() as u64 { tracing::warn!("not all packages could be linked to the cluster, some errors occurred"); } + + if failed_downloads > 0 { + return Err(PackageError::PartialModpackInstall { + failed: failed_downloads, + total: attempted_downloads, + } + .into()); + } + Ok(()) }