- JMAP_SERVER_URL: stalwart.sandbox.vnc.de → emailcore.src-advisory.com - Updated Electron defaults, deploy secrets example, and e2e tests - SMTP server (emailcore-svc.src-advisory.com) is handled by Stalwart internally via JMAP EmailSubmission — no frontend changes needed
581 lines
24 KiB
TypeScript
581 lines
24 KiB
TypeScript
// Electron main process for the VNCmail+ (Bulwark) desktop shell.
|
|
//
|
|
// Boots the exact same Next.js "standalone" server artifact the Dockerfile
|
|
// already produces for production (see next.config.ts's `output:
|
|
// "standalone"` and the Dockerfile's builder stage) as a child process on a
|
|
// random localhost port, then opens a BrowserWindow pointed at it. This is
|
|
// deliberately the same server, not a reimplementation - lib/jmap/client.ts
|
|
// and every app/api/** route behave identically to the web deployment.
|
|
import { app, BrowserWindow, ipcMain, Notification } from "electron";
|
|
import { autoUpdater } from "electron-updater";
|
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { randomBytes } from "node:crypto";
|
|
import { createServer } from "node:net";
|
|
import { get as httpGet } from "node:http";
|
|
import path from "node:path";
|
|
import fs from "node:fs";
|
|
import type { Duplex } from "node:stream";
|
|
import { WebSocket } from "ws";
|
|
import { attachKeyService, checkEncryptionAvailable } from "./key-service";
|
|
|
|
let serverProcess: ChildProcess | null = null;
|
|
let mainWindow: BrowserWindow | null = null;
|
|
|
|
/**
|
|
* Root for the encrypted local search index (lib/mail-index/**). Under
|
|
* `userData`, so it is per-OS-user and removed with the app's data.
|
|
*
|
|
* Passing this to the server child process is what ACTIVATES the index: the
|
|
* routes 404 without it. That matters because the standalone server is the same
|
|
* artifact the production Dockerfile ships to multi-tenant deployments, where a
|
|
* server-side index of every user's mail would be badly wrong. One variable
|
|
* both enables the feature and supplies its path, so the two cannot drift apart.
|
|
*/
|
|
function getIndexStoreDir(): string {
|
|
return path.join(app.getPath("userData"), "offline");
|
|
}
|
|
|
|
/**
|
|
* Every writable data dir the standalone server uses, redirected under
|
|
* `userData`.
|
|
*
|
|
* WITHOUT this, all four default to `<cwd>/data/*` (see lib/admin/paths.ts,
|
|
* lib/settings-sync.ts, lib/telemetry/state.ts, lib/version-check/state.ts),
|
|
* and in a packaged build cwd is `.../VNCmail+.app/Contents/Resources/standalone`
|
|
* - i.e. the app writes its own runtime state INSIDE its own bundle. Three
|
|
* separate failure modes, all observed rather than theorised:
|
|
*
|
|
* 1. It INVALIDATES THE CODE SIGNATURE. A signed .app seals its Resources;
|
|
* writing there breaks the seal, so `codesign --verify` starts failing
|
|
* ("code has no resources but signature indicates they must be present")
|
|
* and macOS reports the app as *damaged* on a later launch. Verified on
|
|
* an installed copy in /Applications: signature valid at install time,
|
|
* exit 1 after the app had run once and written data/admin + data/telemetry.
|
|
* Deep-signing the bundle at build time (scripts/after-sign.cjs) is
|
|
* necessary but NOT sufficient on its own - the app immediately breaks
|
|
* its own signature at runtime unless the writes go elsewhere.
|
|
* 2. An app update replaces the bundle, silently destroying the user's admin
|
|
* config, settings and setup state.
|
|
* 3. It fails outright wherever the bundle isn't user-writable.
|
|
*
|
|
* `userData` is the correct home for per-user mutable state on every platform
|
|
* and is where the search index already lives, so this keeps one convention.
|
|
*/
|
|
function getServerDataDirs(): Record<string, string> {
|
|
const root = app.getPath("userData");
|
|
return {
|
|
ADMIN_CONFIG_DIR: path.join(root, "admin"),
|
|
ADMIN_STATE_DIR: path.join(root, "admin-state"),
|
|
SETTINGS_DATA_DIR: path.join(root, "settings"),
|
|
TELEMETRY_DATA_DIR: path.join(root, "telemetry"),
|
|
VERSION_CHECK_DATA_DIR: path.join(root, "version-check"),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Desktop-shell defaults for a fresh, un-configured install.
|
|
*
|
|
* Setting JMAP_SERVER_URL puts the standalone server into "env-managed"
|
|
* mode (see lib/setup/state.ts's detectSetupState()) - the ONLY thing that
|
|
* disables the setup wizard short of an operator finishing it by hand. Every
|
|
* distributable build of this desktop shell up to 2026-08-05 skipped this,
|
|
* so handing someone the packaged app landed them on "Bulwark Webmail
|
|
* Setup" asking for a token out of container logs they have no access to -
|
|
* caught only by actually launching the packaged .app and looking, not by
|
|
* reading the build log.
|
|
*
|
|
* The rest are CONFIG_ENV_MAP entries (lib/admin/types.ts) that only matter
|
|
* while env-managed - once an admin completes the wizard, config.json wins
|
|
* for everything except jmapServerUrl itself. allowCustomJmapEndpoint keeps
|
|
* the server field on the login screen editable, so this is a starting
|
|
* point for the sandbox, not a hard lock to it.
|
|
*
|
|
* `...process.env` in startStandaloneServer() below is spread AFTER this
|
|
* object, so a real deployment env (the Dockerfile path, or a future
|
|
* per-install override) still wins over these defaults.
|
|
*/
|
|
function getDesktopDefaults(): Record<string, string> {
|
|
return {
|
|
JMAP_SERVER_URL: "https://emailcore.src-advisory.com",
|
|
APP_NAME: "VNCmail+",
|
|
APP_SHORT_NAME: "VNCmail+",
|
|
LOGIN_LOGO_LIGHT_URL: "/branding/SRC_Symbol.png",
|
|
LOGIN_LOGO_DARK_URL: "/branding/SRC_Symbol.png",
|
|
LOGIN_COMPANY_NAME: "VNC AG",
|
|
FAVICON_URL: "/branding/SRC_Symbol.png",
|
|
ALLOW_CUSTOM_JMAP_ENDPOINT: "true",
|
|
// The login page's subtitle falls back to the login.title i18n string
|
|
// whenever it differs from appName (app/(main)/[locale]/login/page.tsx)
|
|
// - a check clearly written for the original Bulwark/"Webmail" pairing,
|
|
// where they matched. With APP_NAME overridden to "VNCmail+" they no
|
|
// longer match, so the raw translation ("Webmail") surfaces instead of
|
|
// anything brand-appropriate. Hiding the subtitle avoids editing a
|
|
// shared i18n string that every other deployment (incl. Bulwark
|
|
// default) still uses - the SRC logo + "VNCmail+" heading is enough
|
|
// context on its own.
|
|
LOGIN_SHOW_SUBTITLE: "false",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Per-install session secret for the standalone server.
|
|
*
|
|
* The server's cookie crypto (lib/auth/crypto.ts) refuses to mint the
|
|
* `jmap_stalwart_ctx` auth-context cookie without a >=32-char SESSION_SECRET,
|
|
* and every server-side-identity feature hangs off that cookie: the encrypted
|
|
* local search index and offline replica (their routes 401 without it),
|
|
* S/MIME enrolment, and the AI `server` class. A web deployment gets the
|
|
* secret from an operator (env var or the setup wizard); the desktop shell
|
|
* has NO operator, and up to 1.7.8 the packaged app simply shipped without
|
|
* one — so every login's stalwart-context POST failed with 500, the index
|
|
* stayed permanently empty, and the AI assistant answered "No local mail
|
|
* index available in this session" against real accounts. Caught live on a
|
|
* real mailbox, not by tests: every test run had injected its own
|
|
* SESSION_SECRET into the child env, masking exactly this.
|
|
*
|
|
* Generated once per install (64 hex chars, comfortably over the minimum),
|
|
* persisted 0600 under userData next to the rest of the per-user state, and
|
|
* handed to the server as SESSION_SECRET_FILE rather than SESSION_SECRET so
|
|
* the value itself stays out of the child's environment block. A
|
|
* deployment-provided SESSION_SECRET env var still wins — getSessionSecret()
|
|
* resolves the env var before the file.
|
|
*/
|
|
function ensureSessionSecretFile(): string | null {
|
|
const secretPath = path.join(app.getPath("userData"), "session-secret");
|
|
try {
|
|
const existing = fs.readFileSync(secretPath, "utf8").trim();
|
|
if (existing.length >= 32) return secretPath;
|
|
} catch {
|
|
/* first run — generate below */
|
|
}
|
|
try {
|
|
fs.mkdirSync(path.dirname(secretPath), { recursive: true });
|
|
fs.writeFileSync(secretPath, randomBytes(32).toString("hex"), { mode: 0o600 });
|
|
return secretPath;
|
|
} catch (cause) {
|
|
// Loud, because the downstream symptom is otherwise "index/AI features
|
|
// return 401" with no hint of why — but never fatal: reading mail does
|
|
// not depend on this cookie.
|
|
console.error("[electron] could not persist a session secret:", cause);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── OpenCode agent server ────────────────────────────────────────────────
|
|
//
|
|
// The `opencode` AI class talks to a locally-running `opencode serve`. Left to
|
|
// the user that means "remember to start a terminal process before opening
|
|
// your mail app, and again after every reboot" - which is to say the feature
|
|
// quietly stops existing. So the desktop shell owns its lifecycle: start it if
|
|
// the binary is installed, restart it if it dies, kill it on quit.
|
|
//
|
|
// SECURITY: opencode itself warns "OPENCODE_SERVER_PASSWORD is not set; server
|
|
// is unsecured" - without one, any local process can drive the agent. We always
|
|
// generate one. Auth is HTTP Basic with the username EXACTLY `opencode`
|
|
// (verified against 1.18.14: an empty or arbitrary username 401s even with the
|
|
// right password, and no bearer/custom-header form works) - undocumented in its
|
|
// own OpenAPI spec, which declares no securitySchemes at all.
|
|
|
|
let opencodeProcess: ChildProcess | null = null;
|
|
let opencodeRestarts = 0;
|
|
/** Set by stopOpencodeServer() so the exit handler can tell a deliberate
|
|
* shutdown from a crash and not fight the quit by respawning. */
|
|
let opencodeStopping = false;
|
|
const OPENCODE_MAX_RESTARTS = 3;
|
|
|
|
/** Where the binary lives. `~/.opencode/bin` is its own installer's default and
|
|
* is NOT on the PATH a GUI app inherits on macOS, so PATH alone finds nothing
|
|
* for most users. */
|
|
function findOpencodeBinary(): string | null {
|
|
const explicit = process.env.OPENCODE_BIN?.trim();
|
|
if (explicit && fs.existsSync(explicit)) return explicit;
|
|
const candidates = [
|
|
path.join(app.getPath("home"), ".opencode", "bin", "opencode"),
|
|
"/opt/homebrew/bin/opencode",
|
|
"/usr/local/bin/opencode",
|
|
"/usr/bin/opencode",
|
|
];
|
|
return candidates.find((c) => fs.existsSync(c)) ?? null;
|
|
}
|
|
|
|
interface OpencodeHandle {
|
|
baseUrl: string;
|
|
password: string;
|
|
}
|
|
|
|
async function startOpencodeServer(): Promise<OpencodeHandle | null> {
|
|
const binary = findOpencodeBinary();
|
|
if (!binary) return null; // not installed - the class simply stays unavailable
|
|
|
|
const port = await getFreePort();
|
|
// Per-launch, never persisted: the server dies with the app, so there is no
|
|
// value in a durable secret and every reason not to leave one on disk.
|
|
const password = randomBytes(24).toString("hex");
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
|
|
const spawnOnce = () => {
|
|
opencodeProcess = spawn(binary, ["serve", "--port", String(port), "--hostname", "127.0.0.1"], {
|
|
env: { ...process.env, OPENCODE_SERVER_PASSWORD: password },
|
|
stdio: "ignore",
|
|
});
|
|
opencodeProcess.on("exit", (code, signal) => {
|
|
opencodeProcess = null;
|
|
// A deliberate shutdown arrives as SIGTERM from stopOpencodeServer().
|
|
if (opencodeStopping || signal === "SIGTERM") return;
|
|
if (opencodeRestarts >= OPENCODE_MAX_RESTARTS) {
|
|
console.error(`[opencode] gave up restarting after ${OPENCODE_MAX_RESTARTS} attempts (last code=${code})`);
|
|
return;
|
|
}
|
|
opencodeRestarts += 1;
|
|
console.error(`[opencode] server exited (code=${code}); restart ${opencodeRestarts}/${OPENCODE_MAX_RESTARTS}`);
|
|
setTimeout(spawnOnce, 1000 * opencodeRestarts);
|
|
});
|
|
};
|
|
spawnOnce();
|
|
|
|
return { baseUrl, password };
|
|
}
|
|
|
|
function stopOpencodeServer(): void {
|
|
opencodeStopping = true;
|
|
if (!opencodeProcess) return;
|
|
const proc = opencodeProcess;
|
|
opencodeProcess = null;
|
|
proc.kill("SIGTERM");
|
|
}
|
|
|
|
/**
|
|
* Locates the standalone server's entrypoint. Packaged builds ship it as an
|
|
* extraResource (see electron-builder.config.js) because .next/standalone
|
|
* isn't inside the app.asar; dev runs read it straight out of the repo via
|
|
* `npm run build:standalone`.
|
|
*/
|
|
function getStandaloneServerEntry(): string {
|
|
if (app.isPackaged) {
|
|
return path.join(process.resourcesPath, "standalone", "server.js");
|
|
}
|
|
return path.join(app.getAppPath(), ".next", "standalone", "server.js");
|
|
}
|
|
|
|
function getFreePort(): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
const server = createServer();
|
|
server.unref();
|
|
server.on("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
if (address && typeof address === "object") {
|
|
const { port } = address;
|
|
server.close(() => resolve(port));
|
|
} else {
|
|
server.close(() => reject(new Error("Could not allocate a free localhost port")));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function waitForServerReady(url: string, timeoutMs = 20000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
return new Promise((resolve, reject) => {
|
|
const attempt = () => {
|
|
const req = httpGet(url, (res) => {
|
|
res.resume();
|
|
resolve();
|
|
});
|
|
req.on("error", () => {
|
|
if (Date.now() > deadline) {
|
|
reject(new Error(`Standalone server never became reachable at ${url}`));
|
|
return;
|
|
}
|
|
setTimeout(attempt, 200);
|
|
});
|
|
};
|
|
attempt();
|
|
});
|
|
}
|
|
|
|
async function startStandaloneServer(): Promise<string> {
|
|
const serverEntry = getStandaloneServerEntry();
|
|
if (!fs.existsSync(serverEntry)) {
|
|
throw new Error(
|
|
`Standalone Next.js server not found at ${serverEntry}. Run "npm run build:standalone" first.`,
|
|
);
|
|
}
|
|
|
|
// Normally a random free port, chosen fresh every launch - JMAP_SERVER_URL
|
|
// never needs to reference it back (a real deployment's Stalwart lives at
|
|
// its own fixed address). VNCMAIL_TEST_FIXED_PORT is a narrow escape hatch
|
|
// for e2e tests that DO need to know the port ahead of time - specifically
|
|
// to point DEV_MOCK_JMAP's JMAP_SERVER_URL at this same standalone server's
|
|
// own /api/dev-jmap route, which is the only way to exercise the real
|
|
// encrypted offline index (lib/mail-index/**) without a real Stalwart
|
|
// fixture: that index's key channel only gets wired up in this function,
|
|
// never when ELECTRON_LOAD_URL bypasses it for a plain `next dev` target.
|
|
// Unset in every normal launch, so this changes nothing outside a test run.
|
|
const fixedPort = process.env.VNCMAIL_TEST_FIXED_PORT ? Number(process.env.VNCMAIL_TEST_FIXED_PORT) : null;
|
|
const port = fixedPort && Number.isInteger(fixedPort) ? fixedPort : await getFreePort();
|
|
const url = `http://127.0.0.1:${port}`;
|
|
|
|
const storeDir = getIndexStoreDir();
|
|
const encryption = checkEncryptionAvailable();
|
|
if (!encryption.ok) {
|
|
// Refuse rather than degrade. On Linux with no keyring, safeStorage
|
|
// "succeeds" using a hardcoded public password, which would look like an
|
|
// encrypted mailbox index while providing no protection. Leaving the env
|
|
// vars unset makes every index route 404, so the app runs normally without
|
|
// the feature.
|
|
console.error(`[electron] local search index disabled: ${encryption.reason}`);
|
|
}
|
|
|
|
// Spawn the Electron binary itself as a plain Node process
|
|
// (ELECTRON_RUN_AS_NODE) instead of depending on a system Node install -
|
|
// the packaged app can't assume Node exists on the target machine, and
|
|
// this keeps dev/packaged behavior identical.
|
|
//
|
|
// stdio gains a 4th entry: fd 3 is the key channel for the local index (see
|
|
// electron/key-service.ts). libuv creates extra stdio "pipe" entries as
|
|
// socketpairs, so it is duplex in both directions - verified by execution
|
|
// before this was built on. Deliberately NOT an environment variable: env is
|
|
// readable by any process running as the same OS user, which would defeat
|
|
// using the OS keychain at all. The fd NUMBER below is not a secret; only
|
|
// what travels over it is.
|
|
const sessionSecretFile = ensureSessionSecretFile();
|
|
// Started before the app server so its address can be handed over as env;
|
|
// null when opencode isn't installed, in which case the class stays absent.
|
|
const opencode = await startOpencodeServer();
|
|
|
|
serverProcess = spawn(process.execPath, [serverEntry], {
|
|
env: {
|
|
// First, so any real deployment env (a future per-install override,
|
|
// or this same binary run somewhere JMAP_SERVER_URL is already set)
|
|
// wins over these desktop-shell defaults - see getDesktopDefaults().
|
|
...getDesktopDefaults(),
|
|
// Also before ...process.env: an operator-provided SESSION_SECRET or
|
|
// SESSION_SECRET_FILE must win over the per-install default (and a
|
|
// SESSION_SECRET env var outranks any file in getSessionSecret()'s
|
|
// resolution order regardless).
|
|
...(sessionSecretFile ? { SESSION_SECRET_FILE: sessionSecretFile } : {}),
|
|
...(opencode
|
|
? { OPENCODE_BASE_URL: opencode.baseUrl, OPENCODE_SERVER_PASSWORD: opencode.password }
|
|
: {}),
|
|
...process.env,
|
|
ELECTRON_RUN_AS_NODE: "1",
|
|
PORT: String(port),
|
|
HOSTNAME: "127.0.0.1",
|
|
NODE_ENV: process.env.NODE_ENV || "production",
|
|
// Keep all mutable state out of the .app bundle - see
|
|
// getServerDataDirs() for why that matters. Placed after
|
|
// ...process.env so the desktop shell's paths win over any inherited
|
|
// value; the same standalone server run outside Electron (the Docker
|
|
// image) never executes this and keeps its documented env behaviour.
|
|
...getServerDataDirs(),
|
|
...(encryption.ok
|
|
? { VNCMAIL_DESKTOP_STORE_DIR: storeDir, VNCMAIL_DESKTOP_KEY_FD: "3" }
|
|
: {}),
|
|
},
|
|
stdio: encryption.ok
|
|
? ["inherit", "inherit", "inherit", "pipe"]
|
|
: "inherit",
|
|
});
|
|
|
|
if (encryption.ok) {
|
|
attachKeyService(serverProcess.stdio[3] as Duplex | null, storeDir);
|
|
}
|
|
|
|
serverProcess.on("exit", (code, signal) => {
|
|
if (code !== 0 && code !== null) {
|
|
console.error(`[electron] standalone server exited early (code=${code}, signal=${signal})`);
|
|
}
|
|
serverProcess = null;
|
|
});
|
|
|
|
await waitForServerReady(url);
|
|
return url;
|
|
}
|
|
|
|
function stopStandaloneServer(): void {
|
|
if (serverProcess && !serverProcess.killed) {
|
|
serverProcess.kill();
|
|
}
|
|
serverProcess = null;
|
|
}
|
|
|
|
async function createMainWindow(): Promise<void> {
|
|
// Test-only escape hatch: when set, skip spawning the standalone server
|
|
// entirely and load this URL instead. Used by
|
|
// integration/tests/11-electron-notification.spec.ts, which needs a
|
|
// dev-mode Next.js server (proxy.ts's CSP only widens connect-src to
|
|
// allow plain-HTTP/ws JMAP in dev - see that file's comments) to reach
|
|
// the integration fixture's deliberately-plaintext local Stalwart,
|
|
// exactly the same trade-off integration/webmail.Dockerfile already makes
|
|
// for the browser-based integration suite. Never set by real users or by
|
|
// any of the packaging/CI paths - those always go through
|
|
// startStandaloneServer() below.
|
|
const url = process.env.ELECTRON_LOAD_URL || (await startStandaloneServer());
|
|
|
|
mainWindow = new BrowserWindow({
|
|
width: 1280,
|
|
height: 860,
|
|
webPreferences: {
|
|
preload: path.join(__dirname, "preload.js"),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
sandbox: true,
|
|
},
|
|
});
|
|
|
|
mainWindow.on("closed", () => {
|
|
mainWindow = null;
|
|
});
|
|
|
|
await mainWindow.loadURL(url);
|
|
}
|
|
|
|
// --- Native notification bridge --------------------------------------------
|
|
// Called from the preload's `window.vnc.showNotification` (electron/preload.ts),
|
|
// itself called from lib/electron-bridge.ts's showElectronNotification(),
|
|
// itself called from app/(main)/[locale]/page.tsx's "new mail arrived"
|
|
// effect whenever lib/jmap/client.ts's push pipeline (WebSocket, or its SSE/
|
|
// polling fallback - see that file's circuit breaker) reports a genuine new
|
|
// message. Electron's own Notification API is the desktop shell's
|
|
// notification path - it sits alongside, not in place of, the browser/PWA's
|
|
// service-worker push path (public/sw.js's `push`/`notificationclick`
|
|
// handlers + lib/web-push.ts).
|
|
ipcMain.handle(
|
|
"vnc:show-notification",
|
|
(_event, title: string, options?: { body?: string; tag?: string }) => {
|
|
// Test-only observability hook, read via Playwright's
|
|
// electronApp.evaluate(({ app }) => ...) - see
|
|
// integration/tests/11-electron-notification.spec.ts. Not gated behind
|
|
// NODE_ENV: it's an inert counter with no behavioral effect, cheaper
|
|
// than maintaining a second code path just for tests.
|
|
const counters = app as unknown as { __notificationCallCount?: number };
|
|
counters.__notificationCallCount = (counters.__notificationCallCount ?? 0) + 1;
|
|
|
|
if (!Notification.isSupported()) {
|
|
return { shown: false };
|
|
}
|
|
const notification = new Notification({
|
|
title,
|
|
body: options?.body ?? "",
|
|
});
|
|
notification.show();
|
|
return { shown: true };
|
|
},
|
|
);
|
|
|
|
// --- WebSocket bridge for renderer ----------------------------------------
|
|
// The browser WebSocket constructor cannot attach Authorization headers, so
|
|
// JMAP-over-WebSocket (RFC 8887) push paths that require auth at the upgrade
|
|
// handshake are unreachable from the renderer. This IPC bridge opens the
|
|
// WebSocket from the main process (where we control headers) and forwards
|
|
// messages to the renderer as 'vnc:ws-message' events.
|
|
|
|
const wsConnections = new Map<string, WebSocket>();
|
|
|
|
ipcMain.handle(
|
|
"vnc:ws-connect",
|
|
(event, { url, authHeader }: { url: string; authHeader: string }) => {
|
|
const id = randomBytes(8).toString("hex");
|
|
const ws = new WebSocket(url, {
|
|
headers: { Authorization: authHeader },
|
|
});
|
|
|
|
ws.on("open", () => {
|
|
event.sender.send("vnc:ws-message", { id, type: "open" });
|
|
});
|
|
|
|
ws.on("message", (data: Buffer) => {
|
|
event.sender.send("vnc:ws-message", {
|
|
id,
|
|
type: "message",
|
|
data: data.toString(),
|
|
});
|
|
});
|
|
|
|
ws.on("close", (code: number) => {
|
|
wsConnections.delete(id);
|
|
event.sender.send("vnc:ws-message", { id, type: "close", code });
|
|
});
|
|
|
|
ws.on("error", (err: Error) => {
|
|
event.sender.send("vnc:ws-message", {
|
|
id,
|
|
type: "error",
|
|
message: err.message,
|
|
});
|
|
});
|
|
|
|
wsConnections.set(id, ws);
|
|
return id;
|
|
},
|
|
);
|
|
|
|
ipcMain.handle(
|
|
"vnc:ws-send",
|
|
(_event, { id, data }: { id: string; data: string }) => {
|
|
const ws = wsConnections.get(id);
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
|
ws.send(data);
|
|
return true;
|
|
},
|
|
);
|
|
|
|
ipcMain.handle("vnc:ws-close", (_event, { id }: { id: string }) => {
|
|
const ws = wsConnections.get(id);
|
|
if (!ws) return;
|
|
ws.close();
|
|
wsConnections.delete(id);
|
|
});
|
|
|
|
// --- Auto-update -------------------------------------------------------
|
|
// GitHub Releases as the update feed (electron-builder.config.js's
|
|
// `publish` block) - the skill's recommendation over standing up a new
|
|
// distribution channel, since the repo is already private. "Light
|
|
// decision" per VNCprodbuild step 7, not re-litigated here.
|
|
//
|
|
// Deliberately best-effort: there's no code signing yet (step 9), so on
|
|
// macOS in particular an update download/install can fail signature
|
|
// verification. A failed check must never take the app down - it's
|
|
// background maintenance, not something the user is blocked on.
|
|
function setupAutoUpdater(): void {
|
|
if (!app.isPackaged) {
|
|
// Unpacked dev/test runs (npm run electron:dev, the Playwright smoke
|
|
// test) have no latest.yml alongside them - checking would just log a
|
|
// noisy 404 against GitHub Releases for every dev run.
|
|
return;
|
|
}
|
|
autoUpdater.autoDownload = true;
|
|
autoUpdater.autoInstallOnAppQuit = true;
|
|
autoUpdater.on("error", (error) => {
|
|
console.error("[electron] auto-update error:", error);
|
|
});
|
|
autoUpdater.checkForUpdatesAndNotify().catch((error) => {
|
|
console.error("[electron] checkForUpdatesAndNotify failed:", error);
|
|
});
|
|
}
|
|
|
|
app.whenReady().then(() => {
|
|
void createMainWindow();
|
|
setupAutoUpdater();
|
|
});
|
|
|
|
app.on("window-all-closed", () => {
|
|
stopStandaloneServer();
|
|
stopOpencodeServer();
|
|
if (process.platform !== "darwin") {
|
|
app.quit();
|
|
}
|
|
});
|
|
|
|
app.on("before-quit", () => {
|
|
stopStandaloneServer();
|
|
stopOpencodeServer();
|
|
});
|
|
|
|
app.on("activate", () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
void createMainWindow();
|
|
}
|
|
});
|