feat: Implemented public plugin system accessible from the store.

feat: Implemented external ryujinx integration plugin
refactor: moved sdk types and schemas to own workspace package
fix: Fixed emulator launch with no game
This commit is contained in:
Simeon Radivoev 2026-05-10 01:46:57 +03:00
parent 9051834ace
commit 38cb752552
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
124 changed files with 1918 additions and 1067 deletions

View file

@ -29,7 +29,7 @@ import FocusTooltip from "@/mainview/components/FocusTooltip";
import { AutoFocus } from "@/mainview/components/AutoFocus";
import { FilterUI } from "@/mainview/components/Filters";
import Markdown from "react-markdown";
import { FrontEndEmulatorDetailed } from "@/shared/types";
import { FrontEndEmulatorDetailed } from "@simeonradivoev/gameflow-sdk/shared";
export const Route = createFileRoute('/store/details/emulator/$id')({
component: RouteComponent,

View file

@ -0,0 +1,161 @@
import { AutoFocus } from '@/mainview/components/AutoFocus';
import DotsLoading from '@/mainview/components/backgrounds/dots';
import { StickyHeaderUI } from '@/mainview/components/Header';
import LoadingScreen from '@/mainview/components/LoadingScreen';
import { Button } from '@/mainview/components/options/Button';
import { FloatingShortcuts } from '@/mainview/components/Shortcuts';
import StatList, { StatEntry } from '@/mainview/components/StatList';
import { installPluginMutation, pluginFilter, uninstallPluginMutation, updatePluginMutation } from '@/mainview/scripts/queries/plugins';
import { pluginDetailsQuery } from '@/mainview/scripts/queries/store';
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
import { HandleGoBack } from '@/mainview/scripts/utils';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { QueryClient, useMutation } from '@tanstack/react-query';
import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router';
import { ArrowRight, CircleFadingArrowUp, Download, Settings, Trash } from 'lucide-react';
import prettyBytes from 'pretty-bytes';
import { Suspense } from 'react';
export const Route = createFileRoute('/store/details/plugin/$id')({
component: RouteComponent,
pendingComponent: Loading,
async loader (ctx)
{
const id = decodeURIComponent(ctx.params.id);
const data = await ctx.context.queryClient.fetchQuery(pluginDetailsQuery(id));
return { data };
},
});
function Loading ()
{
const { ref, focusSelf } = useFocusable({ focusKey: 'plugin-details' });
return <>
<DotsLoading ref={ref} />
<AutoFocus focus={focusSelf} />
</>;
}
function Details ()
{
const { id } = Route.useParams();
const plugin = decodeURIComponent(id);
const { data } = Route.useLoaderData();
const navigate = useNavigate();
const handleRefresh = (client: QueryClient) =>
{
client.invalidateQueries(pluginFilter(plugin));
navigate({ to: '/store/details/plugin/$id', params: { id: encodeURIComponent(id) }, replace: true });
};
const update = useMutation({
...updatePluginMutation(plugin),
onSuccess (data, variables, onMutateResult, context)
{
handleRefresh(context.client);
},
});
const install = useMutation({
...installPluginMutation(plugin),
onSuccess (data, variables, onMutateResult, context)
{
handleRefresh(context.client);
},
});
const uninstall = useMutation({
...uninstallPluginMutation(plugin),
onSuccess (data, variables, onMutateResult, context)
{
handleRefresh(context.client);
},
});
const stats: StatEntry[] = [];
if (data.devDependencies)
{
stats.push({ content: Object.keys(data.devDependencies), label: "Dev Dependecies" });
}
if (data.dependencies)
{
stats.push({ content: Object.keys(data.dependencies), label: "Dependecies" });
}
if (data.maintainers)
{
stats.push({ content: data.maintainers.map(m => m.name), label: "Maintainers" });
}
if (data.dist)
{
stats.push({ content: prettyBytes(data.dist.unpackedSize), label: "Size" });
}
if (data.license)
{
stats.push({ content: data.license, label: "License" });
}
return <>
<div className='flex justify-between p-8'>
<div className='flex flex-col gap-2'>
<div className='text-3xl font-bold'>{data.name}</div>
<div className='flex gap-2'>
<div className='flex gap-1'>
{data.update ? <>
<div className='bg-base-300 px-2 rounded-full'>{data.update.from}</div>
<ArrowRight />
<div className='bg-warning text-warning-content px-2 rounded-full'>{data.version}</div>
</> :
<div className='bg-base-300 px-2 rounded-full'>{data.version}</div>}
</div>
by {data.author?.name ?? data._npmUser?.name}</div>
</div>
<div className='flex gap-2 items-center'>
{data.installed && <>
{!!data.update && <Button onAction={e => update.mutate()} className='gap-2' style='warning' id='install-btn' >
{update.isPending ? <span className="loading loading-spinner loading-lg"></span> : <CircleFadingArrowUp />} Update
</Button>}
<Button onAction={e => uninstall.mutate()} className='gap-2' style='accent' id='install-btn' >
{uninstall.isPending ? <span className="loading loading-spinner loading-lg"></span> : <Trash />} Uninstall
</Button>
<Button external onAction={e => { navigate({ to: '/settings/plugin/$source', params: { source: encodeURIComponent(plugin) } }); }} className='gap-2' style='info' id='install-btn' >
<Settings /> Settings
</Button>
</>}
{!data.installed && <Button onAction={e => install.mutate()} className='gap-2' style='accent' id='install-btn' >
{install.isPending ? <span className="loading loading-spinner loading-lg"></span> : <Download />} Install
</Button>}
</div>
</div>
<div className="divider">Details</div>
<div className='px-8'>
<div className='p-4 bg-base-200 rounded-2xl'>{data.description}</div>
<StatList id={'plugin-stats'} stats={stats} />
</div>
<div className="divider">Keywords</div>
<div className='flex gap-2 px-8'>
{data.keywords.map(k => <li className='flex px-2 bg-base-300 rounded-full'>{k}</li>)}
</div>
</>;
}
function RouteComponent ()
{
const router = useRouter();
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'plugin-details' });
useShortcuts(focusKey, () => [{
label: "Return", button: GamePadButtonCode.B, action (e)
{
HandleGoBack(router, e);
},
}]);
return <div ref={ref} className='absolute w-full h-full overflow-y-scroll overflow-x-hidden'>
<FocusContext value={focusKey}>
<StickyHeaderUI ref={ref} />
<Suspense fallback={<LoadingScreen />}>
<Details />
</Suspense>
<FloatingShortcuts />
</FocusContext>
<AutoFocus focus={focusSelf} />
</div>;
}

