From 445eca27756abca1305a31f0762ae4c064ef9352 Mon Sep 17 00:00:00 2001 From: Wyvest Date: Mon, 30 Mar 2026 03:25:51 -0400 Subject: [PATCH] Spice up onboarding --- apps/oneclient/frontend/src/bindings.gen.ts | 60 +++--- .../frontend/src/components/DownloadMods.tsx | 34 ++- .../src/components/GameBackground.tsx | 3 +- .../overlay/MinecraftAuthErrorModal.tsx | 42 +++- .../overlay/SuperSecretDevOptions.tsx | 14 +- .../src/components/overlay/Toasts.tsx | 2 +- .../src/constants/onboardingTipsBackup.ts | 17 ++ .../frontend/src/hooks/useDebugInfo.ts | 8 +- .../frontend/src/hooks/useDiscordRPC.ts | 2 +- .../frontend/src/hooks/useLaunchCluster.ts | 2 +- .../frontend/src/hooks/useSettings.ts | 26 ++- .../frontend/src/routes/app/debug.tsx | 4 +- .../src/routes/onboarding/downloading.tsx | 198 ++++++++++++++++++ .../src/routes/onboarding/finished.tsx | 26 --- .../preferences/versionCategory.tsx | 48 ++--- .../frontend/src/routes/onboarding/route.tsx | 37 ++-- .../frontend/src/stores/downloadStore.ts | 23 ++ .../frontend/src/utils/onboardingFunFacts.ts | 62 ++++++ 18 files changed, 481 insertions(+), 127 deletions(-) create mode 100644 apps/oneclient/frontend/src/constants/onboardingTipsBackup.ts create mode 100644 apps/oneclient/frontend/src/routes/onboarding/downloading.tsx delete mode 100644 apps/oneclient/frontend/src/routes/onboarding/finished.tsx create mode 100644 apps/oneclient/frontend/src/stores/downloadStore.ts create mode 100644 apps/oneclient/frontend/src/utils/onboardingFunFacts.ts diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index dd372730..57de4a91 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -325,38 +325,10 @@ export type VersionType = */ "old_beta" -const ARGS_MAP = { 'events':'{"ingress":["event"],"process":["event"],"message":["event"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'oneclient':'{"refreshArt":["path"],"copyClusterContent":["source_id","target_id"],"getBundlesFor":["cluster_id"],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"updateBundlePackages":["cluster_id"],"extractBundleOverrides":["bundle_path","cluster_id"],"getClustersGroupedByMajor":[],"getVersions":[],"checkForUpdate":[],"installUpdate":[],"isBundleSyncing":[],"cacheArt":["path"]}', 'core':'{"getClusterById":["id"],"getProfileOrDefault":["name"],"refreshAccounts":[],"killProcess":["pid"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getClusters":[],"getLogByName":["id","name"],"launchCluster":["id","uuid","search_for_java"],"getGameVersions":[],"getUsers":[],"getUsersFromAuthor":["provider","author"],"fetchLoggedInProfile":["access_token"],"isClusterRunning":["cluster_id"],"getProcessLogTail":["id","max_lines"],"writeSettings":["setting"],"getMultiplePackages":["provider","slugs"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"removeCape":["access_token"],"convertUsernameUUID":["username_uuid"],"open":["input"],"searchPackages":["provider","query"],"setDiscordRPCMessage":["message"],"getScreenshots":["id"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"readSettings":[],"refreshAccount":["uuid"],"createCluster":["options"],"syncCluster":["cluster_id"],"togglePackage":["cluster_id","package_hash"],"updateClusterById":["id","request"],"getRunningProcesses":[],"getGlobalProfile":[],"getDefaultUser":["fallback"],"getLinkedPackages":["cluster_id"],"installModpack":["modpack","cluster_id"],"getWorlds":["id"],"getRunningProcessesByClusterId":["cluster_id"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"removeCluster":["id"],"getLoadersForVersion":["mc_version"],"removeUser":["uuid"],"removePackage":["cluster_id","package_hash"],"fetchMinecraftProfile":["uuid"],"setClusterStage":["id","stage"],"getUser":["uuid"],"updateClusterProfile":["name","profile"],"openMsaLogin":[],"getPackage":["provider","slug"],"getLogs":["id"],"createSettingsProfile":["name"],"getPackageBody":["provider","body"],"setDefaultUser":["uuid"],"changeSkin":["access_token","skin_url","skin_variant"],"changeCape":["access_token","cape_uuid"]}', 'debug':'{"getFullDebugInfo":[],"getLocale":[],"getBuildTimestamp":[],"getType":[],"getPlatform":[],"getFamily":[],"hasDevSettings":[],"getGitCommitHash":[],"getPackageVersion":[],"isInDev":[],"getOsVersion":[],"getFullDebugInfoParsedString":[],"getArch":[],"openDevTools":[],"getFullDebugInfoParsed":[],"getOsDistro":[]}' } +const ARGS_MAP = { 'oneclient':'{"refreshArt":["path"],"getVersions":[],"getBundlesFor":["cluster_id"],"updateBundlePackages":["cluster_id"],"copyClusterContent":["source_id","target_id"],"isBundleSyncing":[],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"checkForUpdate":[],"installUpdate":[],"getClustersGroupedByMajor":[],"cacheArt":["path"],"extractBundleOverrides":["bundle_path","cluster_id"]}', 'core':'{"searchPackages":["provider","query"],"updateClusterById":["id","request"],"fetchMinecraftProfile":["uuid"],"getLogs":["id"],"launchCluster":["id","uuid","search_for_java"],"getClusters":[],"getProcessLogTail":["id","max_lines"],"killProcess":["pid"],"getRunningProcesses":[],"setClusterStage":["id","stage"],"getGlobalProfile":[],"getLoadersForVersion":["mc_version"],"getGameVersions":[],"getMultiplePackages":["provider","slugs"],"createSettingsProfile":["name"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getPackage":["provider","slug"],"syncCluster":["cluster_id"],"getLinkedPackages":["cluster_id"],"open":["input"],"readSettings":[],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"refreshAccounts":[],"getClusterById":["id"],"removeUser":["uuid"],"getDefaultUser":["fallback"],"removeCluster":["id"],"createCluster":["options"],"getWorlds":["id"],"getLogByName":["id","name"],"updateClusterProfile":["name","profile"],"setDefaultUser":["uuid"],"getUsers":[],"getPackageBody":["provider","body"],"removePackage":["cluster_id","package_hash"],"fetchLoggedInProfile":["access_token"],"setDiscordRPCMessage":["message"],"getUsersFromAuthor":["provider","author"],"isClusterRunning":["cluster_id"],"refreshAccount":["uuid"],"writeSettings":["setting"],"installModpack":["modpack","cluster_id"],"getProfileOrDefault":["name"],"removeCape":["access_token"],"getRunningProcessesByClusterId":["cluster_id"],"togglePackage":["cluster_id","package_hash"],"getScreenshots":["id"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"getUser":["uuid"],"openMsaLogin":[],"changeSkin":["access_token","skin_url","skin_variant"],"convertUsernameUUID":["username_uuid"],"changeCape":["access_token","cape_uuid"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'events':'{"message":["event"],"process":["event"],"ingress":["event"]}', 'debug':'{"getType":[],"getArch":[],"getFullDebugInfoParsedString":[],"getGitCommitHash":[],"getFamily":[],"getFullDebugInfo":[],"openDevTools":[],"getOsVersion":[],"getPackageVersion":[],"getPlatform":[],"getFullDebugInfoParsed":[],"isInDev":[],"getBuildTimestamp":[],"getLocale":[],"hasDevSettings":[],"getOsDistro":[]}' } export type Router = { 'events': { ingress: (event: IngressPayload) => Promise, message: (event: MessagePayload) => Promise, process: (event: ProcessPayload) => Promise }, -'debug': { openDevTools: () => Promise, -isInDev: () => Promise, -getArch: () => Promise, -getFamily: () => Promise, -getLocale: () => Promise, -getType: () => Promise, -getPlatform: () => Promise, -getOsVersion: () => Promise, -getOsDistro: () => Promise, -getGitCommitHash: () => Promise, -getBuildTimestamp: () => Promise, -getPackageVersion: () => Promise, -getFullDebugInfo: () => Promise, -getFullDebugInfoParsed: () => Promise, -getFullDebugInfoParsedString: () => Promise, -hasDevSettings: () => Promise }, -'oneclient': { getClustersGroupedByMajor: () => Promise>, -getBundlesFor: (clusterId: number) => Promise, -getVersions: () => Promise, -extractBundleOverrides: (bundlePath: string, clusterId: number) => Promise, -checkForUpdate: () => Promise, -installUpdate: () => Promise, -downloadPackageFromBundle: (package: ModpackFileKind, clusterId: number, bundleName: string, skipCompatibility: boolean | null) => Promise, -updateBundlePackages: (clusterId: number) => Promise, -isBundleSyncing: () => Promise, -cacheArt: (path: string) => Promise, -refreshArt: (path: string) => Promise, -copyClusterContent: (sourceId: number, targetId: number) => Promise }, 'core': { getClusters: () => Promise, getClusterById: (id: number) => Promise, removeCluster: (id: number) => Promise, @@ -412,7 +384,35 @@ convertUsernameUUID: (usernameUuid: string) => Promise, setDiscordRPCMessage: (message: string) => Promise, open: (input: string) => Promise }, 'folders': { fromCluster: (folderName: string) => Promise, -openCluster: (folderName: string) => Promise } }; +openCluster: (folderName: string) => Promise }, +'debug': { openDevTools: () => Promise, +isInDev: () => Promise, +getArch: () => Promise, +getFamily: () => Promise, +getLocale: () => Promise, +getType: () => Promise, +getPlatform: () => Promise, +getOsVersion: () => Promise, +getOsDistro: () => Promise, +getGitCommitHash: () => Promise, +getBuildTimestamp: () => Promise, +getPackageVersion: () => Promise, +getFullDebugInfo: () => Promise, +getFullDebugInfoParsed: () => Promise, +getFullDebugInfoParsedString: () => Promise, +hasDevSettings: () => Promise }, +'oneclient': { getClustersGroupedByMajor: () => Promise>, +getBundlesFor: (clusterId: number) => Promise, +getVersions: () => Promise, +extractBundleOverrides: (bundlePath: string, clusterId: number) => Promise, +checkForUpdate: () => Promise, +installUpdate: () => Promise, +downloadPackageFromBundle: (package: ModpackFileKind, clusterId: number, bundleName: string, skipCompatibility: boolean | null) => Promise, +updateBundlePackages: (clusterId: number) => Promise, +isBundleSyncing: () => Promise, +cacheArt: (path: string) => Promise, +refreshArt: (path: string) => Promise, +copyClusterContent: (sourceId: number, targetId: number) => Promise } }; export type { InferCommandOutput } diff --git a/apps/oneclient/frontend/src/components/DownloadMods.tsx b/apps/oneclient/frontend/src/components/DownloadMods.tsx index 6b100da8..503ea00d 100644 --- a/apps/oneclient/frontend/src/components/DownloadMods.tsx +++ b/apps/oneclient/frontend/src/components/DownloadMods.tsx @@ -105,7 +105,39 @@ export function DownloadMods({ modsPerCluster, ref }: { modsPerCluster: Record Promise) { +export function buildModDataArray(modsPerCluster: Record>): ModDataArray { + const modsList: ModDataArray = []; + for (const [clusterId, modsWithBundle] of Object.entries(modsPerCluster)) + for (const { file: mod, bundleName } of modsWithBundle) { + if ('External' in mod.kind) + modsList.push({ + name: getModMetaDataName(mod), + clusterId: Number(clusterId), + managed: false, + package: mod.kind.External, + bundleName, + fileKind: mod.kind, + }); + + if ('Managed' in mod.kind) { + const [pkg, version] = mod.kind.Managed; + modsList.push({ + name: getModMetaDataName(mod), + clusterId: Number(clusterId), + managed: true, + provider: pkg.provider, + id: pkg.id, + versionId: version.version_id, + dependencies: version.dependencies, + bundleName, + fileKind: mod.kind, + }); + } + } + return modsList; +} + +export function downloadModsParallel(items: ModDataArray, limit: number, fn: (mod: ModData, index: number) => Promise) { let index = 0; const workers = Array.from({ length: limit }).fill(null).map(async () => { while (index < items.length) { diff --git a/apps/oneclient/frontend/src/components/GameBackground.tsx b/apps/oneclient/frontend/src/components/GameBackground.tsx index 281d70fc..3027312f 100644 --- a/apps/oneclient/frontend/src/components/GameBackground.tsx +++ b/apps/oneclient/frontend/src/components/GameBackground.tsx @@ -4,12 +4,11 @@ import { twMerge } from 'tailwind-merge'; import CavesAndCliffs from '@/assets/backgrounds/CavesAndCliffs.jpg'; import HypixelSkyblockHub from '@/assets/backgrounds/HypixelSkyblockHub.png'; -import MinecraftBuilding from '@/assets/backgrounds/MinecraftBuilding.png'; const GameBackgrounds = { HypixelSkyblockHub, CavesAndCliffs, - MinecraftBuilding, + MinecraftBuilding: HypixelSkyblockHub, }; export type GameBackgroundName = keyof typeof GameBackgrounds; diff --git a/apps/oneclient/frontend/src/components/overlay/MinecraftAuthErrorModal.tsx b/apps/oneclient/frontend/src/components/overlay/MinecraftAuthErrorModal.tsx index 48ba6c72..9bf81a7c 100644 --- a/apps/oneclient/frontend/src/components/overlay/MinecraftAuthErrorModal.tsx +++ b/apps/oneclient/frontend/src/components/overlay/MinecraftAuthErrorModal.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import type { MinecraftAuthErrorInfo } from './minecraft-auth-errors'; import { Overlay } from '@/components'; import { bindings } from '@/main'; @@ -32,6 +33,42 @@ export function isMinecraftAuthError(error: unknown): boolean { return extractXboxErrorCode(error) !== null; } +function renderStepWithLinks(step: string): Array { + const anchorRegex = /]*>(.*?)<\/a>/g; + const nodes: Array = []; + let lastIndex = 0; + let match = anchorRegex.exec(step); + + while (match) { + const fullMatch = match[0]; + const href = match[1]; + const label = match[2]; + + if (match.index > lastIndex) + nodes.push(step.slice(lastIndex, match.index)); + + nodes.push( + + {label} + , + ); + + lastIndex = match.index + fullMatch.length; + match = anchorRegex.exec(step); + } + + if (lastIndex < step.length) + nodes.push(step.slice(lastIndex)); + + return nodes; +} + function MatchedErrorContent({ matchedError, }: { @@ -52,10 +89,7 @@ function MatchedErrorContent({ {index + 1} - + {renderStepWithLinks(step)} ))} diff --git a/apps/oneclient/frontend/src/components/overlay/SuperSecretDevOptions.tsx b/apps/oneclient/frontend/src/components/overlay/SuperSecretDevOptions.tsx index 3accc1b0..ecd5b819 100644 --- a/apps/oneclient/frontend/src/components/overlay/SuperSecretDevOptions.tsx +++ b/apps/oneclient/frontend/src/components/overlay/SuperSecretDevOptions.tsx @@ -3,11 +3,17 @@ import { useCopyDebugInfo } from '@/hooks/useDebugInfo'; import { useSettings } from '@/hooks/useSettings'; import { bindings } from '@/main'; import { Button } from '@onelauncher/common/components'; -import { Link } from '@tanstack/react-router'; +import { Link, useNavigate } from '@tanstack/react-router'; export function SuperSecretDevOptions() { - const { createSetting } = useSettings(); + const { createSetting, setSetting } = useSettings(); const copyDebugInfo = useCopyDebugInfo(); + const navigate = useNavigate(); + + const handleSkipOnboarding = () => { + setSetting('seen_onboarding', true); + navigate({ to: '/app' }); + }; return ( @@ -31,9 +37,7 @@ export function SuperSecretDevOptions() { - - - + diff --git a/apps/oneclient/frontend/src/components/overlay/Toasts.tsx b/apps/oneclient/frontend/src/components/overlay/Toasts.tsx index 58f9fc31..d883771a 100644 --- a/apps/oneclient/frontend/src/components/overlay/Toasts.tsx +++ b/apps/oneclient/frontend/src/components/overlay/Toasts.tsx @@ -190,7 +190,7 @@ export function Toasts() { if (unlisten) unlisten(); }; - }, []); + }, [toast]); return ( ; export function useDebugInfo(): DebugInfoArray { @@ -38,16 +38,16 @@ export function useCopyDebugInfo() { export function useDebugKeybind() { const copyDebugInfo = useCopyDebugInfo(); - const handleKeybind = (event: ShortcutEvent) => { + const handleKeybind = useCallback((event: ShortcutEvent) => { if (event.state !== 'Pressed') return; copyDebugInfo(); - }; + }, [copyDebugInfo]); useEffect(() => { void (async () => { await register('CommandOrControl+Shift+D', handleKeybind); await register('Alt+F12', handleKeybind); })(); - }, []); + }, [handleKeybind]); } diff --git a/apps/oneclient/frontend/src/hooks/useDiscordRPC.ts b/apps/oneclient/frontend/src/hooks/useDiscordRPC.ts index 86763811..994ee14c 100644 --- a/apps/oneclient/frontend/src/hooks/useDiscordRPC.ts +++ b/apps/oneclient/frontend/src/hooks/useDiscordRPC.ts @@ -36,7 +36,7 @@ export const ResolvedPathNames: Record = { '/onboarding/preferences/versionCategory': 'Preparing OneClient', '/onboarding/preferences': 'Preparing OneClient', '/onboarding/account': 'Preparing OneClient', - '/onboarding/finished': 'Preparing OneClient', + '/onboarding/downloading': 'Preparing OneClient', '/onboarding/language': 'Preparing OneClient', }; diff --git a/apps/oneclient/frontend/src/hooks/useLaunchCluster.ts b/apps/oneclient/frontend/src/hooks/useLaunchCluster.ts index be919927..99a5371d 100644 --- a/apps/oneclient/frontend/src/hooks/useLaunchCluster.ts +++ b/apps/oneclient/frontend/src/hooks/useLaunchCluster.ts @@ -44,7 +44,7 @@ export function useLaunchCluster(clusterId?: number | undefined | null) { }); } }, - [clusterId, navigate], + [clusterId, navigate, toast], ); const forceLaunch = useCallback(async () => { diff --git a/apps/oneclient/frontend/src/hooks/useSettings.ts b/apps/oneclient/frontend/src/hooks/useSettings.ts index 2f575cb7..c30ca4db 100644 --- a/apps/oneclient/frontend/src/hooks/useSettings.ts +++ b/apps/oneclient/frontend/src/hooks/useSettings.ts @@ -5,20 +5,28 @@ import { useEffect, useMemo, useState } from 'react'; export function useSettings() { const settings = useCommandSuspense(['readSettings'], bindings.core.readSettings); - const setting = useMemo(() => (key: TKey) => settings.data[key], [settings]); + const setting = useMemo(() => { + return function setting(key: TKey) { + return settings.data[key]; + }; + }, [settings]); - const setSetting = useMemo(() => (key: TKey, value: Settings[TKey]) => { - bindings.core.writeSettings({ - ...settings.data, - [key]: value, - }); - settings.refetch(); + const setSetting = useMemo(() => { + return function setSetting(key: TKey, value: Settings[TKey]) { + bindings.core.writeSettings({ + ...settings.data, + [key]: value, + }); + settings.refetch(); + }; }, [settings]); type Setter = (value: Settings[TKey]) => void; - const createSetting = useMemo(() => (key: TKey): [Settings[TKey], Setter] => { - return [setting(key), value => setSetting(key, value)]; + const createSetting = useMemo(() => { + return function createSetting(key: TKey): [Settings[TKey], Setter] { + return [setting(key), value => setSetting(key, value)]; + }; }, [setting, setSetting]); return { setting, setSetting, createSetting }; diff --git a/apps/oneclient/frontend/src/routes/app/debug.tsx b/apps/oneclient/frontend/src/routes/app/debug.tsx index ccea9746..7bf92e1a 100644 --- a/apps/oneclient/frontend/src/routes/app/debug.tsx +++ b/apps/oneclient/frontend/src/routes/app/debug.tsx @@ -7,7 +7,7 @@ import useMigrationStore from '@/stores/migrationStore'; import { TitleCase } from '@/utils/string'; import { ToastPositions, ToastTypes, useToast } from '@/utils/toast'; import { Button, Dropdown, TextField } from '@onelauncher/common/components'; -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { dataDir, join } from '@tauri-apps/api/path'; import { useEffect, useState } from 'react'; @@ -170,6 +170,7 @@ function Settings() { function Other() { const [launcherDir, setLauncherDir] = useState(''); const openMigrationGui = useMigrationStore(state => state.openForDebug); + const navigate = useNavigate({ from: '/app/debug' }); useEffect(() => { (async () => { setLauncherDir(await join(await dataDir(), 'OneClient')); @@ -191,6 +192,7 @@ function Other() {

Other

+ diff --git a/apps/oneclient/frontend/src/routes/onboarding/downloading.tsx b/apps/oneclient/frontend/src/routes/onboarding/downloading.tsx new file mode 100644 index 00000000..c2285e88 --- /dev/null +++ b/apps/oneclient/frontend/src/routes/onboarding/downloading.tsx @@ -0,0 +1,198 @@ +import type { ModData } from '@/components'; +import { buildModDataArray, downloadModsParallel, isManagedMod } from '@/components'; +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 { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +export const Route = createFileRoute('/onboarding/downloading')({ + component: RouteComponent, +}); + +// Navbar in onboarding is h-20 = 80px +const NAVBAR_HEIGHT = 80; +// How long the fade-out takes before navigating to /app +const FADE_DURATION_MS = 700; + +function RouteComponent() { + const navigate = useNavigate(); + const { modsPerCluster } = useDownloadStore(); + const clearStore = useDownloadStore(s => s.clear); + const { setSetting } = useSettings(); + const [tips, setTips] = useState>(getCachedOnboardingTips); + + const tip = useMemo(() => tips[Math.floor(Math.random() * tips.length)], [tips]); + + useEffect(() => { + let cancelled = false; + + const loadTips = async () => { + try { + const loadedTips = await loadOnboardingTips(); + if (!cancelled && loadedTips.length > 0) + setTips(loadedTips); + } + catch (error) { + console.warn('[onboarding/downloading] Falling back to backup fun facts.', error); + } + }; + + void loadTips(); + + return () => { + cancelled = true; + }; + }, []); + + // Only redirect on initial mount if store was already empty (safety net for direct navigation). + // Must NOT re-run when clearStore() empties the store at the end of downloads. + const initiallyEmpty = useRef(Object.keys(modsPerCluster).length === 0); + + useEffect(() => { + if (initiallyEmpty.current) + navigate({ to: '/onboarding/preferences/versionCategory' }); + }, [navigate]); + + const mods = useMemo(() => buildModDataArray(modsPerCluster), [modsPerCluster]); + + const [downloadedMods, setDownloadedMods] = useState(0); + const [isComplete, setIsComplete] = useState(false); + const [isLeaving, setIsLeaving] = useState(false); + const hasStarted = useRef(false); + + const percentage = mods.length > 0 ? Math.round((downloadedMods / mods.length) * 100) : 0; + + const download = useCommandMut(async (mod: ModData) => { + 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; + 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); + } + } + + if (mod.bundleName) + await bindings.oneclient.downloadPackageFromBundle(mod.fileKind, mod.clusterId, mod.bundleName, true); + else + await bindings.core.downloadPackage(mod.provider, mod.id, mod.versionId, mod.clusterId, true); + } + else { + if (mod.bundleName) + await bindings.oneclient.downloadPackageFromBundle(mod.fileKind, mod.clusterId, mod.bundleName, true); + else + await bindings.core.downloadExternalPackage(mod.package, mod.clusterId, null, null); + } + }); + + 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 + }, [mods]); + + useEffect(() => { + if (!isComplete) + 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); + }, 400); + + return () => clearTimeout(pauseTimer); + }, [isComplete, clearStore, navigate, setSetting]); + + return ( +
+ {/* Top left: title */} +

+ Downloading... +

+ + {/* Spacer — pushes bottom content to the bottom */} +
+ + {/* Bottom row: percentage+bar on left, tip on right */} +
+ {/* Left: percentage + progress bar */} +
+ + {percentage} + % + +
+
+
+
+ + {/* Right: tip */} +
+

Tip

+

{tip}

+
+
+
+ ); +} diff --git a/apps/oneclient/frontend/src/routes/onboarding/finished.tsx b/apps/oneclient/frontend/src/routes/onboarding/finished.tsx deleted file mode 100644 index 4f42c15b..00000000 --- a/apps/oneclient/frontend/src/routes/onboarding/finished.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useSettings } from '@/hooks/useSettings'; -import { createFileRoute, Link } from '@tanstack/react-router'; - -export const Route = createFileRoute('/onboarding/finished')({ - component: RouteComponent, -}); - -function RouteComponent() { - const { setSetting } = useSettings(); - setSetting('seen_onboarding', true); - - return ( - -
-
-

Finished!

-

Thank you for using OneClient

-
-
- -
-

Click anywhere to exit

-
- - ); -} diff --git a/apps/oneclient/frontend/src/routes/onboarding/preferences/versionCategory.tsx b/apps/oneclient/frontend/src/routes/onboarding/preferences/versionCategory.tsx index 814a425a..cf7dfd8b 100644 --- a/apps/oneclient/frontend/src/routes/onboarding/preferences/versionCategory.tsx +++ b/apps/oneclient/frontend/src/routes/onboarding/preferences/versionCategory.tsx @@ -1,16 +1,17 @@ import type { ClusterModel, ModpackArchive, ModpackFile, OnlineClusterManifest } from '@/bindings.gen'; -import type { DownloadModsRef, ModCardContextApi, ModWithBundle, onClickOnMod } from '@/components'; -import { DownloadMods, ModCardContext, ModList, Overlay } from '@/components'; +import type { ModCardContextApi, ModWithBundle, onClickOnMod } from '@/components'; +import { ModCardContext, ModList, Overlay } from '@/components'; import { useCachedImage } from '@/hooks/useCachedImage'; import { bindings } from '@/main'; import { OnboardingNavigation } from '@/routes/onboarding/route'; +import useDownloadStore from '@/stores/downloadStore'; import { getOnlineClusterForVersion, parseMcVersion } from '@/utils/versionMap'; import { useCommandSuspense } from '@onelauncher/common'; import { Button } from '@onelauncher/common/components'; import { createFileRoute } from '@tanstack/react-router'; import { DotsVerticalIcon } from '@untitled-theme/icons-react'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; -import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Button as AriaButton } from 'react-aria-components'; import { twMerge } from 'tailwind-merge'; @@ -163,27 +164,24 @@ function RouteComponent() { const anySelected = Object.values(modsPerCluster).some(mods => mods.length > 0); - const downloadModsRef = useRef(null); - const wrappedRef = useRef(null); + const setDownloadData = useDownloadStore(s => s.setDownloadData); - useImperativeHandle(wrappedRef, () => ({ - async openDownloadDialog(nextPath?: string) { - 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); - } + 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)); } - downloadModsRef.current?.openDownloadDialog(nextPath); - }, - }), [bundlesPerCluster, modsPerCluster]); + catch (e) { + console.error(`Failed to extract overrides for bundle ${bundle.manifest.name}:`, e); + } + } + setDownloadData(modsPerCluster, bundlesPerCluster); + }; return ( <> @@ -220,15 +218,11 @@ function RouteComponent() { ); })}
- -
- -
- + ); } diff --git a/apps/oneclient/frontend/src/routes/onboarding/route.tsx b/apps/oneclient/frontend/src/routes/onboarding/route.tsx index f9578a52..fd55bac0 100644 --- a/apps/oneclient/frontend/src/routes/onboarding/route.tsx +++ b/apps/oneclient/frontend/src/routes/onboarding/route.tsx @@ -1,9 +1,9 @@ -import type { DownloadModsRef } from '@/components'; import type { PropsWithChildren } from 'react'; import LauncherLogo from '@/assets/logos/oneclient.svg?react'; import { GameBackground, LoaderSuspense, MadeBy, NavbarButton, Overlay, Stepper, SuperSecretDevOptions } from '@/components'; import { prefetchCachedImages } from '@/hooks/useCachedImage'; import { bindings } from '@/main'; +import { preloadOnboardingTips } from '@/utils/onboardingFunFacts'; import { useCommandSuspense } from '@onelauncher/common'; import { Button } from '@onelauncher/common/components'; import { useQueryClient } from '@tanstack/react-query'; @@ -33,10 +33,12 @@ export const Route = createFileRoute('/onboarding')({ const isFirstStep = currentLinearStepIndex === 0; const isLastStep = currentLinearStepIndex === LINEAR_ONBOARDING_STEPS.length - 1; + const isDownloadingStep = location.pathname === '/onboarding/downloading'; return { isFirstStep, isLastStep, + isDownloadingStep, previousPath: currentLinearStepIndex > 0 ? LINEAR_ONBOARDING_STEPS[currentLinearStepIndex - 1]?.path @@ -86,12 +88,13 @@ const ONBOARDING_STEPS: Array = [ title: 'Versions Category', hideNavigationButtons: true, }, + { + path: '/onboarding/downloading', + title: 'Downloading', + hideNavigationButtons: true, + }, ], }, - { - path: '/onboarding/finished', - title: 'Finished', - }, ]; function getLinearSteps(steps: Array): Array> { @@ -126,6 +129,10 @@ function RouteComponent() { void prefetchCachedImages(artPaths); }, [versions.clusters]); + useEffect(() => { + void preloadOnboardingTips(); + }, []); + const { currentLinearStepIndex } = Route.useLoaderData(); return ( @@ -165,7 +172,7 @@ function RouteComponent() { function AppShell({ children, }: PropsWithChildren) { - const { isFirstStep, currentStepIndex } = Route.useLoaderData(); + const { isFirstStep, isDownloadingStep, currentStepIndex } = Route.useLoaderData(); return (
@@ -193,7 +200,7 @@ function AppShell({
-
+
{children}
@@ -210,13 +217,13 @@ function BackgroundGradient() { return (
{/* Linear black gradient: left -> right */} - {/*
-
*/} +
{/* Radial black gradient */}
; disableNext?: boolean }) { +export function OnboardingNavigation({ disableNext, onBeforeNext }: { disableNext?: boolean; onBeforeNext?: () => Promise | void }) { const navigate = useNavigate(); const { isFirstStep, previousPath, nextPath } = Route.useLoaderData(); - function handleNextClick() { + async function handleNextClick() { if (disableNext) return; - if (ref && ref.current !== null) - ref.current.openDownloadDialog(nextPath ?? '/app'); - else - navigate({ to: nextPath ?? '/app' }); + if (onBeforeNext) + await onBeforeNext(); + + navigate({ to: nextPath ?? '/app' }); } return ( diff --git a/apps/oneclient/frontend/src/stores/downloadStore.ts b/apps/oneclient/frontend/src/stores/downloadStore.ts new file mode 100644 index 00000000..0bd9bbdd --- /dev/null +++ b/apps/oneclient/frontend/src/stores/downloadStore.ts @@ -0,0 +1,23 @@ +import type { ModpackArchive } from '@/bindings.gen'; +import type { ModWithBundle } from '@/components'; +import { create } from 'zustand'; + +export interface DownloadStore { + modsPerCluster: Record>; + bundlesPerCluster: Record>; +} + +export interface DownloadStoreActions { + setDownloadData: (modsPerCluster: Record>, bundlesPerCluster: Record>) => void; + clear: () => void; +} + +export const useDownloadStore = create()(set => ({ + modsPerCluster: {}, + bundlesPerCluster: {}, + + setDownloadData: (modsPerCluster, bundlesPerCluster) => set({ modsPerCluster, bundlesPerCluster }), + clear: () => set({ modsPerCluster: {}, bundlesPerCluster: {} }), +})); + +export default useDownloadStore; diff --git a/apps/oneclient/frontend/src/utils/onboardingFunFacts.ts b/apps/oneclient/frontend/src/utils/onboardingFunFacts.ts new file mode 100644 index 00000000..aac381bf --- /dev/null +++ b/apps/oneclient/frontend/src/utils/onboardingFunFacts.ts @@ -0,0 +1,62 @@ +import { ONBOARDING_TIPS_BACKUP } from '@/constants/onboardingTipsBackup'; + +const FUN_FACTS_URL = 'https://raw.githubusercontent.com/Polyfrost/DataStorage/main/oneclient/funfacts.txt'; + +let cachedOnboardingTips: Array | null = null; +let onboardingTipsPromise: Promise> | null = null; + +function normalizeLineBreakMarkers(value: string): string { + return value + .replaceAll('/n', '\n') + .replaceAll('\\n', '\n'); +} + +function parseFunFacts(text: string): Array { + return text + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean) + .map(normalizeLineBreakMarkers); +} + +async function fetchOnboardingTips(): Promise> { + const response = await fetch(FUN_FACTS_URL, { cache: 'no-store' }); + if (!response.ok) + throw new Error(`Failed to fetch onboarding fun facts: ${response.status}`); + + const tips = parseFunFacts(await response.text()); + if (tips.length === 0) + throw new Error('Fetched onboarding fun facts were empty.'); + + return tips; +} + +export function getCachedOnboardingTips(): Array { + return (cachedOnboardingTips ?? ONBOARDING_TIPS_BACKUP).map(normalizeLineBreakMarkers); +} + +export async function loadOnboardingTips(): Promise> { + if (cachedOnboardingTips) + return cachedOnboardingTips; + + if (!onboardingTipsPromise) + onboardingTipsPromise = fetchOnboardingTips() + .then((tips) => { + cachedOnboardingTips = tips; + return tips; + }) + .finally(() => { + onboardingTipsPromise = null; + }); + + return onboardingTipsPromise; +} + +export async function preloadOnboardingTips(): Promise { + try { + await loadOnboardingTips(); + } + catch (error) { + console.warn('[onboarding] Failed to preload fun facts, using backup facts.', error); + } +}