feat(FN-1072): add Electron desktop renderer integration

- Add Electron main-process IPC handlers and preload bridges for API proxying, window controls, update install, and platform lookup
- Introduce a desktop renderer entrypoint with DesktopWrapper and a custom frameless TitleBar component
- Add Electron-aware renderer utilities including API transport selection and hooks for runtime detection, auto-update events, and deep links
- Update dashboard header behavior for Electron mode and expand desktop tests across preload, transport, hooks, and title bar flows
- Document the desktop renderer architecture and update desktop package config/dependencies
This commit is contained in:
gsxdsm
2026-04-08 00:16:25 -07:00
parent 8c5b5e128c
commit a4a99f31b0
27 changed files with 1325 additions and 28 deletions

View File

@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import React from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TitleBar } from "../components/TitleBar";
describe("TitleBar", () => {
beforeEach(() => {
(globalThis.window as Window & { electronAPI?: unknown }).electronAPI = {
windowControl: vi.fn(async (action: string) => action === "isMaximized" ? false : undefined),
getPlatform: vi.fn().mockResolvedValue("win32"),
};
});
afterEach(() => {
cleanup();
(globalThis.window as Window & { electronAPI?: unknown }).electronAPI = undefined;
vi.clearAllMocks();
});
it("renders the Fusion title", () => {
render(<TitleBar />);
expect(screen.getByText("Fusion")).toBeTruthy();
});
it("calls window controls through electronAPI", () => {
render(<TitleBar />);
const api = (globalThis.window as Window & {
electronAPI: { windowControl: ReturnType<typeof vi.fn> };
}).electronAPI;
fireEvent.click(screen.getByTestId("titlebar-minimize"));
fireEvent.click(screen.getByTestId("titlebar-maximize"));
fireEvent.click(screen.getByTestId("titlebar-close"));
expect(api.windowControl).toHaveBeenCalledWith("minimize");
expect(api.windowControl).toHaveBeenCalledWith("maximize");
expect(api.windowControl).toHaveBeenCalledWith("close");
});
it("applies drag region on the title bar", () => {
render(<TitleBar />);
const titlebar = screen.getByTestId("desktop-titlebar");
expect(titlebar.className).toContain("desktop-titlebar--drag");
});
it("double-click toggles maximize", () => {
render(<TitleBar />);
const titlebar = screen.getByTestId("desktop-titlebar");
const api = (globalThis.window as Window & {
electronAPI: { windowControl: ReturnType<typeof vi.fn> };
}).electronAPI;
fireEvent.doubleClick(titlebar);
expect(api.windowControl).toHaveBeenCalledWith("maximize");
});
});

View File

@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createApiClient, ElectronApiTransport } from "../api-electron";
describe("api-electron", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("returns IPC transport when window.electronAPI exists", () => {
const client = createApiClient({
electronAPI: {
invoke: vi.fn(),
},
});
expect(client.mode).toBe("electron");
expect(client.transport).toBeInstanceOf(ElectronApiTransport);
});
it("returns fetch transport when window.electronAPI is absent", () => {
const client = createApiClient({});
expect(client.mode).toBe("web");
expect(client.transport.constructor.name).toBe("FetchApiTransport");
});
it("IPC transport calls electronAPI.invoke with request payload", async () => {
const invoke = vi.fn().mockResolvedValue({ status: 200, data: { ok: true } });
const getServerPort = vi.fn().mockResolvedValue(4040);
const client = createApiClient({
electronAPI: { invoke, getServerPort },
});
const result = await client.transport.request<{ ok: boolean }>("/tasks", {
method: "POST",
headers: { "x-test": "1" },
body: JSON.stringify({ title: "hello" }),
});
expect(result).toEqual({ ok: true });
expect(invoke).toHaveBeenCalledWith("api-request", {
method: "POST",
path: "/tasks",
headers: { "x-test": "1" },
body: { title: "hello" },
port: 4040,
});
});
it("resolves server port dynamically once and reuses it", async () => {
const invoke = vi.fn().mockResolvedValue({ status: 200, data: { ok: true } });
const getServerPort = vi.fn().mockResolvedValue(5050);
const client = createApiClient({
electronAPI: { invoke, getServerPort },
});
await client.transport.request("/tasks");
await client.transport.request("/settings");
expect(getServerPort).toHaveBeenCalledTimes(1);
expect(invoke).toHaveBeenNthCalledWith(1, "api-request", expect.objectContaining({ port: 5050, path: "/tasks" }));
expect(invoke).toHaveBeenNthCalledWith(2, "api-request", expect.objectContaining({ port: 5050, path: "/settings" }));
});
});