View file

@ -8,13 +8,13 @@ import LoadMoreButton from '@/mainview/components/LoadMoreButton';
import { storeGamesInfiniteQuery } from '@queries/store';
import InvalidStoreError from '@/mainview/components/store/InvalidStoreError';
import { CardList, GameMetaExtra } from '@/mainview/components/CardList';
import { GameListFilterType, RPC_URL } from '@/shared/constants';
import { RPC_URL } from '@/shared/constants';
import { GameListFilterType, FrontEndGameType } from '@simeonradivoev/gameflow-sdk/shared';
import { useSessionStorage } from 'usehooks-ts';
import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod';
import SideFilters from '@/mainview/components/SideFilters';
import { gameFiltersQuery } from '@/mainview/scripts/queries/romm';
import { FrontEndGameType } from '@/shared/types';
export const Route = createFileRoute('/store/tab/games')({
component: RouteComponent,

View file

@ -16,7 +16,7 @@ import { useQuery } from '@tanstack/react-query';
import { autoEmulatorsQuery } from '@queries/settings';
import { storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@queries/store';
import ImageWithFallbacks from '@/mainview/components/ImageWithFallbacks';
import { FrontEndGameTypeDetailed } from '@/shared/types';
import { FrontEndGameTypeDetailed } from '@simeonradivoev/gameflow-sdk/shared';
export const Route = createFileRoute('/store/tab/')({
component: RouteComponent

View file

@ -0,0 +1,151 @@
import { allPluginsFilter, installPluginMutation, uninstallPluginMutation, updatePluginMutation } from '@/mainview/scripts/queries/plugins';
import { pluginsQuery } from '@/mainview/scripts/queries/store';
import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts';
import { FOCUS_KEYS } from '@/mainview/scripts/types';
import { PluginEntryType } from '@simeonradivoev/gameflow-sdk/shared';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { QueryClient, useMutation, useQuery } from '@tanstack/react-query';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
import { CircleFadingArrowUp, Dot, Download, HardDrive, Puzzle } from 'lucide-react';
import prettyMilliseconds from 'pretty-ms';
import { useSessionStorage } from 'usehooks-ts';
import z from 'zod';
export const Route = createFileRoute('/store/tab/plugins')({
component: RouteComponent,
validateSearch: zodValidator(z.object({
search: z.string().optional()
}))
});
function PluginCard (data: { plugin: PluginEntryType; })
{
const navigate = useNavigate();
const onAction = () =>
{
navigate({ to: '/store/details/plugin/$id', params: { id: decodeURIComponent(data.plugin.package.name) } });
};
const { ref, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.PLUGIN_ENTRY(data.plugin.package.sanitized_name), onEnterPress: onAction });
const handleRefresh = (client: QueryClient) =>
{
client.invalidateQueries(allPluginsFilter);
navigate({ to: '/store/tab/plugins', replace: true });
};
const update = useMutation({
...updatePluginMutation(data.plugin.package.name),
onSuccess (data, variables, onMutateResult, context)
{
handleRefresh(context.client);
},
});
const install = useMutation({
...installPluginMutation(data.plugin.package.name),
onSuccess (f, variables, onMutateResult, context)
{
handleRefresh(context.client);
}
});
const uninstall = useMutation({
...uninstallPluginMutation(data.plugin.package.name),
onSuccess (f, variables, onMutateResult, context)
{
handleRefresh(context.client);
}
});
useShortcuts(focusKey, () =>
{
const shortcuts: Shortcut[] = [{
label: "Details", button: GamePadButtonCode.A, action (e)
{
onAction();
},
}];
if (data.plugin.installed)
{
shortcuts.push({
label: "Uninstall",
button: GamePadButtonCode.X,
action (e)
{
uninstall.mutate();
},
});
if (data.plugin.update)
{
shortcuts.push({
label: "Update",
button: GamePadButtonCode.Y,
action (e)
{
update.mutate();
},
});
}
} else
{
shortcuts.push({
label: "Install",
button: GamePadButtonCode.X,
action (e)
{
install.mutate();
},
});
}
return shortcuts;
}, [data.plugin.installed, install.isPending, uninstall.isPending]);
return <div ref={ref} onClick={onAction} data-installed={data.plugin.installed} className='flex flex-wrap bg-base-100 p-4 rounded-2xl focusable focusable-secondary focusable-hover justify-between cursor-pointer'>
<div className='flex flex-col gap-1'>
<div className='flex gap-2 font-bold text-xl in-data-[installed=true]:text-info'>
{data.plugin.installed && <HardDrive className='p-1 bg-base-300 rounded-full size-8 text-base-content' />}
{data.plugin.update && <CircleFadingArrowUp className='p-1 bg-warning text-warning-content rounded-full size-8' />}
{data.plugin.package.name}
{(install.isPending || uninstall.isPending) && <span className="loading loading-spinner loading-lg"></span>}
</div>
<div className='text-base-content/40'>{data.plugin.package.description}</div>
<ul className='flex flex-wrap gap-2'>{data.plugin.package.keywords.concat(...data.plugin.installed ? ["installed"] : []).map(k => <li className='bg-base-300 px-2 rounded-full'>{k}</li>)}</ul>
<ul className='flex flex-wrap gap-2'>
<li>{data.plugin.package.publisher.username}</li>
<Dot />
<li>{data.plugin.package.version}</li>
<Dot />
<li>{prettyMilliseconds(new Date().getTime() - data.plugin.package.date.getTime(), { hideSeconds: true })}</li>
<Dot />
<li>{data.plugin.package.license}</li>
{install.isPending && <>
<Dot />
<li><span className="loading loading-spinner loading-md"></span>installing</li>
</>}
{uninstall.isPending && <>
<Dot />
<li><span className="loading loading-spinner loading-md"></span>uninstalling</li>
</>}
</ul>
</div>
<div className='flex justify-center items-center'>
<div className='flex gap-2 bg-base-300 rounded-3xl px-3 py-2'>
<Download />
{data.plugin.downloads.monthly}
</div>
</div>
</div>;
}
function RouteComponent ()
{
const [search] = useSessionStorage<string | undefined>(`${Route.to}-search`, undefined);
const { data: plugins } = useQuery(pluginsQuery(search));
const { ref, focusKey } = useFocusable({ focusKey: "plugins-store" });
return <div ref={ref}>
<FocusContext value={focusKey}>
<div className="divider"><Puzzle className='size-12' /> {plugins?.total} Plugins</div>
<div className='flex flex-col gap-2 p-8'>
{plugins?.objects.map((p, i) => <PluginCard key={i} plugin={p} />)}
</div>
</FocusContext>
</div>;
}

View file

@ -14,6 +14,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useMatchRoute, useRouter } from '@tanstack/react-router';
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
import { Gamepad2, Home, Joystick, Puzzle } from 'lucide-react';
import { useRef } from 'react';
import { useSessionStorage } from 'usehooks-ts';
import z from 'zod';
@ -93,9 +94,10 @@ function RouteComponent ()
const headerRef = useRef(null);
const sentinelRef = useRef(null);
const filters: Record<string, FilterOption> = {
home: { label: "Home", selected: useIsSettings(''), },
emulators: { label: "Emulators", selected: useIsSettings('emulators') },
games: { label: "Games", selected: useIsSettings('games') }
home: { label: "Home", icon: <Home />, selected: useIsSettings(''), },
emulators: { label: "Emulators", icon: <Joystick />, selected: useIsSettings('emulators') },
games: { label: "Games", icon: <Gamepad2 />, selected: useIsSettings('games') },
plugins: { label: "Plugins", icon: <Puzzle />, selected: useIsSettings('plugins') }
};
const [search, setSearch] = useSessionStorage<string | undefined>(`${router.history.location.pathname}-search`, undefined);
const [, setGamesSearch] = useSessionStorage<string | undefined>(`/store/tab/games-search`, undefined);