Skip to content
134 changes: 12 additions & 122 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
Expand Down Expand Up @@ -83,7 +81,7 @@
*
* - Packaged install / `start:desktop` → always `Huabu`. A single global
* instance is intentional: these share one `<userData>/data` tree, so
* two of them at once fight over port 3001 and the same `workspace.json`
* two of them at once fight over port 3001 and the same Workspace registry
* (the exact failure the single-instance lock guards against).
*
* - `dev:desktop` (HMR orchestrator) → `Huabu Dev`, OPTIONALLY suffixed
Expand Down Expand Up @@ -112,8 +110,8 @@
*
* Only the HMR dev orchestrator (`pnpm dev:desktop`) gets a different name.
* Its tsx-watch server and Vite HMR are actively-changing code, so we keep
* its `workspace.json`, Chromium storage, and Electron logs / crash dumps
* isolated from a real install. (Its LLM/integration secrets are a separate
* its Chromium storage and Electron logs / crash dumps isolated from a real
* install. (Its LLM/integration secrets are a separate
* concern that's ALSO isolated, but not by this name split: with
* `EXTERNAL_SERVER_URL` set we skip the `safeStorage`-backed
* `DesktopSecureSecretStore` below entirely, and the tsx-watch server
Expand All @@ -123,7 +121,7 @@
* `pnpm start:desktop`, by contrast, runs the exact same bundled server /
* web build a packaged install would run — it's typically used as a final
* smoke test before shipping, so it intentionally shares `Huabu`'s on-disk
* state with the installed app: same workspace, and the same
* state with the installed app: same Workspace registry, and the same
* `safeStorage`-encrypted `<userData>/data/secure-secrets.json` (so secrets
* already configured in the installed app are reused) rather than starting
* from an empty slate.
Expand Down Expand Up @@ -152,7 +150,7 @@
* a second time, or running `start:desktop` while the installed app is
* open) forks a SECOND Fastify server. The two servers then fight over
* the preferred port (3001) and, worse, share the same
* `<userData>/data` tree — same `workspace.json`, same canvas DB. When
* `<userData>/data` tree — same Workspace registry, same canvas DB. When
* one instance later shuts its server down, any window still pointed at
* `127.0.0.1:3001` starts getting `503 (server closing)` and then
* `ERR_CONNECTION_REFUSED`.
Expand Down Expand Up @@ -457,8 +455,8 @@
// Ensure the data directory exists so the server doesn't have to
// race-condition on first-use creation. The workspace directory is
// intentionally NOT pre-created: in free mode the user picks it via
// the in-app UI (folder picker / path input), and the web client
// persists the selection across launches via localStorage.
// the in-app UI (folder picker / path input), and the Server records the
// selection in its Workspace registry after successful activation.
mkdirSync(dataDir, { recursive: true });

if (IS_DEV && webDistPath && !existsSync(webDistPath)) {
Expand All @@ -485,6 +483,9 @@
SERVER_PORT: String(port),
HUABU_BIND_HOST: '127.0.0.1',
HUABU_DATA_DIR: dataDir,
// Read-only upgrade source. The Server imports this deprecated file only
// when its authoritative storage/disk/workspaces.json does not exist.
HUABU_LEGACY_WORKSPACE_STORE: join(userData, 'workspace.json'),
HUABU_SECRET_BRIDGE: '1',
...(webDistPath ? { WEB_DIST_PATH: webDistPath } : {}),
NODE_ENV: IS_DEV ? 'development' : 'production',
Expand Down Expand Up @@ -612,7 +613,7 @@
// non-zero exit we dump the captured stderr ring as a crash file.

serverExitPromise = new Promise((resolve) => {
serverProcess!.on('exit', (code) => {

Check warning on line 616 in apps/desktop/src/main.ts

View workflow job for this annotation

GitHub Actions / Lint / Format / Typecheck / Test / Build

Forbidden non-null assertion
if (code !== 0) {
console.error(`[desktop] server exited with code ${code}`);
dumpServerCrash(code ?? -1);
Expand Down Expand Up @@ -691,115 +692,6 @@
});
}

// ── Workspace persistence ────────────────────────────────────────────

/**
* Persist the user-selected free-mode workspace path (and recent list)
* in a JSON file under `app.getPath('userData')` so it survives across
* launches independently of the renderer's `localStorage`.
*
* Why not just rely on `localStorage`? Chromium partitions storage by
* origin (scheme + host + port). The Electron shell forks the server on
* a fresh port whenever the preferred port (3001) is busy — e.g. a
* leftover server process, another local service, or simply a second
* launch racing with the first. A different port means a different
* origin, which means a separate, empty `localStorage` bucket and the
* user is dumped back on the workspace picker even though they picked
* a folder yesterday.
*
* Storing the path in the main process (one location per user,
* port-agnostic) and exposing it over IPC sidesteps the partition
* entirely. The renderer still keeps `localStorage` writes for
* browser/dev mode compatibility, but the Electron bridge takes
* precedence when present.
*/

const WORKSPACE_STORE_FILE = 'workspace.json';
const MAX_RECENT_WORKSPACES = 5;

interface WorkspaceStore {
path: string | null;
recent: string[];
}

function workspaceStorePath(): string {
return join(app.getPath('userData'), WORKSPACE_STORE_FILE);
}

function readWorkspaceStore(): WorkspaceStore {
const file = workspaceStorePath();
if (!existsSync(file)) return { path: null, recent: [] };
try {
const raw = readFileSync(file, 'utf8');
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return { path: null, recent: [] };
}
const obj = parsed as Record<string, unknown>;
const path =
typeof obj.path === 'string' && obj.path.length > 0 ? obj.path : null;
const recent = Array.isArray(obj.recent)
? obj.recent.filter((p): p is string => typeof p === 'string')
: [];
return { path, recent };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[desktop] workspace store unreadable: ${message}`);
return { path: null, recent: [] };
}
}

function writeWorkspaceStore(store: WorkspaceStore): void {
const file = workspaceStorePath();
// Atomic-ish write via tmp + rename so a crash mid-write doesn't
// leave a half-truncated JSON the next launch refuses to parse.
const tmp = `${file}.tmp`;
mkdirSync(app.getPath('userData'), { recursive: true });
writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf8');
renameSync(tmp, file);
}

function pushRecentWorkspace(store: WorkspaceStore, path: string): string[] {
const next = [path, ...store.recent.filter((p) => p !== path)].slice(
0,
MAX_RECENT_WORKSPACES,
);
return next;
}

function registerWorkspaceIpc(): void {
ipcMain.handle('workspace:get', () => readWorkspaceStore());

ipcMain.handle('workspace:set', (_event, rawPath: unknown) => {
if (typeof rawPath !== 'string' || rawPath.length === 0) {
throw new Error('workspace:set requires a non-empty string path');
}
if (!isAbsolute(rawPath)) {
throw new Error('workspace:set requires an absolute path');
}
const current = readWorkspaceStore();
const next: WorkspaceStore = {
path: rawPath,
recent: pushRecentWorkspace(current, rawPath),
};
writeWorkspaceStore(next);
return next;
});

ipcMain.handle('workspace:remove-recent', (_event, rawPath: unknown) => {
if (typeof rawPath !== 'string') {
throw new Error('workspace:remove-recent requires a string path');
}
const current = readWorkspaceStore();
const next: WorkspaceStore = {
path: current.path === rawPath ? null : current.path,
recent: current.recent.filter((p) => p !== rawPath),
};
writeWorkspaceStore(next);
return next;
});
}

function registerWindowIpc(): void {
ipcMain.handle('window:is-fullscreen', () => {
return mainWindow ? mainWindow.isFullScreen() : false;
Expand Down Expand Up @@ -1241,10 +1133,8 @@
// inside `createWindow`.
applyApplicationMenu(() => mainWindow);

// Register IPC handlers BEFORE any window is created so the preload
// script's `ipcRenderer.invoke('workspace:get', …)` calls always have
// a handler to talk to, even on the very first render.
registerWorkspaceIpc();
// Register IPC handlers before any window is created so every preload
// bridge is ready on the first render.
registerWindowIpc();
registerDiagnosticsIpc();
registerDialogIpc();
Expand Down
28 changes: 0 additions & 28 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,6 @@ import { contextBridge, ipcRenderer } from 'electron';
*/
const TITLE_BAR_HEIGHT = 36;

interface WorkspaceStoreSnapshot {
path: string | null;
recent: string[];
}

contextBridge.exposeInMainWorld('electronBridge', {
versions: {
node: process.versions.node,
Expand All @@ -66,29 +61,6 @@ contextBridge.exposeInMainWorld('electronBridge', {
* number on its own side.
*/
titleBarHeight: TITLE_BAR_HEIGHT,
/**
* Port-agnostic workspace persistence. The main process writes the
* selected free-mode workspace path (and its recents list) into
* `<userData>/workspace.json`, sidestepping the per-origin
* `localStorage` bucket that resets whenever Electron has to pick a
* fresh server port. See `main.ts` → "Workspace persistence" for
* the full rationale.
*/
workspace: {
get: (): Promise<WorkspaceStoreSnapshot> =>
ipcRenderer.invoke('workspace:get') as Promise<WorkspaceStoreSnapshot>,
set: (path: string): Promise<WorkspaceStoreSnapshot> =>
ipcRenderer.invoke(
'workspace:set',
path,
) as Promise<WorkspaceStoreSnapshot>,
removeRecent: (path: string): Promise<WorkspaceStoreSnapshot> =>
ipcRenderer.invoke(
'workspace:remove-recent',
path,
) as Promise<WorkspaceStoreSnapshot>,
},

window: {
isFullScreen: (): Promise<boolean> =>
ipcRenderer.invoke('window:is-fullscreen') as Promise<boolean>,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
isWorkspaceConfigured,
} from './modules/workspace.js';
import workspaceRoutes from './modules/workspace.route.js';
import workspacesRoutes from './modules/workspaces.route.js';
import { preloadSkills } from './prompt/index.js';
import { getPersistedSecret, setSecrets } from './security/secret-store.js';
import { MAX_UPLOAD_BYTES } from './upload-limits.js';
Expand Down Expand Up @@ -275,6 +276,7 @@ app.register(deploymentRoutes, { prefix: '/api/deployment' });
app.register(interactiveViewRoutes, { prefix: '/api/interactive-views' });
app.register(skillsRoutes, { prefix: '/api/skills' });
app.register(workspaceRoutes, { prefix: '/api/workspace' });
app.register(workspacesRoutes, { prefix: '/api/workspaces' });
app.register(rfsRoutes, { prefix: '/api/rfs' });
app.register(agentTeamRoutes, { prefix: '/api/agent-team' });

Expand Down
Loading
Loading