* fix(ci): restore browser preview module and onboarding e2e The web bundle imported a browser-preview store that was never added, so production builds and every Playwright signup failed before the shell rendered. Mobile check failed because Expo packages were a patch behind the SDK. Restore the store, type the optional desktop bridge, and walk whichever onboarding step scripted CI actually opens. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): stop the blank web client and the send 500 The core barrel pulled git checkout into the browser, so node:child_process crashed the page before sign-up. Serve that module from a node export instead. Add the missing run tool-call budget columns that threads/send writes. Guard an indexed shell flag for typecheck, and expect the files key the desktop preload already exposes. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): isolate pnpm setup and the takeover membership The Team Computer takeover journey inserted a space member the default-space trigger had already created, so shared Postgres rejected the unique pair. Self-hosted jobs now keep a per-job pnpm home and store, and artifact uploads no longer fail a job that already passed. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): clear typecheck, lint, and unit failures Inbound channel rosters can mark a participant list as authoritative. Answered channel asks stay off the group, and clearing a thread waits until delegated returns land. Lint, unit, and web e2e contracts follow the current shell, composer, and settings. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): align e2e with the shipped shell and onboarding Web E2E still expected older focus buttons, nested project rows, and settings that never opened MCP. Persist the signed-in space, list non-default spaces under Projekte, and keep the quote control on screen. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): open group reply from a revealed hover rail The first user row covered its Reply control until the rail accepted pointer events. The group transcript now reveals that rail, then replies. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): stop the shell render loop and settle computer and mention UI A working bot rewrote preview state on every render, the computer menu never stayed still, and the mention picker did not expose its expanded state or option name. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): stop idle probes hanging and accept a follow-up send Background probes walked every process fd and timed out on the shared runner. Linux now tracks the marker with flock and /proc/locks. A new composer draft can send while an earlier send is still refreshing, and group members stay mentionable. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(adapters): detect background work when flock ids are padded The idle probe compared /proc/locks with an unpadded device key, so majors or minors under 16 never matched. It deleted the live marker and reported idle while the command still held the lock. Probe by taking the lock, and print the cancel key the way the kernel does. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(adapters): stabilize background-work probe tests on self-hosted CI Clear stale marker files before each probe scenario and allow longer polling so launch can acquire flock on busy persistent runners. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): do not block onboarding on spaces list Render the first onboarding step from me and integration setup alone. Resolve the optional skip-to-app redirect after that so Playwright signups are not stuck on the loading shell when spaces.list is slow. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): restore green gates after second main rebase Re-apply biome formatting on Shell/SortableGroup and strict TypeScript assertions in computer-idle tests dropped when skipping the duplicate conflict-resolution commit. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): wait for onboarding bootstrap before E2E proceeds Expose data-onboarding-step on the onboarding shell and have Playwright wait until loading finishes. Ignore ad-hoc screenshot scripts on self-hosted runners so they do not fail lint. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): fall back when onboarding bootstrap stalls Race me/integration setup against a timeout and promote loading to the emoji step after 20s so Playwright is not blocked on a hung RPC. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): render onboarding immediately for E2E signups Start on the emoji step instead of a loading shell that waits on RPC. Bootstrap can still redirect once me and integration setup resolve. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): stabilize web e2e on self-hosted runners Per-job pnpm stores broke node_modules symlinks when setup-node restored cached installs, so Vite could not resolve react during dependency optimization. Keep a runner-scoped store, clear stale Vite caches, and pin Playwright to one worker in CI to reduce onboarding RPC contention. Add Vite resolve aliases for react/react-dom, wait for an interactive onboarding step in E2E helpers, and include the React hook smoke spec from main. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): resolve React aliases through pnpm package layout Root node_modules/react does not exist under pnpm, so Vite aliases pointed at missing paths and broke production builds and the Playwright webServer. Resolve react and react-dom from the web package and map jsx-runtime to the actual entry files. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(core): type the lazy child_process spawn cache Main's lazy require left spawn typed as null after assignment, which broke Typecheck on the rebased branch. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(db): mock git-checkout export path in unit tests Vitest resolves vi.mock against package exports; the .js suffix is not declared for @rakazo/core/git-checkout. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): stabilize E2E after main sidebar and onboarding changes Use first() for duplicate transcript lines, wait for interactive onboarding before model-required assertions, and allow project sidebar to settle after create. Extend the Playwright job timeout so the single-worker suite can finish. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): restore Projekte sidebar and integrations MCP entry Use the default space for Unterhaltungen/Projekte separation instead of the active space header, persist the bootstrapped space in localStorage, wire SettingsOverlay to MCP management, and compare hover tones on inactive rows. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): satisfy biome format on homeSpaceId line Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): align sidebar E2E with nested Projekte shell layout Scope Unterhaltungen assertions to conversation rows only, target project-space Chief actions under .sidebar-projekte, and stabilize hover-tone polling. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix: satisfy lint after main merge (format and unused var) Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): show project-space 1:1 bots in Projekte sidebar Include lone bots in projectSidebarGroups, render bot rows in Projekte, and stabilize E2E for nested sidebar layout and duplicate group entries. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): disambiguate duplicate group sidebar buttons in E2E Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): align spaces and group-chats E2E with Projekte sidebar IA Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): dedupe Projekte sidebar rows and stabilize E2E specs - Deduplicate project space and group-chat entries in projectSidebarGroups - Scope group-chats/spaces E2E clicks to first Projekte match after IA split - Wait for shell ready before mention-picker keyboard test; restore main locators - Fix PrivacySettingsOverlay lint and align biome schema with CLI 2.5.13 Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): restrict project drag handle and stabilize space E2E - Move SortableGroup drag listeners to ProjectDragGrip only so sidebar clicks work - Scope space delete actions to project-space rows; wait for menu in E2E - Switch group settings E2E via direct navigation after unsaved draft check - Merge main (DataTrustPanel) and fix Shell import order for CI lint Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): stabilize message hover Escape assertion in E2E Assert the More menu closes and the hover rail stays visible instead of requiring focus restoration, which is flaky across Chromium builds. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * chore: format apps/api/src/app.ts after main merge Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): stabilize team-computer E2E send after bot switch Wait for shell ready and use the Send button instead of Enter after switching bots and closing the computer overlay. The prior run timed out on threads/send with only an apps/api format commit in between. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
1371 lines
48 KiB
TypeScript
1371 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: "#07080A",
|
|
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.files.pickDirectory", async (event) => {
|
|
if (!fromMainWindow(event)) return null;
|
|
const result = await dialog.showOpenDialog(windowFrom(event)!, {
|
|
properties: ["openDirectory", "createDirectory"],
|
|
});
|
|
if (result.canceled || result.filePaths.length === 0) return null;
|
|
return result.filePaths[0] ?? null;
|
|
});
|
|
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();
|
|
});
|