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:
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user