Files
OmniRoute/src/shared/hooks/useElectron.ts
diegosouzapw d624ddde03 fix(electron): code review hardening — 16 fixes for security, performance, robustness
## Critical Fixes
- #1: Server readiness — waitForServer() polls before loading window
- #2: Restart timeout — 5s + SIGKILL prevents IPC handler from hanging
- #3: changePort — now stops/restarts server on new port

## Important Fixes
- #4: Tray cleanup — destroy old Tray before recreating
- #5: IPC emission — server-status & port-changed events
- #6: Disposer pattern — replaces removeAllListeners
- #7: useSyncExternalStore — eliminates 5x re-renders

## Minor: #8-#16 (dead code, CSP, platform titlebar, types, errors, version)

Tests: 76 / 15 suites (was 64/9)
2026-02-28 08:15:04 -03:00

198 lines
4.8 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useSyncExternalStore } from "react";
/**
* Code Review Fixes Applied:
* #7 useIsElectron — useSyncExternalStore for zero re-renders
* #11 Import AppInfo type instead of inline duplication
* #12 useDataDir — add error state (was swallowed silently)
*/
// ── Fix #7: Module-level detection (no state, no re-renders) ──
function getIsElectronSnapshot(): boolean {
return typeof window !== "undefined" && window.electronAPI?.isElectron === true;
}
function getServerSnapshot(): boolean {
return false; // SSR always returns false
}
const noop = () => () => {};
/**
* Check if running in Electron — zero re-renders via useSyncExternalStore
*/
export function useIsElectron(): boolean {
return useSyncExternalStore(noop, getIsElectronSnapshot, getServerSnapshot);
}
/**
* App info shape from Electron main process
* Fix #11: Single source of truth (matches electron/types.d.ts)
*/
interface AppInfo {
name: string;
version: string;
platform: string;
isDev: boolean;
port: number;
}
/**
* Get Electron app information
*/
export function useElectronAppInfo() {
const hasApi = getIsElectronSnapshot();
const [appInfo, setAppInfo] = useState<AppInfo | null>(null);
const [loading, setLoading] = useState(hasApi);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (typeof window === "undefined" || !window.electronAPI) return;
window.electronAPI
.getAppInfo()
.then((info) => {
setAppInfo(info);
setLoading(false);
})
.catch((err) => {
setError(err);
setLoading(false);
});
}, []);
return { appInfo, loading, error };
}
/**
* Get the data directory path
* Fix #12: Now exposes error state (was swallowed silently)
*/
export function useDataDir() {
const hasApi = getIsElectronSnapshot();
const [dataDir, setDataDir] = useState<string | null>(null);
const [loading, setLoading] = useState(hasApi);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (typeof window === "undefined" || !window.electronAPI) return;
window.electronAPI
.getDataDir()
.then((dir) => {
setDataDir(dir);
setLoading(false);
})
.catch((err) => {
setError(err instanceof Error ? err : new Error(String(err)));
setLoading(false);
});
}, []);
return { dataDir, loading, error };
}
/**
* Window controls for Electron
*/
export function useWindowControls() {
const isElectron = useIsElectron();
const minimize = useCallback(() => {
if (isElectron && window.electronAPI) {
window.electronAPI.minimizeWindow();
}
}, [isElectron]);
const maximize = useCallback(() => {
if (isElectron && window.electronAPI) {
window.electronAPI.maximizeWindow();
}
}, [isElectron]);
const close = useCallback(() => {
if (isElectron && window.electronAPI) {
window.electronAPI.closeWindow();
}
}, [isElectron]);
return { isElectron, minimize, maximize, close };
}
/**
* Open external URL in default browser
*/
export function useOpenExternal() {
const isElectron = useIsElectron();
const openExternal = useCallback(
async (url: string) => {
if (isElectron && window.electronAPI) {
await window.electronAPI.openExternal(url);
} else {
window.open(url, "_blank", "noopener,noreferrer");
}
},
[isElectron]
);
return { openExternal };
}
/**
* Server controls for Electron
*/
export function useServerControls() {
const isElectron = useIsElectron();
const [restarting, setRestarting] = useState(false);
const restart = useCallback(async () => {
if (!isElectron || !window.electronAPI) {
return { success: false };
}
setRestarting(true);
try {
const result = await window.electronAPI.restartServer();
return result;
} finally {
setRestarting(false);
}
}, [isElectron]);
return { isElectron, restart, restarting };
}
/**
* Listen for server status updates
* Fix #6: Uses disposer returned by preload for precise cleanup
*/
export function useServerStatus(onStatus: (status: { status: string; port: number }) => void) {
const isElectron = useIsElectron();
useEffect(() => {
if (!isElectron || !window.electronAPI) return;
const dispose = window.electronAPI.onServerStatus(onStatus);
return dispose;
}, [isElectron, onStatus]);
}
/**
* Listen for port changes
* Fix #6: Uses disposer returned by preload for precise cleanup
*/
export function usePortChanged(onPortChanged: (port: number) => void) {
const isElectron = useIsElectron();
useEffect(() => {
if (!isElectron || !window.electronAPI) return;
const dispose = window.electronAPI.onPortChanged(onPortChanged);
return dispose;
}, [isElectron, onPortChanged]);
}