feat: Added QR login
fix: Fixed webview for windows builds
This commit is contained in:
parent
01b91aa48c
commit
4739b89933
26 changed files with 545 additions and 83 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
61
src/bun/api/jobs/login-job.ts
Normal file
61
src/bun/api/jobs/login-job.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
2
src/mainview/auth/qr/index.css
Normal file
2
src/mainview/auth/qr/index.css
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
249
src/mainview/auth/qr/index.html
Normal file
249
src/mainview/auth/qr/index.html
Normal 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>
|
||||
|
|
@ -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)",
|
||||
} : {}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ export function ContextDialog (data: {
|
|||
<ContextDialogContext value={{ id: data.id, close: data.close }} >
|
||||
<div
|
||||
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.className)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export function Button (data: {
|
|||
classNames({
|
||||
"btn-accent": focused,
|
||||
}, data.className))}
|
||||
type={data.type}
|
||||
type={data.type ?? 'button'}
|
||||
>
|
||||
{data.children}
|
||||
</button>;
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export function OptionSpace (data: {
|
|||
data.label
|
||||
)}
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="flex grow justify-end-safe">
|
||||
{data.children}
|
||||
</div>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
return <OptionSpace label={<div className="flex gap-2">
|
||||
return <OptionSpace label={<div className="flex flex-1 gap-2">
|
||||
{data.label}
|
||||
{field.getMeta().errors.length > 0 && <div className="badge badge-error">
|
||||
{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 })}
|
||||
/>
|
||||
</OptionSpace>;;
|
||||
}
|
||||
|
|
@ -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 <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; })
|
||||
{
|
||||
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 && <>
|
||||
<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()} >
|
||||
<Save /> Save
|
||||
</Button>
|
||||
|
|
@ -67,6 +99,11 @@ function LoginControls (data: { hasPassword: boolean; })
|
|||
<Button id="cancel" disabled={context.state.isDefaultValue} type="reset" onAction={() => context.reset()}>
|
||||
<X /> Cancel
|
||||
</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>;
|
||||
}
|
||||
|
||||
|
|
@ -119,9 +156,10 @@ function RouteComponent ()
|
|||
|
||||
const loginMutation = useMutation({
|
||||
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) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue