Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
1363 lines
48 KiB
TypeScript
1363 lines
48 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { existsSync } from "node:fs";
|
|
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import type { DesktopReachability, DesktopSetup } from "@rakazo/contracts";
|
|
import { LOCAL_SETTINGS_PAGE } from "@rakazo/contracts/local-settings";
|
|
import {
|
|
app,
|
|
BrowserWindow,
|
|
dialog,
|
|
ipcMain,
|
|
Menu,
|
|
net,
|
|
type Session,
|
|
session,
|
|
shell,
|
|
} from "electron";
|
|
import {
|
|
DesktopUpdateController,
|
|
type ElectronAutoUpdater,
|
|
LAUNCH_CHECK_DELAY_MS,
|
|
} from "./auto-update.js";
|
|
import { openBrowserAuth } from "./browser-auth.js";
|
|
import { DOCKER_INSTALL_LINKS, isDesktopSetupLink, runDocker } from "./docker-cli.js";
|
|
import { requestLocalSettings } from "./local-settings.js";
|
|
import {
|
|
LocalStackController,
|
|
readStackToken,
|
|
readStackWebUrl,
|
|
resolveImageTag,
|
|
stackDir,
|
|
stackResourceDir,
|
|
} from "./local-stack.js";
|
|
import { oauthCallbackFrom } from "./oauth-callback.js";
|
|
import {
|
|
bundledRendererCandidates,
|
|
contentType,
|
|
forwardedRendererRequestInit,
|
|
immutableRendererAsset,
|
|
isRendererAssetMiss,
|
|
} from "./renderer-assets.js";
|
|
import { installSessionPermissions } from "./session-permissions.js";
|
|
import {
|
|
DEFAULT_LOCAL_WEB_URL,
|
|
desktopStackImageTag,
|
|
isRakazoHealth,
|
|
managedLocalOpenUrl,
|
|
maySendDesktopStackToken,
|
|
normalizeServerUrl,
|
|
parseSetupInput,
|
|
probeFailureMessage,
|
|
readProbeJson,
|
|
resolveStartupTarget,
|
|
safeExternalUrl,
|
|
servesBundledRenderer,
|
|
sessionPartitionForServerUrl,
|
|
} from "./setup-config.js";
|
|
import { clearSetup, readSetup, writeSetup } from "./setup-store.js";
|
|
import { shouldOpenInAppPopup } from "./window-open.js";
|
|
import {
|
|
browserWindowOptions,
|
|
developmentIconFile,
|
|
setupWindowOptions,
|
|
warmWindowTtlMs,
|
|
} from "./window-options.js";
|
|
|
|
const PERFORMANCE_USER_DATA = process.env.RAKAZO_PERFORMANCE_USER_DATA;
|
|
/** Test hook: where the app-managed stack answers. Mode `new` still requires loopback. */
|
|
const LOCAL_WEB_URL = process.env.RAKAZO_LOCAL_WEB_URL?.trim() || DEFAULT_LOCAL_WEB_URL;
|
|
const PROBE_TIMEOUT_MS = 8_000;
|
|
const DESKTOP_STACK_PROBE_PATH = "/.well-known/rakazo-desktop-stack";
|
|
const DESKTOP_STACK_TOKEN_HEADER = "x-rakazo-desktop-stack-token";
|
|
let mainWindow: BrowserWindow | null = null;
|
|
const appWindowTargets = new WeakMap<BrowserWindow, string>();
|
|
let setupWindow: BrowserWindow | null = null;
|
|
let settingsWindow: BrowserWindow | null = null;
|
|
let openingSettings = false;
|
|
let settingsCleanup: Promise<void> = Promise.resolve();
|
|
let settingsTarget: { origin: string; token: string } | null = null;
|
|
const bundledRendererInstallations = new Set<string>();
|
|
let currentSetup: DesktopSetup | null = null;
|
|
let currentTargetUrl: string | null = null;
|
|
let setupError: string | null = null;
|
|
let setupSaveInProgress = false;
|
|
let openAppPromise: Promise<boolean> | null = null;
|
|
/** Prior app window kept until setup is persisted (or the switch is abandoned). */
|
|
let pendingPreviousWindow: BrowserWindow | null = null;
|
|
let quitting = false;
|
|
let warmWindowTimer: NodeJS.Timeout | undefined;
|
|
// Number of short-lived hidden probe windows currently alive. On Windows/Linux,
|
|
// destroying the last window fires "window-all-closed" -> app.quit(); a probe
|
|
// that runs before the first real window exists must not count as "all closed".
|
|
let liveProbeWindows = 0;
|
|
const WARM_WINDOW_TTL_MS = warmWindowTtlMs(process.env.RAKAZO_WARM_WINDOW_TTL_MS);
|
|
|
|
const updaterEnvironment = {
|
|
packaged: app.isPackaged,
|
|
version: app.getVersion(),
|
|
disabled: process.env.RAKAZO_DISABLE_AUTO_UPDATE === "1",
|
|
};
|
|
const desktopUpdater = new DesktopUpdateController(
|
|
updaterEnvironment,
|
|
async () => {
|
|
const module = await import("electron-updater");
|
|
return (module.default ?? module).autoUpdater as unknown as ElectronAutoUpdater;
|
|
},
|
|
undefined,
|
|
() => {
|
|
quitting = false;
|
|
},
|
|
);
|
|
let launchUpdateCheckScheduled = false;
|
|
let localStack: LocalStackController;
|
|
|
|
markOnce("rk:main:module-evaluated");
|
|
if (PERFORMANCE_USER_DATA) {
|
|
app.setPath("userData", PERFORMANCE_USER_DATA);
|
|
app.setPath("sessionData", path.join(PERFORMANCE_USER_DATA, "session"));
|
|
}
|
|
app.once("will-finish-launching", () => markOnce("rk:main:will-finish-launching"));
|
|
app.once("ready", () => markOnce("rk:main:ready"));
|
|
// Includes fresh partitions and popup-created sessions, before they load remote content.
|
|
app.on("session-created", (value) => installSessionPermissions(value, permissionTarget));
|
|
|
|
function permissionTarget() {
|
|
if (mainWindow === null || mainWindow.isDestroyed()) return null;
|
|
const url = appWindowTargets.get(mainWindow);
|
|
return url === undefined ? null : { webContents: mainWindow.webContents, url };
|
|
}
|
|
|
|
function markOnce(name: string) {
|
|
if (performance.getEntriesByName(name).length === 0) performance.mark(name);
|
|
}
|
|
|
|
function windowFrom(event: Electron.IpcMainInvokeEvent) {
|
|
return BrowserWindow.fromWebContents(event.sender);
|
|
}
|
|
|
|
function fromMainWindow(event: Electron.IpcMainInvokeEvent) {
|
|
return mainWindow !== null && windowFrom(event) === mainWindow;
|
|
}
|
|
|
|
/** Dock/taskbar icon for unpackaged launches; packaged builds get theirs from electron-builder. */
|
|
function developmentIcon() {
|
|
if (app.isPackaged) return undefined;
|
|
const icon = path.join(app.getAppPath(), "assets", developmentIconFile(process.platform));
|
|
return existsSync(icon) ? icon : undefined;
|
|
}
|
|
|
|
function sessionPartitionKey(targetUrl: string) {
|
|
return sessionPartitionForServerUrl(targetUrl);
|
|
}
|
|
|
|
function legacyDefaultSessionFlag(partition: string) {
|
|
return path.join(
|
|
app.getPath("userData"),
|
|
`legacy-default-${partition.replace(/[^a-zA-Z0-9_-]/g, "_")}.flag`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Prefer the default session when that origin already has cookies or site
|
|
* storage there, so upgrades keep localStorage/IndexedDB. Fresh origins get
|
|
* an isolated partition.
|
|
*/
|
|
async function resolveSessionForTarget(targetUrl: string) {
|
|
const partition = sessionPartitionKey(targetUrl);
|
|
if (partition === null) {
|
|
return { partition: null, value: session.defaultSession };
|
|
}
|
|
if (existsSync(legacyDefaultSessionFlag(partition))) {
|
|
return { partition: null, value: session.defaultSession };
|
|
}
|
|
const origin = safeOrigin(targetUrl);
|
|
if (origin !== null) {
|
|
try {
|
|
const defaultCookies = await session.defaultSession.cookies.get({ url: origin });
|
|
if (defaultCookies.length > 0) {
|
|
await writeFile(legacyDefaultSessionFlag(partition), new Date().toISOString(), "utf8");
|
|
return { partition: null, value: session.defaultSession };
|
|
}
|
|
} catch {
|
|
// Continue with a storage probe when the profile looks pre-partition.
|
|
}
|
|
if (defaultSessionProfileExists() && (await defaultSessionHasOriginData(origin))) {
|
|
try {
|
|
await writeFile(legacyDefaultSessionFlag(partition), new Date().toISOString(), "utf8");
|
|
} catch {
|
|
// Flag is best-effort; still stay on the default session this launch.
|
|
}
|
|
return { partition: null, value: session.defaultSession };
|
|
}
|
|
}
|
|
return { partition, value: session.fromPartition(partition) };
|
|
}
|
|
|
|
function defaultSessionProfileExists() {
|
|
const root = app.getPath("userData");
|
|
return (
|
|
existsSync(path.join(root, "Local Storage")) ||
|
|
existsSync(path.join(root, "IndexedDB")) ||
|
|
existsSync(path.join(root, "Cookies")) ||
|
|
existsSync(path.join(root, "Network", "Cookies")) ||
|
|
existsSync(path.join(root, "Cache Storage"))
|
|
);
|
|
}
|
|
|
|
/** Page storage in the default session means a pre-partition install for this origin. */
|
|
async function defaultSessionHasOriginData(origin: string): Promise<boolean> {
|
|
const probe = new BrowserWindow({
|
|
show: false,
|
|
width: 1,
|
|
height: 1,
|
|
webPreferences: {
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
sandbox: true,
|
|
},
|
|
});
|
|
// Increment only after construction succeeds so a throw cannot leave the
|
|
// counter stuck > 0 and permanently block quit on Windows/Linux.
|
|
liveProbeWindows++;
|
|
try {
|
|
await probe.loadURL(origin);
|
|
return (await probe.webContents.executeJavaScript(`(async () => {
|
|
if (localStorage.length > 0 || sessionStorage.length > 0) return true;
|
|
if (typeof indexedDB !== "undefined" && indexedDB.databases) {
|
|
try {
|
|
const databases = await indexedDB.databases();
|
|
if (databases.length > 0) return true;
|
|
} catch {}
|
|
}
|
|
if (typeof caches !== "undefined") {
|
|
try {
|
|
const keys = await caches.keys();
|
|
if (keys.length > 0) return true;
|
|
} catch {}
|
|
}
|
|
return false;
|
|
})()`)) as boolean;
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
if (!probe.isDestroyed()) probe.destroy();
|
|
liveProbeWindows--;
|
|
}
|
|
}
|
|
|
|
function createWindow(url: string, partition: string | null) {
|
|
markOnce("rk:main:window-create-start");
|
|
const icon = developmentIcon();
|
|
const win = new BrowserWindow({
|
|
...browserWindowOptions(process.platform),
|
|
...(icon ? { icon } : {}),
|
|
webPreferences: {
|
|
preload: path.join(import.meta.dirname, "preload.cjs"),
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
sandbox: true,
|
|
...(partition === null ? {} : { partition }),
|
|
},
|
|
});
|
|
mainWindow = win;
|
|
appWindowTargets.set(win, url);
|
|
const targetOrigin = safeOrigin(url);
|
|
// Intentional OAuth flows open the provider's authorize page via a named
|
|
// window; give those and same-origin popups a normal frame. Everything else
|
|
// opens in the system browser so a connected server cannot navigate us away.
|
|
// Hoisted for loopback OAuth capture so MCP/in-app localhost callbacks are skipped.
|
|
const appOrigin = targetOrigin ?? safeOrigin(url);
|
|
win.webContents.setWindowOpenHandler(({ url: childUrl, frameName }) => {
|
|
if (shouldOpenInAppPopup(appOrigin, childUrl, frameName)) {
|
|
return {
|
|
action: "allow",
|
|
overrideBrowserWindowOptions: oauthPopupWindowOptions(),
|
|
};
|
|
}
|
|
const external = safeExternalUrl(childUrl);
|
|
if (external !== null) void shell.openExternal(external);
|
|
return { action: "deny" };
|
|
});
|
|
win.webContents.on("will-navigate", (event, navigationUrl) => {
|
|
if (targetOrigin !== null && safeOrigin(navigationUrl) === targetOrigin) return;
|
|
event.preventDefault();
|
|
const external = safeExternalUrl(navigationUrl);
|
|
if (external !== null) void shell.openExternal(external);
|
|
});
|
|
// The popup has no address bar, so a loopback redirect would otherwise strand
|
|
// the user on a blank window holding the authorization code in a URL they
|
|
// cannot read. Capture it here and hand it to the app instead.
|
|
win.webContents.on("did-create-window", (popup) => {
|
|
const capture = (details: {
|
|
preventDefault: () => void;
|
|
url: string;
|
|
isMainFrame?: boolean;
|
|
}) => {
|
|
// will-redirect can fire for iframes; only the top-level callback counts.
|
|
if (details.isMainFrame === false) return;
|
|
const callback = oauthCallbackFrom(details.url, {
|
|
excludeOrigins: appOrigin !== null ? [appOrigin] : [],
|
|
});
|
|
if (!callback) return;
|
|
details.preventDefault();
|
|
if (!win.isDestroyed()) win.webContents.send("desktop.oauth.callback", callback);
|
|
if (!popup.isDestroyed()) popup.close();
|
|
};
|
|
popup.webContents.on("will-redirect", (details) => capture(details));
|
|
popup.webContents.on("will-navigate", (details) => capture(details));
|
|
});
|
|
win.on("close", (event) => {
|
|
if (
|
|
process.platform === "darwin" &&
|
|
!quitting &&
|
|
process.env.RAKAZO_DISABLE_WARM_WINDOW !== "1"
|
|
) {
|
|
event.preventDefault();
|
|
win.hide();
|
|
clearTimeout(warmWindowTimer);
|
|
warmWindowTimer = setTimeout(() => {
|
|
if (mainWindow === win && !win.isDestroyed() && !win.isVisible()) win.destroy();
|
|
}, WARM_WINDOW_TTL_MS);
|
|
}
|
|
});
|
|
win.once("closed", () => {
|
|
clearTimeout(warmWindowTimer);
|
|
if (mainWindow === win) mainWindow = null;
|
|
});
|
|
markOnce("rk:main:window-created");
|
|
if (win.isVisible()) markOnce("rk:main:window-shown");
|
|
win.once("show", () => markOnce("rk:main:window-shown"));
|
|
win.once("ready-to-show", () => markOnce("rk:main:ready-to-show"));
|
|
win.webContents.once("dom-ready", () => markOnce("rk:main:dom-ready"));
|
|
win.webContents.once("did-finish-load", () => markOnce("rk:main:did-finish-load"));
|
|
win.webContents.once("did-stop-loading", () => markOnce("rk:main:did-stop-loading"));
|
|
markOnce("rk:main:load-url-start");
|
|
const loaded = loadAppUrl(win, url).then(
|
|
() => markOnce("rk:main:load-url-resolved"),
|
|
(error: unknown) => {
|
|
markOnce("rk:main:load-url-rejected");
|
|
throw error;
|
|
},
|
|
);
|
|
if (!launchUpdateCheckScheduled) {
|
|
launchUpdateCheckScheduled = true;
|
|
setTimeout(() => void desktopUpdater.check(false), LAUNCH_CHECK_DELAY_MS).unref();
|
|
setInterval(() => void desktopUpdater.check(false), 60 * 60 * 1_000).unref();
|
|
}
|
|
return { loaded, win };
|
|
}
|
|
|
|
async function probeDocument(url: string): Promise<string | null> {
|
|
// Test/dev harnesses may load data: or file: documents; only probe real servers.
|
|
if (!url.startsWith("http://") && !url.startsWith("https://")) return null;
|
|
try {
|
|
const response = await net.fetch(url, {
|
|
method: "GET",
|
|
redirect: "manual",
|
|
cache: "no-store",
|
|
credentials: "omit",
|
|
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
});
|
|
// Allow 3xx (e.g. / → /login); reject hard HTTP errors before opening a window.
|
|
if (response.status >= 400) {
|
|
return `The server answered with HTTP ${response.status}.`;
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
return probeFailureMessage(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* loadURL alone treats many HTTP error documents as success. Reject main-frame
|
|
* failures, renderer crashes during load, HTTP 4xx/5xx main-frame responses, and
|
|
* shells that never mount application content.
|
|
*/
|
|
function loadAppUrl(win: BrowserWindow, url: string): Promise<void> {
|
|
const contents = win.webContents;
|
|
const targetSession = contents.session;
|
|
let mainStatus: number | undefined;
|
|
|
|
targetSession.webRequest.onCompleted({ urls: ["*://*/*"] }, (details) => {
|
|
if (details.webContentsId === contents.id && details.resourceType === "mainFrame") {
|
|
mainStatus = details.statusCode;
|
|
}
|
|
});
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
let settled = false;
|
|
const settle = (error?: Error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
contents.removeListener("did-fail-load", onFail);
|
|
// Keep render-process-gone until document readiness finishes so a crash
|
|
// during mount still fails the switch.
|
|
targetSession.webRequest.onCompleted(null);
|
|
if (error) {
|
|
contents.removeListener("render-process-gone", onGone);
|
|
reject(error);
|
|
return;
|
|
}
|
|
if (mainStatus !== undefined && mainStatus >= 400) {
|
|
contents.removeListener("render-process-gone", onGone);
|
|
reject(new Error(`The server answered with HTTP ${mainStatus}.`));
|
|
return;
|
|
}
|
|
void waitForMountedAppDocument(contents)
|
|
.then(() => {
|
|
contents.removeListener("render-process-gone", onGone);
|
|
if (contents.isCrashed()) {
|
|
reject(new Error("Renderer stopped after load."));
|
|
return;
|
|
}
|
|
resolve();
|
|
})
|
|
.catch((inspectError: unknown) => {
|
|
contents.removeListener("render-process-gone", onGone);
|
|
reject(inspectError instanceof Error ? inspectError : new Error(String(inspectError)));
|
|
});
|
|
};
|
|
|
|
const onFail = (
|
|
_event: Electron.Event,
|
|
_errorCode: number,
|
|
errorDescription: string,
|
|
_validatedURL: string,
|
|
isMainFrame: boolean,
|
|
) => {
|
|
if (isMainFrame) settle(new Error(errorDescription || "Page failed to load."));
|
|
};
|
|
const onGone = (_event: Electron.Event, details: Electron.RenderProcessGoneDetails) => {
|
|
settle(new Error(`Renderer stopped (${details.reason}).`));
|
|
};
|
|
|
|
contents.on("did-fail-load", onFail);
|
|
contents.on("render-process-gone", onGone);
|
|
void contents.loadURL(url).then(
|
|
async () => {
|
|
// onCompleted can lag loadURL; wait briefly for the main-frame status.
|
|
const deadline = Date.now() + 500;
|
|
while (mainStatus === undefined && Date.now() < deadline) {
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
}
|
|
settle();
|
|
},
|
|
(error: unknown) => {
|
|
settle(error instanceof Error ? error : new Error(String(error)));
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Empty `#root` shells and session-pending skeletons count as loaded HTML but are
|
|
* not a usable app. After session resolves, wait for a bootstrapped shell
|
|
* (`data-ready` / shell-ready mark) or an auth/welcome/onboarding surface so a
|
|
* bare Suspense fallback or pre-bootstrap ShellPage cannot pass. Plain e2e
|
|
* fixtures omit the Rakazo app-state marker.
|
|
*/
|
|
async function waitForMountedAppDocument(contents: Electron.WebContents) {
|
|
const deadline = Date.now() + 8_000;
|
|
while (Date.now() < deadline) {
|
|
if (contents.isCrashed()) throw new Error("Renderer stopped after load.");
|
|
const ready = (await contents.executeJavaScript(`(() => {
|
|
const appState =
|
|
document.querySelector("[data-rakazo-app-state]")?.getAttribute("data-rakazo-app-state") ??
|
|
null;
|
|
if (appState === "session-pending") return false;
|
|
|
|
const shell = document.querySelector('[data-testid="shell-root"]');
|
|
const shellBootstrapped = Boolean(
|
|
(shell && shell.getAttribute("data-ready") === "true") ||
|
|
performance.getEntriesByName("rk:renderer:shell-ready").length > 0,
|
|
);
|
|
const authOrWelcomeSurface = Boolean(
|
|
document.querySelector('[data-rakazo-surface="welcome"]') ||
|
|
document.querySelector(
|
|
'form input[type="email"], form input[name="email"], form input#email',
|
|
) ||
|
|
Array.from(document.querySelectorAll("button")).some((button) =>
|
|
/sign\\s*in/i.test((button.textContent || "").trim()),
|
|
) ||
|
|
document.querySelector(
|
|
'[aria-label="Model"], [aria-label="Model id"], [aria-label="Models from server"]',
|
|
),
|
|
);
|
|
const surfaceReady = shellBootstrapped || authOrWelcomeSurface;
|
|
const sessionReady =
|
|
appState === "ready" ||
|
|
performance.getEntriesByName("rk:renderer:session-committed").length > 0;
|
|
if (sessionReady && surfaceReady) return true;
|
|
|
|
// Desktop e2e fixtures mount a plain page without Rakazo app-state markers.
|
|
if (appState === null) {
|
|
const bodyText = (document.body?.innerText || "").trim();
|
|
if (bodyText.includes("Opening your Space")) return false;
|
|
if (bodyText === "Loading…" || bodyText === "Loading...") return false;
|
|
const mainText = (document.querySelector("main")?.textContent || "").trim();
|
|
const rootChildren = document.getElementById("root")?.childElementCount ?? 0;
|
|
return mainText.length > 0 || rootChildren > 0;
|
|
}
|
|
return false;
|
|
})()`)) as boolean;
|
|
if (ready) return;
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
}
|
|
throw new Error("The server page did not become ready.");
|
|
}
|
|
|
|
async function installBundledRenderer(
|
|
targetUrl: string,
|
|
targetSession: Session,
|
|
partition: string | null,
|
|
) {
|
|
if (!app.isPackaged || process.env.RAKAZO_DISABLE_BUNDLED_RENDERER === "1") return;
|
|
if (!servesBundledRenderer(targetUrl)) return;
|
|
const webUrl = new URL(targetUrl);
|
|
const installationKey = `${partition ?? "default"}:${webUrl.protocol}`;
|
|
if (bundledRendererInstallations.has(installationKey)) return;
|
|
const root = path.join(process.resourcesPath, "web");
|
|
|
|
await targetSession.protocol.handle(webUrl.protocol.slice(0, -1), async (request) => {
|
|
const forward = () => {
|
|
return targetSession.fetch(request, forwardedRendererRequestInit(request, webUrl.origin));
|
|
};
|
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
return forward();
|
|
}
|
|
const acceptsHtml = request.headers.get("accept")?.includes("text/html") ?? false;
|
|
const candidates = bundledRendererCandidates(root, request.url, webUrl.origin, acceptsHtml);
|
|
if (!candidates) return forward();
|
|
for (const file of candidates) {
|
|
let body: Buffer | null = null;
|
|
try {
|
|
if (request.method === "HEAD") {
|
|
if (!(await stat(file)).isFile()) continue;
|
|
} else {
|
|
body = await readFile(file);
|
|
}
|
|
} catch (error) {
|
|
if (isRendererAssetMiss(error)) continue;
|
|
throw error;
|
|
}
|
|
const headers = new Headers({
|
|
"cache-control": immutableRendererAsset(file)
|
|
? "public, max-age=31536000, immutable"
|
|
: "no-cache",
|
|
"content-type": contentType(file),
|
|
"x-content-type-options": "nosniff",
|
|
});
|
|
return new Response(body, { headers });
|
|
}
|
|
return forward();
|
|
});
|
|
bundledRendererInstallations.add(installationKey);
|
|
markOnce("rk:main:bundled-renderer-ready");
|
|
}
|
|
|
|
function oauthPopupWindowOptions() {
|
|
return {
|
|
width: 560,
|
|
height: 720,
|
|
frame: true,
|
|
titleBarStyle: "default" as const,
|
|
autoHideMenuBar: true,
|
|
backgroundColor: "#0B0C0E",
|
|
webPreferences: {
|
|
preload: "",
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
sandbox: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
function createSetupWindow() {
|
|
const icon = developmentIcon();
|
|
const win = new BrowserWindow({
|
|
...setupWindowOptions(process.platform),
|
|
...(icon ? { icon } : {}),
|
|
webPreferences: {
|
|
preload: path.join(import.meta.dirname, "setup-preload.cjs"),
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
sandbox: true,
|
|
// A long pull runs while this window sits behind others; throttled timers would freeze it.
|
|
backgroundThrottling: false,
|
|
},
|
|
});
|
|
setupWindow = win;
|
|
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
|
win.once("closed", () => {
|
|
if (setupWindow === win) setupWindow = null;
|
|
// Closing setup without saving restores a connected session (Change Server cancel).
|
|
restoreAppWindowAfterSetup();
|
|
});
|
|
void win.loadFile(path.join(import.meta.dirname, "setup.html"));
|
|
markOnce("rk:main:setup-window-created");
|
|
return win;
|
|
}
|
|
|
|
/** Hide the app while setup is open; do not clear the saved target until a new one opens. */
|
|
function showSetupWindow(error: string | null = null) {
|
|
setupError = error;
|
|
|
|
let win: BrowserWindow;
|
|
if (setupWindow !== null && !setupWindow.isDestroyed()) {
|
|
if (error !== null) setupWindow.reload();
|
|
win = setupWindow;
|
|
} else {
|
|
win = createSetupWindow();
|
|
}
|
|
if (mainWindow !== null && !mainWindow.isDestroyed()) mainWindow.hide();
|
|
win.show();
|
|
win.focus();
|
|
return win;
|
|
}
|
|
|
|
function restoreAppWindowAfterSetup() {
|
|
if (quitting) return;
|
|
if (setupWindow !== null && !setupWindow.isDestroyed()) return;
|
|
if (mainWindow === null || mainWindow.isDestroyed() || currentTargetUrl === null) return;
|
|
clearTimeout(warmWindowTimer);
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
|
|
async function showLocalSettings() {
|
|
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
|
settingsWindow.show();
|
|
settingsWindow.focus();
|
|
return;
|
|
}
|
|
if (openingSettings) return;
|
|
openingSettings = true;
|
|
try {
|
|
const url = localStack.webUrl();
|
|
const token = await readStackToken(stackDir(app.getPath("userData")));
|
|
if (!token || !(await localStack.matchesDesiredStack(url))) {
|
|
await dialog.showMessageBox({
|
|
message: "Start the local server before opening its settings.",
|
|
type: "info",
|
|
});
|
|
return;
|
|
}
|
|
await settingsCleanup;
|
|
const partition = "local-server-settings";
|
|
const targetSession = session.fromPartition(partition);
|
|
installSessionPermissions(targetSession, () => null);
|
|
await installBundledRenderer(url, targetSession, partition);
|
|
const win = new BrowserWindow({
|
|
...browserWindowOptions(process.platform),
|
|
title: "Local Server Settings",
|
|
frame: true,
|
|
titleBarStyle: "default",
|
|
trafficLightPosition: undefined,
|
|
webPreferences: {
|
|
preload: path.join(import.meta.dirname, "preload.cjs"),
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
sandbox: true,
|
|
partition,
|
|
},
|
|
});
|
|
settingsWindow = win;
|
|
const origin = new URL(url).origin;
|
|
settingsTarget = { origin, token };
|
|
win.webContents.setWindowOpenHandler(({ url: externalUrl }) => {
|
|
const external = safeExternalUrl(externalUrl);
|
|
if (external) void shell.openExternal(external);
|
|
return { action: "deny" };
|
|
});
|
|
const preventNavigation = (event: Electron.Event, target: string) => {
|
|
if (target === `${origin}${LOCAL_SETTINGS_PAGE}`) return;
|
|
event.preventDefault();
|
|
};
|
|
win.webContents.on("will-navigate", preventNavigation);
|
|
win.webContents.on("will-redirect", preventNavigation);
|
|
win.once("closed", () => {
|
|
if (settingsWindow === win) {
|
|
settingsWindow = null;
|
|
settingsTarget = null;
|
|
}
|
|
const protocol = new URL(url).protocol;
|
|
if (bundledRendererInstallations.delete(`${partition}:${protocol}`)) {
|
|
targetSession.protocol.unhandle(protocol.slice(0, -1));
|
|
}
|
|
settingsCleanup = targetSession.clearStorageData().catch(() => undefined);
|
|
});
|
|
await win.loadURL(`${origin}${LOCAL_SETTINGS_PAGE}`);
|
|
} catch {
|
|
settingsWindow?.close();
|
|
await dialog.showMessageBox({
|
|
message: "Could not open local server settings. Try again.",
|
|
type: "error",
|
|
});
|
|
} finally {
|
|
openingSettings = false;
|
|
}
|
|
}
|
|
|
|
function installApplicationMenu() {
|
|
const localSettings: Electron.MenuItemConstructorOptions = {
|
|
id: "local-server-settings",
|
|
label: "Local Server Settings…",
|
|
accelerator: "CmdOrCtrl+,",
|
|
click: () => {
|
|
void showLocalSettings();
|
|
},
|
|
};
|
|
const changeServer: Electron.MenuItemConstructorOptions = {
|
|
id: "change-rakazo-server",
|
|
label: "Change Rakazo Server…",
|
|
accelerator: "CmdOrCtrl+Shift+K",
|
|
click: () => showSetupWindow(),
|
|
};
|
|
const stopStack: Electron.MenuItemConstructorOptions = {
|
|
id: "stop-local-stack",
|
|
label: "Stop Local Stack",
|
|
// The stack keeps running after quit (bots are always on); this is the explicit off switch.
|
|
click: () => {
|
|
if (currentSetup?.mode === "new") void localStack.stop();
|
|
},
|
|
};
|
|
const template: Electron.MenuItemConstructorOptions[] =
|
|
process.platform === "darwin"
|
|
? [
|
|
{
|
|
label: app.name,
|
|
submenu: [
|
|
{ role: "about" },
|
|
{ type: "separator" },
|
|
localSettings,
|
|
changeServer,
|
|
stopStack,
|
|
{ type: "separator" },
|
|
{ role: "hide" },
|
|
{ role: "hideOthers" },
|
|
{ role: "unhide" },
|
|
{ type: "separator" },
|
|
{ role: "quit" },
|
|
],
|
|
},
|
|
{ role: "editMenu" },
|
|
{ role: "windowMenu" },
|
|
]
|
|
: [
|
|
{
|
|
label: "File",
|
|
submenu: [
|
|
localSettings,
|
|
changeServer,
|
|
stopStack,
|
|
{ type: "separator" },
|
|
{ role: "quit" },
|
|
],
|
|
},
|
|
{ role: "editMenu" },
|
|
{ role: "windowMenu" },
|
|
];
|
|
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
|
}
|
|
|
|
/** Setup IPC must only answer the setup window, never a connected Rakazo server. */
|
|
function fromSetupWindow(event: Electron.IpcMainInvokeEvent) {
|
|
return (
|
|
setupWindow !== null && !setupWindow.isDestroyed() && event.sender === setupWindow.webContents
|
|
);
|
|
}
|
|
|
|
async function probeServer(rawUrl: string, signal?: AbortSignal): Promise<DesktopReachability> {
|
|
const url = normalizeServerUrl(rawUrl);
|
|
if (url === null) return { ok: false, error: "Enter a valid http:// or https:// address." };
|
|
const timeout = AbortSignal.timeout(PROBE_TIMEOUT_MS);
|
|
|
|
try {
|
|
const response = await net.fetch(`${url}/rpc/health`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ json: {} }),
|
|
cache: "no-store",
|
|
credentials: "omit",
|
|
redirect: "manual",
|
|
signal: signal ? AbortSignal.any([timeout, signal]) : timeout,
|
|
});
|
|
if (response.status >= 300 && response.status < 400) {
|
|
return {
|
|
ok: false,
|
|
status: response.status,
|
|
url,
|
|
error: "That address redirects elsewhere. Enter the final Rakazo server address.",
|
|
};
|
|
}
|
|
if (!response.ok) {
|
|
return {
|
|
ok: false,
|
|
status: response.status,
|
|
url,
|
|
error: `The server answered with HTTP ${response.status}.`,
|
|
};
|
|
}
|
|
const health = await readProbeJson(response);
|
|
if (!isRakazoHealth(health)) {
|
|
return {
|
|
ok: false,
|
|
status: response.status,
|
|
url,
|
|
error: "That address did not respond like a Rakazo server.",
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
status: response.status,
|
|
url,
|
|
};
|
|
} catch (error) {
|
|
return { ok: false, url, error: probeFailureMessage(error) };
|
|
}
|
|
}
|
|
|
|
/** A public health response is not enough: another checkout may own the same fixed port. */
|
|
async function probeManagedStack(
|
|
rawUrl: string,
|
|
token: string,
|
|
signal?: AbortSignal,
|
|
): Promise<string | null> {
|
|
const url = normalizeServerUrl(rawUrl);
|
|
// Never put the private stack token on a cleartext LAN or .local hop.
|
|
if (url === null || !maySendDesktopStackToken(url)) return null;
|
|
const timeout = AbortSignal.timeout(PROBE_TIMEOUT_MS);
|
|
try {
|
|
const response = await net.fetch(`${url}${DESKTOP_STACK_PROBE_PATH}`, {
|
|
method: "GET",
|
|
headers: { [DESKTOP_STACK_TOKEN_HEADER]: token },
|
|
cache: "no-store",
|
|
credentials: "omit",
|
|
redirect: "manual",
|
|
signal: signal ? AbortSignal.any([timeout, signal]) : timeout,
|
|
});
|
|
if (!response.ok) return null;
|
|
return desktopStackImageTag(await readProbeJson(response));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function openApp(targetUrl: string) {
|
|
if (openAppPromise !== null) return openAppPromise;
|
|
openAppPromise = openAppOnce(targetUrl).finally(() => {
|
|
openAppPromise = null;
|
|
});
|
|
return openAppPromise;
|
|
}
|
|
|
|
function openFailureDetail(error: unknown): string {
|
|
if (error instanceof Error) {
|
|
const { message } = error;
|
|
if (
|
|
message.startsWith("The server answered with HTTP") ||
|
|
message.startsWith("The server page loaded empty") ||
|
|
message.startsWith("The server page did not become ready") ||
|
|
message.startsWith("Renderer stopped") ||
|
|
message.startsWith("Page failed to load")
|
|
) {
|
|
return message;
|
|
}
|
|
}
|
|
return probeFailureMessage(error);
|
|
}
|
|
|
|
async function openAppOnce(targetUrl: string) {
|
|
const target = await resolveSessionForTarget(targetUrl);
|
|
const previous = mainWindow;
|
|
let win: BrowserWindow | null = null;
|
|
try {
|
|
const documentError = await probeDocument(targetUrl);
|
|
if (documentError !== null) {
|
|
throw new Error(documentError);
|
|
}
|
|
await installBundledRenderer(targetUrl, target.value, target.partition);
|
|
const created = createWindow(targetUrl, target.partition);
|
|
win = created.win;
|
|
await created.loaded;
|
|
currentTargetUrl = targetUrl;
|
|
setupError = null;
|
|
// Keep the previous window until the caller commits (after setup.json is written).
|
|
pendingPreviousWindow =
|
|
previous !== null && !previous.isDestroyed() && previous !== win ? previous : null;
|
|
if (pendingPreviousWindow !== null) pendingPreviousWindow.hide();
|
|
return true;
|
|
} catch (error) {
|
|
pendingPreviousWindow = null;
|
|
// Keep the previous app window so Cancel / close can restore it.
|
|
if (previous !== null && !previous.isDestroyed()) mainWindow = previous;
|
|
// Show the setup window BEFORE destroying the failed one: on Windows/Linux,
|
|
// destroying the last window fires "window-all-closed" -> app.quit() before
|
|
// showSetupWindow() runs, so the app silently exits instead of showing this error.
|
|
showSetupWindow(`Could not open that server. ${openFailureDetail(error)}`);
|
|
if (win !== null && !win.isDestroyed()) win.destroy();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Drop the previous app window after the new server is opened and persisted. */
|
|
function commitPendingAppSwitch() {
|
|
const previous = pendingPreviousWindow;
|
|
pendingPreviousWindow = null;
|
|
if (previous !== null && !previous.isDestroyed() && previous !== mainWindow) previous.destroy();
|
|
}
|
|
|
|
/**
|
|
* Undo an open that could not be persisted. When a prior session exists, restore
|
|
* it. On first run keep the connected window so the user can retry save.
|
|
*/
|
|
function abandonPendingAppSwitch(
|
|
previousSetup: DesktopSetup | null,
|
|
previousUrl: string | null,
|
|
): "restored" | "kept" {
|
|
const previous = pendingPreviousWindow;
|
|
pendingPreviousWindow = null;
|
|
if (previous !== null && !previous.isDestroyed()) {
|
|
const failed = mainWindow;
|
|
if (failed !== null && !failed.isDestroyed() && failed !== previous) failed.destroy();
|
|
mainWindow = previous;
|
|
currentSetup = previousSetup;
|
|
currentTargetUrl = previousUrl;
|
|
// If setup was already closed (e.g. during a slow write), make the restored
|
|
// session visible — otherwise macOS can be left with no shown window.
|
|
if (setupWindow === null || setupWindow.isDestroyed()) {
|
|
clearTimeout(warmWindowTimer);
|
|
previous.show();
|
|
previous.focus();
|
|
}
|
|
return "restored";
|
|
}
|
|
return "kept";
|
|
}
|
|
|
|
/** Watch for a renderer crash until setup is persisted (or the switch is abandoned). */
|
|
function watchRendererUntilCommitted(win: BrowserWindow) {
|
|
let crashed = false;
|
|
const onGone = () => {
|
|
crashed = true;
|
|
};
|
|
win.webContents.once("render-process-gone", onGone);
|
|
return {
|
|
crashed: () => crashed || (!win.isDestroyed() && win.webContents.isCrashed()),
|
|
dispose: () => {
|
|
if (!win.isDestroyed()) win.webContents.removeListener("render-process-gone", onGone);
|
|
},
|
|
};
|
|
}
|
|
|
|
function destroySetupWindow() {
|
|
const setup = setupWindow;
|
|
setupWindow = null;
|
|
if (setup !== null && !setup.isDestroyed()) setup.destroy();
|
|
}
|
|
|
|
/** Best-effort restore of setup.json after a failed save that already wrote disk. */
|
|
async function rollbackSetupFile(userDataDir: string, previousSetup: DesktopSetup | null) {
|
|
try {
|
|
if (previousSetup !== null) await writeSetup(userDataDir, previousSetup);
|
|
else await clearSetup(userDataDir);
|
|
} catch {
|
|
// Disk rollback is best-effort; callers already restored in-memory state when possible.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* After a renderer crash around save: restore prior setup when possible, otherwise
|
|
* drop the crashed first-run window so setup remains the only recovery surface.
|
|
*/
|
|
async function recoverFromCrashedSave(
|
|
userDataDir: string,
|
|
previousSetup: DesktopSetup | null,
|
|
previousUrl: string | null,
|
|
): Promise<string> {
|
|
const outcome = abandonPendingAppSwitch(previousSetup, previousUrl);
|
|
await rollbackSetupFile(userDataDir, previousSetup);
|
|
if (outcome === "kept") {
|
|
if (mainWindow !== null && !mainWindow.isDestroyed()) mainWindow.destroy();
|
|
mainWindow = null;
|
|
currentSetup = previousSetup;
|
|
currentTargetUrl = previousUrl;
|
|
}
|
|
const message =
|
|
previousSetup !== null
|
|
? "Could not open that server. Renderer stopped. The previous instance was restored for the next launch."
|
|
: "Could not open that server. Renderer stopped.";
|
|
showSetupWindow(message);
|
|
return message;
|
|
}
|
|
|
|
function safeOrigin(targetUrl: string) {
|
|
try {
|
|
return new URL(targetUrl).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
app.whenReady().then(async () => {
|
|
installSessionPermissions(session.defaultSession, permissionTarget);
|
|
const userDataDir = app.getPath("userData");
|
|
localStack = new LocalStackController({
|
|
platform: process.platform,
|
|
env: process.env,
|
|
exists: existsSync,
|
|
run: runDocker,
|
|
stackDir: stackDir(userDataDir),
|
|
resourceDir: stackResourceDir({
|
|
packaged: app.isPackaged,
|
|
resourcesPath: process.resourcesPath,
|
|
appPath: app.getAppPath(),
|
|
}),
|
|
localWebUrl:
|
|
process.env.RAKAZO_LOCAL_WEB_URL?.trim() ||
|
|
(await readStackWebUrl(stackDir(userDataDir), LOCAL_WEB_URL)),
|
|
imageTag: resolveImageTag({
|
|
version: app.getVersion(),
|
|
packaged: app.isPackaged,
|
|
override: process.env.RAKAZO_IMAGE_TAG,
|
|
}),
|
|
probe: (url, signal, token) => probeManagedStack(url, token, signal),
|
|
randomHex: (bytes) => randomBytes(bytes).toString("hex"),
|
|
onState: (state) => {
|
|
if (setupWindow !== null && !setupWindow.isDestroyed()) {
|
|
setupWindow.webContents.send("desktop.setup.stack.changed", state);
|
|
}
|
|
},
|
|
});
|
|
currentSetup = await readSetup(userDataDir);
|
|
const target = resolveStartupTarget({
|
|
envUrl: process.env.RAKAZO_WEB_URL,
|
|
saved: currentSetup,
|
|
forceSetup: process.env.RAKAZO_FORCE_SETUP === "1",
|
|
});
|
|
if (process.env.RAKAZO_PERFORMANCE_CLEAR_CACHE === "1") {
|
|
const cacheSessions = new Set<Session>([session.defaultSession]);
|
|
if (target.kind === "app") {
|
|
cacheSessions.add((await resolveSessionForTarget(target.url)).value);
|
|
}
|
|
await Promise.all(
|
|
[...cacheSessions].flatMap((value) => [value.clearCache(), value.clearCodeCaches({})]),
|
|
);
|
|
markOnce("rk:main:caches-cleared");
|
|
}
|
|
|
|
const icon = developmentIcon();
|
|
if (process.platform === "darwin" && icon) app.dock?.setIcon(icon);
|
|
installApplicationMenu();
|
|
const browserAuthAttempts = new Map<string, AbortController>();
|
|
const cancelBrowserAuth = () => {
|
|
for (const attempt of browserAuthAttempts.values()) attempt.abort();
|
|
browserAuthAttempts.clear();
|
|
};
|
|
app.on("before-quit", cancelBrowserAuth);
|
|
ipcMain.handle("desktop.oauth.open", async (event, url: unknown) => {
|
|
if (
|
|
(!fromMainWindow(event) &&
|
|
!(settingsWindow !== null && windowFrom(event) === settingsWindow)) ||
|
|
event.senderFrame !== event.sender.mainFrame ||
|
|
typeof url !== "string" ||
|
|
url.length > 16_384
|
|
)
|
|
throw new Error("Invalid sign-in request.");
|
|
if (browserAuthAttempts.has(url) || browserAuthAttempts.size >= 8) {
|
|
throw new Error("A sign-in attempt is already active. Cancel it and retry.");
|
|
}
|
|
const controller = new AbortController();
|
|
browserAuthAttempts.set(url, controller);
|
|
const stop = () => controller.abort();
|
|
const expiry = setTimeout(stop, 10 * 60_000);
|
|
expiry.unref();
|
|
event.sender.once("destroyed", stop);
|
|
event.sender.once("did-navigate", stop);
|
|
controller.signal.addEventListener(
|
|
"abort",
|
|
() => {
|
|
clearTimeout(expiry);
|
|
event.sender.removeListener("destroyed", stop);
|
|
event.sender.removeListener("did-navigate", stop);
|
|
if (browserAuthAttempts.get(url) === controller) browserAuthAttempts.delete(url);
|
|
},
|
|
{ once: true },
|
|
);
|
|
try {
|
|
await openBrowserAuth(url, {
|
|
signal: controller.signal,
|
|
onClose: stop,
|
|
openExternal: (target) => shell.openExternal(target),
|
|
onCallback: (callback) => {
|
|
if (!event.sender.isDestroyed()) event.sender.send("desktop.oauth.callback", callback);
|
|
},
|
|
});
|
|
} catch {
|
|
controller.abort();
|
|
throw new Error("Could not open browser sign-in. Close other sign-in attempts and retry.");
|
|
}
|
|
});
|
|
ipcMain.handle("desktop.oauth.cancel", (event, url: unknown) => {
|
|
if (
|
|
(!fromMainWindow(event) &&
|
|
!(settingsWindow !== null && windowFrom(event) === settingsWindow)) ||
|
|
event.senderFrame !== event.sender.mainFrame ||
|
|
typeof url !== "string"
|
|
)
|
|
return;
|
|
browserAuthAttempts.get(url)?.abort();
|
|
});
|
|
ipcMain.handle(
|
|
"desktop.localSettings.request",
|
|
async (event, pathname: unknown, body: unknown) => {
|
|
if (
|
|
!settingsWindow ||
|
|
windowFrom(event) !== settingsWindow ||
|
|
event.senderFrame !== event.sender.mainFrame ||
|
|
!settingsTarget ||
|
|
event.senderFrame.url !== `${settingsTarget.origin}${LOCAL_SETTINGS_PAGE}`
|
|
) {
|
|
throw new Error("Local settings are not active");
|
|
}
|
|
return requestLocalSettings(settingsTarget, pathname, body, (input, init) =>
|
|
net.fetch(input instanceof URL ? input.href : input, {
|
|
...init,
|
|
bypassCustomProtocolHandlers: true,
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
ipcMain.handle("desktop.platform", () => process.platform);
|
|
ipcMain.handle("desktop.window.close", (event) => {
|
|
windowFrom(event)?.close();
|
|
});
|
|
ipcMain.handle("desktop.window.minimize", (event) => {
|
|
windowFrom(event)?.minimize();
|
|
});
|
|
ipcMain.handle("desktop.window.toggleMaximize", (event) => {
|
|
const win = windowFrom(event);
|
|
if (!win) return;
|
|
if (win.isMaximized() || win.isFullScreen()) {
|
|
win.setFullScreen(false);
|
|
if (win.isMaximized()) win.unmaximize();
|
|
} else {
|
|
win.maximize();
|
|
}
|
|
});
|
|
ipcMain.handle("desktop.window.state", (event) => {
|
|
const win = windowFrom(event);
|
|
return {
|
|
minimized: win?.isMinimized() ?? false,
|
|
maximized: win?.isMaximized() ?? false,
|
|
fullScreen: win?.isFullScreen() ?? false,
|
|
};
|
|
});
|
|
ipcMain.handle("desktop.update.state", () => desktopUpdater.state());
|
|
ipcMain.handle("desktop.update.check", (event) =>
|
|
fromMainWindow(event) ? desktopUpdater.check(true) : desktopUpdater.state(),
|
|
);
|
|
ipcMain.handle("desktop.update.download", (event) =>
|
|
fromMainWindow(event) ? desktopUpdater.download() : desktopUpdater.state(),
|
|
);
|
|
ipcMain.handle("desktop.update.install", async (event) => {
|
|
if (!fromMainWindow(event) || desktopUpdater.state().phase !== "ready") {
|
|
return desktopUpdater.state();
|
|
}
|
|
quitting = true;
|
|
const state = await desktopUpdater.install();
|
|
// A failed install stays ready for retry but reports a message. Restore normal
|
|
// window behavior while the user keeps working after that failure.
|
|
if (state.phase !== "ready" || state.message !== null) quitting = false;
|
|
return state;
|
|
});
|
|
ipcMain.handle("desktop.setup.state", (event) => {
|
|
if (!fromSetupWindow(event)) return null;
|
|
return {
|
|
defaultLocalUrl: localStack.webUrl(),
|
|
saved: currentSetup,
|
|
error: setupError ?? undefined,
|
|
};
|
|
});
|
|
|
|
ipcMain.handle("desktop.setup.test", async (event, url: unknown) => {
|
|
if (!fromSetupWindow(event)) return { ok: false, error: "Setup is not active." };
|
|
if (typeof url !== "string") return { ok: false, error: "Enter a server address." };
|
|
return probeServer(url);
|
|
});
|
|
|
|
ipcMain.handle("desktop.setup.save", async (event, payload: unknown) => {
|
|
if (!fromSetupWindow(event)) return { ok: false, error: "Setup is not active." };
|
|
if (setupSaveInProgress)
|
|
return { ok: false, error: "A connection attempt is already running." };
|
|
setupSaveInProgress = true;
|
|
const previousSetup = currentSetup;
|
|
const previousUrl = currentTargetUrl;
|
|
try {
|
|
const setup = parseSetupInput(payload);
|
|
if (setup === null) {
|
|
return {
|
|
ok: false,
|
|
error:
|
|
"Enter a valid server address. Public servers require HTTPS; a new local instance must use localhost.",
|
|
};
|
|
}
|
|
|
|
// Only open the exact origin selected and authenticated by the managed stack.
|
|
let openSetup = setup;
|
|
if (setup.mode === "new") {
|
|
const managedUrl = managedLocalOpenUrl(setup.serverUrl, localStack.webUrl());
|
|
if (managedUrl === null || !(await localStack.matchesDesiredStack())) {
|
|
return {
|
|
ok: false,
|
|
error: "The app-managed Rakazo services are not ready. Retry setup.",
|
|
};
|
|
}
|
|
openSetup = { mode: "new", serverUrl: managedUrl };
|
|
}
|
|
|
|
const reachability = await probeServer(openSetup.serverUrl);
|
|
if (!reachability.ok) return { ok: false, error: reachability.error };
|
|
|
|
// Open before persisting so a failed renderer load keeps the last working setup.
|
|
currentSetup = openSetup;
|
|
const opened = await openApp(openSetup.serverUrl);
|
|
if (!opened) {
|
|
currentSetup = previousSetup;
|
|
return {
|
|
ok: false,
|
|
error: "Could not open that server. The previous instance was left unchanged.",
|
|
};
|
|
}
|
|
|
|
const appWindow = mainWindow;
|
|
const rendererWatch =
|
|
appWindow !== null && !appWindow.isDestroyed()
|
|
? watchRendererUntilCommitted(appWindow)
|
|
: null;
|
|
try {
|
|
await writeSetup(userDataDir, openSetup);
|
|
if (rendererWatch?.crashed()) {
|
|
const message = await recoverFromCrashedSave(userDataDir, previousSetup, previousUrl);
|
|
return { ok: false, error: message };
|
|
}
|
|
// Commit while the crash listener is still armed.
|
|
commitPendingAppSwitch();
|
|
if (rendererWatch?.crashed()) {
|
|
const message = await recoverFromCrashedSave(userDataDir, previousSetup, previousUrl);
|
|
return { ok: false, error: message };
|
|
}
|
|
destroySetupWindow();
|
|
// Final check after setup closes — a crash in this gap still rolls back.
|
|
if (rendererWatch?.crashed()) {
|
|
const message = await recoverFromCrashedSave(userDataDir, previousSetup, previousUrl);
|
|
return { ok: false, error: message };
|
|
}
|
|
return { ok: true };
|
|
} catch {
|
|
const outcome = abandonPendingAppSwitch(previousSetup, previousUrl);
|
|
return {
|
|
ok: false,
|
|
error:
|
|
outcome === "restored"
|
|
? "Could not save setup. The previous instance was restored."
|
|
: "Connected, but could not save setup for the next launch. Try Continue again.",
|
|
};
|
|
} finally {
|
|
rendererWatch?.dispose();
|
|
}
|
|
} finally {
|
|
setupSaveInProgress = false;
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("desktop.setup.quit", (event) => {
|
|
if (fromSetupWindow(event)) app.quit();
|
|
});
|
|
ipcMain.handle("desktop.setup.openLink", async (event, link: unknown) => {
|
|
if (!fromSetupWindow(event) || !isDesktopSetupLink(link)) return;
|
|
await shell.openExternal(DOCKER_INSTALL_LINKS[link]);
|
|
});
|
|
ipcMain.handle("desktop.setup.stack.state", (event) =>
|
|
fromSetupWindow(event) ? localStack.state() : null,
|
|
);
|
|
ipcMain.handle("desktop.setup.stack.start", (event) => {
|
|
if (!fromSetupWindow(event)) return null;
|
|
// Respond right away; the setup window polls `stack.state` until a terminal phase.
|
|
void localStack.start();
|
|
return localStack.state();
|
|
});
|
|
|
|
// Register before startup awaits so macOS dock clicks during probe/open are handled.
|
|
app.on("activate", () => {
|
|
if (setupWindow !== null && !setupWindow.isDestroyed()) {
|
|
setupWindow.show();
|
|
setupWindow.focus();
|
|
return;
|
|
}
|
|
if (mainWindow !== null && !mainWindow.isDestroyed()) {
|
|
clearTimeout(warmWindowTimer);
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
return;
|
|
}
|
|
if (openAppPromise !== null) return;
|
|
if (currentTargetUrl === null) showSetupWindow(setupError);
|
|
else
|
|
void openApp(currentTargetUrl).then((opened) => {
|
|
if (opened) commitPendingAppSwitch();
|
|
});
|
|
});
|
|
|
|
if (target.kind === "setup") {
|
|
showSetupWindow();
|
|
} else if (target.source === "saved") {
|
|
if (currentSetup?.mode === "new") {
|
|
const managedUrl = managedLocalOpenUrl(target.url, localStack.webUrl());
|
|
const managedStackReady =
|
|
managedUrl !== null ? await localStack.matchesDesiredStack() : false;
|
|
if (managedStackReady && managedUrl !== null) {
|
|
if (await openApp(managedUrl)) {
|
|
commitPendingAppSwitch();
|
|
destroySetupWindow();
|
|
}
|
|
} else {
|
|
// Missing, stale, foreign, or owned by another process: reconcile the
|
|
// app-managed stack before any API or computer traffic is allowed.
|
|
void localStack.start();
|
|
showSetupWindow();
|
|
}
|
|
} else {
|
|
const reachability = await probeServer(target.url);
|
|
if (reachability.ok) {
|
|
if (await openApp(target.url)) {
|
|
commitPendingAppSwitch();
|
|
destroySetupWindow();
|
|
}
|
|
} else {
|
|
showSetupWindow(`Could not reconnect to the saved server. ${reachability.error}`);
|
|
}
|
|
}
|
|
} else {
|
|
if (await openApp(target.url)) {
|
|
commitPendingAppSwitch();
|
|
destroySetupWindow();
|
|
}
|
|
}
|
|
});
|
|
|
|
app.on("window-all-closed", () => {
|
|
// A hidden session probe (defaultSessionHasOriginData) can be the only window
|
|
// during startup; its teardown must not quit the app.
|
|
if (liveProbeWindows > 0) return;
|
|
if (process.platform !== "darwin") app.quit();
|
|
});
|
|
|
|
app.on("before-quit", () => {
|
|
quitting = true;
|
|
clearTimeout(warmWindowTimer);
|
|
// Containers keep running; only an in-flight pull/up is cut short.
|
|
localStack?.abort();
|
|
});
|