desktop/preload.ts
import { contextBridge, ipcRenderer } from "electron";
import type { NotificationBridge } from "../shared/notifications.js";
import type { UpdateCommand, UpdateState } from "../shared/updates.js";
contextBridge.exposeInMainWorld("desktop", {
platform: process.platform,
notifications: {
configure: (value, privacy) => ipcRenderer.invoke("notification-configure", value, privacy),
audio: (command) => ipcRenderer.invoke("notification-audio", command),
preview: () => { void ipcRenderer.invoke("notification-audio", "preview"); },
onOpen: (callback) => {
let active = true;
const listener = (_event: Electron.IpcRendererEvent, id: unknown) => {
if (typeof id === "string" && id.length > 0 && id.length <= 1024) callback(id);
};
ipcRenderer.on("notification-open", listener);
void ipcRenderer.invoke("notification-ready").then((ids: unknown) => {
if (active && Array.isArray(ids)) for (const id of ids) listener({} as Electron.IpcRendererEvent, id);
}).catch(() => {});
return () => { active = false; ipcRenderer.removeListener("notification-open", listener); };
},
} satisfies NotificationBridge,
// The window has an OS material behind it: macOS vibrancy, or Mica and
// Acrylic on Windows 11 22H2 (build 22621) and later.
materials: process.platform === "darwin" ||
(process.platform === "win32" && Number(process.getSystemVersion().split(".")[2]) >= 22621),
setMaterial: (material: "none" | "mica" | "acrylic") => ipcRenderer.send("window-material", material),
setScheme: (mode: "system" | "light" | "dark") => ipcRenderer.send("window-scheme", mode),
// Outside macOS: the app's drawn traffic lights or the system caption buttons.
windowControls: process.platform !== "darwin" && process.argv?.includes("--emc2-window-controls=windows") ? "windows" : "mac",
setWindowControls: (controls: "mac" | "windows") => ipcRenderer.send("window-controls", controls),
setOverlay: (colors: { color: string; symbolColor: string }) => ipcRenderer.send("window-overlay", colors),
mailMenu: (items: unknown, position?: unknown) => ipcRenderer.invoke("mail-menu", items, position),
copyMcpEndpoint: () => ipcRenderer.invoke("copy-mcp-endpoint"),
copyOpenCodeConfig: (token: string) => ipcRenderer.invoke("copy-opencode-config", token),
setupOpenCode: (token: string) => ipcRenderer.invoke("setup-opencode", token),
credentialFallback: process.argv?.includes("--emc2-credential-fallback=linux-local")
? "linux-local" : undefined,
updates: process.argv?.includes("--emc2-disable-updates") ? undefined : {
state: () => ipcRenderer.invoke("update-state"),
command: (command: UpdateCommand) => ipcRenderer.invoke("update-command", command),
onState: (callback: (state: UpdateState) => void) => {
const listener = (_event: Electron.IpcRendererEvent, state: UpdateState) => callback(state);
ipcRenderer.on("update-state", listener);
return () => ipcRenderer.removeListener("update-state", listener);
},
onDrain: (callback: () => Promise<void>) => {
const listener = (_event: Electron.IpcRendererEvent, request: string) => {
if (typeof request !== "string" || !/^[a-f0-9]{32}$/.test(request)) return;
void callback().then(() => ipcRenderer.send("update-drained", request), () => {});
};
ipcRenderer.on("update-drain", listener);
return () => ipcRenderer.removeListener("update-drain", listener);
},
},
onNavigation: (callback: (direction: "back" | "forward") => void) => {
const listener = (_event: Electron.IpcRendererEvent, direction: string) => {
if (direction === "back" || direction === "forward") callback(direction);
};
ipcRenderer.on("mail-navigation", listener);
return () => ipcRenderer.removeListener("mail-navigation", listener);
},
onMailChanged: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("mail-changed", listener);
return () => ipcRenderer.removeListener("mail-changed", listener);
},
oauthUrl: "http://127.0.0.1:32145/api/oauth/google",
windowCommand: (command: "minimize" | "maximize" | "close") =>
ipcRenderer.send("window-command", command),
onWindowState: (callback: (maximized: boolean) => void) => {
const listener = (_event: Electron.IpcRendererEvent, maximized: boolean) =>
callback(maximized);
ipcRenderer.on("window-state", listener);
return () => ipcRenderer.removeListener("window-state", listener);
},
onAccountConnected: (callback: (error: string | null) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
error: string | null,
) => callback(error);
ipcRenderer.on("account-connected", listener);
return () => ipcRenderer.removeListener("account-connected", listener);
},
});
let notificationAudio: HTMLAudioElement | undefined;
let notificationAudioTimer: ReturnType<typeof setTimeout> | undefined;
ipcRenderer.on?.("notification-sound", (_event, data: unknown) => {
if (notificationAudioTimer) clearTimeout(notificationAudioTimer);
if (notificationAudio) { notificationAudio.pause(); notificationAudio.removeAttribute("src"); notificationAudio.load(); notificationAudio = undefined; }
if (typeof data !== "string" || data.length > 2800000 || !/^data:audio\/(wav|mpeg|ogg);base64,[A-Za-z0-9+/]+=*$/.test(data)) return;
const audio = notificationAudio = new Audio(data);
const stop = () => {
if (notificationAudio !== audio) return;
audio.onended = null; audio.onerror = null;
audio.pause(); audio.removeAttribute("src"); audio.load();
if (notificationAudio === audio) notificationAudio = undefined;
if (notificationAudioTimer) clearTimeout(notificationAudioTimer);
};
audio.onended = stop;
audio.onerror = stop;
notificationAudioTimer = setTimeout(stop, 30000);
void audio.play().catch(stop);
});