feat: move to secure OS credential storage so that you never get logged out again

This commit is contained in:
Simeon Radivoev 2026-02-10 00:35:37 +02:00
parent d6e0a8350a
commit ef08fa6114
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
15 changed files with 493 additions and 276 deletions

View file

@ -1,7 +1,97 @@
import z from "zod";
import { config } from "./settings";
import Elysia from "elysia";
import Elysia, { status } from "elysia";
import keytar from '@hackolade/keytar';
import { loginApiLoginPost } from "../../clients/romm";
import { CookieJar } from 'tough-cookie';
import FileCookieStore from 'tough-cookie-file-store';
import path from 'node:path';
const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
const jar = new CookieJar(fileCookieStore);
await login();
export async function logout ()
{
if (!config.has('rommAddress'))
{
return;
}
const rommAddress = config.get('rommAddress');
if (rommAddress)
{
console.log("Logging Out of ROMM");
try
{
await loginApiLoginPost({
baseUrl: rommAddress, headers: {
'cookie': await jar.getCookieString(rommAddress)
}
});
} catch (error)
{
console.error("Failed to logout of ROMM ", error);
}
}
}
async function login ()
{
if (!config.has('rommAddress') || !config.has('rommUser'))
{
return;
}
const rommAddress = config.get('rommAddress');
const rommUser = config.get('rommUser');
if (rommAddress && rommUser)
{
console.log("Logging In to ROMM");
const password = await keytar.getPassword('romm', 'gameflow');
const loginResponse = await loginApiLoginPost({ baseUrl: rommAddress, auth: `${rommUser}:${password}` });
loginResponse.response.headers.getSetCookie().map(c => jar.setCookie(c, rommAddress));
}
}
export const romm = new Elysia({ prefix: "/romm" })
.post('/login', async ({ body: { host, username, password } }) =>
{
if (config.has('rommAddress') && config.has('rommUser'))
{
await logout();
const oldRommAddress = config.get('rommAddress');
if (oldRommAddress)
{
const cookies = await jar.getCookies(oldRommAddress);
cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key));
}
}
config.set('rommAddress', host);
config.set('rommUser', username);
await keytar.setPassword('romm', 'gameflow', password);
await login();
return status(200);
}, { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) })
.get('/login', async () =>
{
const credentials = await keytar.getPassword('romm', 'gameflow');
return { hasPassword: !!credentials };
}, { response: z.object({ hasPassword: z.boolean() }) })
.post('/logout', async () =>
{
await keytar.deletePassword('romm', 'gameflow');
await logout();
const rommAddress = config.get('rommAddress');
if (rommAddress)
{
const cookies = await jar.getCookies(rommAddress);
cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key));
}
return status(200);
})
.all("/*", async ({ request, params, set }) =>
{
if (!config.has('rommAddress') && !config.get('rommAddress'))
@ -16,18 +106,32 @@ export const romm = new Elysia({ prefix: "/romm" })
url.port = rommUrl.port;
url.protocol = rommUrl.protocol;
// Forward headers (optional: remove host if needed)
// Forward headers (optional: remove host if needed)
const headers = new Headers(request.headers);
headers.delete('host');
headers.set("accept-encoding", "identity");
headers.set('cookie', await jar.getCookieString(rommUrl.href));
const rommResponse = await fetch(url, {
let rommResponse = await fetch(url, {
method: request.method,
headers,
body: await request.arrayBuffer(),
redirect: 'manual', // avoid ROMM redirects
});
/*
if (rommResponse.status === 403 && config.has('rommUser'))
{
await login();
headers.set('cookie', await jar.getCookieString(rommUrl.href));
rommResponse = await fetch(url, {
method: request.method,
headers,
body: await request.arrayBuffer(),
redirect: 'manual', // avoid ROMM redirects
});
}*/
set.status = rommResponse.status;
rommResponse.headers.forEach((value, key) =>
{
@ -35,4 +139,4 @@ export const romm = new Elysia({ prefix: "/romm" })
});
return new Response(rommResponse.body, { status: rommResponse.status });
});
}).on('stop', logout);

View file

