Migrate desktop shell from Tauri to Electron (#964)

* feat(electron): scaffold Electron desktop package

Main + preload + ssh manager, packaging scripts, icons, root build/lint/type-check wiring.

* feat(ui): add Electron runtime detection and desktopNative facade

isElectronShell via window.__OPENCHAMBER_ELECTRON__, isDesktopShell now covers both. desktopNative wraps window/title/theme calls so UI avoids direct Tauri imports. revealDesktopPath added.

* refactor(ui): route window/title/theme/export through desktopNative

SessionSidebar, MultiRunLauncher, useWindowTitle, ThemeSystemContext, exportSession drop direct @tauri-apps imports.

* refactor(ui): treat all desktop shells uniformly

device.ts switches Tauri-only checks to isDesktopShell. Header OpenInApp button uses actionDirectory so it falls back to the active project path.

* fix(ui): menu Copy clipboard fallback and softer sidebar tint

useMenuActions falls back to Clipboard API for the native Copy action when the page doesn't intercept. cssGenerator lowers sidebar strong/soft alpha so the tinted surface reads gentler.

* chore(electron): mirror Tauri build/type-check script shape

build script becomes no-op so root 'bun run build' skips packaging. Syntax validation (node --check) moves into type-check. electron:build root script still runs full sidecar+bundle+electron-builder.

* fix(electron): sync app identity, preload path, boot outcome, dev entry

Read version from packages/electron/package.json so 'electron ./main.mjs' dev entry reports the app version instead of Electron's. Bump electron package to 1.9.6 for workspace parity.
Resolve preload via app.getAppPath() in prod (bundle lives in dist-bundle while preload.mjs ships at app root).
Compute and inject __OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ in main + preload so the loading gate dismisses (mirrors Tauri Rust injection).
Dev entry uses ./main.mjs to bypass the stale dist-bundle so source edits apply.

* refactor(open-in-app): split directory and file flows

Header button now opens the project/worktree directory only — drop activeFilePath prop and its Header prop passthrough. FilesView editor dropdown opens the active file only via new openDesktopFileInApp.

Electron main.mjs mirrors Tauri's open-chain logic: buildOpenProjectSpecs (finder/terminal direct, vscode-like via CLI -n, JetBrains via open -na --args) and buildOpenFileSpecs (finder -R reveal, terminal opens parent dir, editors via CLI or open -a). runSpecChain falls through specs until one exits 0.

* fix(files-view): keep floating toolbar mounted while its dropdowns are open

Portalled Base UI menu popups render outside floatingToolbarRef. The document mousedown listener and onMouseLeave collapsed the toolbar as soon as the popup appeared, unmounting the DropdownMenu root and swallowing clicks on its items. Track open dropdowns via onOpenChange and skip the collapse while count > 0; also ignore mousedowns that land inside a dropdown-menu-content/item.

* feat(electron): add quit confirmation with risk poller

Mirrors Tauri's macOS-only behavior: poll /api/openchamber/scheduled-tasks/status and /api/openchamber/tunnel/status every 5s. If active tunnel or running/enabled scheduled tasks are detected, Cmd+Q / dock Quit / menu Quit shows a native warning dialog listing reasons; otherwise quit proceeds silently.

performConfirmedQuit persists window state, kills sidecar, shuts down SSH, and fires a 1500ms unref'd safety timeout that calls app.exit(0) if the normal quit sequence stalls.

* feat(notifications): fix payload parsing, restore-on-click, session deep-link

Normalize input so both sidecar stdout path (flat) and UI IPC path ({ payload: {...} }) work; previous destructuring missed requireHidden (camelCase) and the payload wrapper so notifications showed with empty body.

Click handler restores the window if minimized, shows it if hidden, and focuses. When the notification payload carries sessionId, emit openchamber:open-session which the App listener routes to setCurrentSession — matches the PWA service-worker deep-link behavior. macOS notifications now also use sound 'Glass' for parity with Tauri.

* chore(electron): bump to Electron 41 + latest updater/context-menu

electron ^38.2.0 -> ^41.2.1
electron-updater ^6.6.2 -> ^6.8.3
electron-context-menu ^4.0.4 -> ^4.1.2

Dev boot verified: main process starts, preload exposes globals, API server + quit risk poller + autoUpdater all initialize without errors.

* fix: keep todo row alignment stable when expanding text

Keep checkbox and action buttons vertically centered in collapsed todo rows
Prevent first todo line from shifting when expanding to multiple lines

* fix: make commit highlights visible and input behavior reliable

Switch commit message field to native textarea for predictable auto-resize
Fix AI highlights append flow so inserted text is applied consistently
Make chat scroll-to-bottom control fully circular

* style: increase chat bubble corner radius consistency

Use larger radius for user chat message bubbles
Match chat input container radius to user message styling

* feat(electron): adopt OpenCode playbook improvements

mac: hardenedRuntime + entitlements.mac.plist + notarize + dmg.sign for Apple notarization parity.
single-instance lock + openchamber:// protocol with session/project/host routing (host switch done fully in main via activateMainWindow).
setAppUserModelId for Win toast identity; proxy-bypass-list switch; chdir(homedir) for Finder-launch cwd safety.
shell env probe (\$SHELL -il -> -l) merged into sidecar spawn; PATH deduped.
electron-log with 5MB rotation + 7-day cleanup; autoUpdater.logger wired; startup info log.
webContents zoom locked to 1 (zoom-changed + did-finish-load).
UI: openchamber:open-project -> useDirectoryStore.setDirectory.

* fix(electron): make bootOutcome mutable across re-navigation + project deep-link

host deep-link used to land on chooser because contextBridge exposed bootOutcome as read-only; initScript re-assignment became a silent no-op. drop preload's contextBridge for bootOutcome, inject it via main-world initScript, and move injection from did-finish-load to dom-ready so it lands before React mounts.

project deep-link updated currentDirectory only; activeProjectId stayed stale so the sidebar didn't highlight the new project. switch to projectsStore.setActiveProject (or addProject for new paths) which updates both.

add log.info around deep-link dispatch + host switch for diagnostics.

* fix(electron): desktop_hosts_set IPC args + persist initialHostChoiceCompleted + re-eval bootOutcome

UI calls invoke('desktop_hosts_set', { input: {...} }) but main was reading args.config — every onboarding 'i've completed installation' / host-dialog save wrote nothing, so desktopDefaultHostId stayed null and the chooser screen looped forever.

also:
- writeDesktopHostsConfig now persists desktopInitialHostChoiceCompleted so the tauri-compat flag survives writes.
- readDesktopHostsConfig returns initialHostChoiceCompleted so the UI-side config mirror is complete.
- after writing hosts, recompute state.bootOutcome + state.initScript; a subsequent window.location.reload() picks up target=local/status=ok via dom-ready injection without needing a full app restart.
- app.setName('OpenChamber') early (pre log.initialize) so electron-log logs land in ~/Library/Logs/OpenChamber/ instead of the package-derived '@openchamber/electron' path.

* chore(electron): rename appId to dev.openchamber.desktop

ai.opencode.* is the OpenCode team's reverse-DNS namespace; OpenChamber should not squat there. now that we're on Electron, drop the tauri-era inherited identifier and claim our own under openchamber.dev.

user-facing productName stays "OpenChamber". tauri identifier left as-is — legacy shell on the way out.

* feat(ci): add electron build+notarize+publish jobs to release workflow

three new jobs in release.yml, running in parallel with tauri:

- build-desktop-electron-macos: matrix(arm64, x86_64) on macos-26; installs Developer ID via keychain, runs build:sidecar + bundle:main + electron-builder --mac --arch <> --publish=never (with APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID env mapped from existing secrets). verifies hardened runtime, stapled notary ticket, required entitlements. uploads DMG/ZIP/blockmaps to the release and emits per-arch latest-mac.yml as a GH artifact.

- combine-electron-manifests: downloads latest-yml-*-apple-darwin artifacts, runs the existing finalize-latest-yml.mjs to merge per-arch files entries into a single latest-mac.yml, uploads combined yml to the release.

- finalize-release: now also waits on the two new jobs before flipping the draft release to published.

also: explicit artifactName in electron-builder config so arm64 and x64 dmg/zip never collide.

electron-updater in main.mjs (setFeedURL btriapitsyn/openchamber) fetches this latest-mac.yml on desktop_check_for_updates; downloadUpdate / quitAndInstall wire through our existing IPC handlers unchanged.

* docs: future-agent brief for tauri -> electron auto-update cutover

self-contained plan for the one-shot migration release that carries existing tauri installs into the electron shell via tauri's updater. written so a fresh agent with no branch context can execute it.

covers: the trick (repackage signed electron .app as a tauri tarball, minisign with existing TAURI_SIGNING_PRIVATE_KEY), workflow surgery on release.yml, rollback plan, validation steps against a real tauri install, and edge cases (CFBundleIdentifier change, notification perms re-prompt, deep-link re-registration).

* docs: soften framing of cutover playbook (no user-shaming)

* chore: mark electron as primary desktop shell; tune dmg installer window

AGENTS.md: explicit note that new desktop work lands in packages/electron/, packages/desktop/ (tauri) is maintenance-only until the cutover described in docs/TAURI_TO_ELECTRON_CUTOVER.md. updated runtime/entry-points/build-commands sections accordingly.

electron/package.json build.dmg: cleaner title ("OpenChamber 1.9.6" without -arch suffix), 660x400 window matching the tauri layout users are used to, icon size 128, explicit app/Applications positions.

* refactor(web): drop bun-specific runtime deps from server

- 11 test files migrated bun:test -> vitest; API (describe/it/expect) is drop-in; all 73 tests pass under vitest run.
- bun:sqlite -> better-sqlite3 in git/service.js::syncSandboxesToOpenCodeDb. api shift is db.query().get()/run() -> db.prepare().get()/run().
- add "test": "vitest run" script in packages/web.

no production code used Bun.* APIs; server is Express-on-Node already. this commit removes the remaining bun-runtime shape so the server module can be imported and booted inside an electron main process.

* feat(electron): boot web server in-process, drop sidecar subprocess

the electron main process now imports @openchamber/web/server/index.js as a workspace dependency and calls startWebUiServer({...}) directly. the returned handle exposes getPort() / stop() and the notification emitter takes an onDesktopNotification callback, so we no longer spawn a bun-compiled sidecar binary and no longer parse stdout for the one-line notify protocol.

- packages/electron/package.json: +@openchamber/web (workspace:*); extraResources drops 'sidecar'; build:sidecar script renamed to build:web-assets (kept the vite build step, dropped the bun compile step).
- packages/electron/main.mjs: remove spawn/kill-stale-sidecar/sidecar path resolver/stdout-prefix parser; rewrite spawnLocalServer to probe a free port (stored | DEFAULT_DESKTOP_PORT | OS-assigned) then import server and await startWebUiServer; killSidecar calls handle.stop({ exitProcess: false }); hoist user shell env (PATH, etc.) onto process.env once so opencode / git / rg children still inherit the expected runtime environment.
- packages/web/server/lib/notifications/emitter-runtime.js: accept an onDesktopNotification callback (late-bindable via setOnDesktopNotification). when set, notifications are dispatched through the callback instead of process.stdout; tauri path still uses stdout when no callback is bound.
- packages/web/server/index.js: main() wires options.onDesktopNotification to notificationEmitterRuntime.setOnDesktopNotification.
- release.yml + AGENTS.md updated for the new script name + runtime shape.

payoff: -300ms cold start on mac, single process in activity monitor, no stdio IPC, no bun binary in the packaged app. tauri sidecar path is untouched.

* build(electron): rebuild native deps explicitly, bump electron-builder

the previous build failed because electron-builder 24.13.3 tried to run \`bun rebuild\` on native deps (better-sqlite3, node-pty) and bun has no rebuild subcommand; it also couldn't find prebuild-install because bun hoists under node_modules/.bun/<pkg>@<ver>/ and never populates node_modules/.bin for transitive deps.

fix:
- bump electron-builder devDep to ^26, whose packageManager detection understands bun workspace layouts.
- add @electron/rebuild devDep + scripts/rebuild-native.mjs. the script rebuilds better-sqlite3 / node-pty / bun-pty against the installed electron version before electron-builder is invoked.
- set build.npmRebuild=false so electron-builder no longer attempts its own broken PM-based rebuild.
- package script: build:web-assets -> bundle:main -> rebuild:native -> electron-builder.

verified: CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron:build produces signed-ad-hoc dmg/zip/blockmap/latest-mac.yml; artifacts land under packages/electron/dist as expected. cold-start from Applications should work (native bindings now match electron 41 node ABI).

* fix(electron): externalize web server + native deps from main bundle

the ESM bundle was statically inlining @openchamber/web transitively, which pulled in bun-pty/src/terminal.ts with its top-level \`import { dlopen } from "bun:ffi"\`. node's ESM loader parses every static import when the bundle loads, so the bun:ffi scheme crashed the packaged app at startup with ERR_UNSUPPORTED_ESM_URL_SCHEME — the runtime guard (if (globalThis.Bun) { await import('bun-pty') }) never got a chance to skip it.

fix: bundle-main.mjs marks @openchamber/web (+ its bun-pty / node-pty / better-sqlite3 transitives) as external. the dynamic \`await import('@openchamber/web/server/index.js')\` in main.mjs stays a runtime resolution; the conditional bun-pty import stays dynamic; native modules load from node_modules via the standard resolver.

* perf(web): classify UI-only deps as devDependencies, shrink packaged app

packages/web is a hybrid package: server code in server/, react UI source in src/, compiled UI output in dist/. the server serves dist/ as static files — it never imports react/radix/codemirror/etc. at runtime. but electron-builder, npm install, and similar tools treat everything under "dependencies" as shipping surface, so all of react + @radix-ui/* + @codemirror/* + @fontsource/* + @simplewebauthn/browser + cmdk + ghostty-web + ... were landing in app.asar even though the same code is already baked into dist/ chunks.

move ~24 UI-only packages to devDependencies. vite + its plugins still install them in dev (bun install fetches devDependencies in workspaces), so \`bun run build\` is unchanged. consumers doing \`npm install @openchamber/web\` no longer pull ~150MB of unused browser-side modules.

measured on aarch64 darwin build:
- app.asar: 281MB -> 44MB (-237MB, -84%)
- .dmg: 320MB -> 132MB (-59%)
- .zip: 305MB -> 129MB (-58%)

verified type-check, ui build, 73 vitest tests, packaged launch.

* chore(electron): center dmg installer icons, use cream brand background

dmg-builder 26 ignored our previous dmg.contents positions against its template background (they stayed at template coords, producing misalignment with the drawn arrow). switch to a solid backgroundColor (#FFFCF0, the splash light tone) so the template image is dropped entirely and our coordinates are authoritative. window tuned to 540x340, iconSize 100, iconTextSize 13.

dmgbuild treats contents coordinates as icon *centers* (not top-left), so with iconSize=100 in a 540 window, x=180 and x=360 place left and right clusters with equal 130px gaps on both sides of the window. y=140 vertically centres the icon+label pair.

* fix(electron): eliminate main-thread freezes in in-process server

Three blocking paths were running sync work on the Electron main event
loop, causing multi-second UI freezes under the new in-process server:

- package-manager.detectPackageManagerDetails fired spawnSync(pnpm/npm/
  yarn/bun bin -g) with 10s timeouts. In desktop runtime PM detection is
  pointless (app is .app bundle, updates via electron-updater) — short-
  circuit when OPENCHAMBER_RUNTIME=desktop. This was the ~5s freeze.
- buildInstalledApps iterated 22 OPEN_IN_APPS × spawnSync(mdfind, sips).
  Converted to execFile promises so child waits yield to the loop.
- orphan-project-file recovery re-scanned disk on every settings read
  (3+/s from fs/list/etc). Cache the outcome per process lifetime.

Also: resolveProjectDirectory prefers settings.lastDirectory over
activeProjectId so file-open from sidebar/chat doesn't 400 with
"Path is outside of active workspace" after the user navigates.

Plus dropdown typeahead fixes in DesktopHostSwitcher/BranchSelector:
stopPropagation on input keys so cmdk doesn't swallow typing.

* feat(electron): restore desktop LAN access for in-process server

spawnLocalServer now reads settings.desktopLanAccessEnabled and binds
on 0.0.0.0 when enabled, so phones/tablets on the same Wi-Fi can open
the app via http://<lan-ip>:<port>. Adds desktop_get_lan_address IPC
(UDP-connect route lookup with networkInterfaces fallback) for the
settings UI to show the reachable URL.

UI and settings plumbing already existed from the sidecar build; only
the Electron main-process wiring was missing.

* chore: added electron package to version bump script

* fix(electron): address PR review — harden IPC surface + polish

P1 security:
- Gate openchamber:invoke and openchamber:dialog:open by webContents
  origin. Only local (loopback / dev file://) senders can call desktop_*.
  Blocks remote hosts loaded via DesktopHostSwitcher from reading local
  files, opening apps, relaunching, etc.
- desktop_read_file now refuses paths outside $HOME / tmpdir and denies
  .ssh/.aws/.gnupg/.config/gh/credentials + .env/.pem/.key by name
  (defense-in-depth behind the origin gate).

P2:
- webPreferences.sandbox:false: add comment explaining preload needs Node
  (contextBridge+ipcRenderer) and why flipping to true would break IPC.
- desktop_set_vibrancy: comment the intentional no-op (no Electron
  equivalent for the Tauri NSVisualEffectView path), drop requiresRestart.
- desktopNative.ts: replace isTauriShell() guards with isDesktopShell()
  so the semantics match (previous check worked only because Electron
  preload exposes a __TAURI__ shim).
- AGENTS.md: correct entry description — server runs in-process, not as
  a sidecar subprocess.

* fix(electron): stop leaking desktop shell APIs to remote renderer pages

Preload was exposing __TAURI__ and __OPENCHAMBER_ELECTRON__ unconditionally,
so after DesktopHostSwitcher navigated the window to a remote OpenChamber
instance the remote UI saw isDesktopShell() === true and tried to invoke
desktop_* IPC. The main-process origin gate then threw "IPC not available
for this origin", surfacing as a user-visible error on the onboarding
screen of the remote.

Preload re-runs on cross-origin navigation; compute current origin up
front and only expose the shell globals + the openchamber:emit listener
when the document is loopback / state.localOrigin / file://. Remote
pages now look like a plain web runtime — no IPC path to reject.

* fix(electron): restore remote UI shell integration via per-command gate

Previous commit stripped __TAURI__ / __OPENCHAMBER_ELECTRON__ from remote
pages wholesale, which broke DesktopHostSwitcher for anyone switched to
a remote instance: no hosts list, "Unknown" probe status, open-in-new-
window dead. Also lost window chrome affordances that the remote UI
needs to render correctly inside the Electron shell.

Switch from an origin-level gate to a per-command allowlist:

- preload.mjs exposes __TAURI__ and __OPENCHAMBER_ELECTRON__ on every
  page (shell identity + IPC channel). __OPENCHAMBER_LOCAL_ORIGIN__ and
  __OPENCHAMBER_MACOS_MAJOR__ also go everywhere since HostSwitcher and
  window chrome depend on them and neither grants capability.
  __OPENCHAMBER_HOME__ stays local-only (leaks the OS username and is
  misleading if consumed as a workspace hint on a remote page).

- main.mjs ipcMain.handle accepts a curated COMMANDS_SAFE_FOR_REMOTE set
  (hosts_get, host_probe, new_window, new_window_at_url, set_window_*,
  is_window_fullscreen, start_window_drag, get_app_version,
  get_lan_address). Filesystem, shell.openPath, installed-apps scans,
  app relaunch, auto-update, hosts_set, dialog:open, read_file stay
  local-only — remote UI doesn't need them and can't weaponize them.

* ci(release): rebuild native modules against Electron ABI before packaging

Electron job skipped rebuild:native so bun install's Node-ABI builds of
better-sqlite3/node-pty/bun-pty shipped into the asar — packaged app
would crash on require. Local bun run package runs the step via
scripts/rebuild-native.mjs (npmRebuild is disabled in package.json);
mirror it in CI and pass ELECTRON_BUILDER_ARCH so the x64 matrix
cross-builds from the arm64 runner.

Tauri job untouched — both builds continue to produce side-by-side
release artifacts (latest.json for Tauri, latest-mac.yml for Electron)
so each shell's updater finds its own manifest.

* ci(release): split Electron arm64/x64 onto native macOS runners

Both Electron matrix entries were running on macos-26 (arm64) and
cross-building x64 from there. Works for Rust/Tauri; brittle for
native Node modules — better-sqlite3, node-pty, bun-pty (with its
rust-pty crate) each have their own cross-target quirks.

Pin arm64 → macos-14 and x64 → macos-13 so node-gyp and
@electron/rebuild build against the host arch. ELECTRON_BUILDER_ARCH
now just mirrors the runner for clarity.

* Revert "ci(release): split Electron arm64/x64 onto native macOS runners"

This reverts commit f217880e49609cf1418818af0f837b333dbb6f42.

* ci(test-build): add Electron DMG job to arm64 dispatch workflow

Parallel job to the existing Tauri DMG builder, same runner + Apple
cert path. Mirrors the release workflow steps (build:web-assets,
bundle:main, rebuild:native, electron-builder) so maintainers can
smoke-test a signed+notarized Electron DMG before merging.

* ci: use electron-builder v26 boolean arch flags

v26 dropped --arch <name> in favour of per-arch booleans (--arm64,
--x64, etc.). Test build was failing at dispatch time; release job
had the same bug latent. Switch both to the supported form.

* fix(electron): route external links to the system browser

<a href> clicks and window.open calls with non-local URLs were loading
inside the Electron BrowserWindow (or spawning a second Electron window
as a makeshift browser). Add an origin-aware navigation guard to each
window: loopback / state.localOrigin / configured desktop hosts keep
their existing in-window behaviour (HostSwitcher, in-window probes);
everything else hands off to shell.openExternal so http/https links
open in the user's default browser.
This commit is contained in:
Bohdan Triapitsyn
2026-04-20 15:41:15 +03:00
committed by GitHub
parent 4506c18f53
commit 285c3bcaae
68 changed files with 6100 additions and 290 deletions
@@ -123,3 +123,79 @@ jobs:
name: dmg-${{ inputs.macos_version }}-arm64 name: dmg-${{ inputs.macos_version }}-arm64
path: artifacts/*.dmg path: artifacts/*.dmg
retention-days: 7 retention-days: 7
build-macos-dmg-arm64-electron:
name: Build Electron DMG (arm64, ${{ inputs.macos_version }})
runs-on: ${{ inputs.macos_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup bun
uses: oven-sh/setup-bun@v2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Install Apple Certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
KEYCHAIN_PATH=$RUNNER_TEMP/electron-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12
security import $RUNNER_TEMP/certificate.p12 \
-P "$APPLE_CERTIFICATE_PASSWORD" \
-A -t cert -f pkcs12 \
-k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
- name: Build Electron app (arm64)
working-directory: packages/electron
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
ELECTRON_BUILDER_ARCH: arm64
run: |
bun run build:web-assets
bun run bundle:main
bun run rebuild:native
./node_modules/.bin/electron-builder --mac --arm64 --publish=never
- name: Prepare DMG artifact
run: |
set -euo pipefail
mkdir -p artifacts
DMG_PATH="packages/electron/dist/*.dmg"
if ls $DMG_PATH 1> /dev/null 2>&1; then
DMG_FILE=$(ls $DMG_PATH | head -n 1)
DMG_NAME="OpenChamber_Electron_${{ inputs.macos_version }}_arm64.dmg"
cp "$DMG_FILE" "artifacts/$DMG_NAME"
else
echo "Error: DMG file not found at $DMG_PATH"
exit 1
fi
- name: Upload DMG artifact
uses: actions/upload-artifact@v4
with:
name: dmg-electron-${{ inputs.macos_version }}-arm64
path: artifacts/*.dmg
retention-days: 7
+163 -1
View File
@@ -494,8 +494,170 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-desktop-electron-macos:
needs: create-release
runs-on: macos-26
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
arch: arm64
platform: darwin-aarch64
- target: x86_64-apple-darwin
arch: x64
platform: darwin-x86_64
steps:
- uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Install Apple Certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
KEYCHAIN_PATH=$RUNNER_TEMP/electron-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12
security import $RUNNER_TEMP/certificate.p12 \
-P "$APPLE_CERTIFICATE_PASSWORD" \
-A -t cert -f pkcs12 \
-k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
- name: Build Electron app
working-directory: packages/electron
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# rebuild-native.mjs reads this to target the right arch when
# cross-building (runner is arm64; x64 matrix needs the hint).
ELECTRON_BUILDER_ARCH: ${{ matrix.arch }}
run: |
bun run build:web-assets
bun run bundle:main
# npmRebuild=false in package.json, so electron-builder won't
# recompile native deps on its own — we must rebuild against the
# target Electron ABI before packaging, otherwise better-sqlite3/
# node-pty/bun-pty crash on require inside the packaged app.
bun run rebuild:native
./node_modules/.bin/electron-builder --mac --${{ matrix.arch }} --publish=never
- name: Verify signature + entitlements + notarization
run: |
set -euo pipefail
APP_DIR="packages/electron/dist/mac"
[ -d "packages/electron/dist/mac-arm64" ] && APP_DIR="packages/electron/dist/mac-arm64"
APP_PATH=$(find "$APP_DIR" -maxdepth 2 -name "*.app" -print -quit)
if [ -z "$APP_PATH" ]; then
echo "Error: .app not found under packages/electron/dist/mac*"
ls -la packages/electron/dist/
exit 1
fi
echo "Verifying $APP_PATH"
codesign -vv --deep --strict "$APP_PATH"
# Require hardened runtime
CS_INFO=$(codesign -dv --verbose=4 "$APP_PATH" 2>&1)
echo "$CS_INFO"
if ! echo "$CS_INFO" | grep -q "flags=.*runtime"; then
echo "Error: hardened runtime flag missing"
exit 1
fi
# Require notary ticket stapled
xcrun stapler validate "$APP_PATH"
ENTITLEMENTS=$(codesign -d --entitlements :- "$APP_PATH" 2>&1 || true)
if echo "$ENTITLEMENTS" | grep -q "com.apple.security.app-sandbox"; then
echo "Error: app sandbox entitlement is present"
exit 1
fi
for key in \
com.apple.security.cs.allow-jit \
com.apple.security.cs.allow-unsigned-executable-memory \
com.apple.security.cs.disable-library-validation
do
if ! echo "$ENTITLEMENTS" | grep -q "<key>$key</key>"; then
echo "Error: required entitlement missing: $key"
exit 1
fi
done
- name: Upload DMG / ZIP / blockmaps to release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.create-release.outputs.version }}
files: |
packages/electron/dist/*.dmg
packages/electron/dist/*.zip
packages/electron/dist/*.blockmap
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload per-arch latest-mac.yml for merge
uses: actions/upload-artifact@v4
with:
name: latest-yml-${{ matrix.target }}
path: packages/electron/dist/latest-mac.yml
retention-days: 1
combine-electron-manifests:
needs: [create-release, build-desktop-electron-macos]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Download per-arch latest-mac.yml
uses: actions/download-artifact@v4
with:
pattern: latest-yml-*-apple-darwin
path: artifacts
- name: Finalize combined latest-mac.yml
env:
LATEST_YML_DIR: ${{ github.workspace }}/artifacts
GH_REPO: ${{ github.repository }}
OPENCHAMBER_VERSION: ${{ needs.create-release.outputs.version }}
run: node packages/electron/scripts/finalize-latest-yml.mjs
- name: Upload combined latest-mac.yml to release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.create-release.outputs.version }}
files: ${{ runner.temp }}/latest-mac.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
finalize-release: finalize-release:
needs: [create-release, build-desktop-macos, publish-npm, combine-manifests] needs: [create-release, build-desktop-macos, build-desktop-electron-macos, publish-npm, combine-manifests, combine-electron-manifests]
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
+22 -8
View File
@@ -6,9 +6,19 @@ OpenChamber provides UI runtimes (web/desktop/VS Code) for interacting with an O
## Runtime architecture (IMPORTANT) ## Runtime architecture (IMPORTANT)
- `Desktop` is a thin Tauri shell that starts the web server sidecar and loads the web UI from `http://127.0.0.1:<port>`. - `Desktop` (Electron) boots the web server **in the same Node process** as the Electron main, then loads the web UI from `http://127.0.0.1:<port>`. No sidecar subprocess.
- All backend logic lives in `packages/web/server/*` (and `packages/vscode/*` for the VS Code runtime). Desktop Rust is not a feature backend. - `Desktop` (Tauri, legacy) still spawns `openchamber-server` as a bun-compiled sidecar binary. Kept only for auto-update compatibility with existing Tauri installs.
- Tauri is used only for stable native integrations: menu, dialog (open folder), notifications, updater, deep-links. - All backend logic lives in `packages/web/server/*` (and `packages/vscode/*` for the VS Code runtime). The native shell is not a feature backend.
- The shell is used only for stable native integrations: menu, dialog (open folder), notifications, updater, deep-links, quit confirmation.
### Desktop shell: Electron is the target, Tauri is legacy
- **New desktop work goes into `packages/electron/`.** This is the forward path.
- `packages/desktop/` (Tauri) is kept running in parallel only to preserve auto-update for existing installs until the cutover. Do **not** add features to it; do **not** port bug fixes back unless they actually affect currently-released Tauri users.
- Desktop-side changes (IPC handlers, native integrations, window/quit/notification behavior) land in `packages/electron/main.mjs` + `packages/electron/preload.mjs`. The `__TAURI__` shim exposed by the preload keeps the shared UI working against both shells, so renderer-side code should not branch on shell type.
- Electron imports the server via `@openchamber/web/server/index.js` (workspace dep) and calls `startWebUiServer({...})`. The returned handle has `getPort()` / `stop()`. Notifications flow via an `onDesktopNotification` callback injected at startup — no stdout-parsing IPC.
- Build/release: both shells ship in the same GitHub release today (`.github/workflows/release.yml`). The one-shot Tauri → Electron auto-update migration is documented in `docs/TAURI_TO_ELECTRON_CUTOVER.md`; run that when the user decides to flip.
- After the cutover ships and stabilises, `packages/desktop/` is deleted; this note collapses back to "Desktop is Electron".
## Tech stack (source of truth: `package.json`, resolved: `bun.lock`) ## Tech stack (source of truth: `package.json`, resolved: `bun.lock`)
@@ -17,7 +27,8 @@ OpenChamber provides UI runtimes (web/desktop/VS Code) for interacting with an O
- State: Zustand (`packages/ui/src/stores/`) - State: Zustand (`packages/ui/src/stores/`)
- UI primitives: Base UI (`@base-ui/react`, primary source for dropdown/select/dialog/menu/tooltip/etc. — wrappers live in `packages/ui/src/components/ui/`), Radix UI (`package.json` deps, legacy usages being migrated), HeroUI (`package.json` deps), Remixicon (`package.json` deps) - UI primitives: Base UI (`@base-ui/react`, primary source for dropdown/select/dialog/menu/tooltip/etc. — wrappers live in `packages/ui/src/components/ui/`), Radix UI (`package.json` deps, legacy usages being migrated), HeroUI (`package.json` deps), Remixicon (`package.json` deps)
- Server: Express (`packages/web/server/index.js`) - Server: Express (`packages/web/server/index.js`)
- Desktop: Tauri v2 (`packages/desktop/src-tauri/`) - Desktop (forward): Electron 41 (`packages/electron/`)
- Desktop (legacy, maintenance-only): Tauri v2 (`packages/desktop/src-tauri/`)
- VS Code: extension + webview (`packages/vscode/`) - VS Code: extension + webview (`packages/vscode/`)
## Monorepo layout ## Monorepo layout
@@ -26,7 +37,8 @@ Workspaces are `packages/*` (see `package.json`).
- Shared UI: `packages/ui` - Shared UI: `packages/ui`
- Web app + server + CLI: `packages/web` - Web app + server + CLI: `packages/web`
- Desktop app (Tauri): `packages/desktop` - Desktop shell (Electron — forward): `packages/electron`
- Desktop shell (Tauri — legacy, maintenance-only): `packages/desktop`
- VS Code extension: `packages/vscode` - VS Code extension: `packages/vscode`
## Documentation map ## Documentation map
@@ -95,7 +107,9 @@ All scripts are in `package.json`.
- Validate: `bun run type-check`, `bun run lint` - Validate: `bun run type-check`, `bun run lint`
- Build all: `bun run build` - Build all: `bun run build`
- Desktop build: `bun run desktop:build` - Desktop build (Electron — primary): `bun run electron:build`
- Desktop dev (Electron): `bun run electron:dev`
- Desktop build (Tauri — legacy): `bun run desktop:build`
- VS Code build: `bun run vscode:build` - VS Code build: `bun run vscode:build`
- Release smoke build: `bun run release:test` (shell script: `scripts/test-release-build.sh`) - Release smoke build: `bun run release:test` (shell script: `scripts/test-release-build.sh`)
@@ -104,8 +118,8 @@ All scripts are in `package.json`.
- Web bootstrap: `packages/web/src/main.tsx` - Web bootstrap: `packages/web/src/main.tsx`
- Web server: `packages/web/server/index.js` - Web server: `packages/web/server/index.js`
- Web CLI: `packages/web/bin/cli.js` (package bin: `packages/web/package.json`) - Web CLI: `packages/web/bin/cli.js` (package bin: `packages/web/package.json`)
- Desktop: Tauri entry `packages/desktop/src-tauri/src/main.rs` (spawns web server sidecar + loads web UI) - Desktop (Electron — primary): `packages/electron/main.mjs` (boots the web server in-process via `startWebUiServer`, loads web UI over loopback; preload at `packages/electron/preload.mjs` exposes the `__TAURI__` IPC shim so shared UI code is shell-agnostic)
- Tauri backend: `packages/desktop/src-tauri/src/main.rs` - Desktop (Tauri — legacy): `packages/desktop/src-tauri/src/main.rs`
- VS Code extension host: `packages/vscode/src/extension.ts` - VS Code extension host: `packages/vscode/src/extension.ts`
- VS Code webview bootstrap: `packages/vscode/webview/main.tsx` - VS Code webview bootstrap: `packages/vscode/webview/main.tsx`
+733 -44
View File
File diff suppressed because it is too large Load Diff
+349
View File
@@ -0,0 +1,349 @@
# Tauri → Electron auto-update cutover
> Self-contained playbook. The branch and conversation where this plan was
> designed will not be around when the cutover happens — read this file top to
> bottom and execute; do not assume prior context.
## What this is
OpenChamber historically shipped as a Tauri app. A parallel Electron shell was
added on branch `electron-app` (merged to `main` as part of a larger migration).
Since then, both desktop shells have been released in the same GitHub release
and each has its own auto-update channel:
| Shell | Manifest | Update format | Secret used to sign |
|----------|-------------------|---------------------|---------------------|
| Tauri | `latest.json` | `.tar.gz` + `.sig` | `TAURI_SIGNING_PRIVATE_KEY` (minisign) |
| Electron | `latest-mac.yml` | `.zip` + `blockmap` | Developer ID codesign (APPLE_* secrets) |
Existing Tauri installs keep their own auto-update path (`latest.json`).
Electron installs auto-update through `latest-mac.yml`. They coexist without
conflict because filenames and manifests differ.
At some point the user wants to **stop maintaining the Tauri build** and make
the Tauri installs migrate themselves into Electron via auto-update. This
document describes how to do that in a single "transition release".
## The core trick
Tauri's updater downloads whatever `.tar.gz` the `latest.json` points at,
verifies the minisign signature, unpacks the contents **over** the existing
`.app` directory, and restarts. It does **not** introspect the payload — it
just replaces files.
So: produce a `.tar.gz` of the Electron `.app`, sign it with the existing
Tauri minisign key, point `latest.json` at it. Tauri users receive the update,
their `OpenChamber.app` becomes the Electron bundle in-place, and next launch
starts Electron. Subsequent updates go through `latest-mac.yml`
(electron-updater). One-way migration, one-shot workflow change.
## Prerequisites before running the cutover
Check all of these before making any release:
1. **Electron has shipped stable through its own `latest-mac.yml` path for at
least 2 releases.** Verify:
```
gh release list --repo btriapitsyn/openchamber
gh release view vX.Y.Z --repo btriapitsyn/openchamber \
| grep -E 'OpenChamber-.*\.zip|latest-mac\.yml'
```
A user on Electron should have successfully auto-updated at least once.
If not, pause and stabilise that path first — don't stack risk.
2. **`~/.config/openchamber/settings.json` is still the shared state path.**
Tauri `src-tauri/src/main.rs:settings_file_path` and Electron
`packages/electron/main.mjs:settingsFilePath` must both resolve to
`$HOME/.config/openchamber/settings.json`. If either has moved, data parity
breaks and this migration loses user data. Audit both paths, update the
non-migrated shell to match before proceeding.
3. **Electron `appId` is `dev.openchamber.desktop`** (check
`packages/electron/package.json` `build.appId`). Tauri identifier is
`ai.opencode.openchamber`. These differ intentionally — it means macOS
LaunchServices will re-register after the in-place replace. That's fine but
see "Risks" below.
4. **All GitHub secrets still valid:** `APPLE_CERTIFICATE`,
`APPLE_CERTIFICATE_PASSWORD`, `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`,
`TAURI_SIGNING_PRIVATE_KEY`, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. A
workflow_dispatch dry-run should succeed before the real tag.
5. **`minisign` CLI is available on the macOS runner** (or installable via
brew). Used to sign the Electron tarball with the Tauri key.
## Release workflow changes
The file to edit: `.github/workflows/release.yml`.
Today it has these jobs (simplified):
```
create-release
├── build-desktop-macos (Tauri .dmg/.tar.gz/.tar.gz.sig)
├── build-desktop-electron-macos (Electron .dmg/.zip/blockmap/latest-mac.yml)
├── publish-npm
├── combine-manifests (merges Tauri per-arch JSONs → latest.json)
├── combine-electron-manifests (merges Electron per-arch YMLs → latest-mac.yml)
└── finalize-release
```
### Step 1 — Remove the Tauri build
Delete these jobs entirely:
- `build-desktop-macos`
- `combine-manifests`
They are replaced by the repackage job (below). `finalize-release` `needs:`
list must be updated to drop both.
### Step 2 — Add a repackage job
Insert after `build-desktop-electron-macos`:
```yaml
repackage-electron-as-tauri-update:
needs: [create-release, build-desktop-electron-macos]
runs-on: macos-26
strategy:
fail-fast: false
matrix:
include:
- arch: arm64
platform: darwin-aarch64
- arch: x64
platform: darwin-x86_64
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: '20'
# Pull the signed+notarized Electron .app that build-desktop-electron-macos
# already produced. Either re-download the dmg and mount+copy the .app, or
# (cleaner) modify build-desktop-electron-macos to upload the .app itself
# as an artifact so this job can download it. Prefer the latter — adds one
# `actions/upload-artifact@v4` step uploading `packages/electron/dist/mac-<arch>/OpenChamber.app`.
- name: Download signed Electron .app
uses: actions/download-artifact@v4
with:
name: electron-app-${{ matrix.arch }}
path: staged
- name: Install minisign
run: brew install minisign
- name: Tar and sign Electron .app as Tauri update payload
env:
TAURI_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VERSION: ${{ needs.create-release.outputs.version }}
run: |
set -euo pipefail
cd staged
# The tarball name convention Tauri's updater expects. Must end in
# `.app.tar.gz`. Name stays stable — Tauri updater does not care about
# the inner .app name.
TARBALL="OpenChamber.app.tar.gz"
tar -czf "$TARBALL" OpenChamber.app
# minisign needs the private key written to a file and a non-interactive
# password via -W (or env). The key in the secret is a minisign secret
# key block (base64-ish multi-line blob). Write to a file verbatim.
echo "$TAURI_KEY" > ../tauri-signing.key
echo "$TAURI_KEY_PASSWORD" | minisign -S -s ../tauri-signing.key \
-m "$TARBALL" -W
# Rename per platform so the release has distinct names for arm64/x64.
mv "$TARBALL" "OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz"
mv "${TARBALL}.minisig" "OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz.sig"
- name: Generate Tauri latest-<platform>.json
env:
VERSION: ${{ needs.create-release.outputs.version }}
REPO: ${{ github.repository }}
run: |
SIG=$(cat staged/OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz.sig)
TAR=OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz
cat > staged/latest-${{ matrix.platform }}.json <<EOF
{
"version": "${VERSION}",
"notes": "OpenChamber has moved to Electron. This update replaces the Tauri shell with the Electron build. Subsequent updates will be delivered via the Electron auto-updater.",
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"platforms": {
"${{ matrix.platform }}": {
"signature": "${SIG}",
"url": "https://github.com/${REPO}/releases/download/v${VERSION}/${TAR}"
}
}
}
EOF
- name: Upload tarball + sig to release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.create-release.outputs.version }}
files: |
staged/*.app.tar.gz
staged/*.app.tar.gz.sig
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload per-platform manifest as artifact for merge
uses: actions/upload-artifact@v4
with:
name: tauri-manifest-${{ matrix.platform }}
path: staged/latest-${{ matrix.platform }}.json
retention-days: 1
```
### Step 3 — Re-add the `combine-manifests` job
Bring it back (it was deleted in Step 1) but sourcing artifacts from the
repackage job instead of the old Tauri build. The merging logic is identical
to what the old job did. Minimum job shape:
```yaml
combine-manifests:
needs: [create-release, repackage-electron-as-tauri-update]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: tauri-manifest-*
path: artifacts
- name: Merge
run: |
# Copy the original merge logic from git history. It takes the two
# per-platform JSONs and produces a single `latest.json` with both
# platform entries. Upload as a release asset.
# Search git history: git log --all --diff-filter=D -- .github/workflows/release.yml
# Find the commit that deleted the old merge step and copy its shell block.
...
- uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.create-release.outputs.version }}
files: artifacts/latest.json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```
### Step 4 — Update `finalize-release.needs`
```yaml
finalize-release:
needs: [create-release, build-desktop-electron-macos, repackage-electron-as-tauri-update, publish-npm, combine-manifests, combine-electron-manifests]
```
### Step 5 — Remove Tauri-specific code
After the transition release ships and has been out at least 2 weeks with no
rollback, remove:
- `packages/desktop/` (entire package — Tauri Rust + UI glue)
- Any `isTauriShell()` branches that are now dead code in
`packages/ui/src/` (search for the symbol; most call sites already fall
through to the Electron path because our preload exposes a `__TAURI__` shim;
audit each before removing).
- This file (`docs/TAURI_TO_ELECTRON_CUTOVER.md`) — mission accomplished.
Do this in a separate PR. Keep the transition release workflow intact until
the cleanup lands; rolling the cleanup into the transition release itself
makes debugging much harder if the migration misbehaves for a user.
## Validation before tagging the transition release
You must manually validate with a real Tauri install. Do NOT skip this.
1. Have the previous Tauri release installed locally
(`/Applications/OpenChamber.app` with `Contents/Info.plist` showing
`CFBundleIdentifier = ai.opencode.openchamber`).
2. Tag the transition release to a test tag
(e.g. `v2.0.0-migration-test`) and push.
3. Let the workflow complete. Do not merge cleanup PR yet.
4. In the running Tauri app, use the built-in "Check for updates".
5. Accept the update. The app should download, verify, extract, restart.
6. After restart, `Info.plist` under `/Applications/OpenChamber.app/` should
now show `CFBundleIdentifier = dev.openchamber.desktop`.
7. Settings should be intact: hosts list, default host, sessions history.
8. In the new Electron app, "Check for updates" should report no update
available (it's now at the transition version, which is the latest).
9. Produce a dummy v2.0.1 Electron-only release to prove the subsequent
Electron-path update works. Accept it. App relaunches into v2.0.1.
If any step fails:
- Delete the test tag and GitHub release.
- Do not delete yet-shipped artifacts from a real tag until rollback below.
## Rollback if the transition release misbehaves
If users report the Tauri → Electron update bricks their install:
1. **Immediately** delete the latest release asset
`OpenChamber-*.app.tar.gz` and `latest.json` from the GitHub release
(keep the DMGs so manual download still works).
2. Re-upload the previous version's `latest.json` as the current latest so
Tauri updaters see "up to date" instead of a broken update on next check.
3. Post a support note: users who already applied the broken update can
download a fresh Electron `.dmg` manually and drag-replace. Their
`~/.config/openchamber/settings.json` survives.
4. Investigate, fix the workflow, retry with a new version number.
## Risks & edge cases
### Different `CFBundleIdentifier` at same path
macOS LaunchServices caches identifier ↔ path mappings. When we replace
`ai.opencode.openchamber` with `dev.openchamber.desktop` at the same `.app`
path, LaunchServices will rebuild on next launch (automatic). Usually fine.
If a user's system is in a weird state, a `killall Dock` or logout/login
fixes it. Worth noting in the release notes.
### macOS notification permissions
Notification permission is per-bundle-id. After migration, the app has a new
bundle-id, so the first notification will re-prompt the user. Unavoidable.
Mention in release notes.
### Deep-link protocol registration
The `openchamber://` protocol was registered for `ai.opencode.openchamber`.
After migration, `dev.openchamber.desktop` registers itself on first launch.
LaunchServices updates the handler. Usually seamless. Test with
`open 'openchamber://session/test'` post-migration.
### Gatekeeper "damaged app" dialog
Rare. Triggered if the replaced `.app` fails a mid-extract codesign check.
Can happen if Tauri's extractor corrupts xattrs. Mitigation: test on a
pristine macOS install before tagging production.
### Users on unsupported old Tauri versions
If a user is on a very old Tauri build that doesn't know how to do the
fetch-verify-extract flow, they're stuck. Expected: negligibly few users;
they'll just stay on their old version forever until they manually download.
Acceptable.
### Rollback-after-migration-accepted is impossible per-user
Once a user is on Electron, the Tauri updater is gone. If they want to go
back to a Tauri build, they must manually download. We don't support this.
## Relevant files to understand before making changes
- `.github/workflows/release.yml` — the release workflow.
- `packages/electron/package.json` — electron-builder config (appId,
mac/dmg, publish, artifactName).
- `packages/electron/main.mjs` — autoUpdater setup (`setupAutoUpdater`,
`desktop_check_for_updates`, `desktop_download_and_install_update`,
`desktop_restart`). Understand this flow before touching the CI.
- `packages/electron/scripts/finalize-latest-yml.mjs` — per-arch
`latest-mac.yml` merger. Already wired in `combine-electron-manifests`.
- `packages/desktop/src-tauri/tauri.conf.json` — legacy Tauri identifier,
minisign pubkey embedded for updater verification. Don't modify; just
reference for context.
## Working protocol
Default to a dry-run (test tag like `vX.Y.Z-migration-test` on a workflow_dispatch
run) before the real tag. Surface only business-level decisions —
"cutover this release, or hold one more cycle?" — and make technical calls
(minisign invocation flags, YAML layout, job dependency order) yourself,
documenting each one in the PR description.
+5
View File
@@ -26,14 +26,17 @@
"build:web": "bun run --cwd packages/web build", "build:web": "bun run --cwd packages/web build",
"build:ui": "bun run --cwd packages/ui build", "build:ui": "bun run --cwd packages/ui build",
"build:desktop": "bun run --cwd packages/desktop build", "build:desktop": "bun run --cwd packages/desktop build",
"build:electron": "bun run --cwd packages/electron build",
"type-check": "bun run --filter '*' type-check", "type-check": "bun run --filter '*' type-check",
"type-check:web": "bun run --cwd packages/web type-check", "type-check:web": "bun run --cwd packages/web type-check",
"type-check:ui": "bun run --cwd packages/ui type-check", "type-check:ui": "bun run --cwd packages/ui type-check",
"type-check:desktop": "bun run --cwd packages/desktop type-check", "type-check:desktop": "bun run --cwd packages/desktop type-check",
"type-check:electron": "bun run --cwd packages/electron type-check",
"lint": "bun run --filter '*' lint", "lint": "bun run --filter '*' lint",
"lint:web": "bun run --cwd packages/web lint", "lint:web": "bun run --cwd packages/web lint",
"lint:ui": "bun run --cwd packages/ui lint", "lint:ui": "bun run --cwd packages/ui lint",
"lint:desktop": "bun run --cwd packages/desktop lint", "lint:desktop": "bun run --cwd packages/desktop lint",
"lint:electron": "bun run --cwd packages/electron lint",
"clean": "bun run --filter '*' clean", "clean": "bun run --filter '*' clean",
"postinstall": "patch-package", "postinstall": "patch-package",
"dev:web": "bun run --cwd packages/web build:watch", "dev:web": "bun run --cwd packages/web build:watch",
@@ -46,6 +49,8 @@
"desktop:stop-cli": "node ./packages/desktop/scripts/opencode-cli.mjs stop", "desktop:stop-cli": "node ./packages/desktop/scripts/opencode-cli.mjs stop",
"desktop:dev": "node ./packages/desktop/scripts/desktop-dev.mjs", "desktop:dev": "node ./packages/desktop/scripts/desktop-dev.mjs",
"desktop:build": "bun run --cwd packages/desktop build:sidecar && bun run --cwd packages/desktop tauri build", "desktop:build": "bun run --cwd packages/desktop build:sidecar && bun run --cwd packages/desktop tauri build",
"electron:dev": "node ./packages/electron/scripts/electron-dev.mjs",
"electron:build": "bun run --cwd packages/electron package",
"desktop:lint": "bun run --cwd packages/desktop lint && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings", "desktop:lint": "bun run --cwd packages/desktop lint && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings",
"desktop:type-check": "bun run --cwd packages/desktop type-check && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings", "desktop:type-check": "bun run --cwd packages/desktop type-check && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings",
"vscode:dev": "node ./scripts/dev-vscode.mjs", "vscode:dev": "node ./scripts/dev-vscode.mjs",
+13
View File
@@ -0,0 +1,13 @@
# Dependencies
node_modules/
# Electron build output
dist/
dist-bundle/
# Generated packaging resources
resources/web-dist/
resources/sidecar/
# OS-specific
.DS_Store
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
{
"name": "@openchamber/electron",
"version": "1.9.6",
"private": true,
"description": "Electron desktop runtime for OpenChamber",
"author": "OpenChamber",
"type": "module",
"main": "./dist-bundle/main.mjs",
"dependencies": {
"@openchamber/web": "workspace:*",
"electron-context-menu": "^4.1.2",
"electron-log": "^5.4.3",
"electron-updater": "^6.8.3"
},
"devDependencies": {
"@electron/rebuild": "^3.7.0",
"electron": "^41.2.1",
"electron-builder": "^26.0.0"
},
"desktopPrerequisites": [
"Electron runtime dependencies installed via bun install",
"Bun available for sidecar compilation",
"macOS build tools installed for notarized packaging"
],
"scripts": {
"dev": "node ./scripts/electron-dev.mjs",
"build:web-assets": "node ./scripts/build-web-assets.mjs",
"build": "bun -e \"process.exit(0)\"",
"bundle:main": "bun ./scripts/bundle-main.mjs",
"rebuild:native": "node ./scripts/rebuild-native.mjs",
"package": "bun run build:web-assets && bun run bundle:main && bun run rebuild:native && electron-builder",
"finalize:latest-yml": "node ./scripts/finalize-latest-yml.mjs",
"type-check": "node --check ./main.mjs && node --check ./preload.mjs",
"lint": "node -e \"process.exit(0)\""
},
"build": {
"appId": "dev.openchamber.desktop",
"productName": "OpenChamber",
"files": [
"dist-bundle/main.mjs",
"preload.mjs"
],
"extraResources": [
{
"from": "resources/web-dist",
"to": "web-dist"
}
],
"directories": {
"buildResources": "resources/icons",
"output": "dist"
},
"artifactName": "${productName}-${version}-${arch}.${ext}",
"npmRebuild": false,
"mac": {
"category": "public.app-category.developer-tools",
"icon": "resources/icons/icon.icns",
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "resources/entitlements.mac.plist",
"entitlementsInherit": "resources/entitlements.mac.plist",
"notarize": true,
"target": [
"dmg",
"zip"
]
},
"dmg": {
"sign": true,
"title": "${productName} ${version}",
"backgroundColor": "#FFFCF0",
"iconSize": 100,
"iconTextSize": 13,
"window": {
"width": 540,
"height": 340
},
"contents": [
{ "x": 180, "y": 140, "type": "file" },
{ "x": 360, "y": 140, "type": "link", "path": "/Applications" }
]
},
"publish": {
"provider": "github",
"owner": "btriapitsyn",
"repo": "openchamber"
}
}
}
+143
View File
@@ -0,0 +1,143 @@
import { contextBridge, ipcRenderer } from 'electron';
const eventListeners = new Map();
const readArgValue = (name) => {
const prefix = `${name}=`;
const entry = process.argv.find((value) => typeof value === 'string' && value.startsWith(prefix));
if (!entry) {
return '';
}
return entry.slice(prefix.length);
};
const localOrigin = readArgValue('--openchamber-local-origin');
const homeDirectory = readArgValue('--openchamber-home');
const macosMajorRaw = readArgValue('--openchamber-macos-major');
const macosMajor = Number.parseInt(macosMajorRaw, 10);
// Preload re-executes on every cross-origin navigation (we run with
// sandbox:false, per-document). Two separate concerns to balance:
// - __OPENCHAMBER_ELECTRON__ is a shell-identity flag (no capability).
// Remote UIs still need it so isDesktopShell() returns true and the
// window renders with desktop affordances (DesktopHostSwitcher,
// title bar offsets, etc.). Expose unconditionally.
// - __TAURI__ is the IPC channel to the main process. Remote pages must
// not get it — otherwise any page loaded via DesktopHostSwitcher could
// read local files, open apps, relaunch, etc. Expose only on local
// pages (loopback / state.localOrigin / file:// for dev).
// Everything driven by localOrigin (home dir, macOS hints) also stays
// local-only since it leaks info about the Electron host machine.
const currentOrigin = (() => {
try {
return typeof location !== 'undefined' ? location.origin : '';
} catch {
return '';
}
})();
const isLoopbackOrigin = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i.test(currentOrigin);
const isLocalPage = currentOrigin === 'null'
|| isLoopbackOrigin
|| (localOrigin && currentOrigin === localOrigin);
// Remote pages need __OPENCHAMBER_LOCAL_ORIGIN__ so the HostSwitcher knows
// the URL of the Local entry (isDesktopLocalOriginActive() falls back to
// window.location.origin otherwise — wrong on remote). Low risk: the value
// is just "http://127.0.0.1:<port>" which is not exploitable without the
// IPC channel, and CORS on the local server prevents remote-origin fetches.
if (localOrigin) {
contextBridge.exposeInMainWorld('__OPENCHAMBER_LOCAL_ORIGIN__', localOrigin);
}
// Home directory leaks the OS username — keep local-only. Remote pages
// operate on the REMOTE server's filesystem, local home is irrelevant
// (and would be misleading if consumed as a workspace hint).
if (isLocalPage && homeDirectory) {
contextBridge.exposeInMainWorld('__OPENCHAMBER_HOME__', homeDirectory);
}
// macOS major version drives window chrome offsets (traffic lights) — UI
// presentation only, safe to expose.
if (Number.isFinite(macosMajor) && macosMajor > 0) {
contextBridge.exposeInMainWorld('__OPENCHAMBER_MACOS_MAJOR__', macosMajor);
}
contextBridge.exposeInMainWorld('__OPENCHAMBER_ELECTRON__', {
runtime: 'electron',
});
// Note: bootOutcome must stay writable from the main world's initScript so
// re-navigations (host switch via deep link) can refresh it. contextBridge-
// exposed globals are read-only, which blocks that update — rely solely on
// the main-process initScript injection (dispatched on did-finish-load).
const addListener = (event, handler) => {
const listeners = eventListeners.get(event) || new Set();
listeners.add(handler);
eventListeners.set(event, listeners);
return () => {
const current = eventListeners.get(event);
if (!current) {
return;
}
current.delete(handler);
if (current.size === 0) {
eventListeners.delete(event);
}
};
};
const dispatchNativeEvent = (event, detail) => {
const listeners = eventListeners.get(event);
if (listeners) {
for (const listener of listeners) {
try {
listener({ payload: detail });
} catch (error) {
console.error(`[electron:preload] listener failed for ${event}:`, error);
}
}
}
try {
const domEvent = detail === undefined
? new Event(event)
: new CustomEvent(event, { detail });
window.dispatchEvent(domEvent);
} catch (error) {
console.error(`[electron:preload] failed to dispatch DOM event ${event}:`, error);
}
};
// Main-process events are read-only notifications (update progress,
// window focus, etc.) — safe to deliver to any page rendered in this
// webContents. The events themselves don't grant capability.
ipcRenderer.on('openchamber:emit', (_evt, payload) => {
if (!payload || typeof payload !== 'object') {
return;
}
const event = typeof payload.event === 'string' ? payload.event : '';
if (!event) {
return;
}
dispatchNativeEvent(event, payload.detail);
});
// __TAURI__ is exposed on all pages; the main-process gate in
// ipcMain.handle('openchamber:invoke') decides per-command what is safe
// for non-local callers (window/host-switcher ops yes, file/shell ops
// no). See COMMANDS_SAFE_FOR_REMOTE in main.mjs.
contextBridge.exposeInMainWorld('__TAURI__', {
core: {
invoke: (cmd, args) => ipcRenderer.invoke('openchamber:invoke', cmd, args || {}),
},
dialog: {
open: (options) => ipcRenderer.invoke('openchamber:dialog:open', options || {}),
},
event: {
listen: async (event, handler) => addListener(event, handler),
},
});
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Intentionally NOT sandboxed. This app is distributed outside the Mac App Store.
Do not add com.apple.security.app-sandbox.
These entitlements are commonly required for Electron/Chromium JIT behavior
under hardened runtime, plus access the app already relies on (microphone for
voice notes / dictation, network client for sidecar + remote hosts, shell
probing for PATH inheritance).
-->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
</dict>
</plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="iconShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="12" stdDeviation="14" flood-opacity="0.5" flood-color="#000000"/>
</filter>
<linearGradient id="bgGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#303030"/>
<stop offset="100%" stop-color="#141414"/>
</linearGradient>
</defs>
<!-- Icon with Apple standard padding (100px on each side) -->
<g transform="translate(100, 100)">
<!-- Background rounded square - 824x824 (Apple standard) - dark gradient -->
<rect x="0" y="0" width="824" height="824" rx="185" ry="185" fill="url(#bgGradient)" filter="url(#iconShadow)"/>
<!-- OpenChamber logo centered - simplified for dock visibility -->
<g transform="translate(412, 412) scale(6.5)">
<!-- Left face - simplified, no grid cells -->
<path d="M0 0 L-41.568 -24 L-41.568 24 L0 48 Z" fill="white" fill-opacity="0.2" stroke="white" stroke-width="3" stroke-linejoin="round"/>
<!-- Right face - simplified, no grid cells -->
<path d="M0 0 L41.568 -24 L41.568 24 L0 48 Z" fill="white" fill-opacity="0.35" stroke="white" stroke-width="3" stroke-linejoin="round"/>
<!-- Top face - open -->
<path d="M0 -48 L-41.568 -24 L0 0 L41.568 -24 Z" fill="none" stroke="white" stroke-width="3" stroke-linejoin="round"/>
<!-- OpenCode logo on top face -->
<g transform="matrix(0.866, 0.5, -0.866, 0.5, 0, -24) scale(0.75)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z" fill="white"/>
<path d="M-8 -4 L8 -4 L8 12 L-8 12 Z" fill="white" fill-opacity="0.4"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

@@ -0,0 +1,75 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..', '..', '..');
const webDir = path.join(repoRoot, 'packages', 'web');
const electronDir = path.join(repoRoot, 'packages', 'electron');
const resourcesDir = path.join(electronDir, 'resources');
const resourcesWebDistDir = path.join(resourcesDir, 'web-dist');
const webDistDir = path.join(webDir, 'dist');
const run = (cmd, args, cwd) => {
const result = spawnSync(cmd, args, { cwd, stdio: 'inherit' });
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`Command failed: ${cmd} ${args.join(' ')}`);
}
};
const resolveBun = () => {
if (typeof process.env.BUN === 'string' && process.env.BUN.trim()) {
return process.env.BUN.trim();
}
const result = spawnSync('/bin/bash', ['-lc', 'command -v bun'], { encoding: 'utf8' });
const resolved = (result.stdout || '').trim();
return resolved || 'bun';
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const removeDir = async (target) => {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
await fs.rm(target, { recursive: true, force: true });
return;
} catch (error) {
if (attempt === 4) throw error;
if (!['ENOTEMPTY', 'EBUSY', 'EPERM'].includes(error?.code)) throw error;
await sleep(100 * (attempt + 1));
}
}
};
const copyDir = async (src, dst) => {
await fs.mkdir(dst, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const from = path.join(src, entry.name);
const to = path.join(dst, entry.name);
if (entry.isDirectory()) {
await copyDir(from, to);
} else {
await fs.copyFile(from, to);
}
}
};
const bunExe = resolveBun();
console.log('[electron] building web UI dist...');
run(bunExe, ['run', 'build'], webDir);
console.log('[electron] staging packaged resources...');
await fs.mkdir(resourcesDir, { recursive: true });
const stagedWebDistDir = await fs.mkdtemp(path.join(resourcesDir, 'web-dist-staging-'));
await copyDir(webDistDir, stagedWebDistDir);
await removeDir(resourcesWebDistDir);
await fs.rename(stagedWebDistDir, resourcesWebDistDir);
console.log(`[electron] web assets ready: ${resourcesWebDistDir}`);
+44
View File
@@ -0,0 +1,44 @@
/**
* Bundle main.mjs into a single file. Small electron-* helper deps are
* inlined; everything else — including the in-process web server
* (@openchamber/web) and native modules — stays external so it resolves
* from node_modules at runtime inside the packaged app.
*
* Why external matters: packages/web/server pulls in bun-pty, which has
* a top-level `import { dlopen } from "bun:ffi"`. If we inline it here,
* Node's ESM loader sees `bun:ffi` at package load time and crashes with
* ERR_UNSUPPORTED_ESM_URL_SCHEME before any runtime guard can skip it.
* Leaving @openchamber/web external means the conditional
* `if (isBunRuntime) await import('bun-pty')` stays dynamic and is never
* reached under Electron.
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const result = await Bun.build({
entrypoints: [path.join(root, 'main.mjs')],
outdir: path.join(root, 'dist-bundle'),
target: 'node',
format: 'esm',
external: [
'electron',
'@openchamber/web',
'@openchamber/web/*',
'bun-pty',
'node-pty',
'better-sqlite3',
],
minify: false,
sourcemap: 'none',
naming: '[name].mjs',
});
if (!result.success) {
for (const msg of result.logs) console.error(msg);
process.exit(1);
}
console.log('[electron] main.mjs bundled -> dist-bundle/main.mjs');
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '../../..');
const electronDir = path.join(repoRoot, 'packages/electron');
function spawnProcess(command, args, options = {}) {
return spawn(command, args, {
cwd: repoRoot,
env: { ...process.env, OPENCHAMBER_ELECTRON_DEV: '1' },
stdio: 'inherit',
detached: process.platform !== 'win32',
...options,
});
}
function waitForExit(child, timeoutMs) {
return new Promise((resolve) => {
if (!child || child.exitCode !== null || child.signalCode !== null) {
resolve();
return;
}
const onExit = () => {
clearTimeout(timer);
resolve();
};
const timer = setTimeout(() => {
child.off('exit', onExit);
resolve();
}, timeoutMs);
child.once('exit', onExit);
});
}
function signalChild(child, signal) {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return;
}
try {
if (process.platform !== 'win32') {
process.kill(-child.pid, signal);
return;
}
} catch {
}
try {
child.kill(signal);
} catch {
}
}
async function stopChildTree(child) {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return;
}
signalChild(child, 'SIGINT');
await waitForExit(child, 2500);
if (child.exitCode === null && child.signalCode === null) {
signalChild(child, 'SIGTERM');
await waitForExit(child, 2500);
}
if (child.exitCode === null && child.signalCode === null) {
signalChild(child, 'SIGKILL');
await waitForExit(child, 1000);
}
}
async function main() {
const devServer = spawnProcess('node', ['./scripts/dev-web-hmr.mjs'], {
env: {
...process.env,
OPENCHAMBER_ELECTRON_DEV: '1',
OPENCHAMBER_HMR_UI_PORT: '5173',
OPENCHAMBER_HMR_API_PORT: '3901',
OPENCHAMBER_DISABLE_PWA_DEV: '1',
},
});
const electron = spawnProcess('npx', ['electron', './main.mjs'], { cwd: electronDir });
let cleaning = false;
const teardown = async (code) => {
if (cleaning) {
return;
}
cleaning = true;
await Promise.all([stopChildTree(electron), stopChildTree(devServer)]);
process.exit(typeof code === 'number' ? code : 0);
};
const onChildExit = (label) => (code, signal) => {
if (code !== 0 || signal) {
console.warn(`[electron:dev] ${label} exited with code ${code ?? 'null'} signal ${signal ?? 'none'}.`);
}
void teardown(code ?? 1);
};
devServer.on('exit', onChildExit('dev server'));
electron.on('exit', onChildExit('electron'));
devServer.on('error', (error) => {
console.error('[electron:dev] failed to start dev server:', error);
void teardown(1);
});
electron.on('error', (error) => {
console.error('[electron:dev] failed to start electron:', error);
void teardown(1);
});
for (const [signal, exitCode] of Object.entries({ SIGINT: 130, SIGTERM: 143, SIGQUIT: 131, SIGHUP: 129 })) {
process.on(signal, () => {
void teardown(exitCode);
});
}
}
main().catch((error) => {
console.error('[electron:dev] unexpected error:', error);
process.exit(1);
});
@@ -0,0 +1,113 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
const dir = process.env.LATEST_YML_DIR;
const repo = process.env.GH_REPO;
const version = process.env.OPENCHAMBER_VERSION;
if (!dir) throw new Error('LATEST_YML_DIR is required');
if (!repo) throw new Error('GH_REPO is required');
if (!version) throw new Error('OPENCHAMBER_VERSION is required');
const parse = (content) => {
const lines = content.split('\n');
let releaseDate = '';
let parsedVersion = '';
const files = [];
let current;
const flush = () => {
if (current?.url && current?.sha512 && current?.size) {
files.push(current);
}
current = undefined;
};
for (const line of lines) {
const trimmed = line.trim();
const indented = line.startsWith(' ') || line.startsWith(' -');
if (line.startsWith('version:')) {
parsedVersion = line.slice('version:'.length).trim();
} else if (line.startsWith('releaseDate:')) {
releaseDate = line.slice('releaseDate:'.length).trim().replace(/^'|'$/g, '');
} else if (trimmed.startsWith('- url:')) {
flush();
current = { url: trimmed.slice('- url:'.length).trim() };
} else if (indented && current && trimmed.startsWith('sha512:')) {
current.sha512 = trimmed.slice('sha512:'.length).trim();
} else if (indented && current && trimmed.startsWith('size:')) {
current.size = Number(trimmed.slice('size:'.length).trim());
} else if (indented && current && trimmed.startsWith('blockMapSize:')) {
current.blockMapSize = Number(trimmed.slice('blockMapSize:'.length).trim());
} else if (!indented && current) {
flush();
}
}
flush();
return { version: parsedVersion, releaseDate, files };
};
const serialize = (data) => {
const lines = [`version: ${data.version}`, 'files:'];
for (const file of data.files) {
lines.push(` - url: ${file.url}`);
lines.push(` sha512: ${file.sha512}`);
lines.push(` size: ${file.size}`);
if (file.blockMapSize) {
lines.push(` blockMapSize: ${file.blockMapSize}`);
}
}
lines.push(`releaseDate: '${data.releaseDate}'`);
return `${lines.join('\n')}\n`;
};
const read = async (subdir, filename) => {
const filePath = path.join(dir, subdir, filename);
try {
return parse(await fs.readFile(filePath, 'utf8'));
} catch {
return null;
}
};
const output = {};
const winX64 = await read('latest-yml-x86_64-pc-windows-msvc', 'latest.yml');
const winArm64 = await read('latest-yml-aarch64-pc-windows-msvc', 'latest.yml');
if (winX64 || winArm64) {
const base = winArm64 || winX64;
output['latest.yml'] = serialize({
version: base.version,
files: [...(winArm64?.files || []), ...(winX64?.files || [])],
releaseDate: base.releaseDate,
});
}
const linuxX64 = await read('latest-yml-x86_64-unknown-linux-gnu', 'latest-linux.yml');
if (linuxX64) output['latest-linux.yml'] = serialize(linuxX64);
const linuxArm64 = await read('latest-yml-aarch64-unknown-linux-gnu', 'latest-linux-arm64.yml');
if (linuxArm64) output['latest-linux-arm64.yml'] = serialize(linuxArm64);
const macX64 = await read('latest-yml-x86_64-apple-darwin', 'latest-mac.yml');
const macArm64 = await read('latest-yml-aarch64-apple-darwin', 'latest-mac.yml');
if (macX64 || macArm64) {
const base = macArm64 || macX64;
output['latest-mac.yml'] = serialize({
version: base.version,
files: [...(macArm64?.files || []), ...(macX64?.files || [])],
releaseDate: base.releaseDate,
});
}
const tag = `v${version}`;
const tmp = process.env.RUNNER_TEMP || '/tmp';
for (const [filename, content] of Object.entries(output)) {
const outputPath = path.join(tmp, filename);
await fs.writeFile(outputPath, content);
console.log(`prepared ${outputPath} for upload to ${repo} release ${tag}`);
}
console.log('finalized latest yml files');
@@ -0,0 +1,30 @@
#!/usr/bin/env node
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { rebuild } from '@electron/rebuild';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const electronDir = path.resolve(__dirname, '..');
const repoRoot = path.resolve(electronDir, '..', '..');
const require = createRequire(import.meta.url);
const electronPkg = require('electron/package.json');
const electronVersion = electronPkg.version;
console.log(`[electron] rebuilding native modules against Electron ${electronVersion}...`);
// Rebuild against the hoisted root node_modules (bun workspace layout).
// force=true re-links regardless of cached state; prebuild-install lookup is
// bypassed by @electron/rebuild in favor of direct node-gyp builds.
await rebuild({
buildPath: repoRoot,
electronVersion,
force: true,
arch: process.env.ELECTRON_BUILDER_ARCH || process.arch,
onlyModules: ['better-sqlite3', 'node-pty', 'bun-pty'],
});
console.log('[electron] native modules rebuilt successfully');
File diff suppressed because it is too large Load Diff
+35
View File
@@ -35,6 +35,7 @@ import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen';
import type { RecoveryVariant } from '@/components/onboarding/DesktopConnectionRecovery'; import type { RecoveryVariant } from '@/components/onboarding/DesktopConnectionRecovery';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import { SyncProvider, useSessions } from '@/sync/sync-context'; import { SyncProvider, useSessions } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync'; import { useSync } from '@/sync/use-sync';
@@ -452,6 +453,40 @@ function App({ apis }: AppProps) {
}; };
}, [embeddedSessionChat]); }, [embeddedSessionChat]);
React.useEffect(() => {
if (typeof window === 'undefined') return;
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ sessionId?: string }>).detail;
const sessionId = typeof detail?.sessionId === 'string' ? detail.sessionId.trim() : '';
if (!sessionId) return;
void useSessionUIStore.getState().setCurrentSession(sessionId);
};
window.addEventListener('openchamber:open-session', handler as EventListener);
return () => window.removeEventListener('openchamber:open-session', handler as EventListener);
}, []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ projectPath?: string }>).detail;
const projectPath = typeof detail?.projectPath === 'string' ? detail.projectPath.trim() : '';
if (!projectPath) return;
const projectsStore = useProjectsStore.getState();
const existing = projectsStore.projects.find((project) => project.path === projectPath);
if (existing) {
projectsStore.setActiveProject(existing.id);
} else {
projectsStore.addProject(projectPath);
}
};
window.addEventListener('openchamber:open-project', handler as EventListener);
return () => window.removeEventListener('openchamber:open-project', handler as EventListener);
}, []);
React.useEffect(() => { React.useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
if (!isInitialized || isSwitchingDirectory) return; if (!isInitialized || isSwitchingDirectory) return;
@@ -756,7 +756,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const composerHighlightRef = React.useRef<HTMLDivElement | null>(null); const composerHighlightRef = React.useRef<HTMLDivElement | null>(null);
const isDesktopExpanded = isExpandedInput && !isMobile; const isDesktopExpanded = isExpandedInput && !isMobile;
const chatInputRadius = 'var(--radius-lg)'; const chatInputRadius = 'var(--radius-xl)';
const sendableAttachedFiles = attachedFiles; const sendableAttachedFiles = attachedFiles;
@@ -966,7 +966,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const assistantTopPaddingClass = !isUser && shouldShowHeader const assistantTopPaddingClass = !isUser && shouldShowHeader
? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0') ? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0')
: 'pt-0'; : 'pt-0';
const userMessageRadius = 'var(--radius-lg)'; const userMessageRadius = 'var(--radius-xl)';
return ( return (
<> <>
@@ -21,7 +21,7 @@ const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, on
variant="outline" variant="outline"
size="sm" size="sm"
onClick={onClick} onClick={onClick}
className="rounded-full h-8 w-8 p-0 shadow-none bg-background/95 hover:bg-interactive-hover" className="size-8 rounded-full [corner-shape:round] p-0 shadow-none bg-background/95 hover:bg-interactive-hover"
aria-label="Scroll to bottom" aria-label="Scroll to bottom"
> >
<RiArrowDownLine className="h-4 w-4" /> <RiArrowDownLine className="h-4 w-4" />
@@ -549,6 +549,10 @@ export function DesktopHostSwitcherDialog({
setEditUrl(''); setEditUrl('');
}, []); }, []);
const stopDropdownTypeahead = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
event.stopPropagation();
}, []);
const commitEdit = React.useCallback(async () => { const commitEdit = React.useCallback(async () => {
if (!editingId) return; if (!editingId) return;
if (editingId === LOCAL_HOST_ID) { if (editingId === LOCAL_HOST_ID) {
@@ -972,12 +976,14 @@ export function DesktopHostSwitcherDialog({
<Input <Input
value={editLabel} value={editLabel}
onChange={(e) => setEditLabel(e.target.value)} onChange={(e) => setEditLabel(e.target.value)}
onKeyDown={stopDropdownTypeahead}
placeholder="Label" placeholder="Label"
disabled={isSaving} disabled={isSaving}
/> />
<Input <Input
value={editUrl} value={editUrl}
onChange={(e) => setEditUrl(e.target.value)} onChange={(e) => setEditUrl(e.target.value)}
onKeyDown={stopDropdownTypeahead}
placeholder="https://host:port" placeholder="https://host:port"
disabled={isSaving} disabled={isSaving}
/> />
@@ -1033,12 +1039,14 @@ export function DesktopHostSwitcherDialog({
<Input <Input
value={newLabel} value={newLabel}
onChange={(e) => setNewLabel(e.target.value)} onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={stopDropdownTypeahead}
placeholder="Label (optional)" placeholder="Label (optional)"
disabled={!tauriAvailable || isSaving} disabled={!tauriAvailable || isSaving}
/> />
<Input <Input
value={newUrl} value={newUrl}
onChange={(e) => setNewUrl(e.target.value)} onChange={(e) => setNewUrl(e.target.value)}
onKeyDown={stopDropdownTypeahead}
placeholder="https://host:port" placeholder="https://host:port"
disabled={!tauriAvailable || isSaving} disabled={!tauriAvailable || isSaving}
/> />
@@ -69,11 +69,10 @@ const AppIcon = ({
type OpenInAppButtonProps = { type OpenInAppButtonProps = {
directory: string; directory: string;
activeFilePath?: string | null;
className?: string; className?: string;
}; };
export const OpenInAppButton = ({ directory, activeFilePath, className }: OpenInAppButtonProps) => { export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps) => {
const selectedAppId = useOpenInAppsStore((state) => state.selectedAppId); const selectedAppId = useOpenInAppsStore((state) => state.selectedAppId);
const availableApps = useOpenInAppsStore((state) => state.availableApps); const availableApps = useOpenInAppsStore((state) => state.availableApps);
const isCacheStale = useOpenInAppsStore((state) => state.isCacheStale); const isCacheStale = useOpenInAppsStore((state) => state.isCacheStale);
@@ -108,7 +107,7 @@ export const OpenInAppButton = ({ directory, activeFilePath, className }: OpenIn
} }
const handleOpen = async (app: OpenInAppOption) => { const handleOpen = async (app: OpenInAppOption) => {
const opened = await openDesktopProjectInApp(directory, app.id, app.appName, activeFilePath); const opened = await openDesktopProjectInApp(directory, app.id, app.appName);
if (!opened) { if (!opened) {
await openDesktopPath(directory, app.appName); await openDesktopPath(directory, app.appName);
} }
+1 -10
View File
@@ -27,7 +27,6 @@ import { useAllLiveSessions, useSession, useSessionMessagesResolved } from '@/sy
import { getAllSyncSessions } from '@/sync/sync-refs'; import { getAllSyncSessions } from '@/sync/sync-refs';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useGitBranchLabel } from '@/stores/useGitStore'; import { useGitBranchLabel } from '@/stores/useGitStore';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
@@ -1105,14 +1104,6 @@ export const Header: React.FC<HeaderProps> = ({
}, [currentSessionDiffStats]); }, [currentSessionDiffStats]);
const hasNonZeroSessionChanges = currentSessionChanges.additions > 0 || currentSessionChanges.deletions > 0; const hasNonZeroSessionChanges = currentSessionChanges.additions > 0 || currentSessionChanges.deletions > 0;
const selectedFilePath = useFilesViewTabsStore((state) => {
const directory = normalize(openDirectory || '');
if (!directory) {
return null;
}
return state.byRoot[directory]?.selectedPath ?? null;
});
const actionDirectory = React.useMemo(() => { const actionDirectory = React.useMemo(() => {
return normalize(openDirectory || activeProject?.path || ''); return normalize(openDirectory || activeProject?.path || '');
}, [activeProject?.path, openDirectory]); }, [activeProject?.path, openDirectory]);
@@ -1643,7 +1634,7 @@ export const Header: React.FC<HeaderProps> = ({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
<OpenInAppButton directory={openDirectory} activeFilePath={selectedFilePath} className="mr-1" /> <OpenInAppButton directory={actionDirectory} className="mr-1" />
<DesktopServicesMenu <DesktopServicesMenu
isDesktopApp={isDesktopApp} isDesktopApp={isDesktopApp}
currentInstanceLabel={currentInstanceLabel} currentInstanceLabel={currentInstanceLabel}
@@ -21,10 +21,11 @@ import { BranchSelector, useBranchOptions } from './BranchSelector';
import { AgentSelector } from './AgentSelector'; import { AgentSelector } from './AgentSelector';
import { CommandAutocomplete, type CommandAutocompleteHandle } from '@/components/chat/CommandAutocomplete'; import { CommandAutocomplete, type CommandAutocompleteHandle } from '@/components/chat/CommandAutocomplete';
import { FileMentionAutocomplete, type FileMentionHandle } from '@/components/chat/FileMentionAutocomplete'; import { FileMentionAutocomplete, type FileMentionHandle } from '@/components/chat/FileMentionAutocomplete';
import { isDesktopShell, startDesktopWindowDrag } from '@/lib/desktop'; import { isDesktopShell } from '@/lib/desktop';
import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useThemeSystem } from '@/contexts/useThemeSystem';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta'; import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import type { ProjectEntry } from '@/lib/api/types'; import type { ProjectEntry } from '@/lib/api/types';
import { startDesktopWindowDrag } from '@/lib/desktopNative';
/** Max file size in bytes (10MB) */ /** Max file size in bytes (10MB) */
const MAX_FILE_SIZE = 10 * 1024 * 1024; const MAX_FILE_SIZE = 10 * 1024 * 1024;
@@ -569,64 +569,65 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
{todos.map((todo) => { {todos.map((todo) => {
const isExpandedTodo = expandedTodoIds.has(todo.id); const isExpandedTodo = expandedTodoIds.has(todo.id);
return ( return (
<li key={todo.id} className="flex items-start gap-1.5 px-2.5 py-1.5"> <li key={todo.id} className="flex items-start gap-1.5 px-2.5 py-1.5">
<Checkbox <div className="flex h-6 items-center">
checked={todo.completed} <Checkbox
onChange={(checked) => handleToggleTodo(todo.id, checked)} checked={todo.completed}
ariaLabel={`Mark "${todo.text}" complete`} onChange={(checked) => handleToggleTodo(todo.id, checked)}
className="mt-[3px] self-start" ariaLabel={`Mark "${todo.text}" complete`}
/> />
<button </div>
type="button"
onClick={() => handleToggleTodoExpanded(todo.id)}
className={cn(
'mt-[3px] min-w-0 flex-1 self-start bg-transparent p-0 text-left typography-ui-label text-foreground',
isExpandedTodo ? 'whitespace-normal break-words' : 'truncate',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
todo.completed && 'text-muted-foreground line-through'
)}
title={isExpandedTodo ? undefined : todo.text}
aria-label={isExpandedTodo ? `Collapse todo "${todo.text}"` : `Expand todo "${todo.text}"`}
>
{todo.text}
</button>
<div className="mt-0.5 flex self-start items-center gap-0.5">
<button <button
type="button" type="button"
onClick={() => handleDeleteTodo(todo.id)} onClick={() => handleToggleTodoExpanded(todo.id)}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50" className={cn(
aria-label={`Delete "${todo.text}"`} 'block min-h-6 min-w-0 flex-1 bg-transparent p-0 text-left typography-ui-label leading-6 text-foreground',
isExpandedTodo ? 'whitespace-normal break-words' : 'overflow-hidden text-ellipsis whitespace-nowrap',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
todo.completed && 'text-muted-foreground line-through'
)}
title={isExpandedTodo ? undefined : todo.text}
aria-label={isExpandedTodo ? `Collapse todo "${todo.text}"` : `Expand todo "${todo.text}"`}
> >
<RiDeleteBinLine className="h-3.5 w-3.5" /> {todo.text}
</button> </button>
<DropdownMenu> <div className="flex h-6 items-center gap-0.5">
<DropdownMenuTrigger asChild> <button
<button type="button"
type="button" onClick={() => handleDeleteTodo(todo.id)}
disabled={sendingTodoId === todo.id} className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50" aria-label={`Delete "${todo.text}"`}
aria-label={`Send "${todo.text}"`} >
> <RiDeleteBinLine className="h-3.5 w-3.5" />
<RiSendPlaneLine className="h-3.5 w-3.5" /> </button>
</button> <DropdownMenu>
</DropdownMenuTrigger> <DropdownMenuTrigger asChild>
<DropdownMenuContent align="end" className="w-56"> <button
<DropdownMenuItem onClick={() => handleSendToCurrentSession(todo.text)}> type="button"
Send to current session disabled={sendingTodoId === todo.id}
</DropdownMenuItem> className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
<DropdownMenuItem onClick={() => handleSendToNewSession(todo.id, todo.text)}> aria-label={`Send "${todo.text}"`}
Send to new session >
</DropdownMenuItem> <RiSendPlaneLine className="h-3.5 w-3.5" />
<DropdownMenuItem </button>
onClick={() => void handleSendToNewWorktreeSession(todo.id, todo.text)} </DropdownMenuTrigger>
disabled={!canCreateWorktree} <DropdownMenuContent align="end" className="w-56">
> <DropdownMenuItem onClick={() => handleSendToCurrentSession(todo.text)}>
Send to new worktree session Send to current session
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> <DropdownMenuItem onClick={() => handleSendToNewSession(todo.id, todo.text)}>
</DropdownMenu> Send to new session
</div> </DropdownMenuItem>
</li> <DropdownMenuItem
onClick={() => void handleSendToNewWorktreeSession(todo.id, todo.text)}
disabled={!canCreateWorktree}
>
Send to new worktree session
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
); );
})} })}
</ul> </ul>
@@ -3,7 +3,8 @@ import type { Session } from '@opencode-ai/sdk/v2';
import { RiLayoutLeftLine } from '@remixicon/react'; import { RiLayoutLeftLine } from '@remixicon/react';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, startDesktopWindowDrag } from '@/lib/desktop'; import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop';
import { isDesktopWindowFullscreen as getDesktopWindowFullscreen, onDesktopWindowResized, startDesktopWindowDrag } from '@/lib/desktopNative';
import { sessionEvents } from '@/lib/sessionEvents'; import { sessionEvents } from '@/lib/sessionEvents';
import { formatDirectoryName, cn } from '@/lib/utils'; import { formatDirectoryName, cn } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -403,9 +404,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const syncFullscreenState = async () => { const syncFullscreenState = async () => {
try { try {
const { getCurrentWindow } = await import('@tauri-apps/api/window'); const fullscreen = await getDesktopWindowFullscreen();
const currentWindow = getCurrentWindow();
const fullscreen = await currentWindow.isFullscreen();
if (!disposed) { if (!disposed) {
setIsDesktopWindowFullscreen(fullscreen); setIsDesktopWindowFullscreen(fullscreen);
} }
@@ -418,9 +417,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const attach = async () => { const attach = async () => {
try { try {
const { getCurrentWindow } = await import('@tauri-apps/api/window'); unlistenResize = onDesktopWindowResized(() => {
const currentWindow = getCurrentWindow();
unlistenResize = await currentWindow.onResized(() => {
void syncFullscreenState(); void syncFullscreenState();
}); });
} catch { } catch {
+31 -20
View File
@@ -80,8 +80,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes'; import { getDefaultTheme } from '@/lib/theme/themes';
import { openDesktopPath, openDesktopProjectInApp } from '@/lib/desktop'; import { openDesktopFileInApp, openDesktopPath } from '@/lib/desktop';
import { OPEN_DIRECTORY_APP_IDS } from '@/lib/openInApps';
import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore'; import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore';
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
@@ -510,17 +509,32 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [isSearchOpen, setIsSearchOpen] = React.useState(false); const [isSearchOpen, setIsSearchOpen] = React.useState(false);
const [isFloatingToolbarOpen, setIsFloatingToolbarOpen] = React.useState(false); const [isFloatingToolbarOpen, setIsFloatingToolbarOpen] = React.useState(false);
const floatingToolbarRef = React.useRef<HTMLDivElement | null>(null); const floatingToolbarRef = React.useRef<HTMLDivElement | null>(null);
const toolbarDropdownOpenCountRef = React.useRef(0);
const handleToolbarDropdownOpenChange = React.useCallback((open: boolean) => {
toolbarDropdownOpenCountRef.current = Math.max(
0,
toolbarDropdownOpenCountRef.current + (open ? 1 : -1),
);
}, []);
const isClickInsidePortalledMenu = React.useCallback((target: EventTarget | null) => {
if (!(target instanceof Element)) return false;
return target.closest('[data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-item"]') !== null;
}, []);
React.useEffect(() => { React.useEffect(() => {
if (!isFloatingToolbarOpen) return; if (!isFloatingToolbarOpen) return;
const handler = (event: MouseEvent) => { const handler = (event: MouseEvent) => {
if (toolbarDropdownOpenCountRef.current > 0) return;
if (isClickInsidePortalledMenu(event.target)) return;
if (floatingToolbarRef.current && !floatingToolbarRef.current.contains(event.target as Node)) { if (floatingToolbarRef.current && !floatingToolbarRef.current.contains(event.target as Node)) {
setIsFloatingToolbarOpen(false); setIsFloatingToolbarOpen(false);
} }
}; };
document.addEventListener('mousedown', handler); document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler);
}, [isFloatingToolbarOpen]); }, [isClickInsidePortalledMenu, isFloatingToolbarOpen]);
const [textViewMode, setTextViewMode] = React.useState<'view' | 'edit'>('edit'); const [textViewMode, setTextViewMode] = React.useState<'view' | 'edit'>('edit');
const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit'); const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit');
const [jsonViewMode, setJsonViewMode] = React.useState<'tree' | 'text'>('tree'); const [jsonViewMode, setJsonViewMode] = React.useState<'tree' | 'text'>('tree');
@@ -664,21 +678,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}, [files]); }, [files]);
const handleOpenInApp = React.useCallback(async (app: { id: string; appName: string }) => { const handleOpenInApp = React.useCallback(async (app: { id: string; appName: string }) => {
if (!selectedFile?.path || !root) { if (!selectedFile?.path) {
return; return;
} }
const fileDirectory = getParentDirectoryPath(selectedFile.path) || root; const openedInApp = await openDesktopFileInApp(selectedFile.path, app.id, app.appName);
if (OPEN_DIRECTORY_APP_IDS.has(app.id)) {
const openedDirectory = await openDesktopPath(fileDirectory, app.appName);
if (!openedDirectory) {
toast.error(`Failed to open in ${app.appName}`);
}
return;
}
const openedInApp = await openDesktopProjectInApp(root, app.id, app.appName, selectedFile.path);
if (openedInApp) { if (openedInApp) {
return; return;
} }
@@ -688,10 +692,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return; return;
} }
const openedDirectory = await openDesktopPath(fileDirectory, app.appName); const fileDirectory = getParentDirectoryPath(selectedFile.path) || root;
if (!openedDirectory) { if (fileDirectory) {
toast.error(`Failed to open in ${app.appName}`); const openedDirectory = await openDesktopPath(fileDirectory, app.appName);
if (openedDirectory) {
return;
}
} }
toast.error(`Failed to open in ${app.appName}`);
}, [root, selectedFile?.path]); }, [root, selectedFile?.path]);
const handleOpenDialog = React.useCallback((type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => { const handleOpenDialog = React.useCallback((type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => {
@@ -2354,7 +2362,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
) : null ) : null
)} )}
<DropdownMenu> <DropdownMenu onOpenChange={handleToolbarDropdownOpenChange}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
@@ -2758,7 +2766,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
ref={floatingToolbarRef} ref={floatingToolbarRef}
className="absolute right-3 top-3 z-30" className="absolute right-3 top-3 z-30"
onMouseEnter={() => setIsFloatingToolbarOpen(true)} onMouseEnter={() => setIsFloatingToolbarOpen(true)}
onMouseLeave={() => setIsFloatingToolbarOpen(false)} onMouseLeave={() => {
if (toolbarDropdownOpenCountRef.current > 0) return;
setIsFloatingToolbarOpen(false);
}}
> >
{isFloatingToolbarOpen ? ( {isFloatingToolbarOpen ? (
renderFloatingFileControls() renderFloatingFileControls()
+5 -5
View File
@@ -1562,9 +1562,9 @@ export const GitView: React.FC = () => {
[currentDirectory, git, isRevertingAll, refreshStatusAndBranches] [currentDirectory, git, isRevertingAll, refreshStatusAndBranches]
); );
const handleInsertHighlights = React.useCallback(() => { const handleInsertHighlights = React.useCallback((sourceHighlights: string[]) => {
if (generatedHighlights.length === 0) return; if (sourceHighlights.length === 0) return;
const normalizedHighlights = generatedHighlights const normalizedHighlights = sourceHighlights
.map((text) => text.trim()) .map((text) => text.trim())
.filter(Boolean); .filter(Boolean);
if (normalizedHighlights.length === 0) { if (normalizedHighlights.length === 0) {
@@ -1576,7 +1576,8 @@ export const GitView: React.FC = () => {
const separator = base.length > 0 ? '\n\n' : ''; const separator = base.length > 0 ? '\n\n' : '';
return `${base}${separator}${normalizedHighlights.join('\n')}`.trim(); return `${base}${separator}${normalizedHighlights.join('\n')}`.trim();
}); });
}, [generatedHighlights, clearGeneratedHighlights]); clearGeneratedHighlights();
}, [clearGeneratedHighlights]);
const handleSelectGitmoji = React.useCallback((emoji: string, code: string) => { const handleSelectGitmoji = React.useCallback((emoji: string, code: string) => {
const token = code || emoji; const token = code || emoji;
@@ -2093,7 +2094,6 @@ export const GitView: React.FC = () => {
onCommitMessageChange={setCommitMessage} onCommitMessageChange={setCommitMessage}
generatedHighlights={generatedHighlights} generatedHighlights={generatedHighlights}
onInsertHighlights={handleInsertHighlights} onInsertHighlights={handleInsertHighlights}
onClearHighlights={clearGeneratedHighlights}
onGenerateMessage={handleGenerateCommitMessage} onGenerateMessage={handleGenerateCommitMessage}
isGeneratingMessage={isGeneratingMessage} isGeneratingMessage={isGeneratingMessage}
onCommit={() => handleCommit({ pushAfter: false })} onCommit={() => handleCommit({ pushAfter: false })}
@@ -5,22 +5,19 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
interface AIHighlightsBoxProps { interface AIHighlightsBoxProps {
highlights: string[]; highlights: string[];
onInsert: () => void; onInsert: (highlights: string[]) => void;
onClear: () => void;
} }
export const AIHighlightsBox: React.FC<AIHighlightsBoxProps> = ({ export const AIHighlightsBox: React.FC<AIHighlightsBoxProps> = ({
highlights, highlights,
onInsert, onInsert,
onClear,
}) => { }) => {
if (highlights.length === 0) { if (highlights.length === 0) {
return null; return null;
} }
const handleInsert = () => { const handleInsert = () => {
onInsert(); onInsert(highlights);
onClear();
}; };
return ( return (
@@ -74,6 +74,10 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
const [isCreating, setIsCreating] = React.useState(false); const [isCreating, setIsCreating] = React.useState(false);
const createInputRef = React.useRef<HTMLInputElement>(null); const createInputRef = React.useRef<HTMLInputElement>(null);
const stopDropdownTypeahead = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
event.stopPropagation();
}, []);
const hasMultipleRemotes = remotes.length > 1; const hasMultipleRemotes = remotes.length > 1;
const sanitizedNewBranch = React.useMemo( const sanitizedNewBranch = React.useMemo(
@@ -250,7 +254,9 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
placeholder="New branch name" placeholder="New branch name"
value={newBranchName} value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)} onChange={(e) => setNewBranchName(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => { onKeyDown={(e) => {
stopDropdownTypeahead(e);
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
handleCreate(); handleCreate();
@@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
@@ -30,17 +29,30 @@ export const CommitInput: React.FC<CommitInputProps> = ({
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const textarea = textareaRef.current; const textarea = textareaRef.current;
if (!textarea) return; if (!textarea) return;
const hadFocus = document.activeElement === textarea;
// Reset height to measure scrollHeight accurately const resize = () => {
textarea.style.height = `${MIN_HEIGHT}px`; // Reset to baseline to measure content height from a stable line.
const contentHeight = textarea.scrollHeight; textarea.style.height = `${MIN_HEIGHT}px`;
const newHeight = Math.min(Math.max(contentHeight, MIN_HEIGHT), MAX_HEIGHT); const contentHeight = textarea.scrollHeight;
textarea.style.height = `${newHeight}px`; const newHeight = Math.min(Math.max(contentHeight, MIN_HEIGHT), MAX_HEIGHT);
textarea.style.overflowY = contentHeight > MAX_HEIGHT ? 'auto' : 'hidden'; textarea.style.height = `${newHeight}px`;
textarea.style.overflowY = contentHeight > MAX_HEIGHT ? 'auto' : 'hidden';
if (contentHeight > MAX_HEIGHT && !hadFocus) {
textarea.scrollTop = textarea.scrollHeight;
}
};
resize();
const frameId = window.requestAnimationFrame(resize);
return () => {
window.cancelAnimationFrame(frameId);
};
}, [value]); }, [value]);
return ( return (
<Textarea <textarea
ref={textareaRef} ref={textareaRef}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
@@ -50,9 +62,10 @@ export const CommitInput: React.FC<CommitInputProps> = ({
autoCorrect={hasTouchInput ? 'on' : 'off'} autoCorrect={hasTouchInput ? 'on' : 'off'}
autoCapitalize={hasTouchInput ? 'sentences' : 'off'} autoCapitalize={hasTouchInput ? 'sentences' : 'off'}
spellCheck={isMobile || inputSpellcheckEnabled} spellCheck={isMobile || inputSpellcheckEnabled}
scrollbarClassName="hidden"
className={cn( className={cn(
'rounded-lg bg-transparent resize-none overflow-y-hidden', 'w-full rounded-lg border border-border/60 bg-surface-elevated px-3 py-2 typography-ui-label text-foreground placeholder:text-muted-foreground',
'resize-none outline-none transition-[border-color,box-shadow] duration-150',
'focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
disabled && 'opacity-50' disabled && 'opacity-50'
)} )}
style={{ minHeight: MIN_HEIGHT, maxHeight: MAX_HEIGHT }} style={{ minHeight: MIN_HEIGHT, maxHeight: MAX_HEIGHT }}
@@ -18,8 +18,7 @@ interface CommitSectionProps {
commitMessage: string; commitMessage: string;
onCommitMessageChange: (value: string) => void; onCommitMessageChange: (value: string) => void;
generatedHighlights: string[]; generatedHighlights: string[];
onInsertHighlights: () => void; onInsertHighlights: (highlights: string[]) => void;
onClearHighlights: () => void;
onGenerateMessage: () => void; onGenerateMessage: () => void;
isGeneratingMessage: boolean; isGeneratingMessage: boolean;
onCommit: () => void; onCommit: () => void;
@@ -36,7 +35,6 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
onCommitMessageChange, onCommitMessageChange,
generatedHighlights, generatedHighlights,
onInsertHighlights, onInsertHighlights,
onClearHighlights,
onGenerateMessage, onGenerateMessage,
isGeneratingMessage, isGeneratingMessage,
onCommit, onCommit,
@@ -70,7 +68,6 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
<AIHighlightsBox <AIHighlightsBox
highlights={generatedHighlights} highlights={generatedHighlights}
onInsert={onInsertHighlights} onInsert={onInsertHighlights}
onClear={onClearHighlights}
/> />
<CommitInput <CommitInput
@@ -8,6 +8,7 @@ import { flushSync } from 'react-dom';
import type { Theme, ThemeMode } from '@/types/theme'; import type { Theme, ThemeMode } from '@/types/theme';
import type { DesktopSettings } from '@/lib/desktop'; import type { DesktopSettings } from '@/lib/desktop';
import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime } from '@/lib/desktop'; import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
import { setDesktopWindowTheme } from '@/lib/desktopNative';
import { CSSVariableGenerator } from '@/lib/theme/cssGenerator'; import { CSSVariableGenerator } from '@/lib/theme/cssGenerator';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { import {
@@ -579,26 +580,9 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
return; return;
} }
let cancelled = false;
void (async () => { void (async () => {
try { await setDesktopWindowTheme(preferences.themeMode, currentTheme.metadata.variant);
const { invoke } = await import('@tauri-apps/api/core');
if (cancelled) {
return;
}
await invoke('desktop_set_window_theme', {
themeMode: preferences.themeMode,
themeVariant: currentTheme.metadata.variant,
});
} catch {
// noop
}
})(); })();
return () => {
cancelled = true;
};
}, [currentTheme.metadata.variant, isDesktopShell, preferences.themeMode]); }, [currentTheme.metadata.variant, isDesktopShell, preferences.themeMode]);
useEffect(() => { useEffect(() => {
+43 -1
View File
@@ -11,6 +11,48 @@ import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { showOpenCodeStatus } from '@/lib/openCodeStatus'; import { showOpenCodeStatus } from '@/lib/openCodeStatus';
const getActiveElementSelectedText = (): string => {
if (typeof document === 'undefined') {
return '';
}
const activeElement = document.activeElement;
if (activeElement instanceof HTMLTextAreaElement) {
return activeElement.value.slice(activeElement.selectionStart ?? 0, activeElement.selectionEnd ?? 0);
}
if (activeElement instanceof HTMLInputElement) {
const type = activeElement.type?.toLowerCase() ?? 'text';
if (['text', 'search', 'url', 'tel', 'password'].includes(type)) {
return activeElement.value.slice(activeElement.selectionStart ?? 0, activeElement.selectionEnd ?? 0);
}
}
if (activeElement instanceof HTMLElement && activeElement.isContentEditable) {
return activeElement.ownerDocument.defaultView?.getSelection?.()?.toString() ?? '';
}
return '';
};
const copyCurrentSelectionFallback = async (): Promise<boolean> => {
const selectionText = getActiveElementSelectedText() || window.getSelection()?.toString() || '';
if (!selectionText.trim()) {
return false;
}
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(selectionText);
return true;
}
} catch {
// Fall through to execCommand fallback when Clipboard API is unavailable.
}
return document.execCommand('copy');
};
const MENU_ACTION_EVENT = 'openchamber:menu-action'; const MENU_ACTION_EVENT = 'openchamber:menu-action';
const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates'; const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates';
@@ -189,7 +231,7 @@ export const useMenuActions = (
const copyEvent = new Event('openchamber:copy', { cancelable: true }); const copyEvent = new Event('openchamber:copy', { cancelable: true });
const wasHandled = !window.dispatchEvent(copyEvent); const wasHandled = !window.dispatchEvent(copyEvent);
if (!wasHandled) { if (!wasHandled) {
document.execCommand('copy'); void copyCurrentSelectionFallback();
} }
break; break;
} }
+2 -13
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { isDesktopShell, isTauriShell } from '@/lib/desktop'; import { isDesktopShell, isTauriShell } from '@/lib/desktop';
import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts'; import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
import { setDesktopWindowTitle } from '@/lib/desktopNative';
const APP_TITLE = 'OpenChamber'; const APP_TITLE = 'OpenChamber';
@@ -105,31 +106,19 @@ export const useWindowTitle = () => {
return; return;
} }
let cancelled = false;
const applyTitle = async () => { const applyTitle = async () => {
try { try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
if (cancelled) {
return;
}
const isMac = typeof navigator !== 'undefined' && /Macintosh|Mac OS X/.test(navigator.userAgent || ''); const isMac = typeof navigator !== 'undefined' && /Macintosh|Mac OS X/.test(navigator.userAgent || '');
if (isMac) { if (isMac) {
return; return;
} }
const currentWindow = getCurrentWindow(); await setDesktopWindowTitle(title);
await currentWindow.setTitle(title);
} catch { } catch {
return; return;
} }
}; };
void applyTitle(); void applyTitle();
return () => {
cancelled = true;
};
}, [title]); }, [title]);
}; };
+74 -13
View File
@@ -169,12 +169,23 @@ type TauriGlobal = {
}; };
}; };
type ElectronRuntimeGlobal = {
runtime?: string;
};
const getElectronRuntime = (): ElectronRuntimeGlobal | null => {
if (typeof window === 'undefined') return null;
return (window as unknown as { __OPENCHAMBER_ELECTRON__?: ElectronRuntimeGlobal }).__OPENCHAMBER_ELECTRON__ ?? null;
};
export const isTauriShell = (): boolean => { export const isTauriShell = (): boolean => {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
return typeof tauri?.core?.invoke === 'function'; return typeof tauri?.core?.invoke === 'function';
}; };
export const isElectronShell = (): boolean => getElectronRuntime()?.runtime === 'electron';
const normalizeOrigin = (raw: string): string | null => { const normalizeOrigin = (raw: string): string | null => {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return null; if (!trimmed) return null;
@@ -212,6 +223,8 @@ const isLoopbackHost = (host: string): boolean => {
export const isDesktopLocalOriginActive = (): boolean => { export const isDesktopLocalOriginActive = (): boolean => {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
if (!isDesktopShell()) return false;
const local = typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' ? window.__OPENCHAMBER_LOCAL_ORIGIN__ : ''; const local = typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' ? window.__OPENCHAMBER_LOCAL_ORIGIN__ : '';
const localUrl = parseUrl(local); const localUrl = parseUrl(local);
const currentUrl = parseUrl(window.location.origin); const currentUrl = parseUrl(window.location.origin);
@@ -234,17 +247,16 @@ export const isDesktopLocalOriginActive = (): boolean => {
const localOrigin = normalizeOrigin(local); const localOrigin = normalizeOrigin(local);
const currentOrigin = normalizeOrigin(window.location.origin) || window.location.origin; const currentOrigin = normalizeOrigin(window.location.origin) || window.location.origin;
return Boolean(localOrigin && currentOrigin && localOrigin === currentOrigin); if (localOrigin && currentOrigin && localOrigin === currentOrigin) {
};
// Desktop shell detection that doesn't require Tauri IPC availability.
// (Remote pages can temporarily lose window.__TAURI__ if URL doesn't match remote allowlist.)
export const isDesktopShell = (): boolean => {
if (typeof window === 'undefined') return false;
if (typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' && window.__OPENCHAMBER_LOCAL_ORIGIN__.length > 0) {
return true; return true;
} }
return isTauriShell();
return Boolean(currentUrl && isLoopbackHost(currentUrl.hostname));
};
export const isDesktopShell = (): boolean => {
if (typeof window === 'undefined') return false;
return isTauriShell() || isElectronShell();
}; };
export const startDesktopWindowDrag = async (): Promise<boolean> => { export const startDesktopWindowDrag = async (): Promise<boolean> => {
@@ -518,6 +530,27 @@ export const openDesktopPath = async (path: string, app?: string | null): Promis
} }
}; };
export const revealDesktopPath = async (path: string): Promise<boolean> => {
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
return false;
}
const trimmed = path?.trim();
if (!trimmed) {
return false;
}
try {
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
await tauri?.core?.invoke?.('desktop_reveal_path', {
path: trimmed,
});
return true;
} catch {
return openDesktopPath(trimmed);
}
};
export const saveDesktopMarkdownFile = async ( export const saveDesktopMarkdownFile = async (
defaultFileName: string, defaultFileName: string,
content: string, content: string,
@@ -548,7 +581,6 @@ export const openDesktopProjectInApp = async (
projectPath: string, projectPath: string,
appId: string, appId: string,
appName: string, appName: string,
filePath?: string | null,
): Promise<boolean> => { ): Promise<boolean> => {
if (!isTauriShell() || !isDesktopLocalOriginActive()) { if (!isTauriShell() || !isDesktopLocalOriginActive()) {
return false; return false;
@@ -557,7 +589,6 @@ export const openDesktopProjectInApp = async (
const trimmedProjectPath = projectPath?.trim(); const trimmedProjectPath = projectPath?.trim();
const trimmedAppId = appId?.trim(); const trimmedAppId = appId?.trim();
const trimmedAppName = appName?.trim(); const trimmedAppName = appName?.trim();
const trimmedFilePath = typeof filePath === 'string' ? filePath.trim() : '';
if (!trimmedProjectPath || !trimmedAppId || !trimmedAppName) { if (!trimmedProjectPath || !trimmedAppId || !trimmedAppName) {
return false; return false;
@@ -569,11 +600,41 @@ export const openDesktopProjectInApp = async (
projectPath: trimmedProjectPath, projectPath: trimmedProjectPath,
appId: trimmedAppId, appId: trimmedAppId,
appName: trimmedAppName, appName: trimmedAppName,
filePath: trimmedFilePath.length > 0 ? trimmedFilePath : undefined,
}); });
return true; return true;
} catch (error) { } catch (error) {
console.warn('Failed to open project in app (tauri)', error); console.warn('Failed to open project in app', error);
return false;
}
};
export const openDesktopFileInApp = async (
filePath: string,
appId: string,
appName: string,
): Promise<boolean> => {
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
return false;
}
const trimmedFilePath = filePath?.trim();
const trimmedAppId = appId?.trim();
const trimmedAppName = appName?.trim();
if (!trimmedFilePath || !trimmedAppId || !trimmedAppName) {
return false;
}
try {
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
await tauri?.core?.invoke?.('desktop_open_file_in_app', {
filePath: trimmedFilePath,
appId: trimmedAppId,
appName: trimmedAppName,
});
return true;
} catch (error) {
console.warn('Failed to open file in app', error);
return false; return false;
} }
}; };
+162
View File
@@ -0,0 +1,162 @@
import { isDesktopShell } from '@/lib/desktop';
type InvokeArgs = Record<string, unknown>;
const isElectronDesktop = (): boolean => {
return typeof window !== 'undefined' && Boolean((window as { __OPENCHAMBER_ELECTRON__?: unknown }).__OPENCHAMBER_ELECTRON__);
};
const getInvoke = () => {
if (typeof window === 'undefined') {
return null;
}
const tauri = (window as unknown as {
__TAURI__?: { core?: { invoke?: (cmd: string, args?: InvokeArgs) => Promise<unknown> } };
}).__TAURI__;
return typeof tauri?.core?.invoke === 'function' ? tauri.core.invoke : null;
};
export const invokeDesktopCommand = async <TValue = unknown>(
command: string,
args?: InvokeArgs,
): Promise<TValue> => {
const invoke = getInvoke();
if (!invoke) {
throw new Error('Desktop runtime is not available');
}
return invoke(command, args) as Promise<TValue>;
};
export const startDesktopWindowDrag = async (): Promise<void> => {
if (!isDesktopShell()) {
return;
}
try {
if (isElectronDesktop()) {
await invokeDesktopCommand('desktop_start_window_drag');
return;
}
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().startDragging();
} catch {
// ignore
}
};
export const isDesktopWindowFullscreen = async (): Promise<boolean> => {
if (!isDesktopShell()) {
return false;
}
try {
if (!isElectronDesktop()) {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
return await getCurrentWindow().isFullscreen();
}
return Boolean(await invokeDesktopCommand('desktop_is_window_fullscreen'));
} catch {
return false;
}
};
export const onDesktopWindowResized = (handler: () => void): (() => void) => {
if (typeof window === 'undefined') {
return () => {};
}
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
};
export const setDesktopWindowTitle = async (title: string): Promise<void> => {
if (!isDesktopShell()) {
return;
}
try {
if (!isElectronDesktop()) {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().setTitle(title);
return;
}
await invokeDesktopCommand('desktop_set_window_title', { title });
} catch {
// ignore
}
};
export const setDesktopWindowTheme = async (
themeMode?: string,
themeVariant?: string,
): Promise<void> => {
if (!isDesktopShell()) {
return;
}
try {
if (!isElectronDesktop()) {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('desktop_set_window_theme', { themeMode, themeVariant });
return;
}
await invokeDesktopCommand('desktop_set_window_theme', { themeMode, themeVariant });
} catch {
// ignore
}
};
export const getDesktopAppVersion = async (): Promise<string | null> => {
if (!isDesktopShell()) {
return null;
}
try {
if (!isElectronDesktop()) {
const { getVersion } = await import('@tauri-apps/api/app');
return await getVersion();
}
const version = await invokeDesktopCommand('desktop_get_app_version');
return typeof version === 'string' && version.trim().length > 0 ? version : null;
} catch {
return null;
}
};
export const readDesktopFile = async (
path: string,
): Promise<{ mime: string; base64: string; size?: number }> => {
return invokeDesktopCommand('desktop_read_file', { path });
};
export const readDesktopFileAsDataUrl = async (path: string): Promise<string> => {
const result = await readDesktopFile(path);
return `data:${result.mime || 'application/octet-stream'};base64,${result.base64}`;
};
export const listenDesktopNativeDragDrop = async (
handler: (event: unknown) => void,
): Promise<(() => void) | null> => {
if (!isDesktopShell() || typeof window === 'undefined') {
return null;
}
// Electron uses the renderer's native DOM drag/drop events instead of a
// separate webview drag listener.
if (isElectronDesktop()) {
return null;
}
try {
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow');
const webviewWindow = getCurrentWebviewWindow();
return await webviewWindow.onDragDropEvent(handler as never);
} catch {
return null;
}
};
+7 -7
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { isTauriShell } from '@/lib/desktop'; import { isDesktopShell } from '@/lib/desktop';
export type DeviceType = 'desktop' | 'mobile' | 'tablet'; export type DeviceType = 'desktop' | 'mobile' | 'tablet';
@@ -82,7 +82,7 @@ export function getDeviceInfo(): DeviceInfo {
const noHover = hoverQuery?.matches ?? false; const noHover = hoverQuery?.matches ?? false;
const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0; const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0;
const isTauriShellRuntime = isTauriShell(); const isDesktopShellRuntime = isDesktopShell();
const hasTouchInput = prefersCoarsePointer || noHover || maxTouchPoints > 0; const hasTouchInput = prefersCoarsePointer || noHover || maxTouchPoints > 0;
@@ -94,7 +94,7 @@ export function getDeviceInfo(): DeviceInfo {
let isDesktop = !hasTouchInput || width > BREAKPOINTS.lg; let isDesktop = !hasTouchInput || width > BREAKPOINTS.lg;
let deviceType: DeviceType = 'desktop'; let deviceType: DeviceType = 'desktop';
if (isTauriShellRuntime) { if (isDesktopShellRuntime) {
isMobile = false; isMobile = false;
isTablet = false; isTablet = false;
isDesktop = true; isDesktop = true;
@@ -108,7 +108,7 @@ export function getDeviceInfo(): DeviceInfo {
deviceType = 'desktop'; deviceType = 'desktop';
} }
setRootDeviceAttributes(isTauriShellRuntime, deviceType, hasTouchInput); setRootDeviceAttributes(isDesktopShellRuntime, deviceType, hasTouchInput);
let breakpoint: keyof typeof BREAKPOINTS = 'xs'; let breakpoint: keyof typeof BREAKPOINTS = 'xs';
for (const [key, value] of Object.entries(BREAKPOINTS)) { for (const [key, value] of Object.entries(BREAKPOINTS)) {
@@ -131,7 +131,7 @@ export function getDeviceInfo(): DeviceInfo {
export function isMobileDeviceViaCSS(): boolean { export function isMobileDeviceViaCSS(): boolean {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
if (typeof window !== 'undefined' && isTauriShell()) { if (typeof window !== 'undefined' && isDesktopShell()) {
return false; return false;
} }
@@ -213,7 +213,7 @@ export function useDeviceInfo(): DeviceInfo {
React.useEffect(() => { React.useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const isTauriShellRuntime = isTauriShell(); const isDesktopShellRuntime = isDesktopShell();
const supportsMatchMedia = typeof window.matchMedia === 'function'; const supportsMatchMedia = typeof window.matchMedia === 'function';
const pointerQuery = supportsMatchMedia ? window.matchMedia('(pointer: coarse)') : null; const pointerQuery = supportsMatchMedia ? window.matchMedia('(pointer: coarse)') : null;
const hoverQuery = supportsMatchMedia ? window.matchMedia('(hover: none)') : null; const hoverQuery = supportsMatchMedia ? window.matchMedia('(hover: none)') : null;
@@ -221,7 +221,7 @@ export function useDeviceInfo(): DeviceInfo {
const noHover = hoverQuery?.matches ?? false; const noHover = hoverQuery?.matches ?? false;
const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0; const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0;
const hasTouchInput = prefersCoarsePointer || noHover || maxTouchPoints > 0; const hasTouchInput = prefersCoarsePointer || noHover || maxTouchPoints > 0;
setRootDeviceAttributes(isTauriShellRuntime, deviceInfo.deviceType, hasTouchInput); setRootDeviceAttributes(isDesktopShellRuntime, deviceInfo.deviceType, hasTouchInput);
}, [deviceInfo.deviceType, deviceInfo.hasTouchInput]); }, [deviceInfo.deviceType, deviceInfo.hasTouchInput]);
return deviceInfo; return deviceInfo;
+5 -1
View File
@@ -1,6 +1,6 @@
import type { Message, Part } from '@opencode-ai/sdk/v2'; import type { Message, Part } from '@opencode-ai/sdk/v2';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { isVSCodeRuntime, openDesktopPath, saveDesktopMarkdownFile } from '@/lib/desktop'; import { isVSCodeRuntime, openDesktopPath, revealDesktopPath, saveDesktopMarkdownFile } from '@/lib/desktop';
import { getRevealLabel } from '@/lib/utils'; import { getRevealLabel } from '@/lib/utils';
type SessionMessageRecord = { info: Message; parts: Part[] }; type SessionMessageRecord = { info: Message; parts: Part[] };
@@ -138,6 +138,10 @@ export async function revealExportedMarkdown(path: string): Promise<boolean> {
} }
} }
if (await revealDesktopPath(path)) {
return true;
}
return openDesktopPath(path); return openDesktopPath(path);
} }
+2 -2
View File
@@ -121,8 +121,8 @@ const sidebarBaseRgb = hexToRgb(theme.colors.surface.muted);
vars.push(` --sidebar-ring: ${theme.colors.interactive.focusRing} !important;`); vars.push(` --sidebar-ring: ${theme.colors.interactive.focusRing} !important;`);
const isDark = theme.metadata.variant === 'dark'; const isDark = theme.metadata.variant === 'dark';
const strongAlpha = isDark ? 0.35 : 0.75; const strongAlpha = isDark ? 0.15 : 0.5;
const softAlpha = isDark ? 0.2 : 0.6; const softAlpha = isDark ? 0.1 : 0.3;
if (sidebarBaseRgb) { if (sidebarBaseRgb) {
vars.push( vars.push(
+1
View File
@@ -5,6 +5,7 @@ declare global {
__OPENCHAMBER_HOME__?: string; __OPENCHAMBER_HOME__?: string;
__OPENCHAMBER_MACOS_MAJOR__?: number; __OPENCHAMBER_MACOS_MAJOR__?: number;
__OPENCHAMBER_LOCAL_ORIGIN__?: string; __OPENCHAMBER_LOCAL_ORIGIN__?: string;
__OPENCHAMBER_ELECTRON__?: { runtime?: string };
__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__?: DesktopBootOutcome; __OPENCHAMBER_DESKTOP_BOOT_OUTCOME__?: DesktopBootOutcome;
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'vitest';
import path from 'path'; import path from 'path';
import { pathToFileURL } from 'url'; import { pathToFileURL } from 'url';
+43 -40
View File
@@ -19,18 +19,41 @@
"build:watch": "vite build --watch", "build:watch": "vite build --watch",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js", "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js",
"test": "vitest run",
"start": "node bin/cli.js serve" "start": "node bin/cli.js serve"
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.1.0", "@clack/prompts": "^1.1.0",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.4.6",
"@simplewebauthn/server": "13.3.0",
"adm-zip": "^0.5.16",
"better-sqlite3": "^11.7.0",
"bun-pty": "^0.4.5",
"compression": "^1.8.1",
"cron-parser": "^4.9.0",
"express": "^5.1.0",
"http-proxy-middleware": "^3.0.5",
"jose": "^6.1.3",
"jsonc-parser": "^3.3.1",
"luxon": "^3.5.0",
"node-pty": "1.2.0-beta.12",
"openai": "^4.79.0",
"qrcode-terminal": "^0.12.0",
"reflect-metadata": "^0.2.2",
"simple-git": "^3.28.0",
"web-push": "^3.6.7",
"ws": "^8.18.3",
"yaml": "^2.8.1"
},
"devDependencies": {
"@base-ui/react": "^1.4.0",
"@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-go": "^6.0.1", "@codemirror/lang-go": "^6.0.1",
"@eslint/js": "^9.33.0",
"@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.4.6",
"@base-ui/react": "^1.4.0",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -42,57 +65,35 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0", "@remixicon/react": "^4.7.0",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.0",
"@types/react-syntax-highlighter": "^15.5.13",
"adm-zip": "^0.5.16",
"bun-pty": "^0.4.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"compression": "^1.8.1",
"cron-parser": "^4.9.0",
"express": "^5.1.0",
"ghostty-web": "0.3.0",
"http-proxy-middleware": "^3.0.5",
"jose": "^6.1.3",
"jsonc-parser": "^3.3.1",
"luxon": "^3.5.0",
"next-themes": "^0.4.6",
"node-pty": "1.2.0-beta.12",
"openai": "^4.79.0",
"qrcode-terminal": "^0.12.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6",
"reflect-metadata": "^0.2.2",
"remark-gfm": "^4.0.1",
"simple-git": "^3.28.0",
"sonner": "^2.0.7",
"strip-json-comments": "^5.0.3",
"tailwind-merge": "^3.3.1",
"web-push": "^3.6.7",
"ws": "^8.18.3",
"yaml": "^2.8.1",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@tailwindcss/postcss": "^4.0.0", "@tailwindcss/postcss": "^4.0.0",
"@types/adm-zip": "^0.5.7", "@types/adm-zip": "^0.5.7",
"@types/node": "^24.3.1", "@types/node": "^24.3.1",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^9.33.0", "eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"ghostty-web": "0.3.0",
"globals": "^16.3.0", "globals": "^16.3.0",
"next-themes": "^0.4.6",
"nodemon": "^3.1.7", "nodemon": "^3.1.7",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"strip-json-comments": "^5.0.3",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"tw-animate-css": "^1.3.8", "tw-animate-css": "^1.3.8",
@@ -100,7 +101,9 @@
"typescript-eslint": "^8.39.1", "typescript-eslint": "^8.39.1",
"vite": "^7.1.2", "vite": "^7.1.2",
"vite-plugin-pwa": "^1.0.3", "vite-plugin-pwa": "^1.0.3",
"workbox-window": "^7.4.0" "vitest": "^3.0.5",
"workbox-window": "^7.4.0",
"zustand": "^5.0.8"
}, },
"files": [ "files": [
"dist", "dist",
+3
View File
@@ -1008,6 +1008,9 @@ async function main(options = {}) {
if (typeof options.exitOnShutdown === 'boolean') { if (typeof options.exitOnShutdown === 'boolean') {
exitOnShutdown = options.exitOnShutdown; exitOnShutdown = options.exitOnShutdown;
} }
if (typeof options.onDesktopNotification === 'function') {
notificationEmitterRuntime.setOnDesktopNotification(options.onDesktopNotification);
}
console.log(`Starting OpenChamber on port ${port === 0 ? 'auto' : port}`); console.log(`Starting OpenChamber on port ${port === 0 ? 'auto' : port}`);
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'vitest';
import { import {
MESSAGE_STREAM_DIRECTORY_WS_PATH, MESSAGE_STREAM_DIRECTORY_WS_PATH,
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'vitest';
import { createGlobalUiEventBroadcaster } from './runtime.js'; import { createGlobalUiEventBroadcaster } from './runtime.js';
+3 -3
View File
@@ -822,15 +822,15 @@ const getProjectStoragePath = (projectID) => {
const syncSandboxesToOpenCodeDb = (projectID, sandboxes) => { const syncSandboxesToOpenCodeDb = (projectID, sandboxes) => {
try { try {
const { Database } = require('bun:sqlite'); const Database = require('better-sqlite3');
const dbPath = path.join(getOpenCodeDataPath(), 'opencode.db'); const dbPath = path.join(getOpenCodeDataPath(), 'opencode.db');
if (!fs.existsSync(dbPath)) return; if (!fs.existsSync(dbPath)) return;
const db = new Database(dbPath); const db = new Database(dbPath);
try { try {
const row = db.query('SELECT sandboxes FROM project WHERE id = ?').get(projectID); const row = db.prepare('SELECT sandboxes FROM project WHERE id = ?').get(projectID);
if (!row) return; if (!row) return;
const json = JSON.stringify(sandboxes); const json = JSON.stringify(sandboxes);
db.query('UPDATE project SET sandboxes = ?, time_updated = ? WHERE id = ?').run(json, Date.now(), projectID); db.prepare('UPDATE project SET sandboxes = ?, time_updated = ? WHERE id = ?').run(json, Date.now(), projectID);
} finally { } finally {
db.close(); db.close();
} }
@@ -5,8 +5,23 @@ export const createNotificationEmitterRuntime = (dependencies) => {
desktopNotifyPrefix, desktopNotifyPrefix,
getUiNotificationClients, getUiNotificationClients,
getBroadcastGlobalUiEvent, getBroadcastGlobalUiEvent,
// Optional: in-process desktop shells (Electron main) inject a callback so
// notifications are delivered as a direct function call instead of a stdout
// stringly-typed IPC.
onDesktopNotification: initialOnDesktopNotification,
} = dependencies; } = dependencies;
// Late-bindable: main() in server/index.js may call setOnDesktopNotification
// after runtime construction so the in-process shell can subscribe without
// restructuring the module-level wiring.
let onDesktopNotification = typeof initialOnDesktopNotification === 'function'
? initialOnDesktopNotification
: null;
const setOnDesktopNotification = (cb) => {
onDesktopNotification = typeof cb === 'function' ? cb : null;
};
const writeSseEvent = (res, payload) => { const writeSseEvent = (res, payload) => {
res.write(`data: ${JSON.stringify(payload)}\n\n`); res.write(`data: ${JSON.stringify(payload)}\n\n`);
}; };
@@ -21,8 +36,18 @@ export const createNotificationEmitterRuntime = (dependencies) => {
return; return;
} }
if (onDesktopNotification) {
try {
onDesktopNotification(payload);
} catch {
// ignore host-side throw
}
return;
}
try { try {
// One-line protocol consumed by the Tauri shell. // stdout IPC: Tauri shell spawns this process as a sidecar and parses
// its stdout for the one-line `${prefix}{json}` protocol.
process.stdout.write(`${desktopNotifyPrefix}${JSON.stringify(payload)}\n`); process.stdout.write(`${desktopNotifyPrefix}${JSON.stringify(payload)}\n`);
} catch { } catch {
// ignore // ignore
@@ -72,5 +97,6 @@ export const createNotificationEmitterRuntime = (dependencies) => {
writeSseEvent, writeSseEvent,
emitDesktopNotification, emitDesktopNotification,
broadcastUiNotification, broadcastUiNotification,
setOnDesktopNotification,
}; };
}; };
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'vitest';
import { prepareNotificationLastMessage, truncateNotificationText } from './message.js'; import { prepareNotificationLastMessage, truncateNotificationText } from './message.js';
@@ -62,6 +62,21 @@ export const createProjectDirectoryRuntime = (dependencies) => {
? getReadSettingsFromDiskMigrated() ? getReadSettingsFromDiskMigrated()
: readSettingsFromDiskMigrated; : readSettingsFromDiskMigrated;
const settings = await readSettings(); const settings = await readSettings();
// `lastDirectory` reflects the directory the UI is currently browsing —
// useDirectoryStore.setDirectory() persists it on every navigation.
// Prefer it over activeProjectId, because the user may have navigated
// away from the project that was last "clicked" in the sidebar (e.g. via
// `go to parent`, directory picker, or a deep link), leaving
// activeProjectId stale. Fetches scoped to the stale project would 400
// with "Path is outside of active workspace".
if (typeof settings.lastDirectory === 'string' && settings.lastDirectory.trim()) {
const validated = await validateDirectoryPath(settings.lastDirectory);
if (validated.ok) {
return { directory: validated.directory, error: null };
}
}
const projects = sanitizeProjects(settings.projects) || []; const projects = sanitizeProjects(settings.projects) || [];
if (projects.length === 0) { if (projects.length === 0) {
return { directory: null, error: 'Directory parameter or active project is required' }; return { directory: null, error: 'Directory parameter or active project is required' };
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'bun:test'; import { afterEach, describe, expect, it } from 'vitest';
import { createSessionRuntime } from './session-runtime.js'; import { createSessionRuntime } from './session-runtime.js';
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'vitest';
import { createSettingsHelpers } from './settings-helpers.js'; import { createSettingsHelpers } from './settings-helpers.js';
@@ -48,6 +48,14 @@ export const createSettingsRuntime = (deps) => {
let persistSettingsLock = Promise.resolve(); let persistSettingsLock = Promise.resolve();
// Orphan recovery is a one-shot best-effort scan: when orphans can't be
// matched on first pass they stay on disk and every subsequent settings
// read would re-scan them. In-process (Electron) this runs in the main
// event loop, so hitting it 3+ times/second from fs/list, path, etc.
// turns into perceptible UI jank for ~10-15 seconds after launch.
// Cache the outcome for this process lifetime.
let orphanRecoveryDone = false;
const PROJECTS_ROOT_DIR = path.join(path.dirname(SETTINGS_FILE_PATH), 'projects'); const PROJECTS_ROOT_DIR = path.join(path.dirname(SETTINGS_FILE_PATH), 'projects');
const PROJECT_ICONS_DIR = path.join(path.dirname(SETTINGS_FILE_PATH), 'project-icons'); const PROJECT_ICONS_DIR = path.join(path.dirname(SETTINGS_FILE_PATH), 'project-icons');
@@ -250,10 +258,13 @@ export const createSettingsRuntime = (deps) => {
nextProjects.push({ ...project, id: nextId }); nextProjects.push({ ...project, id: nextId });
} }
try { if (!orphanRecoveryDone) {
await recoverOrphanProjectFiles(nextProjects); orphanRecoveryDone = true; // set before await to close races under concurrent settings reads
} catch (error) { try {
console.warn('[projects] Orphan recovery failed, continuing startup:', error); await recoverOrphanProjectFiles(nextProjects);
} catch (error) {
console.warn('[projects] Orphan recovery failed, continuing startup:', error);
}
} }
if (!changed) { if (!changed) {
@@ -327,6 +327,22 @@ function packageManagerOwnsCurrentInstall(pm) {
} }
export function detectPackageManagerDetails() { export function detectPackageManagerDetails() {
// In desktop (Electron) runtime, package-manager detection is worthless —
// the app ships as a .app bundle, not installed via npm/pnpm/yarn/bun, and
// updates are handled by electron-updater. The detection path does up to a
// dozen spawnSync(pm, ['bin', '-g']) calls with 10s timeouts each; under
// the in-process server every one blocks the Electron main event loop and
// manifests as a multi-second UI freeze. Short-circuit here.
if (process.env.OPENCHAMBER_RUNTIME === 'desktop') {
return {
packageManager: 'electron',
reason: 'desktop-runtime',
packagePath: null,
packageManagerCommand: null,
globalNodeModulesRoot: null,
};
}
if (cachedDetectedPm) { if (cachedDetectedPm) {
return { return {
packageManager: cachedDetectedPm, packageManager: cachedDetectedPm,
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'vitest';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { mkdtemp, rm, readFile, writeFile } from 'fs/promises'; import { mkdtemp, rm, readFile, writeFile } from 'fs/promises';
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'vitest';
import { computeNextRunAt, formatScheduledSessionTitle, parseScheduledCommandPrompt } from './runtime.js'; import { computeNextRunAt, formatScheduledSessionTitle, parseScheduledCommandPrompt } from './runtime.js';
describe('scheduled-tasks runtime helpers', () => { describe('scheduled-tasks runtime helpers', () => {
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'vitest';
import { import {
TERMINAL_OUTPUT_REPLAY_MAX_BYTES, TERMINAL_OUTPUT_REPLAY_MAX_BYTES,
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'vitest';
import { import {
TERMINAL_WS_PATH, TERMINAL_WS_PATH,
+1 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'bun:test'; import { afterEach, describe, expect, it } from 'vitest';
import express from 'express'; import express from 'express';
import path from 'path'; import path from 'path';
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'vitest';
import { import {
applyForwardProxyResponseHeaders, applyForwardProxyResponseHeaders,
+1
View File
@@ -11,6 +11,7 @@ const PACKAGES = [
'packages/ui/package.json', 'packages/ui/package.json',
'packages/web/package.json', 'packages/web/package.json',
'packages/desktop/package.json', 'packages/desktop/package.json',
'packages/electron/package.json',
'packages/vscode/package.json', 'packages/vscode/package.json',
]; ];