Skip to content

Commit 3b885cd

Browse files
committed
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.
1 parent 9f1b6d6 commit 3b885cd

13 files changed

Lines changed: 574 additions & 104 deletions

File tree

apps/oneclient/desktop/src/api/commands.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::collections::HashMap;
22
use std::path::{Path, PathBuf};
33

4+
use anyhow::Context;
45
use onelauncher_core::api;
56
use onelauncher_core::api::cluster::dao::ClusterId;
67
use onelauncher_core::api::packages::modpack::data::{ModpackArchive, ModpackFileKind};
@@ -361,7 +362,13 @@ impl OneClientApi for OneClientApiImpl {
361362

362363
// Stamp the target cluster's created_at with the actual migration time so the
363364
// home page can correctly identify it as a newly-migrated cluster.
364-
onelauncher_core::api::cluster::dao::touch_cluster_created_at(target_id).await?;
365+
onelauncher_core::api::cluster::dao::touch_cluster_created_at(target_id)
366+
.await
367+
.with_context(|| {
368+
format!(
369+
"failed to stamp migrated cluster created_at for target cluster {target_id}"
370+
)
371+
})?;
365372

366373
Ok(CopyClusterResult {
367374
fallback_files: fallback_files.into_iter().collect(),

apps/oneclient/frontend/src/bindings.gen.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ export type PackageDonationPlatform = "patreon" | "buymeacoffee" | "paypal" | "g
192192

193193
export type PackageDonationUrl = { id: PackageDonationPlatform; url: string }
194194

195-
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 }
195+
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 }
196196

197197
export type PackageGallery = { url: string; thumbnail_url: string; title: string | null; description: string | null; featured: boolean | null }
198198

@@ -325,11 +325,8 @@ export type VersionType =
325325
*/
326326
"old_beta"
327327

328-
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":[]}' }
329-
export type Router = { 'events': { ingress: (event: IngressPayload) => Promise<void>,
330-
message: (event: MessagePayload) => Promise<void>,
331-
process: (event: ProcessPayload) => Promise<void> },
332-
'core': { getClusters: () => Promise<ClusterModel[]>,
328+
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"]}' }
329+
export type Router = { 'core': { getClusters: () => Promise<ClusterModel[]>,
333330
getClusterById: (id: number) => Promise<ClusterModel | null>,
334331
removeCluster: (id: number) => Promise<null>,
335332
createCluster: (options: CreateCluster) => Promise<ClusterModel>,
@@ -383,6 +380,9 @@ removeCape: (accessToken: string) => Promise<MojangFullPlayerProfile>,
383380
convertUsernameUUID: (usernameUuid: string) => Promise<MowojangProfile>,
384381
setDiscordRPCMessage: (message: string) => Promise<null>,
385382
open: (input: string) => Promise<null> },
383+
'events': { ingress: (event: IngressPayload) => Promise<void>,
384+
message: (event: MessagePayload) => Promise<void>,
385+
process: (event: ProcessPayload) => Promise<void> },
386386
'folders': { fromCluster: (folderName: string) => Promise<string>,
387387
openCluster: (folderName: string) => Promise<null> },
388388
'debug': { openDevTools: () => Promise<void>,

apps/oneclient/frontend/src/components/DownloadMods.tsx

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ export interface ExternalModData extends BaseModData {
3333
export type ModData = ManagedModData | ExternalModData;
3434
export type ModDataArray = Array<ModData>;
3535

36+
export type DownloadModsParallelResult =
37+
| {
38+
ok: true;
39+
index: number;
40+
mod: ModData;
41+
}
42+
| {
43+
ok: false;
44+
index: number;
45+
mod: ModData;
46+
error: unknown;
47+
};
48+
3649
export function isManagedMod(mod: ModData): mod is ManagedModData {
3750
return mod.managed === true;
3851
}
@@ -137,15 +150,50 @@ export function buildModDataArray(modsPerCluster: Record<string, Array<ModWithBu
137150
return modsList;
138151
}
139152

140-
export function downloadModsParallel(items: ModDataArray, limit: number, fn: (mod: ModData, index: number) => Promise<void>) {
153+
export async function downloadModsParallel(
154+
items: ModDataArray,
155+
limit: number,
156+
fn: (mod: ModData, index: number) => Promise<void>,
157+
): Promise<Array<DownloadModsParallelResult>> {
158+
if (items.length === 0)
159+
return [];
160+
141161
let index = 0;
142-
const workers = Array.from({ length: limit }).fill(null).map(async () => {
162+
const workerCount = Math.max(1, Math.min(limit, items.length));
163+
const results: Array<DownloadModsParallelResult> = [];
164+
165+
const workers = Array.from({ length: workerCount }).fill(null).map(async () => {
143166
while (index < items.length) {
144-
const i = index++;
145-
await fn(items[i], i);
167+
const i = index;
168+
index += 1;
169+
170+
if (i >= items.length)
171+
return;
172+
173+
const mod = items[i];
174+
try {
175+
await fn(mod, i);
176+
results.push({
177+
ok: true,
178+
index: i,
179+
mod,
180+
});
181+
}
182+
catch (error) {
183+
results.push({
184+
ok: false,
185+
index: i,
186+
mod,
187+
error,
188+
});
189+
}
146190
}
147191
});
148-
return Promise.all(workers);
192+
193+
await Promise.all(workers);
194+
195+
results.sort((left, right) => left.index - right.index);
196+
return results;
149197
}
150198

151199
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
194242

195243
useEffect(() => {
196244
const downloadAll = async () => {
197-
await downloadModsParallel(mods, 10, async (mod) => {
245+
const results = await downloadModsParallel(mods, 10, async (mod) => {
198246
setModName(mod.name);
199247
try {
200248
await download.mutateAsync(mod);
@@ -203,9 +251,21 @@ function DownloadingMods({ mods, setOpen, nextPath }: { mods: ModDataArray; setO
203251
setDownloadedMods(prev => prev + 1);
204252
}
205253
});
254+
255+
const failed = results.filter(result => !result.ok);
256+
if (failed.length > 0)
257+
console.error('[DownloadMods] Some mods failed to download:', {
258+
failed: failed.length,
259+
total: mods.length,
260+
entries: failed.map(result => ({
261+
index: result.index,
262+
name: result.mod.name,
263+
error: result.error,
264+
})),
265+
});
206266
};
207267

208-
downloadAll();
268+
void downloadAll();
209269
// eslint-disable-next-line react-hooks/exhaustive-deps -- download is not stable, adding it would cause infinite loops
210270
}, [mods]);
211271

apps/oneclient/frontend/src/components/overlay/MigrationModal.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useToast } from '@/utils/toast';
77
import { getOnlineClusterForVersion } from '@/utils/versionMap';
88
import { useCommand } from '@onelauncher/common';
99
import { Dropdown } from '@onelauncher/common/components';
10+
import { useQueryClient } from '@tanstack/react-query';
1011
import { Announcement01Icon } from '@untitled-theme/icons-react';
1112
import { useEffect, useRef, useState } from 'react';
1213

@@ -51,6 +52,7 @@ export function MigrationModal({
5152
const [fallbackFiles, setFallbackFiles] = useState<Array<string>>([]);
5253

5354
const toast = useToast();
55+
const queryClient = useQueryClient();
5456

5557
const { data: manifest } = useCommand(
5658
['getVersions'],
@@ -87,6 +89,17 @@ export function MigrationModal({
8789
setIsMigrating(true);
8890
try {
8991
const result = await bindings.oneclient.copyClusterContent(sourceId, effectiveTargetId);
92+
await Promise.all([
93+
queryClient.invalidateQueries({
94+
queryKey: ['getClusters'],
95+
}),
96+
queryClient.invalidateQueries({
97+
queryKey: ['getClusters', 'sortedByLastPlayed'],
98+
}),
99+
queryClient.invalidateQueries({
100+
queryKey: ['getClustersGroupedByMajor'],
101+
}),
102+
]);
90103
if (result.fallback_files.length > 0)
91104
setFallbackFiles(result.fallback_files);
92105
else

apps/oneclient/frontend/src/hooks/useSettings.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ export function useSettings() {
1212
}, [settings]);
1313

1414
const setSetting = useMemo(() => {
15-
return function setSetting<TKey extends keyof Settings>(key: TKey, value: Settings[TKey]) {
16-
bindings.core.writeSettings({
15+
return async function setSetting<TKey extends keyof Settings>(key: TKey, value: Settings[TKey]) {
16+
await bindings.core.writeSettings({
1717
...settings.data,
1818
[key]: value,
1919
});
20-
settings.refetch();
20+
await settings.refetch();
2121
};
2222
}, [settings]);
2323

apps/oneclient/frontend/src/hooks/useVersionMigration.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { bindings } from '@/main';
22
import useMigrationStore from '@/stores/migrationStore';
33
import { useCommandSuspense } from '@onelauncher/common';
4+
import { useQueryClient } from '@tanstack/react-query';
45
import { useEffect } from 'react';
56

67
const DEFAULT_SEEN_VERSIONS = ['1.21.1', '1.21.10', '1.21.11'] as const;
@@ -24,6 +25,7 @@ function areClustersEqual(
2425
}
2526

2627
export function useVersionMigration() {
28+
const queryClient = useQueryClient();
2729
const { data: grouped } = useCommandSuspense(
2830
['getClustersGroupedByMajor'],
2931
bindings.oneclient.getClustersGroupedByMajor,
@@ -40,11 +42,7 @@ export function useVersionMigration() {
4042
const openForDebug = useMigrationStore(state => state.openForDebug);
4143

4244
useEffect(() => {
43-
type SettingsWithSeenVersions = typeof settingsData & {
44-
seen_versions?: Array<string>;
45-
};
46-
47-
const settings = settingsData as SettingsWithSeenVersions;
45+
const settings = settingsData;
4846
const storedSeenVersions = Array.isArray(settings.seen_versions) ? settings.seen_versions : [];
4947
const seenVersions = [
5048
...new Set([
@@ -68,10 +66,12 @@ export function useVersionMigration() {
6866
if (!areClustersEqual(migrationState.allClusters, nextAllClusters))
6967
setAllClusters(nextAllClusters);
7068

71-
if (
72-
!areStringArraysEqual(migrationState.newVersions, unseenUnplayed)
73-
|| !areClustersEqual(migrationState.sourceClusters, sources)
74-
)
69+
const migrationCandidatesChanged
70+
= !areStringArraysEqual(migrationState.newVersions, unseenUnplayed)
71+
|| !areClustersEqual(migrationState.sourceClusters, sources);
72+
73+
// Keep active modal data stable while it is open.
74+
if (migrationCandidatesChanged && !migrationState.isOpen)
7575
setMigrationCandidates(unseenUnplayed, sources);
7676

7777
if (unseenUnplayed.length > 0 && sources.length > 0 && !migrationState.isOpen)
@@ -82,12 +82,16 @@ export function useVersionMigration() {
8282
const hasSeenVersionsChanged
8383
= !areStringArraysEqual(nextSeenVersions, normalizedStoredSeenVersions);
8484

85-
if (hasSeenVersionsChanged)
86-
void bindings.core.writeSettings({
85+
if (hasSeenVersionsChanged) {
86+
const nextSettings = {
8787
...settings,
8888
seen_versions: nextSeenVersions,
89-
} as typeof settingsData);
90-
}, [grouped, setAllClusters, setIsOpen, setMigrationCandidates, settingsData]);
89+
} as typeof settingsData;
90+
91+
queryClient.setQueryData(['readSettings'], nextSettings);
92+
void bindings.core.writeSettings(nextSettings);
93+
}
94+
}, [grouped, queryClient, setAllClusters, setIsOpen, setMigrationCandidates, settingsData]);
9195

9296
return { isOpen, isDebugPreview, setIsOpen, newVersions, sourceClusters, allClusters, openForDebug };
9397
}

apps/oneclient/frontend/src/routes/app/index.tsx

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,28 @@ export const Route = createFileRoute('/app/')({
2020
component: RouteComponent,
2121
});
2222

23+
function getCreatedAtMs(createdAt: string): number | null {
24+
const parsed = Date.parse(createdAt);
25+
if (Number.isFinite(parsed))
26+
return parsed;
27+
28+
// Normalize high-precision RFC3339 fractions for stricter engines.
29+
const normalized = createdAt.replace(/\.(\d{3})\d+(?=(?:Z|[+-]\d{2}:\d{2})$)/, '.$1');
30+
const normalizedParsed = Date.parse(normalized);
31+
return Number.isFinite(normalizedParsed) ? normalizedParsed : null;
32+
}
33+
34+
function getBoostedCreatedAtMs(cluster: ClusterModel, now: number, boostWindowMs: number): number | null {
35+
if (cluster.last_played !== null)
36+
return null;
37+
38+
const createdAtMs = getCreatedAtMs(cluster.created_at);
39+
if (createdAtMs === null)
40+
return null;
41+
42+
return (now - createdAtMs) < boostWindowMs ? createdAtMs : null;
43+
}
44+
2345
function RouteComponent() {
2446
// Preload the clusters for version page
2547
useCommandSuspense(['getClustersGroupedByMajor'], bindings.oneclient.getClustersGroupedByMajor);
@@ -37,12 +59,16 @@ function RouteComponent() {
3759
const BOOST_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
3860
const now = Date.now();
3961
const displayClusters = [...lastPlayedClusters].sort((a, b) => {
40-
const aIsNew = a.last_played === null && (now - new Date(a.created_at).getTime()) < BOOST_WINDOW_MS;
41-
const bIsNew = b.last_played === null && (now - new Date(b.created_at).getTime()) < BOOST_WINDOW_MS;
42-
if (aIsNew && !bIsNew)
62+
const aBoostedCreatedAtMs = getBoostedCreatedAtMs(a, now, BOOST_WINDOW_MS);
63+
const bBoostedCreatedAtMs = getBoostedCreatedAtMs(b, now, BOOST_WINDOW_MS);
64+
65+
if (aBoostedCreatedAtMs !== null && bBoostedCreatedAtMs === null)
4366
return -1;
44-
if (bIsNew && !aIsNew)
67+
if (bBoostedCreatedAtMs !== null && aBoostedCreatedAtMs === null)
4568
return 1;
69+
if (aBoostedCreatedAtMs !== null && bBoostedCreatedAtMs !== null)
70+
return bBoostedCreatedAtMs - aBoostedCreatedAtMs;
71+
4672
return 0;
4773
}).slice(0, 3);
4874

0 commit comments

Comments
 (0)