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

@@ -24,14 +24,31 @@ async function importPreloadModule() {
await import("../preload.ts");
}
function getExposedApi() {
const call = mocks.contextBridge.exposeInMainWorld.mock.calls[0] as
| [string, {
getAppVersion: () => Promise<string>;
quit: () => void;
onDashboardReady: (callback: () => void) => () => void;
}]
| undefined;
function getExposedFusionDesktopApi() {
const call = mocks.contextBridge.exposeInMainWorld.mock.calls.find(
(entry) => entry[0] === "fusionDesktop",
) as [string, {
getAppVersion: () => Promise<string>;
quit: () => void;
onDashboardReady: (callback: () => void) => () => void;
}] | undefined;
return call?.[1];
}
function getExposedElectronApi() {
const call = mocks.contextBridge.exposeInMainWorld.mock.calls.find(
(entry) => entry[0] === "electronAPI",
) as [string, {
invoke: (channel: string, payload?: unknown) => Promise<unknown>;
apiRequest: (method: string, path: string, body?: unknown) => Promise<unknown>;
getServerPort: () => Promise<number>;
windowControl: (action: string) => Promise<boolean | void>;
onUpdateAvailable: (callback: (info: Record<string, unknown>) => void) => () => void;
installUpdate: () => Promise<void>;
onDeepLink: (callback: (url: string) => void) => () => void;
getPlatform: () => Promise<string>;
}] | undefined;
return call?.[1];
}
@@ -42,39 +59,43 @@ describe("preload", () => {
vi.resetModules();
});
it("contextBridge.exposeInMainWorld called with fusionDesktop", async () => {
it("exposes fusionDesktop and electronAPI", async () => {
await importPreloadModule();
expect(mocks.contextBridge.exposeInMainWorld).toHaveBeenCalledWith(
"fusionDesktop",
expect.any(Object),
);
expect(mocks.contextBridge.exposeInMainWorld).toHaveBeenCalledWith(
"electronAPI",
expect.any(Object),
);
});
it("getAppVersion calls ipcRenderer.invoke", async () => {
it("fusionDesktop.getAppVersion calls ipcRenderer.invoke", async () => {
mocks.ipcRenderer.invoke.mockResolvedValue("0.1.0");
await importPreloadModule();
const api = getExposedApi();
const api = getExposedFusionDesktopApi();
const version = await api?.getAppVersion();
expect(version).toBe("0.1.0");
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("app:get-version");
});
it("quit calls ipcRenderer.send", async () => {
it("fusionDesktop.quit calls ipcRenderer.send", async () => {
await importPreloadModule();
const api = getExposedApi();
const api = getExposedFusionDesktopApi();
api?.quit();
expect(mocks.ipcRenderer.send).toHaveBeenCalledWith("app:quit");
});
it("onDashboardReady returns unsubscribe function", async () => {
it("fusionDesktop.onDashboardReady returns unsubscribe function", async () => {
await importPreloadModule();
const api = getExposedApi();
const api = getExposedFusionDesktopApi();
const callback = vi.fn();
const unsubscribe = api?.onDashboardReady(callback);
@@ -91,4 +112,43 @@ describe("preload", () => {
expect.any(Function),
);
});
it("electronAPI methods invoke expected IPC channels", async () => {
await importPreloadModule();
const api = getExposedElectronApi();
await api?.invoke("api-request", { method: "GET", path: "/tasks" });
await api?.apiRequest("POST", "/tasks", { title: "Task" });
await api?.getServerPort();
await api?.windowControl("maximize");
await api?.installUpdate();
await api?.getPlatform();
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("api-request", { method: "GET", path: "/tasks" });
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("api-request", { method: "POST", path: "/tasks", body: { title: "Task" } });
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("server:get-port");
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:control", "maximize");
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("update:install");
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("system:get-platform");
});
it("electronAPI event subscriptions provide unsubscribe functions", async () => {
await importPreloadModule();
const api = getExposedElectronApi();
const onUpdate = vi.fn();
const onDeepLink = vi.fn();
const unsubscribeUpdate = api?.onUpdateAvailable(onUpdate);
const unsubscribeDeepLink = api?.onDeepLink(onDeepLink);
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("update:available", expect.any(Function));
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("deep-link", expect.any(Function));
unsubscribeUpdate?.();
unsubscribeDeepLink?.();
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith("update:available", expect.any(Function));
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith("deep-link", expect.any(Function));
});
});

View File

@@ -6,6 +6,40 @@ import { setupTray, updateTrayStatus } from "./tray.js";
export const DASHBOARD_URL = process.env.FUSION_DASHBOARD_URL || "http://localhost:4040";
interface ApiRequestPayload {
method?: string;
path: string;
body?: unknown;
headers?: Record<string, string>;
port?: number;
}
function createDashboardBaseUrl(portOverride?: number): URL {
const dashboardUrl = new URL(DASHBOARD_URL);
if (typeof portOverride === "number" && Number.isFinite(portOverride)) {
dashboardUrl.port = String(portOverride);
}
return dashboardUrl;
}
function getDashboardPort(): number {
const dashboardUrl = createDashboardBaseUrl();
const port = Number.parseInt(dashboardUrl.port || "", 10);
if (!Number.isFinite(port) || port <= 0) {
return dashboardUrl.protocol === "https:" ? 443 : 80;
}
return port;
}
function buildApiUrl(path: string, portOverride?: number): string {
const normalizedPath = path.startsWith("/api")
? path
: `/api${path.startsWith("/") ? path : `/${path}`}`;
return new URL(normalizedPath, createDashboardBaseUrl(portOverride)).toString();
}
export function createMainWindow(): BrowserWindow {
const mainWindow = new BrowserWindow({
width: 1280,
@@ -25,6 +59,104 @@ export function createMainWindow(): BrowserWindow {
export function registerIpcHandlers(): void {
ipcMain.handle("app:get-version", () => app.getVersion());
ipcMain.on("app:quit", () => app.quit());
ipcMain.handle("server:get-port", () => getDashboardPort());
ipcMain.handle("api-request", async (_event, payload: ApiRequestPayload) => {
const method = (payload.method ?? "GET").toUpperCase();
const headers: Record<string, string> = { ...(payload.headers ?? {}) };
let requestBody: string | undefined;
if (payload.body !== undefined && method !== "GET" && method !== "HEAD") {
if (typeof payload.body === "string") {
requestBody = payload.body;
} else {
requestBody = JSON.stringify(payload.body);
if (!Object.keys(headers).some((key) => key.toLowerCase() === "content-type")) {
headers["Content-Type"] = "application/json";
}
}
}
try {
const response = await fetch(buildApiUrl(payload.path, payload.port), {
method,
headers,
body: requestBody,
});
const responseText = await response.text();
const responseContentType = response.headers.get("content-type") ?? "";
let responseData: unknown = responseText;
if (responseContentType.includes("application/json")) {
try {
responseData = responseText ? JSON.parse(responseText) : null;
} catch {
responseData = responseText;
}
}
const responseError = response.ok
? undefined
: (typeof responseData === "object" && responseData && "error" in responseData
? String((responseData as { error?: string }).error ?? "Request failed")
: responseText || `Request failed (${response.status})`);
return {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
data: responseData,
error: responseError,
};
} catch (error) {
return {
status: 500,
statusText: "Internal Error",
headers: {},
data: null,
error: error instanceof Error ? error.message : "Failed to process API request",
};
}
});
ipcMain.handle("window:control", (event, action: "minimize" | "maximize" | "close" | "isMaximized") => {
const targetWindow = BrowserWindow.fromWebContents(event.sender);
if (!targetWindow) {
return false;
}
switch (action) {
case "minimize":
targetWindow.minimize();
return false;
case "maximize": {
const willMaximize = !targetWindow.isMaximized();
if (willMaximize) {
targetWindow.maximize();
} else {
targetWindow.unmaximize();
}
return willMaximize;
}
case "close":
targetWindow.close();
return false;
case "isMaximized":
return targetWindow.isMaximized();
default:
return false;
}
});
ipcMain.handle("update:install", async () => {
// Auto-update wiring is implemented by FN-1071. Keep this as a safe no-op
// so renderer hooks can call installUpdate() without exploding.
return;
});
ipcMain.handle("system:get-platform", () => process.platform);
}
export function run(): void {

View File

@@ -1,3 +1,5 @@
import type { ElectronAPI } from "./renderer/types";
export interface FusionDesktopAPI {
getAppVersion(): Promise<string>;
quit(): void;
@@ -7,5 +9,6 @@ export interface FusionDesktopAPI {
declare global {
interface Window {
fusionDesktop: FusionDesktopAPI;
electronAPI?: ElectronAPI;
}
}

View File

@@ -1,4 +1,5 @@
import { contextBridge, ipcRenderer } from "electron";
import type { ElectronAPI, ElectronApiResponsePayload, WindowControlAction } from "./renderer/types";
export interface FusionDesktopAPI {
getAppVersion(): Promise<string>;
@@ -6,7 +7,7 @@ export interface FusionDesktopAPI {
onDashboardReady(callback: () => void): () => void;
}
const api: FusionDesktopAPI = {
const fusionDesktop: FusionDesktopAPI = {
getAppVersion(): Promise<string> {
return ipcRenderer.invoke("app:get-version");
},
@@ -22,4 +23,44 @@ const api: FusionDesktopAPI = {
},
};
contextBridge.exposeInMainWorld("fusionDesktop", api);
const electronAPI: ElectronAPI = {
invoke(channel: string, payload?: unknown): Promise<unknown> {
return ipcRenderer.invoke(channel, payload);
},
apiRequest(method: string, path: string, body?: unknown): Promise<ElectronApiResponsePayload> {
return ipcRenderer.invoke("api-request", { method, path, body });
},
getServerPort(): Promise<number> {
return ipcRenderer.invoke("server:get-port");
},
windowControl(action: WindowControlAction): Promise<boolean | void> {
return ipcRenderer.invoke("window:control", action);
},
onUpdateAvailable(callback: (info: Record<string, unknown>) => void): () => void {
const listener = (_event: unknown, info: Record<string, unknown>) => {
callback(info);
};
ipcRenderer.on("update:available", listener);
return () => {
ipcRenderer.removeListener("update:available", listener);
};
},
installUpdate(): Promise<void> {
return ipcRenderer.invoke("update:install");
},
onDeepLink(callback: (url: string) => void): () => void {
const listener = (_event: unknown, url: string) => {
callback(url);
};
ipcRenderer.on("deep-link", listener);
return () => {
ipcRenderer.removeListener("deep-link", listener);
};
},
getPlatform() {
return ipcRenderer.invoke("system:get-platform");
},
};
contextBridge.exposeInMainWorld("fusionDesktop", fusionDesktop);
contextBridge.exposeInMainWorld("electronAPI", electronAPI);

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 {};