Skip to content

feat: migrate from Wails/Go to native C++/WebView host#28

Open
KrisPowers wants to merge 472 commits into
mainfrom
feat/cpp-migration
Open

feat: migrate from Wails/Go to native C++/WebView host#28
KrisPowers wants to merge 472 commits into
mainfrom
feat/cpp-migration

Conversation

@KrisPowers

Copy link
Copy Markdown
Member

What this is

Merges the full C++/WebView migration into main. The app no longer runs on Wails and Go. It's now a from-scratch C++ host wrapped around WebView2 (and webview/webview for cross-platform parity), talking to the frontend over a typed IPC layer instead of Wails bindings.

This was done in two stages on top of each other:

  • Step 1 (feat/cpp-migration): got the C++ backend compiling and talking to the existing Go process over a named pipe.
  • Step 2 (feat/webview-migration, merged via feat: complete Go/Wails → C++/WebView migration #26): built the real native window, wired up IPC end-to-end, ported every backend feature, rebuilt the installer in C++/WebView, and deleted the entire Go/Wails codebase. No .go files remain outside .git/.

See #26 for the full writeup of how the migration went.

Also included

This branch carried a long tail of feature work alongside the migration: the rebrand to Binder, a CWE security scanner with a redesigned Problems UI, a SQLite-backed database panel with privacy mode, a tab/search UX overhaul, terminal quality-of-life fixes (Ctrl+click to open/cd, CWD truncation, resize guards), the events map rebuild, local TCP/UDP port forwarding, theme simplification down to dark/light, and settings consolidation.

Test plan

  • CI green on windows/macos/ubuntu compile, TypeScript, ESLint, CodeQL, secret scan, npm audit
  • cmdide-host.exe opens a window with the frontend UI and the terminal works end to end
  • No .go files remain outside .git/
  • Manual smoke test of macOS and Linux builds on real hardware

Workflow runs now live in a global store so they keep streaming and
finish in the background regardless of which page or pane is active.
A bottom-right notification follows the user across the app, showing
a spinner while a run is in progress, pass/fail on completion, a log
download with downloading/downloaded states, and a dismiss button.

Also loosens up the Workflows panel layout (wider sidebar, larger
type, more spacing) so run output and step lists are easier to read.
Replace the single shared layoutRoot/focusedPaneId with a per-workspace
layouts map keyed by top-level tab id. New tabs get their own isolated
single-pane layout instead of being swept into whichever pane is focused
in the currently viewed tab, so split-view tabs no longer leak terminals
into each other. The global tab bar now lists top-level tabs and switches
between workspaces' layouts.
…un timeline

Replace the dense list+detail layout with spacious workflow cards in the
sidebar, a hero header for the selected workflow, a segmented Code/Run
control, and a redesigned Run view with a vertical step timeline above a
single combined output log.
…cal sections

Top header now shows only the workflow name, path, and a separated run
control cluster (status badge + Run button). The Run button gets a
sun-fade/glare animation while a run is active, and now reflects the
global run state so it keeps showing "Running" across page navigation.

Code/Run tabs are replaced with a vertical Code / Events Map / History
nav. Events Map renders a node graph of triggers -> jobs -> outcomes
parsed from the workflow YAML via a new lightweight parser. History
lists past runs (pass/fail, timestamp, local) with an expandable
timeline + log view.

Sidebar is wider and scroll areas use a custom scrollbar.
.replace('}', '') only stripped the first match, leaving a stray
brace and unsanitized ref text rendered for stash refs containing
multiple braces.
Adds NotepadPage with notes scoped to the current working directory,
registers the 'notepad' page type, and wires recentPaths/onSelectRecentPath
into App so the sidebar's recent-paths menu can cd the active terminal via
a terminal:cd-to event. Also drops the old quick-paths terminal banner and
disables drag-to-split for the page already focused in the pane.
Merge the duplicate Sidebar import, drop unused paneModel imports
(serializeLayout/deserializeLayout/SerializedNode), remove the dead
duplicate handleAddSiblingTerminal callback, and drop the unused
updateRatioInTree import from SplitPaneView.
Replace the raw xterm passthrough with a block-based view: each
command gets a status dot (running/success/background/error), a
header showing [branch], cwd, and command, and an L-shaped indent
for its output. xterm now stays headless except during interactive
PTY sessions, and a single always-visible input row drives typing,
history, and tab completion in every alignment mode.

Backend now tracks process exit codes through run_command and
includes them in the bar-prompt event so the frontend can derive
each block's status.
…dback

In default alignment the input row now flows inline after the last
block's output instead of being pinned to the bottom of the pane.
Branch/cwd in block headers and the input row are white, with the
branch wrapped in parentheses and bold.

Re-add ctrl+scroll font zoom (attached to the pane root instead of the
now-hidden xterm element) and drive both xterm and the block/input row
font size from it via a --term-font-size CSS variable, so default_zoom
and live zoom apply to the new UI too.

cd and clear now print a confirmation line ('Path changed to ...' /
'Cleared successfully') as block output.
Strip leading blank lines from a block's output (not just trailing),
so the leading \r\n on builtin messages like 'Path changed to ...'
no longer pushes the └ connector onto its own empty line.

Size the status dot in em units so it scales with --term-font-size
instead of staying a fixed 8px next to zoomed header text.
…a runtime is ready

The input row now matches a traditional shell: while the latest block
is still 'running' (which the blocking run_command() backend already
enforces), the input becomes read-only with a 'Running... (Ctrl+C to
interrupt)' placeholder, and only Ctrl+C is processed.

