feat: Phase 3+4 — security hardening + polish + offline + Electron push
Phase 3 (security): - P3.1: Feature gate server-side enforcement (403 on disabled features) - P3.2: Unified auth error interceptor (401→logout) - P3.3: Store-level state isolation via StoreSnapshot contract (added message-list-tabs + task stores to snapshot/restore cycle) - P3.4: Push event bus extraction — email-store no longer imports calendar/contact/filter/file stores directly - P1.3: Auth localStorage AES-GCM encryption via custom Zustand adapter Phase 4 (polish): - P4.1: Offline write queue — pending operations in localStorage, auto-retry on reconnect, offline-queue-indicator banner - P4.2: Identity spoofing — fromOverrideEmail domain validation - P4.3: WebSocket push for Electron via main-process IPC bridge (ws package with Authorization headers)
This commit is contained in:
@@ -15,6 +15,7 @@ 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;
|
||||
@@ -463,6 +464,70 @@ ipcMain.handle(
|
||||
},
|
||||
);
|
||||
|
||||
// --- 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
|
||||
|
||||
@@ -13,6 +13,14 @@ export interface ShowNotificationResult {
|
||||
shown: boolean;
|
||||
}
|
||||
|
||||
export interface WsMessageEvent {
|
||||
id: string;
|
||||
type: "open" | "message" | "close" | "error";
|
||||
data?: string;
|
||||
code?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("vnc", {
|
||||
isElectron: true,
|
||||
// Routes to Electron's own Notification API in main.ts (ipcMain.handle
|
||||
@@ -24,4 +32,26 @@ contextBridge.exposeInMainWorld("vnc", {
|
||||
options?: ShowNotificationOptions,
|
||||
): Promise<ShowNotificationResult> =>
|
||||
ipcRenderer.invoke("vnc:show-notification", title, options),
|
||||
|
||||
// WebSocket bridge for JMAP-over-WebSocket (RFC 8887). The browser
|
||||
// WebSocket constructor cannot attach Authorization headers, so
|
||||
// connections go through the main process which controls headers.
|
||||
wsConnect: (
|
||||
url: string,
|
||||
authHeader: string,
|
||||
): Promise<string> =>
|
||||
ipcRenderer.invoke("vnc:ws-connect", { url, authHeader }),
|
||||
|
||||
wsSend: (id: string, data: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke("vnc:ws-send", { id, data }),
|
||||
|
||||
wsClose: (id: string): Promise<void> =>
|
||||
ipcRenderer.invoke("vnc:ws-close", { id }),
|
||||
|
||||
onWsMessage: (callback: (event: WsMessageEvent) => void): () => void => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: WsMessageEvent) =>
|
||||
callback(data);
|
||||
ipcRenderer.on("vnc:ws-message", handler);
|
||||
return () => { ipcRenderer.removeListener("vnc:ws-message", handler); };
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user