From c992a63b8f7804770a220eaaffc81192f1bee8f8 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Sun, 20 Sep 2026 21:08:56 -0500 Subject: [PATCH] Fix e621 images and the setup screen in the desktop shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- desktop/README.md | 5 ++ desktop/src/main.ts | 69 ++++++++++++++++++++++- frontend/src/features/setup/SetupPage.tsx | 37 +++++++++--- 3 files changed, 101 insertions(+), 10 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index 7aac79d..b589baf 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -20,6 +20,11 @@ src/preload.ts window.j621Desktop bridge electron-builder.yml deb + pacman + nsis packaging ``` +The main process attaches a `Referer` to requests for e621 hosts: Chromium +sends no referrer from a custom-scheme page, and e621's CDN answers +cross-site image loads without one with a 403 (which Chromium then blocks as +ORB). API calls work either way. + ## Development ```bash diff --git a/desktop/src/main.ts b/desktop/src/main.ts index a8bea2c..4c0a845 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -12,9 +12,9 @@ * 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, shell } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, Menu, protocol, session, shell } from "electron"; import { autoUpdater } from "electron-updater"; -import { readFileSync } from "node:fs"; +import { readFileSync, rmSync } from "node:fs"; import { readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; @@ -24,6 +24,15 @@ 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, @@ -83,6 +92,29 @@ async function fileResponse(file: string): Promise { }); } +/** 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 }); @@ -91,6 +123,21 @@ async function handleAppRequest(request: Request): Promise { 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 }); @@ -426,6 +473,7 @@ function runSmokeTest(win: BrowserWindow): void { 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 { @@ -433,6 +481,18 @@ function runSmokeTest(win: BrowserWindow): void { 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", @@ -452,6 +512,10 @@ function runSmokeTest(win: BrowserWindow): void { 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, @@ -517,6 +581,7 @@ if (!gotLock) { app.whenReady().then(() => { protocol.handle(SCHEME, handleAppRequest); + installRefererFix(session.defaultSession); ipcMain.handle("j621:version", () => app.getVersion()); ipcMain.handle("j621:open-external", (_event, url: unknown) => { diff --git a/frontend/src/features/setup/SetupPage.tsx b/frontend/src/features/setup/SetupPage.tsx index 9b7a518..c2c2dfe 100644 --- a/frontend/src/features/setup/SetupPage.tsx +++ b/frontend/src/features/setup/SetupPage.tsx @@ -17,6 +17,16 @@ import { setToken } from "@/lib/api"; type TestResult = { ok: boolean; text: string } | null; async function testBackend(base: string): Promise { + // The desktop shell has no server of its own: an empty URL is not a + // same-origin backend, and /health on app://j621 is answered by the shell. + if (!base && window.j621Desktop) { + return { + ok: false, + text: + "The desktop app has no backend of its own. Enter your J621 server's " + + "URL, or continue without a backend.", + }; + } const controller = new AbortController(); const timeout = window.setTimeout(() => controller.abort(), 5_000); try { @@ -110,10 +120,15 @@ export function SetupPage() {

- Enter the origin this app should call for its API. Leave it blank - when the app and the API are served from the same domain. Without a - backend the app runs in local mode: e621 browsing only, with - credentials kept in this browser. + {window.j621Desktop + ? "Enter the origin of your J621 server (for example " + + "https://j621.example.com). Without a backend the app runs in " + + "local mode: e621 browsing only, with credentials kept on this " + + "device." + : "Enter the origin this app should call for its API. Leave it " + + "blank when the app and the API are served from the same " + + "domain. Without a backend the app runs in local mode: e621 " + + "browsing only, with credentials kept in this browser."}