feat: added update notes and moved update to own tab

feat: added update info for emulators
This commit is contained in:
Simeon Radivoev 2026-04-26 14:56:54 +03:00
parent 813785f4f3
commit cf84f40a17
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
14 changed files with 318 additions and 34 deletions

View file

@ -20,9 +20,12 @@ import SelfUpdateJob from "./jobs/self-update-job";
async function checkUpdate (force?: boolean)
{
const latest = await getOrCachedGithubRelease('simeonradivoev/gameflow-deck', force);
if (!latest || !latest.tag_name) return { hasUpdate: 0, version: getAppVersion() };
if (!latest || !latest.tag_name) return {
hasUpdate: 0,
version: getAppVersion()
};
const hasUpdate = semver.order(latest.tag_name, getAppVersion());
return { hasUpdate, version: latest.tag_name };
return { hasUpdate, version: latest.tag_name, info: latest.body };
}
export const system = new Elysia({ prefix: '/api/system' })

View file

@ -25,7 +25,6 @@ async function shutdown (code: number)
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
process.on('SIGUSR1', () => shutdown(3));
if (process.env.HEADLESS)
{

View file

@ -89,7 +89,7 @@ function UpdateStatus ()
{
const handleSelect = () =>
{
navigate({ to: '/settings/about' });
navigate({ to: '/settings/update' });
};
const hasUnread = false;
const navigate = useNavigate();

View file

@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './../routes/__root'
import { Route as GamesRouteImport } from './../routes/games'
import { Route as SettingsRouteRouteImport } from './../routes/settings/route'
import { Route as IndexRouteImport } from './../routes/index'
import { Route as SettingsUpdateRouteImport } from './../routes/settings/update'
import { Route as SettingsPluginsRouteImport } from './../routes/settings/plugins'
import { Route as SettingsInterfaceRouteImport } from './../routes/settings/interface'
import { Route as SettingsEmulatorsRouteImport } from './../routes/settings/emulators'
@ -45,6 +46,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsUpdateRoute = SettingsUpdateRouteImport.update({
id: '/update',
path: '/update',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsPluginsRoute = SettingsPluginsRouteImport.update({
id: '/plugins',
path: '/plugins',
@ -142,6 +148,7 @@ export interface FileRoutesByFullPath {
'/settings/emulators': typeof SettingsEmulatorsRoute
'/settings/interface': typeof SettingsInterfaceRoute
'/settings/plugins': typeof SettingsPluginsRoute
'/settings/update': typeof SettingsUpdateRoute
'/collection/$source/$id': typeof CollectionSourceIdRoute
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
'/game/$source/$id': typeof GameSourceIdRoute
@ -163,6 +170,7 @@ export interface FileRoutesByTo {
'/settings/emulators': typeof SettingsEmulatorsRoute
'/settings/interface': typeof SettingsInterfaceRoute
'/settings/plugins': typeof SettingsPluginsRoute
'/settings/update': typeof SettingsUpdateRoute
'/collection/$source/$id': typeof CollectionSourceIdRoute
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
'/game/$source/$id': typeof GameSourceIdRoute
@ -186,6 +194,7 @@ export interface FileRoutesById {
'/settings/emulators': typeof SettingsEmulatorsRoute
'/settings/interface': typeof SettingsInterfaceRoute
'/settings/plugins': typeof SettingsPluginsRoute
'/settings/update': typeof SettingsUpdateRoute
'/collection/$source/$id': typeof CollectionSourceIdRoute
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
'/game/$source/$id': typeof GameSourceIdRoute
@ -210,6 +219,7 @@ export interface FileRouteTypes {
| '/settings/emulators'
| '/settings/interface'
| '/settings/plugins'
| '/settings/update'
| '/collection/$source/$id'
| '/embedded/$source/$id'
| '/game/$source/$id'
@ -231,6 +241,7 @@ export interface FileRouteTypes {
| '/settings/emulators'
| '/settings/interface'
| '/settings/plugins'
| '/settings/update'
| '/collection/$source/$id'
| '/embedded/$source/$id'
| '/game/$source/$id'
@ -253,6 +264,7 @@ export interface FileRouteTypes {
| '/settings/emulators'
| '/settings/interface'
| '/settings/plugins'
| '/settings/update'
| '/collection/$source/$id'
| '/embedded/$source/$id'
| '/game/$source/$id'
@ -301,6 +313,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/settings/update': {
id: '/settings/update'
path: '/update'
fullPath: '/settings/update'
preLoaderRoute: typeof SettingsUpdateRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/plugins': {
id: '/settings/plugins'
path: '/plugins'
@ -430,6 +449,7 @@ interface SettingsRouteRouteChildren {
SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute
SettingsInterfaceRoute: typeof SettingsInterfaceRoute
SettingsPluginsRoute: typeof SettingsPluginsRoute
SettingsUpdateRoute: typeof SettingsUpdateRoute
SettingsPluginSourceRoute: typeof SettingsPluginSourceRoute
}
@ -440,6 +460,7 @@ const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
SettingsEmulatorsRoute: SettingsEmulatorsRoute,
SettingsInterfaceRoute: SettingsInterfaceRoute,
SettingsPluginsRoute: SettingsPluginsRoute,
SettingsUpdateRoute: SettingsUpdateRoute,
SettingsPluginSourceRoute: SettingsPluginSourceRoute,
}

View file

@ -1,6 +1,7 @@
@import "tailwindcss";
@import 'animate.css';
@plugin "daisyui";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@custom-variant light (&:where([data-theme=light], [data-theme=light] *));

View file

@ -16,15 +16,7 @@ function RouteComponent ()
{
const { data: systemInfo } = useQuery(systemInfoQuery);
const { ref, focusKey } = useFocusable({ focusKey: 'about-section' });
const { data: hasUpdate, refetch: refetchHasUpdate } = useQuery(hasUpdateQuery);
const update = useMutation(updateMutation);
const forceCheckUpdate = useMutation({
...checkUpdateMutation,
onSuccess (data, variables, onMutateResult, context)
{
refetchHasUpdate();
},
});
return <table ref={ref} className="table">
@ -34,17 +26,6 @@ function RouteComponent ()
<th>Version</th>
<td>{systemInfo?.data?.version}</td>
</tr>
<tr>
<th>Update</th>
<td className='flex flex-flex-wrap gap-2'>
{
hasUpdate && hasUpdate.hasUpdate > 0 ?
<Button className='gap-3' style='warning' id='update-btn' onAction={() => update.mutate()}><CircleFadingArrowUp /> Update to {hasUpdate?.version}</Button> :
<Button className='gap-3' id='update-btn' onAction={() => forceCheckUpdate.mutate()}>{forceCheckUpdate.isPending ? <span className="loading loading-spinner loading-lg"></span> : <RefreshCcw />}Check for Update</Button>
}
{<Button className='gap-3' id='force-update-btn' onAction={() => update.mutate()}><CircleFadingArrowUp /> Force Update</Button>}
</td>
</tr>
<tr>
<th>Agent</th>
<td>{navigator.userAgent}</td>

View file

@ -23,6 +23,7 @@ import
Joystick,
MonitorCog,
Puzzle,
RefreshCcw,
} from "lucide-react";
import { JSX, useMemo } from "react";
import { twMerge } from "tailwind-merge";
@ -166,6 +167,12 @@ function SettingsMenu (data: {})
label="Directories"
icon={<HardDrive />}
/>
<MenuItem
focusSelect
route="/settings/update"
label="Updates"
icon={<RefreshCcw />}
/>
<MenuItem
focusSelect
route="/settings/about"

View file

@ -0,0 +1,75 @@
import { AutoFocus } from '@/mainview/components/AutoFocus';
import DotsLoading from '@/mainview/components/backgrounds/dots';
import { Button } from '@/mainview/components/options/Button';
import { checkUpdateMutation, hasUpdateQuery, updateMutation } from '@/mainview/scripts/queries/system';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { useMutation, useQuery } from '@tanstack/react-query';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { CircleFadingArrowUp, RefreshCcw } from 'lucide-react';
import { MarkdownAsync } from 'react-markdown';
export const Route = createFileRoute('/settings/update')({
component: RouteComponent,
pendingComponent: Loading,
async loader (ctx)
{
const data = await ctx.context.queryClient.fetchQuery(hasUpdateQuery);
return { data: data };
},
});
function Loading ()
{
const { ref, focusSelf } = useFocusable({ focusKey: 'updates' });
return <>
<DotsLoading ref={ref} />
<AutoFocus focus={focusSelf} />
</>;
}
function RouteComponent ()
{
const { data } = Route.useLoaderData();
const navigate = useNavigate();
const update = useMutation(updateMutation);
const forceCheckUpdate = useMutation({
...checkUpdateMutation,
onSuccess (data, variables, onMutateResult, context)
{
context.client.invalidateQueries(hasUpdateQuery);
navigate({ to: '/settings/update', replace: true });
},
});
const { ref, focusKey } = useFocusable({ focusKey: 'updates' });
return <div ref={ref}>
<FocusContext value={focusKey}>
<h1 className='text-2xl text-center'>Version: {data.version}</h1>
<div className='flex flex-flex-wrap gap-2'>
{
data.hasUpdate > 0 ?
<Button className='gap-3' style='warning' id='update-btn' onAction={() => update.mutate()}><CircleFadingArrowUp /> Update to {data.version}</Button> :
<Button className='gap-3' id='update-btn' onAction={() => forceCheckUpdate.mutate()}>{forceCheckUpdate.isPending ? <span className="loading loading-spinner loading-lg"></span> : <RefreshCcw />}Check for Update</Button>
}
{<Button className='gap-3' id='force-update-btn' onAction={() => update.mutate()}><CircleFadingArrowUp /> Force Update</Button>}
</div>
<div className="divider">Version Info</div>
<div className="prose lg:prose-xl">
<MarkdownAsync components={{
a ({ node, children, ...props })
{
try
{
new URL(props.href ?? "");
// If we don't get an error, then it's an absolute URL.
props.target = "_blank";
props.rel = "noopener noreferrer";
} catch (e) { }
return <a {...props}>{children}</a>;
},
}} >{data.info}</MarkdownAsync>
</div>
</FocusContext>
</div>;
}

View file

@ -1,4 +1,4 @@
import { useRef } from "react";
import { useRef, useState } from "react";
import
{
useFocusable,
@ -27,6 +27,8 @@ import { deleteBiosMutation, downloadBiosMutation, installEmulatorMutation, stor
import { gamesRecommendedBasedOnEmulatorQuery } from "@queries/romm";
import FocusTooltip from "@/mainview/components/FocusTooltip";
import { AutoFocus } from "@/mainview/components/AutoFocus";
import { FilterUI } from "@/mainview/components/Filters";
import Markdown from "react-markdown";
export const Route = createFileRoute('/store/details/emulator/$id')({
component: RouteComponent,
@ -330,6 +332,16 @@ const capabilityIconMap: Record<string, any> = {
batch: <Terminal />
};
function InfoTabs (data: { tabs: Record<string, FilterOption>, selectTab: (v: string) => void; })
{
const { ref, focusKey } = useFocusable({ focusKey: 'emulator-info-tabs-section', onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'start' })(focusKey, ref.current, d) });
return <div ref={ref} className="divider scroll-mt-48">
<FocusContext value={focusKey}>
<FilterUI id="emulator-info-tabs" options={data.tabs} setSelected={v => data.selectTab(v)} />
</FocusContext>
</div>;
}
export function RouteComponent ()
{
const { id } = Route.useParams();
@ -343,6 +355,7 @@ export function RouteComponent ()
const { data: emulator, isPending: isEmulatorPending } = useQuery(storeEmulatorDetailsQuery(id));
const { data: recommendedEmulators } = useQuery(storeEmulatorsRecommendedQuery(id));
const { data: recommendedGames } = useQuery(gamesRecommendedBasedOnEmulatorQuery(id));
const [infoTab, setInfoTab] = useState("stats");
useShortcuts(focusKey, () => [{
label: "Return",
@ -389,7 +402,15 @@ export function RouteComponent ()
stats.push({
label: "Bios", content: emulator.bios && emulator.bios.length > 0 ? emulator.bios : <div className="text-warning font-semibold">Missing</div>
});
}
const infoTabs: Record<string, FilterOption> = {
stats: { label: "Stats", selected: infoTab === 'stats', icon: <Info /> },
};
if (emulator?.storeDownloadInfo?.hasUpdate)
{
infoTabs.update = { label: "Update", icon: <CircleFadingArrowUp />, selected: infoTab === 'update' };
}
return (
@ -411,8 +432,9 @@ export function RouteComponent ()
</div>
</div>
<div className="flex flex-col bg-base-100 py-4 gap-12 z-10">
<div className="divider"> <Info className="size-12" /> Stats</div>
<StatList id="emulator-details-stats" stats={stats} onFocus={scrollIntoViewHandler({ block: 'center' })} />
<InfoTabs tabs={infoTabs} selectTab={setInfoTab} />
{infoTab === 'stats' && <StatList id="emulator-details-stats" stats={stats} focusable={false} />}
{infoTab === 'update' && <Markdown>{emulator?.storeDownloadInfo?.description}</Markdown>}
{recommendedEmulators && <div className="relative bg-base-200">
<div className="bg-dots z-0"></div>
<EmulatorsSection

View file

@ -38,7 +38,7 @@ declare interface FrontEndEmulatorDetailed extends FrontEndEmulator
screenshots: string[];
biosRequirement?: "required" | "optional";
bios?: string[];
storeDownloadInfo?: { hasUpdate: boolean; version?: string, type: string; };
storeDownloadInfo?: { hasUpdate: boolean; version?: string, type: string; description?: string; };
}
declare interface FrontEndGameTypeDetailedAchievement