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

@ -9,7 +9,6 @@
"@elysiajs/cors": "^1.4.1", "@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.6", "@elysiajs/eden": "^1.4.6",
"@elysiajs/static": "^1.4.7", "@elysiajs/static": "^1.4.7",
"@rcompat/webview": "^0.18.0",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"conf": "^15.0.2", "conf": "^15.0.2",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
@ -26,6 +25,7 @@
"tough-cookie": "^6.0.0", "tough-cookie": "^6.0.0",
"tough-cookie-file-store": "^3.3.0", "tough-cookie-file-store": "^3.3.0",
"unzip-stream": "^0.3.4", "unzip-stream": "^0.3.4",
"webview-bun": "^2.4.0",
"zod": "^4.3.6", "zod": "^4.3.6",
}, },
"devDependencies": { "devDependencies": {
@ -64,6 +64,7 @@
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-error-boundary": "^6.1.0", "react-error-boundary": "^6.1.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-qr-code": "^2.0.18",
"sass-embedded": "^1.97.3", "sass-embedded": "^1.97.3",
"standard-version": "^9.5.0", "standard-version": "^9.5.0",
"tailwind-merge": "^3.4.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=="], "@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=="], "@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=="], "@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=="], "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=="], "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=="], "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=="], "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=="], "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], "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=="], "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=="], "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=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"quick-lru": ["quick-lru@4.0.1", "", {}, "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g=="], "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-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=="], "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=="], "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=="], "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-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=="], "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],

View file

@ -44,7 +44,6 @@
"@elysiajs/cors": "^1.4.1", "@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.6", "@elysiajs/eden": "^1.4.6",
"@elysiajs/static": "^1.4.7", "@elysiajs/static": "^1.4.7",
"@rcompat/webview": "^0.18.0",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"conf": "^15.0.2", "conf": "^15.0.2",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
@ -61,6 +60,7 @@
"tough-cookie": "^6.0.0", "tough-cookie": "^6.0.0",
"tough-cookie-file-store": "^3.3.0", "tough-cookie-file-store": "^3.3.0",
"unzip-stream": "^0.3.4", "unzip-stream": "^0.3.4",
"webview-bun": "^2.4.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
@ -99,6 +99,7 @@
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-error-boundary": "^6.1.0", "react-error-boundary": "^6.1.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-qr-code": "^2.0.18",
"sass-embedded": "^1.97.3", "sass-embedded": "^1.97.3",
"standard-version": "^9.5.0", "standard-version": "^9.5.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",

View file

@ -28,6 +28,7 @@ await ensureDir("build");
// Copy app dir // Copy app dir
await fs.cp(`${APP_DIR}/.`, path.join(APPDIR, `usr`, 'share'), { recursive: true }); 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', 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] await fs.writeFile(path.join(APPDIR, `${APP_ID}.desktop`), `[Desktop Entry]
Version=${pkg.version} Version=${pkg.version}

View file

