Fix e621 images and the setup screen in the desktop shell

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.
This commit is contained in:
2026-09-20 21:08:56 -05:00
parent bd2417aff8
commit c992a63b8f
3 changed files with 101 additions and 10 deletions
+5
View File
@@ -20,6 +20,11 @@ src/preload.ts window.j621Desktop bridge
electron-builder.yml deb + pacman + nsis packaging 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 ## Development
```bash ```bash
+67 -2
View File
@@ -12,9 +12,9 @@
* the same way it does in a browser. The only desktop-specific bits are the * the same way it does in a browser. The only desktop-specific bits are the
* origin, external-link handling and (later) updates. * 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 { autoUpdater } from "electron-updater";
import { readFileSync } from "node:fs"; import { readFileSync, rmSync } from "node:fs";
import { readFile, stat, writeFile } from "node:fs/promises"; import { readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
@@ -24,6 +24,15 @@ const APP_ORIGIN = `${SCHEME}://${HOST}`;
const DEV_SERVER = (process.env.J621_DEV_SERVER ?? "").replace(/\/+$/, ""); const DEV_SERVER = (process.env.J621_DEV_SERVER ?? "").replace(/\/+$/, "");
const SMOKE = process.argv.includes("--j621-smoke"); 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([ protocol.registerSchemesAsPrivileged([
{ {
scheme: SCHEME, scheme: SCHEME,
@@ -83,6 +92,29 @@ async function fileResponse(file: string): Promise<Response> {
}); });
} }
/** 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> { async function handleAppRequest(request: Request): Promise<Response> {
const url = new URL(request.url); const url = new URL(request.url);
if (url.host !== HOST) return new Response("Not found", { status: 404 }); if (url.host !== HOST) return new Response("Not found", { status: 404 });
@@ -91,6 +123,21 @@ async function handleAppRequest(request: Request): Promise<Response> {
let pathname = decodeURIComponent(url.pathname); let pathname = decodeURIComponent(url.pathname);
if (!pathname || pathname === "/") pathname = "/index.html"; 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); const target = path.resolve(root, "." + pathname);
if (target !== root && !target.startsWith(root + path.sep)) { if (target !== root && !target.startsWith(root + path.sep)) {
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
@@ -426,6 +473,7 @@ function runSmokeTest(win: BrowserWindow): void {
const asset = await fetch("/favicon.svg"); const asset = await fetch("/favicon.svg");
const route = await fetch("/gallery/some/deep/route"); const route = await fetch("/gallery/some/deep/route");
const routeBody = await route.text(); const routeBody = await route.text();
const health = await fetch("/health");
localStorage.setItem("j621.smoke", "ok"); localStorage.setItem("j621.smoke", "ok");
history.pushState({}, "", "/gallery"); history.pushState({}, "", "/gallery");
return { return {
@@ -433,6 +481,18 @@ function runSmokeTest(win: BrowserWindow): void {
title: document.title, title: document.title,
assetOk: asset.ok && (await asset.text()).includes("<svg"), assetOk: asset.ok && (await asset.text()).includes("<svg"),
routeOk: route.ok && routeBody.includes('id="root"'), 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), mounted: await waitFor(() => (document.querySelector("#root")?.childElementCount ?? 0) > 0),
setupOk: document.body.innerText.includes("Where is your backend?"), setupOk: document.body.innerText.includes("Where is your backend?"),
storageOk: localStorage.getItem("j621.smoke") === "ok", storageOk: localStorage.getItem("j621.smoke") === "ok",
@@ -452,6 +512,10 @@ function runSmokeTest(win: BrowserWindow): void {
routeOk: true, routeOk: true,
mounted: true, mounted: true,
setupOk: !DEV_SERVER, 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, storageOk: true,
historyOk: true, historyOk: true,
opfsOk: true, opfsOk: true,
@@ -517,6 +581,7 @@ if (!gotLock) {
app.whenReady().then(() => { app.whenReady().then(() => {
protocol.handle(SCHEME, handleAppRequest); protocol.handle(SCHEME, handleAppRequest);
installRefererFix(session.defaultSession);
ipcMain.handle("j621:version", () => app.getVersion()); ipcMain.handle("j621:version", () => app.getVersion());
ipcMain.handle("j621:open-external", (_event, url: unknown) => { ipcMain.handle("j621:open-external", (_event, url: unknown) => {
+29 -8
View File
@@ -17,6 +17,16 @@ import { setToken } from "@/lib/api";
type TestResult = { ok: boolean; text: string } | null; type TestResult = { ok: boolean; text: string } | null;
async function testBackend(base: string): Promise<TestResult> { async function testBackend(base: string): Promise<TestResult> {
// 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 controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 5_000); const timeout = window.setTimeout(() => controller.abort(), 5_000);
try { try {
@@ -110,10 +120,15 @@ export function SetupPage() {
</div> </div>
<p className="mt-3 text-sm leading-relaxed text-ctp-subtext0"> <p className="mt-3 text-sm leading-relaxed text-ctp-subtext0">
Enter the origin this app should call for its API. Leave it blank {window.j621Desktop
when the app and the API are served from the same domain. Without a ? "Enter the origin of your J621 server (for example " +
backend the app runs in local mode: e621 browsing only, with "https://j621.example.com). Without a backend the app runs in " +
credentials kept in this browser. "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."}
</p> </p>
<label className="mt-5 flex flex-col gap-1.5"> <label className="mt-5 flex flex-col gap-1.5">
@@ -122,7 +137,11 @@ export function SetupPage() {
</span> </span>
<input <input
className={cn(inputClass, "font-mono")} className={cn(inputClass, "font-mono")}
placeholder="https://j621.example.com — blank for this server" placeholder={
window.j621Desktop
? "https://j621.example.com"
: "https://j621.example.com — blank for this server"
}
value={value} value={value}
onChange={(event) => { onChange={(event) => {
setValue(event.target.value); setValue(event.target.value);
@@ -161,7 +180,7 @@ export function SetupPage() {
{testing ? <Spinner className="h-3.5 w-3.5" /> : null} {testing ? <Spinner className="h-3.5 w-3.5" /> : null}
{testing ? "Testing…" : "Test connection"} {testing ? "Testing…" : "Test connection"}
</Button> </Button>
{value.trim() ? ( {value.trim() && !window.j621Desktop ? (
<Button variant="ghost" onClick={() => save("")}> <Button variant="ghost" onClick={() => save("")}>
Use this server Use this server
</Button> </Button>
@@ -172,8 +191,10 @@ export function SetupPage() {
</div> </div>
<p className="mt-4 text-xs leading-relaxed text-ctp-overlay0"> <p className="mt-4 text-xs leading-relaxed text-ctp-overlay0">
Stored in this browser only. Changing the backend signs you out of {window.j621Desktop
the previous one. ? "Stored in this app only."
: "Stored in this browser only."}{" "}
Changing the backend signs you out of the previous one.
</p> </p>
</div> </div>
</div> </div>