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:
@@ -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 }));
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) }));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user