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 c93f61b506
commit 695cb0bca5
13 changed files with 341 additions and 21 deletions

View File

@@ -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` |

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");

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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();

View File

@@ -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),

View File

@@ -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>;