e621's CDN answers cross-site image loads that carry no Referer with a 403 (Chromium sends none from a custom-scheme page, then blocks the response as ORB), so images never appeared in the desktop app. The main process now attaches an e621 referrer to requests for its hosts. The shell also answers /api, /admin, /static and /health with a 404 JSON instead of the SPA fallback — that fallback made the setup screen's empty-URL connection test report "Connected" against the shell itself. The setup screen is now desktop-aware (no same-origin option, no "Use this server", clearer copy), and the smoke test runs against a throwaway profile and covers both regressions.
604 lines
19 KiB
TypeScript
604 lines
19 KiB
TypeScript
/**
|
|
* 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<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" },
|
|
});
|
|
}
|
|
|
|
/** 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<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";
|
|
|
|
// 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<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());
|
|
}
|
|
|
|
/**
|
|
* 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 comes from `publish` in
|
|
* electron-builder.yml and can be overridden with J621_UPDATE_URL.
|
|
*/
|
|
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;
|
|
|
|
function setUpUpdates(win: BrowserWindow): void {
|
|
const override = process.env.J621_UPDATE_URL?.trim();
|
|
if (override) autoUpdater.setFeedURL({ provider: "generic", url: override });
|
|
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<void> {
|
|
if (updateCheckRunning) return;
|
|
updateCheckRunning = true;
|
|
try {
|
|
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("<svg"),
|
|
routeOk: route.ok && routeBody.includes('id="root"'),
|
|
// /health must not fall back to the SPA (false "connected" test).
|
|
healthOk: health.status === 404,
|
|
// Testing an empty backend URL on the desktop must say so instead
|
|
// of "Connected" against the shell's own origin.
|
|
emptyTestOk: await (async () => {
|
|
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<string, unknown>;
|
|
|
|
const expected: Record<string, unknown> = {
|
|
// 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<UpdateEvent>((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();
|
|
});
|
|
}
|