@ -11,10 +11,10 @@ if (!Bun.env.PUBLIC_ACCESS)
bunServer = RunBunServer();
}
function cleanup ()
async function cleanup ()
{
bunServer?.stop();
api.apiServer.stop();
await api.apiServer.stop();
process.exit(0);
}
@ -25,7 +25,7 @@ try
});
webviewWorker.addEventListener('error', console.error);
await new Promise(resolve => webviewWorker.addEventListener('close', resolve));
cleanup();
await cleanup();
}
catch (error)
{

View file

@ -1,6 +1,7 @@
import { SERVER_PORT } from "../shared/constants";
import path from 'node:path';
import { host } from "./utils";
import appInfo from '../../package.json';
export function RunBunServer ()
{
@ -12,6 +13,19 @@ export function RunBunServer ()
"/": Bun.file("./dist/index.html"),
// Serve a file by lazily loading it into memory
"/favicon.ico": Bun.file("./dist/favicon.ico"),
"/.well-known/appspecific/com.chrome.devtools.json": new Response(
JSON.stringify({
name: appInfo.name,
version: appInfo.version,
debuggable: true,
}),
{
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache",
},
}
)
},
fetch: async (req) =>
{

View file

@ -0,0 +1,49 @@
import classNames from "classnames";
import { ChangeEventHandler, FocusEventHandler, HTMLInputTypeAttribute, JSX, useRef } from "react";
import { twMerge } from "tailwind-merge";
import { useOptionContext } from "./OptionSpace";
export function OptionInput (data: {
name: string;
type: HTMLInputTypeAttribute;
className?: string;
placeholder?: string;
icon?: JSX.Element;
value?: string;
defaultValue?: string;
onBlur?: FocusEventHandler<HTMLInputElement>;
onChange?: ChangeEventHandler<HTMLInputElement>;
})
{
const inputRef = useRef<HTMLInputElement>(null);
const option = useOptionContext({
onOptionEnterPress ()
{
inputRef.current?.focus();
},
});
return (
<label className="flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent">
<span className={twMerge("text-base-content/80", classNames({
"text-primary-content": option.focused
}))}>{data.icon}</span>
<input
ref={inputRef}
id={data.name}
name={data.name}
value={data.value}
defaultValue={data.defaultValue}
type={data.type}
onFocus={() => option.focus()}
placeholder={data.placeholder}
onChange={data.onChange}
onBlur={data.onBlur}
className={twMerge(
"input grow rounded-full ring-primary-content focus:ring-3",
data.className,
)}
/>
</label>
);
}

View file

@ -0,0 +1,89 @@
import { FocusContext, FocusDetails, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { createContext, JSX, useContext, useEffect, useMemo } from "react";
import { twMerge } from "tailwind-merge";
export const OptionContext = createContext(
{} as {
focused: boolean;
focus: (focusDetails?: FocusDetails | undefined) => void;
eventTarget: EventTarget;
},
);
export function useOptionContext (params?: { onOptionEnterPress?: () => void; })
{
const context = useContext(OptionContext);
useEffect(() =>
{
if (params?.onOptionEnterPress)
{
context.eventTarget.addEventListener(
"onEnterPress",
params.onOptionEnterPress,
);
}
return () =>
{
if (params?.onOptionEnterPress)
{
context.eventTarget.removeEventListener(
"onEnterPress",
params.onOptionEnterPress,
);
}
};
}, [context.eventTarget]);
return context;
}
export function OptionSpace (data: {
id?: string;
className?: string;
focusable?: boolean;
children: JSX.Element;
label?: string | JSX.Element;
})
{
const eventTarget = useMemo(() => new EventTarget(), []);
const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({
focusKey: data.id,
focusable: data.focusable !== false,
trackChildren: true,
onEnterPress ()
{
eventTarget.dispatchEvent(new CustomEvent("onEnterPress"));
},
});
return (<FocusContext value={focusKey}>
<OptionContext value={{ focused, focus: focusSelf, eventTarget }}>
<li
ref={ref}
className={twMerge("flex sm:p-2 md:p-4 pl-8! rounded-full bg-base-content/1", classNames(
{
"text-primary-content bg-primary ": focused || hasFocusedChild,
}),
data.className,
)}
>
<div className="label flex-1 md:text-lg pr-4">
{typeof data.label === "string" ? (
<label
className={classNames({
"text-primary-content font-semibold": focused,
})}
>
{data.label}
</label>
) : (
data.label
)}
</div>
{data.children}
</li>
</OptionContext>
</FocusContext>
);
}

View file

@ -0,0 +1,38 @@
import { createFormHook, createFormHookContexts } from "@tanstack/react-form";
import { HTMLInputTypeAttribute, JSX } from "react";
import { OptionInput } from "./OptionInput";
import { OptionSpace } from "./OptionSpace";
import classNames from "classnames";
import { TriangleAlert } from "lucide-react";
// export useFieldContext for use in your custom components
export const { fieldContext, formContext, useFieldContext } =
createFormHookContexts();
export const { useAppForm: useSettingsForm, useTypedAppFormContext: useSettingsFormContext } = createFormHook({
fieldContext,
formContext,
fieldComponents: { FormOption },
formComponents: {}
});
function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; label?: string | JSX.Element; placeholder?: string; })
{
const field = useFieldContext<string>();
return <OptionSpace label={<div className="flex gap-2">
{data.label}
{field.getMeta().errors.length > 0 && <div className="badge badge-error">
{field.state.meta.errors.map(e => e.message).join(',')}
</div>}
</div>}>
<OptionInput
icon={data.icon}
name={field.name}
value={field.state.value}
type={data.type}
onChange={e => field.handleChange(e.target.value)}
placeholder={data.placeholder}
className={classNames({ "ring-4 ring-accent": field.getMeta().isDirty })}
/>
</OptionSpace>;;
}

