feat(FN-1075): add desktop native and deep-link APIs

- Add electron-updater dependency and implement native desktop integration utilities for dialogs, notifications, auto-update wiring, and window state persistence
- Add fusion:// deep-link protocol registration, URL parsing, single-instance handlers, and renderer event forwarding for task/project routes
- Add comprehensive Vitest coverage for native integrations and deep-link handling across success, edge, and failure cases
- Document native and deep-link API contracts, behavior, and usage details in the desktop package README
This commit is contained in:
gsxdsm
2026-04-07 23:36:29 -07:00
parent 5b6a695c53
commit d42ceb9890
7 changed files with 1155 additions and 0 deletions

View File

@@ -0,0 +1,273 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const appHandlers = new Map<string, (...args: unknown[]) => void>();
const app = {
setAsDefaultProtocolClient: vi.fn(() => true),
requestSingleInstanceLock: vi.fn(() => true),
quit: vi.fn(),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
appHandlers.set(event, handler);
return app;
}),
};
const browserWindow = {
isVisible: vi.fn(() => true),
show: vi.fn(),
isMinimized: vi.fn(() => false),
restore: vi.fn(),
focus: vi.fn(),
webContents: {
send: vi.fn(),
},
};
return {
app,
appHandlers,
browserWindow,
};
});
vi.mock("electron", () => ({
app: mocks.app,
BrowserWindow: vi.fn(() => mocks.browserWindow),
}));
async function importDeepLinkModule() {
return import("../deep-link.ts");
}
describe("deep-link module", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
mocks.appHandlers.clear();
mocks.app.setAsDefaultProtocolClient.mockReturnValue(true);
mocks.app.requestSingleInstanceLock.mockReturnValue(true);
mocks.browserWindow.isVisible.mockReturnValue(true);
mocks.browserWindow.isMinimized.mockReturnValue(false);
});
describe("parseDeepLink", () => {
it("parses task deep links", async () => {
const { parseDeepLink } = await importDeepLinkModule();
expect(parseDeepLink("fusion://task/FN-123")).toEqual({
type: "task",
id: "FN-123",
raw: "fusion://task/FN-123",
});
});
it("parses project deep links", async () => {
const { parseDeepLink } = await importDeepLinkModule();
expect(parseDeepLink("fusion://project/my-project")).toEqual({
type: "project",
id: "my-project",
raw: "fusion://project/my-project",
});
});
it("returns empty id for task links without an identifier", async () => {
const { parseDeepLink } = await importDeepLinkModule();
expect(parseDeepLink("fusion://task/")).toEqual({
type: "task",
id: "",
raw: "fusion://task/",
});
});
it("returns null for fusion root URL", async () => {
const { parseDeepLink } = await importDeepLinkModule();
expect(parseDeepLink("fusion://")).toBeNull();
});
it("returns null for empty input", async () => {
const { parseDeepLink } = await importDeepLinkModule();
expect(parseDeepLink("")).toBeNull();
});
it("returns null for non-fusion scheme", async () => {
const { parseDeepLink } = await importDeepLinkModule();
expect(parseDeepLink("https://example.com")).toBeNull();
});
it("returns null for unknown fusion hosts", async () => {
const { parseDeepLink } = await importDeepLinkModule();
expect(parseDeepLink("fusion://unknown/something")).toBeNull();
});
it("ignores extra path segments", async () => {
const { parseDeepLink } = await importDeepLinkModule();
expect(parseDeepLink("fusion://task/FN-123/extra/path")).toEqual({
type: "task",
id: "FN-123",
raw: "fusion://task/FN-123/extra/path",
});
});
it("decodes URL-encoded project identifiers", async () => {
const { parseDeepLink } = await importDeepLinkModule();
expect(parseDeepLink("fusion://project/my%20project")).toEqual({
type: "project",
id: "my project",
raw: "fusion://project/my%20project",
});
});
});
describe("registerDeepLinkProtocol", () => {
it("registers fusion as default protocol", async () => {
const { registerDeepLinkProtocol } = await importDeepLinkModule();
registerDeepLinkProtocol();
expect(mocks.app.setAsDefaultProtocolClient).toHaveBeenCalledWith("fusion");
});
it("does not throw when registration fails", async () => {
const { registerDeepLinkProtocol } = await importDeepLinkModule();
mocks.app.setAsDefaultProtocolClient.mockReturnValueOnce(false);
expect(() => registerDeepLinkProtocol()).not.toThrow();
});
});
describe("handleDeepLink", () => {
it("sends deep-link event for task URLs", async () => {
const { handleDeepLink } = await importDeepLinkModule();
handleDeepLink(mocks.browserWindow as never, "fusion://task/FN-123");
expect(mocks.browserWindow.webContents.send).toHaveBeenCalledWith("deep-link", {
type: "task",
id: "FN-123",
raw: "fusion://task/FN-123",
});
});
it("sends deep-link event for project URLs", async () => {
const { handleDeepLink } = await importDeepLinkModule();
handleDeepLink(mocks.browserWindow as never, "fusion://project/alpha");
expect(mocks.browserWindow.webContents.send).toHaveBeenCalledWith("deep-link", {
type: "project",
id: "alpha",
raw: "fusion://project/alpha",
});
});
it("does not send event for invalid URLs", async () => {
const { handleDeepLink } = await importDeepLinkModule();
handleDeepLink(mocks.browserWindow as never, "https://example.com");
expect(mocks.browserWindow.webContents.send).not.toHaveBeenCalled();
});
it("shows and focuses hidden windows", async () => {
const { handleDeepLink } = await importDeepLinkModule();
mocks.browserWindow.isVisible.mockReturnValueOnce(false);
handleDeepLink(mocks.browserWindow as never, "fusion://task/FN-999");
expect(mocks.browserWindow.show).toHaveBeenCalledTimes(1);
expect(mocks.browserWindow.focus).toHaveBeenCalledTimes(1);
});
it("restores minimized windows before focus", async () => {
const { handleDeepLink } = await importDeepLinkModule();
mocks.browserWindow.isMinimized.mockReturnValueOnce(true);
handleDeepLink(mocks.browserWindow as never, "fusion://task/FN-200");
expect(mocks.browserWindow.restore).toHaveBeenCalledTimes(1);
expect(mocks.browserWindow.focus).toHaveBeenCalledTimes(1);
});
});
describe("setupDeepLinkHandler", () => {
it("requests single instance lock", async () => {
const { setupDeepLinkHandler } = await importDeepLinkModule();
setupDeepLinkHandler(mocks.browserWindow as never);
expect(mocks.app.requestSingleInstanceLock).toHaveBeenCalledTimes(1);
});
it("quits when single instance lock is not granted", async () => {
const { setupDeepLinkHandler } = await importDeepLinkModule();
mocks.app.requestSingleInstanceLock.mockReturnValueOnce(false);
setupDeepLinkHandler(mocks.browserWindow as never);
expect(mocks.app.quit).toHaveBeenCalledTimes(1);
expect(mocks.app.on).not.toHaveBeenCalled();
});
it("registers open-url and second-instance handlers", async () => {
const { setupDeepLinkHandler } = await importDeepLinkModule();
setupDeepLinkHandler(mocks.browserWindow as never);
expect(mocks.app.on).toHaveBeenCalledWith("open-url", expect.any(Function));
expect(mocks.app.on).toHaveBeenCalledWith("second-instance", expect.any(Function));
});
it("open-url handler prevents default and routes URL", async () => {
const { setupDeepLinkHandler } = await importDeepLinkModule();
setupDeepLinkHandler(mocks.browserWindow as never);
const event = { preventDefault: vi.fn() };
mocks.appHandlers.get("open-url")?.(event, "fusion://task/FN-777");
expect(event.preventDefault).toHaveBeenCalledTimes(1);
expect(mocks.browserWindow.webContents.send).toHaveBeenCalledWith("deep-link", {
type: "task",
id: "FN-777",
raw: "fusion://task/FN-777",
});
});
it("second-instance handler extracts fusion URL from argv", async () => {
const { setupDeepLinkHandler } = await importDeepLinkModule();
setupDeepLinkHandler(mocks.browserWindow as never);
mocks.appHandlers.get("second-instance")?.({}, [
"electron",
"main.js",
"--flag",
"fusion://project/my-app",
]);
expect(mocks.browserWindow.webContents.send).toHaveBeenCalledWith("deep-link", {
type: "project",
id: "my-app",
raw: "fusion://project/my-app",
});
});
it("second-instance handler ignores argv without deep links", async () => {
const { setupDeepLinkHandler } = await importDeepLinkModule();
setupDeepLinkHandler(mocks.browserWindow as never);
mocks.appHandlers.get("second-instance")?.({}, ["electron", "main.js", "--help"]);
expect(mocks.browserWindow.webContents.send).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,473 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const showSaveDialog = vi.fn();
const showOpenDialog = vi.fn();
const app = {
getPath: vi.fn((name: string) => {
if (name === "documents") return "/mock/documents";
if (name === "userData") return "/mock/user-data";
return "/mock/other";
}),
};
const dialog = {
showSaveDialog,
showOpenDialog,
};
const notificationInstances: Array<{
show: ReturnType<typeof vi.fn>;
on: ReturnType<typeof vi.fn>;
emit: (event: string) => void;
options: Record<string, unknown>;
}> = [];
const Notification = vi.fn().mockImplementation((options: Record<string, unknown>) => {
const listeners = new Map<string, () => void>();
const instance = {
show: vi.fn(),
on: vi.fn((event: string, callback: () => void) => {
listeners.set(event, callback);
}),
emit: (event: string) => {
listeners.get(event)?.();
},
options,
};
notificationInstances.push(instance);
return instance;
});
Object.assign(Notification, {
isSupported: vi.fn(() => true),
});
const browserWindow = {
webContents: {
send: vi.fn(),
},
isDestroyed: vi.fn(() => false),
getBounds: vi.fn(() => ({
x: 40,
y: 60,
width: 1280,
height: 900,
})),
isMaximized: vi.fn(() => false),
};
const updaterHandlers = new Map<string, (...args: unknown[]) => void>();
const autoUpdater = {
autoDownload: false,
autoInstallOnAppQuit: false,
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
updaterHandlers.set(event, handler);
return autoUpdater;
}),
checkForUpdates: vi.fn(() => Promise.resolve()),
};
const readFile = vi.fn();
const writeFile = vi.fn(() => Promise.resolve());
const rename = vi.fn(() => Promise.resolve());
return {
app,
dialog,
Notification,
browserWindow,
autoUpdater,
updaterHandlers,
notificationInstances,
readFile,
writeFile,
rename,
};
});
vi.mock("electron", () => ({
app: mocks.app,
dialog: mocks.dialog,
Notification: mocks.Notification,
BrowserWindow: vi.fn(() => mocks.browserWindow),
}));
vi.mock("electron-updater", () => ({
autoUpdater: mocks.autoUpdater,
}));
vi.mock("node:fs/promises", () => ({
readFile: mocks.readFile,
writeFile: mocks.writeFile,
rename: mocks.rename,
}));
async function importNativeModule() {
return import("../native.ts");
}
describe("native integrations", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
vi.clearAllMocks();
vi.resetModules();
mocks.notificationInstances.length = 0;
mocks.updaterHandlers.clear();
mocks.autoUpdater.autoDownload = false;
mocks.autoUpdater.autoInstallOnAppQuit = false;
mocks.dialog.showSaveDialog.mockResolvedValue({
canceled: false,
filePath: "/tmp/export.json",
});
mocks.dialog.showOpenDialog.mockResolvedValue({
canceled: false,
filePaths: ["/tmp/import.json"],
});
mocks.readFile.mockResolvedValue(
JSON.stringify({
x: 10,
y: 20,
width: 1400,
height: 900,
isMaximized: true,
}),
);
});
afterEach(() => {
vi.useRealTimers();
});
describe("showExportSettingsDialog", () => {
it("calls showSaveDialog with JSON filter and generated filename", async () => {
const { showExportSettingsDialog } = await importNativeModule();
await showExportSettingsDialog();
expect(mocks.dialog.showSaveDialog).toHaveBeenCalledWith(
expect.objectContaining({
filters: [{ name: "JSON Files", extensions: ["json"] }],
defaultPath: "/mock/documents/fusion-settings-2026-01-01-000000.json",
}),
);
});
it("returns selected path when user picks a file", async () => {
const { showExportSettingsDialog } = await importNativeModule();
await expect(showExportSettingsDialog()).resolves.toBe("/tmp/export.json");
});
it("returns null when dialog is cancelled", async () => {
const { showExportSettingsDialog } = await importNativeModule();
mocks.dialog.showSaveDialog.mockResolvedValueOnce({
canceled: true,
filePath: undefined,
});
await expect(showExportSettingsDialog()).resolves.toBeNull();
});
it("returns null when filePath is missing", async () => {
const { showExportSettingsDialog } = await importNativeModule();
mocks.dialog.showSaveDialog.mockResolvedValueOnce({
canceled: false,
filePath: undefined,
});
await expect(showExportSettingsDialog()).resolves.toBeNull();
});
it("passes parent window to showSaveDialog", async () => {
const { showExportSettingsDialog } = await importNativeModule();
await showExportSettingsDialog(mocks.browserWindow as never);
expect(mocks.dialog.showSaveDialog).toHaveBeenCalledWith(
mocks.browserWindow,
expect.any(Object),
);
});
});
describe("showImportSettingsDialog", () => {
it("calls showOpenDialog with JSON filter and openFile property", async () => {
const { showImportSettingsDialog } = await importNativeModule();
await showImportSettingsDialog();
expect(mocks.dialog.showOpenDialog).toHaveBeenCalledWith(
expect.objectContaining({
properties: ["openFile"],
filters: [{ name: "JSON Files", extensions: ["json"] }],
}),
);
});
it("returns first selected path", async () => {
const { showImportSettingsDialog } = await importNativeModule();
await expect(showImportSettingsDialog()).resolves.toBe("/tmp/import.json");
});
it("returns null when dialog is cancelled", async () => {
const { showImportSettingsDialog } = await importNativeModule();
mocks.dialog.showOpenDialog.mockResolvedValueOnce({
canceled: true,
filePaths: [],
});
await expect(showImportSettingsDialog()).resolves.toBeNull();
});
it("returns null when no file paths are returned", async () => {
const { showImportSettingsDialog } = await importNativeModule();
mocks.dialog.showOpenDialog.mockResolvedValueOnce({
canceled: false,
filePaths: [],
});
await expect(showImportSettingsDialog()).resolves.toBeNull();
});
});
describe("showDesktopNotification", () => {
it("creates a notification and shows it", async () => {
const { showDesktopNotification } = await importNativeModule();
showDesktopNotification("Title", "Body");
expect(mocks.Notification).toHaveBeenCalledWith({
title: "Title",
body: "Body",
silent: undefined,
});
expect(mocks.notificationInstances[0]?.show).toHaveBeenCalledTimes(1);
});
it("passes silent option to Notification", async () => {
const { showDesktopNotification } = await importNativeModule();
showDesktopNotification("Title", "Body", { silent: true });
expect(mocks.Notification).toHaveBeenCalledWith({
title: "Title",
body: "Body",
silent: true,
});
});
it("guards unsupported environments", async () => {
const { showDesktopNotification } = await importNativeModule();
(mocks.Notification.isSupported as ReturnType<typeof vi.fn>).mockReturnValueOnce(false);
expect(() => showDesktopNotification("Title", "Body")).not.toThrow();
expect(mocks.Notification).not.toHaveBeenCalled();
});
it("wires click callback", async () => {
const { showDesktopNotification } = await importNativeModule();
const onClick = vi.fn();
showDesktopNotification("Title", "Body", { onClick });
mocks.notificationInstances[0]?.emit("click");
expect(onClick).toHaveBeenCalledTimes(1);
});
it("swallows constructor errors and does not throw", async () => {
const { showDesktopNotification } = await importNativeModule();
const original = mocks.Notification.getMockImplementation();
mocks.Notification.mockImplementationOnce(() => {
throw new Error("boom");
});
expect(() => showDesktopNotification("Title", "Body")).not.toThrow();
mocks.Notification.mockImplementation(original ?? (() => ({})));
});
});
describe("setupAutoUpdater", () => {
it("sets updater download and install flags", async () => {
const { setupAutoUpdater } = await importNativeModule();
setupAutoUpdater(mocks.browserWindow as never);
expect(mocks.autoUpdater.autoDownload).toBe(true);
expect(mocks.autoUpdater.autoInstallOnAppQuit).toBe(true);
});
it("registers updater listeners and checks for updates", async () => {
const { setupAutoUpdater } = await importNativeModule();
setupAutoUpdater(mocks.browserWindow as never);
expect(mocks.autoUpdater.on).toHaveBeenCalledWith("update-available", expect.any(Function));
expect(mocks.autoUpdater.on).toHaveBeenCalledWith("update-downloaded", expect.any(Function));
expect(mocks.autoUpdater.on).toHaveBeenCalledWith("error", expect.any(Function));
expect(mocks.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1);
});
it("update-available triggers notification and renderer IPC", async () => {
const { setupAutoUpdater } = await importNativeModule();
setupAutoUpdater(mocks.browserWindow as never);
mocks.updaterHandlers.get("update-available")?.({ version: "1.2.0" });
const latestNotification = mocks.notificationInstances.at(-1);
expect(latestNotification?.options).toMatchObject({
title: "Fusion Update Available",
});
expect(mocks.browserWindow.webContents.send).toHaveBeenCalledWith(
"update-available",
expect.objectContaining({ version: "1.2.0" }),
);
});
it("update-downloaded triggers notification and renderer IPC", async () => {
const { setupAutoUpdater } = await importNativeModule();
setupAutoUpdater(mocks.browserWindow as never);
mocks.updaterHandlers.get("update-downloaded")?.({ version: "1.2.0" });
const latestNotification = mocks.notificationInstances.at(-1);
expect(latestNotification?.options).toMatchObject({
title: "Fusion Update Ready",
});
expect(mocks.browserWindow.webContents.send).toHaveBeenCalledWith(
"update-downloaded",
expect.objectContaining({ version: "1.2.0" }),
);
});
it("error handler does not crash", async () => {
const { setupAutoUpdater } = await importNativeModule();
setupAutoUpdater(mocks.browserWindow as never);
expect(() => mocks.updaterHandlers.get("error")?.(new Error("network"))).not.toThrow();
});
it("catches checkForUpdates rejection", async () => {
const { setupAutoUpdater } = await importNativeModule();
mocks.autoUpdater.checkForUpdates.mockRejectedValueOnce(new Error("dev mode"));
expect(() => setupAutoUpdater(mocks.browserWindow as never)).not.toThrow();
await Promise.resolve();
});
it("wraps setup in try/catch when updater throws during registration", async () => {
const { setupAutoUpdater } = await importNativeModule();
mocks.autoUpdater.on.mockImplementationOnce(() => {
throw new Error("not supported in dev");
});
expect(() => setupAutoUpdater(mocks.browserWindow as never)).not.toThrow();
});
});
describe("window state", () => {
it("loadWindowState returns parsed state", async () => {
const { loadWindowState } = await importNativeModule();
await expect(loadWindowState()).resolves.toEqual({
x: 10,
y: 20,
width: 1400,
height: 900,
isMaximized: true,
});
});
it("loadWindowState returns null when file does not exist", async () => {
const { loadWindowState } = await importNativeModule();
mocks.readFile.mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
await expect(loadWindowState()).resolves.toBeNull();
});
it("loadWindowState returns null for invalid JSON", async () => {
const { loadWindowState } = await importNativeModule();
mocks.readFile.mockResolvedValueOnce("not-json");
await expect(loadWindowState()).resolves.toBeNull();
});
it("loadWindowState returns null for invalid schema", async () => {
const { loadWindowState } = await importNativeModule();
mocks.readFile.mockResolvedValueOnce(JSON.stringify({ width: "bad" }));
await expect(loadWindowState()).resolves.toBeNull();
});
it("uses userData path for window-state.json", async () => {
const { loadWindowState } = await importNativeModule();
await loadWindowState();
expect(mocks.readFile).toHaveBeenCalledWith("/mock/user-data/window-state.json", "utf-8");
});
it("saveWindowState writes temp file and renames atomically", async () => {
const { saveWindowState } = await importNativeModule();
saveWindowState(mocks.browserWindow as never);
await Promise.resolve();
expect(mocks.writeFile).toHaveBeenCalledWith(
"/mock/user-data/window-state.json.tmp",
expect.any(String),
"utf-8",
);
expect(mocks.rename).toHaveBeenCalledWith(
"/mock/user-data/window-state.json.tmp",
"/mock/user-data/window-state.json",
);
});
it("saveWindowState captures bounds and maximized state", async () => {
const { saveWindowState } = await importNativeModule();
mocks.browserWindow.isMaximized.mockReturnValueOnce(true);
saveWindowState(mocks.browserWindow as never);
await Promise.resolve();
const payload = mocks.writeFile.mock.calls[0]?.[1] as string;
expect(JSON.parse(payload)).toEqual({
x: 40,
y: 60,
width: 1280,
height: 900,
isMaximized: true,
});
});
it("saveWindowState skips destroyed windows", async () => {
const { saveWindowState } = await importNativeModule();
mocks.browserWindow.isDestroyed.mockReturnValueOnce(true);
saveWindowState(mocks.browserWindow as never);
expect(mocks.writeFile).not.toHaveBeenCalled();
expect(mocks.rename).not.toHaveBeenCalled();
});
it("DEFAULT_WINDOW_STATE has expected fallback dimensions", async () => {
const { DEFAULT_WINDOW_STATE } = await importNativeModule();
expect(DEFAULT_WINDOW_STATE).toEqual({
width: 1280,
height: 900,
isMaximized: false,
});
});
});
});

