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:
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