From 4739b89933f9dd6082d40f84f8fedd19a013ee98 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Tue, 3 Mar 2026 15:51:47 +0200 Subject: [PATCH] feat: Added QR login fix: Fixed webview for windows builds --- bun.lock | 27 +- package.json | 3 +- scripts/build-appimage.ts | 1 + scripts/package-bun.ts | 15 +- src/bun/api/app.ts | 7 +- src/bun/api/auth.ts | 114 ++++++-- src/bun/api/games/games.ts | 13 - src/bun/api/jobs/login-job.ts | 61 +++++ src/bun/api/settings.ts | 6 +- src/bun/api/task-queue.ts | 3 +- src/bun/browser.ts | 4 +- src/bun/utils/host.ts | 2 +- src/bun/webview/base.ts | 5 +- src/bun/webview/linux.ts | 5 +- src/bun/webview/win32.ts | 7 +- src/mainview/auth/qr/index.css | 2 + src/mainview/auth/qr/index.html | 249 ++++++++++++++++++ .../components/AnimatedBackground.tsx | 2 +- src/mainview/components/ContextDialog.tsx | 2 +- src/mainview/components/options/Button.tsx | 2 +- .../components/options/OptionSpace.tsx | 2 +- .../components/options/SettingsAppForm.tsx | 4 +- src/mainview/routes/settings/accounts.tsx | 46 +++- src/mainview/scripts/utils.ts | 41 ++- src/shared/constants.ts | 1 + vite.config.ts | 4 + 26 files changed, 545 insertions(+), 83 deletions(-) create mode 100644 src/bun/api/jobs/login-job.ts create mode 100644 src/mainview/auth/qr/index.css create mode 100644 src/mainview/auth/qr/index.html diff --git a/bun.lock b/bun.lock index 19b6d56..d382346 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,6 @@ "@elysiajs/cors": "^1.4.1", "@elysiajs/eden": "^1.4.6", "@elysiajs/static": "^1.4.7", - "@rcompat/webview": "^0.18.0", "cheerio": "^1.2.0", "conf": "^15.0.2", "drizzle-orm": "^0.45.1", @@ -26,6 +25,7 @@ "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", "unzip-stream": "^0.3.4", + "webview-bun": "^2.4.0", "zod": "^4.3.6", }, "devDependencies": { @@ -64,6 +64,7 @@ "react-dom": "^19.2.4", "react-error-boundary": "^6.1.0", "react-hot-toast": "^2.6.0", + "react-qr-code": "^2.0.18", "sass-embedded": "^1.97.3", "standard-version": "^9.5.0", "tailwind-merge": "^3.4.0", @@ -321,16 +322,6 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], - "@rcompat/assert": ["@rcompat/assert@0.6.0", "", { "dependencies": { "@rcompat/is": "^0.4.0", "@rcompat/type": "^0.9.0" } }, "sha512-V8YrttJqBNsLo9DQkXGpPt09LqfUGvwA30q8Tf+uukEhE6Nraw4jM4Nq0c5yKQF0IWv1eSoPUJyCO4W4neS5IA=="], - - "@rcompat/dict": ["@rcompat/dict@0.3.1", "", { "dependencies": { "@rcompat/assert": "^0.6.0", "@rcompat/is": "^0.4.2" } }, "sha512-eWZ4ACk0DpT8PS+umVlp/TmFfWAD0yqkGxfvvtfL/9fqPEh1bcCFtGMySCwmTGx/FU8sPnxwnSiZGZmN36gTBQ=="], - - "@rcompat/is": ["@rcompat/is@0.4.3", "", {}, "sha512-IRTVOUhgmRjnlEyZ76wmxPNS46TnUTp7m54mbNgMFDdTTNpjnmpuWw0DcBSUuh4p66fqP/7t9M5tmMUiYAFVzQ=="], - - "@rcompat/type": ["@rcompat/type@0.9.0", "", {}, "sha512-oMGchVrm9K98rXigdfHY98P223iHKfmCxH2GfD+vcwHdWqC3YyuJxlfhd6AeLFNkuBz+0G27GD5qm9g03IPIrA=="], - - "@rcompat/webview": ["@rcompat/webview@0.18.0", "", { "dependencies": { "@rcompat/dict": "^0.3.0" } }, "sha512-bJaDPFPSgXg4dhKVbohDgUZZcP+wsO49RRDaWj01L4klxyiMa6EUCv7RIoOgYnwNt+WRDZ0dJbH8AuSmhHdcPA=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw=="], @@ -955,6 +946,8 @@ "lodash.ismatch": ["lodash.ismatch@4.4.0", "", {}, "sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="], @@ -1019,6 +1012,8 @@ "oauth4webapi": ["oauth4webapi@2.17.0", "", {}, "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], @@ -1093,8 +1088,12 @@ "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "q": ["q@1.5.1", "", {}, "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw=="], + "qr.js": ["qr.js@0.0.0", "", {}, "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "quick-lru": ["quick-lru@4.0.1", "", {}, "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g=="], @@ -1109,6 +1108,10 @@ "react-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "react-qr-code": ["react-qr-code@2.0.18", "", { "dependencies": { "prop-types": "^15.8.1", "qr.js": "0.0.0" }, "peerDependencies": { "react": "*" } }, "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "read-pkg": ["read-pkg@3.0.0", "", { "dependencies": { "load-json-file": "^4.0.0", "normalize-package-data": "^2.3.2", "path-type": "^3.0.0" } }, "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA=="], @@ -1351,6 +1354,8 @@ "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "webview-bun": ["webview-bun@2.4.0", "", {}, "sha512-0+ugnQlcUHmuW+iLeb+Lzb8rGUJh7WEdXvNsuvaVEXT3EagK380XdD7heVJu0Ek/mNxMY3G2JM142YRQ1hDUGQ=="], + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], diff --git a/package.json b/package.json index 0179b2a..ed7c71d 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "@elysiajs/cors": "^1.4.1", "@elysiajs/eden": "^1.4.6", "@elysiajs/static": "^1.4.7", - "@rcompat/webview": "^0.18.0", "cheerio": "^1.2.0", "conf": "^15.0.2", "drizzle-orm": "^0.45.1", @@ -61,6 +60,7 @@ "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", "unzip-stream": "^0.3.4", + "webview-bun": "^2.4.0", "zod": "^4.3.6" }, "devDependencies": { @@ -99,6 +99,7 @@ "react-dom": "^19.2.4", "react-error-boundary": "^6.1.0", "react-hot-toast": "^2.6.0", + "react-qr-code": "^2.0.18", "sass-embedded": "^1.97.3", "standard-version": "^9.5.0", "tailwind-merge": "^3.4.0", diff --git a/scripts/build-appimage.ts b/scripts/build-appimage.ts index de73f2e..447c32d 100644 --- a/scripts/build-appimage.ts +++ b/scripts/build-appimage.ts @@ -28,6 +28,7 @@ await ensureDir("build"); // Copy app dir await fs.cp(`${APP_DIR}/.`, path.join(APPDIR, `usr`, 'share'), { recursive: true }); await fs.rename(path.join(APPDIR, `usr`, 'share', BINARY_NAME), path.join(APPDIR, `usr`, 'bin', BINARY_NAME)); +await fs.rename(path.join(APPDIR, `usr`, 'share', `libwebview-${process.arch}.so`), path.join(APPDIR, `usr`, 'lib', `libwebview-${process.arch}.so`)); await fs.writeFile(path.join(APPDIR, `${APP_ID}.desktop`), `[Desktop Entry] Version=${pkg.version} diff --git a/scripts/package-bun.ts b/scripts/package-bun.ts index 47be731..917c0c5 100644 --- a/scripts/package-bun.ts +++ b/scripts/package-bun.ts @@ -19,6 +19,17 @@ if (process.env.TARGET) compileOption.target = process.env.TARGET as any; } +let webviewLib = "libwebview.dll"; +if (process.platform === 'linux' && system.arch === 'x64') + webviewLib = "libwebview-x64.so"; +if (process.platform === 'linux' && system.arch === 'arm64') + webviewLib = "libwebview-arm64.so"; +if (process.platform === 'darwin') + webviewLib = "libwebview-arm64.dylib"; + +if (process.env.APPIMAGE === "true") + webviewLib = `./usr/lib/${webviewLib}`; + await Bun.build({ entrypoints: ["./src/bun/index.ts", `./src/bun/webview/${system.platform}.ts`], metafile: true, @@ -26,7 +37,8 @@ await Bun.build({ outdir: buildSubDir, root: './src/bun', define: { - "process.env.IS_BINARY": "true" + "process.env.IS_BINARY": "true", + "process.env.WEBVIEW_PATH": `./${webviewLib}`, }, minify: process.env.NODE_ENV !== 'development', sourcemap: process.env.NODE_ENV === 'development' ? 'inline' : "linked", @@ -55,6 +67,7 @@ await Bun.build({ await fs.cp('./dist', `${buildSubDir}/dist`, { recursive: true }); await fs.cp('./drizzle', `${buildSubDir}/drizzle`, { recursive: true }); await fs.cp(`./vendors/es-de/emulators.${system.platform}.${system.arch}.sqlite`, `${buildSubDir}/vendors/es-de/emulators.${system.platform}.${system.arch}.sqlite`, { recursive: true }); + await fs.cp(path.join(`node_modules/webview-bun/build/`, webviewLib), path.join(buildSubDir, webviewLib)); }); }, }] diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index 413a72c..ef1d20a 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -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({ 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; 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 diff --git a/src/bun/api/auth.ts b/src/bun/api/auth.ts index 03001dd..494b67c 100644 --- a/src/bun/api/auth.ts +++ b/src/bun/api/auth.ts @@ -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); + } + } -} \ No newline at end of file +} + diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 357bc52..2e8f957 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -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); diff --git a/src/bun/api/jobs/login-job.ts b/src/bun/api/jobs/login-job.ts new file mode 100644 index 0000000..c0f4849 --- /dev/null +++ b/src/bun/api/jobs/login-job.ts @@ -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 + { + 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(); + } + } + +} \ No newline at end of file diff --git a/src/bun/api/settings.ts b/src/bun/api/settings.ts index 2273385..9d67421 100644 --- a/src/bun/api/settings.ts +++ b/src/bun/api/settings.ts @@ -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); diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index e00c039..88002df 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -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 { diff --git a/src/bun/browser.ts b/src/bun/browser.ts index 86aa036..4bc6833 100644 --- a/src/bun/browser.ts +++ b/src/bun/browser.ts @@ -24,8 +24,9 @@ export default async function init (events: EventEmitter, forceBrowser: boolean) async function runWebview (events: EventEmitter) { - const webviewWorker = new Worker(Bun.env.IS_BINARY ? `./webview/${os.platform()}.ts` : new URL(`./webview/${os.platform()}`, import.meta.url).href, { + const webviewWorker = new Worker(Bun.env.IS_BINARY ? new URL(`./webview/${os.platform()}`, import.meta.url).href : `./webview/${os.platform()}.ts`, { smol: true, + ref: false }); return new Promise((resolve, reject) => @@ -47,6 +48,7 @@ async function runWebview (events: EventEmitter) events.on('exitapp', () => { resolve(true); + webviewWorker.terminate(); }); }); } diff --git a/src/bun/utils/host.ts b/src/bun/utils/host.ts index 3ed7fb6..cf1eff1 100644 --- a/src/bun/utils/host.ts +++ b/src/bun/utils/host.ts @@ -1,6 +1,6 @@ import { networkInterfaces } from "node:os"; -const localIp = Object.values(networkInterfaces()) +export const localIp = Object.values(networkInterfaces()) .flat() .find((iface) => iface?.family === 'IPv4' && !iface.internal)?.address || 'localhost'; diff --git a/src/bun/webview/base.ts b/src/bun/webview/base.ts index 26c9876..91bfcb2 100644 --- a/src/bun/webview/base.ts +++ b/src/bun/webview/base.ts @@ -3,14 +3,15 @@ import { host } from "../utils/host"; export default function (webview: { navigate: (url: string) => void; run: () => void; destroy: () => void; }) { - self.addEventListener('message', (e) => + self.onmessage = (e) => { console.log("Terminate"); if (e.data === 'exit') { webview.destroy(); + process.exit(); } - }); + }; webview.navigate(SERVER_URL(host)); webview.run(); } \ No newline at end of file diff --git a/src/bun/webview/linux.ts b/src/bun/webview/linux.ts index d1845ff..d0a91af 100644 --- a/src/bun/webview/linux.ts +++ b/src/bun/webview/linux.ts @@ -1,5 +1,4 @@ -import Webview from "@rcompat/webview"; -import platform from "@rcompat/webview/linux-x64"; +import { Webview } from 'webview-bun'; import webviewWorkerBase from "./base"; if (process.env.FLATPAK_BUILD === "true") @@ -29,6 +28,6 @@ if (process.env.FLATPAK_BUILD === "true") } else { console.log("Launching Webview"); - const webview = new Webview({ debug: import.meta.env.NODE_ENV === 'development', platform }); + const webview = new Webview(import.meta.env.NODE_ENV === 'development'); webviewWorkerBase(webview); } \ No newline at end of file diff --git a/src/bun/webview/win32.ts b/src/bun/webview/win32.ts index a60197e..bfb6404 100644 --- a/src/bun/webview/win32.ts +++ b/src/bun/webview/win32.ts @@ -1,7 +1,6 @@ -import Webview from "@rcompat/webview"; -import platform from "@rcompat/webview/windows-x64"; + +import { Webview } from 'webview-bun'; import webviewWorkerBase from "./base"; -console.log("Launching Webview"); -const webview = new Webview({ debug: import.meta.env.NODE_ENV === 'development', platform }); +const webview = new Webview(import.meta.env.NODE_ENV === 'development'); webviewWorkerBase(webview); \ No newline at end of file diff --git a/src/mainview/auth/qr/index.css b/src/mainview/auth/qr/index.css new file mode 100644 index 0000000..74d8662 --- /dev/null +++ b/src/mainview/auth/qr/index.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; +@plugin "daisyui"; \ No newline at end of file diff --git a/src/mainview/auth/qr/index.html b/src/mainview/auth/qr/index.html new file mode 100644 index 0000000..4e94be4 --- /dev/null +++ b/src/mainview/auth/qr/index.html @@ -0,0 +1,249 @@ + + + + + + Quick Login + + + +
+
+

Quick Login

+
+ 70% +
+
+ Fetching session info… +
+
+ +
+ + + + + + + + + + + + + +
+
+ + + + diff --git a/src/mainview/components/AnimatedBackground.tsx b/src/mainview/components/AnimatedBackground.tsx index 43f9d5b..97227a4 100644 --- a/src/mainview/components/AnimatedBackground.tsx +++ b/src/mainview/components/AnimatedBackground.tsx @@ -71,7 +71,7 @@ export function AnimatedBackground (data: { backgroundSize: '100%', backgroundPositionY: 'bottom', backgroundPositionX: 'center', - backgroundBlendMode: 'soft-light', + backgroundBlendMode: blur ? 'normal' : 'soft-light', backgroundColor: "var(--color-base-100)", } : {}} > diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index 50351f9..1690899 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -115,7 +115,7 @@ export function ContextDialog (data: {
{data.children} ; diff --git a/src/mainview/components/options/OptionSpace.tsx b/src/mainview/components/options/OptionSpace.tsx index e7f58c8..6148e3b 100644 --- a/src/mainview/components/options/OptionSpace.tsx +++ b/src/mainview/components/options/OptionSpace.tsx @@ -87,7 +87,7 @@ export function OptionSpace (data: { data.label )}
-
+
{data.children}
diff --git a/src/mainview/components/options/SettingsAppForm.tsx b/src/mainview/components/options/SettingsAppForm.tsx index 16633ba..bb36a3e 100644 --- a/src/mainview/components/options/SettingsAppForm.tsx +++ b/src/mainview/components/options/SettingsAppForm.tsx @@ -19,7 +19,7 @@ export const { useAppForm: useSettingsForm, useTypedAppFormContext: useSettingsF function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; label?: string | JSX.Element; placeholder?: string; }) { const field = useFieldContext(); - return + return {data.label} {field.getMeta().errors.length > 0 &&
{field.state.meta.errors.map(e => e.message).join(',')} @@ -32,7 +32,7 @@ function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; l type={data.type} onChange={e => field.handleChange(e.target.value)} placeholder={data.placeholder} - className={classNames({ "ring-4 ring-accent": field.getMeta().isDirty })} + className={classNames({ " flex-3 ring-4 ring-accent": field.getMeta().isDirty })} /> ;; } \ No newline at end of file diff --git a/src/mainview/routes/settings/accounts.tsx b/src/mainview/routes/settings/accounts.tsx index d83ddad..5ce1194 100644 --- a/src/mainview/routes/settings/accounts.tsx +++ b/src/mainview/routes/settings/accounts.tsx @@ -1,12 +1,13 @@ import { FocusContext, + setFocus, useFocusable, } from "@noriginmedia/norigin-spatial-navigation"; import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import classNames from "classnames"; -import { Key, Link, Lock, Save, Trash, User, X } from "lucide-react"; +import { Key, Link, Lock, Save, ScanQrCode, Trash, User, X } from "lucide-react"; import { useEffect, @@ -23,11 +24,22 @@ import { OptionSpace } from "../../components/options/OptionSpace"; import { useSettingsForm, useSettingsFormContext } from "../../components/options/SettingsAppForm"; import { rommApi, settingsApi } from "../../scripts/clientApi"; import { Button } from "../../components/options/Button"; +import { ContextDialog } from "@/mainview/components/ContextDialog"; +import QRCode from "react-qr-code"; +import { useAsyncGenerator } from "@/mainview/scripts/utils"; export const Route = createFileRoute("/settings/accounts")({ component: RouteComponent, }); +function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url: string; endsAt: Date; }) +{ + return data.cancel()} className="flex flex-col justify-center items-center gap-2"> + + + ; +} + function LoginControls (data: { hasPassword: boolean; }) { const user = useQuery({ @@ -36,8 +48,27 @@ function LoginControls (data: { hasPassword: boolean; }) refetchOnWindowFocus: false, retry: 0 }); + const { data: qrLoginStatusGen, refetch } = useQuery({ + queryKey: ['login', 'qr'], queryFn: async () => + { + const { data, error } = await rommApi.api.romm.login.remote.status.get(); + if (error) throw error; + return data; + } + }); + + const statusValue = useAsyncGenerator(qrLoginStatusGen, [qrLoginStatusGen]); + const cancelQrMutation = useMutation({ + mutationKey: ['login', 'qr', 'cancel'], + mutationFn: () => rommApi.api.romm.login.remote.cancel.post(), + onSuccess: () => refetch() + }); + const requestQrLoginMutation = useMutation({ + mutationKey: ['login', 'qr'], + mutationFn: () => rommApi.api.romm.login.remote.start.post(), + onSuccess: () => refetch() + }); const context = useSettingsFormContext({}); - context.state.canSubmit; const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0; const logoutMutation = useMutation({ mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post(), @@ -52,6 +83,7 @@ function LoginControls (data: { hasPassword: boolean; }) {user.isSuccess && <>

Logged In As:

{user.data?.username}
} + @@ -67,6 +99,11 @@ function LoginControls (data: { hasPassword: boolean; }) + {statusValue?.data?.endsAt && + { + setFocus(`qr-login`); + cancelQrMutation.mutate(); + }} url={statusValue?.data?.url ?? ''} />}
; } @@ -119,9 +156,10 @@ function RouteComponent () const loginMutation = useMutation({ mutationKey: ["romm", "login"], - mutationFn: (data: z.infer) => + mutationFn: async (data: z.infer) => { - return rommApi.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname }); + const { error } = await rommApi.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname }); + if (error) throw error; }, onSuccess: (d, v, r, c) => { diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index 6325c79..b0e0e25 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -1,5 +1,5 @@ import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; -import { RefObject, useEffect } from "react"; +import { RefObject, useEffect, useState } from "react"; export function selfFocusSmart (shouldFocus: boolean, focusSelf: () => void) { @@ -62,4 +62,41 @@ export function mobileCheck () let check = false; (function (a) { if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))) check = true; })(navigator.userAgent || navigator.vendor || window.opera); return check; -}; \ No newline at end of file +}; + +export function useAsyncGenerator ( + generator: AsyncGenerator | undefined, + deps: any[] +) +{ + const [value, setValue] = useState(null); + + useEffect(() => + { + if (!generator) + { + setValue(null); + return; + } + + let cancelled = false; + + const run = async () => + { + for await (const v of generator) + { + if (cancelled) break; + setValue(v); + } + }; + + run(); + + return () => + { + cancelled = true; + }; + }, deps); + + return value; +} \ No newline at end of file diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d918b88..1265ce3 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -3,6 +3,7 @@ import { FocusDetails } from '@noriginmedia/norigin-spatial-navigation'; import { JSX } from 'react'; import * as z from 'zod'; +export const LOGIN_PORT = 5196; export const SERVER_PORT = 5173; export const SERVER_URL = (host: string) => `http://${host}:${SERVER_PORT}`; export const WINDOW_PORT = 4656; diff --git a/vite.config.ts b/vite.config.ts index b2cbcb7..d85f747 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -49,6 +49,10 @@ export default defineConfig(() => minify: production, sourcemap: production ? false : 'inline', rollupOptions: { + input: { + main: 'src/mainview/index.html', + login: 'src/mainview/auth/qr/index.html' + }, output: { manualChunks: (id ) =>