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.
This commit is contained in:
Wyvest 2026-03-30 05:47:24 -04:00
parent 9f1b6d6ae2
commit 3b885cdb0f
No known key found for this signature in database
13 changed files with 578 additions and 108 deletions

View file

@ -1,6 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use anyhow::Context;
use onelauncher_core::api; use onelauncher_core::api;
use onelauncher_core::api::cluster::dao::ClusterId; use onelauncher_core::api::cluster::dao::ClusterId;
use onelauncher_core::api::packages::modpack::data::{ModpackArchive, ModpackFileKind}; 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 // 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. // 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 { Ok(CopyClusterResult {
fallback_files: fallback_files.into_iter().collect(), fallback_files: fallback_files.into_iter().collect(),

View file

@ -192,7 +192,7 @@ export type PackageDonationPlatform = "patreon" | "buymeacoffee" | "paypal" | "g
export type PackageDonationUrl = { id: PackageDonationPlatform; url: string } 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 } 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" "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":[]}' } 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 = { 'events': { ingress: (event: IngressPayload) => Promise<void>, export type Router = { 'core': { getClusters: () => Promise<ClusterModel[]>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> },
'core': { getClusters: () => Promise<ClusterModel[]>,
getClusterById: (id: number) => Promise<ClusterModel | null>, getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>, removeCluster: (id: number) => Promise<null>,
createCluster: (options: CreateCluster) => Promise<ClusterModel>, createCluster: (options: CreateCluster) => Promise<ClusterModel>,
@ -383,6 +380,9 @@ removeCape: (accessToken: string) => Promise<MojangFullPlayerProfile>,
convertUsernameUUID: (usernameUuid: string) => Promise<MowojangProfile>, convertUsernameUUID: (usernameUuid: string) => Promise<MowojangProfile>,
setDiscordRPCMessage: (message: string) => Promise<null>, setDiscordRPCMessage: (message: string) => Promise<null>,
open: (input: string) => Promise<null> }, open: (input: string) => Promise<null> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> },
'folders': { fromCluster: (folderName: string) => Promise<string>, 'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> }, openCluster: (folderName: string) => Promise<null> },
'debug': { openDevTools: () => Promise<void>, 'debug': { openDevTools: () => Promise<void>,

View file

@ -33,6 +33,19 @@ export interface ExternalModData extends BaseModData {
export type ModData = ManagedModData | ExternalModData; export type ModData = ManagedModData | ExternalModData;
export type ModDataArray = Array<ModData>; export type ModDataArray = Array<ModData>;
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 { export function isManagedMod(mod: ModData): mod is ManagedModData {
return mod.managed === true; return mod.managed === true;
} }
@ -137,15 +150,50 @@ export function buildModDataArray(modsPerCluster: Record<string, Array<ModWithBu
return modsList; return modsList;
} }
export function downloadModsParallel(items: ModDataArray, limit: number, fn: (mod: ModData, index: number) => Promise<void>) { export async function downloadModsParallel(
items: ModDataArray,
limit: number,
fn: (mod: ModData, index: number) => Promise<void>,
): Promise<Array<DownloadModsParallelResult>> {
if (items.length === 0)
return [];
let index = 0; 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<DownloadModsParallelResult> = [];
const workers = Array.from({ length: workerCount }).fill(null).map(async () => {
while (index < items.length) { while (index < items.length) {
const i = index++; const i = index;
await fn(items[i], i); 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<React.SetStateAction<boolean>>; nextPath: string }) { function DownloadingMods({ mods, setOpen, nextPath }: { mods: ModDataArray; setOpen: React.Dispatch<React.SetStateAction<boolean>>; nextPath: string }) {
@ -194,7 +242,7 @@ function DownloadingMods({ mods, setOpen, nextPath }: { mods: ModDataArray; setO
useEffect(() => { useEffect(() => {
const downloadAll = async () => { const downloadAll = async () => {
await downloadModsParallel(mods, 10, async (mod) => { const results = await downloadModsParallel(mods, 10, async (mod) => {
setModName(mod.name); setModName(mod.name);
try { try {
await download.mutateAsync(mod); await download.mutateAsync(mod);
@ -203,9 +251,21 @@ function DownloadingMods({ mods, setOpen, nextPath }: { mods: ModDataArray; setO
setDownloadedMods(prev => prev + 1); 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 // eslint-disable-next-line react-hooks/exhaustive-deps -- download is not stable, adding it would cause infinite loops
}, [mods]); }, [mods]);

View file

@ -7,6 +7,7 @@ import { useToast } from '@/utils/toast';
import { getOnlineClusterForVersion } from '@/utils/versionMap'; import { getOnlineClusterForVersion } from '@/utils/versionMap';
import { useCommand } from '@onelauncher/common'; import { useCommand } from '@onelauncher/common';
import { Dropdown } from '@onelauncher/common/components'; import { Dropdown } from '@onelauncher/common/components';
import { useQueryClient } from '@tanstack/react-query';
import { Announcement01Icon } from '@untitled-theme/icons-react'; import { Announcement01Icon } from '@untitled-theme/icons-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
@ -51,6 +52,7 @@ export function MigrationModal({
const [fallbackFiles, setFallbackFiles] = useState<Array<string>>([]); const [fallbackFiles, setFallbackFiles] = useState<Array<string>>([]);
const toast = useToast(); const toast = useToast();
const queryClient = useQueryClient();
const { data: manifest } = useCommand( const { data: manifest } = useCommand(
['getVersions'], ['getVersions'],
@ -87,6 +89,17 @@ export function MigrationModal({
setIsMigrating(true); setIsMigrating(true);
try { try {
const result = await bindings.oneclient.copyClusterContent(sourceId, effectiveTargetId); 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) if (result.fallback_files.length > 0)
setFallbackFiles(result.fallback_files); setFallbackFiles(result.fallback_files);
else else

View file

@ -12,12 +12,12 @@ export function useSettings() {
}, [settings]); }, [settings]);
const setSetting = useMemo(() => { const setSetting = useMemo(() => {
return function setSetting<TKey extends keyof Settings>(key: TKey, value: Settings[TKey]) { return async function setSetting<TKey extends keyof Settings>(key: TKey, value: Settings[TKey]) {
bindings.core.writeSettings({ await bindings.core.writeSettings({
...settings.data, ...settings.data,
[key]: value, [key]: value,
}); });
settings.refetch(); await settings.refetch();
}; };
}, [settings]); }, [settings]);

View file

@ -1,6 +1,7 @@
import { bindings } from '@/main'; import { bindings } from '@/main';
import useMigrationStore from '@/stores/migrationStore'; import useMigrationStore from '@/stores/migrationStore';
import { useCommandSuspense } from '@onelauncher/common'; import { useCommandSuspense } from '@onelauncher/common';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react'; import { useEffect } from 'react';
const DEFAULT_SEEN_VERSIONS = ['1.21.1', '1.21.10', '1.21.11'] as const; const DEFAULT_SEEN_VERSIONS = ['1.21.1', '1.21.10', '1.21.11'] as const;
@ -24,6 +25,7 @@ function areClustersEqual(
} }
export function useVersionMigration() { export function useVersionMigration() {
const queryClient = useQueryClient();
const { data: grouped } = useCommandSuspense( const { data: grouped } = useCommandSuspense(
['getClustersGroupedByMajor'], ['getClustersGroupedByMajor'],
bindings.oneclient.getClustersGroupedByMajor, bindings.oneclient.getClustersGroupedByMajor,
@ -40,11 +42,7 @@ export function useVersionMigration() {
const openForDebug = useMigrationStore(state => state.openForDebug); const openForDebug = useMigrationStore(state => state.openForDebug);
useEffect(() => { useEffect(() => {
type SettingsWithSeenVersions = typeof settingsData & { const settings = settingsData;
seen_versions?: Array<string>;
};
const settings = settingsData as SettingsWithSeenVersions;
const storedSeenVersions = Array.isArray(settings.seen_versions) ? settings.seen_versions : []; const storedSeenVersions = Array.isArray(settings.seen_versions) ? settings.seen_versions : [];
const seenVersions = [ const seenVersions = [
...new Set([ ...new Set([
@ -68,10 +66,12 @@ export function useVersionMigration() {
if (!areClustersEqual(migrationState.allClusters, nextAllClusters)) if (!areClustersEqual(migrationState.allClusters, nextAllClusters))
setAllClusters(nextAllClusters); setAllClusters(nextAllClusters);
if ( const migrationCandidatesChanged
!areStringArraysEqual(migrationState.newVersions, unseenUnplayed) = !areStringArraysEqual(migrationState.newVersions, unseenUnplayed)
|| !areClustersEqual(migrationState.sourceClusters, sources) || !areClustersEqual(migrationState.sourceClusters, sources);
)
// Keep active modal data stable while it is open.
if (migrationCandidatesChanged && !migrationState.isOpen)
setMigrationCandidates(unseenUnplayed, sources); setMigrationCandidates(unseenUnplayed, sources);
if (unseenUnplayed.length > 0 && sources.length > 0 && !migrationState.isOpen) if (unseenUnplayed.length > 0 && sources.length > 0 && !migrationState.isOpen)
@ -82,12 +82,16 @@ export function useVersionMigration() {
const hasSeenVersionsChanged const hasSeenVersionsChanged
= !areStringArraysEqual(nextSeenVersions, normalizedStoredSeenVersions); = !areStringArraysEqual(nextSeenVersions, normalizedStoredSeenVersions);
if (hasSeenVersionsChanged) if (hasSeenVersionsChanged) {
void bindings.core.writeSettings({ const nextSettings = {
...settings, ...settings,
seen_versions: nextSeenVersions, seen_versions: nextSeenVersions,
} as typeof settingsData); } as typeof settingsData;
}, [grouped, setAllClusters, setIsOpen, setMigrationCandidates, settingsData]);
queryClient.setQueryData(['readSettings'], nextSettings);
void bindings.core.writeSettings(nextSettings);
}
}, [grouped, queryClient, setAllClusters, setIsOpen, setMigrationCandidates, settingsData]);
return { isOpen, isDebugPreview, setIsOpen, newVersions, sourceClusters, allClusters, openForDebug }; return { isOpen, isDebugPreview, setIsOpen, newVersions, sourceClusters, allClusters, openForDebug };
} }

View file

@ -20,6 +20,28 @@ export const Route = createFileRoute('/app/')({
component: RouteComponent, 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() { function RouteComponent() {
// Preload the clusters for version page // Preload the clusters for version page
useCommandSuspense(['getClustersGroupedByMajor'], bindings.oneclient.getClustersGroupedByMajor); useCommandSuspense(['getClustersGroupedByMajor'], bindings.oneclient.getClustersGroupedByMajor);
@ -37,12 +59,16 @@ function RouteComponent() {
const BOOST_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; const BOOST_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
const now = Date.now(); const now = Date.now();
const displayClusters = [...lastPlayedClusters].sort((a, b) => { const displayClusters = [...lastPlayedClusters].sort((a, b) => {
const aIsNew = a.last_played === null && (now - new Date(a.created_at).getTime()) < BOOST_WINDOW_MS; const aBoostedCreatedAtMs = getBoostedCreatedAtMs(a, now, BOOST_WINDOW_MS);
const bIsNew = b.last_played === null && (now - new Date(b.created_at).getTime()) < BOOST_WINDOW_MS; const bBoostedCreatedAtMs = getBoostedCreatedAtMs(b, now, BOOST_WINDOW_MS);
if (aIsNew && !bIsNew)
if (aBoostedCreatedAtMs !== null && bBoostedCreatedAtMs === null)
return -1; return -1;
if (bIsNew && !aIsNew) if (bBoostedCreatedAtMs !== null && aBoostedCreatedAtMs === null)
return 1; return 1;
if (aBoostedCreatedAtMs !== null && bBoostedCreatedAtMs !== null)
return bBoostedCreatedAtMs - aBoostedCreatedAtMs;
return 0; return 0;
}).slice(0, 3); }).slice(0, 3);

View file

@ -4,9 +4,10 @@ import { useSettings } from '@/hooks/useSettings';
import { bindings } from '@/main'; import { bindings } from '@/main';
import useDownloadStore from '@/stores/downloadStore'; import useDownloadStore from '@/stores/downloadStore';
import { getCachedOnboardingTips, loadOnboardingTips } from '@/utils/onboardingFunFacts'; 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 { 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')({ export const Route = createFileRoute('/onboarding/downloading')({
component: RouteComponent, component: RouteComponent,
@ -17,13 +18,45 @@ const NAVBAR_HEIGHT = 80;
// How long the fade-out takes before navigating to /app // How long the fade-out takes before navigating to /app
const FADE_DURATION_MS = 700; 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() { function RouteComponent() {
const navigate = useNavigate(); const navigate = useNavigate();
const { modsPerCluster } = useDownloadStore(); const { modsPerCluster, isFinishingOnboarding } = useDownloadStore();
const clearStore = useDownloadStore(s => s.clear); const clearStore = useDownloadStore(s => s.clear);
const markOnboardingFinishing = useDownloadStore(s => s.markOnboardingFinishing);
const { setSetting } = useSettings(); const { setSetting } = useSettings();
const toast = useToast();
const { data: clusters } = useCommandSuspense(['getClusters'], () => bindings.core.getClusters());
const [tips, setTips] = useState<Array<string>>(getCachedOnboardingTips); const [tips, setTips] = useState<Array<string>>(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]); const tip = useMemo(() => tips[Math.floor(Math.random() * tips.length)], [tips]);
useEffect(() => { useEffect(() => {
@ -52,31 +85,124 @@ function RouteComponent() {
const initiallyEmpty = useRef(Object.keys(modsPerCluster).length === 0); const initiallyEmpty = useRef(Object.keys(modsPerCluster).length === 0);
useEffect(() => { useEffect(() => {
if (initiallyEmpty.current) if (!initiallyEmpty.current)
navigate({ to: '/onboarding/preferences/versionCategory' }); return;
}, [navigate]);
if (isFinishingOnboarding)
return;
navigate({ to: '/onboarding/preferences/versionCategory' });
}, [isFinishingOnboarding, navigate]);
const mods = useMemo(() => buildModDataArray(modsPerCluster), [modsPerCluster]); const mods = useMemo(() => buildModDataArray(modsPerCluster), [modsPerCluster]);
const bundledManagedIdsByCluster = useMemo(() => {
const map: Map<number, Set<string>> = new Map();
const [downloadedMods, setDownloadedMods] = useState(0); for (const mod of mods) {
if (!isManagedMod(mod) || !mod.bundleName)
continue;
const knownManagedIds: Set<string> = 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<Array<DownloadFailure>>([]);
const [batchError, setBatchError] = useState<string | null>(null);
const [retryQueue, setRetryQueue] = useState<Array<ModData>>([]);
const [currentBatchTotal, setCurrentBatchTotal] = useState(0);
const [isRunningBatch, setIsRunningBatch] = useState(false);
const [isComplete, setIsComplete] = useState(false); const [isComplete, setIsComplete] = useState(false);
const [isLeaving, setIsLeaving] = useState(false); const [isLeaving, setIsLeaving] = useState(false);
const hasStarted = useRef(false); const hasStarted = useRef(false);
const finishTimerRef = useRef<number | null>(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<void> => {
if (isManagedMod(mod)) { if (isManagedMod(mod)) {
if (mod.dependencies.length > 0) if (mod.dependencies.length > 0)
for (const dependency of mod.dependencies) { for (const dependency of mod.dependencies) {
const cluster = await bindings.core.getClusterById(mod.clusterId); const cluster = await bindings.core.getClusterById(mod.clusterId);
if (!cluster) if (!cluster)
continue; throw new Error(`Cluster ${mod.clusterId} not found while downloading dependency for '${mod.name}'`);
if (dependency.dependency_type === 'required') { if (dependency.dependency_type === 'required') {
const slug = dependency.project_id ?? ''; const dependencyProjectId = dependency.project_id ?? '';
const versions = await bindings.core.getPackageVersions(mod.provider, slug, cluster.mc_version, cluster.mc_loader, 0, 1); if (!dependencyProjectId)
if (versions.items.length !== 0) throw new Error(`Required dependency for '${mod.name}' is missing a project id`);
await bindings.core.downloadPackage(mod.provider, slug, versions.items[0].version_id, cluster.id, null);
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 else
await bindings.core.downloadExternalPackage(mod.package, mod.clusterId, null, null); await bindings.core.downloadExternalPackage(mod.package, mod.clusterId, null, null);
} }
}); };
const startDownloadBatch = async (batchMods: Array<ModData>) => {
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(() => { useEffect(() => {
if (hasStarted.current || mods.length === 0) if (hasStarted.current || mods.length === 0)
return; return;
hasStarted.current = true; hasStarted.current = true;
void startDownloadBatch(mods);
const downloadAll = async () => { // eslint-disable-next-line react-hooks/exhaustive-deps -- startDownloadBatch closes over mutable progress state; this effect intentionally runs once per mods snapshot.
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
}, [mods]); }, [mods]);
useEffect(() => { useEffect(() => {
if (!isComplete) if (!isComplete || failedMods.length > 0 || batchError !== null)
return; return;
// Brief pause at 100%, then fade out, then navigate straight to /app. // Brief pause at 100%, then fade out, then navigate straight to /app.
const pauseTimer = setTimeout(() => { const pauseTimer = setTimeout(() => {
setIsLeaving(true); finishOnboarding();
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);
}, 400); }, 400);
return () => clearTimeout(pauseTimer); 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 ( return (
<div <div
@ -165,9 +322,55 @@ function RouteComponent() {
> >
{/* Top left: title */} {/* Top left: title */}
<h1 className="text-4xl font-bold text-fg-primary"> <h1 className="text-4xl font-bold text-fg-primary">
Downloading... {heading}
</h1> </h1>
{hasFailures && isComplete
? (
<div className="mt-6 max-w-2xl rounded-lg border border-red-500/50 bg-red-500/10 p-5 text-fg-primary">
<p className="text-lg font-semibold">Some mods could not be downloaded.</p>
<p className="mt-2 text-sm text-fg-secondary">
Successful: {successfulMods} / {currentBatchTotal}
</p>
{batchError
? <p className="mt-3 text-sm text-red-200">Batch error: {batchError}</p>
: null}
{failedMods.length > 0
? (
<div className="mt-4 max-h-40 overflow-auto rounded bg-black/20 p-3 text-sm">
{failedMods.slice(0, 8).map((failure) => {
return (
<p key={`${failure.mod.clusterId}-${failure.mod.name}-${failure.index}`}>
{failure.mod.name} (MC {failure.clusterVersion}, cluster {failure.mod.clusterId}): {failure.reason}
</p>
);
})}
{failedMods.length > 8
? <p className="mt-2 text-fg-secondary">+{failedMods.length - 8} more failures</p>
: null}
</div>
)
: null}
<div className="mt-4 flex gap-3">
<button
className="rounded bg-white/90 px-4 py-2 text-black transition hover:bg-white"
onClick={handleRetryFailed}
type="button"
>
Retry Failed Mods
</button>
<button
className="rounded border border-white/30 px-4 py-2 text-white transition hover:bg-white/10"
onClick={finishOnboarding}
type="button"
>
Continue Anyway
</button>
</div>
</div>
)
: null}
{/* Spacer — pushes bottom content to the bottom */} {/* Spacer — pushes bottom content to the bottom */}
<div className="flex-1" /> <div className="flex-1" />
@ -179,6 +382,9 @@ function RouteComponent() {
{percentage} {percentage}
% %
</span> </span>
<span className="text-xs text-fg-secondary">
Processed: {processedMods} / {currentBatchTotal}
</span>
<div className="w-full h-0.5 rounded-full bg-white/20"> <div className="w-full h-0.5 rounded-full bg-white/20">
<div <div
className="h-full rounded-full bg-white transition-all duration-300" className="h-full rounded-full bg-white transition-all duration-300"

View file

@ -19,6 +19,12 @@ export const Route = createFileRoute('/onboarding/preferences/versionCategory')(
component: RouteComponent, component: RouteComponent,
}); });
interface OverrideExtractionFailure {
clusterId: number;
bundleName: string;
reason: string;
}
function extractDisplayName(manifestName: string): string { function extractDisplayName(manifestName: string): string {
return manifestName.match(/\[(.*?)\]/)?.[1] ?? manifestName; return manifestName.match(/\[(.*?)\]/)?.[1] ?? manifestName;
} }
@ -165,22 +171,57 @@ function RouteComponent() {
const anySelected = Object.values(modsPerCluster).some(mods => mods.length > 0); const anySelected = Object.values(modsPerCluster).some(mods => mods.length > 0);
const setDownloadData = useDownloadStore(s => s.setDownloadData); const setDownloadData = useDownloadStore(s => s.setDownloadData);
const [isPreparingNext, setIsPreparingNext] = useState(false);
const [overrideFailures, setOverrideFailures] = useState<Array<OverrideExtractionFailure>>([]);
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<boolean> => {
setIsPreparingNext(true);
setOverrideFailures([]);
const failures: Array<OverrideExtractionFailure> = [];
const handleBeforeNext = async () => {
for (const [clusterId, bundles] of Object.entries(bundlesPerCluster)) for (const [clusterId, bundles] of Object.entries(bundlesPerCluster))
for (const bundle of bundles) { for (const bundle of bundles) {
const selectedMods = modsPerCluster[clusterId] ?? []; const selectedMods = modsPerCluster[clusterId] ?? [];
const hasSelectedBundleContent = selectedMods.some(mod => mod.bundleName === bundle.manifest.name); const hasSelectedBundleContent = selectedMods.some(mod => mod.bundleName === bundle.manifest.name);
if (!hasSelectedBundleContent) if (!hasSelectedBundleContent)
continue; continue;
try { try {
await bindings.oneclient.extractBundleOverrides(bundle.path, Number(clusterId)); await bindings.oneclient.extractBundleOverrides(bundle.path, Number(clusterId));
} }
catch (e) { catch (e) {
console.error(`Failed to extract overrides for bundle ${bundle.manifest.name}:`, 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); setDownloadData(modsPerCluster, bundlesPerCluster);
return true;
}; };
return ( return (
@ -193,6 +234,23 @@ function RouteComponent() {
<p className="text-slate-400 text-lg mb-4"> <p className="text-slate-400 text-lg mb-4">
Choose which mod bundles to install. Your selection will be applied across all available versions. Choose which mod bundles to install. Your selection will be applied across all available versions.
</p> </p>
{overrideFailures.length > 0
? (
<div className="mb-4 rounded-lg border border-red-500/50 bg-red-500/10 p-4 text-sm text-fg-primary">
<p className="font-semibold">Some bundle overrides failed to extract.</p>
<p className="mt-1 text-fg-secondary">Fix or retry before continuing.</p>
<div className="mt-3 max-h-24 overflow-auto rounded bg-black/20 p-2">
{overrideFailures.map((failure) => {
return (
<p key={`${failure.clusterId}-${failure.bundleName}`}>
Cluster {failure.clusterId} / {failure.bundleName}: {failure.reason}
</p>
);
})}
</div>
</div>
)
: null}
<div className="bg-page-elevated p-4 rounded-xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6"> <div className="bg-page-elevated p-4 rounded-xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
{unifiedBundles.map((ub) => { {unifiedBundles.map((ub) => {
@ -222,7 +280,7 @@ function RouteComponent() {
</OverlayScrollbarsComponent> </OverlayScrollbarsComponent>
</div> </div>
</div> </div>
<OnboardingNavigation disableNext={!anySelected} onBeforeNext={handleBeforeNext} /> <OnboardingNavigation disableNext={!anySelected || isPreparingNext} onBeforeNext={handleBeforeNext} />
</> </>
); );
} }

View file

@ -251,7 +251,7 @@ function BackgroundGradient() {
); );
} }
export function OnboardingNavigation({ disableNext, onBeforeNext }: { disableNext?: boolean; onBeforeNext?: () => Promise<void> | void }) { export function OnboardingNavigation({ disableNext, onBeforeNext }: { disableNext?: boolean; onBeforeNext?: () => Promise<boolean | void> | boolean | void }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { isFirstStep, previousPath, nextPath } = Route.useLoaderData(); const { isFirstStep, previousPath, nextPath } = Route.useLoaderData();
@ -259,8 +259,11 @@ export function OnboardingNavigation({ disableNext, onBeforeNext }: { disableNex
if (disableNext) if (disableNext)
return; return;
if (onBeforeNext) if (onBeforeNext) {
await onBeforeNext(); const shouldContinue = await onBeforeNext();
if (shouldContinue === false)
return;
}
navigate({ to: nextPath ?? '/app' }); navigate({ to: nextPath ?? '/app' });
} }

View file

@ -5,19 +5,33 @@ import { create } from 'zustand';
export interface DownloadStore { export interface DownloadStore {
modsPerCluster: Record<string, Array<ModWithBundle>>; modsPerCluster: Record<string, Array<ModWithBundle>>;
bundlesPerCluster: Record<number, Array<ModpackArchive>>; bundlesPerCluster: Record<number, Array<ModpackArchive>>;
isFinishingOnboarding: boolean;
} }
export interface DownloadStoreActions { export interface DownloadStoreActions {
setDownloadData: (modsPerCluster: Record<string, Array<ModWithBundle>>, bundlesPerCluster: Record<number, Array<ModpackArchive>>) => void; setDownloadData: (modsPerCluster: Record<string, Array<ModWithBundle>>, bundlesPerCluster: Record<number, Array<ModpackArchive>>) => void;
markOnboardingFinishing: () => void;
resetOnboardingFinishing: () => void;
clear: () => void; clear: () => void;
} }
export const useDownloadStore = create<DownloadStore & DownloadStoreActions>()(set => ({ export const useDownloadStore = create<DownloadStore & DownloadStoreActions>()(set => ({
modsPerCluster: {}, modsPerCluster: {},
bundlesPerCluster: {}, bundlesPerCluster: {},
isFinishingOnboarding: false,
setDownloadData: (modsPerCluster, bundlesPerCluster) => set({ modsPerCluster, bundlesPerCluster }), setDownloadData: (modsPerCluster, bundlesPerCluster) => set({
clear: () => set({ modsPerCluster: {}, bundlesPerCluster: {} }), modsPerCluster,
bundlesPerCluster,
isFinishingOnboarding: false,
}),
markOnboardingFinishing: () => set({ isFinishingOnboarding: true }),
resetOnboardingFinishing: () => set({ isFinishingOnboarding: false }),
clear: () => set(state => ({
modsPerCluster: {},
bundlesPerCluster: {},
isFinishingOnboarding: state.isFinishingOnboarding,
})),
})); }));
export default useDownloadStore; export default useDownloadStore;

View file

@ -28,6 +28,8 @@ pub enum PackageError {
NoPrimaryFile, NoPrimaryFile,
#[error("package is not a modpack")] #[error("package is not a modpack")]
IsNotModPack, IsNotModPack,
#[error("modpack install completed with partial failures ({failed} of {total} files failed)")]
PartialModpackInstall { failed: u64, total: u64 },
#[error(transparent)] #[error(transparent)]
Incompatible(#[from] IncompatiblePackageType), Incompatible(#[from] IncompatiblePackageType),
#[error("missing API key for provider '{0}'")] #[error("missing API key for provider '{0}'")]
@ -208,7 +210,23 @@ async fn persist_downloaded_package(
update_existing_package: bool, update_existing_package: bool,
) -> LauncherResult<packages::Model> { ) -> LauncherResult<packages::Model> {
if !update_existing_package { 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(); let hash = model.hash.clone();
@ -241,6 +259,14 @@ async fn persist_downloaded_package(
.await .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. /// 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 /// * `skip_compatibility` - Checks whether the `mc_loader` and `mc_version` is compatible. Default is false
#[tracing::instrument(level = "debug", skip_all)] #[tracing::instrument(level = "debug", skip_all)]
@ -332,9 +358,13 @@ pub async fn link_many_packages_to_cluster(
hard_link(package, cluster).await?; 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)] #[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?; io::create_dir_all(&dest_dir).await?;
let dest_pkg = dest_dir.join(package.file_name.as_str()); let dest_pkg = dest_dir.join(package.file_name.as_str());
tokio::fs::hard_link(src_pkg, dest_pkg) match tokio::fs::hard_link(&src_pkg, &dest_pkg).await {
.await Ok(()) => {}
.map_err(IOError::from)?; 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(()) Ok(())
} }

View file

@ -10,6 +10,7 @@ use serde::Deserialize;
use tokio::sync::OnceCell; use tokio::sync::OnceCell;
use crate::api::cluster::ClusterError; use crate::api::cluster::ClusterError;
use crate::api::packages::PackageError;
use crate::api::packages::data::{ExternalPackage, ManagedVersion, PackageOverrides, PackageSide}; use crate::api::packages::data::{ExternalPackage, ManagedVersion, PackageOverrides, PackageSide};
use crate::api::packages::modpack::data::{ use crate::api::packages::modpack::data::{
ModpackArchive, ModpackFile, ModpackFileKind, ModpackManifest, 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 ) // if cluster.mc_loader_version.is_some_and(|v| v )
let mut packages_to_link = Vec::new(); let mut packages_to_link = Vec::new();
let mut failed_downloads = 0u64;
let mut attempted_downloads = 0u64;
// TODO: Ingress // TODO: Ingress
for file in &manifest.files { for file in &manifest.files {
@ -215,12 +218,21 @@ pub(super) async fn download_and_link_packages(
continue; continue;
} }
attempted_downloads += 1;
match &file.kind { match &file.kind {
ModpackFileKind::Managed(managed) => { ModpackFileKind::Managed(managed) => {
let (package, version) = &**managed; let (package, version) = &**managed;
match api::packages::download_package(package, version, None, None).await { match api::packages::download_package(package, version, None, None).await {
Ok(model) => packages_to_link.push(model), 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) => { ModpackFileKind::External(package) => {
@ -234,8 +246,17 @@ pub(super) async fn download_and_link_packages(
.await .await
{ {
Ok(Some(model)) => packages_to_link.push(model), Ok(Some(model)) => packages_to_link.push(model),
Ok(None) => {} Ok(None) => {
Err(e) => tracing::error!("failed to download external package: {e:?}"), 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 { if linked < packages_to_link.len() as u64 {
tracing::warn!("not all packages could be linked to the cluster, some errors occurred"); 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(()) Ok(())
} }