Spice up onboarding

This commit is contained in:
Wyvest 2026-03-30 03:25:51 -04:00
parent 1c3041679e
commit 445eca2775
No known key found for this signature in database
18 changed files with 481 additions and 127 deletions

View file

@ -325,38 +325,10 @@ export type VersionType =
*/ */
"old_beta" "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<void>, export type Router = { 'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>, message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> }, process: (event: ProcessPayload) => Promise<void> },
'debug': { openDevTools: () => Promise<void>,
isInDev: () => Promise<boolean>,
getArch: () => Promise<string>,
getFamily: () => Promise<string>,
getLocale: () => Promise<string>,
getType: () => Promise<string>,
getPlatform: () => Promise<string>,
getOsVersion: () => Promise<string>,
getOsDistro: () => Promise<string>,
getGitCommitHash: () => Promise<string>,
getBuildTimestamp: () => Promise<string>,
getPackageVersion: () => Promise<string>,
getFullDebugInfo: () => Promise<DebugInfoData>,
getFullDebugInfoParsed: () => Promise<DebugInfoParsedLine[]>,
getFullDebugInfoParsedString: () => Promise<string>,
hasDevSettings: () => Promise<boolean> },
'oneclient': { getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]>,
getVersions: () => Promise<OnlineClusterManifest>,
extractBundleOverrides: (bundlePath: string, clusterId: number) => Promise<null>,
checkForUpdate: () => Promise<Update | null>,
installUpdate: () => Promise<null>,
downloadPackageFromBundle: (package: ModpackFileKind, clusterId: number, bundleName: string, skipCompatibility: boolean | null) => Promise<PackageModel>,
updateBundlePackages: (clusterId: number) => Promise<ApplyBundleUpdatesResult>,
isBundleSyncing: () => Promise<boolean>,
cacheArt: (path: string) => Promise<string>,
refreshArt: (path: string) => Promise<null>,
copyClusterContent: (sourceId: number, targetId: number) => Promise<CopyClusterResult> },
'core': { getClusters: () => Promise<ClusterModel[]>, 'core': { getClusters: () => Promise<ClusterModel[]>,
getClusterById: (id: number) => Promise<ClusterModel | null>, getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>, removeCluster: (id: number) => Promise<null>,
@ -412,7 +384,35 @@ convertUsernameUUID: (usernameUuid: string) => Promise<MowojangProfile>,
setDiscordRPCMessage: (message: string) => Promise<null>, setDiscordRPCMessage: (message: string) => Promise<null>,
open: (input: string) => Promise<null> }, open: (input: string) => Promise<null> },
'folders': { fromCluster: (folderName: string) => Promise<string>, 'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> } }; openCluster: (folderName: string) => Promise<null> },
'debug': { openDevTools: () => Promise<void>,
isInDev: () => Promise<boolean>,
getArch: () => Promise<string>,
getFamily: () => Promise<string>,
getLocale: () => Promise<string>,
getType: () => Promise<string>,
getPlatform: () => Promise<string>,
getOsVersion: () => Promise<string>,
getOsDistro: () => Promise<string>,
getGitCommitHash: () => Promise<string>,
getBuildTimestamp: () => Promise<string>,
getPackageVersion: () => Promise<string>,
getFullDebugInfo: () => Promise<DebugInfoData>,
getFullDebugInfoParsed: () => Promise<DebugInfoParsedLine[]>,
getFullDebugInfoParsedString: () => Promise<string>,
hasDevSettings: () => Promise<boolean> },
'oneclient': { getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]>,
getVersions: () => Promise<OnlineClusterManifest>,
extractBundleOverrides: (bundlePath: string, clusterId: number) => Promise<null>,
checkForUpdate: () => Promise<Update | null>,
installUpdate: () => Promise<null>,
downloadPackageFromBundle: (package: ModpackFileKind, clusterId: number, bundleName: string, skipCompatibility: boolean | null) => Promise<PackageModel>,
updateBundlePackages: (clusterId: number) => Promise<ApplyBundleUpdatesResult>,
isBundleSyncing: () => Promise<boolean>,
cacheArt: (path: string) => Promise<string>,
refreshArt: (path: string) => Promise<null>,
copyClusterContent: (sourceId: number, targetId: number) => Promise<CopyClusterResult> } };
export type { InferCommandOutput } export type { InferCommandOutput }

View file

