038 · Electron desktop app
The desktop app in Electron: one engine on every platform, and a real app on Windows (spec_electron_038)
| Status | Shipping from v0.7.0 (approved 2026-10-16; the owner’s decision after trying it on macOS and Windows): Electron replaces the Wails app in releases (EL-80). Done: the shared host and blitz-tray --serve (spec_service_021 SVC-53a), the shell (EL-01–EL-22), the page’s routing (EL-40–EL-42), the packages (EL-80). Done also: the updates (EL-90–EL-93) and the web preview with Add to chat and Save to workspace (EL-100–EL-106). The Wails app is removed from the repository (since v0.7.0; restorable from the v0.6.5 tag): apps/desktop keeps only the page (apps/desktop/web). |
| Source | apps/electron (src/main.ts, src/preload.ts, src/logic.ts, dev.sh, defs.bzl, BUILD.bazel, appicon.png, appicon.svg, winexe), apps/electron/packaging (BUILD.bazel, mac.bzl, sign_macos.sh, entitlements.plist, contents_test.sh, linux/), the Electron runtime in MODULE.bazel, pkg/pagehost, apps/tray/internal/tray/serve.go |
| Tests | apps/electron/src/logic.test.ts (vitest), pkg/selfupdate/*_test.go, pkg/pagehost/update_test.go, apps/tray/internal/tray/apply_test.go, apps/desktop/web/src/updates.test.ts, apps/desktop/web/src/desktop.test.ts (the routing), //apps/electron:typecheck, apps/tray/internal/tray/serve_test.go, pkg/pagehost/host_test.go |
Until v0.6.5 the desktop app (spec_desktop_024) was Wails: each system’s own web view, WebKit on macOS and WebKitGTK on Linux. Windows had none. There the tray served the page to Edge (spec_windows_036), without a folder picker, PDF export, the CLI’s installation or menus. WebKitGTK cost real bugs too (the NVIDIA crashes, #39 and #52).
This spec began as a trial of Electron beside the Wails app: Chromium on macOS, Linux and Windows. The owner chose it (2026-10-16): since v0.7.0 it’s the desktop app on every platform, and the Wails app is removed from the repository (restorable from the v0.6.5 tag if it’s ever needed).
- EL-01 Electron’s main process starts
blitz-tray --serve --page <app>/page(SVC-53a) from the app’sbin/, reads the link it prints, and opens the window on it. The page, the service’s API (/blitz.v1.*, to the socket) and the host’s calls (/host/,pkg/pagehost) share that one loopback origin, behind the one-time link and the cookie (WIN-11–13). So the page needs nothing new to reach the service or the host. - EL-02 The host stops when its standard input closes, which happens when Electron quits. If it stops on its own, the main process starts it again and points the window at the new link.
- EL-03 The app (
//apps/electron:app,resources/apponce packaged) holdspackage.json,dist/(the main process and preload, TypeScript compiled bytsc),page/(the desktop page,//apps/desktop/web:page) andbin/(blitz-tray, withblitzdandblitzbeside it as in the desktop app’s packages, so the host finds them as it does there). - EL-04 The Electron runtime comes from Electron’s release archives, one per platform (darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-x64), each pinned by its SHA-256 in
MODULE.bazel(44.5.1). Theelectronnpm package is there for its type definitions only: pnpm runs no install scripts. - EL-05
bazel run //:desktop(//apps/electron:dev) runs it on this machine against its service. The root’s//:mac-app,//:deb,//:pacman,//:desktop-tarand//:windows-zipare the Electron packages (EL-80). Electron runs from where its archive was unpacked, not from the runfiles’ links, so macOS finds its framework. On macOS it runs a copy of Electron’s bundle named Blitz (CFBundleName,CFBundleDisplayName, bundle IDdev.blitz.electron, re-signed ad hoc), made once per Electron in~/Library/Caches/dev.blitz.electron: macOS names the app in the menu bar, the Dock and the window switcher from the bundle, which says Electron.
-
EL-06 The Windows zip’s program is Electron’s, as
Blitz.exe, with Blitz’s icon (the app’s, at 16–256 px) and file details (product and description Blitz, Retail Cortex, the release’s version), which Explorer, the taskbar and Task Manager show://apps/electron/winexerewrites its resources at build time, on any machine (tc-hib/winres), keeping the rest (its manifest), and removes Electron’s signature, which no longer matches. Elsewhere the window has the icon too (icon.pngin the app). -
EL-07 The window draws with the graphics card (Chromium’s own choice of renderer) unless
BLITZ_GPU=off, or thegpupreference isoff(Use the graphics card in Settings › Appearance, shown on Linux): then Electron starts with hardware acceleration off (gpuOff, read from~/.blitz/desktop.jsonbefore Electron starts). The Wails app’s WebKitGTK workarounds (its NVIDIA renderer, DMA-BUF) went with it. -
EL-10 One window: a second start (with a
blitz://link, on Linux and Windows) goes to the first (requestSingleInstanceLock). -
EL-11 Locked down: the page runs sandboxed, with context isolation and without Node. It sees only the preload’s
window.blitzElectron. The window never leaves the host’s origin: other links open in the system’s browser or mail program if they’re web or mail links (isExternal, as the desktop app’sSafeURL), and nowhere otherwise. New windows are refused. Permissions are granted for notifications, writing to the clipboard and full screen only. -
EL-12 On macOS the page draws the whole window, title bar included (hidden inset), over the desktop showing through (
vibrancy). Its title bar moves the window: the page’s.drag-regions are Electron’s drag regions too (app-region: drag), with what can be clicked in them excepted; and since Electron drags wherever a region is, even under what’s drawn over it, menus, dialogs, the palette, snackbars and the drawer are excepted too (app-region: no-drag). The preload says so (nativeGlass), for the page’s glass (spec_desktop_024 DSK-38). Elsewhere the window is solid. -
EL-13 macOS’s menu bar is the desktop app’s (DSK-101): About Blitz, Settings… (⌘,), Hide, Hide Others, Show All, Quit Blitz (⌘Q), then Edit and Window, in the page’s language (
SetMenu). About and Settings tell the page (menu:about,menu:settings). Elsewhere there’s no menu bar. -
EL-14 Closing the window, or quitting, with unsaved changes in the editor asks first, in the page’s words (
SetUnsaved).
- EL-20
window.blitzElectronhasmethods(the calls below),nativeGlass,call(method, ...args)andon(event, f)formenu:about,menu:settings,deeplink:openandnotification:open. The main process answers only calls from the page’s own origin, and only these:ChooseWorkspace(title): the system’s folder picker (it can make a folder);""when cancelled.Notify(title, body, dir): a system notification; a click brings the window back and sendsnotification:openwithdir.OpenURL(url): web and mail links only, in the system’s browser; anything else is an error.PrintPDF(html, pageSize): the page (whole HTML, pictures inline) printed to PDF in a hidden window with its scripts off, on A4 or Letter paper with 15 mm margins, once it has loaded and its fonts and pictures are ready to draw (at most 10 s more: DSK-93a); the bytes as base64 (DSK-93a). A minute at most.SetAppearance(theme): the window’s theme (system,lightordark).SetMenu(labels),SetUnsaved(u): EL-13 and EL-14.PendingLinks(): theblitz://links that came before the page was ready.ToggleFullscreen().
- EL-21 Everything else the page asks of its host (the service’s status and controls, preferences in
~/.blitz/desktop.json, the CLI, the sandbox, files, licenses) goes to/host/(WIN-14), as on the Windows tray’s page. - EL-22
blitz://open?dir=<folder>&prompt=<text>links (spec_parity_027 PAR-INT-03) reach the page asdeeplink:open, or wait forPendingLinks: the folder must be an absolute path that exists (~is the home folder), the prompt at most 20,000 characters. Since v0.7.1blitz://workers?dir=<folder>(the tray’s Show in Blitz, WK-57) opens the folder on its workers (DeepLink.view; the page’sshowView, which waits for a workspace still opening,takePendingView). The packaged app registers theblitzscheme.
- EL-40 The page’s
desktop.tsroutes each of the app’s calls by capability (canCall): the Electron window (window.blitzElectron), else the host (/host/), else the browser’s stand-in (the Wails path went with the Wails app). The window’s events (onAppMenu,onDeepLink,onNotificationOpen) and its glass (nativeGlass) come from the Electron window, and Export as PDF is offered whereverPrintPDFis. - EL-41 Preferences go wherever
GetPrefsis answered. The first time the host keeps them and the browser’s storage still has some, the browser’s move to the host if the host has no workspaces yet, and the browser’s copy is forgotten either way (adoptBrowserPrefs): the Windows tray’s page, which kept them in the browser, doesn’t lose its workspaces. - EL-42 Known limit. The host serves on a new loopback port each time it starts, so the window’s origin changes with each launch, and what the page keeps in the browser’s storage for itself (where the user guide was left, folded sections) starts afresh each time, as on the Windows tray’s page.
- EL-31 Not yet: Authenticode signing on Windows (SmartScreen asks before the zip’s
Blitz.exe, or the setup program, first runs). Since v0.7.1 there’s a Windows installer (EL-81). - EL-80 Since v0.7.0 releases carry the Electron app, not the Wails app (
apps/electron/packaging; spec_release_025 REL-21–26). Each package’s app comes fromelectron_app(defs.bzl):package.json,dist/,page/,icon.pngandbin/with that platform’sblitz,blitzdandblitz-tray. - EL-81 Since v0.7.1 (the owner’s request). The Windows setup program,
Blitz_<version>_windows_x64_setup.exe(apps/electron/winsetup, pure Go, cross-built;pkg/wininstall): the setup program with the Windows zip at its end, then the zip’s length andBLITZZIP(pack,//apps/electron/packaging:windows_setup). It installs for the person running it, without an administrator (its manifest runs itasInvoker, whichwinexeadds to a program without resources, so Windows doesn’t take it for an installer that needs one): it asks first (a message box;/Sinstalls silently), stops Blitz, its tray and its service if they run from the app’s folder, unpacks the zip beside%LOCALAPPDATA%\Programs\Blitzand puts it in place (an older one moved aside and removed; a failure leaves the older one), makes Blitz shortcuts in the Start menu and on the desktop, writes its entry in Add or remove programs (HKCU\…\Uninstall\Blitz: name, version, publisher, icon, folder, size, the uninstaller and its silent form; no Modify or Repair) and starts Blitz. The zip carries the same program, without a zip, asUninstall Blitz.exe(so an update, which replaces the whole folder, EL-91, keeps it): run from Add or remove programs it copies itself to%TEMP%and runs from there, asks first (/S: not), stops the app, and removes its folder, its shortcuts, its entry, the tray’s start at login (the Run value) and the app’s folder on the user’s PATH (pagehost.RemoveFromUserPath, WIN-14), keeping~/.blitz; the copy deletes itself after. After an update the tray sets the entry’s version to the new tray’s (wininstall.SetDisplayVersion). The release signs it with its own Sigstore bundle, as the zip. The zip stays: updates download it.- macOS:
Blitz_<arm64|x64>.app(mac.bzl), Electron’s bundle copied whole (ditto, the framework’s links kept), named Blitz with the Wails app’s identifierdev.blitz.desktop(so permissions and settings carry over), the release’s version, theblitzURL scheme, the app’s icon, the app inContents/Resources/app, Electron’s default app removed and its licenses inContents/Resources/licenses, signed ad hoc.sign_macos.shsigns it inside out with the hardened runtime andentitlements.plist(JIT, unsigned executable memory, library validation off) and makes the disk image, notarised with the signing secrets. One image per Mac: Electron’s runtimes are separate. - Windows:
Blitz_windows_x64.zip(windows_zip, cross-built): Electron’s files,Blitz.exe(EL-06), the app inresources\appand the licenses inblitz-licenses. - Linux (this machine’s architecture): Electron in
/usr/lib/blitz-desktopwith its program asblitz-desktop(where the Wails app’s was, so the link on PATH and the launcher stay) andchrome-sandboxsetuid root (Electron’s sandbox where user namespaces are restricted, as on Ubuntu 24.04), as a.deb, an Arch package and a tarball (linux_tar, which updates itself; unpacked by a user, itschrome-sandboxisn’t setuid). contents_test.shchecks each package’s programs, licenses and identity.
- macOS:
- EL-90 Since v0.7.0 the app updates itself from the releases (the owner’s choice: Blitz’s own updater, not Electron’s). The host (
pkg/pagehostUpdater, inblitz-tray --serve) answers:CheckUpdate(force): the latest release and where the update is (UpdateStatus: current, latest, available, can and why not, downloading, ready, error, the release’s page). The answer of the last check (~/.blitz/updates/last-check.json) stands for five days (CheckEvery): GitHub is asked again only after that, or when forced (Check now).DownloadUpdate: downloads the release’s package for this machine (selfupdate.DesktopAsset: the Mac’s disk image, the Windows zip, the Linux tarball) into~/.blitz/updates/<tag>, checks it against its own Sigstore bundle (selfupdate.VerifySignature, spec_release_025 REL-13 and REL-24), and unpacks it (a disk image throughhdiutil, mounted where nothing shows it; archives refusing anything outside their folder). The call returns when it’s done: nothing is polled.UpdateStatus: where it is, without asking GitHub.ApplyUpdate: copies the host out of the app (~/.blitz/updates/apply-blitz-tray) and starts it as--apply-updatewith the app’s process (the host’s parent) to wait for; the page then quits the app through the window (Quit, which asks first about unsaved changes).
- EL-91
blitz-tray --apply-update --staged … --root … --relaunch … --wait-pid …(tray.Update): waits for the app to quit (2 min at most, else nothing changes), stops the service if it runs from the app (Windows can’t replace a program in use), swaps the app (selfupdate.Apply: the old one moved aside, the new one moved in, or copied across disks, the old one put back if that fails, then removed), starts the service again, and the app (the new one, or the old one if the swap failed), logging to~/.blitz/logs/update.log. - EL-92 It doesn’t update itself where a package manager installed it (under
/usror/opt: the.deb, the Arch package) or in a development build:canis false andreasonsays so, and the page shows the reason. - EL-93 The page (
updates.ts): when the app opens, unlessupdatesisoffin the preferences, it checks (at most every five days), downloads an update it can take, and when it’s ready says so with Restart (a snackbar); nothing repeats while the app runs. Settings › About shows the update’s state with Check now, Download, Restart to update and the release’s notes, and Update automatically (theupdatespreference).
- EL-100 Since v0.7.0 web pages open in the editor, in tabs of their own (
web:<id>beside the files’ tabs;useEditoropenWeb,WebPreview.tsx), only in the Electron app (canPreview: the window answersPreviewOpen). Each is aWebContentsViewover the tab’s placeholder, in a session of its own (persist:preview, so sign-ins last), sandboxed, with no preload and no Node; downloads and every permission but writing to the clipboard are refused, and a page’s new windows open in the same view. Its toolbar has back, forward, reload (stop while loading), the address, Open in browser and Add to chat; the tab is named after the page’s title (preview:stateevents). Closing the tab closes the page. Tabs aren’t kept when the app restarts. - EL-101 Only web addresses open (
previewURL): http and https; a bare host getshttp://for this machine (localhost:3000,127.0.0.1,[::1],*.localhost) andhttps://elsewhere;file:,data:,javascript:and other schemes are refused, typed or followed from a page. A typed address goes throughPreviewOpen; the toolbar’s buttons throughPreviewNavigate, which takes only back, forward, reload and stop. - EL-101a Since v0.7.1 the address bar searches Google too (
addressURL, only for what’s typed): words with a space, or a bare word that isn’t a host (no dot, no port, not this machine’s, likegolang), openhttps://www.google.com/search?q=<words>; anything else goes throughpreviewURLas above. A typedfile:orjavascript:address is still refused, not searched. Links followed from a page take onlypreviewURL. - EL-102 Opening one: Open web page… in the command palette, the globe button in the top bar (beside Settings, whenever a workspace is open) and in the editor’s tab bar, and links in the chat (or a document) to a web server on this machine, which open in a web tab instead of the browser; other links still open in the browser.
- EL-103 The view is drawn above the page, so the page places it (
PreviewBounds, on resizes and layout changes) and hides it (PreviewVisible) while the tab isn’t shown, its workspace is hidden, or something overlaps it: a menu, a dialog’s scrim, the palette, the drawer, a snackbar, the composer’s menus (covered,overlays). - EL-104 Add to chat: The page and The selection (their HTML, read in a world of its own so the page’s scripts can’t change it,
contentScript, at most 2 MB), A screenshot (PreviewCapture, a PNG added as a pasted image is) and Console errors (the errors the page’s console showed since it opened, the latest 200). Each shows as a chip in the composer, and the turn carries the page, selection and errors asTurn.context(spec_service_021 SVC-13a). - EL-105 In Chromium (Electron)
scrollIntoViewreturns a promise: an effect must not return it (React takes it for a cleanup and the page fails). The palette’s, the search dialog’s and the table view’s effects are written in braces. - EL-106 Save to workspace (the toolbar’s save button;
SaveWebDialog.tsx,webSave.ts,webMarkdown.ts) writes the page into the workspace as Markdown, HTML or PDF, in a folder the user names (one of the workspace’s top-level folders offered; one that isn’t there is made,CreateFolder; outside the workspace is refused) under a name taken from the page’s title.- Markdown (the default: what the agent reads best): the page’s main content (
main, elsearticle, else the body; scripts, styles, forms and navigation left out), its links made absolute, written by the visual editor’s writer (serializeMarkdown, as the editor writes Markdown), with “Saved fromon .” under its first heading (the page’s title when it starts without one). It’s loaded only when used. - Its pictures are saved beside it, in
<name>_files(Save its pictures beside it, on unless the user turns it off): each web picture of the main content once (pagePictures), downloaded by the main process through the preview’s session (PreviewPicture: web addresses only, pictures only, 10 MB each, 30 s), named after its address with the extension its type says (pictureName), at most 50 and 50 MB in all (pictureLimits); the Markdown shows them from there, so the page reads whole without the site, in the editor and for the agent. A picture that can’t be had keeps its web address, and the snackbar says how many. - HTML: the page’s HTML as it is (
PreviewContent, at most 2 MB), with a<base>to the site so its links and pictures still reach it, and a comment saying where it came from. - PDF: the page as it shows, printed by its view (
PreviewPrintPDF, A4 or Letter by the user’s locale). - A file that’s there is replaced only when the user says (Replace); the snackbar opens the saved file. The folder and format are remembered for the next save while the app runs.
- Markdown (the default: what the agent reads best): the page’s main content (
- EL-110 Since v0.7.1 workflows’ human tasks (spec_workflows_043 WF-18) reach the user with no app window open. The tray asks the service for them every 10 s (
ListHumanTasks, timers left out): while some wait its icon has a dot and its menu N workflow tasks waiting, which opens them. A task that arrives (not one already waiting when the tray starts) opensblitz://tasksas[ui] task_alertssays:?notify=1with a title and body in the user’s language (notify, the default), plain (open), or nothing (off). The app takes the link (parseTasksLink): with its window open it shows the tasks dialog (a notify link is left to the page, which announces tasks itself); otherwise it shows a system notification whose click opens the tasks window (or that window at once: asked to, or where there are no notifications), a 440×520 window on the page’s?tasks(the one-time link keepstasksthrough its redirect,pageserver) with only the tasks, Later and Open Blitz. The tasks window may call onlyOpenURL,SetAppearanceandOpenApp. Started only for tasks, the app opens no window of its own, quits when the tasks window closes (it closes itself after the last answer) or ten minutes after a notification nobody clicked, and opens its window when started again (or, on macOS, activated). Failed workflow runs (spec_workflows_043 WF-19) reach the user the same way: on the same check the tray asks for the latest runs (ListWorkflowRuns), and a run that failed since the tray started opensblitz://run?notify=1(unlesstask_alertsis off) with a title and body in the user’s language and the run (dir,workflow,run). The app takes it (parseRunLink): with its window open, nothing (the page announces failures itself); otherwise a system notification whose click opens the app on the run (Studio › Workflows, the workflow’s canvas showing the run), or the app on it at once where there are no notifications; started only for this, it quits ten minutes after a notification nobody clicked.
Superseded by the owner’s decision (2026-10-16, EL-80); kept as the record. The Electron build beside the Wails app on macOS and Linux, and beside the Edge tray on Windows: download and installed size, memory with one workspace open after five minutes (all processes), cold start until the page is ready, macOS glass, the NVIDIA checks (#39, #52), and on Windows the folder picker, Export as PDF, notifications, the CLI and starting at login. Then a recommendation: replace Wails, keep both, or stop.
First readings (macOS, Apple silicon, 2026-10-16, development build): the page loads from the host and reaches the service; PrintPDF returns a PDF; file: links and unknown calls are refused; quitting stops the host; with the page routed (EL-40), the window has its glass, a workspace added through the host’s preferences opens (saved in desktop.json), and Export as PDF writes the PDF beside the file; Electron and the host together hold about 530 MB resident at idle on the welcome screen (resident sizes added up, which counts shared memory more than once: a first number, not the comparison).