feat: Added QR login

fix: Fixed webview for windows builds
This commit is contained in:
Simeon Radivoev 2026-03-03 15:51:47 +02:00
parent 01b91aa48c
commit 4739b89933
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
26 changed files with 545 additions and 83 deletions

View file

@ -20,6 +20,7 @@ import EventEmitter from "node:events";
import { ErrorLike } from "bun";
import { appPath, getErrorMessage } from "../utils";
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
import { ensureDir } from "fs-extra";
export const config = new Conf<SettingsType>({
projectName: projectPackage.name,
@ -48,11 +49,9 @@ console.log("App Directory is ", process.env.APPDIR);
const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
export const jar = new CookieJar(fileCookieStore);
await fs.mkdir(config.get('downloadPath'), { recursive: true });
let sqlite: Database;
export let db: DrizzleSqliteDODatabase<typeof schema>;
await reloadDatabase();
migrate(db!, { migrationsFolder: appPath("./drizzle") });
const emulatorsSqlite = new Database(appPath(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`), { readonly: true });
export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
export const taskQueue = new TaskQueue();
@ -73,7 +72,7 @@ events.addListener('activegameexit', ({ error }) =>
events.emit('notification', { message: getErrorMessage(error), type: 'error' });
}
});
console.log("Logging In to Romm");
config.onDidChange('downloadPath', () => reloadDatabase());
export async function cleanup ()
{
@ -85,8 +84,10 @@ export async function cleanup ()
export async function reloadDatabase ()
{
await ensureDir(config.get('downloadPath'));
sqlite = new Database(path.join(config.get('downloadPath'), 'db.sqlite'), { create: true, readwrite: true });
db = drizzle(sqlite, { schema });
migrate(db!, { migrationsFolder: appPath("./drizzle") });
}
interface AppEventMap

View file

@ -1,32 +1,47 @@
import Elysia, { status } from "elysia";
import { config, db, jar } from "./app";
import Elysia, { sse, status } from "elysia";
import { config, jar, taskQueue } from "./app";
import z from "zod";
import { client } from "@clients/romm/client.gen";
import { loginApiLoginPost } from "@clients/romm";
import { loginApiLoginPost, logoutApiLogoutPost } from "@clients/romm";
import secrets from '../api/secrets';
import { LoginJob } from "./jobs/login-job";
export default new Elysia()
.post('/login', async ({ body: { host, username, password } }) =>
.post('/login/remote/start', async () =>
{
if (config.has('rommAddress') && config.has('rommUser'))
if (taskQueue.hasActiveOfType(LoginJob))
{
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));
}
return status("Conflict", "Login Already Active");
}
config.set('rommAddress', host);
config.set('rommUser', username);
const job = new LoginJob();
taskQueue.enqueue("login", job);
return status("OK");
})
.get('/login/remote/status', async function* ()
{
const job = taskQueue.findJob("login");
if (job)
{
const loginJob = job.job as LoginJob;
yield sse({ data: { endsAt: loginJob.endsAt, url: loginJob.url } });
await taskQueue.waitForJob('login');
yield sse({ data: {} });
}
await secrets.set({ service: 'gameflow', name: 'romm', value: password });
await login();
return status(200);
}, { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) })
yield sse({ data: {} });
})
.post('/login/remote/cancel', async () =>
{
const job = taskQueue.findJob("login");
if (job)
{
job.abort("cancel");
await taskQueue.waitForJob('login');
}
return {};
})
.post('/login', async ({ body }) => tryLoginAndSave(body), { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) })
.get('/login', async () =>
{
const credentials = await secrets.get({ service: 'gameflow', name: 'romm' });
@ -54,6 +69,31 @@ async function updateClient ()
});
}
export async function tryLoginAndSave ({ host, username, password }: { host: string, username: string, password: string; })
{
if (config.has('rommAddress') && config.has('rommUser'))
{
await logout();
const oldRommAddress = config.get('rommAddress');
if (oldRommAddress)
{
const cookies = await jar.getCookies(oldRommAddress);
await Promise.all(cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key)));
}
}
const response = await login({ rommAddress: host, rommUser: username, rommPassword: password });
if (response?.code === 200)
{
config.set('rommAddress', host);
config.set('rommUser', username);
await secrets.set({ service: 'gameflow', name: 'romm', value: password });
}
return response;
}
export async function logout ()
{
if (!config.has('rommAddress'))
@ -66,11 +106,12 @@ export async function logout ()
console.log("Logging Out of ROMM");
try
{
await loginApiLoginPost({
await logoutApiLogoutPost({
baseUrl: rommAddress, headers: {
'cookie': await jar.getCookieString(rommAddress)
}
});
await jar.store.removeCookie(new URL(rommAddress).host, null, "romm_session");
} catch (error)
{
console.error("Failed to logout of ROMM ", error);
@ -78,20 +119,39 @@ export async function logout ()
}
}
export async function login ()
export async function login (data?: { rommAddress?: string, rommUser?: string, rommPassword?: string; })
{
if (!config.has('rommAddress') || !config.has('rommUser'))
const address = data?.rommAddress ?? config.get('rommAddress');
const user = data?.rommUser ?? config.get('rommUser');
const password = data?.rommPassword ?? await secrets.get({ service: 'gameflow', name: "romm" });
if (!address || !user)
{
return;
console.warn("Romm not setup");
return status(404);
}
const rommAddress = config.get('rommAddress');
const rommUser = config.get('rommUser');
if (rommAddress && rommUser)
{
console.log("Logging In to ROMM");
const password = await secrets.get({ service: 'gameflow', name: "romm" });
if (password === null)
{
return status(404, "No Found Password");
}
const loginResponse = await loginApiLoginPost({ baseUrl: rommAddress, auth: `${rommUser}:${password}` });
loginResponse.response.headers.getSetCookie().map(c => jar.setCookie(c, rommAddress));
await updateClient();
if (loginResponse.response.status === 200)
{
loginResponse.response.headers.getSetCookie().map(c => jar.setCookie(c, rommAddress));
await updateClient();
return status(200, loginResponse.response.statusText);
} else
{
console.error("Could not Login to Romm: ", loginResponse.response.statusText);
return status(loginResponse.response.status, loginResponse.response.statusText);
}
}
}
}

View file

@ -59,10 +59,6 @@ export default new Elysia()
}
return processImage(coverBlob.cover, query);
/*return sharp(coverBlob.cover)
.resize({ width, height, withoutEnlargement: true })
.blur(blur)
.toBuffer();*/
}, {
params: z.object({ id: z.coerce.number() }),
query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() })
@ -73,13 +69,6 @@ export default new Elysia()
{
const rommAdress = config.get('rommAddress');
return processImage(`${rommAdress}/${path}`, query);
/*
const rommFetch = await fetch(`${rommAdress}/${path}`);
return sharp(await rommFetch.arrayBuffer())
.resize({ width, height, withoutEnlargement: true })
.blur(blur)
.toBuffer();*/
}
return status('Not Found');
}, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) })
@ -94,8 +83,6 @@ export default new Elysia()
}
return processImage(screenshot.content, query);
//return sharp(screenshot.content).resize({ width, height, withoutEnlargement: true }).blur(blur).toBuffer();
//return screenshot.content;
}
return status(404);

View file

@ -0,0 +1,61 @@
import Elysia, { status } from "elysia";
import { IJob, JobContext } from "../task-queue";
import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
import { host, localIp } from "@/bun/utils/host";
import cors from "@elysiajs/cors";
import { tryLoginAndSave } from "../auth";
import z from "zod";
import { config } from "../app";
export class LoginJob implements IJob
{
endsAt: Date;
url: string;
constructor()
{
this.endsAt = new Date();
this.url = `http://${localIp}:${LOGIN_PORT}/`;
}
async start (context: JobContext): Promise<any>
{
const loginServer = new Elysia({ serve: { hostname: localIp, port: LOGIN_PORT } })
.use(cors())
.get(`/`, ({ headers }) => process.env.PUBLIC_ACCESS ? fetch(`${SERVER_URL(host)}/auth/qr/`, { headers: headers as any }) : Bun.file(`./dist/auth/qr/index.html`))
.get(`/*`, ({ path, headers }) => process.env.PUBLIC_ACCESS ? fetch(`${SERVER_URL(host)}/auth/qr/${path}`, { headers: headers as any }) : Bun.file(`./dist/${path}`))
.get('/status', () => ({ expires_at: this.endsAt, max_time: 300000 }))
.post('/cancel', () => context.abort("cancel"))
.get('/defaults', () => ({ host: config.get('rommAddress'), username: config.get('rommUser') ?? '' }))
.post(`/login`, async ({ body }) =>
{
const response = await tryLoginAndSave(body as any);
if (response?.code === 200)
{
context.abort("success");
return status("Accepted");
} else
{
return response;
}
});
try
{
loginServer.listen({});
await new Promise((resolve, reject) =>
{
this.endsAt = new Date(new Date().getTime() + 300000);
context.abortSignal.addEventListener('abort', () => reject());
setTimeout(() => { reject('timeout'); }, 300000); // auto close after 5 minutes
});
} catch
{
} finally
{
await loginServer.stop();
}
}
}

View file

@ -1,5 +1,5 @@
import z from "zod";
import { SettingsSchema } from "@shared/constants";
import { LOGIN_PORT, SettingsSchema } from "@shared/constants";
import Elysia, { status } from "elysia";
import { config, customEmulators, db, emulatorsDb, taskQueue } from "./app";
import * as appSchema from './schema/app';
@ -103,7 +103,7 @@ export const settings = new Elysia({ prefix: '/api/settings' })
const oldDownloadPath = config.get('downloadPath');
if (!existsSync(oldDownloadPath))
{
return status("Not Found", "Old downlod path doesn't exist");
return status("Not Found", "Old download path doesn't exist");
}
async function isDirEmpty (dirname: string)
@ -121,7 +121,7 @@ export const settings = new Elysia({ prefix: '/api/settings' })
if (existsSync(path) && !isDirEmpty(path))
{
return status("Conflict", "New location alaready exists and is not empty");
return status("Conflict", "New location already exists and is not empty");
}
await move(oldDownloadPath, path);

View file

@ -138,6 +138,7 @@ export interface IPublicJob
state?: string;
status: JobStatus;
job: any;
abort: (reason?: any) => void;
}
export class JobContext implements IPublicJob
@ -177,7 +178,7 @@ export class JobContext implements IPublicJob
} catch (error)
{
console.error(error);
this.events.emit('error', { id: this.m_id, error });
this.events.emit('error', { id: this.m_id, job: this.m_job, error });
this.error = error;
} finally
{