View File

@@ -0,0 +1,54 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoUpdate } from "../hooks/useAutoUpdate";
describe("useAutoUpdate", () => {
afterEach(() => {
cleanup();
window.electronAPI = undefined;
vi.clearAllMocks();
});
it("subscribes to update notifications and exposes update info", async () => {
let onUpdateAvailable: ((info: Record<string, unknown>) => void) | undefined;
const installUpdate = vi.fn().mockResolvedValue(undefined);
window.electronAPI = {
onUpdateAvailable: (callback) => {
onUpdateAvailable = callback;
return () => {
onUpdateAvailable = undefined;
};
},
installUpdate,
};
const { result } = renderHook(() => useAutoUpdate());
act(() => {
onUpdateAvailable?.({ version: "1.2.3" });
});
expect(result.current.updateAvailable).toBe(true);
expect(result.current.updateInfo).toEqual({ version: "1.2.3" });
await act(async () => {
await result.current.downloadAndInstall();
});
expect(installUpdate).toHaveBeenCalledTimes(1);
});
it("returns safe defaults when not running in Electron", async () => {
window.electronAPI = undefined;
const { result } = renderHook(() => useAutoUpdate());
expect(result.current.updateAvailable).toBe(false);
expect(result.current.updateInfo).toBeNull();
await expect(result.current.downloadAndInstall()).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,59 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { parseDeepLink, useDeepLink } from "../hooks/useDeepLink";
describe("parseDeepLink", () => {
it("parses task and project links", () => {
expect(parseDeepLink("fusion://task/FN-100")).toBe("fusion://task/FN-100");
expect(parseDeepLink("fusion://project/main")).toBe("fusion://project/main");
});
it("rejects unsupported links", () => {
expect(parseDeepLink("https://example.com")).toBeNull();
expect(parseDeepLink("fusion://invalid/test")).toBeNull();
expect(parseDeepLink("fusion://task")).toBeNull();
});
});
describe("useDeepLink", () => {
afterEach(() => {
cleanup();
window.electronAPI = undefined;
});
it("captures deep link events from Electron", () => {
let onDeepLink: ((url: string) => void) | undefined;
window.electronAPI = {
onDeepLink: (callback) => {
onDeepLink = callback;
return () => {
onDeepLink = undefined;
};
},
};
const { result } = renderHook(() => useDeepLink());
act(() => {
onDeepLink?.("fusion://task/FN-321");
});
expect(result.current.lastDeepLink).toBe("fusion://task/FN-321");
act(() => {
onDeepLink?.("https://example.com/not-supported");
});
expect(result.current.lastDeepLink).toBe("fusion://task/FN-321");
});
it("defaults to null outside Electron", () => {
window.electronAPI = undefined;
const { result } = renderHook(() => useDeepLink());
expect(result.current.lastDeepLink).toBeNull();
});
});

View File

@@ -0,0 +1,32 @@
// @vitest-environment jsdom
import { cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { useElectron } from "../hooks/useElectron";
describe("useElectron", () => {
afterEach(() => {
cleanup();
window.electronAPI = undefined;
});
it("returns electron context when electronAPI exists", () => {
window.electronAPI = {
getServerPort: async () => 4040,
};
const { result } = renderHook(() => useElectron());
expect(result.current.isElectron).toBe(true);
expect(result.current.electronAPI).toBe(window.electronAPI);
});
it("returns web defaults when electronAPI is absent", () => {
window.electronAPI = undefined;
const { result } = renderHook(() => useElectron());
expect(result.current.isElectron).toBe(false);
expect(result.current.electronAPI).toBeNull();
});
});

View File

@@ -0,0 +1,171 @@
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
export interface ApiRequestPayload {
method: HttpMethod;
path: string;
body?: unknown;
headers?: Record<string, string>;
port?: number;
}
export interface ApiResponsePayload {
status: number;
statusText?: string;
headers?: Record<string, string>;
data?: unknown;
error?: string;
}
export interface ElectronApiLike {
invoke?: (channel: string, payload?: unknown) => Promise<unknown>;
getServerPort?: () => Promise<number>;
}
export interface WindowLike {
electronAPI?: ElectronApiLike;
}
export interface ApiTransport {
request<T = unknown>(path: string, opts?: RequestInit): Promise<T>;
}
export interface ApiClient {
mode: "electron" | "web";
transport: ApiTransport;
}
function normalizeMethod(method?: string): HttpMethod {
const normalized = (method ?? "GET").toUpperCase();
switch (normalized) {
case "POST":
case "PUT":
case "PATCH":
case "DELETE":
return normalized as HttpMethod;
default:
return "GET";
}
}
function parseJsonBody(body: BodyInit | null | undefined): unknown {
if (typeof body !== "string") {
return body;
}
try {
return JSON.parse(body);
} catch {
return body;
}
}
function toHeaderRecord(headers: HeadersInit | undefined): Record<string, string> {
if (!headers) {
return {};
}
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries());
}
if (Array.isArray(headers)) {
return Object.fromEntries(headers);
}
return { ...headers };
}
export class FetchApiTransport implements ApiTransport {
async request<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
const response = await fetch(`/api${path}`, {
headers: { "Content-Type": "application/json" },
...opts,
});
if (response.status === 204) {
return undefined as T;
}
const payload = (await response.json()) as unknown;
if (!response.ok) {
const errorMessage = (payload as { error?: string }).error ?? `Request failed: ${response.status}`;
throw new Error(errorMessage);
}
return payload as T;
}
}
export class ElectronApiTransport implements ApiTransport {
private readonly electronApi: ElectronApiLike;
private serverPortPromise: Promise<number | undefined> | null = null;
constructor(electronApi: ElectronApiLike) {
this.electronApi = electronApi;
}
private async resolveServerPort(): Promise<number | undefined> {
if (this.serverPortPromise) {
return this.serverPortPromise;
}
this.serverPortPromise = (async () => {
if (!this.electronApi.getServerPort) {
return undefined;
}
try {
return await this.electronApi.getServerPort();
} catch {
return undefined;
}
})();
return this.serverPortPromise;
}
async request<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
if (!this.electronApi.invoke) {
throw new Error("electronAPI.invoke is not available");
}
const port = await this.resolveServerPort();
const payload: ApiRequestPayload = {
method: normalizeMethod(opts.method),
path,
headers: toHeaderRecord(opts.headers),
body: parseJsonBody(opts.body),
port,
};
const result = (await this.electronApi.invoke("api-request", payload)) as ApiResponsePayload;
if (result.status === 204) {
return undefined as T;
}
if (result.status >= 400) {
throw new Error(result.error ?? `Request failed: ${result.status}`);
}
return result.data as T;
}
}
export function isElectronEnvironment(windowObject: WindowLike | undefined): boolean {
return Boolean(windowObject?.electronAPI);
}
export function createApiClient(windowObject: WindowLike | undefined = typeof window !== "undefined" ? window : undefined): ApiClient {
if (isElectronEnvironment(windowObject)) {
return {
mode: "electron",
transport: new ElectronApiTransport(windowObject!.electronAPI!),
};
}
return {
mode: "web",
transport: new FetchApiTransport(),
};
}

View File

@@ -0,0 +1,37 @@
import React, { useEffect } from "react";
import type { PropsWithChildren } from "react";
import { TitleBar } from "./TitleBar";
function detectElectron(): boolean {
if (typeof window === "undefined") {
return false;
}
return Boolean((window as Window & { electronAPI?: unknown }).electronAPI);
}
export function DesktopWrapper({ children }: PropsWithChildren) {
const isElectron = detectElectron();
useEffect(() => {
if (!isElectron) {
return;
}
document.body.classList.add("fusion-desktop");
return () => {
document.body.classList.remove("fusion-desktop");
};
}, [isElectron]);
if (!isElectron) {
return <>{children}</>;
}
return (
<div className="desktop-app-shell" data-testid="desktop-wrapper">
<TitleBar />
<div className="desktop-app-content">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
:root {
--desktop-titlebar-height: 38px;
}
.desktop-titlebar {
height: var(--desktop-titlebar-height);
min-height: var(--desktop-titlebar-height);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 10px;
border-bottom: 1px solid var(--border);
background: var(--surface);
color: var(--text);
user-select: none;
flex-shrink: 0;
}
.desktop-titlebar--drag {
-webkit-app-region: drag;
}
.desktop-titlebar__brand {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.desktop-titlebar__logo {
width: 16px;
height: 16px;
color: var(--logo-accent, var(--todo));
flex-shrink: 0;
}
.desktop-titlebar__title {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--text);
}
.desktop-titlebar__controls {
display: inline-flex;
align-items: center;
gap: 4px;
}
.desktop-titlebar__controls--no-drag,
.desktop-titlebar__control {
-webkit-app-region: no-drag;
}
.desktop-titlebar__control {
width: 28px;
height: 24px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 13px;
line-height: 1;
transition: background-color 0.15s ease, color 0.15s ease;
}
.desktop-titlebar__control:hover {
background: var(--card);
color: var(--text);
}
.desktop-titlebar__control:focus-visible {
outline: 2px solid var(--todo);
outline-offset: 1px;
}
.desktop-titlebar__control--close:hover {
background: color-mix(in srgb, var(--color-error) 20%, transparent);
color: var(--color-error);
}
.desktop-app-shell {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.desktop-app-content {
flex: 1;
min-height: 0;
overflow: hidden;
}
body.fusion-desktop {
overflow: hidden;
}

View File

@@ -0,0 +1,125 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import "./TitleBar.css";
type WindowControlAction = "minimize" | "maximize" | "close" | "isMaximized";
type Platform = "darwin" | "win32" | "linux";
interface ElectronApiForTitleBar {
windowControl?: (action: WindowControlAction) => Promise<boolean | void>;
getPlatform?: () => Promise<Platform>;
}
function getElectronApi(): ElectronApiForTitleBar | null {
if (typeof window === "undefined") {
return null;
}
const maybeApi = (window as Window & { electronAPI?: ElectronApiForTitleBar }).electronAPI;
return maybeApi ?? null;
}
export function TitleBar() {
const electronApi = getElectronApi();
const [isMaximized, setIsMaximized] = useState(false);
const [platform, setPlatform] = useState<Platform | null>(null);
useEffect(() => {
if (!electronApi?.windowControl) {
return;
}
void electronApi.windowControl("isMaximized").then((value) => {
setIsMaximized(Boolean(value));
}).catch(() => {
setIsMaximized(false);
});
}, [electronApi]);
useEffect(() => {
if (!electronApi?.getPlatform) {
return;
}
void electronApi.getPlatform().then(setPlatform).catch(() => {
setPlatform(null);
});
}, [electronApi]);
const handleWindowControl = useCallback(async (action: Exclude<WindowControlAction, "isMaximized">) => {
if (!electronApi?.windowControl) {
return;
}
const result = await electronApi.windowControl(action);
if (action === "maximize") {
if (typeof result === "boolean") {
setIsMaximized(result);
} else {
setIsMaximized((prev) => !prev);
}
}
}, [electronApi]);
const handleTitleDoubleClick = useCallback(() => {
void handleWindowControl("maximize");
}, [handleWindowControl]);
const maximizeGlyph = useMemo(() => (isMaximized ? "⧉" : "□"), [isMaximized]);
const controlsOnLeft = platform === "darwin";
const controls = (
<div className="desktop-titlebar__controls desktop-titlebar__controls--no-drag">
<button
type="button"
className="desktop-titlebar__control"
onClick={() => void handleWindowControl("minimize")}
aria-label="Minimize window"
title="Minimize"
data-testid="titlebar-minimize"
>
</button>
<button
type="button"
className="desktop-titlebar__control"
onClick={() => void handleWindowControl("maximize")}
aria-label={isMaximized ? "Restore window" : "Maximize window"}
title={isMaximized ? "Restore" : "Maximize"}
data-testid="titlebar-maximize"
>
{maximizeGlyph}
</button>
<button
type="button"
className="desktop-titlebar__control desktop-titlebar__control--close"
onClick={() => void handleWindowControl("close")}
aria-label="Close window"
title="Close"
data-testid="titlebar-close"
>
×
</button>
</div>
);
return (
<header
className="desktop-titlebar desktop-titlebar--drag"
onDoubleClick={handleTitleDoubleClick}
data-testid="desktop-titlebar"
>
{controlsOnLeft ? controls : null}
<div className="desktop-titlebar__brand">
<svg className="desktop-titlebar__logo" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
<circle cx="5" cy="5" r="2.5" fill="currentColor" />
<circle cx="11" cy="5" r="2.5" fill="currentColor" />
<circle cx="5" cy="11" r="2.5" fill="currentColor" />
<circle cx="11" cy="11" r="2.5" fill="currentColor" />
</svg>
<span className="desktop-titlebar__title">Fusion</span>
</div>
{controlsOnLeft ? null : controls}
</header>
);
}

View File

@@ -0,0 +1,3 @@
export * from "./useElectron";
export * from "./useAutoUpdate";
export * from "./useDeepLink";

View File

@@ -0,0 +1,45 @@
import { useCallback, useEffect, useState } from "react";
import { useElectron } from "./useElectron";
export interface UseAutoUpdateResult {
updateAvailable: boolean;
updateInfo: Record<string, unknown> | null;
downloadAndInstall: () => Promise<void>;
}
export function useAutoUpdate(): UseAutoUpdateResult {
const { isElectron, electronAPI } = useElectron();
const [updateAvailable, setUpdateAvailable] = useState(false);
const [updateInfo, setUpdateInfo] = useState<Record<string, unknown> | null>(null);
useEffect(() => {
if (!isElectron || !electronAPI?.onUpdateAvailable) {
return;
}
const unsubscribe = electronAPI.onUpdateAvailable((info: Record<string, unknown>) => {
setUpdateAvailable(true);
setUpdateInfo(info);
});
return () => {
if (typeof unsubscribe === "function") {
unsubscribe();
}
};
}, [isElectron, electronAPI]);
const downloadAndInstall = useCallback(async () => {
if (!isElectron || !electronAPI?.installUpdate) {
return;
}
await electronAPI.installUpdate();
}, [isElectron, electronAPI]);
return {
updateAvailable,
updateInfo,
downloadAndInstall,
};
}

View File

@@ -0,0 +1,57 @@
import { useEffect, useState } from "react";
import { useElectron } from "./useElectron";
export function parseDeepLink(rawLink: string): string | null {
try {
const parsed = new URL(rawLink);
if (parsed.protocol !== "fusion:") {
return null;
}
const segments = parsed.pathname.split("/").filter(Boolean);
const resource = parsed.hostname || segments[0];
const identifier = parsed.hostname ? segments[0] : segments[1];
if (!identifier) {
return null;
}
if (resource === "task" || resource === "project") {
return `fusion://${resource}/${decodeURIComponent(identifier)}`;
}
return null;
} catch {
return null;
}
}
export interface UseDeepLinkResult {
lastDeepLink: string | null;
}
export function useDeepLink(): UseDeepLinkResult {
const { isElectron, electronAPI } = useElectron();
const [lastDeepLink, setLastDeepLink] = useState<string | null>(null);
useEffect(() => {
if (!isElectron || !electronAPI?.onDeepLink) {
return;
}
const unsubscribe = electronAPI.onDeepLink((rawLink: string) => {
const parsed = parseDeepLink(rawLink);
if (parsed) {
setLastDeepLink(parsed);
}
});
return () => {
if (typeof unsubscribe === "function") {
unsubscribe();
}
};
}, [isElectron, electronAPI]);
return { lastDeepLink };
}

View File

@@ -0,0 +1,21 @@
import { useMemo } from "react";
import type { ElectronAPI } from "../types";
export interface UseElectronResult {
isElectron: boolean;
electronAPI: ElectronAPI | null;
}
export function useElectron(): UseElectronResult {
return useMemo(() => {
if (typeof window === "undefined") {
return { isElectron: false, electronAPI: null };
}
const electronAPI = window.electronAPI ?? null;
return {
isElectron: Boolean(electronAPI),
electronAPI,
};
}, []);
}

View File

@@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fusion</title>
<script>
// Theme initialization - runs before React to prevent flash
(function () {
try {
var mode = localStorage.getItem("kb-dashboard-theme-mode") || "dark";
var colorTheme = localStorage.getItem("kb-dashboard-color-theme") || "default";
var validThemes = [
"default",
"ocean",
"forest",
"sunset",
"zen",
"berry",
"high-contrast",
"industrial",
"monochrome",
"slate",
"ash",
"graphite",
"silver",
"solarized",
"factory",
"ayu",
"one-dark",
"nord",
"dracula",
"gruvbox",
"tokyo-night",
"catppuccin-mocha",
"github-dark",
"everforest",
"rose-pine",
"kanagawa",
"night-owl",
"palenight",
"monokai-pro",
"slime",
];
if (!validThemes.includes(colorTheme)) {
colorTheme = "default";
}
var systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
var effectiveMode = mode === "system" ? (systemDark ? "dark" : "light") : mode;
document.documentElement.setAttribute("data-theme", effectiveMode);
document.documentElement.setAttribute("data-color-theme", colorTheme);
} catch (error) {
document.documentElement.setAttribute("data-theme", "dark");
document.documentElement.setAttribute("data-color-theme", "default");
}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,54 @@
import React, { StrictMode, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { DesktopWrapper } from "./components/DesktopWrapper";
const dashboardStylesModulePath = "../../../../dashboard/app/styles.css";
const dashboardAppModulePath = "../../../../dashboard/app/App";
void import(/* @vite-ignore */ dashboardStylesModulePath).catch(() => {
// Dashboard styles are loaded in the web app bundle; the desktop renderer
// best-effort imports them for shared theming.
});
function RendererApp() {
const [AppComponent, setAppComponent] = useState<React.ComponentType | null>(null);
useEffect(() => {
let cancelled = false;
void import(/* @vite-ignore */ dashboardAppModulePath)
.then((module) => {
if (!cancelled) {
setAppComponent(() => (module as { App?: React.ComponentType }).App ?? null);
}
})
.catch((error: unknown) => {
console.error("Failed to load dashboard App for desktop renderer", error);
});
return () => {
cancelled = true;
};
}, []);
if (!AppComponent) {
return null;
}
return (
<DesktopWrapper>
<AppComponent />
</DesktopWrapper>
);
}
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Missing #root element for desktop renderer");
}
createRoot(rootElement).render(
<StrictMode>
<RendererApp />
</StrictMode>,
);

View File

@@ -0,0 +1,38 @@
export type DesktopPlatform = "darwin" | "win32" | "linux";
export type WindowControlAction = "minimize" | "maximize" | "close" | "isMaximized";
export interface ElectronApiRequestPayload {
method: string;
path: string;
body?: unknown;
headers?: Record<string, string>;
port?: number;
}
export interface ElectronApiResponsePayload {
status: number;
statusText?: string;
headers?: Record<string, string>;
data?: unknown;
error?: string;
}
export interface ElectronAPI {
invoke?: (channel: string, payload?: unknown) => Promise<unknown>;
apiRequest?: (method: string, path: string, body?: unknown) => Promise<ElectronApiResponsePayload>;
getServerPort?: () => Promise<number>;
windowControl?: (action: WindowControlAction) => Promise<boolean | void>;
onUpdateAvailable?: (callback: (info: Record<string, unknown>) => void) => (() => void) | void;
installUpdate?: () => Promise<void>;
onDeepLink?: (callback: (url: string) => void) => (() => void) | void;
getPlatform?: () => Promise<DesktopPlatform>;
}
declare global {
interface Window {
electronAPI?: ElectronAPI;
}
}
export {};