Skip to content

feat: desktop-substrate — Electron preload + transport substrate for sense:// (15 commits)#27

Open
garrettmflynn wants to merge 228 commits into
mainfrom
feat/desktop-substrate
Open

feat: desktop-substrate — Electron preload + transport substrate for sense:// (15 commits)#27
garrettmflynn wants to merge 228 commits into
mainfrom
feat/desktop-substrate

Conversation

@garrettmflynn

Copy link
Copy Markdown
Member

Summary

Long-running branch carrying substrate-level changes that landed in the UB SDK desktop app (universalbrain/sdk → branch desktop-sense-transport) over the past two sessions. Each commit is independently scoped + landable; bundling them so reviewers can see the full picture of what apps/desktop now relies on.

What's in here

Custom-preload renderer tolerance (4 commits — 646af06, a924a47, bfc1cc5, bc917cb):

When an Electron app spawns a BrowserWindow with its own preload (bypassing the commoners contextBridge in favor of raw Electron IPC — common for transparent overlay canvases, custom picker dialogs, etc.), commoners' renderer-side scaffolding assumed every renderer would have __commoners global. It didn't, so every plugin's renderer-side `load()` threw `TEMP_COMMONERS.send/on is not a function` immediately on mount. Four small fixes make commoners gracefully no-op in these renderers:

  • `registerPluginAsLoaded` early-returns if `TEMP_COMMONERS.send` isn't callable
  • ctx methods built via a `tempCall(method, fallback)` helper so plugin `load()` bodies that eagerly call `this.on()` (commoners' own BLE + Serial plugins do this) complete with no-ops
  • `createElectronRendererEvents(send, on)` accepts undefined params
  • console proxy from main → renderer flattened to `[main]` line prefix (was `console.groupCollapsed`, hidden by default + collapsed-groups don't survive DevTools "Save as..." log export — engineers couldn't see their main-process diagnostic logs in saved logs)

CSP for Vite module workers (40b443e):

Default CSP had `script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'` but no `worker-src`. Vite's canonical worker pattern (`new Worker(new URL('./w.ts', import.meta.url), { type: 'module' })`) resolves to a `blob:` URL in dev; browsers fall back to `script-src` for worker creation; blob: not allowed → worker throws "Refused to create a worker" + silently never spawns. Added `worker-src 'self' blob:` to defaults so this just works.

wasm-pack external in config bundlers (2 commits — e1a04b7, ca2bee8):

wasm-pack's `--target nodejs` produces CJS using `__dirname` + `require('fs')` for sync WASM load. When commoners' two separate config-bundle paths (loadConfigFromFile's esbuild → mjs, bundleConfig's Vite/Rollup → mjs+cjs) include a wasm-pack output in the bundle, the CJS reference to `__dirname` collapses into "is not defined in ES module scope". Both bundler paths now externalize `-wasm` / `wasm-` globs so Node's runtime resolver picks the right per-target build via the package's `exports` conditions.

Plus 8 prior commits on the same branch (BLE permissions / single-instance opt-out / haptics plugin / postMessage MessagePort routing / alpha.4 version bump / etc.) — all landed via apps/desktop usage at `universalbrain/sdk`'s `desktop-sense-transport` branch.

Where this was needed

The UB SDK's `apps/desktop` (menubar shell with always-on-top transparent overlay) is the first commoners app that:

  • ships a Web Worker for an in-process sense:// broker (needs worker-src CSP)
  • ships a custom preload for the overlay BrowserWindow (needs custom-preload tolerance)
  • transfers MessagePort across worlds (needs the postMessage routing already in `5bb797c`)
  • imports wasm-pack `--target nodejs` output in the main process (needs wasm externals)

Each of these probably comes up for other commoners apps eventually. Landing them as defaults rather than per-app workarounds.

Test plan

  • apps/desktop validated end-to-end: 6 sink ops publishing to broker, broker forwarding to overlay + main subscribers, visible gauge moves, mouse-noise + system-volume side effects fire
  • apps/desktop renderer + main no longer log TEMP_COMMONERS TypeErrors
  • Overlay window console now visible in popover devtools as `[main] [overlay:info] ...`
  • Saved DevTools log exports preserve main-process output verbatim (no more collapsed-group hiding)
  • Other commoners apps regressed by any of these changes (custom-preload renderers will now silently no-op some commoners API calls instead of throwing — that's the intended behavior, but worth confirming nothing depended on the throws as a signal)

🤖 Generated with Claude Code

garrettmflynn and others added 30 commits March 22, 2026 18:22
Percentages are misleading since they depend on the app's own code
size. Show absolute overhead range (20–63 KB) instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Service hot-reload:
- Enable stdin pipe to Electron child process (was 'ignore', now 'pipe')
- Add sendCommand() helper to send JSON commands to running Electron
- Extend Vite handleHotUpdate to detect service file changes and send
  reload commands via stdin
- Add onServiceReload callback to setupStdinCommands in lifecycle module
- Wire up in main.ts: close service, restart, update sanitized URLs,
  notify renderer windows of updated service URLs

Test fix:
- Fix pre-existing failure in plugins.test.ts: plugin src is null in
  renderer context because import.meta.url doesn't resolve after
  bundling into commoners.config.mjs (browser bundle strips src)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use __IS_DESKTOP__ compile-time guard for event system branching:
  web builds exclude Electron events, desktop builds exclude web events
- onload.mjs: 4.6 KB → 3.7 KB for hello world (was 14 KB before
  tree-shaking work)
- Remove redundant rows from why.md comparison table (Auto-bundle,
  Local + remote services, Frontend framework, Overhead)
- Add CLI display overhaul to roadmap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use __IS_DEV__ compile-time guard to exclude WebSocket dev server
setup and plugin hot-reload handler from production builds. Demo app
onload.mjs drops from 12 KB to 8.5 KB in production.

Total onload.mjs reduction across all optimizations:
- Hello world: 14 KB → 3.7 KB (74%)
- Demo (plugins, prod): 13 KB → 8.5 KB (35%)

Also adds CLI display overhaul to roadmap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Update checkAssets to expect .cjs only in Electron builds
- Fix "Source file is resolved" in shared test utils to expect null
- Close stdin pipe in Electron cleanup to prevent process hanging
- Unref stdin in setupStdinCommands to not block process exit
- Remove unused build parameter from checkAssets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Electron .cjs config bundle was not overriding import.meta.url,
causing getDirname() to resolve paths relative to the bundle output
directory (.commoners/.tmp/assets/) instead of the project root. This
broke Python service resolution in desktop dev mode — the source files
couldn't be found.

Fix: add import.meta.url define to the Vite/Rollup config bundling,
matching the existing override in the Node.js config loading step.

Python service echo tests (basic-python, numpy) now pass in desktop
mode. 673/700 tests pass (remaining: tauri-driver missing, demo icon
too small for Electron builder).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Electron builder requires icons to be at least 512x512. The demo icon
was 32x32, causing desktop-build and desktop-zlaunch tests to fail.

All 33 test files now pass (693 tests, 7 skipped).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The process/browser polyfill used in browser config bundles provides an
empty env object, so process.env.VITE_* references were always undefined
at runtime. Now these values are inlined at bundle time via Vite's define
map, fixing support for patterns like `process.env.VITE_BASE_PATH || '/'`
in commoners.config.ts.

Adds vite-integration.test.ts covering process.env in config bundling,
vite.base path forwarding, and VITE_* env var inlining.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Guard servicesModule usage behind a null check so apps without services
still create the main window. Also externalize @commoners/solidarity
and commoners from config esbuild to prevent bundling the entire
framework when users import defineConfig.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Externalize @commoners/solidarity from config esbuild to prevent
  bundling the entire framework when users import defineConfig
- Support object-form CSP overrides (Record<string, string[]>) that
  merge per-directive into the default policy
- Format esbuild errors with color-coded output instead of raw dumps
- Validate project has a config file or index.html before launching
- Validate config defines at least one page or service

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous integrity check hashed each executable service binary at build
time and compared bytes at spawn. This couldn't survive code signing —
signtool/codesign mutate the bytes after the hash was computed, so every
legitimate signed binary failed verification and the service was silently
rejected. Apps that signed their builds saw services fail to spawn with no
visible error (only "Starting <service>..." then nothing), because the hash
mismatch was emitted only as a hook event.

Replaces the byte-hash design with OS-native code-signature verification:
PowerShell Get-AuthenticodeSignature on Windows, codesign --verify on macOS,
skipped on platforms without native code signing (Linux). The signing
identity must contain an expected publisher substring declared in
electron.security.expectedPublisher; that field lives in service-trust.json
sealed inside app.asar via ASAR integrity, so an attacker cannot redirect
the trust without invalidating the asar.

- assets/utils/sign-verify.ts — new platform-aware verifier
- ElectronBuildStrategy.generateServiceTrustManifest replaces
  generateServiceHashManifest; emits service-trust.json keyed by service id
- assets/electron/main.ts reads service-trust.json instead of
  service-hashes.json and threads it through start() as serviceTrust
- assets/services/index.ts replaces hashManifest comparison with
  verifySignature(); failures still emit security:service:integrity:fail
  with reason, plus a console.error so misconfigurations surface in logs
- New event security:service:integrity:skipped for unsupported platforms
- ElectronSecuritySettings.expectedPublisher added to types
- Drive-by: rename two unused 'target' params to '_target' to satisfy
  the no-unused-vars rule (pre-existing lint errors in the same file)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CONTRIBUTING.md only covered the contributor flow (add a changeset to
your PR). The maintainer flow — taking accumulated changesets (or a
manual version bump) on dev and turning it into a published npm
artifact — was implicit. That's a problem when a downstream consumer
cites a specific commoners alpha version and expects the release
procedure that produced it to be retraceable.

docs/RELEASE.md covers:
- When to cut a pre-1.0 alpha vs (eventual) post-1.0 stable release
- The linked-package versioning model from .changeset/config.json
  (all linked packages share one version)
- Path A: changeset-driven release (pnpm changeset version + pnpm release)
- Path B: manual bump path that's been used in practice when no
  changesets accumulated, with the caveat that the changelog must be
  filled in by hand
- Verification checklist (npm view, git tag, dist.tarball reachable,
  CHANGELOG entries) before announcing

CONTRIBUTING.md gets a "Releases" section pointing at the new doc so
maintainers can find it without grepping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The renderer-side onload populated `loaded[id] = load.call(...)` and then
`await loaded[id]` on the next line. The await ensures load() completed but
does not mutate the slot — so `loaded[id]` (and therefore `ENV.PLUGINS.<id>`,
which consumers receive from `await commoners.READY`) was the *Promise* itself
rather than the resolved manager/handle.

Most consumers happen to await the call chain (`await commoners.READY.then(p
=> p.foo.bar())`) and the Promise unwraps transparently. But any consumer
that destructures and reads sub-properties synchronously hits `undefined`:

    const PLUGINS = await commoners.READY
    const { windows } = PLUGINS              // Promise
    windows.popup.create()                   // TypeError: undefined.create

This was masked by a "race condition" escape hatch in the windows-plugin
test that returned early when `'popup' in plugins.windows` was false (which
is always false for a Promise). Removed that escape hatch and added a
generic regression test that asserts no `loaded[id]` value has a `.then`
method, so any future async-load plugin trips the same trap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`loadPage` resolved the URL via `getPageLocation` (which uses fileURLToPath
and existsSync) and then called `loadURL(pathToFileURL(loc).href)`. The
query string and fragment from the originating navigation URL were dropped
on the floor — `window.location.href = "file:///.../profile.html?id=xyz"`
in the renderer landed at `file:///.../profile.html` with no search.

Fixed in two places (mirror of each other):

  - The `will-navigate` handler at line 357 now passes `urlObj.search` and
    `urlObj.hash` through to `loadPage`, which appends them to the URL it
    hands `loadURL`.
  - The custom-protocol handler at line 739 already captured search/hash
    into `__location` per-window state but didn't pass them to `loadPage`
    either; now does.

Affects in-window page-to-page navigation in dev mode (file:// origin) and
production mode (custom protocol). Without this fix, any
`navigate("page", { search })` call silently strips the query, and the
destination page sees an empty `window.location.search`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a downstream consumer builds with SKIP_SIGNING=1 to produce an
unsigned dev-test artifact, also skip writing service-trust.json. The
runtime signature-verification block in assets/services/index.ts only
fires when opts.serviceTrust[id] is present, so omitting the manifest
is enough to bypass it cleanly. Without this, an unsigned bundled
service would fail spawn-time verification (\`actual: '(no signer)'\`)
and the app couldn't launch at all in unsigned mode.

Counterpart change lives in the consumer's afterSign hook (the place
that decides whether to actually invoke signtool). Together they let
downstreams trade signature integrity for build speed during local
iteration. Production builds always sign — never set SKIP_SIGNING.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Includes the five commits since alpha.3: service trust manifest skip
under SKIP_SIGNING, navigation search/hash preservation, plugin loader
Promise unwrap, release-process docs, and Authenticode-based service
binary verification.

Widens the @commoners/solidarity peer constraint in serial and windows
to accept alpha.4 alongside alpha.3 + future 1.x finals.

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

Single-instance was previously hardcoded — every consumer app blocked
concurrent instances regardless of preference. Add `electron.singleInstance`
(default true, preserves existing behavior). Setting it to false skips
makeSingleInstance entirely. Useful in dev when an old Electron process
hasn't released the OS lock, and for apps that genuinely want concurrent
instances.

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

The renderer's reconnect path relies on `navigator.bluetooth.getDevices()`
returning the list of previously-selected devices. In Electron that list
is gated by `session.setDevicePermissionHandler` — without one, the call
returns [] regardless of prior `requestDevice()` grants. Mirroring the
serial plugin's approach (which has had `setDevicePermissionHandler(()
=> true)` from the start), the BLE plugin now grants Bluetooth-typed
device permissions session-wide.

Effect: pair a Muse once via the picker; on the next connect the
renderer's `getDevices()` returns it directly and `gatt.connect()`
re-opens the link without showing the picker again.

Scope is in-session only — cross-restart persistence requires a userData
JSON store and is left for a follow-up. Matches serial's current
posture exactly.

Drive-by: tighten two pre-existing `Function` type usages flagged by
the pre-commit ESLint hook on this file. Same behavior, narrower types.
The previous commit added both `setPermissionCheckHandler` and
`setDevicePermissionHandler`. The former covers many permission
types (clipboard, media, notifications) and returning false for
non-bluetooth ones causes Chromium to terminate the renderer with
`bad IPC message, reason 105` when the renderer requests anything
besides bluetooth.

setDevicePermissionHandler alone is sufficient for the BLE reconnect
path — it's what `navigator.bluetooth.getDevices()` consults to
decide which devices to surface. Leaving the broader permission
machinery to Electron's default keeps clipboard/media/etc. working.
Adds `postMessage(channel, message, transferList)` to the commoners
scoped-IPC API alongside the existing `send/invoke/on/once`. Plugins
needed to expose `ipcRenderer.postMessage` to renderer code in order
to transfer transferable objects (MessagePort, ArrayBuffer) to the
main process; the existing `send` accepts only structured-cloneable
args.

Two-line addition:
- `packages/core/assets/electron/preload.ts` — exposes
  `TEMP_COMMONERS.postMessage` wrapping `ipcRenderer.postMessage`,
  guarded by the same `isAllowedChannel` check the other IPC
  wrappers use.
- `packages/core/assets/onload.ts` — adds `postMessage` to the
  per-plugin scoped renderer ctx alongside `send`, scoping the
  channel as `plugins:<id>:<channel>`.

No main-side changes needed: `scopedOn` in
`packages/core/assets/electron/modules/ipc.ts` already forwards
`IpcMainEvent` untouched, so handlers read `event.ports[]` when
expecting transferables.

Motivating use case: sense:// broker subscriber ports between
BrowserWindows in apps/desktop. The popover renderer creates a
MessageChannel, hands port2 to a DedicatedWorker-hosted broker, and
transfers port1 to the main process via
`commoners.<plugin>.postMessage('sense-port-to-overlay', null, [port1])`.
Main re-transfers to the overlay window. Generic addition — any
commoners plugin can now transfer MessagePorts.

See `roadmap/design/desktop-sense-transport.md` in the
universalbrain/sdk repo for the consumer pattern.
New `@commoners/haptics` plugin exposes a uniform haptic API across
the three target runtimes:

- Mobile (Capacitor): native intensity via @capacitor/haptics —
  light/medium/heavy impact, vibrate, taptic-engine selection feedback
- Web: navigator.vibrate fallback for vibrate(); intensity-aware
  impact() is unsupported (returns no-op)
- Desktop: not supported (no haptic hardware on the platform)

API: `impact(style)`, `vibrate(duration)`, `selectionStart/Changed/End`.
Same shape as the other commoners device-style plugins (one renderer
backend per runtime, isSupported gating, capacitor configuration).
Pivots away from the WebBluetoothNewPermissionsBackend approach
(earlier on this branch in `4cd3756` + `e3ceb41`). The picker-skip /
silent-reconnect UX the switch was enabling isn't load-bearing for
the consumer apps that drove the original need; the runtime-
permission + permission-handler stack already covers the operator-
visible cases.

Removes:
- `commandLineSwitches: { 'enable-features': 'WebBluetoothNewPermissionsBackend' }`
  export from packages/plugins/devices/ble/index.ts
- The plugin-iteration loop in packages/core/assets/electron/main.ts
  that applied those switches pre-app.whenReady()

The generic `commandLineSwitches` plugin contract goes with this
revert — no plugin in the tree was using it for anything besides
BLE. If a future plugin needs Chromium feature flags, we'll re-
introduce the contract under its own design discussion.

Also widens the WIN_STATES.select + callbacks Record signatures to
match the post-revert runtime shape (eslint-clean `(...args: unknown[]) => unknown`
in place of the original narrow MACAddress callback).
The TEMP_COMMONERS.postMessage exposed via contextBridge cannot
transfer MessagePort across the main-world ↔ isolated-world boundary
— Electron's IPC raises "Invalid value for transfer" because
contextBridge's V8 serialization doesn't preserve MessagePort
identity. Documented Electron limitation; see
https://www.electronjs.org/docs/latest/tutorial/message-ports.

Surfaced during runtime testing of the sdk's
desktop-sense-transport branch: 6 sink ops fire correctly, broker
subscriber registers correctly, but the `commoners.overlayCanvas
.postSubscriberPort(port)` call throws "Invalid value for transfer"
on the renderer→main IPC hop.

Fix: route port transfers through window.postMessage (which DOES
support MessagePort across the world boundary), then a new preload
listener catches the message + forwards via ipcRenderer.postMessage
in the isolated world where it works cleanly.

Two edits:
- `preload.ts` adds a `window.addEventListener('message', ...)` that
  watches for `__commoners_port_transfer` envelopes + their ports,
  validates the channel name against the IPC allowlist, then
  forwards via `ipcRenderer.postMessage`.
- `onload.ts` per-plugin scoped `postMessage` detects MessagePort
  in the transfer list + dispatches via `window.postMessage` instead
  of TEMP_COMMONERS.postMessage. Non-port transfers (ArrayBuffer-
  only) take the direct path since ArrayBuffer survives contextBridge.

Generic addition — works for any commoners plugin shipping
MessagePort across renderers.
esbuild bundles the user's commoners.config.ts into a `.mjs` ESM
snapshot. If that config statically imports a wasm-pack `--target
nodejs` output (CJS using `__dirname` + `require('fs')`), esbuild
wraps the CJS module but the `__dirname` reference has no value in
ESM scope → "__dirname is not defined in ES module scope" with no
stack at launch.

Wasm-pack Node outputs are not structurally bundlable into ESM
without rewriting their sync-load path. The cleanest fix is to mark
them external so Node's real resolver loads them at runtime, where
their nested package.json (`"type": "commonjs"` if the consumer set
it) is honored.

Widens the config-bundle externals from a hardcoded list to also
match `*-wasm` and `wasm-*` globs. esbuild's `external` field
supports prefix/suffix wildcards but not regex — `*-wasm` matches
`ub-sense-wasm`, `muse-wasm`, etc.; `wasm-*` mirrors for the other
naming convention.

Caller pattern (verified working in apps/desktop):
  package.json:
    "type": "module",
    "exports": {
      ".": {
        "node": "./node/ub_sense_wasm.js",
        "browser": "./web/ub_sense_wasm.js",
        "default": "./web/ub_sense_wasm.js"
      }
    }
  node/package.json:
    { "type": "commonjs" }
When a window ships its own preload (e.g. an ambient-feedback overlay
using raw Electron IPC for `senseBridge` / `overlayBridge` rather
than the commoners contextBridge), `globalThis.__commoners` is
undefined → `TEMP_COMMONERS = {}` → `.send` is missing.

`registerPluginAsLoaded` then threw at every plugin, surfaced as
`[commoners] <plugin> (load) failed to execute: TypeError:
TEMP_COMMONERS.send is not a function` for the full plugin list in
the overlay window's devtools.

Custom-preload renderers DON'T want to participate in the commoners
IPC graph by design — that's the whole point of shipping a custom
preload. Skip the notify rather than throwing. The plugin's own
`load()` closure still runs (returns the consumer-facing API);
consumers that DON'T touch the missing `.send`/`.on` keep working.
Consumers that DO touch them still get a clear "X is not a function"
at the call site, just not at plugin-load time.
Follow-up to the registerPluginAsLoaded fix: every plugin's
renderer-side ctx (`this.send`, `this.on`, `this.invoke`, etc.)
was hard-wired to `TEMP_COMMONERS.X(...)`. In a custom-preload
renderer (e.g. the desktop app's transparent overlay using
`window.senseBridge` / `window.overlayBridge` via raw Electron
IPC), `TEMP_COMMONERS = {}` and these calls threw at every
plugin that eagerly registered listeners in its renderer-side
`load()` body — the commoners BLE + serial plugins both do this,
so the overlay devtools spammed:

  [commoners] bluetooth plugin (load) failed to execute:
    TypeError: TEMP_COMMONERS.on is not a function
  [commoners] serial plugin (load) failed to execute:
    TypeError: TEMP_COMMONERS.on is not a function

Replace direct `TEMP_COMMONERS.X(...)` with a `tempCall(method,
fallback)` helper. If the underlying TEMP_COMMONERS method is a
function, use it; otherwise return the fallback (mostly no-op).
The plugin's renderer-side `load()` body completes; any consumer
that actually calls `commoners.<plugin>.X(...)` and depends on
the IPC will see the no-op behavior (e.g. an `.invoke()` resolves
undefined) — they can still detect missing commoners IPC at the
call site.

Combined with the prior `registerPluginAsLoaded` guard, the overlay
canvas window now boots clean: no plugin-load TypeError, sense://
subscriber-port preload bridge active and awaiting frames.
Last residual from the custom-preload-renderer fix series. The
events bootstrap at `onload.ts:32-34` immediately calls
`createElectronRendererEvents(TEMP_COMMONERS.send, TEMP_COMMONERS.on)`
which (line 17) registered a listener on `on(EVENTS_RECEIVE_CHANNEL,
...)` — undefined call → `Uncaught (in promise) TypeError: on is
not a function` in the overlay devtools.

Make the parameters optional. If `on` is missing, skip the
incoming-message subscription (cross-window events from other
renderers won't reach this one — fine, the overlay doesn't
participate in commoners cross-window events anyway). If `send` is
missing, `emit()` only fires same-window listeners (still useful
for in-window pub/sub). Same-window `on/off/once` continue to
work via the local `listeners` Map regardless.

Combined with `646af06` (registerPluginAsLoaded guard) and
`a924a47` (tempCall-wrapped ctx methods), the desktop overlay
window now boots completely clean — zero TypeError, sense://
subscriber port ready, awaiting frames.
Previously each main-process console.{log,warn,error} call was
wrapped in `console.groupCollapsed('Commoners Electron Process')`
in the renderer devtools. Hidden by default; surface only when the
operator clicks the group header.

This collides with debugging via exported logs: DevTools' "Save
as..." serializes collapsed groups to just the header line and
drops everything inside. Recurring failure mode — engineers add
`console.log` to main code for diagnostics, ship a log export to
investigate a bug, and find their diagnostic prints missing.

Replace with a `[main]` prefix. Same visual signal in devtools (one
short token tells you it's from main, not the renderer), but the
output is flat and survives export verbatim. Reviewers can grep
the exported file directly.
Without explicit `worker-src`, browsers fall back to `script-src`
for worker creation. The default CSP's script-src is
`'self' 'unsafe-inline' 'wasm-unsafe-eval'` — no `blob:`. But Vite
in dev mode resolves the canonical `new Worker(new URL('./w.ts',
import.meta.url), { type: 'module' })` pattern to a `blob:` URL
(it wraps the module body as a blob to serve as a worker source).

Result: `new Worker(...)` throws "Refused to create a worker from
blob:..." in dev and the worker never starts. Reported via a Vite
HMR-channel CSP violation log:

  Creating a worker from 'blob:http://localhost:5173/<uuid>' violates
  the following Content Security Policy directive:
  "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'". Note that
  'worker-src' was not explicitly set, so 'script-src' is used as a
  fallback. The action has been blocked.

Add `worker-src 'self' blob:` to the default CSP. 'self' covers
production bundles where the worker file is served from the same
origin. blob: is what dev mode needs. This unblocks any commoners
app using the standard module-worker pattern (which is broadly the
recommended Vite-supported way to ship workers).

Found while debugging desktop-sense-transport: the sense:// broker
worker silently never spawned in the popover, so the entire output
path stayed dark with no usable diagnostic. The broker-host code
DID call `new Worker(...)` — the CSP killed it before our own logs
could fire.
Companion to e1a04b7 (config-bundle esbuild external). That commit
covered loadConfigFromFile's `.mjs` snapshot. But commoners has a
SECOND config-bundle path — `bundleConfig` in utils/assets.ts uses
Vite/Rollup and emits both `.mjs` (renderer) AND `.cjs` (Electron
main). The `.cjs` is what `assets/electron/modules/config.ts` loads
in main process and the Vite/Rollup external list didn't have the
wasm-pack carve-out.

Result: `ub-sense-wasm`'s web-target init (which calls `fetch()`
for the .wasm bytes) gets bundled into `commoners.config.cjs`.
At runtime in Electron main, the bundled code calls fetch on a
data/blob URL → Node's undici throws "not implemented... yet..."
→ `main-subscriber attach failed: TypeError: fetch failed`.

This was caught while debugging desktop-sense-transport: the
main-process sense:// subscriber couldn't initialize the codec
because the wrong wasm-pack target was bundled. The overlay
subscriber (running in a renderer) was fine because the web
target works there.

Switch the Rollup `external` from an array to a function so we can
match suffix/prefix patterns. Keep the existing nodeExternals list
intact (just lift into the same check).
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