View file

@ -12,7 +12,7 @@ function RootComponent ()
return (
<div className="w-screen h-screen overflow-hidden">
<Outlet />
{import.meta.env.DEV && false &&
{import.meta.env.DEV &&
<>
<TanStackRouterDevtools position="top-left" />
<ReactQueryDevtools buttonPosition="top-right" />

View file

@ -39,7 +39,7 @@ import { SaveSource } from "../scripts/spatialNavigation";
import LoadingCardList from "../components/LoadingCardList";
import { AutoFocus } from "../components/AutoFocus";
import SaveScroll from "../components/SaveScroll";
import { ErrorBoundary } from "react-error-boundary";
import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";
import { twMerge } from "tailwind-merge";
import Shortcuts from "../components/Shortcuts";
@ -157,6 +157,15 @@ function CollectionList (data: { id: string, setBackground: (url: string) => voi
);
}
function HomeListError (data: { focused: boolean; })
{
const error = useErrorBoundary();
return <div className="flex justify-center items-center h-(--game-card-height)"><div role="alert" className={twMerge("alert alert-error", classNames({ "alert-outline": !data.focused }))}>
<OctagonAlert />
<span>{(error.error as any).detail}</span>
</div></div>;
}
function HomeList (data: {
selectedFilter: keyof typeof filters;
})
@ -176,14 +185,9 @@ function HomeList (data: {
return (
<FocusContext value={focusKey}>
<div ref={ref} className="flex overflow-x-scroll no-scrollbar pb-3 mb-1">
<div ref={ref} className="flex overflow-x-scroll no-scrollbar pb-3 mb-1 justify-center-safe">
<div className="flex px-16">
<ErrorBoundary fallback={
<div role="alert" className="alert alert-error alert-outline">
<OctagonAlert />
<span>Error! Task failed successfully.</span>
</div>
}>
<ErrorBoundary fallback={<HomeListError focused={focused} />}>
<Suspense key={data.selectedFilter} fallback={<LoadingCardList placeholderCount={8} />}>
{lists[data.selectedFilter]}
<SaveScroll id={`card-list-${data.selectedFilter}`} ref={ref} />

View file

@ -1,171 +1,39 @@
import
{
FocusContext,
FocusDetails,
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import { QueriesResults, useIsMutating, useMutation, useQuery, UseQueryResult } from "@tanstack/react-query";
import { createFileRoute, useSearch } from "@tanstack/react-router";
import { useIsMutating, useMutation, useQuery, UseQueryResult } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import classNames from "classnames";
import { DoorOpen, Key, Link, Lock, User } from "lucide-react";
import { Cross, Delete, Key, Link, Lock, Save, Trash, User, X } from "lucide-react";
import
{
ChangeEventHandler,
createContext,
FocusEventHandler,
HTMLInputTypeAttribute,
JSX,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { client } from "../..";
import { SettingsType } from "../../../shared/constants";
import { RPC_URL, SettingsType } from "../../../shared/constants";
import
{
getCurrentUserApiUsersMeGetOptions,
loginApiLoginPostMutation,
logoutApiLogoutPostMutation,
statsApiStatsGetOptions,
} from "../../../clients/romm/@tanstack/react-query.gen";
import { useToasters } from "../../contexts/ToasterContext";
import { UserSchema } from "../../../clients/romm";
import toast from "react-hot-toast";
import z from "zod";
import { OptionSpace } from "../../components/options/OptionSpace";
import { OptionInput } from "../../components/options/OptionInput";
import { useSettingsForm, useSettingsFormContext } from "../../components/options/SettingsAppForm";
import { twMerge } from "tailwind-merge";
export const Route = createFileRoute("/settings/accounts")({
component: RouteComponent,
});
const OptionContext = createContext(
{} as {
focused: boolean;
focus: (focusDetails?: FocusDetails | undefined) => void;
eventTarget: EventTarget;
},
);
function useOptionContext (params?: { onOptionEnterPress?: () => void; })
{
const context = useContext(OptionContext);
useEffect(() =>
{
if (params?.onOptionEnterPress)
{
context.eventTarget.addEventListener(
"onEnterPress",
params.onOptionEnterPress,
);
}
return () =>
{
if (params?.onOptionEnterPress)
{
context.eventTarget.removeEventListener(
"onEnterPress",
params.onOptionEnterPress,
);
}
};
}, [context.eventTarget]);
return context;
}
function OptionSpace (data: {
id?: string;
className?: string;
focusable?: boolean;
children: JSX.Element;
label?: string | JSX.Element;
})
{
const eventTarget = useMemo(() => new EventTarget(), []);
const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({
focusKey: data.id,
focusable: data.focusable !== false,
trackChildren: true,
onEnterPress ()
{
eventTarget.dispatchEvent(new CustomEvent("onEnterPress"));
},
});
return (<FocusContext value={focusKey}>
<OptionContext value={{ focused, focus: focusSelf, eventTarget }}>
<li
ref={ref}
className={twMerge("flex sm:p-2 md:p-4 pl-8! rounded-full bg-base-content/1", classNames(
{
"text-primary-content bg-primary ": focused || hasFocusedChild,
}),
data.className,
)}
>
{typeof data.label === "string" ? (
<label
className={classNames("label flex-1 md:text-lg pr-4", {
"text-primary-content font-semibold": focused,
})}
>
{data.label}
</label>
) : (
data.label
)}
{data.children}
</li>
</OptionContext>
</FocusContext>
);
}
function OptionInput (data: {
name: string;
type: HTMLInputTypeAttribute;
className?: string;
placeholder?: string;
icon?: JSX.Element;
value?: string;
onBlur?: FocusEventHandler<HTMLInputElement>;
onChange?: ChangeEventHandler<HTMLInputElement>;
})
{
const inputRef = useRef<HTMLInputElement>(null);
const option = useOptionContext({
onOptionEnterPress ()
{
inputRef.current?.focus();
},
});
return (
<label className="flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent">
<span className={twMerge("text-base-content/80", classNames({
"text-primary-content": option.focused
}))}>{data.icon}</span>
<input
ref={inputRef}
id={data.name}
name={data.name}
value={data.value}
type={data.type}
onFocus={() => option.focus()}
placeholder={data.placeholder}
onChange={data.onChange}
onBlur={data.onBlur}
className={classNames(
"input grow rounded-full ring-primary-content focus:ring-3",
data.className,
)}
/>
</label>
);
}
type KeysWithValueAssignableTo<T, Value> = {
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
}[keyof T];
@ -185,7 +53,7 @@ function Option (data: {
queryKey: ["setting", data.id],
queryFn: async () =>
{
const value = (await client.api.settings({ id: data.id! }).get()).data?.value;
const value = await client.api.settings({ id: data.id! }).get().then(d => d.data?.value);
if (!dirty)
{
setLocalValue(String(value));
@ -227,72 +95,109 @@ function Option (data: {
);
}
function Button (data: { children?: any, disabled?: boolean, type: "reset" | "button" | "submit" | undefined; } & InteractParams & FocusParams)
function Button (data: { children?: any, className?: string, disabled?: boolean, type: "reset" | "button" | "submit" | undefined; } & InteractParams & FocusParams)
{
const { ref, focused } = useFocusable({
focusKey: data.type,
onEnterPress: data.onAction,
onFocus: data.onFocus
onFocus: data.onFocus,
focusable: !data.disabled
});
return <button
ref={ref}
onClick={data.onAction}
disabled={data.disabled}
className={classNames("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg", {
className={twMerge("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg", classNames({
"btn-accent": focused
})}
}, data.className))}
type={data.type}
>
{data.children}
</button>;
}
function LoginControls (data: { user: UseQueryResult<UserSchema | null, Error>; })
function LoginControls (data: { hasPassword: boolean; })
{
const user = useQuery({
...getCurrentUserApiUsersMeGetOptions(),
queryKey: ['romm', 'auth', "login"],
refetchOnWindowFocus: false,
retry: 0
});
const context = useSettingsFormContext({});
context.state.canSubmit;
const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0;
const logoutMutation = useMutation({
mutationKey: ["romm", "auth", "logout"], mutationFn: () => window.cookieStore.delete({ name: "romm_session" }),
mutationKey: ["romm", "auth", "logout"], mutationFn: () => client.api.romm.logout.post(),
onSuccess: async (d, v, r, c) =>
{
c.client.invalidateQueries({ queryKey: ["romm", "auth"] });
}
});
return <div className="flex gap-2 items-center">
{data.user.isError && <div className="badge badge-error gap-2 tooltip" data-tip={(data.user.error as any)?.detail ?? ''}>
{user.isError && <div className="badge badge-error gap-2 tooltip" data-tip={(user.error as any)?.detail ?? ''}>
<Lock className="size-4" /></div>}
{data.user.isSuccess && <div className="badge badge-success badge-lg rounded-full gap-2">Logged In As: <b>{data.user.data?.username}</b></div>}
<Button disabled={isMutatingRomm} type="submit" >
<Lock /> Login
{user.isSuccess && <>
<div className="badge badge-success badge-lg rounded-full gap-2"> Logged In As: <img className="size-6 rounded-full" src={`${RPC_URL(__HOST__)}/api/romm/assets/romm/assets/${user.data?.avatar_path}`} /><b>{user.data?.username}</b></div>
</>}
<Button disabled={!context.state.canSubmit || !context.state.isDirty} type="submit" onAction={() => context.handleSubmit()} >
<Save /> Save
</Button>
<Button onAction={() =>
{
toast("Logout", { id: 'romm-logout-noti' });
logoutMutation.mutate();
}} disabled={isMutatingRomm} type="button" >
<DoorOpen /> Logout
{data.hasPassword &&
<Button onAction={() =>
{
toast("Logout", { id: 'romm-logout-noti' });
logoutMutation.mutate();
}} disabled={isMutatingRomm} type="button" >
<Trash /> Forget
</Button>
}
<Button disabled={context.state.isDefaultValue} type="reset" onAction={() => context.reset()}>
<X /> Cancel
</Button>
</div>;
}
const dataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() });
function RouteComponent ()
{
const { focus } = Route.useSearch();
const { ref, focusKey, focusSelf } = useFocusable({
preferredChildFocusKey: focus
});
const { data: hasPassword } = useQuery({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => client.api.romm.login.get().then(d => d.data?.hasPassword as boolean) });
const { data: hostname } = useQuery({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => client.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) });
const { data: username } = useQuery({ queryKey: ['romm', 'auth', 'username'], queryFn: () => client.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) });
const loginForm = useSettingsForm({
defaultValues: {
hostname: hostname ?? '',
username: username ?? '',
password: ''
},
onSubmit: async ({ value }) =>
{
await toast.promise(loginMutation.mutateAsync(value), {
loading: "Logging In",
success: "Logged In",
error: e => e?.detail ?? "Error Logging In",
});
loginForm.reset();
},
validators: {
onChange: dataSchema
}
});
const rommOnline = useQuery({
...statsApiStatsGetOptions(),
refetchInterval: 30000,
retry: false,
});
const user = useQuery({
...getCurrentUserApiUsersMeGetOptions(),
queryKey: ['romm', 'auth', "login"],
refetchOnWindowFocus: false,
retry: 0
});
useEffect(() =>
{
if (focus)
@ -303,7 +208,10 @@ function RouteComponent ()
const loginMutation = useMutation({
mutationKey: ["romm", "login"],
...loginApiLoginPostMutation(),
mutationFn: (data: z.infer<typeof dataSchema>) =>
{
return client.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname });
},
onSuccess: (d, v, r, c) =>
{
c.client.invalidateQueries({ queryKey: ['romm', 'auth'] });
@ -331,54 +239,40 @@ function RouteComponent ()
<h3>Romm</h3>
</div>
</div>
<Option
id="rommAddress"
type="text"
icon={
<div className="indicator">
<span
className={classNames("indicator-item status", indicator)}
></span>
<Link />
</div>
}
label="Romm Address"
/>
<form
className="flex flex-col gap-2"
onSubmit={(e) =>
{
e.preventDefault();
const data = new FormData(e.currentTarget);
toast.promise(loginMutation.mutateAsync({
auth: `${data.get("username")}:${data.get("password")}`,
}), {
loading: "Logging In",
success: "Logged In",
error: e => e?.detail ?? "Error Logging In",
});
}}
>
<OptionSpace label="User">
<OptionInput
icon={<User />}
name="username"
type="text"
placeholder="Username"
/>
</OptionSpace>
<OptionSpace label="Password">
<OptionInput
icon={<Key />}
name="password"
type="password"
placeholder="Password"
/>
</OptionSpace>
<OptionSpace className="justify-end">
<LoginControls user={user} />
</OptionSpace>
</form>
<loginForm.AppForm>
<form
className="flex flex-col gap-2"
onSubmit={(e) =>
{
e.preventDefault();
e.stopPropagation();
loginForm.handleSubmit();
}}
onReset={e =>
{
e.preventDefault();
e.stopPropagation();
loginForm.reset();
}}
>
<loginForm.AppField name="hostname" children={(field) =>
<field.FormOption label="Romm Address" icon={<div className="indicator">
<span
className={classNames("indicator-item status", indicator)}
></span>
<Link />
</div>
} type='url' />} />
<loginForm.AppField name="username" children={(field) =>
<field.FormOption label={"Romm Username"} icon={<User />} type="text" />} />
<loginForm.AppField name="password" children={(field) =>
<field.FormOption label={"Romm Password"} icon={<Key />} type="password" placeholder={hasPassword ? '*****' : "Password"} />} />
<loginForm.Subscribe children={(form) =>
<OptionSpace className="justify-end">
<LoginControls hasPassword={hasPassword === true} />
</OptionSpace>} />
</form>
</loginForm.AppForm>
</ul>
</FocusContext.Provider>
);

View file

@ -20,6 +20,7 @@ export type GameMeta = z.infer<typeof GameMetaSchema>;
export const SettingsSchema = z.object({
rommAddress: z.url().optional(),
rommUser: z.string().default('admin').optional(),
disableBlur: z.boolean().default(false),
windowSize: z.object({ width: z.number(), height: z.number() }).default({ width: 1280, height: 800 }),
windowPosition: z.object({ x: z.number(), y: z.number() }).optional(),