Skip to content

[codex] Add mac desktop app#316

Open
furukama wants to merge 15 commits intomainfrom
codex/electron-mac-app
Open

[codex] Add mac desktop app#316
furukama wants to merge 15 commits intomainfrom
codex/electron-mac-app

Conversation

@furukama
Copy link
Copy Markdown
Contributor

@furukama furukama commented Apr 14, 2026

What changed

This adds a new desktop/ Electron workspace that packages HybridClaw as a native macOS desktop app.

  • wrap the existing /chat, /agents, and /admin surfaces inside Electron windows
  • bundle the local HybridClaw runtime so the app can reuse an existing gateway or start its own bundled gateway automatically
  • add mac-specific window chrome, menu wiring, route handling, and About/help behavior
  • add HybridClaw app icon generation, Icon Composer assets, and source-run app bundle support for local development
  • add mac packaging support for .zip and .dmg, including a branded drag-to-install DMG layout
  • wire the root workspace scripts, docs, and ignore rules for the desktop workspace

Why

HybridClaw already has the web surfaces needed for chat and admin flows. This change ships a native mac wrapper around those existing surfaces so the product can be installed and used like a standard mac application.

User impact

  • npm run desktop launches a native HybridClaw mac app from source
  • npm run desktop:mac produces packaged mac artifacts
  • the app opens Chat, Agents, and Admin in-app instead of sending those routes to the browser
  • packaged builds include the bundled runtime/gateway needed for local use

Validation

  • npm run format
  • npm run lint
  • npm --workspace desktop run test
  • npm run build:desktop
  • npm --workspace desktop run dist:mac

@furukama furukama marked this pull request as ready for review April 14, 2026 21:33
Copilot AI review requested due to automatic review settings April 14, 2026 21:33
@furukama
Copy link
Copy Markdown
Contributor Author

@claude Do a thorough review of this PR in terms of: KISS, YAGNI, DRY, Fail Fast, Secure by Default and performance/speed

@claude
Copy link
Copy Markdown

claude Bot commented Apr 14, 2026

Claude finished @furukama's task in 2m 56s —— View job


PR Review: KISS, YAGNI, DRY, Fail Fast, Secure by Default, Performance

Tasks

  • Gather context and understand request
  • Read core desktop source files
  • Read build/config files
  • Read modified console files
  • Post comprehensive review

Overall the Electron wrapper is well-structured: small modules, clear separation of concerns, and good security defaults on BrowserWindow. Below is a focused assessment against each principle, from most to least severe.


Secure by Default

[HIGH] XSS via unescaped environment variable in buildAboutHtml()

gateway.baseUrl is interpolated raw into HTML (desktop/src/main.ts:302):

<div class="metric-value">${gateway.baseUrl}</div>

If HYBRIDCLAW_DESKTOP_GATEWAY_URL contains <script>alert(1)</script> (or any HTML-special characters), it will be injected verbatim into the rendered page. The About window loads this via a data:text/html URI so contextIsolation/sandbox still apply — but injected scripts would run inside the renderer. Attacker only needs to control the env var, which is trivial in certain deployment setups.

Fix: escape the value before interpolation.

function escapeHtml(s: string): string {
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// then:
<div class="metric-value">${escapeHtml(gateway.baseUrl)}</div>

Fix this →

[LOW] Unvalidated cast after hc-about:// protocol parse (desktop/src/main.ts:366-376)

const route = url.slice('hc-about://'.length) as DesktopRoute;
void openRoute(route);

The string is cast to DesktopRoute without verifying it's one of 'chat' | 'agents' | 'admin'. A navigation to hc-about://anything would call openRoute with an invalid route, producing an unexpected gateway URL. Not exploitable without already controlling the renderer, but the cast is unsafe. Add a guard:

const routeCandidate = url.slice('hc-about://'.length);
if (routeCandidate === 'chat' || routeCandidate === 'agents' || routeCandidate === 'admin') {
  void openRoute(routeCandidate);
}

Fix this →

[GOOD] All BrowserWindow instances use contextIsolation: true, nodeIntegration: false, sandbox: true. External URLs always go through shell.openExternal(). will-navigate blocks non-gateway loads. Solid baseline.


DRY

Redundant double-normalization of baseUrl (desktop/src/gateway-target.ts:29-33)

GatewayRuntime stores an already-normalized baseUrl, but GatewayRuntime.routeUrl() delegates to the module-level routeUrl() which calls normalizeGatewayBaseUrl() again:

// gateway-runtime.ts:82
routeUrl(route: DesktopRoute): string {
  return routeUrl(this.baseUrl, route);   // ← normalizes again
}

// gateway-target.ts:29
export function routeUrl(baseUrl: string, route: DesktopRoute): string {
  const normalizedBaseUrl = normalizeGatewayBaseUrl(baseUrl);  // ← redundant
  ...
}

Same pattern in routeForUrl and buildGatewayEnv. These are called on every navigation event. The normalized URL should be passed through as an already-clean value, or the public functions should accept a pre-normalized URL object.

process.platform === 'darwin' repeated 3× in createWindow() (desktop/src/main.ts:423,430,435)

window.webContents.on('did-finish-load', () => {
  if (process.platform === 'darwin') { ... }   // line 423
});
window.webContents.on('did-navigate', (_event, url) => {
  ...
  if (process.platform === 'darwin') { ... }   // line 430
});
window.webContents.on('did-navigate-in-page', (_event, url) => {
  ...
  if (process.platform === 'darwin') { ... }   // line 435
});

Extract const isMac = process.platform === 'darwin' once at module level (it's already done inside buildMenu() at line 495 but not reused elsewhere).
Fix this →

Dual-map bookkeeping (desktop/src/main.ts:43-44)

windows (route → window) and windowRoutes (window → route) are two maps that must always be kept in sync. setWindowRoute (line 67-76) and the closed handler (line 412-418) both manually update both maps. A lightweight BiMap helper or a single Map<DesktopRoute, BrowserWindow> with the reverse lookup derived on demand would remove the invariant-maintenance burden.

window-chrome.ts CSS repetition (desktop/src/window-chrome.ts:19-65)

The 8 interactive element types (a, button, input, select, textarea, summary, [role='button'], [role='link'], [contenteditable='true']) are repeated verbatim for 5 parent selectors, producing 40 lines of selectors. CSS can't deduplicate cross-parent selectors elegantly, but the CSS could be restructured with :is():

:is(.sidebar-top, .chat-topbar, .page-header, .topbar, [data-hc-sidebar-header])
:is(a, button, input, select, textarea, summary, [role='button'], [role='link'], [contenteditable='true']) {
  -webkit-app-region: no-drag;
}

Fix this →


KISS

main.ts is 621 lines doing too many things

The file contains: window lifecycle management, menu building, About window HTML generation (~180 lines of inline CSS+HTML), gateway crash handling, and app lifecycle hooks. The About window HTML template is the biggest offender — a 183-line template literal with embedded styles is harder to maintain, harder to lint (no HTML/CSS tooling applies), and harder to test.

Consider extracting buildAboutHtml() into a separate about.html static file loaded with loadFile(), or at minimum a dedicated about-window.ts module.

handleWindowOpen always returns false (desktop/src/main.ts:441-452)

const handleWindowOpen = (target: string): boolean => {
  if (isInAppUrl(target, gateway.baseUrl)) { ... }
  void shell.openExternal(target);
  return false;  // ← always false
};

window.webContents.setWindowOpenHandler(({ url }) => {
  return { action: handleWindowOpen(url) ? 'allow' : 'deny' };  // ← always 'deny'
});

The ternary is dead code. Just use return { action: 'deny' } directly and change handleWindowOpen to void handleWindowOpen(...).
Fix this →


YAGNI

Magic per-route window widths (desktop/src/main.ts:55-57)

function widthForRoute(route: DesktopRoute): number {
  return route === 'chat' ? 1440 : route === 'agents' ? 1480 : 1520;
}

The three routes differ by exactly 40px. Unless there's a documented reason why Chat needs to be 1440 and Admin 1520, this is speculative differentiation. A single default width would be simpler and the user can resize.

--prepare-only flag in start-dev.mjs (desktop/scripts/start-dev.mjs:70-72)

A dev-script-only CLI flag to prepare the Electron bundle without launching it. No tests, no docs, no callers outside the script itself. If it's not used in CI or in any npm script, remove it.

support/packaging-anchor/ — A dedicated two-file npm package (index.js + package.json) whose sole purpose is to make node_modules appear in the extraResources filter. This is a valid workaround for an electron-builder limitation, but it's worth a comment explaining why it exists, since it looks like dead code to future maintainers.


Fail Fast

Gateway process exit not short-circuiting ensureRunning wait (desktop/src/gateway-runtime.ts:42-56)

If the gateway child exits immediately (e.g., port already in use), ensureRunning continues polling for up to 20 seconds before giving up. The unexpected-exit event fires immediately, but ensureRunning ignores it. Listening on the child's exit event inside waitForGatewayReachable would fail fast on a dead process instead of spinning for 20s.

resolveRuntimeRoot in dev mode returns an unverified path (desktop/src/runtime-paths.ts:13)

In dev mode, it returns ../../ relative to dist/main.js. If someone runs from an unexpected CWD, startChild() will fail later with a cryptic "gateway build not found" error. Adding a check that the resolved directory contains a package.json would give a clearer error sooner.

restart() pings a just-stopped gateway (desktop/src/gateway-runtime.ts:58-61)

restart() calls stop() then ensureRunning(). ensureRunning() immediately tries isGatewayReachable() — which always returns false right after a stop. It's a cheap wasted HTTP round-trip, but calling startChild() directly after a confirmed stop would be cleaner.


Performance

fs.existsSync(desktopIconPath) called on every window creation (desktop/src/main.ts:350,403)

This synchronous filesystem stat is in the window-creation path and is called twice (once for content windows, once for the About window). The icon either exists or it doesn't — cache the result at startup:

const hasDesktopIcon = fs.existsSync(desktopIconPath);

Fix this →

buildAboutHtml() called on every About window open (desktop/src/main.ts:380)

buildAboutHtml() calls nativeImage.createFromPath(desktopIconPath).toDataURL() — an image decode + base64 encode — every time the window opens. Since the icon and version string don't change at runtime, cache the result:

let cachedAboutHtml: string | undefined;
function getAboutHtml(): string {
  return (cachedAboutHtml ??= buildAboutHtml());
}

insertCSS leaks on repeated loads (desktop/src/main.ts:424)

window.webContents.insertCSS(MAC_WINDOW_CHROME_CSS) is called on every did-finish-load. Electron removes injected CSS on navigation, so the CSS is correctly re-applied after hard reloads — but the returned removal key is silently discarded (void ...). If the same page fires did-finish-load multiple times before navigation (unlikely but possible), CSS would be injected multiple times. Storing the key and removing before re-injecting would be defensive.


Summary

Area Severity Count
Secure by Default High (XSS), Low (unsafe cast) 2
DRY Medium 4
KISS Medium 2
YAGNI Low 3
Fail Fast Low–Medium 3
Performance Low 3

The XSS in buildAboutHtml is the only finding that should block merge. Everything else is code-quality debt that can be addressed as follow-up.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new desktop/ Electron workspace that wraps HybridClaw’s existing /chat, /agents, and /admin web surfaces into a macOS desktop app, including packaging scripts/assets and small console UI hooks to support macOS window chrome.

Changes:

  • Introduces the desktop/ workspace with Electron main process, gateway runtime bootstrap, route handling, and unit tests.
  • Adds macOS-specific chrome behavior via injected CSS + new data-hc-* hooks in the console sidebar layout.
  • Wires root scripts/docs/ignore rules and adds mac packaging/icon generation support (zip + dmg).

Reviewed changes

Copilot reviewed 23 out of 41 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
package.json Adds desktop workspace and root scripts to build/typecheck/test/package the desktop app.
desktop/vitest.config.ts Vitest config for the desktop workspace unit tests.
desktop/tsconfig.json TypeScript configuration for the Electron main-process code.
desktop/support/packaging-anchor/package.json Anchor package to scope electron-builder packaging to the desktop workspace.
desktop/support/packaging-anchor/index.js Empty module export for the packaging anchor dependency.
desktop/src/window-chrome.ts macOS traffic-light/drag-region CSS injected into embedded web surfaces.
desktop/src/window-chrome.test.ts Validates the window chrome CSS contains expected selectors/behaviors.
desktop/src/runtime-paths.ts Resolves runtime root + gateway entry paths for dev vs packaged apps.
desktop/src/runtime-paths.test.ts Unit tests for runtime path resolution.
desktop/src/main.ts Electron main process: window/menu wiring, navigation handling, about window, chrome injection.
desktop/src/gateway-target.ts Normalizes gateway base URL + maps URLs to in-app routes + builds child env.
desktop/src/gateway-target.test.ts Unit tests for gateway URL normalization/routing/env mapping.
desktop/src/gateway-runtime.ts Starts/stops bundled gateway as a child process and waits for health readiness.
desktop/scripts/start-dev.mjs Dev launcher (mac app bundle wrapper on darwin, plain electron elsewhere).
desktop/scripts/generate-mac-icon.swift Generates app icon assets + DMG background imagery.
desktop/scripts/build-dmg.mjs Builds a branded DMG from the packaged app directory output.
desktop/package.json Desktop workspace package + electron-builder config + packaging scripts.
desktop/icon/AppIcon.icon/icon.json Icon Composer manifest used by packaging/icon pipeline.
desktop/build/icon.iconset/icon_32x32@2x.png Generated iconset artifact (build output).
desktop/build/icon.iconset/icon_32x32.png Generated iconset artifact (build output).
desktop/build/icon.iconset/icon_16x16@2x.png Generated iconset artifact (build output).
desktop/build/icon.iconset/icon_16x16.png Generated iconset artifact (build output).
desktop/build/icon-source.png Copied icon source image (build output).
desktop/build/background.png Generated DMG background (build output).
console/src/components/sidebar/sidebar.test.tsx Updates expectation after removing “Admin console” subtitle text.
console/src/components/sidebar/index.tsx Adds data-hc-* attributes for desktop chrome targeting.
console/src/components/sidebar/index.module.css Updates brand styling/classes to match new logo/wordmark layout.
console/src/components/sidebar/app-sidebar.tsx Switches from icon component + subtitle to shared SVG logo + wordmark.
README.md Documents npm run desktop workflow and gateway behavior for the mac app.
.gitignore Ignores desktop build outputs like desktop/dist, .electron-dev, and desktop/release.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread desktop/src/main.ts Outdated
Comment thread desktop/src/main.ts Outdated
Comment thread desktop/src/gateway-runtime.ts Outdated
Benedikt Koehler and others added 3 commits April 15, 2026 08:29
- Escape gateway.baseUrl in About HTML to prevent XSS via env var injection
- Validate hc-about:// route before casting to DesktopRoute
- Short-circuit gateway readiness polling when child process exits early
- Validate dev-mode runtime root resolves to a real project directory
- Skip redundant reachability ping in restart() after confirmed stop
- Cache icon existence check and About HTML at startup
- Track insertCSS key to prevent duplicate CSS injection on reload
- Extract IS_MAC constant and collapse CSS selectors with :is()
- Remove dead handleWindowOpen return value and unused --prepare-only flag
- Unify window width to single default instead of per-route magic numbers
- Document packaging-anchor's purpose for future maintainers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Exclude onnxruntime-web (~92 MB) — browser-only, unused in Electron
- Strip .js.map source maps from bundled node_modules and dist output (~107 MB)
- Drop test/tests/__tests__ directories from copied packages (~6 MB)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@furukama furukama requested a review from maxnoller April 15, 2026 15:33
@maxnoller
Copy link
Copy Markdown
Member

Code review

Found 3 issues:

  1. .brandWordmark not hidden in collapsed sidebar state. The PR renames .brandText to .brandWordmark in JSX and CSS definitions, but the collapsed-state CSS rule (line 313 of index.module.css) still targets .brandText. After this PR, the "HybridClaw" wordmark will remain visible when the sidebar is collapsed.

src="/static/hybridclaw-logo.svg"
alt=""
aria-hidden="true"
/>
<span className={styles.brandWordmark}>HybridClaw</span>
</div>

  1. shouldCopyEntry strip logic in prepare-runtime.mjs is inverted relative to its comment. The comment says "Only strip when the directory sits directly inside a package" but the condition !parent.startsWith('@') && parent !== 'node_modules' strips deeply-nested test dirs (which may be runtime-required) and keeps top-level ones -- the opposite of the stated intent.

const base = path.basename(src);
if (base.endsWith('.js.map')) return false;
if (STRIPPED_DIRS.has(base)) {
const parent = path.basename(path.dirname(src));
// Only strip when the directory sits directly inside a package (or scoped
// package). Never strip "test" inside deeply-nested paths that may be
// runtime-required.
if (!parent.startsWith('@') && parent !== 'node_modules') {
return false;
}
}
return true;
}

  1. waitForExit in gateway-runtime.ts has a race condition: after the 5-second timeout fires and sends SIGKILL, stop() resets #stopping = false before the child's exit event fires. The exit handler then sees #stopping === false and emits unexpected-exit, showing a crash dialog for what was actually an intentional stop.

await this.stop();
throw new Error(
`HybridClaw gateway did not become reachable at ${this.baseUrl} within ${timeoutMs}ms.`,
);
}
async stop(): Promise<void> {
const child = this.#child;
if (!child) return;

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@maxnoller
Copy link
Copy Markdown
Member

Sidebar changes — conflict with #323

This PR's sidebar modifications overlap with #323 (chat-ux-improvements), which also rewrites the same four files (app-sidebar.tsx, index.module.css, index.tsx, sidebar.test.tsx). The two PRs will conflict on merge.

The sidebar changes in this PR serve two independent purposes:

  1. Desktop integration hooks — the data-hc-sidebar-root, data-hc-sidebar-header, and data-hc-main-panel attributes. These are one-line additive changes that the desktop chrome CSS (window-chrome.ts) targets for title bar padding and drag regions. They don't overlap with feat(chat): remove legacy chat.html, React 19 architecture, a11y and UX polish #323's work.

  2. Branding rework — swapping the HybridClaw React icon for an <img>, removing the "Admin console" eyebrow, downgrading <h1> to <span>, renaming .brandMark/.brandText to .brandLogo/.brandWordmark. These changes have issues (collapsed-state rule at line 313 of index.module.css still targets the old .brandText class, dead .eyebrow CSS left behind, <h1> removal breaks heading hierarchy) and conflict directly with feat(chat): remove legacy chat.html, React 19 architecture, a11y and UX polish #323's sidebar work (which adds .headerRow layout, toggle button, collapsible modes, and its own collapsed-state rules including .brand { display: none }).

Suggestion: strip the branding rework from this PR and keep only the data-hc-* attributes. The desktop app doesn't depend on the branding changes — the chrome CSS hooks target the data-hc-* attributes and existing class names like .sidebar and .topbar. This lets #316 merge first cleanly, #323 rebases with trivial conflicts (three additive attribute lines), and the branding simplification can land as a small follow-up after #323 that accounts for its collapsed-state handling.

Max Noller and others added 5 commits April 19, 2026 22:46
- Fix collapsed sidebar CSS to target .brandWordmark (was still
  referencing the old .brandText class)
- Remove dead .eyebrow CSS left over from the sidebar branding rework
- Invert shouldCopyEntry logic in prepare-runtime so only top-level
  test dirs inside packages are stripped, matching the stated intent
- Make waitForExit always await the actual exit event, even after
  SIGKILL, so callers can clear #stopping without racing the exit
  handler (avoids false unexpected-exit crash dialogs)
- Restrict shell.openExternal to http/https/mailto via a new
  openExternalSafely helper; reject javascript:/file:/custom schemes
- Replace `??=` assignment-in-expression in getAboutHtml to satisfy
  biome noAssignInExpressions
- Fix runtime-paths dev test to use a tmpdir with package.json so the
  fs.existsSync guard is exercised instead of tripping on a fake path
- Update window-chrome test to match the :is() combined selector

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion

- Add isDesktopRoute type guard in gateway-target to replace two
  identical route-string disjunctions in the About-window handlers
- Drop redundant normalizeGatewayBaseUrl calls inside routeUrl,
  routeForUrl, and buildGatewayEnv; the sole caller (GatewayRuntime)
  already stores the normalized baseUrl, so re-normalizing on every
  navigation is wasted work

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Revert the logo/wordmark/eyebrow changes in sidebar to avoid conflict with
PR #323, which rewrites the same files. The desktop chrome only depends on
the data-hc-* attributes in index.tsx, not on the branding changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	console/src/components/sidebar/index.tsx
#	tests/tui-slash-command.test.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants