feat: Implemented romm saves for dolphin and xenia

feat: Implemented save backups for emulatorjs
fix: Added support for rar archives
fix: Moved to individual ini adjustments for pcsx2 and ppsspp to allow for user editing of configs
This commit is contained in:
Simeon Radivoev 2026-04-09 17:15:37 +03:00
parent 54dd9256e3
commit 7948bd24fa
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
36 changed files with 1103 additions and 243 deletions

View file

@ -1,7 +1,15 @@
import { RPC_URL } from "@/shared/constants";
import { Clock, CloudUpload, Save } from "lucide-react";
import { useEffect } from "react";
import toast, { ToastOptions } from "react-hot-toast";
const customIconMap = {
save: <Save />,
upload: <CloudUpload />,
clock: <Clock />
};
export default function Notifications (data: {})
{
useEffect(() =>
@ -10,7 +18,13 @@ export default function Notifications (data: {})
es.addEventListener('notification', (e) =>
{
const notification = JSON.parse(e.data) as FrontendNotification;
const options: ToastOptions = { removeDelay: notification.duration };
const options: ToastOptions = {
removeDelay: notification.duration,
style: {
borderRadius: "64px"
}
};
if (notification.icon) options.icon = customIconMap[notification.icon];
if (notification.type === 'error')
{
toast.error(notification.message, options);

View file

@ -2,16 +2,16 @@ import { scrollIntoViewHandler } from "@/mainview/scripts/utils";
import { RPC_URL } from "@/shared/constants";
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { Clock, CloudDownload, HardDrive, Store, TriangleAlert } from "lucide-react";
import { Clock, CloudBackup, CloudDownload, CloudUpload, HardDrive, Store, TriangleAlert } from "lucide-react";
import prettyBytes from "pretty-bytes";
import { JSX } from "react";
import ActionButtons from "./ActionButtons";
import prettyMilliseconds from 'pretty-ms';
export function DetailElement (data: { icon: JSX.Element; children?: any | any[]; })
export function DetailElement (data: { icon: JSX.Element; tooltip?: string | null, children?: any | any[]; })
{
return (
<div className="flex gap-2 items-center">
<div className="flex gap-2 items-center tooltip" data-tip={data.tooltip}>
{data.icon}
{data.children}
</div>
@ -62,15 +62,14 @@ export default function Details (data: {
}
</div>
<div className="flex-2 flex flex-col sm:gap-1 md:gap-6 sm:pt-2 md:pt-16 min-h-0">
<div className="flex flex-wrap sm:gap-4 md:gap-6 shrink-0">
<DetailElement icon={<Clock />} >{data.game?.last_played ? new Date(data.game.last_played).toDateString() : "Never"}</DetailElement>
<div className="flex flex-wrap items-center sm:gap-4 md:gap-6 shrink-0">
<DetailElement icon={<Clock />} >{data.game?.last_played ? `${prettyMilliseconds(new Date().getTime() - new Date(data.game.last_played).getTime(), { compact: true, verbose: true })} ago` : "Never"}</DetailElement>
{!!data.game && (data.game.fs_size_bytes !== null || data.game.missing) &&
<div className={classNames({ "text-error": data.game.missing })}>
<div className="tooltip" data-tip={data.game.path_fs}>
<DetailElement icon={fileSizeIcon} >{data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)}</DetailElement>
</div>
<div className={classNames("flex items-center", { "text-error": data.game.missing })}>
<DetailElement tooltip={data.game.path_fs} icon={fileSizeIcon} >{data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)}</DetailElement>
</div>}
<DetailElement icon={platformCoverImg ? <img className="size-6" src={platformCoverImg.href}></img> : <div className="skeleton size-6 rounded-full shrink-0"></div>} >{data.game?.platform_display_name ?? <div className="skeleton h-4 w-32"></div>}</DetailElement>
{data.game?.emulators?.some(e => e.integrations.some(i => i.capabilities?.includes('saves'))) && <DetailElement tooltip={"Save Backup"} icon={<CloudUpload />} />}
<DetailElement icon={
<Store />
} >

View file

@ -10,10 +10,11 @@ Array.from(params.entries()).forEach(([key, value]) =>
window.addEventListener('message', (e) =>
{
switch (e.data.type)
const data = e.data as EmulatorJsMessage;
switch (data.type)
{
case 'pause':
if (e.data.data === true)
if (data.paused)
{
window.EJS_emulator.pause();
} else
@ -24,14 +25,51 @@ window.addEventListener('message', (e) =>
case 'restart':
window.EJS_emulator.elements.bottomBar.restart[0].click();
break;
case 'requestSave':
window.EJS_emulator.elements.bottomBar.saveSavFiles[0].click();
break;
}
});
function postMessage (m: EmulatorJsMessage)
{
window.parent.postMessage(
m,
"*"
);
}
export function loadEmulatorJSSave (save: Uint8Array)
{
const FS = window.EJS_emulator.gameManager.FS;
const path = window.EJS_emulator.gameManager.getSaveFilePath();
const paths = path.split("/");
let cp = "";
for (let i = 0; i < paths.length - 1; i++)
{
if (paths[i] === "") continue;
cp += "/" + paths[i];
if (!FS.analyzePath(cp).exists) FS.mkdir(cp);
}
if (FS.analyzePath(path).exists) FS.unlink(path);
FS.writeFile(path, save);
window.EJS_emulator.gameManager.loadSaveFiles();
}
window.EJS_threads = !__PUBLIC__;
window.EJS_player = "#game";
window.EJS_lightgun = false;
window.EJS_startOnLoaded = true;
window.EJS_onGameStart = async () =>
{
const savesResponse = await fetch(`${RPC_URL(__HOST__)}/api/romm/emulatorjs/load?filePath=${encodeURIComponent(window.EJS_emulator.gameManager.getSaveFilePath())}`);
if (savesResponse.ok)
{
loadEmulatorJSSave(new Uint8Array(await savesResponse.arrayBuffer()));
postMessage({ type: "loaded" });
}
};
// For core downloads, it either redirects to CDN or uses local if downloaded
window.EJS_pathtodata = `${RPC_URL(__HOST__)}/api/romm/emulatorjs/data`;
window.EJS_Buttons = {
@ -40,10 +78,8 @@ window.EJS_Buttons = {
displayName: "Exit",
callback: () =>
{
window.parent.postMessage(
{ type: "exit" },
"*"
);
const saveFile = window.EJS_emulator.gameManager.getSaveFile(false);
postMessage({ type: "exit", save: saveFile ? new File([saveFile], window.EJS_emulator.gameManager.getSaveFilePath()) : undefined });
}
}
};
@ -58,7 +94,18 @@ const moduleUrls = import.meta.glob
import: 'default',
});
function handeSave (ctx: { save: ArrayBuffer, screenshot: ArrayBuffer | undefined, format: string; })
{
window.parent.postMessage({ type: 'save', save: new File([ctx.save], window.EJS_emulator.gameManager.getSaveFilePath()) });
}
// emulatorjs expects basenames instead of paths for some reason
window.EJS_paths = Object.fromEntries(await Promise.all(Object.entries(moduleUrls).map(async ([key, value]) => [basename(key), await value()])));
window.EJS_onSaveUpdate = (ctx: { hash: string, save: ArrayBuffer, screenshot: ArrayBuffer | undefined, format: string; }) => handeSave(ctx);
window.EJS_onSaveSave = (ctx: {
save: ArrayBuffer;
screenshot: ArrayBuffer;
format: string;
}) => handeSave(ctx);
await import('@emulatorjs/emulatorjs/data/loader.js' as any);

View file

@ -14,6 +14,7 @@ export declare global
EJS_cheats: string[][],
EJS_fullscreenOnLoaded: boolean,
EJS_startOnLoaded: boolean,
EJS_onGameStart,
EJS_core: string,
EJS_lightgun: boolean,
EJS_biosUrl: string,
@ -56,7 +57,9 @@ export declare global
EJS_browserMode,
EJS_shaders,
EJS_fixedSaveInterval,
EJS_onSaveUpdate,
EJS_disableAutoUnload,
EJS_disableBatchBootup;
EJS_onSaveSave;
}
}

View file

@ -5,20 +5,24 @@ import z from 'zod';
import { RefObject, useEffect, useRef, useState } from 'react';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { ButtonStyle } from '../components/options/Button';
import { DoorOpen, RefreshCw, Undo } from 'lucide-react';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts';
import { CloudDownload, DoorOpen, RefreshCw, Save, Undo } from 'lucide-react';
import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts';
import { FloatingShortcuts } from '../components/Shortcuts';
import { useEventListener } from 'usehooks-ts';
import useActiveControl from '../scripts/gamepads';
import { twMerge } from 'tailwind-merge';
import { HeaderAccounts, HeaderStatusBar } from '../components/Header';
import { RoundButton } from '../components/RoundButton';
import { gameQuery } from '@queries/romm';
import { rommApi } from '../scripts/clientApi';
import toast from 'react-hot-toast';
import { getErrorMessage } from 'react-error-boundary';
export const Route = createFileRoute('/embedded/$source/$id')({
component: RouteComponent,
staticData: {
enterSound: 'launch'
enterSound: 'launch',
missNavSound: false
},
loader: async (ctx) =>
{
@ -45,7 +49,7 @@ function OverlayButton (data: {
function Overlay (data: {
open: boolean;
iframeRef: RefObject<HTMLIFrameElement | null>;
postMessage: (m: EmulatorJsMessage) => void;
close: () => void;
goBack: () => void;
})
@ -64,7 +68,6 @@ function Overlay (data: {
}, [data.open]);
const { isPointer } = useActiveControl();
const handleEvent = (type: string, value?: any) => data.iframeRef.current?.contentWindow?.postMessage({ type, data: value });
return <div data-open={data.open} className='flex group w-full flex-col gap-2 transition-opacity p-4 not-data-[open=true]:pointer-events-none not-data-[open=true]:opacity-0'>
<div className='grid grid-cols-3 justify-between items-start'>
@ -78,7 +81,7 @@ function Overlay (data: {
<OverlayButton id="restart" style='secondary' tooltip='Restart' setTooltip={setTooltip} onAction={() =>
{
data.close();
handleEvent('restart');
data.postMessage({ type: 'restart' });
}} ><RefreshCw /></OverlayButton>
<OverlayButton id="exit" style='warning' tooltip='Exit' setTooltip={setTooltip} onAction={data.goBack} ><DoorOpen /></OverlayButton>
</FocusContext>
@ -132,6 +135,7 @@ function RouteComponent ()
});
const iframeRef = useRef<HTMLIFrameElement>(null);
const [overlayOpen, setOverlayOpen] = useState(false);
const postMessage = (m: EmulatorJsMessage) => iframeRef.current?.contentWindow?.postMessage(m);
const { source, id } = Route.useParams();
function HandleGoBack ()
@ -147,9 +151,23 @@ function RouteComponent ()
useEventListener('message', e =>
{
if (e.data.type === 'exit')
const data = e.data as EmulatorJsMessage;
switch (data.type)
{
HandleGoBack();
case "exit":
rommApi.api.romm.emulatorjs.post_play({ source })({ id }).post({ save: data.save });
HandleGoBack();
break;
case "loaded":
toast.success("Save Loaded", { icon: <CloudDownload /> });
break;
case "save":
rommApi.api.romm.emulatorjs.save.put({ save: data.save }).then(r =>
{
if (r.error) toast.error(getErrorMessage(r.error.value) ?? "Error While Saving");
else toast.success("Save Backed Up");
});
break;
}
});
@ -173,11 +191,11 @@ function RouteComponent ()
const setPaused = (paused: boolean) =>
{
if (paused) iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: true });
if (paused) postMessage({ type: 'pause', paused: true });
else
{
// we want to prevent input from closing the overlay spilling
setTimeout(() => iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: false }), 100);
setTimeout(() => postMessage({ type: 'pause', paused: false }), 100);
}
};
useEffect(() => setPaused(overlayOpen), [overlayOpen]);
@ -191,7 +209,7 @@ function RouteComponent ()
<FocusContext value={focusKey}>
<Frame ref={iframeRef} />
<div className='flex fixed left-0 right-0 top-0'>
<Overlay iframeRef={iframeRef} goBack={HandleGoBack} open={overlayOpen} close={handleClose} />
<Overlay postMessage={postMessage} goBack={HandleGoBack} open={overlayOpen} close={handleClose} />
</div>
<FloatingShortcuts />
</FocusContext>

View file

@ -104,6 +104,8 @@ function Stats (data: { game: FrontEndGameTypeDetailed | undefined; })
stats.push({ label: "Release Date", content: data.game.release_date.toLocaleDateString(), icon: <Calendar /> });
if (data.game.emulators)
stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) });
const integrations = new Set<string>(data.game.emulators?.flatMap(e => e.integrations).flatMap(i => i.capabilities).filter(c => !!c));
stats.push({ label: "Integrations", content: Array.from(integrations) });
}
return <StatList elementClassName="bg-base-300" stats={stats} id="game-detail-stats" />;

View file

@ -5,6 +5,7 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts';
import { useJobStatus } from '../scripts/utils';
import { useRef } from 'react';
export const Route = createFileRoute('/launcher/$source/$id')({
component: RouteComponent,
@ -13,6 +14,10 @@ export const Route = createFileRoute('/launcher/$source/$id')({
},
});
const stateLookup: Record<string, string> = {
saves: "Syncing Saves"
};
function RouteComponent ()
{
const router = useRouter();
@ -27,12 +32,18 @@ function RouteComponent ()
}
}
const progressRef = useRef<HTMLProgressElement>(null);
const { source, id } = Route.useParams();
const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` });
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { data } = useJobStatus('launch-game', {
const { data, state } = useJobStatus('launch-game', {
onProgress (process, data)
{
if (progressRef.current)
progressRef.current.value = process;
},
onEnded (data)
{
HandleGoBack();
@ -41,14 +52,19 @@ function RouteComponent ()
{
HandleGoBack();
},
});
}, [progressRef.current, HandleGoBack]);
useBlocker({ shouldBlockFn: () => !!data });
return <AnimatedBackground ref={ref} backgroundKey='game-details'>
<div className='flex shadow-2xs shadow-black flex-col absolute w-screen h-screen overflow-hidden justify-center items-center gap-4'>
<DotsLoading />
<h1 className='font-semibold'>Launching {data?.name} ...</h1>
{!!state && !!stateLookup[state] ?
<>
<h1 className='font-semibold'>Launching {data?.name} ...</h1> <progress ref={progressRef} className="progress w-56" value={0} max="100"></progress>
</>
:
<h1 className='font-semibold'>Launching {data?.name} ...</h1>}
</div>
<FloatingShortcuts />
</AnimatedBackground>;

View file

@ -10,7 +10,7 @@ import Shortcuts, { FloatingShortcuts } from "@/mainview/components/Shortcuts";
import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
import { rommApi, systemApi } from "@/mainview/scripts/clientApi";
import { Button } from "@/mainview/components/options/Button";
import { ChevronDown, CircleFadingArrowUp, Cpu, Download, Gamepad2, Info, Puzzle, Settings, Trash2, TriangleAlert, WandSparkles } from "lucide-react";
import { ChevronDown, CircleFadingArrowUp, CloudUpload, Cpu, Download, Fullscreen, Gamepad2, Info, Monitor, Puzzle, Save, Settings, Settings2, Terminal, Trash2, TriangleAlert, WandSparkles } from "lucide-react";
import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog";
import { RPC_URL } from "@/shared/constants";
import Screenshots from "@/mainview/components/Screenshots";
@ -283,6 +283,9 @@ function TitleArea (data: {
{data.emulator && data.emulator.integrations.length > 0 && <div className="tooltip" data-tip="Has Integration">
<div className="bg-base-200 rounded-full p-2"><WandSparkles className="size-5" /></div>
</div>}
{data.emulator?.integrations.some(s => s.capabilities?.includes('saves')) && <div className="tooltip" data-tip="Save Support">
<div className="bg-base-200 rounded-full p-2"><CloudUpload className="size-5" /></div>
</div>}
</div>
</div>
<div className="flex relative sm:portrait:grow md:grow-0 justify-center gap-4 items-center">
@ -319,6 +322,14 @@ function Description (data: { emulator?: FrontEndEmulatorDetailed; })
</div>;
}
const capabilityIconMap: Record<string, any> = {
saves: <CloudUpload />,
fullscreen: <Fullscreen />,
resolution: <Monitor />,
config: <Settings2 />,
batch: <Terminal />
};
export function RouteComponent ()
{
const { id } = Route.useParams();
@ -366,7 +377,9 @@ export function RouteComponent ()
<Puzzle />
<div>{i.id}</div>
</div>
<div className="text-base-content/40">{`${i.capabilities?.join(", ")}`}</div>
<div className="flex flex-wrap text-base-content/40">
{i.capabilities?.map(c => <><div className="divider divider-horizontal"></div><div className="flex gap-1">{capabilityIconMap[c]}{c}</div></>)}
</div>
</div>;
})}
</div>

View file

@ -28,6 +28,7 @@ declare module '@tanstack/react-router' {
enterSound?: keyof typeof soundMap | null;
enterHaptic?: keyof typeof hapticMap | null;
goBackSound?: keyof typeof soundMap | null;
missNavSound?: boolean;
}
}

View file

@ -3,6 +3,7 @@ import { GetFocusedElement } from "./spatialNavigation";
import { useEffect, useState } from "react";
import { getLocalSetting, mobileCheck } from "./utils";
import { oneShot } from "./audio/audio";
import { Router } from "@/mainview";
let loopStarted = false;
let isTouching = false;
@ -108,7 +109,13 @@ function throttleNav (key: string, dir: string, event: Event)
const currentFocusKey = getCurrentFocusKey();
navigateByDirection(dir, { event });
if (currentFocusKey === getCurrentFocusKey())
oneShot('invalidNavigation');
{
const routes = Router.matchRoutes(Router.history.location.pathname);
if (!routes.some(r => r.staticData.missNavSound === false))
{
oneShot('invalidNavigation');
}
}
throttleMap.set(key, currentDate.getTime());
throttleAcceleration.set(key, acceleration + 1);
return true;

View file

@ -1,5 +1,5 @@
import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants";
import { RefObject, useEffect, useRef, useState } from "react";
import { DependencyList, RefObject, useEffect, useRef, useState } from "react";
import { useLocalStorage } from "usehooks-ts";
import { jobsApi } from "./clientApi";
import { JobsAPIType } from "@/bun/api/rpc";
@ -272,7 +272,8 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
onEnded?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
onCompleted?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
onError?: (error: string) => void;
}
},
deps?: DependencyList
)
{
type Response = JobResponse<JOB>;
@ -325,7 +326,7 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
sub.close();
ref.current = null;
};
}, [id, init?.query, init?.onEnded, init?.onCompleted, init?.onProgress, init?.onError]);
}, [id, init?.query, init?.onEnded, init?.onCompleted, init?.onProgress, init?.onError, ...(deps ?? [])]);
return { data, state, error, wsRef: ref };
}

View file

@ -60,4 +60,11 @@ declare interface FilterOption extends FocusParams, InteractParams
label: string;
selected: boolean;
icon?: any;
}
}
declare type EmulatorJsMessage = { type: 'restart'; } |
{ type: 'pause'; paused: boolean; } |
{ type: 'exit'; save?: File; } |
{ type: 'save', save: File, screenshot?: File, type: string; } |
{ type: 'loaded'; } |
{ type: 'requestSave'; };