A still-running block whose output matches common dev-server "ready"
signals (ready in, compiled successfully, listening on, a localhost
URL, etc.) now shows a steady green dot instead of the white pulse,
indicating the runtime came up even though the command hasn't exited.
FullscreenIDE was only rendered while a pane's activePage was 'editor',
so switching to the terminal (or another workspace tab) and back fully
unmounted it - losing open files, explorer scroll/expand state, and
cursor/scroll position. It's now rendered in an always-mounted overlay
(like terminals) and just hidden/shown per pane.

Also preserve per-file cursor/scroll position when switching between
open files within the editor, since each file still gets its own Monaco
model and was previously discarding view state on every tab switch.
… re-entry

WorkflowsPanel was only rendered while a pane's activePage was
'workflows', so switching away and back fully unmounted it - refetching
the workflow list and re-reading the open workflow's YAML every time,
with a full skeleton flash.

It's now rendered in an always-mounted overlay (like terminals/editor)
and just hidden/shown per pane, so its already-fetched list/content
render instantly on re-entry.

On re-entry (and periodically while open), it also does a silent
background re-check for new/changed workflows - no skeleton, doesn't
touch an in-progress run, and shows a small spinner next to the
workflow count while checking.
Adds follow-up jobs to lint.yml and audit.yml that run eslint --fix or
npm audit fix on the PR's own branch when the corresponding check
fails, then open a small PR with the corrections targeting that branch.
Remove unused GitFileEntry import and mark fire-and-forget git
operations with void to satisfy no-floating-promises.
KrisPowers and others added 5 commits June 23, 2026 17:37
Sidebar pages are no longer hardcoded; an apps/ registry tracks
install state in config.json and a Vite-glob loader code-splits each
app package so uninstalled apps are never fetched or parsed (verified
notepad now builds to its own chunk separate from the main bundle).
Notepad is the first page converted to this pattern; Ports, Database,
Version Control, Workflows, and Live Preview stay as core pages for
now pending their own conversion. Also drops the dead ai-plugin and
git (Git Insights) plugins, whose install path never worked.
@github-actions

Copy link
Copy Markdown

🔧 Auto-fixed ESLint issues — app/frontend

The ESLint check failed, so eslint --fix was run and the result committed directly to this branch:

 app/frontend/src/App.tsx                 | 2 +-
 app/frontend/src/apps/sidebarRegistry.ts | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

