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:
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
3
packages/desktop/src/preload.d.ts
vendored
3
packages/desktop/src/preload.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
63
packages/desktop/src/renderer/__tests__/TitleBar.test.tsx
Normal file
63
packages/desktop/src/renderer/__tests__/TitleBar.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
66
packages/desktop/src/renderer/__tests__/api-electron.test.ts
Normal file
66
packages/desktop/src/renderer/__tests__/api-electron.test.ts
Normal 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" }));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
59
packages/desktop/src/renderer/__tests__/useDeepLink.test.ts
Normal file
59
packages/desktop/src/renderer/__tests__/useDeepLink.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
32
packages/desktop/src/renderer/__tests__/useElectron.test.ts
Normal file
32
packages/desktop/src/renderer/__tests__/useElectron.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
171
packages/desktop/src/renderer/api-electron.ts
Normal file
171
packages/desktop/src/renderer/api-electron.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
37
packages/desktop/src/renderer/components/DesktopWrapper.tsx
Normal file
37
packages/desktop/src/renderer/components/DesktopWrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
packages/desktop/src/renderer/components/TitleBar.css
Normal file
102
packages/desktop/src/renderer/components/TitleBar.css
Normal 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;
|
||||
}
|
||||
125
packages/desktop/src/renderer/components/TitleBar.tsx
Normal file
125
packages/desktop/src/renderer/components/TitleBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
packages/desktop/src/renderer/hooks/index.ts
Normal file
3
packages/desktop/src/renderer/hooks/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./useElectron";
|
||||
export * from "./useAutoUpdate";
|
||||
export * from "./useDeepLink";
|
||||
45
packages/desktop/src/renderer/hooks/useAutoUpdate.ts
Normal file
45
packages/desktop/src/renderer/hooks/useAutoUpdate.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
57
packages/desktop/src/renderer/hooks/useDeepLink.ts
Normal file
57
packages/desktop/src/renderer/hooks/useDeepLink.ts
Normal 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 };
|
||||
}
|
||||
21
packages/desktop/src/renderer/hooks/useElectron.ts
Normal file
21
packages/desktop/src/renderer/hooks/useElectron.ts
Normal 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,
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
65
packages/desktop/src/renderer/index.html
Normal file
65
packages/desktop/src/renderer/index.html
Normal 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>
|
||||
54
packages/desktop/src/renderer/index.tsx
Normal file
54
packages/desktop/src/renderer/index.tsx
Normal 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>,
|
||||
);
|
||||
38
packages/desktop/src/renderer/types.ts
Normal file
38
packages/desktop/src/renderer/types.ts
Normal 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 {};
|
||||
Reference in New Issue
Block a user