macOS desktop: install + in-app self-update (#35607)

* fix(installer): align macOS HERMES_HOME with the rest of the stack

paths.rs computed the macOS Hermes home as ~/Library/Application Support/
hermes, but nothing else does: hermes_constants.get_hermes_home() (Python),
scripts/install.sh, and the Electron desktop's resolveHermesHome() all use
~/.hermes on macOS. The drift meant the Tauri installer wrote the install to
one directory and the desktop looked for it in another, so a fresh GUI
install never found its backend (the file's own comment warned this exact
drift would break things). Use ~/.hermes on macOS to match.

* fix(install.sh): always emit a stage result frame on failure

Stage helpers (clone_repo, install_deps, check_python, …) were written for
the monolithic flow and call `exit 1` on failure. Under `--stage`, that
terminated the process before the JSON result frame was printed, so the
installer's parse_stage_result saw "no frame" instead of a clean
{ok:false,...} contract response. Run the stage body in a subshell so an
`exit` only unwinds the subshell and the parent still emits the frame.

* feat(install.sh): auto-provision git on macOS/Linux (parity with install.ps1)

install.ps1 downloads PortableGit on Windows, but install.sh just printed a
"please install git" hint and exited — so a fresh Mac with no developer tools
(no Xcode CLT → no git) couldn't get past the clone step. check_git now tries
to install git before bailing:
  - macOS: Homebrew if present (headless), else `xcode-select --install`
    (the CLT prompt also provides the compiler some wheels need), polling for
    git to appear.
  - Linux: apt/dnf/pacman via sudo when available.
Falls back to the manual instructions only if auto-provision fails.

* feat(desktop): in-app GUI+backend self-update on macOS/Linux

On Windows the staged Hermes-Setup binary drives updates (quit → hermes
update → hermes desktop --build-only → relaunch). The mac drag-install has no
such binary, so "Update now" previously just printed `hermes update`.

Since there's no venv-shim file lock on POSIX, the desktop can drive the whole
update itself. applyUpdates now, when no staged updater exists on mac/linux:
  1. runs `hermes update --yes [--branch <current>]` (backend git pull + deps),
  2. runs `hermes desktop --build-only` (OS-aware GUI rebuild) with the
     Hermes-managed Node + venv on PATH,
  3. spawns a detached swapper that waits for this process to exit, dittos the
     freshly built Hermes.app over the running bundle, clears quarantine, and
     relaunches.
Degrades to "backend updated — restart to load the new GUI" if the rebuild
fails or there's no .app bundle to swap (dev run, Linux AppImage).

* chore: uptick
This commit is contained in:
brooklyn! 2026-05-30 22:26:08 -05:00 committed by GitHub
parent dfc2fd887e
commit 5f9e0545ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 340 additions and 14 deletions

View file

@ -233,6 +233,23 @@ json_escape() {
-e 's/"/\\"/g'
}
# npm rewrites tracked package-lock.json files non-deterministically during
# `npm install` / `npm run pack`. On a managed install those diffs are never
# intentional, but they leave the checkout dirty — which forces `hermes update`
# to autostash on every run and makes branch switches fragile. Restore them so
# a fresh install ends with a clean tree. Best-effort; only touches lockfiles.
restore_dirty_lockfiles() {
local repo="${1:-$INSTALL_DIR}"
[ -n "$repo" ] && [ -d "$repo/.git" ] || return 0
command -v git >/dev/null 2>&1 || return 0
local dirty
dirty=$(git -C "$repo" diff --name-only 2>/dev/null | grep 'package-lock\.json$' || true)
[ -z "$dirty" ] && return 0
echo "$dirty" | while IFS= read -r f; do
[ -n "$f" ] && git -C "$repo" checkout -- "$f" 2>/dev/null || true
done
}
emit_manifest() {
# Stage-Desktop is included only with --include-desktop, mirroring
# install.ps1: the signed bootstrap installer (Hermes-Setup) passes it so
@ -617,6 +634,73 @@ ensure_fts5() {
fi
}
# Best-effort automatic git provisioning, mirroring install.ps1's Install-Git
# (which downloads PortableGit on Windows). git is required to clone the repo,
# and a fresh "normie" machine with no developer tools won't have it. Returns 0
# if git is available afterwards, non-zero otherwise (caller prints manual
# instructions and aborts).
attempt_install_git() {
case "$OS" in
macos)
# Prefer Homebrew — fully headless when present.
if command -v brew >/dev/null 2>&1; then
log_info "Installing Git via Homebrew..."
brew install git >/dev/null 2>&1 || true
command -v git >/dev/null 2>&1 && return 0
fi
# Fall back to Apple Command Line Tools, which provide git AND the
# compiler some Python wheels need. `xcode-select --install` pops a
# system dialog (Apple gates CLT behind it — it cannot be fully
# silent without MDM), so we trigger it and poll for git to appear.
if command -v xcode-select >/dev/null 2>&1; then
log_info "Requesting Apple Command Line Tools (provides git + compiler)..."
log_info "If a macOS dialog appears, click \"Install\" and accept the license."
xcode-select --install >/dev/null 2>&1 || true
local waited=0
local timeout=900
while [ "$waited" -lt "$timeout" ]; do
if command -v git >/dev/null 2>&1 && git --version >/dev/null 2>&1; then
return 0
fi
sleep 5
waited=$((waited + 5))
if [ $((waited % 60)) -eq 0 ]; then
log_info "Still waiting for Command Line Tools install ($((waited / 60))m)..."
fi
done
fi
return 1
;;
linux)
local sudo_cmd=""
if [ "$(id -u 2>/dev/null || echo 1000)" -ne 0 ]; then
command -v sudo >/dev/null 2>&1 && sudo_cmd="sudo"
fi
case "$DISTRO" in
ubuntu|debian)
log_info "Installing Git via apt..."
$sudo_cmd env DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true
$sudo_cmd env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git >/dev/null 2>&1 || true
;;
fedora)
log_info "Installing Git via dnf..."
$sudo_cmd dnf install -y git >/dev/null 2>&1 || true
;;
arch)
log_info "Installing Git via pacman..."
$sudo_cmd pacman -S --noconfirm git >/dev/null 2>&1 || true
;;
*)
return 1
;;
esac
command -v git >/dev/null 2>&1 && return 0
return 1
;;
esac
return 1
}
check_git() {
log_info "Checking Git..."
@ -638,7 +722,15 @@ check_git() {
fi
fi
log_info "Please install Git:"
# Try to install it automatically before giving up (parity with install.ps1).
log_info "Attempting to install Git automatically..."
if attempt_install_git; then
GIT_VERSION=$(git --version | awk '{print $3}')
log_success "Git $GIT_VERSION installed"
return 0
fi
log_warn "Could not install Git automatically. Please install it manually:"
case "$OS" in
linux)
@ -1833,7 +1925,8 @@ install_node_deps() {
log_success "TUI dependencies installed"
fi
# Keep the checkout clean so `hermes update` doesn't autostash every run.
restore_dirty_lockfiles "$INSTALL_DIR"
}
run_setup_wizard() {
@ -2232,6 +2325,10 @@ install_desktop() {
return 1
fi
log_success "Desktop app built: $app"
# `npm install` + `npm run pack` rewrite lockfiles; restore them so the
# checkout stays clean for the next `hermes update`.
restore_dirty_lockfiles "$INSTALL_DIR"
}
# Each --stage runs in its own process, so (unlike the monolithic main() where
@ -2354,8 +2451,14 @@ run_stage_protocol() {
return 0
fi
# Run the stage body in a subshell so a stage helper that calls `exit 1`
# on failure (clone_repo, install_deps, etc. were written for the monolithic
# flow) only exits the subshell — the parent still reaches the JSON result
# frame below. Without this, a failed --stage would terminate the process
# before emitting the frame and the Rust/Electron parser would see "no
# result frame" instead of a clean {ok:false} contract response.
set +e
run_stage_body "$stage"
( run_stage_body "$stage" )
local code=$?
set -e