@ -105,7 +105,39 @@ export function DownloadMods({ modsPerCluster, ref }: { modsPerCluster: Record<s
); );
} }
function downloadModsParallel(items: ModDataArray, limit: number, fn: (mod: ModData, index: number) => Promise<void>) { export function buildModDataArray(modsPerCluster: Record<string, Array<ModWithBundle>>): 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<void>) {
let index = 0; let index = 0;
const workers = Array.from({ length: limit }).fill(null).map(async () => { const workers = Array.from({ length: limit }).fill(null).map(async () => {
while (index < items.length) { while (index < items.length) {

View file

@ -4,12 +4,11 @@ import { twMerge } from 'tailwind-merge';
import CavesAndCliffs from '@/assets/backgrounds/CavesAndCliffs.jpg'; import CavesAndCliffs from '@/assets/backgrounds/CavesAndCliffs.jpg';
import HypixelSkyblockHub from '@/assets/backgrounds/HypixelSkyblockHub.png'; import HypixelSkyblockHub from '@/assets/backgrounds/HypixelSkyblockHub.png';
import MinecraftBuilding from '@/assets/backgrounds/MinecraftBuilding.png';
const GameBackgrounds = { const GameBackgrounds = {
HypixelSkyblockHub, HypixelSkyblockHub,
CavesAndCliffs, CavesAndCliffs,
MinecraftBuilding, MinecraftBuilding: HypixelSkyblockHub,
}; };
export type GameBackgroundName = keyof typeof GameBackgrounds; export type GameBackgroundName = keyof typeof GameBackgrounds;

View file

@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import type { MinecraftAuthErrorInfo } from './minecraft-auth-errors'; import type { MinecraftAuthErrorInfo } from './minecraft-auth-errors';
import { Overlay } from '@/components'; import { Overlay } from '@/components';
import { bindings } from '@/main'; import { bindings } from '@/main';
@ -32,6 +33,42 @@ export function isMinecraftAuthError(error: unknown): boolean {
return extractXboxErrorCode(error) !== null; return extractXboxErrorCode(error) !== null;
} }
function renderStepWithLinks(step: string): Array<ReactNode> {
const anchorRegex = /<a\s+href="([^"]+)"[^>]*>(.*?)<\/a>/g;
const nodes: Array<ReactNode> = [];
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(
<a
className="text-brand-primary underline"
href={href}
key={`${href}-${match.index}`}
rel="noopener noreferrer"
target="_blank"
>
{label}
</a>,
);
lastIndex = match.index + fullMatch.length;
match = anchorRegex.exec(step);
}
if (lastIndex < step.length)
nodes.push(step.slice(lastIndex));
return nodes;
}
function MatchedErrorContent({ function MatchedErrorContent({
matchedError, matchedError,
}: { }: {
@ -52,10 +89,7 @@ function MatchedErrorContent({
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-brand-primary/20 text-brand-primary text-xs flex items-center justify-center font-medium mt-0.5"> <span className="flex-shrink-0 w-5 h-5 rounded-full bg-brand-primary/20 text-brand-primary text-xs flex items-center justify-center font-medium mt-0.5">
{index + 1} {index + 1}
</span> </span>
<span <span className="text-fg-secondary text-sm">{renderStepWithLinks(step)}</span>
className="text-fg-secondary text-sm [&_a]:text-brand-primary [&_a]:underline"
dangerouslySetInnerHTML={{ __html: step }}
/>
</li> </li>
))} ))}
</ol> </ol>

View file

@ -3,11 +3,17 @@ import { useCopyDebugInfo } from '@/hooks/useDebugInfo';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { bindings } from '@/main'; import { bindings } from '@/main';
import { Button } from '@onelauncher/common/components'; import { Button } from '@onelauncher/common/components';
import { Link } from '@tanstack/react-router'; import { Link, useNavigate } from '@tanstack/react-router';
export function SuperSecretDevOptions() { export function SuperSecretDevOptions() {
const { createSetting } = useSettings(); const { createSetting, setSetting } = useSettings();
const copyDebugInfo = useCopyDebugInfo(); const copyDebugInfo = useCopyDebugInfo();
const navigate = useNavigate();
const handleSkipOnboarding = () => {
setSetting('seen_onboarding', true);
navigate({ to: '/app' });
};
return ( return (
<Overlay.Dialog> <Overlay.Dialog>
@ -31,9 +37,7 @@ export function SuperSecretDevOptions() {
<Button size="normal">Debug Page</Button> <Button size="normal">Debug Page</Button>
</Link> </Link>
<Link to="/onboarding/finished"> <Button onPress={handleSkipOnboarding} size="normal">Skip Onboarding</Button>
<Button size="normal">Skip Onboarding</Button>
</Link>
</div> </div>

View file

@ -190,7 +190,7 @@ export function Toasts() {
if (unlisten) if (unlisten)
unlisten(); unlisten();
}; };
}, []); }, [toast]);
return ( return (
<ToastContainer <ToastContainer

View file

@ -0,0 +1,17 @@
export const ONBOARDING_TIPS_BACKUP = [
'418 I\'m a teapot',
'Floors 8 through 10 of the Catacombs were originally planned for release, but portions of it were later merged into Floor 7 and its Master Mode counterpart.',
'At one point, Feather Client was willing to pay $300 USD to add Skytils, a popular SkyBlock mod.',
'Grass is a plant with narrow leaves growing from the base. [...]\nGrasses are monocotyledon, herbaceous plants.',
'In Canada, Santa\'s postal code is H0H 0H0.',
'Guvf vf abg na vzcbegnag fragrapr.',
'Vs lbh qrpvcure guvf, lbh fubhyq yvfgra gb Mrgihr\'f zhfvp.',
'Try to do something creative! It could be music, art, coding...\nYou won\'t know how much you like it until you try it, so give it a shot :)',
'At one point in Minecraft, there was a rare chance of Snow Golems naturally spawning.',
'W[h]yvest',
'RUMOUR. Open your ears; 9r"5j5&?OWTY Z0d',
'Make sure to drink water! You never know if you might be dehydrated.',
'People asking you to "test their mod" are likely trying to hack you.\nThe only ones you can trust are ones widely trusted by the community.',
'Have a great day!',
'You can sleep through a thunderstorm, even during the day',
];

View file

@ -4,7 +4,7 @@ import { bindings } from '@/main';
import { useToast } from '@/utils/toast'; import { useToast } from '@/utils/toast';
import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { register } from '@tauri-apps/plugin-global-shortcut'; import { register } from '@tauri-apps/plugin-global-shortcut';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
export type DebugInfoArray = Array<DebugInfoParsedLine>; export type DebugInfoArray = Array<DebugInfoParsedLine>;
export function useDebugInfo(): DebugInfoArray { export function useDebugInfo(): DebugInfoArray {
@ -38,16 +38,16 @@ export function useCopyDebugInfo() {
export function useDebugKeybind() { export function useDebugKeybind() {
const copyDebugInfo = useCopyDebugInfo(); const copyDebugInfo = useCopyDebugInfo();
const handleKeybind = (event: ShortcutEvent) => { const handleKeybind = useCallback((event: ShortcutEvent) => {
if (event.state !== 'Pressed') if (event.state !== 'Pressed')
return; return;
copyDebugInfo(); copyDebugInfo();
}; }, [copyDebugInfo]);
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
await register('CommandOrControl+Shift+D', handleKeybind); await register('CommandOrControl+Shift+D', handleKeybind);
await register('Alt+F12', handleKeybind); await register('Alt+F12', handleKeybind);
})(); })();
}, []); }, [handleKeybind]);
} }

