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:
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user