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."}