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:
parent
9f1b6d6ae2
commit
3b885cdb0f
13 changed files with 578 additions and 108 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<void>,
|
||||
message: (event: MessagePayload) => Promise<void>,
|
||||
process: (event: ProcessPayload) => Promise<void> },
|
||||
'core': { getClusters: () => Promise<ClusterModel[]>,
|
||||
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<ClusterModel[]>,
|
||||
getClusterById: (id: number) => Promise<ClusterModel | null>,
|
||||
removeCluster: (id: number) => Promise<null>,
|
||||
createCluster: (options: CreateCluster) => Promise<ClusterModel>,
|
||||
|
|
@ -383,6 +380,9 @@ removeCape: (accessToken: string) => Promise<MojangFullPlayerProfile>,
|
|||
convertUsernameUUID: (usernameUuid: string) => Promise<MowojangProfile>,
|
||||
setDiscordRPCMessage: (message: 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>,
|
||||
openCluster: (folderName: string) => Promise<null> },
|
||||
'debug': { openDevTools: () => Promise<void>,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,19 @@ export interface ExternalModData extends BaseModData {
|
|||
export type ModData = ManagedModData | ExternalModData;
|
||||
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 {
|
||||
return mod.managed === true;
|
||||
}
|
||||
|
|
@ -137,15 +150,50 @@ export function buildModDataArray(modsPerCluster: Record<string, Array<ModWithBu
|
|||
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;
|
||||
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) {
|
||||
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<React.SetStateAction<boolean>>; 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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Array<string>>([]);
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@ export function useSettings() {
|
|||
}, [settings]);
|
||||
|
||||
const setSetting = useMemo(() => {
|
||||
return function setSetting<TKey extends keyof Settings>(key: TKey, value: Settings[TKey]) {
|
||||
bindings.core.writeSettings({
|
||||
return async function setSetting<TKey extends keyof Settings>(key: TKey, value: Settings[TKey]) {
|
||||
await bindings.core.writeSettings({
|
||||
...settings.data,
|
||||
[key]: value,
|
||||
});
|
||||
settings.refetch();
|
||||
await settings.refetch();
|
||||
};
|
||||
}, [settings]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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]);
|
||||
|
||||
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<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 [isLeaving, setIsLeaving] = useState(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 (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<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(() => {
|
||||
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 (
|
||||
<div
|
||||
|
|
@ -165,9 +322,55 @@ function RouteComponent() {
|
|||
>
|
||||
{/* Top left: title */}
|
||||
<h1 className="text-4xl font-bold text-fg-primary">
|
||||
Downloading...
|
||||
{heading}
|
||||
</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 */}
|
||||
<div className="flex-1" />
|
||||
|
||||
|
|
@ -179,6 +382,9 @@ function RouteComponent() {
|
|||
{percentage}
|
||||
%
|
||||
</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="h-full rounded-full bg-white transition-all duration-300"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ export const Route = createFileRoute('/onboarding/preferences/versionCategory')(
|
|||
component: RouteComponent,
|
||||
});
|
||||
|
||||
interface OverrideExtractionFailure {
|
||||
clusterId: number;
|
||||
bundleName: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
function extractDisplayName(manifestName: string): string {
|
||||
return manifestName.match(/\[(.*?)\]/)?.[1] ?? manifestName;
|
||||
}
|
||||
|
|
@ -165,22 +171,57 @@ function RouteComponent() {
|
|||
const anySelected = Object.values(modsPerCluster).some(mods => mods.length > 0);
|
||||
|
||||
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 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() {
|
|||
<p className="text-slate-400 text-lg mb-4">
|
||||
Choose which mod bundles to install. Your selection will be applied across all available versions.
|
||||
</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">
|
||||
{unifiedBundles.map((ub) => {
|
||||
|
|
@ -222,7 +280,7 @@ function RouteComponent() {
|
|||
</OverlayScrollbarsComponent>
|
||||
</div>
|
||||
</div>
|
||||
<OnboardingNavigation disableNext={!anySelected} onBeforeNext={handleBeforeNext} />
|
||||
<OnboardingNavigation disableNext={!anySelected || isPreparingNext} onBeforeNext={handleBeforeNext} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { 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' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,19 +5,33 @@ import { create } from 'zustand';
|
|||
export interface DownloadStore {
|
||||
modsPerCluster: Record<string, Array<ModWithBundle>>;
|
||||
bundlesPerCluster: Record<number, Array<ModpackArchive>>;
|
||||
isFinishingOnboarding: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadStoreActions {
|
||||
setDownloadData: (modsPerCluster: Record<string, Array<ModWithBundle>>, bundlesPerCluster: Record<number, Array<ModpackArchive>>) => void;
|
||||
markOnboardingFinishing: () => void;
|
||||
resetOnboardingFinishing: () => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useDownloadStore = create<DownloadStore & DownloadStoreActions>()(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;
|
||||
|
|
|
|||
|
|
@ -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<packages::Model> {
|
||||
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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue