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:
@@ -65,7 +65,9 @@ Desktop boots through a shell-owned mode chooser before mounting the dashboard a
|
||||
|
||||
- **First run choice:** users choose **Local Fusion (bundled runtime)** or **Remote connection path**.
|
||||
- **Mode contract:** `desktopMode` is `"local" | "remote" | null` and `hasCompletedModeSelection` determines whether the renderer treats startup as first-run. IPC also exposes a renderer-safe `{ isFirstRun, desktopMode }` shape via `shell:getDesktopModeState`.
|
||||
- **Desktop mode restore:** after selection, mode is persisted and reused on relaunch.
|
||||
- **Desktop mode restore:** launch mode is stored in `app.getPath("userData")/desktop-launch-mode.json` as `{ "mode": "choose" | "local" | "remote" }` and reused on relaunch.
|
||||
- **Restore rules:** `choose` keeps chooser-first startup behavior, `local` attempts to start the embedded local runtime on launch, and `remote` skips embedded runtime startup.
|
||||
- **Failure fallback:** if remembered `local` restore fails, the shell stops partial runtime state, falls back to `choose`, and persists that fallback to avoid broken relaunch loops.
|
||||
- **Remote profiles:** multiple saved profiles are supported (`name`, `serverUrl`, optional `authToken`) and can be created/edited/switched/deleted from the dashboard connection manager.
|
||||
- **Delete fallback:** if the active profile is deleted, desktop shell settings automatically select the first remaining profile; deleting the final profile leaves a valid empty payload (`activeProfileId: null`, `profiles: []`).
|
||||
- **Storage boundary:** shell connection state is stored only in desktop-local app data at `app.getPath("userData")/shell-connections.json` and is not written to `.fusion/config.json` or dashboard project storage keys.
|
||||
@@ -93,6 +95,8 @@ Desktop boots through a shell-owned mode chooser before mounting the dashboard a
|
||||
| `desktopRuntime:getStatus` | renderer → main | none | `Promise<DesktopRuntimeStatus>` |
|
||||
| `desktopRuntime:startLocal` | renderer → main | none | `Promise<DesktopRuntimeStatus>` |
|
||||
| `desktopRuntime:stopLocal` | renderer → main | none | `Promise<DesktopRuntimeStatus>` |
|
||||
| `desktopLaunchMode:getMode` | renderer → main | none | `Promise<"choose" \| "local" \| "remote">` |
|
||||
| `desktopLaunchMode:setMode` | renderer → main | `mode: "choose" \| "local" \| "remote"` | `Promise<"choose" \| "local" \| "remote">` |
|
||||
| `tray:updateStatus` | renderer → main | `status: "running" \| "paused" \| "stopped"` | `Promise<void>` |
|
||||
| `native:showExportDialog` | renderer → main | none | `Promise<string \| null>` |
|
||||
| `native:showImportDialog` | renderer → main | none | `Promise<string \| null>` |
|
||||
@@ -135,14 +139,16 @@ Desktop local mode uses an in-process runtime manager (`src/local-runtime.ts`) t
|
||||
`src/main.ts` orchestrates module startup in this order:
|
||||
|
||||
1. `loadWindowState()`
|
||||
2. `createMainWindow(state)`
|
||||
3. `buildAppMenu({ mainWindow, appName: "Fusion" })`
|
||||
4. `setupTray(mainWindow, tray)`
|
||||
5. `registerIpcHandlers(mainWindow, tray)`
|
||||
6. `registerDeepLinkProtocol()`
|
||||
7. `setupDeepLinkHandler(mainWindow)`
|
||||
8. `setupAutoUpdater(mainWindow)`
|
||||
9. `mainWindow.maximize()` when restored state was maximized
|
||||
2. `loadDesktopLaunchMode()`
|
||||
3. Restore launch mode behavior (`local` attempts embedded runtime start; `remote`/`choose` skip)
|
||||
4. `createMainWindow(state)`
|
||||
5. `buildAppMenu({ mainWindow, appName: "Fusion" })`
|
||||
6. `setupTray(mainWindow, tray)`
|
||||
7. `registerIpcHandlers(mainWindow, tray)`
|
||||
8. `registerDeepLinkProtocol()`
|
||||
9. `setupDeepLinkHandler(mainWindow)`
|
||||
10. `setupAutoUpdater(mainWindow)`
|
||||
11. `mainWindow.maximize()` when restored state was maximized
|
||||
|
||||
### Window state and close-to-tray behavior
|
||||
|
||||
@@ -167,6 +173,7 @@ Desktop local mode uses an in-process runtime manager (`src/local-runtime.ts`) t
|
||||
- Window control: `minimize()`, `maximize()`, `close()`, `isMaximized()`
|
||||
- App/system: `getSystemInfo()`, `checkForUpdates()`, `getServerPort()`
|
||||
- Desktop runtime: `getDesktopRuntimeStatus()`, `startDesktopLocalRuntime()`, `stopDesktopLocalRuntime()`
|
||||
- Desktop launch mode: `getDesktopLaunchMode()`, `setDesktopLaunchMode(mode)`
|
||||
- Tray: `updateTrayStatus(status)`
|
||||
- Native dialogs: `showExportDialog()`, `showImportDialog()`
|
||||
- Event subscriptions (return unsubscribe functions):
|
||||
@@ -238,6 +245,9 @@ The desktop shell installs a native menu with standard shortcuts.
|
||||
- `loadWindowState()` reads `window-state.json` from `app.getPath("userData")`.
|
||||
- `saveWindowState(mainWindow)` writes bounds/maximized state atomically (`.tmp` + rename).
|
||||
- `DEFAULT_WINDOW_STATE` is the fallback (`1280x900`, not maximized).
|
||||
- **Desktop launch-mode persistence**
|
||||
- `loadDesktopLaunchMode()` reads `desktop-launch-mode.json` and returns `"choose" | "local" | "remote"` (invalid/missing files fall back to `"choose"`).
|
||||
- `saveDesktopLaunchMode(mode)` writes the mode atomically (`.tmp` + rename).
|
||||
|
||||
## Deep Linking
|
||||
|
||||
@@ -274,6 +284,8 @@ FN-1076 depends on these exact exports and names.
|
||||
| `setupAutoUpdater` | `(mainWindow?) => void` |
|
||||
| `loadWindowState` | `() => Promise<WindowState \| null>` |
|
||||
| `saveWindowState` | `(mainWindow) => void` |
|
||||
| `loadDesktopLaunchMode` | `() => Promise<"choose" \| "local" \| "remote">` |
|
||||
| `saveDesktopLaunchMode` | `(mode) => Promise<void>` |
|
||||
| `DEFAULT_WINDOW_STATE` | `WindowState` |
|
||||
| `WindowState` | `interface` |
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -32,12 +32,20 @@ interface ShellConnectionState {
|
||||
localRuntime?: DesktopRuntimeStatus;
|
||||
}
|
||||
|
||||
type DesktopLaunchMode = "choose" | "local" | "remote";
|
||||
|
||||
interface RegisterIpcOptions {
|
||||
onDesktopModeChange?: (mode: DesktopShellMode) => Promise<void>;
|
||||
onDesktopLaunchModeChange?: (mode: DesktopLaunchMode) => Promise<void>;
|
||||
getRuntimeStatus?: () => DesktopRuntimeStatus;
|
||||
startLocalRuntime?: () => Promise<DesktopRuntimeStatus>;
|
||||
stopLocalRuntime?: () => Promise<DesktopRuntimeStatus>;
|
||||
getServerPort?: () => number | undefined;
|
||||
getDesktopLaunchMode?: () => DesktopLaunchMode;
|
||||
}
|
||||
|
||||
function isDesktopLaunchMode(value: unknown): value is DesktopLaunchMode {
|
||||
return value === "choose" || value === "local" || value === "remote";
|
||||
}
|
||||
|
||||
function toShellState(
|
||||
@@ -103,6 +111,24 @@ export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray, optio
|
||||
ipcMain.handle("desktopRuntime:getStatus", async () => options.getRuntimeStatus?.() ?? { source: "none", state: "stopped" });
|
||||
ipcMain.handle("desktopRuntime:startLocal", async () => options.startLocalRuntime?.() ?? { source: "none", state: "stopped" });
|
||||
ipcMain.handle("desktopRuntime:stopLocal", async () => options.stopLocalRuntime?.() ?? { source: "none", state: "stopped" });
|
||||
ipcMain.handle("desktopLaunchMode:getMode", async () => options.getDesktopLaunchMode?.() ?? "choose");
|
||||
ipcMain.handle("desktopLaunchMode:setMode", async (_event, mode: unknown) => {
|
||||
if (!isDesktopLaunchMode(mode)) {
|
||||
throw new Error("Invalid desktop launch mode");
|
||||
}
|
||||
|
||||
if (options.onDesktopLaunchModeChange) {
|
||||
await options.onDesktopLaunchModeChange(mode);
|
||||
return options.getDesktopLaunchMode?.() ?? mode;
|
||||
}
|
||||
|
||||
if ((mode === "local" || mode === "remote") && options.onDesktopModeChange) {
|
||||
await options.onDesktopModeChange(mode);
|
||||
return options.getDesktopLaunchMode?.() ?? mode;
|
||||
}
|
||||
|
||||
return options.getDesktopLaunchMode?.() ?? mode;
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:getState", () => readShellSettings().then((settings) => toShellState(settings, options.getRuntimeStatus?.())));
|
||||
ipcMain.handle("shell:listProfiles", async () => (await readShellSettings()).profiles);
|
||||
|
||||
@@ -6,9 +6,12 @@ import { registerIpcHandlers } from "./ipc.js";
|
||||
import { buildAppMenu } from "./menu.js";
|
||||
import {
|
||||
DEFAULT_WINDOW_STATE,
|
||||
loadDesktopLaunchMode,
|
||||
loadWindowState,
|
||||
saveDesktopLaunchMode,
|
||||
saveWindowState,
|
||||
setupAutoUpdater,
|
||||
type DesktopLaunchMode,
|
||||
type WindowState,
|
||||
} from "./native.js";
|
||||
import { setupTray } from "./tray.js";
|
||||
@@ -35,11 +38,32 @@ enableSourceMaps();
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let localRuntimeManager: LocalRuntimeManager | null = null;
|
||||
let currentDesktopLaunchMode: DesktopLaunchMode = "choose";
|
||||
let localRuntimeStartupAttempted = false;
|
||||
|
||||
function getAppWithQuitFlag(): Electron.App & AppWithQuitFlag {
|
||||
return app as Electron.App & AppWithQuitFlag;
|
||||
}
|
||||
|
||||
async function startLocalRuntimeOnce(): Promise<void> {
|
||||
if (!localRuntimeManager || localRuntimeStartupAttempted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = localRuntimeManager.getStatus();
|
||||
if (status.source === "embedded-local" && status.state === "running") {
|
||||
localRuntimeStartupAttempted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
localRuntimeStartupAttempted = true;
|
||||
await localRuntimeManager.startLocal();
|
||||
}
|
||||
|
||||
export function getCurrentDesktopLaunchMode(): DesktopLaunchMode {
|
||||
return currentDesktopLaunchMode;
|
||||
}
|
||||
|
||||
export function createMainWindow(state?: WindowState): BrowserWindow {
|
||||
const hasValidPosition = typeof state?.x === "number" && typeof state?.y === "number";
|
||||
|
||||
@@ -55,7 +79,6 @@ export function createMainWindow(state?: WindowState): BrowserWindow {
|
||||
},
|
||||
});
|
||||
|
||||
// Use renderer module to determine how to load the UI
|
||||
if (isUrlRenderer()) {
|
||||
void window.loadURL(getRendererUrl());
|
||||
} else {
|
||||
@@ -83,6 +106,29 @@ export function createMainWindow(state?: WindowState): BrowserWindow {
|
||||
|
||||
export async function initializeApp(): Promise<void> {
|
||||
const state = await loadWindowState();
|
||||
const rememberedLaunchMode = await loadDesktopLaunchMode();
|
||||
|
||||
localRuntimeManager = new LocalRuntimeManager({ rootDir: process.cwd() });
|
||||
currentDesktopLaunchMode = rememberedLaunchMode;
|
||||
localRuntimeStartupAttempted = false;
|
||||
|
||||
if (rememberedLaunchMode === "local") {
|
||||
try {
|
||||
await startLocalRuntimeOnce();
|
||||
} catch (error) {
|
||||
await localRuntimeManager.stopLocal();
|
||||
currentDesktopLaunchMode = "choose";
|
||||
localRuntimeStartupAttempted = false;
|
||||
await saveDesktopLaunchMode("choose");
|
||||
console.error("[desktop/main] Failed to restore local mode; falling back to chooser", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentDesktopLaunchMode === "choose" && process.env.FUSION_DESKTOP_MODE === "local") {
|
||||
await startLocalRuntimeOnce();
|
||||
currentDesktopLaunchMode = "local";
|
||||
}
|
||||
|
||||
const createdWindow = createMainWindow(state ?? undefined);
|
||||
|
||||
buildAppMenu({
|
||||
@@ -90,8 +136,6 @@ export async function initializeApp(): Promise<void> {
|
||||
appName: "Fusion",
|
||||
});
|
||||
|
||||
localRuntimeManager = new LocalRuntimeManager({ rootDir: process.cwd() });
|
||||
|
||||
tray = new Tray(nativeImage.createEmpty());
|
||||
setupTray(createdWindow, tray);
|
||||
|
||||
@@ -100,25 +144,39 @@ export async function initializeApp(): Promise<void> {
|
||||
if (!localRuntimeManager) {
|
||||
return;
|
||||
}
|
||||
currentDesktopLaunchMode = mode;
|
||||
if (mode === "local") {
|
||||
await localRuntimeManager.startLocal();
|
||||
localRuntimeStartupAttempted = false;
|
||||
await startLocalRuntimeOnce();
|
||||
} else {
|
||||
localRuntimeStartupAttempted = false;
|
||||
await localRuntimeManager.stopLocal();
|
||||
}
|
||||
await saveDesktopLaunchMode(mode);
|
||||
},
|
||||
onDesktopLaunchModeChange: async (mode) => {
|
||||
if (!localRuntimeManager) {
|
||||
return;
|
||||
}
|
||||
currentDesktopLaunchMode = mode;
|
||||
localRuntimeStartupAttempted = false;
|
||||
if (mode === "local") {
|
||||
await startLocalRuntimeOnce();
|
||||
} else {
|
||||
await localRuntimeManager.stopLocal();
|
||||
}
|
||||
await saveDesktopLaunchMode(mode);
|
||||
},
|
||||
getRuntimeStatus: () => localRuntimeManager?.getStatus() ?? { source: "none", state: "stopped" },
|
||||
startLocalRuntime: () => localRuntimeManager?.startLocal() ?? Promise.resolve({ source: "none", state: "stopped" }),
|
||||
stopLocalRuntime: () => localRuntimeManager?.stopLocal() ?? Promise.resolve({ source: "none", state: "stopped" }),
|
||||
getServerPort: () => localRuntimeManager?.getServerPort(),
|
||||
getDesktopLaunchMode: () => currentDesktopLaunchMode,
|
||||
});
|
||||
registerDeepLinkProtocol();
|
||||
setupDeepLinkHandler(createdWindow);
|
||||
setupAutoUpdater(createdWindow);
|
||||
|
||||
if (process.env.FUSION_DESKTOP_MODE === "local") {
|
||||
await localRuntimeManager.startLocal();
|
||||
}
|
||||
|
||||
if (state?.isMaximized === true) {
|
||||
createdWindow.maximize();
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface WindowState {
|
||||
isMaximized: boolean;
|
||||
}
|
||||
|
||||
export type DesktopLaunchMode = "choose" | "local" | "remote";
|
||||
|
||||
export const DEFAULT_WINDOW_STATE: WindowState = {
|
||||
width: 1280,
|
||||
height: 900,
|
||||
@@ -44,6 +46,10 @@ function getWindowStatePath(): string {
|
||||
return join(app.getPath("userData"), "window-state.json");
|
||||
}
|
||||
|
||||
function getDesktopLaunchModePath(): string {
|
||||
return join(app.getPath("userData"), "desktop-launch-mode.json");
|
||||
}
|
||||
|
||||
function isValidWindowState(value: unknown): value is WindowState {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return false;
|
||||
@@ -160,6 +166,38 @@ export function setupAutoUpdater(mainWindow?: BrowserWindow): void {
|
||||
}
|
||||
}
|
||||
|
||||
function isValidDesktopLaunchMode(value: unknown): value is DesktopLaunchMode {
|
||||
return value === "choose" || value === "local" || value === "remote";
|
||||
}
|
||||
|
||||
export async function loadDesktopLaunchMode(): Promise<DesktopLaunchMode> {
|
||||
const launchModePath = getDesktopLaunchModePath();
|
||||
|
||||
try {
|
||||
const raw = await readFile(launchModePath, "utf-8");
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
|
||||
if (parsed && typeof parsed === "object" && "mode" in parsed) {
|
||||
const mode = (parsed as { mode?: unknown }).mode;
|
||||
if (isValidDesktopLaunchMode(mode)) {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
|
||||
return "choose";
|
||||
} catch {
|
||||
return "choose";
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveDesktopLaunchMode(mode: DesktopLaunchMode): Promise<void> {
|
||||
const launchModePath = getDesktopLaunchModePath();
|
||||
const tempPath = `${launchModePath}.tmp`;
|
||||
|
||||
await writeFile(tempPath, JSON.stringify({ mode }, null, 2), "utf-8");
|
||||
await rename(tempPath, launchModePath);
|
||||
}
|
||||
|
||||
export async function loadWindowState(): Promise<WindowState | null> {
|
||||
const statePath = getWindowStatePath();
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ const electronApi = {
|
||||
getDesktopRuntimeStatus: (): Promise<ShellConnectionState["localRuntime"]> => ipcRenderer.invoke("desktopRuntime:getStatus"),
|
||||
startDesktopLocalRuntime: (): Promise<ShellConnectionState["localRuntime"]> => ipcRenderer.invoke("desktopRuntime:startLocal"),
|
||||
stopDesktopLocalRuntime: (): Promise<ShellConnectionState["localRuntime"]> => ipcRenderer.invoke("desktopRuntime:stopLocal"),
|
||||
getDesktopLaunchMode: (): Promise<"choose" | "local" | "remote"> => ipcRenderer.invoke("desktopLaunchMode:getMode"),
|
||||
setDesktopLaunchMode: (mode: "choose" | "local" | "remote"): Promise<"choose" | "local" | "remote"> =>
|
||||
ipcRenderer.invoke("desktopLaunchMode:setMode", mode),
|
||||
|
||||
// Tray status
|
||||
updateTrayStatus: (status: string): Promise<void> => ipcRenderer.invoke("tray:updateStatus", status),
|
||||
|
||||
2
packages/desktop/src/types.d.ts
vendored
2
packages/desktop/src/types.d.ts
vendored
@@ -34,6 +34,8 @@ export interface FusionAPI {
|
||||
getDesktopRuntimeStatus(): Promise<ShellConnectionState["localRuntime"]>;
|
||||
startDesktopLocalRuntime(): Promise<ShellConnectionState["localRuntime"]>;
|
||||
stopDesktopLocalRuntime(): Promise<ShellConnectionState["localRuntime"]>;
|
||||
getDesktopLaunchMode(): Promise<"choose" | "local" | "remote">;
|
||||
setDesktopLaunchMode(mode: "choose" | "local" | "remote"): Promise<"choose" | "local" | "remote">;
|
||||
|
||||
// Tray status
|
||||
updateTrayStatus(status: string): Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user