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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user