Both move out of app/frontend/src/components into packages/ports and
packages/versioncontrol, exporting an AppManifest with a sidebar slot
instead of being statically wired into App.tsx/Sidebar.tsx. They're
picked up by the existing app loader/registry with no further plumbing.

Pins react/react-dom/lucide-react via vite resolve.alias since app
packages live outside the frontend project root and Rolldown's bare
import resolution doesn't reliably walk up to find them otherwise.
Pin react/react-dom/lucide-react aliases (vite.config + tsconfig) since
app packages live outside the frontend project root.
Moves out of app/frontend/src/components into packages/database. Adds
focusPath to AppContext (plugin-sdk) so opening a specific .db file
from elsewhere still pre-selects it; privacy mode is now self-fetched
via config.get instead of prop-drilled from App.tsx.
…seeding

New first screen asks what kind of user you are (Hobbyist, Student,
Software Engineer, Data Engineer, Process Engineer, Network Engineer)
and seeds the corresponding non-essential apps for first launch via a
one-shot marker file the main app's Config::load() consumes once and
deletes -- the choice itself is never stored or transmitted beyond that.
Window grows from 460x330 to 640x520 to fit the new step. The version
picker now surfaces latest stable and latest dev build as quick picks
above a scrollable full release list; GetReleases no longer filters out
prereleases at the API layer, since the channel split is now a frontend
concern.
Apps can now contribute UI to other parts of the host instead of the
host hardcoding knowledge of them: AppManifest.contributes.fileExplorerContextMenu
lets an app add right-click items to the Code Editor's file explorer.
Live Preview's hardcoded 'Open Live Preview' menu item (for .md/.html
files) moves out of FileExplorer.tsx into the Live Preview app package
as its first user.

Live Preview's state also moves out of App.tsx into a standalone
lib/livePreviewStore.ts (same pattern as workflowRunsStore), so the
page can mount/unmount freely with the App Store instead of needing to
stay always-mounted. A new generic 'apps:navigate' window event lets
any app ask to be brought to the foreground after doing something
elsewhere in the host.
…lay mechanism

Workflows needed to stay mounted across page switches (its internal
selected-file/scroll state isn't lifted to a store like the run-tracking
state already is), so the editor-only useStickyLeafIds is generalized
into useStickyLeafIdsMap, driven by any installed app whose manifest
sets sidebar.sticky. App.tsx no longer has Workflows-specific overlay
code; future sticky apps need zero App.tsx changes.

The app self-derives gpuColors/indentGuides/zoom via config.get instead
of receiving them as props, and edits route through the generic
context.openFile (now pane-aware) instead of a dedicated callback.
App backends are now meant to be separate DLLs, LoadLibrary'd by the
host only when that app is installed (driven by config.json's
installed_apps), instead of being statically linked into the host
binary regardless of install state -- the native-code equivalent of
the frontend's dynamic-import code splitting.

app_plugin_abi.h defines the C ABI boundary (binder_app_init/dispatch/
alloc/free/shutdown exports, BinderHostApi callback table for
config get/set and event emission) so plugin DLLs never link
config.cpp or dispatch.cpp directly -- that would create a second,
unsynced copy of the Config singleton inside each DLL's own CRT
instance. app_plugin_loader.{hpp,cpp} is the host-side LoadLibrary/
FreeLibrary manager and dispatch fallback, wired into Dispatcher's
existing OR-chain and a new app.loadBackend/app.unloadBackend IPC pair
for live install/uninstall without a restart.

No app DLLs exist yet -- this is the infra only, landed first so the
ABI can be proven correct on one simple app before converting the rest.
Proof of concept for the native app-plugin ABI: git.cpp moves out of
binder-backend-lib into its own SHARED target (cpp/apps/versioncontrol),
exporting the 4 C ABI functions via a thin dllmain.cpp wrapper around
the unchanged git_ops::dispatch. Verified with dumpbin that all 5
exports (init/dispatch/alloc/free/shutdown) resolve with clean,
unmangled names, and that binder-host still builds and links without
git.cpp -- Version Control's backend now contributes zero compiled
code to the host binary unless its DLL is actually loaded.