View file

@ -36,7 +36,7 @@ export const ResolvedPathNames: Record<URLPath, string> = {
'/onboarding/preferences/versionCategory': 'Preparing OneClient', '/onboarding/preferences/versionCategory': 'Preparing OneClient',
'/onboarding/preferences': 'Preparing OneClient', '/onboarding/preferences': 'Preparing OneClient',
'/onboarding/account': 'Preparing OneClient', '/onboarding/account': 'Preparing OneClient',
'/onboarding/finished': 'Preparing OneClient', '/onboarding/downloading': 'Preparing OneClient',
'/onboarding/language': 'Preparing OneClient', '/onboarding/language': 'Preparing OneClient',
}; };

View file

@ -44,7 +44,7 @@ export function useLaunchCluster(clusterId?: number | undefined | null) {
}); });
} }
}, },
[clusterId, navigate], [clusterId, navigate, toast],
); );
const forceLaunch = useCallback(async () => { const forceLaunch = useCallback(async () => {

View file

@ -5,20 +5,28 @@ import { useEffect, useMemo, useState } from 'react';
export function useSettings() { export function useSettings() {
const settings = useCommandSuspense(['readSettings'], bindings.core.readSettings); const settings = useCommandSuspense(['readSettings'], bindings.core.readSettings);
const setting = useMemo(() => <TKey extends keyof Settings>(key: TKey) => settings.data[key], [settings]); const setting = useMemo(() => {
return function setting<TKey extends keyof Settings>(key: TKey) {
return settings.data[key];
};
}, [settings]);
const setSetting = useMemo(() => <TKey extends keyof Settings>(key: TKey, value: Settings[TKey]) => { const setSetting = useMemo(() => {
bindings.core.writeSettings({ return function setSetting<TKey extends keyof Settings>(key: TKey, value: Settings[TKey]) {
...settings.data, bindings.core.writeSettings({
[key]: value, ...settings.data,
}); [key]: value,
settings.refetch(); });
settings.refetch();
};
}, [settings]); }, [settings]);
type Setter<TKey extends keyof Settings> = (value: Settings[TKey]) => void; type Setter<TKey extends keyof Settings> = (value: Settings[TKey]) => void;
const createSetting = useMemo(() => <TKey extends keyof Settings>(key: TKey): [Settings[TKey], Setter<TKey>] => { const createSetting = useMemo(() => {
return [setting(key), value => setSetting(key, value)]; return function createSetting<TKey extends keyof Settings>(key: TKey): [Settings[TKey], Setter<TKey>] {
return [setting(key), value => setSetting(key, value)];
};
}, [setting, setSetting]); }, [setting, setSetting]);
return { setting, setSetting, createSetting }; return { setting, setSetting, createSetting };

View file

@ -7,7 +7,7 @@ import useMigrationStore from '@/stores/migrationStore';
import { TitleCase } from '@/utils/string'; import { TitleCase } from '@/utils/string';
import { ToastPositions, ToastTypes, useToast } from '@/utils/toast'; import { ToastPositions, ToastTypes, useToast } from '@/utils/toast';
import { Button, Dropdown, TextField } from '@onelauncher/common/components'; 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 { dataDir, join } from '@tauri-apps/api/path';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@ -170,6 +170,7 @@ function Settings() {
function Other() { function Other() {
const [launcherDir, setLauncherDir] = useState(''); const [launcherDir, setLauncherDir] = useState('');
const openMigrationGui = useMigrationStore(state => state.openForDebug); const openMigrationGui = useMigrationStore(state => state.openForDebug);
const navigate = useNavigate({ from: '/app/debug' });
useEffect(() => { useEffect(() => {
(async () => { (async () => {
setLauncherDir(await join(await dataDir(), 'OneClient')); setLauncherDir(await join(await dataDir(), 'OneClient'));
@ -191,6 +192,7 @@ function Other() {
<h1 className="text-3xl font-semibold">Other</h1> <h1 className="text-3xl font-semibold">Other</h1>
<div className="flex flex-row gap-4"> <div className="flex flex-row gap-4">
<Button onPress={bindings.debug.openDevTools} size="normal">Open Dev Tools</Button> <Button onPress={bindings.debug.openDevTools} size="normal">Open Dev Tools</Button>
<Button onPress={() => navigate({ to: '/onboarding' })} size="normal">Open Onboarding</Button>
<Button onPress={openMigrationGui} size="normal">Open Migration GUI</Button> <Button onPress={openMigrationGui} size="normal">Open Migration GUI</Button>
<Button onClick={openLauncherDir} size="normal">Open Launcher Data</Button> <Button onClick={openLauncherDir} size="normal">Open Launcher Data</Button>
<Button onClick={logRunningProcesses} size="normal">Log Running Processes to frontend</Button> <Button onClick={logRunningProcesses} size="normal">Log Running Processes to frontend</Button>

View file

@ -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<Array<string>>(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 (
<div
className="flex flex-col px-12 pb-12 transition-opacity"
style={{
// min-height fills the screen below the navbar without relying on h-full,
// which collapses inside the motion.div wrapper that has no explicit height.
minHeight: `calc(100vh - ${NAVBAR_HEIGHT}px)`,
paddingTop: '1rem',
opacity: isLeaving ? 0 : 1,
transitionDuration: `${FADE_DURATION_MS}ms`,
transitionTimingFunction: 'ease-in-out',
}}
>
{/* Top left: title */}
<h1 className="text-4xl font-bold text-fg-primary">
Downloading...
</h1>
{/* Spacer — pushes bottom content to the bottom */}
<div className="flex-1" />
{/* Bottom row: percentage+bar on left, tip on right */}
<div className="flex items-end justify-between gap-12">
{/* Left: percentage + progress bar */}
<div className="flex flex-col gap-2 w-64">
<span className="text-2xl font-semibold text-fg-primary">
{percentage}
%
</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"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
{/* Right: tip */}
<div className="max-w-xs text-right">
<p className="text-xs font-semibold uppercase tracking-widest text-fg-secondary mb-1">Tip</p>
<p className="text-sm text-fg-secondary whitespace-pre-line">{tip}</p>
</div>
</div>
</div>
);
}

View file

@ -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 (
<Link to="/app">
<div className="flex flex-col h-full px-12">
<div>
<h1 className="text-4xl font-semibold mb-2">Finished!</h1>
<p className="text-slate-400 text-lg mb-2">Thank you for using OneClient</p>
</div>
</div>
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex flex-row gap-2">
<p className="text-slate-500 text-lg mb-2">Click anywhere to exit</p>
</div>
</Link>
);
}

View file

@ -1,16 +1,17 @@
import type { ClusterModel, ModpackArchive, ModpackFile, OnlineClusterManifest } from '@/bindings.gen'; import type { ClusterModel, ModpackArchive, ModpackFile, OnlineClusterManifest } from '@/bindings.gen';
import type { DownloadModsRef, ModCardContextApi, ModWithBundle, onClickOnMod } from '@/components'; import type { ModCardContextApi, ModWithBundle, onClickOnMod } from '@/components';
import { DownloadMods, ModCardContext, ModList, Overlay } from '@/components'; import { ModCardContext, ModList, Overlay } from '@/components';
import { useCachedImage } from '@/hooks/useCachedImage'; import { useCachedImage } from '@/hooks/useCachedImage';
import { bindings } from '@/main'; import { bindings } from '@/main';
import { OnboardingNavigation } from '@/routes/onboarding/route'; import { OnboardingNavigation } from '@/routes/onboarding/route';
import useDownloadStore from '@/stores/downloadStore';
import { getOnlineClusterForVersion, parseMcVersion } from '@/utils/versionMap'; import { getOnlineClusterForVersion, parseMcVersion } from '@/utils/versionMap';
import { useCommandSuspense } from '@onelauncher/common'; import { useCommandSuspense } from '@onelauncher/common';
import { Button } from '@onelauncher/common/components'; import { Button } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { DotsVerticalIcon } from '@untitled-theme/icons-react'; import { DotsVerticalIcon } from '@untitled-theme/icons-react';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-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 { Button as AriaButton } from 'react-aria-components';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
@ -163,27 +164,24 @@ function RouteComponent() {
const anySelected = Object.values(modsPerCluster).some(mods => mods.length > 0); const anySelected = Object.values(modsPerCluster).some(mods => mods.length > 0);
const downloadModsRef = useRef<DownloadModsRef>(null); const setDownloadData = useDownloadStore(s => s.setDownloadData);
const wrappedRef = useRef<DownloadModsRef>(null);
useImperativeHandle(wrappedRef, () => ({ const handleBeforeNext = async () => {
async openDownloadDialog(nextPath?: string) { for (const [clusterId, bundles] of Object.entries(bundlesPerCluster))
for (const [clusterId, bundles] of Object.entries(bundlesPerCluster)) for (const bundle of bundles) {
for (const bundle of bundles) { const selectedMods = modsPerCluster[clusterId] ?? [];
const selectedMods = modsPerCluster[clusterId] ?? []; const hasSelectedBundleContent = selectedMods.some(mod => mod.bundleName === bundle.manifest.name);
const hasSelectedBundleContent = selectedMods.some(mod => mod.bundleName === bundle.manifest.name); if (!hasSelectedBundleContent)
if (!hasSelectedBundleContent) continue;
continue; try {
try { await bindings.oneclient.extractBundleOverrides(bundle.path, Number(clusterId));
await bindings.oneclient.extractBundleOverrides(bundle.path, Number(clusterId));
}
catch (e) {
console.error(`Failed to extract overrides for bundle ${bundle.manifest.name}:`, e);
}
} }
downloadModsRef.current?.openDownloadDialog(nextPath); catch (e) {
}, console.error(`Failed to extract overrides for bundle ${bundle.manifest.name}:`, e);
}), [bundlesPerCluster, modsPerCluster]); }
}
setDownloadData(modsPerCluster, bundlesPerCluster);
};
return ( return (
<> <>
@ -220,15 +218,11 @@ function RouteComponent() {
); );
})} })}
</div> </div>
<div className="hidden">
<DownloadMods modsPerCluster={modsPerCluster} ref={downloadModsRef} />
</div>
</div> </div>
</OverlayScrollbarsComponent> </OverlayScrollbarsComponent>
</div> </div>
</div> </div>
<OnboardingNavigation disableNext={!anySelected} ref={wrappedRef} /> <OnboardingNavigation disableNext={!anySelected} onBeforeNext={handleBeforeNext} />
</> </>
); );
} }

View file

@ -1,9 +1,9 @@
import type { DownloadModsRef } from '@/components';
import type { PropsWithChildren } from 'react'; import type { PropsWithChildren } from 'react';
import LauncherLogo from '@/assets/logos/oneclient.svg?react'; import LauncherLogo from '@/assets/logos/oneclient.svg?react';
import { GameBackground, LoaderSuspense, MadeBy, NavbarButton, Overlay, Stepper, SuperSecretDevOptions } from '@/components'; import { GameBackground, LoaderSuspense, MadeBy, NavbarButton, Overlay, Stepper, SuperSecretDevOptions } from '@/components';
import { prefetchCachedImages } from '@/hooks/useCachedImage'; import { prefetchCachedImages } from '@/hooks/useCachedImage';
import { bindings } from '@/main'; import { bindings } from '@/main';
import { preloadOnboardingTips } from '@/utils/onboardingFunFacts';
import { useCommandSuspense } from '@onelauncher/common'; import { useCommandSuspense } from '@onelauncher/common';
import { Button } from '@onelauncher/common/components'; import { Button } from '@onelauncher/common/components';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
@ -33,10 +33,12 @@ export const Route = createFileRoute('/onboarding')({
const isFirstStep = currentLinearStepIndex === 0; const isFirstStep = currentLinearStepIndex === 0;
const isLastStep = currentLinearStepIndex === LINEAR_ONBOARDING_STEPS.length - 1; const isLastStep = currentLinearStepIndex === LINEAR_ONBOARDING_STEPS.length - 1;
const isDownloadingStep = location.pathname === '/onboarding/downloading';
return { return {
isFirstStep, isFirstStep,
isLastStep, isLastStep,
isDownloadingStep,
previousPath: previousPath:
currentLinearStepIndex > 0 currentLinearStepIndex > 0
? LINEAR_ONBOARDING_STEPS[currentLinearStepIndex - 1]?.path ? LINEAR_ONBOARDING_STEPS[currentLinearStepIndex - 1]?.path
@ -86,12 +88,13 @@ const ONBOARDING_STEPS: Array<OnboardingStep> = [
title: 'Versions Category', title: 'Versions Category',
hideNavigationButtons: true, hideNavigationButtons: true,
}, },
{
path: '/onboarding/downloading',
title: 'Downloading',
hideNavigationButtons: true,
},
], ],
}, },
{
path: '/onboarding/finished',
title: 'Finished',
},
]; ];
function getLinearSteps(steps: Array<OnboardingStep>): Array<Omit<OnboardingStep, 'subSteps'>> { function getLinearSteps(steps: Array<OnboardingStep>): Array<Omit<OnboardingStep, 'subSteps'>> {
@ -126,6 +129,10 @@ function RouteComponent() {
void prefetchCachedImages(artPaths); void prefetchCachedImages(artPaths);
}, [versions.clusters]); }, [versions.clusters]);
useEffect(() => {
void preloadOnboardingTips();
}, []);
const { currentLinearStepIndex } = Route.useLoaderData(); const { currentLinearStepIndex } = Route.useLoaderData();
return ( return (
@ -165,7 +172,7 @@ function RouteComponent() {
function AppShell({ function AppShell({
children, children,
}: PropsWithChildren) { }: PropsWithChildren) {
const { isFirstStep, currentStepIndex } = Route.useLoaderData(); const { isFirstStep, isDownloadingStep, currentStepIndex } = Route.useLoaderData();
return ( return (
<div className="flex flex-col h-full w-full"> <div className="flex flex-col h-full w-full">
@ -193,7 +200,7 @@ function AppShell({
<MadeBy /> <MadeBy />
</div> </div>
<div className={`flex-1 flex ${isFirstStep ? '' : 'bg-page'} flex-col relative`}> <div className={`flex-1 flex ${(isFirstStep || isDownloadingStep) ? '' : 'bg-page'} flex-col relative`}>
{children} {children}
</div> </div>
</div> </div>
@ -210,13 +217,13 @@ function BackgroundGradient() {
return ( return (
<div className="relative"> <div className="relative">
{/* Linear black gradient: left -> right */} {/* Linear black gradient: left -> right */}
{/* <div <div
className="absolute top-0 left-0 w-screen h-screen -z-10" className="absolute top-0 left-0 w-screen h-screen -z-10"
style={{ style={{
background: 'linear-gradient(270deg, rgba(0, 0, 0, 0.00) 35%, rgba(0, 0, 0, 0.60) 87.5%)', background: 'linear-gradient(270deg, rgba(0, 0, 0, 0.00) 35%, rgba(0, 0, 0, 0.60) 87.5%)',
}} }}
> >
</div> */} </div>
{/* Radial black gradient */} {/* Radial black gradient */}
<div <div
@ -244,18 +251,18 @@ function BackgroundGradient() {
); );
} }
export function OnboardingNavigation({ ref, disableNext }: { ref?: React.RefObject<DownloadModsRef | null>; disableNext?: boolean }) { export function OnboardingNavigation({ disableNext, onBeforeNext }: { disableNext?: boolean; onBeforeNext?: () => Promise<void> | void }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { isFirstStep, previousPath, nextPath } = Route.useLoaderData(); const { isFirstStep, previousPath, nextPath } = Route.useLoaderData();
function handleNextClick() { async function handleNextClick() {
if (disableNext) if (disableNext)
return; return;
if (ref && ref.current !== null) if (onBeforeNext)
ref.current.openDownloadDialog(nextPath ?? '/app'); await onBeforeNext();
else
navigate({ to: nextPath ?? '/app' }); navigate({ to: nextPath ?? '/app' });
} }
return ( return (

View file

@ -0,0 +1,23 @@
import type { ModpackArchive } from '@/bindings.gen';
import type { ModWithBundle } from '@/components';
import { create } from 'zustand';
export interface DownloadStore {
modsPerCluster: Record<string, Array<ModWithBundle>>;
bundlesPerCluster: Record<number, Array<ModpackArchive>>;
}
export interface DownloadStoreActions {
setDownloadData: (modsPerCluster: Record<string, Array<ModWithBundle>>, bundlesPerCluster: Record<number, Array<ModpackArchive>>) => void;
clear: () => void;
}
export const useDownloadStore = create<DownloadStore & DownloadStoreActions>()(set => ({
modsPerCluster: {},
bundlesPerCluster: {},
setDownloadData: (modsPerCluster, bundlesPerCluster) => set({ modsPerCluster, bundlesPerCluster }),
clear: () => set({ modsPerCluster: {}, bundlesPerCluster: {} }),
}));
export default useDownloadStore;

View file

@ -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<string> | null = null;
let onboardingTipsPromise: Promise<Array<string>> | null = null;
function normalizeLineBreakMarkers(value: string): string {
return value
.replaceAll('/n', '\n')
.replaceAll('\\n', '\n');
}
function parseFunFacts(text: string): Array<string> {
return text
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean)
.map(normalizeLineBreakMarkers);
}
async function fetchOnboardingTips(): Promise<Array<string>> {
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<string> {
return (cachedOnboardingTips ?? ONBOARDING_TIPS_BACKUP).map(normalizeLineBreakMarkers);
}
export async function loadOnboardingTips(): Promise<Array<string>> {
if (cachedOnboardingTips)
return cachedOnboardingTips;
if (!onboardingTipsPromise)
onboardingTipsPromise = fetchOnboardingTips()
.then((tips) => {
cachedOnboardingTips = tips;
return tips;
})
.finally(() => {
onboardingTipsPromise = null;
});
return onboardingTipsPromise;
}
export async function preloadOnboardingTips(): Promise<void> {
try {
await loadOnboardingTips();
}
catch (error) {
console.warn('[onboarding] Failed to preload fun facts, using backup facts.', error);
}
}