The updater now resolves the newest non-draft desktop-v* release through the Gitea API at check time (J621_UPDATE_REPO, lowercase because the API path is case-sensitive), picks the platform's latest*.yml asset and uses that release as a generic electron-updater feed; J621_UPDATE_URL still overrides everything. Verified against the live API: release picked, yml fetched, artifact HEAD 200. electron-builder's publish.url is now metadata only (still needed so the build emits latest*.yml). Docs updated: CD release assets are the feed, the website /desktop/ feed only matters for installs before 0.1.2.
682 lines
21 KiB
TypeScript
682 lines
21 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 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<string> {
|
|
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 <origin>/<owner>/<repo> (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<void> {
|
|
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("<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();
|
|
});
|
|
}
|