Merge pull request #37739 from NousResearch/bb/desktop-macos-install-forward

fix(desktop): adopt existing macOS install + auto-place app
This commit is contained in:
brooklyn!
2026-06-02 19:49:05 -05:00
committed by GitHub

View File

@ -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()
@ -1506,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: "<branch name>" | null,
// adopted: <bool>, // true when we adopted a pre-existing
// // install rather than bootstrapping it;
// // treated as authoritative even sans commit
// completedAt: "<ISO 8601>",
// desktopVersion: "<app.getVersion()>" // for forensics
// }
@ -1515,11 +1523,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 +1549,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 +1573,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 +1731,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 +1939,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 +3458,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 +3486,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 +4004,99 @@ 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
// 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')
} 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(url)) return done({ alreadyPresent: true })
} catch {
// persistent-apps may not exist yet; -array-add creates it
}
const tile =
'<dict><key>tile-data</key><dict><key>file-data</key><dict>' +
`<key>_CFURLString</key><string>${url}</string><key>_CFURLStringType</key><integer>15</integer>` +
'</dict></dict></dict>'
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: ${url}`)
} 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 {