* 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.
2196 lines
73 KiB
JavaScript
2196 lines
73 KiB
JavaScript
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, Notification, session, shell } from 'electron';
|
||
import contextMenu from 'electron-context-menu';
|
||
import log from 'electron-log/main.js';
|
||
import dgram from 'node:dgram';
|
||
import fs from 'node:fs';
|
||
import fsp from 'node:fs/promises';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { execFile, spawn, spawnSync } from 'node:child_process';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { promisify } from 'node:util';
|
||
import updaterPkg from 'electron-updater';
|
||
import { ElectronSshManager } from './ssh-manager.mjs';
|
||
|
||
const execFileAsync = promisify(execFile);
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = path.dirname(__filename);
|
||
const isDev = process.env.OPENCHAMBER_ELECTRON_DEV === '1' || !app.isPackaged;
|
||
|
||
const DEEP_LINK_PROTOCOL = 'openchamber';
|
||
const APP_USER_MODEL_ID = 'dev.openchamber.desktop';
|
||
|
||
if (!app.requestSingleInstanceLock()) {
|
||
app.exit(0);
|
||
process.exit(0);
|
||
}
|
||
|
||
// Set the product name early so electron-log derives its log directory as
|
||
// ~/Library/Logs/OpenChamber/ (not ~/Library/Logs/@openchamber/electron/).
|
||
app.setName('OpenChamber');
|
||
app.setAppUserModelId(APP_USER_MODEL_ID);
|
||
app.commandLine.appendSwitch('proxy-bypass-list', '<-loopback>');
|
||
|
||
try {
|
||
process.chdir(os.homedir());
|
||
} catch {
|
||
}
|
||
|
||
log.initialize();
|
||
log.transports.file.maxSize = 5 * 1024 * 1024;
|
||
log.transports.file.level = 'info';
|
||
log.transports.console.level = isDev ? 'debug' : 'warn';
|
||
|
||
const LOG_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||
try {
|
||
const logPath = log.transports.file.getFile().path;
|
||
const logDir = path.dirname(logPath);
|
||
const cutoff = Date.now() - LOG_MAX_AGE_MS;
|
||
for (const entry of fs.readdirSync(logDir)) {
|
||
const candidate = path.join(logDir, entry);
|
||
try {
|
||
const info = fs.statSync(candidate);
|
||
if (info.isFile() && info.mtimeMs < cutoff) {
|
||
fs.unlinkSync(candidate);
|
||
}
|
||
} catch {
|
||
}
|
||
}
|
||
} catch {
|
||
}
|
||
|
||
try {
|
||
if (!app.isDefaultProtocolClient(DEEP_LINK_PROTOCOL)) {
|
||
app.setAsDefaultProtocolClient(DEEP_LINK_PROTOCOL);
|
||
}
|
||
} catch (error) {
|
||
// log.* not yet initialized at this point; fall back to console.
|
||
console.warn('[electron] failed to register deep-link protocol:', error);
|
||
}
|
||
|
||
const readAppMetadata = () => {
|
||
const candidates = [
|
||
path.join(__dirname, 'package.json'),
|
||
path.join(__dirname, '..', 'package.json'),
|
||
path.join(app.getAppPath?.() || '', 'package.json'),
|
||
].filter(Boolean);
|
||
for (const candidate of candidates) {
|
||
try {
|
||
const raw = fs.readFileSync(candidate, 'utf8');
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed?.name === '@openchamber/electron' && typeof parsed.version === 'string') {
|
||
return { name: parsed.name, version: parsed.version };
|
||
}
|
||
} catch {
|
||
}
|
||
}
|
||
return { name: '@openchamber/electron', version: app.getVersion() };
|
||
};
|
||
|
||
const APP_METADATA = readAppMetadata();
|
||
const APP_VERSION = APP_METADATA.version;
|
||
|
||
const DEFAULT_DESKTOP_PORT = 57123;
|
||
const MIN_WINDOW_WIDTH = 800;
|
||
const MIN_WINDOW_HEIGHT = 520;
|
||
const MIN_RESTORE_WINDOW_WIDTH = 900;
|
||
const MIN_RESTORE_WINDOW_HEIGHT = 560;
|
||
const LOCAL_HOST_ID = 'local';
|
||
const ENV_OVERRIDE_HOST_ID = '__env';
|
||
const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md';
|
||
const UPDATE_METADATA_URL = 'https://github.com/btriapitsyn/openchamber/releases/latest/download/latest.json';
|
||
const GITHUB_BUG_REPORT_URL = 'https://github.com/btriapitsyn/openchamber/issues/new?template=bug_report.yml';
|
||
const GITHUB_FEATURE_REQUEST_URL = 'https://github.com/btriapitsyn/openchamber/issues/new?template=feature_request.yml';
|
||
const DISCORD_INVITE_URL = 'https://discord.gg/ZYRSdnwwKA';
|
||
const INSTALLED_APPS_CACHE_TTL_SECS = 60 * 60 * 24;
|
||
const INSTALLED_APPS_CACHE_FILE = 'discovered-apps.json';
|
||
|
||
const { autoUpdater } = updaterPkg;
|
||
|
||
const state = {
|
||
serverHandle: null,
|
||
sidecarUrl: null,
|
||
localOrigin: null,
|
||
bootOutcome: null,
|
||
initScript: null,
|
||
mainWindow: null,
|
||
quitRequested: false,
|
||
quitConfirmed: false,
|
||
quitConfirmationPending: false,
|
||
quitRiskPollerStarted: false,
|
||
pendingUpdate: null,
|
||
unreachableHosts: new Set(),
|
||
windowCounter: 1,
|
||
focusedWindowIds: new Set(),
|
||
windowGeometryRevisions: new Map(),
|
||
sshStatuses: new Map(),
|
||
sshLogs: new Map(),
|
||
};
|
||
|
||
const QUIT_RISK_POLL_INTERVAL_MS = 5_000;
|
||
const quitRisk = {
|
||
hasActiveTunnel: false,
|
||
hasRunningScheduledTasks: false,
|
||
hasEnabledScheduledTasks: false,
|
||
runningScheduledTasksCount: 0,
|
||
enabledScheduledTasksCount: 0,
|
||
};
|
||
|
||
const shouldRequireQuitConfirmation = () =>
|
||
quitRisk.hasActiveTunnel
|
||
|| quitRisk.hasRunningScheduledTasks
|
||
|| quitRisk.hasEnabledScheduledTasks;
|
||
|
||
const quitConfirmationMessage = () => {
|
||
const reasons = [];
|
||
if (quitRisk.hasActiveTunnel) {
|
||
reasons.push('an active tunnel');
|
||
}
|
||
if (quitRisk.runningScheduledTasksCount > 0) {
|
||
reasons.push(`${quitRisk.runningScheduledTasksCount} running scheduled task${quitRisk.runningScheduledTasksCount === 1 ? '' : 's'}`);
|
||
}
|
||
if (quitRisk.enabledScheduledTasksCount > 0) {
|
||
reasons.push(`${quitRisk.enabledScheduledTasksCount} enabled scheduled task${quitRisk.enabledScheduledTasksCount === 1 ? '' : 's'}`);
|
||
}
|
||
if (reasons.length === 0) {
|
||
return 'Background processes (sidecar, SSH sessions) will be stopped.';
|
||
}
|
||
return `OpenChamber detected ${reasons.join(', ')}. Quitting now will stop sidecar/background processes and may interrupt pending work.`;
|
||
};
|
||
|
||
const performConfirmedQuit = () => {
|
||
if (state.quitConfirmed) return;
|
||
state.quitConfirmed = true;
|
||
state.quitRequested = true;
|
||
|
||
if (state.mainWindow && !state.mainWindow.isDestroyed()) {
|
||
try {
|
||
debounceWindowStatePersist(state.mainWindow, true);
|
||
} catch {
|
||
}
|
||
}
|
||
|
||
try {
|
||
killSidecar();
|
||
} catch {
|
||
}
|
||
void sshManager.shutdownAll().catch(() => {});
|
||
|
||
// Safety net: force-exit if normal quit sequence stalls (e.g. background
|
||
// handles in electron-updater / fetch refs) after a short grace period.
|
||
const safety = setTimeout(() => {
|
||
app.exit(0);
|
||
}, 1500);
|
||
if (typeof safety?.unref === 'function') safety.unref();
|
||
|
||
app.quit();
|
||
};
|
||
|
||
const requestQuitWithConfirmation = async () => {
|
||
if (!shouldRequireQuitConfirmation()) {
|
||
performConfirmedQuit();
|
||
return;
|
||
}
|
||
|
||
if (state.quitConfirmationPending) {
|
||
return;
|
||
}
|
||
state.quitConfirmationPending = true;
|
||
|
||
const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed());
|
||
const visible = windows.find((window) => window.isVisible());
|
||
if (!visible) {
|
||
const hidden = windows.find((window) => !window.isVisible());
|
||
if (hidden) {
|
||
hidden.show();
|
||
hidden.focus();
|
||
}
|
||
}
|
||
|
||
try {
|
||
const result = await dialog.showMessageBox({
|
||
type: 'warning',
|
||
title: 'Quit OpenChamber?',
|
||
message: 'Quit OpenChamber?',
|
||
detail: quitConfirmationMessage(),
|
||
buttons: ['Quit', 'Cancel'],
|
||
defaultId: 1,
|
||
cancelId: 1,
|
||
});
|
||
state.quitConfirmationPending = false;
|
||
if (result.response === 0) {
|
||
performConfirmedQuit();
|
||
}
|
||
} catch (error) {
|
||
state.quitConfirmationPending = false;
|
||
log.warn('[electron] quit confirmation dialog failed:', error);
|
||
}
|
||
};
|
||
|
||
const refreshQuitRiskFlags = async () => {
|
||
const base = typeof state.sidecarUrl === 'string' ? state.sidecarUrl.trim().replace(/\/$/, '') : '';
|
||
if (!base) return;
|
||
|
||
const scheduledUrl = `${base}/api/openchamber/scheduled-tasks/status`;
|
||
const tunnelUrl = `${base}/api/openchamber/tunnel/status`;
|
||
|
||
const fetchJson = async (url) => {
|
||
try {
|
||
const response = await fetch(url, { signal: AbortSignal.timeout(2_000) });
|
||
if (!response.ok) return null;
|
||
return await response.json();
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const [scheduled, tunnel] = await Promise.all([fetchJson(scheduledUrl), fetchJson(tunnelUrl)]);
|
||
|
||
if (scheduled && typeof scheduled === 'object') {
|
||
const enabledCount = Number(scheduled.enabledScheduledTasksCount ?? 0);
|
||
const runningCount = Number(scheduled.runningScheduledTasksCount ?? 0);
|
||
quitRisk.enabledScheduledTasksCount = Number.isFinite(enabledCount) ? enabledCount : 0;
|
||
quitRisk.runningScheduledTasksCount = Number.isFinite(runningCount) ? runningCount : 0;
|
||
quitRisk.hasEnabledScheduledTasks = Boolean(scheduled.hasEnabledScheduledTasks) || quitRisk.enabledScheduledTasksCount > 0;
|
||
quitRisk.hasRunningScheduledTasks = Boolean(scheduled.hasRunningScheduledTasks) || quitRisk.runningScheduledTasksCount > 0;
|
||
}
|
||
|
||
if (tunnel && typeof tunnel === 'object') {
|
||
quitRisk.hasActiveTunnel = Boolean(tunnel.active);
|
||
}
|
||
};
|
||
|
||
const startQuitRiskPoller = () => {
|
||
if (process.platform !== 'darwin') return;
|
||
if (state.quitRiskPollerStarted) return;
|
||
state.quitRiskPollerStarted = true;
|
||
|
||
const loop = async () => {
|
||
while (!state.quitConfirmed && !state.quitRequested) {
|
||
await refreshQuitRiskFlags();
|
||
if (state.quitConfirmed || state.quitRequested) break;
|
||
await new Promise((resolve) => {
|
||
const timer = setTimeout(resolve, QUIT_RISK_POLL_INTERVAL_MS);
|
||
if (typeof timer?.unref === 'function') timer.unref();
|
||
});
|
||
}
|
||
};
|
||
void loop();
|
||
};
|
||
|
||
const settingsFilePath = () => {
|
||
if (typeof process.env.OPENCHAMBER_DATA_DIR === 'string' && process.env.OPENCHAMBER_DATA_DIR.trim()) {
|
||
return path.join(process.env.OPENCHAMBER_DATA_DIR.trim(), 'settings.json');
|
||
}
|
||
return path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
|
||
};
|
||
|
||
const sshManager = new ElectronSshManager({
|
||
settingsFilePath: settingsFilePath(),
|
||
appVersion: APP_VERSION,
|
||
emit: (event, detail) => emitToAllWindows(event, detail),
|
||
});
|
||
|
||
const readJsonFile = (filePath) => {
|
||
try {
|
||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||
} catch {
|
||
return {};
|
||
}
|
||
};
|
||
|
||
const writeJsonFile = async (filePath, data) => {
|
||
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
||
await fsp.writeFile(filePath, JSON.stringify(data, null, 2));
|
||
};
|
||
|
||
const readSettingsRoot = () => {
|
||
const root = readJsonFile(settingsFilePath());
|
||
return root && typeof root === 'object' && !Array.isArray(root) ? root : {};
|
||
};
|
||
|
||
const writeSettingsRoot = async (root) => writeJsonFile(settingsFilePath(), root);
|
||
|
||
const normalizeHostUrl = (raw) => {
|
||
const trimmed = typeof raw === 'string' ? raw.trim() : '';
|
||
if (!trimmed) return null;
|
||
try {
|
||
const parsed = new URL(trimmed);
|
||
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
|
||
parsed.hash = '';
|
||
return parsed.toString();
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const sanitizeHostUrlForStorage = (raw) => normalizeHostUrl(raw);
|
||
|
||
const readDesktopHostsConfig = () => {
|
||
const root = readSettingsRoot();
|
||
const hostsRaw = Array.isArray(root.desktopHosts) ? root.desktopHosts : [];
|
||
const hosts = hostsRaw
|
||
.map((entry) => {
|
||
const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
|
||
const url = sanitizeHostUrlForStorage(entry?.url);
|
||
if (!id || id === LOCAL_HOST_ID || !url) return null;
|
||
const label = typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url;
|
||
return { id, label, url };
|
||
})
|
||
.filter(Boolean);
|
||
|
||
return {
|
||
hosts,
|
||
defaultHostId: typeof root.desktopDefaultHostId === 'string' && root.desktopDefaultHostId.trim()
|
||
? root.desktopDefaultHostId.trim()
|
||
: null,
|
||
initialHostChoiceCompleted: root.desktopInitialHostChoiceCompleted === true,
|
||
};
|
||
};
|
||
|
||
const writeDesktopHostsConfig = async (config) => {
|
||
const root = readSettingsRoot();
|
||
root.desktopHosts = Array.isArray(config?.hosts)
|
||
? config.hosts
|
||
.map((entry) => {
|
||
const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
|
||
const url = sanitizeHostUrlForStorage(entry?.url);
|
||
if (!id || id === LOCAL_HOST_ID || !url) return null;
|
||
return {
|
||
id,
|
||
label: typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url,
|
||
url,
|
||
};
|
||
})
|
||
.filter(Boolean)
|
||
: [];
|
||
root.desktopDefaultHostId = typeof config?.defaultHostId === 'string' && config.defaultHostId.trim()
|
||
? config.defaultHostId.trim()
|
||
: null;
|
||
if (typeof config?.initialHostChoiceCompleted === 'boolean') {
|
||
root.desktopInitialHostChoiceCompleted = config.initialHostChoiceCompleted;
|
||
}
|
||
await writeSettingsRoot(root);
|
||
};
|
||
|
||
const readWindowState = () => {
|
||
const stateValue = readSettingsRoot().desktopWindowState;
|
||
return stateValue && typeof stateValue === 'object' ? stateValue : null;
|
||
};
|
||
|
||
const writeWindowState = async (browserWindow) => {
|
||
if (!browserWindow || browserWindow.isDestroyed()) return;
|
||
if (!state.mainWindow || browserWindow.id !== state.mainWindow.id) return;
|
||
|
||
const bounds = browserWindow.getBounds();
|
||
const root = readSettingsRoot();
|
||
root.desktopWindowState = {
|
||
x: bounds.x,
|
||
y: bounds.y,
|
||
width: Math.max(bounds.width, MIN_WINDOW_WIDTH),
|
||
height: Math.max(bounds.height, MIN_WINDOW_HEIGHT),
|
||
maximized: browserWindow.isMaximized(),
|
||
fullscreen: browserWindow.isFullScreen(),
|
||
};
|
||
await writeSettingsRoot(root);
|
||
};
|
||
|
||
const debounceWindowStatePersist = (browserWindow, immediate = false) => {
|
||
if (!browserWindow || browserWindow.isDestroyed()) return;
|
||
const key = String(browserWindow.id);
|
||
const revision = (state.windowGeometryRevisions.get(key) || 0) + 1;
|
||
state.windowGeometryRevisions.set(key, revision);
|
||
|
||
const persist = async () => {
|
||
if (state.windowGeometryRevisions.get(key) !== revision) return;
|
||
await writeWindowState(browserWindow);
|
||
};
|
||
|
||
if (immediate) {
|
||
void persist();
|
||
return;
|
||
}
|
||
|
||
setTimeout(() => {
|
||
void persist();
|
||
}, 300);
|
||
};
|
||
|
||
const buildHealthUrl = (url) => {
|
||
try {
|
||
const parsed = new URL(url);
|
||
parsed.pathname = `${parsed.pathname.replace(/\/$/, '') || ''}/health`;
|
||
return parsed.toString();
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const probeHostWithTimeout = async (url, timeoutMs) => {
|
||
const healthUrl = buildHealthUrl(url);
|
||
if (!healthUrl) {
|
||
throw new Error('Invalid URL');
|
||
}
|
||
|
||
const started = Date.now();
|
||
try {
|
||
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(timeoutMs) });
|
||
const status = response.status;
|
||
return {
|
||
status: status >= 200 && status < 300 ? 'ok' : (status === 401 || status === 403 ? 'auth' : 'unreachable'),
|
||
latencyMs: Date.now() - started,
|
||
};
|
||
} catch {
|
||
return { status: 'unreachable', latencyMs: Date.now() - started };
|
||
}
|
||
};
|
||
|
||
const waitForHealth = async (url, timeoutMs = 20_000, initialPollMs = 250, maxPollMs = 2000) => {
|
||
const deadline = Date.now() + timeoutMs;
|
||
let pollMs = initialPollMs;
|
||
while (Date.now() < deadline) {
|
||
try {
|
||
const response = await fetch(buildHealthUrl(url), { signal: AbortSignal.timeout(Math.min(pollMs * 4, 1500)) });
|
||
if (response.ok) {
|
||
return true;
|
||
}
|
||
} catch {
|
||
}
|
||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||
pollMs = Math.min(pollMs * 2, maxPollMs);
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const pickUnusedPort = async () => {
|
||
const net = await import('node:net');
|
||
return await new Promise((resolve, reject) => {
|
||
const server = net.createServer();
|
||
server.listen(0, '127.0.0.1', () => {
|
||
const address = server.address();
|
||
const port = typeof address === 'object' && address ? address.port : 0;
|
||
server.close(() => resolve(port));
|
||
});
|
||
server.on('error', reject);
|
||
});
|
||
};
|
||
|
||
const isPortFree = async (port) => {
|
||
if (!Number.isFinite(port) || port <= 0) return false;
|
||
const net = await import('node:net');
|
||
return await new Promise((resolve) => {
|
||
const test = net.createServer();
|
||
const done = (value) => {
|
||
try { test.close(); } catch {}
|
||
resolve(value);
|
||
};
|
||
test.once('error', () => done(false));
|
||
test.listen(port, '127.0.0.1', () => done(true));
|
||
});
|
||
};
|
||
|
||
// Return the LAN IPv4 of the interface that routes to the public internet.
|
||
// UDP "connect" is a kernel-side route lookup — no packet actually goes out —
|
||
// and it picks the same interface as a real outbound connection, which is what
|
||
// a phone on the same Wi-Fi needs to reach us. Falls back to scanning
|
||
// os.networkInterfaces() if the socket trick fails (e.g. no default route).
|
||
const detectLanIPv4Address = async () => {
|
||
const ip = await new Promise((resolve) => {
|
||
const socket = dgram.createSocket('udp4');
|
||
const finish = (value) => {
|
||
try { socket.close(); } catch {}
|
||
resolve(value);
|
||
};
|
||
socket.once('error', () => finish(null));
|
||
try {
|
||
socket.connect(80, '8.8.8.8', (error) => {
|
||
if (error) return finish(null);
|
||
try {
|
||
const addr = socket.address();
|
||
finish(addr && typeof addr.address === 'string' ? addr.address : null);
|
||
} catch {
|
||
finish(null);
|
||
}
|
||
});
|
||
} catch {
|
||
finish(null);
|
||
}
|
||
});
|
||
if (ip && ip !== '0.0.0.0' && !ip.startsWith('127.')) return ip;
|
||
|
||
for (const entries of Object.values(os.networkInterfaces() || {})) {
|
||
for (const entry of entries || []) {
|
||
if (entry.family === 'IPv4' && !entry.internal && entry.address) {
|
||
return entry.address;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const buildLocalUrl = (port) => `http://127.0.0.1:${port}`;
|
||
|
||
const resourceRoot = () => isDev ? path.join(__dirname, 'resources') : process.resourcesPath;
|
||
const resolveWebDistDir = () => path.join(resourceRoot(), 'web-dist');
|
||
|
||
const normalizeNotificationInput = (raw) => {
|
||
if (!raw || typeof raw !== 'object') return {};
|
||
// UI IPC path wraps in { payload: {...} }; sidecar stdout path is flat.
|
||
if (raw.payload && typeof raw.payload === 'object') {
|
||
return { ...raw, ...raw.payload };
|
||
}
|
||
return raw;
|
||
};
|
||
|
||
const isAnyWindowFocused = () =>
|
||
BrowserWindow.getAllWindows().some(
|
||
(window) => !window.isDestroyed() && window.isFocused(),
|
||
);
|
||
|
||
const focusForegroundWindow = () => {
|
||
const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed());
|
||
if (windows.length === 0) return;
|
||
const target = state.mainWindow && !state.mainWindow.isDestroyed()
|
||
? state.mainWindow
|
||
: windows.find((window) => window.isVisible()) || windows[0];
|
||
if (target.isMinimized()) target.restore();
|
||
if (!target.isVisible()) target.show();
|
||
target.focus();
|
||
if (process.platform === 'darwin') app.focus({ steal: true });
|
||
};
|
||
|
||
const maybeShowNativeNotification = (rawInput) => {
|
||
const payload = normalizeNotificationInput(rawInput);
|
||
const requireHidden = Boolean(payload.requireHidden ?? payload.require_hidden);
|
||
|
||
if (requireHidden && isAnyWindowFocused()) {
|
||
return;
|
||
}
|
||
|
||
if (!Notification.isSupported()) {
|
||
return;
|
||
}
|
||
|
||
const title = typeof payload.title === 'string' && payload.title.trim()
|
||
? payload.title.trim()
|
||
: 'OpenChamber';
|
||
const body = typeof payload.body === 'string' ? payload.body : '';
|
||
const sessionId = typeof payload.sessionId === 'string' && payload.sessionId.trim()
|
||
? payload.sessionId.trim()
|
||
: null;
|
||
|
||
const notification = new Notification({
|
||
title,
|
||
body,
|
||
silent: false,
|
||
...(process.platform === 'darwin' ? { sound: 'Glass' } : {}),
|
||
});
|
||
|
||
notification.on('click', () => {
|
||
focusForegroundWindow();
|
||
if (sessionId) {
|
||
emitToAllWindows('openchamber:open-session', { sessionId });
|
||
}
|
||
});
|
||
|
||
notification.show();
|
||
};
|
||
|
||
const mapUpdaterProgressEvent = (payload) => ({
|
||
event: payload.event,
|
||
data: payload.data,
|
||
});
|
||
|
||
const SHELL_ENV_TIMEOUT_MS = 5_000;
|
||
let cachedShellEnv = null;
|
||
let shellEnvProbed = false;
|
||
|
||
const isNushell = (shell) => {
|
||
const name = path.basename(shell).toLowerCase();
|
||
return name === 'nu' || name === 'nu.exe';
|
||
};
|
||
|
||
const parseShellEnv = (buf) => {
|
||
const result = {};
|
||
for (const line of buf.toString('utf8').split('\0')) {
|
||
if (!line) continue;
|
||
const idx = line.indexOf('=');
|
||
if (idx <= 0) continue;
|
||
result[line.slice(0, idx)] = line.slice(idx + 1);
|
||
}
|
||
return result;
|
||
};
|
||
|
||
const probeShellEnv = (shell, mode) => {
|
||
const result = spawnSync(shell, [mode, '-c', 'env -0'], {
|
||
stdio: ['ignore', 'pipe', 'ignore'],
|
||
timeout: SHELL_ENV_TIMEOUT_MS,
|
||
windowsHide: true,
|
||
});
|
||
if (result.error || result.status !== 0) return null;
|
||
const env = parseShellEnv(result.stdout);
|
||
return Object.keys(env).length > 0 ? env : null;
|
||
};
|
||
|
||
// Finder-launched apps on macOS inherit a minimal PATH (no /opt/homebrew, mise, asdf, etc.).
|
||
// Probe the user's login shell once so the sidecar sees the same PATH / tool env as `$SHELL -il`.
|
||
const loadShellEnv = () => {
|
||
if (shellEnvProbed) return cachedShellEnv;
|
||
shellEnvProbed = true;
|
||
if (process.platform === 'win32') return null;
|
||
const shell = process.env.SHELL || '/bin/sh';
|
||
if (isNushell(shell)) return null;
|
||
cachedShellEnv = probeShellEnv(shell, '-il') || probeShellEnv(shell, '-l');
|
||
return cachedShellEnv;
|
||
};
|
||
|
||
// Merge the user's login-shell env (PATH, etc.) into this process before we
|
||
// import/start the server in-process. The server and its children (opencode
|
||
// CLI, git, etc.) inherit process.env directly now — there is no sidecar
|
||
// subprocess to hand a custom env to.
|
||
const inheritUserShellEnv = () => {
|
||
const shellEnv = loadShellEnv();
|
||
if (!shellEnv) return;
|
||
|
||
const homeDir = os.homedir();
|
||
const shellPathSegments = typeof shellEnv.PATH === 'string' ? shellEnv.PATH.split(':') : [];
|
||
const processPathSegments = typeof process.env.PATH === 'string' ? process.env.PATH.split(':') : [];
|
||
const pathSegments = [
|
||
...shellPathSegments,
|
||
'/opt/homebrew/bin',
|
||
'/usr/local/bin',
|
||
'/usr/bin',
|
||
'/bin',
|
||
'/usr/sbin',
|
||
'/sbin',
|
||
path.join(homeDir, '.opencode', 'bin'),
|
||
path.join(homeDir, '.local', 'bin'),
|
||
path.join(homeDir, '.bun', 'bin'),
|
||
path.join(homeDir, '.cargo', 'bin'),
|
||
path.join(homeDir, 'bin'),
|
||
...processPathSegments,
|
||
].filter(Boolean);
|
||
const uniquePath = Array.from(new Set(pathSegments)).join(':');
|
||
|
||
for (const [key, value] of Object.entries(shellEnv)) {
|
||
if (key === 'PATH') continue;
|
||
if (typeof process.env[key] === 'undefined') {
|
||
process.env[key] = value;
|
||
}
|
||
}
|
||
process.env.PATH = uniquePath;
|
||
};
|
||
|
||
const spawnLocalServer = async () => {
|
||
inheritUserShellEnv();
|
||
|
||
const settings = readSettingsRoot();
|
||
const storedPort = Number.isFinite(settings.desktopLocalPort) ? settings.desktopLocalPort : null;
|
||
// When the user enables "Desktop Network Access" we bind on all interfaces
|
||
// so phones/tablets on the same Wi-Fi can reach the app. UI shows a clear
|
||
// warning and persists the flag via /api/config/settings.
|
||
const lanAccessEnabled = settings.desktopLanAccessEnabled === true;
|
||
const bindHost = lanAccessEnabled ? '0.0.0.0' : '127.0.0.1';
|
||
|
||
// Probe before starting the server — main() in the server module sets up a
|
||
// lot of global state before binding, and calling it twice after a listen
|
||
// failure would double-wire runtimes. Pick a known-free port in one shot.
|
||
const candidates = [storedPort, DEFAULT_DESKTOP_PORT].filter((v) => Number.isFinite(v) && v > 0);
|
||
let chosenPort = 0;
|
||
for (const candidate of candidates) {
|
||
if (await isPortFree(candidate)) {
|
||
chosenPort = candidate;
|
||
break;
|
||
}
|
||
}
|
||
if (chosenPort === 0) {
|
||
chosenPort = await pickUnusedPort();
|
||
}
|
||
|
||
// The server module reads ENV_DESKTOP_NOTIFY / OPENCHAMBER_DIST_DIR /
|
||
// OPENCHAMBER_RUNTIME at import time (top-level const), so these must be
|
||
// set before the first import. After this point, the same env is used by
|
||
// both the Electron main and the server running inside it.
|
||
process.env.OPENCHAMBER_HOST = bindHost;
|
||
process.env.OPENCHAMBER_DIST_DIR = resolveWebDistDir();
|
||
process.env.OPENCHAMBER_RUNTIME = 'desktop';
|
||
process.env.OPENCHAMBER_DESKTOP_NOTIFY = 'true';
|
||
process.env.NO_PROXY = process.env.NO_PROXY || 'localhost,127.0.0.1';
|
||
process.env.no_proxy = process.env.no_proxy || 'localhost,127.0.0.1';
|
||
|
||
const { startWebUiServer } = await import('@openchamber/web/server/index.js');
|
||
|
||
const handle = await startWebUiServer({
|
||
port: chosenPort,
|
||
host: bindHost,
|
||
attachSignals: false,
|
||
exitOnShutdown: false,
|
||
onDesktopNotification: (payload) => maybeShowNativeNotification(payload),
|
||
});
|
||
|
||
const port = handle.getPort();
|
||
const url = buildLocalUrl(port);
|
||
|
||
state.serverHandle = handle;
|
||
state.sidecarUrl = url;
|
||
|
||
const root = readSettingsRoot();
|
||
root.desktopLocalPort = port;
|
||
await writeSettingsRoot(root);
|
||
|
||
return url;
|
||
};
|
||
|
||
const killSidecar = () => {
|
||
if (state.serverHandle) {
|
||
try {
|
||
const result = state.serverHandle.stop({ exitProcess: false });
|
||
if (result && typeof result.then === 'function') {
|
||
result.catch(() => {});
|
||
}
|
||
} catch {
|
||
}
|
||
state.serverHandle = null;
|
||
}
|
||
state.sidecarUrl = null;
|
||
};
|
||
|
||
const macosMajorVersion = () => {
|
||
if (process.platform !== 'darwin') return 0;
|
||
const result = spawnSync('/usr/bin/sw_vers', ['-productVersion'], { encoding: 'utf8' });
|
||
const raw = (result.stdout || '').trim();
|
||
const [majorRaw, minorRaw] = raw.split('.');
|
||
const major = Number.parseInt(majorRaw || '0', 10);
|
||
const minor = Number.parseInt(minorRaw || '0', 10);
|
||
return major === 10 ? minor : major;
|
||
};
|
||
|
||
const buildInitScript = (localOrigin, bootOutcome) => {
|
||
const home = JSON.stringify(os.homedir() || '');
|
||
const local = JSON.stringify(localOrigin || '');
|
||
const macVersion = macosMajorVersion();
|
||
const outcome = JSON.stringify(bootOutcome ?? null);
|
||
return [
|
||
'(function(){',
|
||
`try{window.__OPENCHAMBER_HOME__=${home};window.__OPENCHAMBER_MACOS_MAJOR__=${macVersion};window.__OPENCHAMBER_LOCAL_ORIGIN__=${local};var __oc_bo=${outcome};if(__oc_bo){window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__=__oc_bo;}}catch(_e){}`,
|
||
'}())',
|
||
].join('');
|
||
};
|
||
|
||
const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) => {
|
||
if (envTargetUrl) {
|
||
const status = probe && probe.status === 'unreachable' ? 'unreachable' : 'ok';
|
||
return { target: 'remote', status, hostId: ENV_OVERRIDE_HOST_ID, url: envTargetUrl };
|
||
}
|
||
|
||
const defaultId = config.defaultHostId || '';
|
||
if (!defaultId) {
|
||
return { target: null, status: 'not-configured' };
|
||
}
|
||
|
||
if (defaultId === LOCAL_HOST_ID) {
|
||
return localAvailable
|
||
? { target: 'local', status: 'ok' }
|
||
: { target: 'local', status: 'unreachable' };
|
||
}
|
||
|
||
const host = config.hosts.find((entry) => entry.id === defaultId);
|
||
if (!host) {
|
||
return { target: 'remote', status: 'missing', hostId: defaultId };
|
||
}
|
||
|
||
const status = probe && probe.status === 'unreachable' ? 'unreachable' : 'ok';
|
||
return { target: 'remote', status, hostId: host.id, url: host.url };
|
||
};
|
||
|
||
const buildStartupSplashHtml = () => {
|
||
const settings = readSettingsRoot();
|
||
const splashBgLight = typeof settings.splashBgLight === 'string' ? settings.splashBgLight.trim() : '#f5f5f4';
|
||
const splashFgLight = typeof settings.splashFgLight === 'string' ? settings.splashFgLight.trim() : '#1c1917';
|
||
const splashBgDark = typeof settings.splashBgDark === 'string' ? settings.splashBgDark.trim() : '#0c0a09';
|
||
const splashFgDark = typeof settings.splashFgDark === 'string' ? settings.splashFgDark.trim() : '#fafaf9';
|
||
|
||
return `<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<style>
|
||
:root { color-scheme: light dark; }
|
||
body {
|
||
margin: 0;
|
||
font-family: "IBM Plex Sans", sans-serif;
|
||
display: grid;
|
||
place-items: center;
|
||
height: 100vh;
|
||
background: ${splashBgLight};
|
||
color: ${splashFgLight};
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
body { background: ${splashBgDark}; color: ${splashFgDark}; }
|
||
}
|
||
.mark {
|
||
font-size: 20px;
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
opacity: 0.88;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="mark">OpenChamber</div>
|
||
</body>
|
||
</html>`;
|
||
};
|
||
|
||
const isBenignNavigationAbort = (error) => {
|
||
if (!error || typeof error !== 'object') {
|
||
return false;
|
||
}
|
||
|
||
if (error.errno === -3) {
|
||
return true;
|
||
}
|
||
|
||
const message = typeof error.message === 'string' ? error.message : '';
|
||
return message.includes('ERR_ABORTED') || message.includes(' (-3) loading ');
|
||
};
|
||
|
||
const navigateWindow = async (browserWindow, url, { allowAbort = false } = {}) => {
|
||
try {
|
||
await browserWindow.loadURL(url);
|
||
} catch (error) {
|
||
if (allowAbort && isBenignNavigationAbort(error)) {
|
||
return;
|
||
}
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
const emitToWindow = (browserWindow, event, detail) => {
|
||
if (!browserWindow || browserWindow.isDestroyed()) return;
|
||
browserWindow.webContents.send('openchamber:emit', { event, detail });
|
||
};
|
||
|
||
const emitToAllWindows = (event, detail) => {
|
||
for (const browserWindow of BrowserWindow.getAllWindows()) {
|
||
emitToWindow(browserWindow, event, detail);
|
||
}
|
||
};
|
||
|
||
const pendingDeepLinks = [];
|
||
|
||
const parseDeepLink = (raw) => {
|
||
if (typeof raw !== 'string') return null;
|
||
const trimmed = raw.trim();
|
||
if (!trimmed) return null;
|
||
try {
|
||
const url = new URL(trimmed);
|
||
if (url.protocol !== `${DEEP_LINK_PROTOCOL}:`) return null;
|
||
const type = url.hostname;
|
||
if (!type) return null;
|
||
const segments = url.pathname.split('/').filter(Boolean);
|
||
const value = segments.length > 0
|
||
? decodeURIComponent(segments.join('/'))
|
||
: '';
|
||
return { type, value };
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const switchToHostById = async (rawId) => {
|
||
const id = typeof rawId === 'string' ? rawId.trim() : '';
|
||
if (!id) return;
|
||
const config = readDesktopHostsConfig();
|
||
let targetUrl = null;
|
||
if (id === LOCAL_HOST_ID) {
|
||
targetUrl = state.sidecarUrl || state.localOrigin;
|
||
} else {
|
||
const host = config.hosts.find((entry) => entry.id === id);
|
||
if (!host) {
|
||
log.warn('[electron] deep-link host not found:', id);
|
||
return;
|
||
}
|
||
targetUrl = host.url;
|
||
}
|
||
if (!targetUrl) {
|
||
log.warn('[electron] deep-link host has no target URL:', id);
|
||
return;
|
||
}
|
||
const bootOutcome = id === LOCAL_HOST_ID
|
||
? { target: 'local', status: 'ok' }
|
||
: { target: 'remote', status: 'ok', hostId: id, url: targetUrl };
|
||
log.info('[electron] switching to host', { id, bootOutcome });
|
||
await activateMainWindow(targetUrl, state.localOrigin, bootOutcome);
|
||
};
|
||
|
||
const dispatchDeepLink = (link) => {
|
||
if (!link) return;
|
||
log.info('[electron] dispatching deep-link', { type: link.type, valueLen: link.value?.length || 0 });
|
||
if (link.type === 'session' && link.value) {
|
||
emitToAllWindows('openchamber:open-session', { sessionId: link.value });
|
||
return;
|
||
}
|
||
if (link.type === 'project' && link.value) {
|
||
emitToAllWindows('openchamber:open-project', { projectPath: link.value });
|
||
return;
|
||
}
|
||
if (link.type === 'host' && link.value) {
|
||
void switchToHostById(link.value);
|
||
return;
|
||
}
|
||
log.warn('[electron] unknown deep-link action:', link.type);
|
||
};
|
||
|
||
const flushPendingDeepLinks = () => {
|
||
while (pendingDeepLinks.length > 0) {
|
||
dispatchDeepLink(pendingDeepLinks.shift());
|
||
}
|
||
};
|
||
|
||
const isMainWindowReadyForDeepLink = () =>
|
||
Boolean(state.mainWindow)
|
||
&& !state.mainWindow.isDestroyed()
|
||
&& !state.mainWindow.webContents.isLoading();
|
||
|
||
const handleDeepLinks = (urls) => {
|
||
for (const raw of urls) {
|
||
const parsed = parseDeepLink(raw);
|
||
if (!parsed) continue;
|
||
if (isMainWindowReadyForDeepLink()) {
|
||
dispatchDeepLink(parsed);
|
||
} else {
|
||
pendingDeepLinks.push(parsed);
|
||
}
|
||
}
|
||
};
|
||
|
||
const extractInitialDeepLinks = () =>
|
||
process.argv.filter((arg) => typeof arg === 'string' && arg.startsWith(`${DEEP_LINK_PROTOCOL}://`));
|
||
|
||
const dispatchDomEventToWindow = (browserWindow, event, detail) => {
|
||
if (!browserWindow || browserWindow.isDestroyed()) return;
|
||
|
||
const eventLiteral = JSON.stringify(event);
|
||
const script = detail === undefined
|
||
? `window.dispatchEvent(new Event(${eventLiteral}));`
|
||
: `window.dispatchEvent(new CustomEvent(${eventLiteral}, { detail: ${JSON.stringify(detail)} }));`;
|
||
|
||
void browserWindow.webContents.executeJavaScript(script, true).catch(() => {});
|
||
};
|
||
|
||
const getMenuTargetWindow = () => {
|
||
const focused = BrowserWindow.getFocusedWindow();
|
||
if (focused && !focused.isDestroyed()) return focused;
|
||
if (state.mainWindow && !state.mainWindow.isDestroyed()) return state.mainWindow;
|
||
const [firstWindow] = BrowserWindow.getAllWindows();
|
||
return firstWindow && !firstWindow.isDestroyed() ? firstWindow : null;
|
||
};
|
||
|
||
const dispatchMenuAction = (action) => {
|
||
const target = getMenuTargetWindow();
|
||
emitToWindow(target, 'openchamber:menu-action', action);
|
||
dispatchDomEventToWindow(target, 'openchamber:menu-action', action);
|
||
};
|
||
|
||
const dispatchCheckForUpdates = () => {
|
||
emitToAllWindows('openchamber:check-for-updates');
|
||
for (const browserWindow of BrowserWindow.getAllWindows()) {
|
||
dispatchDomEventToWindow(browserWindow, 'openchamber:check-for-updates');
|
||
}
|
||
};
|
||
|
||
const nextWindowLabel = () => {
|
||
const value = state.windowCounter++;
|
||
return value === 1 ? 'main' : `main-${value}`;
|
||
};
|
||
|
||
const readThemeSource = () => {
|
||
const settings = readSettingsRoot();
|
||
if (settings.useSystemTheme === true) return 'system';
|
||
if (settings.themeMode === 'light' || settings.themeVariant === 'light') return 'light';
|
||
if (settings.themeMode === 'dark' || settings.themeVariant === 'dark') return 'dark';
|
||
return 'system';
|
||
};
|
||
|
||
const createBrowserWindow = ({ label, restoreGeometry, url }) => {
|
||
const saved = restoreGeometry ? readWindowState() : null;
|
||
const useSaved = saved && typeof saved.width === 'number' && typeof saved.height === 'number';
|
||
const desktopLocalOrigin = state.localOrigin || '';
|
||
const desktopHome = os.homedir() || '';
|
||
const desktopMacosMajor = String(macosMajorVersion());
|
||
const options = {
|
||
title: 'OpenChamber',
|
||
width: useSaved ? Math.max(saved.width, MIN_RESTORE_WINDOW_WIDTH) : 1280,
|
||
height: useSaved ? Math.max(saved.height, MIN_RESTORE_WINDOW_HEIGHT) : 800,
|
||
minWidth: MIN_WINDOW_WIDTH,
|
||
minHeight: MIN_WINDOW_HEIGHT,
|
||
show: false,
|
||
backgroundColor: '#151313',
|
||
// Tauri used an overlay title bar with explicit traffic-light placement.
|
||
// Electron's hiddenInset adds its own extra inset, which leaves the controls
|
||
// visibly lower than the app header. Use a plain hidden title bar instead.
|
||
titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default',
|
||
trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 18 } : undefined,
|
||
webPreferences: {
|
||
additionalArguments: [
|
||
`--openchamber-local-origin=${desktopLocalOrigin}`,
|
||
`--openchamber-home=${desktopHome}`,
|
||
`--openchamber-macos-major=${desktopMacosMajor}`,
|
||
`--openchamber-boot-outcome=${JSON.stringify(state.bootOutcome || null)}`,
|
||
],
|
||
preload: isDev ? path.join(__dirname, 'preload.mjs') : path.join(app.getAppPath(), 'preload.mjs'),
|
||
backgroundThrottling: true,
|
||
contextIsolation: true,
|
||
nodeIntegration: false,
|
||
// sandbox must stay off: the preload uses contextBridge + ipcRenderer
|
||
// from Electron's Node layer. contextIsolation + nodeIntegration:false
|
||
// keep the renderer world walled off from Node. Do NOT flip to true —
|
||
// the preload would fail to load and __TAURI__ would go undefined.
|
||
sandbox: false,
|
||
},
|
||
};
|
||
|
||
const browserWindow = new BrowserWindow(options);
|
||
browserWindow.__ocLabel = label || nextWindowLabel();
|
||
|
||
if (useSaved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) {
|
||
browserWindow.setPosition(saved.x, saved.y);
|
||
}
|
||
|
||
if (useSaved && saved.maximized) {
|
||
browserWindow.maximize();
|
||
}
|
||
|
||
browserWindow.on('focus', () => {
|
||
state.focusedWindowIds.add(browserWindow.id);
|
||
});
|
||
browserWindow.on('blur', () => {
|
||
state.focusedWindowIds.delete(browserWindow.id);
|
||
});
|
||
browserWindow.on('resize', () => {
|
||
emitToWindow(browserWindow, 'openchamber:window-resized');
|
||
debounceWindowStatePersist(browserWindow, false);
|
||
});
|
||
browserWindow.on('move', () => {
|
||
debounceWindowStatePersist(browserWindow, false);
|
||
});
|
||
browserWindow.on('close', (event) => {
|
||
if (process.platform === 'darwin' && !state.quitRequested) {
|
||
const remainingVisible = BrowserWindow.getAllWindows().filter(
|
||
(window) => !window.isDestroyed() && window.isVisible(),
|
||
).length;
|
||
|
||
if (remainingVisible <= 1) {
|
||
debounceWindowStatePersist(browserWindow, true);
|
||
event.preventDefault();
|
||
browserWindow.hide();
|
||
return;
|
||
}
|
||
}
|
||
|
||
debounceWindowStatePersist(browserWindow, true);
|
||
});
|
||
browserWindow.on('closed', () => {
|
||
state.focusedWindowIds.delete(browserWindow.id);
|
||
if (state.mainWindow && browserWindow.id === state.mainWindow.id) {
|
||
state.mainWindow = null;
|
||
}
|
||
if (BrowserWindow.getAllWindows().length === 0) {
|
||
killSidecar();
|
||
if (process.platform !== 'darwin') {
|
||
app.quit();
|
||
}
|
||
}
|
||
});
|
||
|
||
// Any navigation target that isn't our own UI (local server / configured
|
||
// desktop hosts) should open in the user's default browser, not spawn
|
||
// another Electron window loading arbitrary web content.
|
||
const isAllowedNavigationUrl = (raw) => {
|
||
try {
|
||
const url = new URL(raw);
|
||
if (url.protocol === 'file:' || url.protocol === 'about:' || url.protocol === 'devtools:') return true;
|
||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
||
const hostname = url.hostname;
|
||
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return true;
|
||
if (state.localOrigin) {
|
||
try {
|
||
if (new URL(state.localOrigin).origin === url.origin) return true;
|
||
} catch {
|
||
}
|
||
}
|
||
const hosts = readDesktopHostsConfig()?.hosts || [];
|
||
for (const entry of hosts) {
|
||
if (typeof entry?.url !== 'string') continue;
|
||
try {
|
||
if (new URL(entry.url).origin === url.origin) return true;
|
||
} catch {
|
||
}
|
||
}
|
||
return false;
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
browserWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||
if (isAllowedNavigationUrl(url)) {
|
||
return { action: 'allow' };
|
||
}
|
||
void shell.openExternal(url).catch(() => {});
|
||
return { action: 'deny' };
|
||
});
|
||
|
||
browserWindow.webContents.on('will-navigate', (event, url) => {
|
||
if (isAllowedNavigationUrl(url)) return;
|
||
event.preventDefault();
|
||
void shell.openExternal(url).catch(() => {});
|
||
});
|
||
|
||
browserWindow.webContents.setZoomFactor(1);
|
||
browserWindow.webContents.on('zoom-changed', () => {
|
||
browserWindow.webContents.setZoomFactor(1);
|
||
});
|
||
|
||
browserWindow.webContents.on('dom-ready', () => {
|
||
if (state.initScript) {
|
||
void browserWindow.webContents.executeJavaScript(state.initScript).catch(() => {});
|
||
}
|
||
});
|
||
|
||
browserWindow.webContents.on('did-finish-load', () => {
|
||
browserWindow.webContents.setZoomFactor(1);
|
||
if (state.mainWindow && browserWindow.id === state.mainWindow.id && pendingDeepLinks.length > 0) {
|
||
const timer = setTimeout(flushPendingDeepLinks, 400);
|
||
if (typeof timer?.unref === 'function') timer.unref();
|
||
}
|
||
});
|
||
|
||
browserWindow.once('ready-to-show', () => {
|
||
browserWindow.show();
|
||
browserWindow.focus();
|
||
});
|
||
|
||
if (url) {
|
||
void navigateWindow(browserWindow, url);
|
||
} else {
|
||
void navigateWindow(
|
||
browserWindow,
|
||
`data:text/html;charset=utf-8,${encodeURIComponent(buildStartupSplashHtml())}`,
|
||
{ allowAbort: true },
|
||
);
|
||
}
|
||
|
||
return browserWindow;
|
||
};
|
||
|
||
const activateMainWindow = async (url, localOrigin, bootOutcome) => {
|
||
state.localOrigin = localOrigin;
|
||
state.bootOutcome = bootOutcome ?? null;
|
||
state.initScript = buildInitScript(localOrigin, state.bootOutcome);
|
||
|
||
const mainWindow = state.mainWindow;
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
await navigateWindow(mainWindow, url, { allowAbort: true });
|
||
mainWindow.show();
|
||
mainWindow.focus();
|
||
return mainWindow;
|
||
}
|
||
|
||
state.mainWindow = createBrowserWindow({
|
||
label: 'main',
|
||
restoreGeometry: true,
|
||
url,
|
||
});
|
||
return state.mainWindow;
|
||
};
|
||
|
||
const createAdditionalWindow = async (url) => {
|
||
if (!state.localOrigin) {
|
||
return null;
|
||
}
|
||
const browserWindow = createBrowserWindow({
|
||
label: nextWindowLabel(),
|
||
restoreGeometry: false,
|
||
url,
|
||
});
|
||
return browserWindow;
|
||
};
|
||
|
||
const resolveInitialUrl = async () => {
|
||
const localUrl = isDev && await waitForHealth('http://127.0.0.1:3901', 5_000, 100)
|
||
? 'http://127.0.0.1:3901'
|
||
: await spawnLocalServer();
|
||
|
||
const localUiUrl = isDev && await waitForHealth('http://127.0.0.1:5173', 8_000, 100)
|
||
? 'http://127.0.0.1:5173'
|
||
: localUrl;
|
||
|
||
state.sidecarUrl = localUrl;
|
||
const localAvailable = Boolean(localUrl);
|
||
|
||
const localOrigin = new URL(localUiUrl).origin;
|
||
let initialUrl = localUiUrl;
|
||
let remoteProbe = null;
|
||
|
||
const envTarget = normalizeHostUrl(process.env.OPENCHAMBER_SERVER_URL || '');
|
||
const config = readDesktopHostsConfig();
|
||
if (envTarget) {
|
||
initialUrl = envTarget;
|
||
} else if (config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID) {
|
||
const host = config.hosts.find((entry) => entry.id === config.defaultHostId);
|
||
if (host?.url) {
|
||
initialUrl = host.url;
|
||
}
|
||
}
|
||
|
||
if (initialUrl !== localUiUrl) {
|
||
remoteProbe = await probeHostWithTimeout(initialUrl, 2_000);
|
||
if (remoteProbe.status === 'unreachable') {
|
||
remoteProbe = await probeHostWithTimeout(initialUrl, 10_000);
|
||
}
|
||
if (remoteProbe.status === 'unreachable') {
|
||
state.unreachableHosts.add(initialUrl);
|
||
initialUrl = localUiUrl;
|
||
}
|
||
}
|
||
|
||
const bootOutcome = computeBootOutcome({
|
||
envTargetUrl: envTarget || null,
|
||
probe: remoteProbe,
|
||
config,
|
||
localAvailable,
|
||
});
|
||
|
||
return { initialUrl, localOrigin, localUiUrl, bootOutcome };
|
||
};
|
||
|
||
const compareSemver = (left, right) => {
|
||
const a = String(left || '').replace(/^v/, '').split('.').map((value) => Number.parseInt(value || '0', 10));
|
||
const b = String(right || '').replace(/^v/, '').split('.').map((value) => Number.parseInt(value || '0', 10));
|
||
const length = Math.max(a.length, b.length);
|
||
for (let index = 0; index < length; index += 1) {
|
||
const diff = (a[index] || 0) - (b[index] || 0);
|
||
if (diff !== 0) return diff;
|
||
}
|
||
return 0;
|
||
};
|
||
|
||
const parseGithubRepo = () => {
|
||
return { owner: 'btriapitsyn', repo: 'openchamber' };
|
||
};
|
||
|
||
const setupAutoUpdater = () => {
|
||
if (!app.isPackaged) {
|
||
return;
|
||
}
|
||
autoUpdater.autoDownload = false;
|
||
autoUpdater.autoInstallOnAppQuit = false;
|
||
autoUpdater.allowPrerelease = false;
|
||
autoUpdater.fullChangelog = true;
|
||
autoUpdater.disableWebInstaller = false;
|
||
autoUpdater.logger = log;
|
||
|
||
const { owner, repo } = parseGithubRepo();
|
||
autoUpdater.setFeedURL({
|
||
provider: 'github',
|
||
owner,
|
||
repo,
|
||
});
|
||
|
||
autoUpdater.on('download-progress', (progress) => {
|
||
emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({
|
||
event: 'Progress',
|
||
data: {
|
||
chunkLength: Math.max(0, Math.round(progress.bytesPerSecond || 0)),
|
||
downloaded: Math.round(progress.transferred || 0),
|
||
total: Math.round(progress.total || 0),
|
||
},
|
||
}));
|
||
});
|
||
};
|
||
|
||
const parseRelevantChangelogNotes = async (fromVersion, toVersion) => {
|
||
try {
|
||
const response = await fetch(CHANGELOG_URL, { signal: AbortSignal.timeout(10_000) });
|
||
if (!response.ok) return null;
|
||
const changelog = await response.text();
|
||
const sections = changelog.split(/^##\s+\[/m).slice(1);
|
||
const relevant = [];
|
||
for (const section of sections) {
|
||
const version = section.split(']')[0];
|
||
if (compareSemver(version, fromVersion) > 0 && compareSemver(version, toVersion) <= 0) {
|
||
relevant.push(`## [${section}`.trim());
|
||
}
|
||
}
|
||
return relevant.length > 0 ? relevant.join('\n\n') : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const buildInstalledAppsCachePath = () => path.join(path.dirname(settingsFilePath()), INSTALLED_APPS_CACHE_FILE);
|
||
|
||
// Async variants. sips + mdfind via spawnSync blocked the Electron main event
|
||
// loop for 2-3s on boot (22 OPEN_IN_APPS × ~200 ms each). Use execFile promises
|
||
// so each child-process wait yields to the loop and the UI stays responsive.
|
||
const pathExists = async (candidate) => {
|
||
try {
|
||
await fsp.access(candidate);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
const resolveAppBundlePath = async (appName) => {
|
||
if (process.platform !== 'darwin') return null;
|
||
const bundleName = appName.endsWith('.app') ? appName : `${appName}.app`;
|
||
const candidates = [
|
||
`/Applications/${bundleName}`,
|
||
`/System/Applications/${bundleName}`,
|
||
`/System/Applications/Utilities/${bundleName}`,
|
||
path.join(os.homedir(), 'Applications', bundleName),
|
||
];
|
||
for (const candidate of candidates) {
|
||
if (await pathExists(candidate)) return candidate;
|
||
}
|
||
try {
|
||
const { stdout } = await execFileAsync('mdfind', ['-name', bundleName], { encoding: 'utf8' });
|
||
const first = (stdout || '').split('\n').map((line) => line.trim()).find(Boolean);
|
||
return first || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const isAppBundleInstalled = async (appName) => Boolean(await resolveAppBundlePath(appName));
|
||
|
||
const iconToDataUrl = async (iconPath, appName) => {
|
||
if (!iconPath || !(await pathExists(iconPath))) return null;
|
||
const safeName = String(appName || 'app').replace(/[^a-z0-9]/gi, '_');
|
||
const tempPath = path.join(os.tmpdir(), `openchamber-icon-${safeName}-${Date.now()}.png`);
|
||
try {
|
||
await execFileAsync('sips', ['-s', 'format', 'png', '-Z', '32', iconPath, '--out', tempPath], { stdio: 'ignore' });
|
||
} catch {
|
||
return null;
|
||
}
|
||
if (!(await pathExists(tempPath))) return null;
|
||
try {
|
||
const bytes = await fsp.readFile(tempPath);
|
||
return `data:image/png;base64,${bytes.toString('base64')}`;
|
||
} finally {
|
||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||
}
|
||
};
|
||
|
||
const resolveAppIconPath = async (appPath) => {
|
||
if (!appPath || !(await pathExists(appPath))) return null;
|
||
const resourcesPath = path.join(appPath, 'Contents', 'Resources');
|
||
if (!(await pathExists(resourcesPath))) return null;
|
||
let entries;
|
||
try {
|
||
entries = await fsp.readdir(resourcesPath);
|
||
} catch {
|
||
return null;
|
||
}
|
||
const icon = entries.find((entry) => entry.toLowerCase().endsWith('.icns'));
|
||
return icon ? path.join(resourcesPath, icon) : null;
|
||
};
|
||
|
||
const buildInstalledApps = async (apps) => {
|
||
const seen = new Set();
|
||
const names = apps
|
||
.map((raw) => String(raw || '').trim())
|
||
.filter((raw) => raw && !seen.has(raw) && seen.add(raw));
|
||
const results = [];
|
||
for (const name of names) {
|
||
const appPath = await resolveAppBundlePath(name);
|
||
if (!appPath) continue;
|
||
const iconDataUrl = await iconToDataUrl(await resolveAppIconPath(appPath), name);
|
||
results.push({ name, iconDataUrl });
|
||
}
|
||
return results;
|
||
};
|
||
|
||
const parseSshConfigImports = () => {
|
||
const sshConfigPath = path.join(os.homedir(), '.ssh', 'config');
|
||
if (!fs.existsSync(sshConfigPath)) return [];
|
||
const lines = fs.readFileSync(sshConfigPath, 'utf8').split(/\r?\n/);
|
||
const results = [];
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed || trimmed.startsWith('#') || !trimmed.toLowerCase().startsWith('host ')) {
|
||
continue;
|
||
}
|
||
const hosts = trimmed.slice(5).trim().split(/\s+/).filter(Boolean);
|
||
for (const host of hosts) {
|
||
results.push({
|
||
host,
|
||
pattern: /[*?]/.test(host),
|
||
source: sshConfigPath,
|
||
sshCommand: `ssh ${host}`,
|
||
});
|
||
}
|
||
}
|
||
return results;
|
||
};
|
||
|
||
const readDesktopSshInstances = () => {
|
||
const root = readSettingsRoot();
|
||
return { instances: Array.isArray(root.desktopSshInstances) ? root.desktopSshInstances : [] };
|
||
};
|
||
|
||
const writeDesktopSshInstances = async (config) => {
|
||
const root = readSettingsRoot();
|
||
root.desktopSshInstances = Array.isArray(config?.instances) ? config.instances : [];
|
||
await writeSettingsRoot(root);
|
||
return { instances: root.desktopSshInstances };
|
||
};
|
||
|
||
const updateHostUrlForSshInstance = async (id, label, localUrl) => {
|
||
const config = readDesktopHostsConfig();
|
||
const nextHosts = config.hosts.filter((entry) => entry.id !== id);
|
||
nextHosts.push({ id, label, url: localUrl });
|
||
await writeDesktopHostsConfig({ hosts: nextHosts, defaultHostId: config.defaultHostId });
|
||
};
|
||
|
||
const JETBRAINS_APP_IDS = new Set([
|
||
'pycharm',
|
||
'intellij',
|
||
'webstorm',
|
||
'phpstorm',
|
||
'rider',
|
||
'rustrover',
|
||
'android-studio',
|
||
]);
|
||
|
||
const CLI_BY_APP_ID = {
|
||
vscode: 'code',
|
||
cursor: 'cursor',
|
||
vscodium: 'codium',
|
||
windsurf: 'windsurf',
|
||
zed: 'zed',
|
||
};
|
||
|
||
const buildOpenProjectSpecs = ({ projectPath, appId, appName }) => {
|
||
if (appId === 'finder') {
|
||
return [{ program: 'open', args: [projectPath] }];
|
||
}
|
||
|
||
if (appId === 'terminal' || appId === 'iterm2' || appId === 'ghostty') {
|
||
return [{ program: 'open', args: ['-a', appName, projectPath] }];
|
||
}
|
||
|
||
const specs = [];
|
||
|
||
const cli = CLI_BY_APP_ID[appId];
|
||
if (cli) {
|
||
specs.push({ program: cli, args: ['-n', projectPath] });
|
||
}
|
||
|
||
if (JETBRAINS_APP_IDS.has(appId)) {
|
||
specs.push({ program: 'open', args: ['-na', appName, '--args', projectPath] });
|
||
}
|
||
|
||
specs.push({ program: 'open', args: ['-a', appName, projectPath] });
|
||
return specs;
|
||
};
|
||
|
||
const buildOpenFileSpecs = ({ filePath, appId, appName }) => {
|
||
if (appId === 'finder') {
|
||
return [{ program: 'open', args: ['-R', filePath] }];
|
||
}
|
||
|
||
const parentDir = path.dirname(filePath);
|
||
if (appId === 'terminal' || appId === 'iterm2' || appId === 'ghostty') {
|
||
return [{ program: 'open', args: ['-a', appName, parentDir] }];
|
||
}
|
||
|
||
const specs = [];
|
||
|
||
const cli = CLI_BY_APP_ID[appId];
|
||
if (cli) {
|
||
specs.push({ program: cli, args: [filePath] });
|
||
}
|
||
|
||
specs.push({ program: 'open', args: ['-a', appName, filePath] });
|
||
return specs;
|
||
};
|
||
|
||
const runSpecChain = (specs, appName) => {
|
||
const failures = [];
|
||
for (const spec of specs) {
|
||
const result = spawnSync(spec.program, spec.args, { stdio: 'ignore' });
|
||
if (result.error) {
|
||
failures.push(`${spec.program}: ${result.error.message}`);
|
||
continue;
|
||
}
|
||
if (result.status === 0) {
|
||
return;
|
||
}
|
||
failures.push(`${spec.program} exited ${result.status}`);
|
||
}
|
||
throw new Error(`Failed to open in ${appName}: ${failures.join('; ')}`);
|
||
};
|
||
|
||
const handleInvoke = async (browserWindow, command, args = {}) => {
|
||
switch (command) {
|
||
case 'desktop_start_window_drag':
|
||
return null;
|
||
|
||
case 'desktop_is_window_fullscreen':
|
||
return Boolean(browserWindow?.isFullScreen());
|
||
|
||
case 'desktop_set_window_title':
|
||
if (browserWindow && typeof args.title === 'string') {
|
||
browserWindow.setTitle(args.title);
|
||
}
|
||
return null;
|
||
|
||
case 'desktop_get_app_version':
|
||
return APP_VERSION;
|
||
|
||
case 'desktop_save_markdown_file': {
|
||
const defaultPath = typeof args.defaultFileName === 'string' ? args.defaultFileName.trim() : '';
|
||
if (!defaultPath) {
|
||
throw new Error('Default file name is required');
|
||
}
|
||
|
||
const content = typeof args.content === 'string' ? args.content : '';
|
||
const result = await dialog.showSaveDialog(browserWindow || undefined, {
|
||
defaultPath,
|
||
filters: [{ name: 'Markdown', extensions: ['md'] }],
|
||
});
|
||
if (result.canceled || !result.filePath) {
|
||
return null;
|
||
}
|
||
|
||
await fsp.writeFile(result.filePath, content, 'utf8');
|
||
return result.filePath;
|
||
}
|
||
|
||
case 'desktop_read_file': {
|
||
const rawPath = typeof args.path === 'string' ? args.path : '';
|
||
if (!rawPath) throw new Error('Path is required');
|
||
// Defense in depth behind the IPC origin gate: even our own UI (or a
|
||
// prompt-injected agent) can't read credential stores. Resolve the
|
||
// path, require it under $HOME or tmpdir, and refuse known secret dirs
|
||
// / dotfiles commonly holding keys.
|
||
const filePath = path.resolve(rawPath);
|
||
const home = os.homedir() || '';
|
||
const tmp = os.tmpdir() || '';
|
||
const underHome = home && (filePath === home || filePath.startsWith(home + path.sep));
|
||
const underTmp = tmp && (filePath === tmp || filePath.startsWith(tmp + path.sep));
|
||
if (!underHome && !underTmp) {
|
||
throw new Error('File is outside the allowed workspace');
|
||
}
|
||
const DENIED_SEGMENTS = ['.ssh', '.aws', '.gnupg', '.gpg', '.config/gh', '.config/openchamber/credentials'];
|
||
const relFromHome = underHome ? filePath.slice(home.length + 1) : '';
|
||
const relNormalized = relFromHome.split(path.sep).join('/');
|
||
if (DENIED_SEGMENTS.some((segment) => relNormalized === segment || relNormalized.startsWith(`${segment}/`))) {
|
||
throw new Error('Access to this path is not allowed');
|
||
}
|
||
const basename = path.basename(filePath).toLowerCase();
|
||
if (basename === '.env' || basename.startsWith('.env.') || basename.endsWith('.pem') || basename.endsWith('.key')) {
|
||
throw new Error('Access to this path is not allowed');
|
||
}
|
||
const stats = await fsp.stat(filePath);
|
||
if (stats.size > 50 * 1024 * 1024) {
|
||
throw new Error('File is too large. Maximum size is 50MB.');
|
||
}
|
||
const bytes = await fsp.readFile(filePath);
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
const mime = ({
|
||
'.png': 'image/png',
|
||
'.jpg': 'image/jpeg',
|
||
'.jpeg': 'image/jpeg',
|
||
'.gif': 'image/gif',
|
||
'.webp': 'image/webp',
|
||
'.svg': 'image/svg+xml',
|
||
'.bmp': 'image/bmp',
|
||
'.ico': 'image/x-icon',
|
||
'.pdf': 'application/pdf',
|
||
'.txt': 'text/plain',
|
||
'.md': 'text/markdown',
|
||
'.json': 'application/json',
|
||
'.js': 'text/javascript',
|
||
'.ts': 'text/typescript',
|
||
'.tsx': 'text/typescript-jsx',
|
||
'.jsx': 'text/javascript-jsx',
|
||
'.html': 'text/html',
|
||
'.css': 'text/css',
|
||
'.py': 'text/x-python',
|
||
})[ext] || 'application/octet-stream';
|
||
return { mime, base64: bytes.toString('base64'), size: bytes.length };
|
||
}
|
||
|
||
case 'desktop_notify':
|
||
maybeShowNativeNotification(args);
|
||
return null;
|
||
|
||
case 'desktop_clear_cache':
|
||
await session.defaultSession.clearStorageData();
|
||
for (const browserWindow of BrowserWindow.getAllWindows()) {
|
||
browserWindow.webContents.reload();
|
||
}
|
||
return null;
|
||
|
||
case 'desktop_open_path': {
|
||
const targetPath = typeof args.path === 'string' ? args.path.trim() : '';
|
||
const appName = typeof args.app === 'string' ? args.app.trim() : '';
|
||
if (!targetPath) throw new Error('Path is required');
|
||
if (process.platform === 'darwin') {
|
||
const openArgs = appName ? ['-a', appName, targetPath] : [targetPath];
|
||
spawn('open', openArgs, { detached: true, stdio: 'ignore' }).unref();
|
||
return null;
|
||
}
|
||
await shell.openPath(targetPath);
|
||
return null;
|
||
}
|
||
|
||
case 'desktop_reveal_path': {
|
||
const targetPath = typeof args.path === 'string' ? args.path.trim() : '';
|
||
if (!targetPath) {
|
||
throw new Error('Path is required');
|
||
}
|
||
|
||
const stats = await fsp.stat(targetPath).catch(() => null);
|
||
if (stats?.isDirectory()) {
|
||
await shell.openPath(targetPath);
|
||
return null;
|
||
}
|
||
|
||
shell.showItemInFolder(targetPath);
|
||
return null;
|
||
}
|
||
|
||
case 'desktop_open_in_app': {
|
||
if (process.platform !== 'darwin') {
|
||
throw new Error('desktop_open_in_app is only supported on macOS');
|
||
}
|
||
const projectPath = typeof args.projectPath === 'string' ? args.projectPath.trim() : '';
|
||
const appId = typeof args.appId === 'string' ? args.appId.trim().toLowerCase() : '';
|
||
const appName = typeof args.appName === 'string' ? args.appName.trim() : '';
|
||
if (!projectPath || !appId || !appName) {
|
||
throw new Error('Project path, app id, and app name are required');
|
||
}
|
||
runSpecChain(buildOpenProjectSpecs({ projectPath, appId, appName }), appName);
|
||
return null;
|
||
}
|
||
|
||
case 'desktop_open_file_in_app': {
|
||
if (process.platform !== 'darwin') {
|
||
throw new Error('desktop_open_file_in_app is only supported on macOS');
|
||
}
|
||
const filePath = typeof args.filePath === 'string' ? args.filePath.trim() : '';
|
||
const appId = typeof args.appId === 'string' ? args.appId.trim().toLowerCase() : '';
|
||
const appName = typeof args.appName === 'string' ? args.appName.trim() : '';
|
||
if (!filePath || !appId || !appName) {
|
||
throw new Error('File path, app id, and app name are required');
|
||
}
|
||
runSpecChain(buildOpenFileSpecs({ filePath, appId, appName }), appName);
|
||
return null;
|
||
}
|
||
|
||
case 'desktop_filter_installed_apps': {
|
||
if (process.platform !== 'darwin') {
|
||
throw new Error('desktop_filter_installed_apps is only supported on macOS');
|
||
}
|
||
if (!Array.isArray(args.apps)) return [];
|
||
const results = await Promise.all(
|
||
args.apps.map(async (appName) => (await isAppBundleInstalled(String(appName))) ? String(appName) : null)
|
||
);
|
||
return results.filter(Boolean);
|
||
}
|
||
|
||
case 'desktop_fetch_app_icons': {
|
||
if (process.platform !== 'darwin') {
|
||
throw new Error('desktop_fetch_app_icons is only supported on macOS');
|
||
}
|
||
const names = Array.isArray(args.apps) ? args.apps : [];
|
||
const results = [];
|
||
for (const name of names) {
|
||
const appPath = await resolveAppBundlePath(String(name));
|
||
if (!appPath) continue;
|
||
const dataUrl = await iconToDataUrl(await resolveAppIconPath(appPath), String(name));
|
||
if (dataUrl) results.push({ app: String(name), dataUrl });
|
||
}
|
||
return results;
|
||
}
|
||
|
||
case 'desktop_get_installed_apps': {
|
||
if (process.platform !== 'darwin') {
|
||
throw new Error('desktop_get_installed_apps is only supported on macOS');
|
||
}
|
||
const cachePath = buildInstalledAppsCachePath();
|
||
const now = Math.floor(Date.now() / 1000);
|
||
let cache = null;
|
||
try {
|
||
cache = JSON.parse(await fsp.readFile(cachePath, 'utf8'));
|
||
} catch {
|
||
}
|
||
const cachedApps = Array.isArray(cache?.apps) ? cache.apps : [];
|
||
const hasCache = Boolean(cache);
|
||
const isCacheStale = !cache || (now - Number(cache.updatedAt || 0)) > INSTALLED_APPS_CACHE_TTL_SECS;
|
||
const refresh = async () => {
|
||
const apps = await buildInstalledApps(Array.isArray(args.apps) ? args.apps : []);
|
||
await fsp.mkdir(path.dirname(cachePath), { recursive: true });
|
||
await fsp.writeFile(cachePath, JSON.stringify({ updatedAt: now, apps }, null, 2));
|
||
emitToAllWindows('openchamber:installed-apps-updated', apps);
|
||
};
|
||
if (!hasCache || isCacheStale || args.force === true) {
|
||
void refresh();
|
||
}
|
||
return { apps: cachedApps, hasCache, isCacheStale };
|
||
}
|
||
|
||
case 'desktop_hosts_get':
|
||
return readDesktopHostsConfig();
|
||
|
||
case 'desktop_hosts_set': {
|
||
await writeDesktopHostsConfig(args.input || args.config || {});
|
||
const updatedConfig = readDesktopHostsConfig();
|
||
const envTarget = normalizeHostUrl(process.env.OPENCHAMBER_SERVER_URL || '');
|
||
state.bootOutcome = computeBootOutcome({
|
||
envTargetUrl: envTarget || null,
|
||
probe: null,
|
||
config: updatedConfig,
|
||
localAvailable: Boolean(state.sidecarUrl || state.localOrigin),
|
||
});
|
||
state.initScript = buildInitScript(state.localOrigin, state.bootOutcome);
|
||
log.info('[electron] hosts config updated, recomputed bootOutcome', state.bootOutcome);
|
||
return null;
|
||
}
|
||
|
||
case 'desktop_host_probe':
|
||
return probeHostWithTimeout(String(args.url || ''), 2_000);
|
||
|
||
case 'desktop_set_window_theme': {
|
||
const mode = typeof args.themeMode === 'string' ? args.themeMode : '';
|
||
const variant = typeof args.themeVariant === 'string' ? args.themeVariant : '';
|
||
nativeTheme.themeSource = mode === 'dark' || variant === 'dark'
|
||
? 'dark'
|
||
: (mode === 'light' || variant === 'light' ? 'light' : 'system');
|
||
return null;
|
||
}
|
||
|
||
case 'desktop_set_vibrancy': {
|
||
// Vibrancy (macOS blur) is not supported in the Electron shell — the
|
||
// Tauri build used NSVisualEffectView via Tauri plugin, Electron has
|
||
// no equivalent for our titleBarStyle:'hidden' setup. Persist the
|
||
// disabled state so settings UI reflects it; args.enabled is ignored.
|
||
const root = readSettingsRoot();
|
||
root.desktopVibrancy = false;
|
||
await writeSettingsRoot(root);
|
||
return { enabled: false, requiresRestart: false };
|
||
}
|
||
|
||
case 'desktop_check_for_updates': {
|
||
const currentVersion = APP_VERSION;
|
||
let payload = null;
|
||
try {
|
||
const response = await fetch(UPDATE_METADATA_URL, { signal: AbortSignal.timeout(10_000) });
|
||
payload = await response.json();
|
||
} catch {
|
||
}
|
||
|
||
let updateResult = null;
|
||
try {
|
||
updateResult = await autoUpdater.checkForUpdates();
|
||
} catch {
|
||
}
|
||
|
||
const updateInfo = updateResult?.updateInfo;
|
||
const nextVersion =
|
||
(typeof updateInfo?.version === 'string' && updateInfo.version) ||
|
||
(typeof payload?.version === 'string' && payload.version) ||
|
||
currentVersion;
|
||
const available = compareSemver(nextVersion, currentVersion) > 0;
|
||
const body =
|
||
(typeof payload?.notes === 'string' && payload.notes.trim() ? payload.notes : null) ||
|
||
(typeof updateInfo?.releaseNotes === 'string' && updateInfo.releaseNotes.trim() ? updateInfo.releaseNotes : null) ||
|
||
await parseRelevantChangelogNotes(currentVersion, nextVersion);
|
||
state.pendingUpdate = available ? { version: nextVersion, metadata: payload, electronUpdate: updateResult } : null;
|
||
return {
|
||
available,
|
||
currentVersion,
|
||
version: available ? nextVersion : null,
|
||
body: body || null,
|
||
date:
|
||
(typeof updateInfo?.releaseDate === 'string' && updateInfo.releaseDate) ||
|
||
(typeof payload?.pub_date === 'string' ? payload.pub_date : null),
|
||
};
|
||
}
|
||
|
||
case 'desktop_download_and_install_update':
|
||
if (!state.pendingUpdate) {
|
||
throw new Error('No pending update');
|
||
}
|
||
emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({
|
||
event: 'Started',
|
||
data: {
|
||
contentLength: null,
|
||
},
|
||
}));
|
||
if (!state.pendingUpdate.electronUpdate) {
|
||
throw new Error('Electron updater metadata is not available for this build');
|
||
}
|
||
await autoUpdater.downloadUpdate();
|
||
state.pendingUpdate.downloaded = true;
|
||
emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({
|
||
event: 'Finished',
|
||
data: {},
|
||
}));
|
||
return null;
|
||
|
||
case 'desktop_restart':
|
||
if (state.pendingUpdate?.downloaded && app.isPackaged) {
|
||
autoUpdater.quitAndInstall(false, true);
|
||
return null;
|
||
}
|
||
app.relaunch();
|
||
app.exit(0);
|
||
return null;
|
||
|
||
case 'desktop_get_lan_address':
|
||
return await detectLanIPv4Address();
|
||
|
||
case 'desktop_new_window': {
|
||
const config = readDesktopHostsConfig();
|
||
const localUiUrl = state.sidecarUrl || state.localOrigin;
|
||
let targetUrl = localUiUrl;
|
||
if (config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID) {
|
||
const host = config.hosts.find((entry) => entry.id === config.defaultHostId);
|
||
if (host?.url && !state.unreachableHosts.has(host.url)) {
|
||
targetUrl = host.url;
|
||
}
|
||
}
|
||
await createAdditionalWindow(targetUrl);
|
||
return null;
|
||
}
|
||
|
||
case 'desktop_new_window_at_url': {
|
||
const targetUrl = normalizeHostUrl(String(args.url || ''));
|
||
if (!targetUrl) {
|
||
throw new Error('Invalid URL');
|
||
}
|
||
await createAdditionalWindow(targetUrl);
|
||
return null;
|
||
}
|
||
|
||
case 'desktop_ssh_instances_get':
|
||
return sshManager.readInstances();
|
||
|
||
case 'desktop_ssh_instances_set':
|
||
await sshManager.setInstances(args.config || {});
|
||
return null;
|
||
|
||
case 'desktop_ssh_import_hosts':
|
||
return await sshManager.importHosts();
|
||
|
||
case 'desktop_ssh_connect': {
|
||
const id = String(args.id || '').trim();
|
||
await sshManager.connect(id);
|
||
return null;
|
||
}
|
||
|
||
case 'desktop_ssh_disconnect': {
|
||
const id = String(args.id || '').trim();
|
||
await sshManager.disconnect(id);
|
||
return null;
|
||
}
|
||
|
||
case 'desktop_ssh_status': {
|
||
const id = String(args.id || '').trim();
|
||
return await sshManager.statusesWithDefaults(id || undefined);
|
||
}
|
||
|
||
case 'desktop_ssh_logs':
|
||
return sshManager.logsForInstance(String(args.id || '').trim(), Number(args.limit) || 200);
|
||
|
||
case 'desktop_ssh_logs_clear':
|
||
sshManager.clearLogsForInstance(String(args.id || '').trim());
|
||
return null;
|
||
|
||
default:
|
||
throw new Error(`Unknown desktop command: ${command}`);
|
||
}
|
||
};
|
||
|
||
const buildMacMenu = () => {
|
||
const dispatchAction = (action) => dispatchMenuAction(action);
|
||
const handleCopyAction = () => {
|
||
BrowserWindow.getFocusedWindow()?.webContents.copy();
|
||
dispatchAction('copy');
|
||
};
|
||
|
||
return Menu.buildFromTemplate([
|
||
{
|
||
label: app.name,
|
||
submenu: [
|
||
{ role: 'about' },
|
||
{
|
||
label: 'Check for Updates',
|
||
click: () => dispatchCheckForUpdates(),
|
||
},
|
||
{ type: 'separator' },
|
||
{ label: 'Settings', accelerator: 'Cmd+,', click: () => dispatchAction('settings') },
|
||
{ label: 'Command Palette', accelerator: 'Cmd+K', click: () => dispatchAction('command-palette') },
|
||
{ label: 'Quick Open…', accelerator: 'Cmd+P', click: () => dispatchAction('quick-open') },
|
||
{ type: 'separator' },
|
||
{ role: 'services' },
|
||
{ type: 'separator' },
|
||
{ role: 'hide' },
|
||
{ role: 'hideOthers' },
|
||
{ type: 'separator' },
|
||
{ role: 'quit' },
|
||
],
|
||
},
|
||
{
|
||
label: 'File',
|
||
submenu: [
|
||
{ label: 'New Window', accelerator: 'Cmd+Shift+Alt+N', click: () => void handleInvoke(null, 'desktop_new_window') },
|
||
{ type: 'separator' },
|
||
{ label: 'New Session', accelerator: 'Cmd+N', click: () => dispatchAction('new-session') },
|
||
{ label: 'New Worktree', accelerator: 'Cmd+Shift+N', click: () => dispatchAction('new-worktree-session') },
|
||
{ type: 'separator' },
|
||
{ label: 'Add Workspace', click: () => dispatchAction('change-workspace') },
|
||
{ type: 'separator' },
|
||
{ role: 'close' },
|
||
],
|
||
},
|
||
{
|
||
label: 'Edit',
|
||
submenu: [
|
||
{ role: 'undo' },
|
||
{ role: 'redo' },
|
||
{ type: 'separator' },
|
||
{ role: 'cut' },
|
||
{ label: 'Copy', accelerator: 'Cmd+C', click: () => handleCopyAction() },
|
||
{ role: 'paste' },
|
||
{ role: 'selectAll' },
|
||
],
|
||
},
|
||
{
|
||
label: 'View',
|
||
submenu: [
|
||
{ label: 'Git', accelerator: 'Cmd+G', click: () => dispatchAction('open-git-tab') },
|
||
{ label: 'Diff', accelerator: 'Cmd+E', click: () => dispatchAction('open-diff-tab') },
|
||
{ label: 'Files', click: () => dispatchAction('open-files-tab') },
|
||
{ label: 'Terminal', accelerator: 'Cmd+T', click: () => dispatchAction('open-terminal-tab') },
|
||
{ type: 'separator' },
|
||
{ label: 'Light Theme', click: () => dispatchAction('theme-light') },
|
||
{ label: 'Dark Theme', click: () => dispatchAction('theme-dark') },
|
||
{ label: 'System Theme', click: () => dispatchAction('theme-system') },
|
||
{ type: 'separator' },
|
||
{ label: 'Toggle Session Sidebar', accelerator: 'Cmd+L', click: () => dispatchAction('toggle-sidebar') },
|
||
{ label: 'Toggle Memory Debug', accelerator: 'Cmd+Shift+D', click: () => dispatchAction('toggle-memory-debug') },
|
||
{ type: 'separator' },
|
||
{ role: 'togglefullscreen' },
|
||
],
|
||
},
|
||
{
|
||
label: 'Window',
|
||
submenu: [
|
||
{ role: 'minimize' },
|
||
{ role: 'zoom' },
|
||
{ type: 'separator' },
|
||
{ role: 'close' },
|
||
],
|
||
},
|
||
{
|
||
label: 'Help',
|
||
submenu: [
|
||
{ label: 'Keyboard Shortcuts', accelerator: 'Cmd+.', click: () => dispatchAction('help-dialog') },
|
||
{ label: 'Show Diagnostics', accelerator: 'Cmd+Shift+L', click: () => dispatchAction('download-logs') },
|
||
{ type: 'separator' },
|
||
{ label: 'Clear Cache', click: () => void handleInvoke(null, 'desktop_clear_cache') },
|
||
{ type: 'separator' },
|
||
{ label: 'Report a Bug', click: () => shell.openExternal(GITHUB_BUG_REPORT_URL) },
|
||
{ label: 'Request a Feature', click: () => shell.openExternal(GITHUB_FEATURE_REQUEST_URL) },
|
||
{ type: 'separator' },
|
||
{ label: 'Join Discord', click: () => shell.openExternal(DISCORD_INVITE_URL) },
|
||
],
|
||
},
|
||
]);
|
||
};
|
||
|
||
contextMenu({
|
||
showInspectElement: isDev,
|
||
showSaveImageAs: true,
|
||
showCopyImage: true,
|
||
showCopyLink: true,
|
||
});
|
||
|
||
// All desktop_* IPC and dialog:open run with full Electron main privileges
|
||
// (fs access, shell.openPath, spawn, app.relaunch, …). The preload shim is
|
||
// injected into every webContents in the window, including remote hosts the
|
||
// user switches to via DesktopHostSwitcher. Without a gate, a malicious
|
||
// remote page could read arbitrary local files, open arbitrary apps, etc.
|
||
//
|
||
// Strategy: commands fall into two buckets by capability, not by origin.
|
||
// Window/host-switcher operations (probe a URL, open a new window, set
|
||
// title, read the hosts list) are safe for any renderer. Filesystem,
|
||
// shell.openPath, installed-app scans, app relaunch, and file dialogs
|
||
// are gated to local senders — even the user's own remote UI shouldn't
|
||
// need them, and a compromised remote can't use them either.
|
||
const isLocalSender = (webContents) => {
|
||
try {
|
||
const raw = typeof webContents?.getURL === 'function' ? webContents.getURL() : '';
|
||
if (!raw) return false;
|
||
if (raw.startsWith('file://') || raw === 'about:blank') return true;
|
||
const url = new URL(raw);
|
||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
||
const hostname = url.hostname;
|
||
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return true;
|
||
if (state.localOrigin) {
|
||
try {
|
||
const allowed = new URL(state.localOrigin);
|
||
if (allowed.origin === url.origin) return true;
|
||
} catch {
|
||
}
|
||
}
|
||
return false;
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
const COMMANDS_SAFE_FOR_REMOTE = new Set([
|
||
'desktop_hosts_get',
|
||
'desktop_host_probe',
|
||
'desktop_new_window',
|
||
'desktop_new_window_at_url',
|
||
'desktop_set_window_title',
|
||
'desktop_set_window_theme',
|
||
'desktop_is_window_fullscreen',
|
||
'desktop_start_window_drag',
|
||
'desktop_get_app_version',
|
||
'desktop_get_lan_address',
|
||
]);
|
||
|
||
ipcMain.handle('openchamber:invoke', async (event, command, args) => {
|
||
if (!isLocalSender(event.sender) && !COMMANDS_SAFE_FOR_REMOTE.has(command)) {
|
||
log.warn(`[ipc] rejected ${command} from non-local origin: ${event.sender?.getURL?.() || '(unknown)'}`);
|
||
throw new Error('IPC not available for this origin');
|
||
}
|
||
const browserWindow = BrowserWindow.fromWebContents(event.sender);
|
||
return handleInvoke(browserWindow, command, args);
|
||
});
|
||
|
||
ipcMain.handle('openchamber:dialog:open', async (event, options) => {
|
||
// Native file dialogs expose absolute local paths; never grant to remote.
|
||
if (!isLocalSender(event.sender)) {
|
||
log.warn(`[ipc] rejected dialog:open from non-local origin: ${event.sender?.getURL?.() || '(unknown)'}`);
|
||
throw new Error('IPC not available for this origin');
|
||
}
|
||
const browserWindow = BrowserWindow.fromWebContents(event.sender);
|
||
const result = await dialog.showOpenDialog(browserWindow || undefined, {
|
||
title: typeof options?.title === 'string' ? options.title : undefined,
|
||
filters: Array.isArray(options?.filters)
|
||
? options.filters
|
||
.filter((filter) => filter && typeof filter === 'object')
|
||
.map((filter) => ({
|
||
name: typeof filter.name === 'string' && filter.name.trim().length > 0 ? filter.name : 'Files',
|
||
extensions: Array.isArray(filter.extensions)
|
||
? filter.extensions.filter((extension) => typeof extension === 'string' && extension.trim().length > 0)
|
||
: [],
|
||
}))
|
||
: undefined,
|
||
properties: [
|
||
options?.directory ? 'openDirectory' : 'openFile',
|
||
options?.multiple ? 'multiSelections' : null,
|
||
'createDirectory',
|
||
].filter(Boolean),
|
||
});
|
||
if (result.canceled) return null;
|
||
if (options?.multiple) return result.filePaths;
|
||
return result.filePaths[0] || null;
|
||
});
|
||
|
||
app.on('window-all-closed', () => {
|
||
if (process.platform === 'darwin' && !state.quitRequested) {
|
||
return;
|
||
}
|
||
|
||
killSidecar();
|
||
void sshManager.shutdownAll();
|
||
if (process.platform !== 'darwin') {
|
||
app.quit();
|
||
}
|
||
});
|
||
|
||
app.on('before-quit', (event) => {
|
||
if (state.quitConfirmed || process.platform !== 'darwin') {
|
||
state.quitRequested = true;
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
void requestQuitWithConfirmation();
|
||
});
|
||
|
||
app.on('second-instance', (_event, argv) => {
|
||
const urls = Array.isArray(argv)
|
||
? argv.filter((arg) => typeof arg === 'string' && arg.startsWith(`${DEEP_LINK_PROTOCOL}://`))
|
||
: [];
|
||
if (urls.length > 0) handleDeepLinks(urls);
|
||
focusForegroundWindow();
|
||
});
|
||
|
||
app.on('open-url', (event, url) => {
|
||
event.preventDefault();
|
||
handleDeepLinks([url]);
|
||
});
|
||
|
||
app.on('activate', async () => {
|
||
const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed());
|
||
if (windows.length > 0) {
|
||
const visibleWindow = windows.find((window) => window.isVisible());
|
||
const targetWindow = visibleWindow || state.mainWindow || windows[0];
|
||
if (!targetWindow.isVisible()) {
|
||
targetWindow.show();
|
||
}
|
||
targetWindow.focus();
|
||
return;
|
||
}
|
||
|
||
if (state.localOrigin) {
|
||
const config = readDesktopHostsConfig();
|
||
const localUiUrl = state.sidecarUrl || state.localOrigin;
|
||
const host = config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID
|
||
? config.hosts.find((entry) => entry.id === config.defaultHostId)
|
||
: null;
|
||
const targetUrl = host?.url && !state.unreachableHosts.has(host.url) ? host.url : localUiUrl;
|
||
await createAdditionalWindow(targetUrl);
|
||
}
|
||
});
|
||
|
||
app.whenReady().then(async () => {
|
||
log.info('[electron] app starting', {
|
||
version: APP_VERSION,
|
||
packaged: app.isPackaged,
|
||
platform: process.platform,
|
||
arch: process.arch,
|
||
});
|
||
nativeTheme.themeSource = readThemeSource();
|
||
setupAutoUpdater();
|
||
|
||
if (process.platform === 'darwin') {
|
||
Menu.setApplicationMenu(buildMacMenu());
|
||
}
|
||
|
||
const initial = extractInitialDeepLinks();
|
||
if (initial.length > 0) handleDeepLinks(initial);
|
||
|
||
const { initialUrl, localOrigin, bootOutcome } = await resolveInitialUrl();
|
||
await activateMainWindow(initialUrl, localOrigin, bootOutcome);
|
||
startQuitRiskPoller();
|
||
}).catch((error) => {
|
||
log.error('[electron] startup failed:', error);
|
||
app.exit(1);
|
||
});
|