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:
273
packages/desktop/src/__tests__/deep-link.test.ts
Normal file
273
packages/desktop/src/__tests__/deep-link.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
473
packages/desktop/src/__tests__/native.test.ts
Normal file
473
packages/desktop/src/__tests__/native.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
99
packages/desktop/src/deep-link.ts
Normal file
99
packages/desktop/src/deep-link.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
206
packages/desktop/src/native.ts
Normal file
206
packages/desktop/src/native.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user