@ -19,6 +19,17 @@ if (process.env.TARGET)
compileOption.target = process.env.TARGET as any; 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({ await Bun.build({
entrypoints: ["./src/bun/index.ts", `./src/bun/webview/${system.platform}.ts`], entrypoints: ["./src/bun/index.ts", `./src/bun/webview/${system.platform}.ts`],
metafile: true, metafile: true,
@ -26,7 +37,8 @@ await Bun.build({
outdir: buildSubDir, outdir: buildSubDir,
root: './src/bun', root: './src/bun',
define: { define: {
"process.env.IS_BINARY": "true" "process.env.IS_BINARY": "true",
"process.env.WEBVIEW_PATH": `./${webviewLib}`,
}, },
minify: process.env.NODE_ENV !== 'development', minify: process.env.NODE_ENV !== 'development',
sourcemap: process.env.NODE_ENV === 'development' ? 'inline' : "linked", 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('./dist', `${buildSubDir}/dist`, { recursive: true });
await fs.cp('./drizzle', `${buildSubDir}/drizzle`, { 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(`./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));
}); });
}, },
}] }]

View file

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

View file

@ -1,32 +1,47 @@
import Elysia, { status } from "elysia"; import Elysia, { sse, status } from "elysia";
import { config, db, jar } from "./app"; import { config, jar, taskQueue } from "./app";
import z from "zod"; import z from "zod";
import { client } from "@clients/romm/client.gen"; import { client } from "@clients/romm/client.gen";
import { loginApiLoginPost } from "@clients/romm"; import { loginApiLoginPost, logoutApiLogoutPost } from "@clients/romm";
import secrets from '../api/secrets'; import secrets from '../api/secrets';
import { LoginJob } from "./jobs/login-job";
export default new Elysia() 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(); return status("Conflict", "Login Already Active");
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); const job = new LoginJob();
config.set('rommUser', username); 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 }); yield sse({ data: {} });
await login(); })
.post('/login/remote/cancel', async () =>
return status(200); {
}, { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) }) 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 () => .get('/login', async () =>
{ {
const credentials = await secrets.get({ service: 'gameflow', name: 'romm' }); 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 () export async function logout ()
{ {
if (!config.has('rommAddress')) if (!config.has('rommAddress'))
@ -66,11 +106,12 @@ export async function logout ()
console.log("Logging Out of ROMM"); console.log("Logging Out of ROMM");
try try
{ {
await loginApiLoginPost({ await logoutApiLogoutPost({
baseUrl: rommAddress, headers: { baseUrl: rommAddress, headers: {
'cookie': await jar.getCookieString(rommAddress) 'cookie': await jar.getCookieString(rommAddress)
} }
}); });
await jar.store.removeCookie(new URL(rommAddress).host, null, "romm_session");
} catch (error) } catch (error)
{ {
console.error("Failed to logout of ROMM ", 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 rommAddress = config.get('rommAddress');
const rommUser = config.get('rommUser'); const rommUser = config.get('rommUser');
if (rommAddress && rommUser) if (rommAddress && rommUser)
{ {
console.log("Logging In to ROMM"); 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}` }); const loginResponse = await loginApiLoginPost({ baseUrl: rommAddress, auth: `${rommUser}:${password}` });
loginResponse.response.headers.getSetCookie().map(c => jar.setCookie(c, rommAddress)); if (loginResponse.response.status === 200)
await updateClient(); {
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 processImage(coverBlob.cover, query);
/*return sharp(coverBlob.cover)
.resize({ width, height, withoutEnlargement: true })
.blur(blur)
.toBuffer();*/
}, { }, {
params: z.object({ id: z.coerce.number() }), 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() }) 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'); const rommAdress = config.get('rommAddress');
return processImage(`${rommAdress}/${path}`, query); 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'); return status('Not Found');
}, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) }) }, { 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 processImage(screenshot.content, query);
//return sharp(screenshot.content).resize({ width, height, withoutEnlargement: true }).blur(blur).toBuffer();
//return screenshot.content;
} }
return status(404); 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 z from "zod";
import { SettingsSchema } from "@shared/constants"; import { LOGIN_PORT, SettingsSchema } from "@shared/constants";
import Elysia, { status } from "elysia"; import Elysia, { status } from "elysia";
import { config, customEmulators, db, emulatorsDb, taskQueue } from "./app"; import { config, customEmulators, db, emulatorsDb, taskQueue } from "./app";
import * as appSchema from './schema/app'; import * as appSchema from './schema/app';
@ -103,7 +103,7 @@ export const settings = new Elysia({ prefix: '/api/settings' })
const oldDownloadPath = config.get('downloadPath'); const oldDownloadPath = config.get('downloadPath');
if (!existsSync(oldDownloadPath)) 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) async function isDirEmpty (dirname: string)
@ -121,7 +121,7 @@ export const settings = new Elysia({ prefix: '/api/settings' })
if (existsSync(path) && !isDirEmpty(path)) 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); await move(oldDownloadPath, path);

View file