Added req_id as an explicit binder_app_dispatch parameter (some
existing dispatch handlers use it as more than a response-correlation
id, e.g. a temp-file uniqueness key) before converting any more apps.
webview::webview(false, nullptr) let the library create and immediately
show its own default WS_OVERLAPPEDWINDOW (generic icon, default position)
before our code ever got to style or centre it, then MakeFrameless hid and
re-showed it without calling SetForegroundWindow. That produced the
visible flash of a generic window followed by the installer landing on
the taskbar without focus.

Now mirrors the pattern already used by cpp/host: pre-create our own
hidden, already-styled, already-centred window and hand its HWND to the
webview constructor (m_owns_window=false skips webview's internal
CreateWindow+ShowWindow entirely). The window stays hidden until the
React app signals it has painted its first frame via a new
installer.ready IPC call, at which point we ShowWindow + SetForegroundWindow.
Replace the version dropdown with an always-visible checklist (radio
rows) so the version is printed up front instead of hidden behind a
menu, with exactly one selectable at a time.

Give every persona card a fixed width and height (was auto-height per
grid row, so cards varied in size depending on blurb length) and add a
lucide icon per persona for a less bare look. Swap the remaining inline
Unicode glyphs (close button, dismiss button, done checkmark) for
lucide-react icons for visual consistency.
First app needing the host_api config bridge: port_forward persists
its rules through host_api->config_get/config_set instead of linking
config.cpp directly, since linking it into the DLL would create a
second, unsynced Config singleton. start_persisted()/stop_all() move
from being called directly in Dispatcher's constructor/destructor to
the DLL's binder_app_init/shutdown, so persisted forwards only start
running if the Ports app is actually installed.

ws2_32 moves off binder-backend-lib onto the ports DLL target; iphlpapi/
psapi stay on binder-backend-lib since sysinfo.cpp (core) needs them too.
Passing our own pre-created HWND to webview::webview sets its internal
m_owns_window=false, which skips webview's own CoInitializeEx and
DPI-awareness setup (gated behind if(m_owns_window) in win32_edge_engine's
constructor). Without COM initialized on this thread, WebView2's
environment/controller creation has nothing to rely on and the installer
never opened. cpp/host/main.cpp already does this init explicitly before
constructing webview for the same reason; port it over here too.
workflows.cpp + workflow_yaml.cpp + workflow_expr.cpp + workflow_runner.cpp
+ process_registry.cpp (only ever used by workflow_runner) move wholesale
out of binder-backend-lib into one DLL -- no host_api bridging needed,
the whole bundle was already self-contained.

workflows.run/workflows.stop used to be special-cased directly in
Dispatcher::dispatch_worker (not through the dispatch() OR-chain) so the
runner could capture this->emit. That capture doesn't work across a DLL
boundary, so those two cases move into the DLL's binder_app_dispatch,
streaming run output/step/done events through host_api->emit_event
instead. They now flow through the same generic dispatch path as
everything else.
InstallerApp::Ready ran on the detached IPC worker thread and called
ShowWindow/SetForegroundWindow directly there instead of marshalling them
onto the UI thread via wv_.dispatch(), which is what the working precedent
in cpp/host/dispatch.cpp's app.ready handler does. The hidden setup window
was never actually being shown as a result.
preview.cpp (markdown rendering + the local static-file/preview HTTP
server) moves out of binder-backend-lib into its own DLL with httplib+
cmark linked privately there instead of into the core lib -- cmark was
only ever used by preview.cpp, so it drops off binder-backend-lib
entirely; httplib stays since updater.cpp (core) also needs it.

