From 746618217950f73a7fba335a53d78306352b0024 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 19:11:05 -0500 Subject: [PATCH 1/3] fix(desktop): adopt existing macOS install + auto-place app First-launch "already installed?" hinged solely on a marker that only the desktop's own bootstrap writes, so a runtime from `install.sh --include-desktop` (or a DMG launch over a prior CLI install) was runnable yet markerless and got the WHOLE installer re-run on top of it. Detect a runnable ACTIVE_HERMES_ROOT (valid source + venv), adopt it (stamp the marker, recording HEAD), and forward straight to the app. Repair keeps forcing a real re-bootstrap. Also: on first packaged macOS launch relocate the bundle into /Applications (Electron relaunches from there) and pin the canonical copy to the Dock once, so users stop re-opening the installer from Downloads/the DMG. --- apps/desktop/electron/main.cjs | 147 ++++++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 2 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index f75fd83ce..cd8c6a08b 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -445,6 +445,10 @@ let bootstrapFailure = null // Active first-launch install, so the renderer's Cancel button (and app quit) // can abort the in-flight install.sh/ps1 instead of leaving it running. let bootstrapAbortController = null +// Set by the renderer's "Repair install" IPC. While true, resolution skips the +// existing-install adopt branch (3b) so repair re-drives the installer instead +// of re-adopting the install we're repairing. Cleared once a bootstrap runs. +let forceBootstrapRepair = false let connectionConfigCache = null const hermesLog = [] const previewWatchers = new Map() @@ -1515,11 +1519,25 @@ function readBootstrapMarker() { return readJson(BOOTSTRAP_COMPLETE_MARKER) } +// Marker-independent: is the canonical install at ACTIVE_HERMES_ROOT actually +// runnable right now? A complete CLI install (`install.sh --include-desktop`) +// or a DMG launch over a prior CLI install satisfies this WITHOUT the desktop +// ever having written the bootstrap marker -- so we must be able to recognise +// "already installed" off the filesystem alone, not just the marker. +function isActiveRuntimeUsable() { + return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT)) +} + function isBootstrapComplete() { const marker = readBootstrapMarker() if (!marker || typeof marker !== 'object') return false if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) return false - if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) return false + if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) { + // Adopted markers (an existing install we detected and took ownership of, + // possibly without a resolvable commit) are still authoritative -- they + // attest a runnable install we deliberately decided to forward to. + if (marker.adopted !== true) return false + } // We DELIBERATELY do NOT verify that the checkout is currently at the // pinned commit -- users update via the in-app update path or `hermes // update`, which moves HEAD legitimately. The marker just attests "we @@ -1527,7 +1545,22 @@ function isBootstrapComplete() { // a runnable venv: an interrupted or split-home install can leave the marker // + checkout without a venv, and trusting that spawns a dead backend // ("gateway offline") instead of re-running bootstrap to repair it. - return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT)) + return isActiveRuntimeUsable() +} + +// HEAD commit of ACTIVE_HERMES_ROOT so an adopted marker carries the same +// provenance a freshly-bootstrapped one would. null when git is unavailable or +// the root isn't a checkout -- the marker stays valid via its `adopted` flag. +function readActiveHeadCommit() { + try { + const sha = execFileSync(resolveGitBinary(), ['-C', ACTIVE_HERMES_ROOT, 'rev-parse', 'HEAD'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }).trim() + return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : null + } catch { + return null + } } function writeBootstrapMarker(payload) { @@ -1536,6 +1569,7 @@ function writeBootstrapMarker(payload) { schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION, pinnedCommit: payload.pinnedCommit || null, pinnedBranch: payload.pinnedBranch || null, + adopted: Boolean(payload.adopted), completedAt: new Date().toISOString(), desktopVersion: app.getVersion() } @@ -1693,6 +1727,24 @@ function resolveHermesBackend(dashboardArgs) { return createActiveBackend(dashboardArgs) } + // 3b. Existing-but-unmarked install at ACTIVE_HERMES_ROOT. The marker is + // written only by OUR bootstrap, so a runtime from `install.sh + // --include-desktop` (or a DMG launch over a prior CLI install) is + // runnable yet markerless -- without this we'd fall to step 6 and re-run + // the WHOLE install on top of a working one. ACTIVE_HERMES_ROOT is our + // canonical location (unlike a random `hermes` on PATH), so adopt it: + // stamp the marker once and forward straight to the app. Repair skips + // this so a broken-but-present venv still gets rebuilt. + if (!forceBootstrapRepair && isActiveRuntimeUsable()) { + rememberLog(`[bootstrap] adopting existing install at ${ACTIVE_HERMES_ROOT}; skipping first-launch setup`) + try { + writeBootstrapMarker({ pinnedCommit: readActiveHeadCommit(), pinnedBranch: null, adopted: true }) + } catch (err) { + rememberLog(`[bootstrap] could not stamp adopted marker: ${err.message}`) + } + return createActiveBackend(dashboardArgs) + } + // 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from // a previous tool-only setup, or pip-installed system-wide. Use it but // do NOT write a bootstrap marker; the user did this themselves and we @@ -1883,6 +1935,9 @@ async function ensureRuntime(backend) { } rememberLog('[bootstrap] bootstrap complete; marker written. Re-resolving backend.') + // A repair (if any) has now re-run, so clear the gate -- the re-resolution + // below SHOULD land on the fresh marker fast-path rather than skip it. + forceBootstrapRepair = false // Re-resolve now that the install exists. The new resolution lands in // step 3 (bootstrap-complete marker) and we recurse to wire venvPython. return ensureRuntime(resolveHermesBackend(backend.args)) @@ -3399,6 +3454,7 @@ ipcMain.handle('hermes:bootstrap:reset', async () => { // full backend flow (including a fresh runBootstrap pass). rememberLog('[bootstrap] reset requested by renderer; clearing latched failure') bootstrapFailure = null + forceBootstrapRepair = false connectionPromise = null bootstrapState = { active: false, @@ -3426,6 +3482,9 @@ ipcMain.handle('hermes:bootstrap:repair', async () => { rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`) } bootstrapFailure = null + // Force the next resolution past both the marker fast-path and the adopt + // branch so the installer actually re-runs (the whole point of repair). + forceBootstrapRepair = true resetHermesConnection() return { ok: true } }) @@ -3941,7 +4000,91 @@ ipcMain.handle('hermes:version', async () => ({ hermesRoot: resolveUpdateRoot() })) +// --------------------------------------------------------------------------- +// macOS first-launch placement: move into /Applications and pin to the Dock +// --------------------------------------------------------------------------- +// +// The DMG and CLI-built apps launch from wherever the user left them (a DMG +// mount, ~/Downloads, ~/.hermes/...) -- which means Gatekeeper translocation, +// no Dock tile, and "which icon do I click?" confusion. On first packaged +// launch we relocate into /Applications (Electron relaunches from there) and, +// once we're that canonical copy, pin to the Dock. Both macOS-only, +// packaged-only, best-effort, run at most once. + +// Move the bundle into /Applications and relaunch. Returns true when a relaunch +// is underway (caller must stop init). No-op in dev, off macOS, or already in +// /Applications. `existsAndRunning` -> another copy owns the slot; don't fight +// it. `exists` -> stale copy; replace it so there's exactly one current app. +function maybeRelocateToApplications() { + if (!IS_MAC || !IS_PACKAGED || process.env.HERMES_DESKTOP_NO_AUTO_MOVE === '1') return false + try { + if (app.isInApplicationsFolder()) return false + const moved = app.moveToApplicationsFolder({ conflictHandler: type => type !== 'existsAndRunning' }) + if (moved) rememberLog('[install] relocated into /Applications; relaunching') + return moved + } catch (err) { + rememberLog(`[install] move to /Applications skipped: ${err.message}`) + return false + } +} + +const DOCK_PINNED_MARKER = 'dock-pinned.json' + +// Pin the /Applications copy to the Dock once. macOS has no Electron API for +// this, so we append to com.apple.dock's persistent-apps and restart the Dock. +// Guarded by a userData marker + membership check so we never duplicate the tile. +function maybePinToDock() { + if (!IS_MAC || !IS_PACKAGED || process.env.HERMES_DESKTOP_NO_DOCK_PIN === '1') return + const marker = path.join(app.getPath('userData'), DOCK_PINNED_MARKER) + if (fileExists(marker)) return + + let bundle + try { + if (!app.isInApplicationsFolder()) return // don't pin a soon-to-be-stale path + bundle = runningAppBundle() + } catch { + return + } + if (!bundle) return + + const done = note => { + try { + fs.writeFileSync(marker, JSON.stringify({ bundle, pinnedAt: new Date().toISOString(), ...note }) + '\n') + } catch { + // best-effort; we re-check next launch (membership guard dedupes) + } + } + + try { + const apps = execFileSync('defaults', ['read', 'com.apple.dock', 'persistent-apps'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }) + if (apps.includes(bundle)) return done({ alreadyPresent: true }) + } catch { + // persistent-apps may not exist yet; -array-add creates it + } + + const tile = + 'tile-datafile-data' + + `_CFURLString${bundle}_CFURLStringType0` + + '' + try { + execFileSync('defaults', ['write', 'com.apple.dock', 'persistent-apps', '-array-add', tile], { stdio: 'ignore' }) + execFileSync('killall', ['Dock'], { stdio: 'ignore' }) + done() + rememberLog(`[install] pinned to Dock: ${bundle}`) + } catch (err) { + rememberLog(`[install] Dock pin skipped: ${err.message}`) + } +} + app.whenReady().then(() => { + // macOS: relocate into /Applications before anything else so setup + state + // land in the final location; on success this relaunches, so bail here. + if (maybeRelocateToApplications()) return + maybePinToDock() + if (IS_MAC) { Menu.setApplicationMenu(buildApplicationMenu()) } else { From 1daecfa4b0cb5101f9fb1cd6b01a8d877155bab6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 19:30:06 -0500 Subject: [PATCH 2/3] fix(desktop): write Dock tile as a file-reference URL The Dock stores persistent-apps as type-15 file:// URLs; the type-0/raw-path tile we wrote was silently dropped on the next Dock restart (so the pin never took, yet we'd stamped the marker and never retried). Use pathToFileURL + type 15 and flush prefs through cfprefsd before `killall Dock`. Verified end-to-end on a packaged build: move -> adopt -> Dock tile lands as file:///Applications/Hermes.app/. --- apps/desktop/electron/main.cjs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index cd8c6a08b..71aded3fd 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -4047,6 +4047,11 @@ function maybePinToDock() { } if (!bundle) return + // The Dock stores tiles as file-reference URLs (type 15), e.g. + // file:///Applications/Hermes.app/ -- NOT a raw POSIX path. A type-0/raw-path + // tile is silently dropped when the Dock rewrites persistent-apps on restart. + const url = pathToFileURL(bundle.endsWith('/') ? bundle : `${bundle}/`).href + const done = note => { try { fs.writeFileSync(marker, JSON.stringify({ bundle, pinnedAt: new Date().toISOString(), ...note }) + '\n') @@ -4060,20 +4065,23 @@ function maybePinToDock() { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) - if (apps.includes(bundle)) return done({ alreadyPresent: true }) + if (apps.includes(url)) return done({ alreadyPresent: true }) } catch { // persistent-apps may not exist yet; -array-add creates it } const tile = 'tile-datafile-data' + - `_CFURLString${bundle}_CFURLStringType0` + + `_CFURLString${url}_CFURLStringType15` + '' try { execFileSync('defaults', ['write', 'com.apple.dock', 'persistent-apps', '-array-add', tile], { stdio: 'ignore' }) + // Flush the write through cfprefsd before restarting the Dock, otherwise the + // Dock reloads stale prefs and our tile is lost in the race. + execFileSync('defaults', ['read', 'com.apple.dock', 'persistent-apps'], { stdio: 'ignore' }) execFileSync('killall', ['Dock'], { stdio: 'ignore' }) done() - rememberLog(`[install] pinned to Dock: ${bundle}`) + rememberLog(`[install] pinned to Dock: ${url}`) } catch (err) { rememberLog(`[install] Dock pin skipped: ${err.message}`) } From afec339e967f7ae499fc1ed9f05aa57393df784a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 19:42:59 -0500 Subject: [PATCH 3/3] docs(desktop): sync marker schema comment + default dock note arg Address Copilot review: document the `adopted` flag and nullable `pinnedCommit` in the marker schema comment, and default `done(note = {})` so the dock-pinned marker write is unambiguous (object spread of undefined was already a no-op, but explicit is clearer). --- apps/desktop/electron/main.cjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 71aded3fd..7ee3ce64a 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -1510,8 +1510,12 @@ function readJson(filePath) { // Marker schema (version 1): // { // schemaVersion: 1, -// pinnedCommit: "<40-char SHA>", // what install.ps1 was driven against +// pinnedCommit: "<40-char SHA>" | null, // what install.ps1 was driven against; +// // may be null for adopted installs // pinnedBranch: "" | null, +// adopted: , // true when we adopted a pre-existing +// // install rather than bootstrapping it; +// // treated as authoritative even sans commit // completedAt: "", // desktopVersion: "" // for forensics // } @@ -4052,7 +4056,7 @@ function maybePinToDock() { // tile is silently dropped when the Dock rewrites persistent-apps on restart. const url = pathToFileURL(bundle.endsWith('/') ? bundle : `${bundle}/`).href - const done = note => { + const done = (note = {}) => { try { fs.writeFileSync(marker, JSON.stringify({ bundle, pinnedAt: new Date().toISOString(), ...note }) + '\n') } catch {