feat(FN-3405): persist and restore desktop launch mode via IPC bridge

Merges five features: **Launch mode persistence** (FN-3405) — desktop now remembers and restores the user's preferred window mode across restarts via an IPC bridge; **Mailbox detail pane** (FN-3719/3720) — clicking messages in the Mail tab and Mailbox opens the detail pane and reply panel; **Remote

Fusion-Task-Id: FN-3405
This commit is contained in:
Fusion
2026-05-07 20:26:14 -07:00
committed by gsxdsm
parent 460b51f877
commit f5e78d77d6
13 changed files with 341 additions and 21 deletions

View File

@@ -127,6 +127,8 @@ describe("ipc handlers", () => {
expect(channels.has("desktopRuntime:getStatus")).toBe(true);
expect(channels.has("desktopRuntime:startLocal")).toBe(true);
expect(channels.has("desktopRuntime:stopLocal")).toBe(true);
expect(channels.has("desktopLaunchMode:getMode")).toBe(true);
expect(channels.has("desktopLaunchMode:setMode")).toBe(true);
expect(channels.has("platform:get")).toBe(true);
});
@@ -153,6 +155,17 @@ describe("ipc handlers", () => {
expect(window.webContents.send).toHaveBeenCalledWith("shell:state", expect.any(Object));
});
it("desktop launch mode handlers return mode and validate payload", async () => {
const onDesktopLaunchModeChange = vi.fn(async () => undefined);
const getDesktopLaunchMode = vi.fn(() => "remote");
await registerHandlers({ onDesktopLaunchModeChange, getDesktopLaunchMode });
await expect(mocks.ipcHandlers.get("desktopLaunchMode:getMode")?.({})).resolves.toBe("remote");
await expect(mocks.ipcHandlers.get("desktopLaunchMode:setMode")?.({}, "local")).resolves.toBe("remote");
await expect(mocks.ipcHandlers.get("desktopLaunchMode:setMode")?.({}, "bad")).rejects.toThrow("Invalid desktop launch mode");
expect(onDesktopLaunchModeChange).toHaveBeenCalledWith("local");
});
it("desktopRuntime start/stop/getStatus handlers proxy runtime manager", async () => {
const getRuntimeStatus = vi.fn(() => ({ source: "none", state: "stopped" }));
const startLocalRuntime = vi.fn(async () => ({ source: "embedded-local", state: "running", port: 4510 }));

View File

@@ -93,6 +93,16 @@ const mocks = vi.hoisted(() => {
return null;
});
const loadDesktopLaunchMode = vi.fn(async () => {
callLog.push("loadDesktopLaunchMode");
return "choose";
});
const saveDesktopLaunchMode = vi.fn(async () => undefined);
const startLocal = vi.fn(async () => ({ source: "embedded-local", state: "running", port: 4545 }));
const stopLocal = vi.fn(async () => ({ source: "none", state: "stopped" }));
const getStatus = vi.fn(() => ({ source: "none", state: "stopped" }));
const saveWindowState = vi.fn();
const DEFAULT_WINDOW_STATE = {
@@ -117,7 +127,12 @@ const mocks = vi.hoisted(() => {
setupDeepLinkHandler,
setupAutoUpdater,
loadWindowState,
loadDesktopLaunchMode,
saveDesktopLaunchMode,
saveWindowState,
startLocal,
stopLocal,
getStatus,
DEFAULT_WINDOW_STATE,
};
});
@@ -148,11 +163,22 @@ vi.mock("../deep-link.js", () => ({
vi.mock("../native.js", () => ({
loadWindowState: mocks.loadWindowState,
loadDesktopLaunchMode: mocks.loadDesktopLaunchMode,
saveDesktopLaunchMode: mocks.saveDesktopLaunchMode,
saveWindowState: mocks.saveWindowState,
setupAutoUpdater: mocks.setupAutoUpdater,
DEFAULT_WINDOW_STATE: mocks.DEFAULT_WINDOW_STATE,
}));
vi.mock("../local-runtime.js", () => ({
LocalRuntimeManager: vi.fn(() => ({
startLocal: mocks.startLocal,
stopLocal: mocks.stopLocal,
getStatus: mocks.getStatus,
getServerPort: vi.fn(() => 0),
})),
}));
// Mock renderer module
vi.mock("../renderer.js", () => ({
isDevelopmentMode: vi.fn(() => false),
@@ -187,6 +213,10 @@ describe("main integration", () => {
mocks.callLog.push("loadWindowState");
return null;
});
mocks.loadDesktopLaunchMode.mockImplementation(async () => {
mocks.callLog.push("loadDesktopLaunchMode");
return "choose";
});
});
it("initializeApp calls modules in the expected order", async () => {
@@ -196,6 +226,7 @@ describe("main integration", () => {
expect(mocks.callLog).toEqual([
"loadWindowState",
"loadDesktopLaunchMode",
"createMainWindow",
"buildAppMenu",
"setupTray",
@@ -344,6 +375,27 @@ describe("main integration", () => {
}
});
it("starts local runtime when remembered mode is local", async () => {
mocks.loadDesktopLaunchMode.mockResolvedValueOnce("local");
const { initializeApp } = await importMainModule();
await initializeApp();
expect(mocks.startLocal).toHaveBeenCalledTimes(1);
});
it("falls back to choose and persists fallback when local restore fails", async () => {
mocks.loadDesktopLaunchMode.mockResolvedValueOnce("local");
mocks.startLocal.mockRejectedValueOnce(new Error("start failed"));
const { initializeApp, getCurrentDesktopLaunchMode } = await importMainModule();
await initializeApp();
expect(mocks.saveDesktopLaunchMode).toHaveBeenCalledWith("choose");
expect(getCurrentDesktopLaunchMode()).toBe("choose");
expect(mocks.stopLocal).toHaveBeenCalledTimes(1);
});
it("importing main module does not auto-start app lifecycle", async () => {
await importMainModule();

View File

@@ -47,7 +47,14 @@ vi.mock("../renderer.js", () => ({ isUrlRenderer: vi.fn(() => true), getRenderer
vi.mock("../menu.js", () => ({ buildAppMenu: vi.fn() }));
vi.mock("../tray.js", () => ({ setupTray: vi.fn() }));
vi.mock("../ipc.js", () => ({ registerIpcHandlers: vi.fn() }));
vi.mock("../native.js", () => ({ DEFAULT_WINDOW_STATE: { width: 1000, height: 800 }, loadWindowState: vi.fn(async () => null), saveWindowState: vi.fn(), setupAutoUpdater: vi.fn() }));
vi.mock("../native.js", () => ({
DEFAULT_WINDOW_STATE: { width: 1000, height: 800 },
loadWindowState: vi.fn(async () => null),
loadDesktopLaunchMode: vi.fn(async () => "choose"),
saveDesktopLaunchMode: vi.fn(async () => undefined),
saveWindowState: vi.fn(),
setupAutoUpdater: vi.fn(),
}));
vi.mock("../deep-link.js", () => ({ registerDeepLinkProtocol: vi.fn(), setupDeepLinkHandler: vi.fn() }));
vi.mock("../local-runtime.js", () => ({ LocalRuntimeManager: vi.fn(() => mocks.localRuntimeManager) }));

View File

@@ -36,6 +36,8 @@ const mocks = vi.hoisted(() => {
registerDeepLinkProtocol: vi.fn(),
setupDeepLinkHandler: vi.fn(),
loadWindowState: vi.fn(async () => null),
loadDesktopLaunchMode: vi.fn(async () => "choose"),
saveDesktopLaunchMode: vi.fn(async () => undefined),
saveWindowState: vi.fn(),
setupAutoUpdater: vi.fn(),
};
@@ -72,6 +74,8 @@ vi.mock("../native.js", () => ({
isMaximized: false,
},
loadWindowState: mocks.loadWindowState,
loadDesktopLaunchMode: mocks.loadDesktopLaunchMode,
saveDesktopLaunchMode: mocks.saveDesktopLaunchMode,
saveWindowState: mocks.saveWindowState,
setupAutoUpdater: mocks.setupAutoUpdater,
}));

View File

@@ -105,6 +105,8 @@ const mainDeps = vi.hoisted(() => {
const stopLocal = vi.fn(async () => ({ source: "none", state: "stopped" }));
const getStatus = vi.fn(() => ({ source: "none", state: "stopped" }));
const getServerPort = vi.fn(() => 0);
const loadDesktopLaunchMode = vi.fn(async () => "choose");
const saveDesktopLaunchMode = vi.fn(async () => undefined);
return {
registerIpcHandlers: vi.fn(),
buildAppMenu: vi.fn(),
@@ -113,6 +115,8 @@ const mainDeps = vi.hoisted(() => {
setupDeepLinkHandler: vi.fn(),
setupAutoUpdater: vi.fn(),
loadWindowState: vi.fn(async () => null),
loadDesktopLaunchMode,
saveDesktopLaunchMode,
saveWindowState: vi.fn(),
LocalRuntimeManager: vi.fn(() => ({ startLocal, stopLocal, getStatus, getServerPort })),
startLocal,
@@ -129,6 +133,8 @@ vi.mock("../deep-link.js", () => ({
vi.mock("../native.js", () => ({
DEFAULT_WINDOW_STATE: { width: 1280, height: 900, isMaximized: false },
loadWindowState: mainDeps.loadWindowState,
loadDesktopLaunchMode: mainDeps.loadDesktopLaunchMode,
saveDesktopLaunchMode: mainDeps.saveDesktopLaunchMode,
saveWindowState: mainDeps.saveWindowState,
setupAutoUpdater: mainDeps.setupAutoUpdater,
}));
@@ -239,7 +245,48 @@ describe("main process", () => {
expect(typeof mainModule.initializeApp).toBe("function");
});
it("initializeApp starts local runtime when FUSION_DESKTOP_MODE=local", async () => {
it("initializeApp starts local runtime when remembered mode is local", async () => {
mainDeps.loadDesktopLaunchMode.mockResolvedValueOnce("local");
const { initializeApp, getCurrentDesktopLaunchMode } = await importMainModule();
await initializeApp();
expect(mainDeps.startLocal).toHaveBeenCalledTimes(1);
expect(getCurrentDesktopLaunchMode()).toBe("local");
});
it("initializeApp does not start local runtime for remembered choose mode", async () => {
mainDeps.loadDesktopLaunchMode.mockResolvedValueOnce("choose");
const { initializeApp } = await importMainModule();
await initializeApp();
expect(mainDeps.startLocal).not.toHaveBeenCalled();
});
it("initializeApp does not start local runtime for remembered remote mode", async () => {
mainDeps.loadDesktopLaunchMode.mockResolvedValueOnce("remote");
const { initializeApp, getCurrentDesktopLaunchMode } = await importMainModule();
await initializeApp();
expect(mainDeps.startLocal).not.toHaveBeenCalled();
expect(getCurrentDesktopLaunchMode()).toBe("remote");
});
it("initializeApp falls back to choose and persists when remembered local start fails", async () => {
mainDeps.loadDesktopLaunchMode.mockResolvedValueOnce("local");
mainDeps.startLocal.mockRejectedValueOnce(new Error("boom"));
const { initializeApp, getCurrentDesktopLaunchMode } = await importMainModule();
await initializeApp();
expect(mainDeps.saveDesktopLaunchMode).toHaveBeenCalledWith("choose");
expect(getCurrentDesktopLaunchMode()).toBe("choose");
});
it("initializeApp avoids duplicate local start when remembered mode and env flag are local", async () => {
mainDeps.loadDesktopLaunchMode.mockResolvedValueOnce("local");
process.env.FUSION_DESKTOP_MODE = "local";
const { initializeApp } = await importMainModule();
@@ -248,12 +295,17 @@ describe("main process", () => {
expect(mainDeps.startLocal).toHaveBeenCalledTimes(1);
});
it("initializeApp does not start local runtime when FUSION_DESKTOP_MODE is unset", async () => {
it("onDesktopModeChange persists the selected launch mode", async () => {
const { initializeApp } = await importMainModule();
await initializeApp();
expect(mainDeps.startLocal).not.toHaveBeenCalled();
const options = mainDeps.registerIpcHandlers.mock.calls[0]?.[2] as
| { onDesktopModeChange?: (mode: "local" | "remote") => Promise<void> }
| undefined;
await options?.onDesktopModeChange?.("remote");
expect(mainDeps.saveDesktopLaunchMode).toHaveBeenCalledWith("remote");
});
it("createMainWindow registers close and closed handlers", async () => {

View File

@@ -374,6 +374,45 @@ describe("native integrations", () => {
});
});
describe("desktop launch mode", () => {
it("loadDesktopLaunchMode returns choose when file is missing", async () => {
const { loadDesktopLaunchMode } = await importNativeModule();
mocks.readFile.mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
await expect(loadDesktopLaunchMode()).resolves.toBe("choose");
});
it("loadDesktopLaunchMode returns persisted mode", async () => {
const { loadDesktopLaunchMode } = await importNativeModule();
mocks.readFile.mockResolvedValueOnce(JSON.stringify({ mode: "local" }));
await expect(loadDesktopLaunchMode()).resolves.toBe("local");
});
it("loadDesktopLaunchMode falls back to choose for invalid payload", async () => {
const { loadDesktopLaunchMode } = await importNativeModule();
mocks.readFile.mockResolvedValueOnce(JSON.stringify({ mode: "invalid" }));
await expect(loadDesktopLaunchMode()).resolves.toBe("choose");
});
it("saveDesktopLaunchMode writes temp file and renames atomically", async () => {
const { saveDesktopLaunchMode } = await importNativeModule();
await saveDesktopLaunchMode("remote");
expect(mocks.writeFile).toHaveBeenCalledWith(
"/mock/user-data/desktop-launch-mode.json.tmp",
JSON.stringify({ mode: "remote" }, null, 2),
"utf-8",
);
expect(mocks.rename).toHaveBeenCalledWith(
"/mock/user-data/desktop-launch-mode.json.tmp",
"/mock/user-data/desktop-launch-mode.json",
);
});
});
describe("window state", () => {
it("loadWindowState returns parsed state", async () => {
const { loadWindowState } = await importNativeModule();

View File

@@ -50,6 +50,20 @@ describe("preload", () => {
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("app:getServerPort");
});
it("electronAPI launch mode methods delegate to IPC", async () => {
await importPreloadModule();
const api = getExposed<{
getDesktopLaunchMode: () => Promise<string>;
setDesktopLaunchMode: (mode: "choose" | "local" | "remote") => Promise<string>;
}>("electronAPI");
await api?.getDesktopLaunchMode();
await api?.setDesktopLaunchMode("local");
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("desktopLaunchMode:getMode");
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("desktopLaunchMode:setMode", "local");
});
it("fusionShell subscribes and unsubscribes state listener", async () => {
await importPreloadModule();
const shell = getExposed<{ subscribe: (listener: (state: unknown) => void) => () => void }>("fusionShell");