The terminal's /preview slash command used to call preview_ops::dispatch
directly from inside Dispatcher::dispatch_worker; that call now goes
through app_plugin_loader::dispatch like everything else, so /preview
degrades to a silent no-op instead of a link error when Live Preview
isn't installed.
The __binder_invoke handler's try/catch only wrapped std::thread
construction, not the detached thread's actual lambda body. Any exception
thrown inside a handler (e.g. GetReleases' GitHub network call) escaped
the thread's entry function, which calls std::terminate() -> abort() and
kills the whole process. Windows logs this as Application Error,
exception code 0xc0000409, with no other trace. This explains the
installer silently vanishing shortly after launch instead of opening.

Also wrap the rest of wWinMain in a try/catch with a visible MessageBoxW
so future startup failures (e.g. missing WebView2 runtime) show the user
something instead of disappearing without a trace.
… DLL

db.read/db.exec were inlined directly in Dispatcher::dispatch_worker,
each spawning its own nested detached thread and resolving the IPC
seq directly via d->resolve_ok/resolve_err -- not even their own
module, let alone DLL-able. Extracted into cpp/src/database.{hpp,cpp}
as database_ops::dispatch(type, msg, id, resp), matching every other
module's convention, and made synchronous: the nested thread was
redundant since the caller (a per-request worker thread already) was
never the UI thread to begin with.

This was the last of the five apps with native backends -- Version
Control, Ports, Workflows, Live Preview, and now Database all build as
separate DLLs, LoadLibrary'd by the host only when installed, with
nothing static-linked into binder-backend-lib on their behalf anymore.
Notepad has no backend and needs none, unchanged.
installApp()/uninstallApp() now call app.loadBackend/app.unloadBackend
right after config.set succeeds, so toggling an app with a native
backend (Version Control, Ports, Workflows, Live Preview, Database)
takes effect immediately in the running process instead of only on
next launch -- the host only scans installed_apps for backends to
load once, at its own startup, so without this the DLL wouldn't
actually load until a restart.

Verified end-to-end: closed the running dev instance, relaunched with
all five apps already in installed_apps, and confirmed via the live
process's module list that every one of versioncontrol.dll, ports.dll,
workflows.dll, livepreview.dll, and database.dll loaded correctly.
Root cause of "the window stays hidden / nothing appears": resources.rc
embeds generated/frontend.zip via RCDATA, but MSBuild's RC compile step
only tracks resources.rc's own timestamp, not the zip's contents. Every
prior rebuild today kept re-embedding the same stale zip (predating the
installer.ready IPC call) no matter how many times the C++ logic changed,
because frontend.zip itself regenerated correctly but the resource
compiler never noticed and never re-embedded it.

Fixed by declaring frontend.zip as an OBJECT_DEPENDS of resources.rc, and
switching the dist file glob to CONFIGURE_DEPENDS so Vite's
content-hashed output filenames are picked up without a manual
reconfigure. Also hardened ExtractInstallerAssets' cache-key hash to
cover the whole resource instead of just the first 64 bytes, since a
couple of embedded files are byte-identical across builds and could
collide a short prefix hash, causing the same staleness bug to resurface
via the extraction cache instead of the resource compiler.

Confirmed via direct Win32 IsWindowVisible() check that the window now
actually becomes visible after installer.ready fires.
Full structural rebuild, not a restyle: fixed titlebar with small logo
mark, a step indicator (Profile / Configure / Install) instead of no
progress feedback, a single reusable select-row component shared by both
the persona list and version list instead of two unrelated ad hoc
widgets, a toggle switch instead of a raw checkbox, and a persistent
footer action bar instead of inline buttons baked into each screen. Added
a Back button on the configure step since there was previously no way to
revisit the persona choice.

New CSS design system built on custom properties (surface/border/text/accent
scale) instead of scattered inline rgba() values repeated per component.
Drop Hobbyist entirely (PERSONAS, PERSONA_APPS, Persona type) — five
roles remain. Replace the vertical select-row list for the role picker
with a 2-column grid of fixed-size block cards, selection indicated by a
blue border instead of a radio dot. The version picker keeps the
select-row list, only the role-type screen changes.
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.

1 participant