/** * 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, dialog, ipcMain, Menu, protocol, session, shell } from "electron"; import { autoUpdater } from "electron-updater"; import { readFileSync, rmSync } 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"); if (SMOKE) { // A throwaway profile keeps the checks deterministic (always the first-run // setup screen) and never touches real state or leaves a stale // single-instance lock behind. const smokeDir = path.join(app.getPath("temp"), "j621-smoke"); rmSync(smokeDir, { recursive: true, force: true }); app.setPath("userData", smokeDir); } protocol.registerSchemesAsPrivileged([ { scheme: SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true, stream: true, }, }, ]); const MIME_TYPES: Record = { ".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 { try { return (await stat(candidate)).isFile(); } catch { return false; } } async function fileResponse(file: string): Promise { 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" }, }); } /** Paths the nginx proxy sends to Django; there is no backend behind app://. */ const BACKEND_PREFIXES = ["/api/", "/admin/", "/static/", "/health"]; /** * e621's CDN refuses cross-site image loads that carry no Referer * (`Sec-Fetch-Site: cross-site` with an empty referrer returns 403), and * Chromium never sends one for pages on a custom scheme like app://j621. * Attach a normal e621 referrer to its hosts so images load; API calls are * unaffected. */ function installRefererFix(targetSession: Electron.Session): void { targetSession.webRequest.onBeforeSendHeaders( { urls: ["*://e621.net/*", "*://*.e621.net/*"] }, (details, callback) => { const headers = details.requestHeaders; if (!headers.Referer && !headers.referer) { headers.Referer = "https://e621.net/"; } callback({ requestHeaders: headers }); }, ); } async function handleAppRequest(request: Request): Promise { 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"; // Never answer backend paths with the SPA: that made the setup screen's // connection test "succeed" against the shell's own origin. if ( BACKEND_PREFIXES.some( (prefix) => pathname === prefix.replace(/\/$/, "") || pathname.startsWith(prefix), ) ) { return new Response( JSON.stringify({ detail: "No backend is attached to the desktop app; set one in /setup.", }), { status: 404, headers: { "content-type": "application/json" } }, ); } 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; 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`, ``), 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()); } /** * Updates are manual by design: Linux packages install through pacman/dpkg * (pkexec/sudo) and the Windows build is unsigned, so the app asks before * downloading and again before installing. The feed is resolved at check time * from the newest `desktop-v*` release on Gitea (`J621_UPDATE_REPO`); * `J621_UPDATE_URL` overrides it for forks and the smoke test. */ type UpdateEvent = | { state: "available"; version: string } | { state: "not-available" } | { state: "downloaded"; version: string } | { state: "error"; message: string }; let updateReporter: ((event: UpdateEvent) => void) | null = null; let updateCheckRunning = false; const UPDATE_REPO = process.env.J621_UPDATE_REPO?.trim() || "https://gitea.rainbow-herring.ts.net/jakebreath/j621"; interface ReleaseAsset { name: string; browser_download_url: string; } interface Release { tag_name: string; draft: boolean; prerelease: boolean; assets?: ReleaseAsset[]; } function releaseVersion(tag: string): [number, number, number] | null { const match = /^desktop-v(\d+)\.(\d+)\.(\d+)$/.exec(tag); if (!match) return null; return [Number(match[1]), Number(match[2]), Number(match[3])]; } function compareVersions( a: [number, number, number], b: [number, number, number], ): number { for (let index = 0; index < 3; index += 1) { if (a[index] !== b[index]) return a[index] - b[index]; } return 0; } /** * Resolve the generic feed base electron-updater should use. * * Gitea's API path is case-sensitive (owner/repo must match the login), while * the asset URLs it returns are canonical, so the base is derived from the * platform's metadata asset (`latest-linux.yml` / `latest.yml`). */ async function resolveReleaseFeed(): Promise { const override = process.env.J621_UPDATE_URL?.trim(); if (override) return override; const match = /^(https?:\/\/[^/]+)\/([^/]+)\/([^/]+?)\/?$/.exec(UPDATE_REPO); if (!match) { throw new Error( `J621_UPDATE_REPO must be // (got ${UPDATE_REPO}).`, ); } const [, origin, owner, repo] = match; const response = await fetch( `${origin}/api/v1/repos/${owner}/${repo}/releases?limit=50`, { headers: { Accept: "application/json" } }, ); if (!response.ok) { throw new Error(`Release lookup on Gitea failed (HTTP ${response.status}).`); } const releases = (await response.json()) as Release[]; const assetName = process.platform === "win32" ? "latest.yml" : "latest-linux.yml"; let best: { version: [number, number, number]; asset: ReleaseAsset } | null = null; for (const release of releases) { if (release.draft || release.prerelease) continue; const version = releaseVersion(release.tag_name); if (!version) continue; const asset = release.assets?.find((entry) => entry.name === assetName); if (!asset) continue; if (!best || compareVersions(version, best.version) > 0) { best = { version, asset }; } } if (!best) { throw new Error(`No ${assetName} asset found in ${UPDATE_REPO} releases.`); } return best.asset.browser_download_url.replace(/\/[^/]*$/, ""); } function setUpUpdates(win: BrowserWindow): void { if (!app.isPackaged) autoUpdater.forceDevUpdateConfig = true; autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = false; autoUpdater.on("error", (error) => { updateReporter?.({ state: "error", message: error.message }); }); autoUpdater.on("update-not-available", () => { if (SMOKE) return updateReporter?.({ state: "not-available" }); void dialog.showMessageBox(win, { type: "info", title: "Updates", message: `J621 ${app.getVersion()} is up to date.`, }); }); autoUpdater.on("update-available", (info) => { if (SMOKE) { return updateReporter?.({ state: "available", version: info.version }); } void (async () => { const { response } = await dialog.showMessageBox(win, { type: "info", title: "Updates", message: `J621 ${info.version} is available.`, detail: "Download it now? Installing it later needs administrator rights " + "on Linux (pacman/dpkg); the Windows installer runs without them.", buttons: ["Download", "Later"], defaultId: 0, cancelId: 1, }); if (response !== 0) return; try { await autoUpdater.downloadUpdate(); } catch (error) { await dialog.showMessageBox(win, { type: "error", title: "Update failed", message: "The update could not be downloaded.", detail: error instanceof Error ? error.message : String(error), }); } })(); }); autoUpdater.on("download-progress", (progress) => { win.setProgressBar(Math.min(progress.percent / 100, 1)); }); autoUpdater.on("update-downloaded", (info) => { win.setProgressBar(-1); if (SMOKE) { return updateReporter?.({ state: "downloaded", version: info.version }); } void (async () => { const { response } = await dialog.showMessageBox(win, { type: "info", title: "Updates", message: `J621 ${info.version} is ready to install.`, detail: process.platform === "linux" ? "Installing asks for administrator rights and then restarts J621." : "J621 will restart to finish installing.", buttons: ["Restart and install", "Later"], defaultId: 0, cancelId: 1, }); if (response === 0) { autoUpdater.quitAndInstall(false, true); } else { // Linux packages elevate, so never install silently on quit there. autoUpdater.autoInstallOnAppQuit = process.platform !== "linux"; } })(); }); } async function checkForUpdates(win: BrowserWindow): Promise { if (updateCheckRunning) return; updateCheckRunning = true; try { const feed = await resolveReleaseFeed(); autoUpdater.setFeedURL({ provider: "generic", url: feed }); await autoUpdater.checkForUpdates(); } catch (error) { const message = error instanceof Error ? error.message : String(error); updateReporter?.({ state: "error", message }); if (!SMOKE) { await dialog.showMessageBox(win, { type: "error", title: "Updates", message: "Could not check for updates.", detail: message, }); } } finally { updateCheckRunning = false; } } 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"), }, { label: "Check for updates…", click: () => void checkForUpdates(win), }, { 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); setUpUpdates(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(); const health = await fetch("/health"); localStorage.setItem("j621.smoke", "ok"); history.pushState({}, "", "/gallery"); return { origin: location.origin, title: document.title, assetOk: asset.ok && (await asset.text()).includes(" { const button = [...document.querySelectorAll("button")].find((b) => b.textContent.includes("Test connection"), ); if (!button) return false; button.click(); return waitFor(() => document.body.innerText.includes("no backend of its own")); })(), 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; const expected: Record = { // In dev the window hosts the Vite dev server, which has no /setup // screen (the SPA only asks for a backend in production builds). origin: DEV_SERVER || APP_ORIGIN, title: "J621", assetOk: true, routeOk: true, mounted: true, setupOk: !DEV_SERVER, // Both are app://-only checks: the Vite dev server serves the SPA // for /health and never shows the setup screen. healthOk: !DEV_SERVER, emptyTestOk: !DEV_SERVER, 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; } if (process.env.J621_UPDATE_URL) { const update = await new Promise((resolve) => { updateReporter = resolve; const timer = setTimeout( () => resolve({ state: "error", message: "update check timed out" }), 60_000, ); void checkForUpdates(win).finally(() => clearTimeout(timer)); }); updateReporter = null; console.log("[smoke] update:", JSON.stringify(update)); if (update.state === "error") { console.error("[smoke] FAILED: update check"); 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); installRefererFix(session.defaultSession); 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(); }); }