@ -138,6 +138,7 @@ export interface IPublicJob
state?: string; state?: string;
status: JobStatus; status: JobStatus;
job: any; job: any;
abort: (reason?: any) => void;
} }
export class JobContext implements IPublicJob export class JobContext implements IPublicJob
@ -177,7 +178,7 @@ export class JobContext implements IPublicJob
} catch (error) } catch (error)
{ {
console.error(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; this.error = error;
} finally } finally
{ {

View file

@ -24,8 +24,9 @@ export default async function init (events: EventEmitter, forceBrowser: boolean)
async function runWebview (events: EventEmitter) 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, smol: true,
ref: false
}); });
return new Promise((resolve, reject) => return new Promise((resolve, reject) =>
@ -47,6 +48,7 @@ async function runWebview (events: EventEmitter)
events.on('exitapp', () => events.on('exitapp', () =>
{ {
resolve(true); resolve(true);
webviewWorker.terminate();
}); });
}); });
} }

View file

@ -1,6 +1,6 @@
import { networkInterfaces } from "node:os"; import { networkInterfaces } from "node:os";
const localIp = Object.values(networkInterfaces()) export const localIp = Object.values(networkInterfaces())
.flat() .flat()
.find((iface) => iface?.family === 'IPv4' && !iface.internal)?.address || 'localhost'; .find((iface) => iface?.family === 'IPv4' && !iface.internal)?.address || 'localhost';

View file

@ -3,14 +3,15 @@ import { host } from "../utils/host";
export default function (webview: { navigate: (url: string) => void; run: () => void; destroy: () => void; }) export default function (webview: { navigate: (url: string) => void; run: () => void; destroy: () => void; })
{ {
self.addEventListener('message', (e) => self.onmessage = (e) =>
{ {
console.log("Terminate"); console.log("Terminate");
if (e.data === 'exit') if (e.data === 'exit')
{ {
webview.destroy(); webview.destroy();
process.exit();
} }
}); };
webview.navigate(SERVER_URL(host)); webview.navigate(SERVER_URL(host));
webview.run(); webview.run();
} }

View file

@ -1,5 +1,4 @@
import Webview from "@rcompat/webview"; import { Webview } from 'webview-bun';
import platform from "@rcompat/webview/linux-x64";
import webviewWorkerBase from "./base"; import webviewWorkerBase from "./base";
if (process.env.FLATPAK_BUILD === "true") if (process.env.FLATPAK_BUILD === "true")
@ -29,6 +28,6 @@ if (process.env.FLATPAK_BUILD === "true")
} else } else
{ {
console.log("Launching Webview"); 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); webviewWorkerBase(webview);
} }

View file

@ -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"; import webviewWorkerBase from "./base";
console.log("Launching Webview"); const webview = new Webview(import.meta.env.NODE_ENV === 'development');
const webview = new Webview({ debug: import.meta.env.NODE_ENV === 'development', platform });
webviewWorkerBase(webview); webviewWorkerBase(webview);

View file

@ -0,0 +1,2 @@
@import "tailwindcss";
@plugin "daisyui";

View file

