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

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

View File

@@ -0,0 +1,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,
};
}, []);
}