View File

@@ -0,0 +1,99 @@
import { app, BrowserWindow } from "electron";
export interface DeepLinkResult {
type: "task" | "project" | "unknown";
id: string;
raw: string;
}
const DEEP_LINK_EVENT = "deep-link";
const FUSION_SCHEME = "fusion:";
export function registerDeepLinkProtocol(): void {
try {
const isRegistered = app.setAsDefaultProtocolClient("fusion");
if (!isRegistered) {
console.warn("[desktop/deep-link] Failed to register fusion:// protocol");
return;
}
console.log("[desktop/deep-link] Registered fusion:// protocol handler");
} catch (error) {
console.error("[desktop/deep-link] Error while registering fusion:// protocol", error);
}
}
export function parseDeepLink(rawUrl: string): DeepLinkResult | null {
if (!rawUrl) {
return null;
}
try {
const parsedUrl = new URL(rawUrl);
if (parsedUrl.protocol !== FUSION_SCHEME) {
return null;
}
const type = parsedUrl.hostname;
if (type !== "task" && type !== "project") {
return null;
}
const pathSegments = parsedUrl.pathname
.split("/")
.filter((segment) => segment.length > 0);
const decodedId = pathSegments.length > 0 ? decodeURIComponent(pathSegments[0]) : "";
return {
type,
id: decodedId,
raw: rawUrl,
};
} catch {
return null;
}
}
export function handleDeepLink(mainWindow: BrowserWindow, url: string): void {
const parsed = parseDeepLink(url);
if (!parsed || parsed.type === "unknown") {
console.warn(`[desktop/deep-link] Ignoring unsupported deep link: ${url}`);
return;
}
if (!mainWindow.isVisible()) {
mainWindow.show();
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.focus();
mainWindow.webContents.send(DEEP_LINK_EVENT, parsed);
}
export function setupDeepLinkHandler(mainWindow: BrowserWindow): void {
const hasLock = app.requestSingleInstanceLock();
if (!hasLock) {
app.quit();
return;
}
app.on("open-url", (event, url) => {
event.preventDefault();
handleDeepLink(mainWindow, url);
});
app.on("second-instance", (_event, argv) => {
const deepLink = argv.find((arg) => arg.startsWith("fusion://"));
if (!deepLink) {
return;
}
handleDeepLink(mainWindow, deepLink);
});
}

View File

@@ -0,0 +1,206 @@
import { readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
app,
BrowserWindow,
dialog,
Notification,
type OpenDialogOptions,
type SaveDialogOptions,
} from "electron";
import { autoUpdater } from "electron-updater";
export interface WindowState {
x?: number;
y?: number;
width: number;
height: number;
isMaximized: boolean;
}
export const DEFAULT_WINDOW_STATE: WindowState = {
width: 1280,
height: 900,
isMaximized: false,
};
interface DesktopNotificationOptions {
silent?: boolean;
onClick?: () => void;
}
function generateSettingsExportFilename(date: Date = new Date()): string {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
const hours = String(date.getUTCHours()).padStart(2, "0");
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
const seconds = String(date.getUTCSeconds()).padStart(2, "0");
return `fusion-settings-${year}-${month}-${day}-${hours}${minutes}${seconds}.json`;
}
function getWindowStatePath(): string {
return join(app.getPath("userData"), "window-state.json");
}
function isValidWindowState(value: unknown): value is WindowState {
if (value === null || typeof value !== "object") {
return false;
}
const candidate = value as Partial<WindowState>;
const hasValidPosition =
(candidate.x === undefined || typeof candidate.x === "number") &&
(candidate.y === undefined || typeof candidate.y === "number");
return (
hasValidPosition &&
typeof candidate.width === "number" &&
Number.isFinite(candidate.width) &&
typeof candidate.height === "number" &&
Number.isFinite(candidate.height) &&
typeof candidate.isMaximized === "boolean"
);
}
export async function showExportSettingsDialog(parentWindow?: BrowserWindow): Promise<string | null> {
const filename = generateSettingsExportFilename();
const defaultPath = join(app.getPath("documents"), filename);
const dialogOptions: SaveDialogOptions = {
title: "Export Fusion Settings",
defaultPath,
filters: [{ name: "JSON Files", extensions: ["json"] }],
};
const result = parentWindow
? await dialog.showSaveDialog(parentWindow, dialogOptions)
: await dialog.showSaveDialog(dialogOptions);
if (result.canceled || !result.filePath) {
return null;
}
return result.filePath;
}
export async function showImportSettingsDialog(parentWindow?: BrowserWindow): Promise<string | null> {
const dialogOptions: OpenDialogOptions = {
title: "Import Fusion Settings",
properties: ["openFile"],
filters: [{ name: "JSON Files", extensions: ["json"] }],
};
const result = parentWindow
? await dialog.showOpenDialog(parentWindow, dialogOptions)
: await dialog.showOpenDialog(dialogOptions);
if (result.canceled || result.filePaths.length === 0) {
return null;
}
return result.filePaths[0] ?? null;
}
export function showDesktopNotification(
title: string,
body: string,
options: DesktopNotificationOptions = {},
): void {
if (!Notification.isSupported()) {
console.warn("[desktop/native] Notifications are not supported in this environment");
return;
}
try {
const notification = new Notification({
title,
body,
silent: options.silent,
});
if (options.onClick) {
notification.on("click", options.onClick);
}
notification.show();
} catch (error) {
console.error("[desktop/native] Failed to display desktop notification", error);
}
}
export function setupAutoUpdater(mainWindow?: BrowserWindow): void {
try {
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on("update-available", (info) => {
showDesktopNotification("Fusion Update Available", "Update available — downloading in background", {
silent: true,
});
mainWindow?.webContents.send("update-available", info);
});
autoUpdater.on("update-downloaded", (info) => {
showDesktopNotification("Fusion Update Ready", "Update ready — will install on quit", {
silent: true,
});
mainWindow?.webContents.send("update-downloaded", info);
});
autoUpdater.on("error", (error) => {
console.error("[desktop/native] Auto-updater error", error);
});
void autoUpdater.checkForUpdates().catch((error) => {
console.error("[desktop/native] Auto-updater check failed", error);
});
} catch (error) {
console.error("[desktop/native] Auto-updater unavailable", error);
}
}
export async function loadWindowState(): Promise<WindowState | null> {
const statePath = getWindowStatePath();
try {
const raw = await readFile(statePath, "utf-8");
const parsed: unknown = JSON.parse(raw);
if (!isValidWindowState(parsed)) {
return null;
}
return parsed;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
return null;
}
}
export function saveWindowState(mainWindow: BrowserWindow): void {
if (mainWindow.isDestroyed()) {
return;
}
const bounds = mainWindow.getBounds();
const state: WindowState = {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
isMaximized: mainWindow.isMaximized(),
};
const statePath = getWindowStatePath();
const tempPath = `${statePath}.tmp`;
void writeFile(tempPath, JSON.stringify(state, null, 2), "utf-8")
.then(() => rename(tempPath, statePath))
.catch((error) => {
console.error("[desktop/native] Failed to save window state", error);
});
}