@ -0,0 +1,249 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quick Login</title>
<link rel="stylesheet" href="./index.css" />
</head>
<body
class="flex overflow-hidden relative items-center justify-center min-h-screen bg-base-300"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col items-center justify-center gap-4">
<h1 class="text-4xl font-semibold">Quick Login</h1>
<div
id="arc"
class="radial-progress"
aria-valuenow="70"
role="progressbar"
>
70%
</div>
<div class="alert alert-dash alert-info" id="expiryNote">
Fetching session info…
</div>
</div>
<form
id="loginForm"
class="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4"
>
<label class="label" for="host">Host</label>
<input
type="text"
id="host"
name="host"
placeholder="my-server.local"
autocomplete="off"
class="input"
/>
<label class="label" for="username">Username</label>
<input
type="text"
id="username"
name="username"
placeholder="your-username"
autocomplete="username"
class="input"
/>
<label class="label" for="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="••••••••"
autocomplete="current-password"
class="input join-item"
/>
<button class="btn btn-primary mt-4" id="loginBtn" type="submit">
Login →
</button>
<button
onclick="handleCancel()"
class="btn btn-primary mt-4"
id="loginBtn"
type="button"
>
Cancel
</button>
<div class="alert hidden" id="statusPill">
<div class="dot"></div>
<span id="statusText"></span>
</div>
</form>
</div>
<script>
const _base = window.location.href.split("?")[0].replace(/\/[^\/]*$/, "");
const ENDPOINT = _base + "/login";
const STATUS_EP = _base + "/status";
const DEFAULTS_EP = _base + "/defaults";
const CANCEL_EP = _base + "/cancel";
const arc = document.getElementById("arc");
const expireBar = document.getElementById("expireBar");
const expiryNote = document.getElementById("expiryNote");
const loginForm = document.getElementById("loginForm");
const pill = document.getElementById("statusPill");
const statusText = document.getElementById("statusText");
let expiresAt = null;
let totalSpan = null;
function handleCancel() {
fetch(CANCEL_EP, { method: "POST" });
}
async function fetchDefault() {
try {
const res = await fetch(DEFAULTS_EP, { method: "GET" });
const data = await res.json();
document.getElementById("host").defaultValue = data.host;
document.getElementById("username").defaultValue =
data.username ?? "";
} catch (e) {
showStatus("Could not fetch defaults", "err");
}
}
fetchDefault();
async function fetchExpiry() {
try {
const res = await fetch(STATUS_EP, { method: "GET" });
const data = await res.json();
if (data.expires_at) {
expiresAt = new Date(data.expires_at);
totalSpan = data.max_time;
renderExpiryNote();
} else {
expiryNote.textContent = "No expiry info from server";
}
} catch (e) {
expiryNote.textContent = "Could not reach server";
}
}
function renderExpiryNote() {
if (!expiresAt) return;
const fmt = expiresAt.toLocaleString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
expiryNote.textContent = `Active until ${fmt}`;
}
fetchExpiry();
// Countdown timer
function updateTimer() {
const loginBtn = document.getElementById("loginBtn");
if (!expiresAt) {
arc.style.setProperty("--value", 70);
arc.innerHTML = "<small>loading…</small>";
return;
}
const remainMs = expiresAt - Date.now();
if (remainMs <= 0) {
arc.style.setProperty("--value", 0);
arc.innerHTML = "<small>Expired</small>";
loginBtn.disabled = true;
return;
}
const pct = Math.min(remainMs / (totalSpan || remainMs), 1);
arc.style.setProperty("--value", pct * 100);
const totalSec = Math.ceil(remainMs / 1000);
const d = Math.floor(totalSec / 86400);
const h = Math.floor((totalSec % 86400) / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
let main, sub;
if (d > 0) {
main = `${d}d`;
sub = `${h}h left`;
} else if (h > 0) {
main = `${h}h`;
sub = `${m}m left`;
} else if (m > 0) {
main = `${m}:${String(s).padStart(2, "0")}`;
sub = "left";
} else {
main = `${s}s`;
sub = "left";
}
arc.innerHTML = `${main}<br><small>${sub}</small>`;
}
updateTimer();
setInterval(updateTimer, 1000);
function showStatus(msg, type) {
pill.className = "alert " + type;
statusText.textContent = msg;
if (type === "ok") setTimeout(() => pill.classList.add("hidden"), 4000);
}
// Login submit
loginForm.onsubmit = async (e) => {
e.preventDefault();
if (!e.target.host.value) {
showStatus("Host is required", "alert-error");
return;
}
if (!e.target.username.value) {
showStatus("Username is required", "alert-error");
return;
}
if (!e.target.password.value) {
showStatus("Password is required", "alert-error");
return;
}
const btn = document.getElementById("loginBtn");
btn.textContent = "Logging in…";
btn.style.opacity = "0.75";
btn.disabled = true;
try {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
host: e.target.host.value,
username: e.target.username.value,
password: e.target.password.value,
}),
});
if (res.ok) {
showStatus("Logged in!", "alert-success");
setTimeout(() => window.close(), 1000);
} else {
showStatus(`Error ${res.status}: ${res.statusText}`, "alert-error");
}
} catch (e) {
showStatus("Could not reach server", "alert-error");
} finally {
btn.textContent = "Login →";
btn.style.opacity = "1";
btn.disabled = false;
}
};
</script>
</body>
</html>

View file

@ -71,7 +71,7 @@ export function AnimatedBackground (data: {
backgroundSize: '100%', backgroundSize: '100%',
backgroundPositionY: 'bottom', backgroundPositionY: 'bottom',
backgroundPositionX: 'center', backgroundPositionX: 'center',
backgroundBlendMode: 'soft-light', backgroundBlendMode: blur ? 'normal' : 'soft-light',
backgroundColor: "var(--color-base-100)", backgroundColor: "var(--color-base-100)",
} : {}} } : {}}
> >

View file

@ -115,7 +115,7 @@ export function ContextDialog (data: {
<ContextDialogContext value={{ id: data.id, close: data.close }} > <ContextDialogContext value={{ id: data.id, close: data.close }} >
<div <div
className={twMerge( className={twMerge(
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[30vw] cursor-auto", "bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] cursor-auto",
data.open ? "animate-scale-delayed" : "opacity-0", data.open ? "animate-scale-delayed" : "opacity-0",
data.className) data.className)
} }

View file

@ -38,7 +38,7 @@ export function Button (data: {
classNames({ classNames({
"btn-accent": focused, "btn-accent": focused,
}, data.className))} }, data.className))}
type={data.type} type={data.type ?? 'button'}
> >
{data.children} {data.children}
</button>; </button>;

View file

@ -87,7 +87,7 @@ export function OptionSpace (data: {
data.label data.label
)} )}
</div> </div>
<div className="flex"> <div className="flex grow justify-end-safe">
{data.children} {data.children}
</div> </div>
</li> </li>

View file

@ -19,7 +19,7 @@ export const { useAppForm: useSettingsForm, useTypedAppFormContext: useSettingsF
function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; label?: string | JSX.Element; placeholder?: string; }) function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; label?: string | JSX.Element; placeholder?: string; })
{ {
const field = useFieldContext<string>(); const field = useFieldContext<string>();
return <OptionSpace label={<div className="flex gap-2"> return <OptionSpace label={<div className="flex flex-1 gap-2">
{data.label} {data.label}
{field.getMeta().errors.length > 0 && <div className="badge badge-error"> {field.getMeta().errors.length > 0 && <div className="badge badge-error">
{field.state.meta.errors.map(e => e.message).join(',')} {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} type={data.type}
onChange={e => field.handleChange(e.target.value)} onChange={e => field.handleChange(e.target.value)}
placeholder={data.placeholder} placeholder={data.placeholder}
className={classNames({ "ring-4 ring-accent": field.getMeta().isDirty })} className={classNames({ " flex-3 ring-4 ring-accent": field.getMeta().isDirty })}
/> />
</OptionSpace>;; </OptionSpace>;;
} }

View file

@ -1,12 +1,13 @@
import import
{ {
FocusContext, FocusContext,
setFocus,
useFocusable, useFocusable,
} from "@noriginmedia/norigin-spatial-navigation"; } from "@noriginmedia/norigin-spatial-navigation";
import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query"; import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import classNames from "classnames"; 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 import
{ {
useEffect, useEffect,
@ -23,11 +24,22 @@ import { OptionSpace } from "../../components/options/OptionSpace";
import { useSettingsForm, useSettingsFormContext } from "../../components/options/SettingsAppForm"; import { useSettingsForm, useSettingsFormContext } from "../../components/options/SettingsAppForm";
import { rommApi, settingsApi } from "../../scripts/clientApi"; import { rommApi, settingsApi } from "../../scripts/clientApi";
import { Button } from "../../components/options/Button"; 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")({ export const Route = createFileRoute("/settings/accounts")({
component: RouteComponent, component: RouteComponent,
}); });
function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url: string; endsAt: Date; })
{
return <ContextDialog id={data.id} open={data.isOpen} close={() => data.cancel()} className="flex flex-col justify-center items-center gap-2">
<QRCode value={data.url} />
<Button id="qr-login-cancel" focusClassName="btn-warning" type="button" onAction={() => data.cancel()}><X /> Cancel</Button>
</ContextDialog>;
}
function LoginControls (data: { hasPassword: boolean; }) function LoginControls (data: { hasPassword: boolean; })
{ {
const user = useQuery({ const user = useQuery({
@ -36,8 +48,27 @@ function LoginControls (data: { hasPassword: boolean; })
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: 0 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({}); const context = useSettingsFormContext({});
context.state.canSubmit;
const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0; const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0;
const logoutMutation = useMutation({ const logoutMutation = useMutation({
mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post(), mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post(),
@ -52,6 +83,7 @@ function LoginControls (data: { hasPassword: boolean; })
{user.isSuccess && <> {user.isSuccess && <>
<div className="badge badge-success badge-lg rounded-full gap-2"> <p className="sm:hidden md:inline">Logged In As:</p> <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> <div className="badge badge-success badge-lg rounded-full gap-2"> <p className="sm:hidden md:inline">Logged In As:</p> <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 id="qr-login" type="button" onAction={() => requestQrLoginMutation.mutate()}><ScanQrCode /> </Button>
<Button id="can-submit" disabled={!context.state.canSubmit || !context.state.isDirty} type="submit" onAction={() => context.handleSubmit()} > <Button id="can-submit" disabled={!context.state.canSubmit || !context.state.isDirty} type="submit" onAction={() => context.handleSubmit()} >
<Save /> Save <Save /> Save
</Button> </Button>
@ -67,6 +99,11 @@ function LoginControls (data: { hasPassword: boolean; })
<Button id="cancel" disabled={context.state.isDefaultValue} type="reset" onAction={() => context.reset()}> <Button id="cancel" disabled={context.state.isDefaultValue} type="reset" onAction={() => context.reset()}>
<X /> Cancel <X /> Cancel
</Button> </Button>
{statusValue?.data?.endsAt && <LoginQR id="qr-login-context" endsAt={statusValue.data.endsAt} isOpen={true} cancel={() =>
{
setFocus(`qr-login`);
cancelQrMutation.mutate();
}} url={statusValue?.data?.url ?? ''} />}
</div>; </div>;
} }
@ -119,9 +156,10 @@ function RouteComponent ()
const loginMutation = useMutation({ const loginMutation = useMutation({
mutationKey: ["romm", "login"], mutationKey: ["romm", "login"],
mutationFn: (data: z.infer<typeof dataSchema>) => mutationFn: async (data: z.infer<typeof dataSchema>) =>
{ {
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) => onSuccess: (d, v, r, c) =>
{ {

View file

@ -1,5 +1,5 @@
import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; 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) export function selfFocusSmart (shouldFocus: boolean, focusSelf: () => void)
{ {
@ -62,4 +62,41 @@ export function mobileCheck ()
let check = false; 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); (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; return check;
}; };
export function useAsyncGenerator<T> (
generator: AsyncGenerator<T> | undefined,
deps: any[]
)
{
const [value, setValue] = useState<T | null>(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;
}

View file

@ -3,6 +3,7 @@ import { FocusDetails } from '@noriginmedia/norigin-spatial-navigation';
import { JSX } from 'react'; import { JSX } from 'react';
import * as z from 'zod'; import * as z from 'zod';
export const LOGIN_PORT = 5196;
export const SERVER_PORT = 5173; export const SERVER_PORT = 5173;
export const SERVER_URL = (host: string) => `http://${host}:${SERVER_PORT}`; export const SERVER_URL = (host: string) => `http://${host}:${SERVER_PORT}`;
export const WINDOW_PORT = 4656; export const WINDOW_PORT = 4656;

View file

@ -49,6 +49,10 @@ export default defineConfig(() =>
minify: production, minify: production,
sourcemap: production ? false : 'inline', sourcemap: production ? false : 'inline',
rollupOptions: { rollupOptions: {
input: {
main: 'src/mainview/index.html',
login: 'src/mainview/auth/qr/index.html'
},
output: { output: {
manualChunks: (id manualChunks: (id
) => ) =>