Add an Electron desktop shell for the SPA
desktop/ serves the normal frontend build over a privileged app://j621 scheme, so localStorage, OPFS, Web Workers, WebCodecs and history routing behave exactly like Chrome. Development points at the Vite dev server; `npm run smoke` runs headless Electron and checks the bundled app. External links open in the system browser, and download navigations (?download=1 or media URLs) are rerouted through webContents.downloadURL, since preventing them cancels the download. The setup screen names the shell's origin when the connection test fails, and the deploy docs list app://j621 for CORS_ALLOWED_ORIGINS.
This commit is contained in:
@@ -31,6 +31,9 @@ ALLOWED_HOSTS=j621.rainbow-herring.ts.net,localhost,127.0.0.1
|
|||||||
# Cross-origin access (needed for the separate frontend + backend deploys)
|
# Cross-origin access (needed for the separate frontend + backend deploys)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# The origin the SPA is served from, e.g. https://j621-frontend.<tailnet>.ts.net
|
# The origin the SPA is served from, e.g. https://j621-frontend.<tailnet>.ts.net
|
||||||
|
# The desktop app (desktop/) is served from app://j621, so add that origin too
|
||||||
|
# when it should reach this backend:
|
||||||
|
# CORS_ALLOWED_ORIGINS=https://j621-frontend.<tailnet>.ts.net,app://j621
|
||||||
# CORS_ALLOWED_ORIGINS=
|
# CORS_ALLOWED_ORIGINS=
|
||||||
# CSRF_TRUSTED_ORIGINS=
|
# CSRF_TRUSTED_ORIGINS=
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ The SPA asks where the backend is (`/setup`) in production builds:
|
|||||||
backend's funnel URL, e.g. `https://j621-backend.<tailnet>.ts.net:8443`,
|
backend's funnel URL, e.g. `https://j621-backend.<tailnet>.ts.net:8443`,
|
||||||
and give the backend `CORS_ALLOWED_ORIGINS=https://<frontend-host>.<tailnet>.ts.net`
|
and give the backend `CORS_ALLOWED_ORIGINS=https://<frontend-host>.<tailnet>.ts.net`
|
||||||
(plus `CSRF_TRUSTED_ORIGINS` for the admin).
|
(plus `CSRF_TRUSTED_ORIGINS` for the admin).
|
||||||
|
- **Desktop app** — the Electron shell (`desktop/`) is served from the
|
||||||
|
`app://j621` origin, so it needs that entry too:
|
||||||
|
`CORS_ALLOWED_ORIGINS=...,app://j621`. The shell's setup screen prints the
|
||||||
|
exact origin when the connection test fails.
|
||||||
- Without a backend at all, choose **"Continue without a backend"** to run in
|
- Without a backend at all, choose **"Continue without a backend"** to run in
|
||||||
local mode (e621 browsing only).
|
local mode (e621 browsing only).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# J621 desktop (Electron)
|
||||||
|
|
||||||
|
An Electron shell around the web build. The main process serves the SPA from
|
||||||
|
the privileged `app://j621` scheme — a real origin, so `localStorage`, OPFS,
|
||||||
|
Web Workers and WebCodecs all behave exactly like they do in Chrome — and the
|
||||||
|
SPA keeps its normal first-run flow: on launch it asks where the backend is
|
||||||
|
(or runs in local mode with no backend at all).
|
||||||
|
|
||||||
|
The renderer code is not forked: the shell loads `frontend/dist` as built by
|
||||||
|
`npm run build` in `frontend/`.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main.ts window, app:// protocol handler, menu, external links
|
||||||
|
src/preload.ts window.j621Desktop bridge
|
||||||
|
electron-builder.yml deb + pacman + nsis packaging
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# terminal 1: the SPA with its /api proxy (needs the backend for library pages)
|
||||||
|
cd frontend && npm run dev
|
||||||
|
|
||||||
|
# terminal 2
|
||||||
|
cd desktop && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run dev` builds the main process and points the window at
|
||||||
|
`http://localhost:5173` (`J621_DEV_SERVER`). Without the dev server,
|
||||||
|
`npm start` builds both halves and serves the bundled SPA over `app://j621`.
|
||||||
|
|
||||||
|
## Builds
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dist:linux # release/*.deb + release/*.pkg.tar.zst
|
||||||
|
npm run dist:win # release/J621-Setup-*.exe (cross-built with wine; untested)
|
||||||
|
npm run dist:all
|
||||||
|
```
|
||||||
|
|
||||||
|
The Arch package can be installed and removed with pacman:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo pacman -U release/j621-*.pkg.tar.zst
|
||||||
|
```
|
||||||
|
|
||||||
|
## Smoke test
|
||||||
|
|
||||||
|
`npm run smoke` builds everything, runs Electron headless through `xvfb-run`
|
||||||
|
and checks that the bundled SPA loads over `app://j621`: the SPA fallback, an
|
||||||
|
asset fetch, `localStorage`, OPFS, WebCodecs and the preload bridge. It exits
|
||||||
|
non-zero on failure.
|
||||||
|
|
||||||
|
## Backend CORS
|
||||||
|
|
||||||
|
A desktop app is cross-origin to the backend, exactly like the frontend-only
|
||||||
|
Docker deployment. Add the shell's origin to the backend:
|
||||||
|
|
||||||
|
```
|
||||||
|
CORS_ALLOWED_ORIGINS=app://j621
|
||||||
|
```
|
||||||
|
|
||||||
|
The setup screen shows this hint (with the actual origin) when the connection
|
||||||
|
test fails. Without a backend, choose "Continue without a backend" — the
|
||||||
|
e621-facing pages work the same as in the browser.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
appId: io.j621.desktop
|
||||||
|
productName: J621
|
||||||
|
copyright: Copyright (c) 2026 JakeBreath — Jake Labs Non-Commercial Software Licence
|
||||||
|
|
||||||
|
directories:
|
||||||
|
output: release
|
||||||
|
buildResources: build
|
||||||
|
|
||||||
|
# The Electron main process and preload live in dist/ (tsc output). The SPA is
|
||||||
|
# copied from the web build as an extra resource and served over app://j621.
|
||||||
|
files:
|
||||||
|
- dist/**
|
||||||
|
- package.json
|
||||||
|
|
||||||
|
extraResources:
|
||||||
|
- from: ../frontend/dist
|
||||||
|
to: dist
|
||||||
|
filter:
|
||||||
|
- "**/*"
|
||||||
|
|
||||||
|
linux:
|
||||||
|
target:
|
||||||
|
- deb
|
||||||
|
- pacman
|
||||||
|
category: AudioVideo
|
||||||
|
synopsis: Desktop client for the J621 self-hosted media archive
|
||||||
|
description: >-
|
||||||
|
J621 is a self-hosted browser for an e621-linked media library. This
|
||||||
|
desktop build talks to a J621 backend over the network, the same way the
|
||||||
|
web app does.
|
||||||
|
icon: build/icon.png
|
||||||
|
|
||||||
|
win:
|
||||||
|
target:
|
||||||
|
- nsis
|
||||||
|
icon: build/icon.ico
|
||||||
|
|
||||||
|
nsis:
|
||||||
|
oneClick: false
|
||||||
|
perMachine: false
|
||||||
|
allowToChangeInstallationDirectory: true
|
||||||
|
shortcutName: J621
|
||||||
Generated
+3617
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "j621-desktop",
|
||||||
|
"productName": "J621",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Desktop shell for the J621 self-hosted media archive",
|
||||||
|
"author": "JakeBreath",
|
||||||
|
"license": "SEE LICENSE IN ../LICENSE",
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"build:main": "tsc -p tsconfig.json",
|
||||||
|
"build:renderer": "npm --prefix ../frontend run build",
|
||||||
|
"build": "npm run build:main && npm run build:renderer",
|
||||||
|
"dev": "npm run build:main && J621_DEV_SERVER=http://localhost:5173 electron .",
|
||||||
|
"start": "npm run build && electron .",
|
||||||
|
"smoke": "npm run build && xvfb-run -a electron . --j621-smoke",
|
||||||
|
"dist:linux": "npm run build && electron-builder --linux",
|
||||||
|
"dist:win": "npm run build && electron-builder --win",
|
||||||
|
"dist:all": "npm run build && electron-builder --linux --win"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.6",
|
||||||
|
"electron": "^44.4.3",
|
||||||
|
"electron-builder": "^26.15.3",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
/**
|
||||||
|
* Electron shell for the J621 SPA.
|
||||||
|
*
|
||||||
|
* The renderer is the normal web build (`frontend/dist`), served from the
|
||||||
|
* privileged `app://j621` scheme so it is a real secure origin: localStorage,
|
||||||
|
* OPFS, Web Workers, WebCodecs and `history.pushState` all behave exactly
|
||||||
|
* like they do in Chrome. In development `J621_DEV_SERVER` points the window
|
||||||
|
* at the Vite dev server instead, which keeps HMR and the `/api` proxy.
|
||||||
|
*
|
||||||
|
* The shell is deliberately thin: the SPA still asks for its backend URL on
|
||||||
|
* first launch (or runs in local e621-only mode) and talks to it over HTTP
|
||||||
|
* the same way it does in a browser. The only desktop-specific bits are the
|
||||||
|
* origin, external-link handling and (later) updates.
|
||||||
|
*/
|
||||||
|
import { app, BrowserWindow, ipcMain, Menu, protocol, shell } from "electron";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { readFile, stat, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const SCHEME = "app";
|
||||||
|
const HOST = "j621";
|
||||||
|
const APP_ORIGIN = `${SCHEME}://${HOST}`;
|
||||||
|
const DEV_SERVER = (process.env.J621_DEV_SERVER ?? "").replace(/\/+$/, "");
|
||||||
|
const SMOKE = process.argv.includes("--j621-smoke");
|
||||||
|
|
||||||
|
protocol.registerSchemesAsPrivileged([
|
||||||
|
{
|
||||||
|
scheme: SCHEME,
|
||||||
|
privileges: {
|
||||||
|
standard: true,
|
||||||
|
secure: true,
|
||||||
|
supportFetchAPI: true,
|
||||||
|
corsEnabled: true,
|
||||||
|
stream: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".mjs": "text/javascript; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".map": "application/json; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".wasm": "application/wasm",
|
||||||
|
".mp4": "video/mp4",
|
||||||
|
".webm": "video/webm",
|
||||||
|
".txt": "text/plain; charset=utf-8",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Where `vite build` output lives, packaged or not. */
|
||||||
|
function rendererRoot(): string {
|
||||||
|
return app.isPackaged
|
||||||
|
? path.join(process.resourcesPath, "dist")
|
||||||
|
: path.resolve(__dirname, "..", "..", "frontend", "dist");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function existingFile(candidate: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return (await stat(candidate)).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileResponse(file: string): Promise<Response> {
|
||||||
|
const body = await readFile(file);
|
||||||
|
const type =
|
||||||
|
MIME_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
||||||
|
return new Response(body, {
|
||||||
|
headers: { "content-type": type, "cache-control": "no-cache" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAppRequest(request: Request): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.host !== HOST) return new Response("Not found", { status: 404 });
|
||||||
|
|
||||||
|
const root = rendererRoot();
|
||||||
|
let pathname = decodeURIComponent(url.pathname);
|
||||||
|
if (!pathname || pathname === "/") pathname = "/index.html";
|
||||||
|
|
||||||
|
const target = path.resolve(root, "." + pathname);
|
||||||
|
if (target !== root && !target.startsWith(root + path.sep)) {
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (await existingFile(target)) return await fileResponse(target);
|
||||||
|
// Missing assets keep their 404; anything else is a client route and
|
||||||
|
// falls back to the SPA entry point (BrowserRouter).
|
||||||
|
if (path.extname(pathname)) return new Response("Not found", { status: 404 });
|
||||||
|
return await fileResponse(path.join(root, "index.html"));
|
||||||
|
} catch (error) {
|
||||||
|
const hint = app.isPackaged
|
||||||
|
? "The bundled frontend is missing from the application resources."
|
||||||
|
: "Build the frontend first: npm --prefix ../frontend run build";
|
||||||
|
console.error("[app://]", error);
|
||||||
|
return new Response(`${hint}\n`, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindowState {
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
maximized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_WINDOW_STATE: WindowState = {
|
||||||
|
width: 1440,
|
||||||
|
height: 900,
|
||||||
|
maximized: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function stateFile(): string {
|
||||||
|
return path.join(app.getPath("userData"), "window-state.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readWindowState(): WindowState {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(
|
||||||
|
readFileSync(stateFile(), "utf8"),
|
||||||
|
) as Partial<WindowState>;
|
||||||
|
return { ...DEFAULT_WINDOW_STATE, ...parsed };
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_WINDOW_STATE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveWindowState(win: BrowserWindow): void {
|
||||||
|
if (win.isDestroyed()) return;
|
||||||
|
const { x, y, width, height } = win.getNormalBounds();
|
||||||
|
const state: WindowState = {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
maximized: win.isMaximized(),
|
||||||
|
};
|
||||||
|
void writeFile(stateFile(), JSON.stringify(state)).catch(() => {
|
||||||
|
// Window geometry is a nice-to-have; never fail a quit over it.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openExternal(rawUrl: string): void {
|
||||||
|
if (/^https?:/i.test(rawUrl)) void shell.openExternal(rawUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File extensions the SPA navigates to when it wants a save dialog. */
|
||||||
|
const DOWNLOAD_EXTENSIONS = new Set([
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".jpe",
|
||||||
|
".png",
|
||||||
|
".gif",
|
||||||
|
".webp",
|
||||||
|
".avif",
|
||||||
|
".mp4",
|
||||||
|
".webm",
|
||||||
|
".mov",
|
||||||
|
".swf",
|
||||||
|
".zip",
|
||||||
|
".pdf",
|
||||||
|
".bin",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads in the SPA are same-window navigations (`location.href =
|
||||||
|
* url?download=1`, `<a href=...>`), and Chromium cancels those when
|
||||||
|
* `will-navigate` is prevented. Recognise them so they become real
|
||||||
|
* downloads instead of being opened in the browser.
|
||||||
|
*/
|
||||||
|
function isDownloadNavigation(url: URL): boolean {
|
||||||
|
if (/(?:^|&)download=1(?:&|$)/.test(url.search.slice(1))) return true;
|
||||||
|
return DOWNLOAD_EXTENSIONS.has(path.extname(url.pathname).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
function buildMenu(win: BrowserWindow): void {
|
||||||
|
const template: Electron.MenuItemConstructorOptions[] = [
|
||||||
|
{
|
||||||
|
label: "J621",
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: "Backend setup…",
|
||||||
|
click: () => void win.loadURL(`${APP_ORIGIN}/setup`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Open backend in browser",
|
||||||
|
click: () => win.webContents.send("j621:open-backend"),
|
||||||
|
},
|
||||||
|
{ type: "separator" },
|
||||||
|
{ role: "quit" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ role: "editMenu" },
|
||||||
|
{
|
||||||
|
label: "View",
|
||||||
|
submenu: [
|
||||||
|
{ role: "reload" },
|
||||||
|
{ role: "forceReload" },
|
||||||
|
{ role: "toggleDevTools" },
|
||||||
|
{ type: "separator" },
|
||||||
|
{ role: "resetZoom" },
|
||||||
|
{ role: "zoomIn" },
|
||||||
|
{ role: "zoomOut" },
|
||||||
|
{ type: "separator" },
|
||||||
|
{ role: "togglefullscreen" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow(): BrowserWindow {
|
||||||
|
const state = readWindowState();
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
x: state.x,
|
||||||
|
y: state.y,
|
||||||
|
width: state.width,
|
||||||
|
height: state.height,
|
||||||
|
minWidth: 960,
|
||||||
|
minHeight: 600,
|
||||||
|
backgroundColor: "#11111b",
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, "preload.js"),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
spellcheck: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.maximized) win.maximize();
|
||||||
|
win.once("ready-to-show", () => win.show());
|
||||||
|
win.on("close", () => saveWindowState(win));
|
||||||
|
win.on("closed", () => {
|
||||||
|
mainWindow = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Links to the web open in the user's browser, never in this window.
|
||||||
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
openExternal(url);
|
||||||
|
return { action: "deny" };
|
||||||
|
});
|
||||||
|
win.webContents.on("will-navigate", (event, url) => {
|
||||||
|
const allowed = DEV_SERVER
|
||||||
|
? url.startsWith(DEV_SERVER)
|
||||||
|
: url.startsWith(APP_ORIGIN);
|
||||||
|
if (allowed) return;
|
||||||
|
event.preventDefault();
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (/^https?:$/.test(parsed.protocol) && isDownloadNavigation(parsed)) {
|
||||||
|
win.webContents.downloadURL(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openExternal(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
buildMenu(win);
|
||||||
|
void win.loadURL(DEV_SERVER || `${APP_ORIGIN}/`);
|
||||||
|
if (SMOKE) runSmokeTest(win);
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `npm run smoke`: load the bundled SPA and check the things that are only
|
||||||
|
* true under the custom scheme. Exits non-zero on the first broken promise.
|
||||||
|
*/
|
||||||
|
function runSmokeTest(win: BrowserWindow): void {
|
||||||
|
win.webContents.once("did-finish-load", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const result = (await win.webContents.executeJavaScript(`(async () => {
|
||||||
|
const waitFor = async (probe, timeout = 8000) => {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
if (probe()) return true;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const asset = await fetch("/favicon.svg");
|
||||||
|
const route = await fetch("/gallery/some/deep/route");
|
||||||
|
const routeBody = await route.text();
|
||||||
|
localStorage.setItem("j621.smoke", "ok");
|
||||||
|
history.pushState({}, "", "/gallery");
|
||||||
|
return {
|
||||||
|
origin: location.origin,
|
||||||
|
title: document.title,
|
||||||
|
assetOk: asset.ok && (await asset.text()).includes("<svg"),
|
||||||
|
routeOk: route.ok && routeBody.includes('id="root"'),
|
||||||
|
mounted: await waitFor(() => (document.querySelector("#root")?.childElementCount ?? 0) > 0),
|
||||||
|
setupOk: document.body.innerText.includes("Where is your backend?"),
|
||||||
|
storageOk: localStorage.getItem("j621.smoke") === "ok",
|
||||||
|
historyOk: location.pathname === "/gallery",
|
||||||
|
opfsOk: typeof navigator.storage?.getDirectory === "function",
|
||||||
|
webcodecsOk: typeof VideoEncoder !== "undefined",
|
||||||
|
bridgeOk: window.j621Desktop?.isDesktop === true,
|
||||||
|
};
|
||||||
|
})()`, true)) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const expected: Record<string, unknown> = {
|
||||||
|
origin: APP_ORIGIN,
|
||||||
|
title: "J621",
|
||||||
|
assetOk: true,
|
||||||
|
routeOk: true,
|
||||||
|
mounted: true,
|
||||||
|
setupOk: true,
|
||||||
|
storageOk: true,
|
||||||
|
historyOk: true,
|
||||||
|
opfsOk: true,
|
||||||
|
webcodecsOk: true,
|
||||||
|
bridgeOk: true,
|
||||||
|
};
|
||||||
|
const failures = Object.entries(expected).filter(
|
||||||
|
([key, value]) => result[key] !== value,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"[smoke] electron",
|
||||||
|
process.versions.electron,
|
||||||
|
"chrome",
|
||||||
|
process.versions.chrome,
|
||||||
|
);
|
||||||
|
console.log("[smoke]", JSON.stringify(result));
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error(
|
||||||
|
"[smoke] FAILED:",
|
||||||
|
failures.map(([key]) => key).join(", "),
|
||||||
|
);
|
||||||
|
app.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("[smoke] OK");
|
||||||
|
app.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[smoke] FAILED:", error);
|
||||||
|
app.exit(1);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const gotLock = app.requestSingleInstanceLock();
|
||||||
|
if (!gotLock) {
|
||||||
|
app.quit();
|
||||||
|
} else {
|
||||||
|
app.on("second-instance", () => {
|
||||||
|
if (!mainWindow) return;
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
|
mainWindow.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.setAppUserModelId("io.j621.desktop");
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
protocol.handle(SCHEME, handleAppRequest);
|
||||||
|
|
||||||
|
ipcMain.handle("j621:version", () => app.getVersion());
|
||||||
|
ipcMain.handle("j621:open-external", (_event, url: unknown) => {
|
||||||
|
if (typeof url === "string") openExternal(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow = createWindow();
|
||||||
|
|
||||||
|
app.on("activate", () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
mainWindow = createWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("window-all-closed", () => {
|
||||||
|
if (process.platform !== "darwin") app.quit();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* The only bridge between the desktop shell and the SPA. Keep it tiny: the
|
||||||
|
* renderer is the normal web build and must not need Electron to work.
|
||||||
|
*/
|
||||||
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld("j621Desktop", {
|
||||||
|
isDesktop: true as const,
|
||||||
|
platform: process.platform,
|
||||||
|
// The SPA's own origin (`app://j621`), shown by the setup screen when it
|
||||||
|
// has to explain which origin to allow in the backend's CORS settings.
|
||||||
|
origin: window.location.origin,
|
||||||
|
getVersion: (): Promise<string> => ipcRenderer.invoke("j621:version"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Menu → "Open backend in browser": read the SPA's stored backend URL and
|
||||||
|
// hand it to the main process after checking it is a real web URL.
|
||||||
|
ipcRenderer.on("j621:open-backend", () => {
|
||||||
|
let url = "";
|
||||||
|
try {
|
||||||
|
url = window.localStorage.getItem("j621.backend") ?? "";
|
||||||
|
} catch {
|
||||||
|
// Storage can be unavailable in rare cases; nothing to open then.
|
||||||
|
}
|
||||||
|
if (url === "none") url = "";
|
||||||
|
if (url) void ipcRenderer.invoke("j621:open-external", url);
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "node16",
|
||||||
|
"moduleResolution": "node16",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"types": ["node"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -41,11 +41,17 @@ async function testBackend(base: string): Promise<TestResult> {
|
|||||||
text: `The backend answered HTTP ${response.status}.`,
|
text: `The backend answered HTTP ${response.status}.`,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
// The desktop shell is a different origin from the backend, and that is
|
||||||
|
// the usual reason the first connection test fails there.
|
||||||
|
const desktopHint = window.j621Desktop
|
||||||
|
? ` The desktop app's origin is ${window.j621Desktop.origin} — allow it in CORS_ALLOWED_ORIGINS on the backend.`
|
||||||
|
: "";
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
text:
|
text:
|
||||||
"Could not reach the backend. Check the URL, and if it lives on " +
|
"Could not reach the backend. Check the URL, and if it lives on " +
|
||||||
"another domain add this site to CORS_ALLOWED_ORIGINS there.",
|
"another domain add this site to CORS_ALLOWED_ORIGINS there." +
|
||||||
|
desktopHint,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
window.clearTimeout(timeout);
|
window.clearTimeout(timeout);
|
||||||
|
|||||||
Vendored
+16
@@ -1,2 +1,18 @@
|
|||||||
/** Baked at build time by Vite (see vite.config.ts). */
|
/** Baked at build time by Vite (see vite.config.ts). */
|
||||||
declare const __GIT_HASH__: string;
|
declare const __GIT_HASH__: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injected by the Electron shell (desktop/src/preload.ts) when it hosts the
|
||||||
|
* SPA. Absent in browsers.
|
||||||
|
*/
|
||||||
|
interface J621DesktopBridge {
|
||||||
|
readonly isDesktop: true;
|
||||||
|
readonly platform: string;
|
||||||
|
/** The shell's origin (e.g. `app://j621`), for backend CORS hints. */
|
||||||
|
readonly origin: string;
|
||||||
|
getVersion(): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
j621Desktop?: J621DesktopBridge;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user