mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 13:26:40 +00:00
feat(desktop): bundle, publish, and update the desktop app as MSIX
Wire the desktop app onto the pm store for real distribution: - MSIX bundle: electron-builder config, appx assets, manifest, copilot key + deep-link routing, App Installer + Windows Store variant (sign only the msix; inner binaries covered by the package block map) - Rust CLI shim (apps/desktop/shim) — bundled builds run from the store python + shim, never the venv; payload symlinks relativized so the relocatable venv survives relocation - Cloudflare R2 release pipeline: publish binaries + update feeds, nightly channels/tags, stamp-first version resolution - Update system: gate, uninstall steward, boot bootstrap, release channels, update receipts - install.ps1 reduced to a 361-line stage-protocol bootstrapper (heavy deps are pm's job); darwin updater + update-channel mirror ripped - doctor: main's re-landed TCC anchor kept, termux branches removed Rebuilt from ethie/pm onto the pm-store stack. 22 hot files hand-merged; uv.lock + package-lock.json keep main's newer dep tree; test_engines reads the pm/lock.json pin; lazy_deps.py deleted (all 222 importers migrated to pm in the foundation commit).
This commit is contained in:
1 parent
f9656f111a
commit
47f4ab3a17
192 files changed
+17025
-10106
No files matched your search
@@ -63,7 +63,7 @@ jobs:
|
||||
client-id: ${{ vars.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@v7.0.0 # immutable release. safe to pin.
|
||||
with:
|
||||
node-version: 26
|
||||
cache: npm
|
||||
|
||||
@@ -94,6 +94,9 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Write install stamp
|
||||
run: python3 scripts/write_install_stamp.py --output install-stamp.json --distribution docker --update-mechanism external --source ci
|
||||
|
||||
# Retry once on transient Docker Hub / buildkit pull failures
|
||||
# (connection reset, auth token timeout, rate limiting). The action
|
||||
# generates a unique builder name per invocation so the retry doesn't
|
||||
@@ -119,8 +122,6 @@ jobs:
|
||||
load: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
cache-to: ${{ (github.event_name != 'pull_request') && matrix.cache-to || '' }}
|
||||
|
||||
@@ -206,6 +207,9 @@ jobs:
|
||||
- name: Checkout trusted source
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Write install stamp
|
||||
run: python3 scripts/write_install_stamp.py --output install-stamp.json --distribution docker --update-mechanism external --source ci
|
||||
|
||||
# Retry once on transient Docker Hub / buildkit pull failures.
|
||||
# See build job for rationale; same pattern.
|
||||
- name: Set up Docker Buildx
|
||||
@@ -234,8 +238,6 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
cache-to: ${{ matrix.cache-to }}
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@v7.0.0 # immutable release. safe to pin.
|
||||
with:
|
||||
node-version: 26
|
||||
cache: npm
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
xdg-utils libatspi2.0-0 libdrm2 libgbm1 libasound2t64
|
||||
|
||||
# ── Node ───────────────────────────────────────────────────────────
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@v7.0.0 # immutable release. safe to pin.
|
||||
with:
|
||||
node-version: 26
|
||||
cache: npm
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@v7.0.0 # immutable release. safe to pin.
|
||||
with:
|
||||
node-version: 26
|
||||
cache: npm
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@v7.0.0 # immutable release. safe to pin.
|
||||
with:
|
||||
node-version: 26
|
||||
cache: npm
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
name: Nightly Release
|
||||
|
||||
# Publishes the nightly prerelease channel: a dated prerelease versioned
|
||||
# NEXT-MINOR over the newest stable tag
|
||||
# (v<stable.minor+1>.0-nightly.<YYYYMMDDHHMMSS>), so a nightly always
|
||||
# outversions every stable build of the current line and loses to the
|
||||
# next stable minor — which is exactly how the electron-updater channel
|
||||
# switch works in both directions (see hermes_cli/update_channel.py).
|
||||
# Second-precision suffix: manual dispatches can publish several
|
||||
# nightlies per day; the scheduled run publishes at most one because
|
||||
# release.py skips when HEAD has no new commits since the last nightly.
|
||||
#
|
||||
# ALL of it lives in scripts/release.py --nightly: the tag math, the
|
||||
# skip gate, the draft release, and the dispatch of the desktop build.
|
||||
# This workflow only supplies a schedule and credentials, which is why
|
||||
# `release.py --nightly --publish` by hand does exactly what the cron
|
||||
# does — same tag, same draft, same build, same auto-publish.
|
||||
#
|
||||
# The desktop build uploads the installers to the Cloudflare R2 release
|
||||
# bucket and publishes the draft when the whole matrix is green. A failed
|
||||
# matrix leaves a draft: inspectable, re-runnable, and unreachable by
|
||||
# users. The prune job deletes both the GitHub releases+tags and the R2
|
||||
# nightly objects older than 14 days.
|
||||
#
|
||||
# schedule: is the ONE sanctioned time trigger in this repo (nightly
|
||||
# exemption). Everything else stays event-driven.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 8 * * *' # daily, 0300 EST / 0800 UTC, middle of the night when most stuff will be moving less
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: write # tag push + release create/delete for prune
|
||||
actions: write # release.py dispatches the desktop build
|
||||
|
||||
concurrency:
|
||||
group: nightly-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
name: Tag, draft, and start the nightly build
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# The changelog walks commits since the previous nightly tag
|
||||
fetch-depth: 0
|
||||
# the tag math reads the full tag list
|
||||
fetch-tags: true
|
||||
|
||||
- name: Tag and draft
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
python3 scripts/release.py --nightly --publish --remote origin
|
||||
|
||||
prune:
|
||||
name: Prune nightlies older than 14 days
|
||||
runs-on: ubuntu-24.04
|
||||
needs: tag
|
||||
environment: release-signing
|
||||
# Prune even when there was nothing to tag today. The desktop build
|
||||
# runs in its own workflow run now, so this no longer waits on it —
|
||||
# the window is 14 days, far wider than any build.
|
||||
if: always() && needs.tag.result != 'cancelled'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- name: Prune
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 scripts/release.py --prune-nightlies --publish --remote origin
|
||||
- name: Prune R2 nightly objects
|
||||
env:
|
||||
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
|
||||
CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
|
||||
CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
|
||||
CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }}
|
||||
run: node scripts/r2-release.mjs prune-nightlies --keep-days 14
|
||||
@@ -0,0 +1,124 @@
|
||||
name: PM Bundle
|
||||
|
||||
# Stages the self-contained agent payload for each (os, arch) target with
|
||||
# `hermes pm bundle`: repo snapshot + tool store + facts + a relocatable
|
||||
# venv built on the staged python-build-standalone interpreter. Each
|
||||
# payload is smoke-tested on its native runner — the staged interpreter
|
||||
# boots hermes_cli out of the payload with no network and no PYTHONPATH —
|
||||
# as a pre-merge check for the pm tooling itself.
|
||||
#
|
||||
# Staging is native per target (no cross-target wheel tables anywhere):
|
||||
# the runner IS the target. pm's arch guard fails the job if any staged
|
||||
# mismatches, before anything ships.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Git ref to bundle (default: the triggering ref)'
|
||||
required: false
|
||||
type: string
|
||||
pull_request:
|
||||
paths:
|
||||
- 'pm/**'
|
||||
- 'uv.lock'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/pm-bundle.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pm-bundle-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
bundle:
|
||||
name: bundle ${{ matrix.target.label }}
|
||||
runs-on: ${{ matrix.target.runner }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target:
|
||||
- label: linux-x64
|
||||
runner: ubuntu-24.04
|
||||
- label: linux-arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
- label: darwin-arm64
|
||||
runner: macos-15
|
||||
- label: darwin-x64
|
||||
runner: macos-15-intel
|
||||
- label: win32-x64
|
||||
runner: windows-2025
|
||||
- label: win32-arm64
|
||||
runner: windows-11-arm
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
fetch-tags: true
|
||||
|
||||
# The host uv only bootstraps a python to run pm itself; every
|
||||
# payload tool (uv included) is staged by pm from pm/lock.json.
|
||||
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # 9.0.0
|
||||
with:
|
||||
enable-cache: false
|
||||
|
||||
# One cache for the pm store: keyed on the lockfile, so a pin bump
|
||||
# rotates it. pm verifies every restored entry against the lock
|
||||
# (hash-named fetches, verify() on entries) — the cache is an
|
||||
# optimization, never a proof.
|
||||
- name: Cache pm store
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: build/agent-payload/tools
|
||||
key: pm-store-${{ matrix.target.label }}-${{ hashFiles('pm/lock.json', 'uv.lock') }}
|
||||
|
||||
# win32-arm64 builds cryptography from sdist (no win_arm64 wheel);
|
||||
# its Rust core links OpenSSL statically from vcpkg.
|
||||
- name: Cache vcpkg OpenSSL (arm64)
|
||||
if: matrix.target.label == 'win32-arm64'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: C:\vcpkg\installed\arm64-windows-static-md
|
||||
key: vcpkg-openssl-arm64-windows-static-md-${{ runner.os }}
|
||||
|
||||
- name: Install OpenSSL (arm64)
|
||||
if: matrix.target.label == 'win32-arm64'
|
||||
shell: bash
|
||||
run: |
|
||||
if [ ! -f "/c/vcpkg/installed/arm64-windows-static-md/lib/libcrypto.lib" ]; then
|
||||
"$VCPKG_INSTALLATION_ROOT/vcpkg" install openssl:arm64-windows-static-md
|
||||
fi
|
||||
{
|
||||
printf 'OPENSSL_DIR=C:\\vcpkg\\installed\\arm64-windows-static-md\n'
|
||||
printf 'OPENSSL_STATIC=1\n'
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Stage the payload
|
||||
shell: bash
|
||||
env:
|
||||
# Windows runners default to cp1252; pm prints UTF-8 (✓/✗).
|
||||
PYTHONUTF8: '1'
|
||||
# ring/obstore's C sources need clang, not MSVC, on windows-arm64.
|
||||
# Scoped to the cargo target triple so every other sdist keeps MSVC.
|
||||
CC_aarch64_pc_windows_msvc: ${{ matrix.target.label == 'win32-arm64' && 'clang' || '' }}
|
||||
run: |
|
||||
# The bootstrap interpreter tracks the payload interpreter's
|
||||
# minor version — one authority (pm/lock.json), no drift.
|
||||
PYVER=$(python3 -c "import json; v=json.load(open('pm/lock.json'))['packages']['python']['version']; print('.'.join(v.split('+')[0].split('.')[:2]))" 2>/dev/null || python -c "import json; v=json.load(open('pm/lock.json'))['packages']['python']['version']; print('.'.join(v.split('+')[0].split('.')[:2]))")
|
||||
# Archive what actions/checkout actually checked out. On
|
||||
# pull_request events github.sha names a merge commit that a
|
||||
# force-push invalidates mid-run ("not a tree object").
|
||||
uv run --no-project --python "$PYVER" python -m pm.cli bundle \
|
||||
--out build/agent-payload \
|
||||
--ref HEAD
|
||||
|
||||
# The payload must boot with nothing from this checkout: the staged
|
||||
# venv's interpreter, cwd at the staged REPO (the desktop spawn
|
||||
# convention — sys.path[0] from cwd survives relocation where the
|
||||
# editable install's absolute path would not), no PYTHONPATH.
|
||||
- name: Smoke test the payload
|
||||
shell: bash
|
||||
run: bash scripts/smoke-payload.sh build/agent-payload
|
||||
@@ -34,28 +34,22 @@ class OTLPUnavailable(RuntimeError):
|
||||
"""Raised when the optional OpenTelemetry SDK isn't installed."""
|
||||
|
||||
|
||||
def _require_sdk(*, auto_install: bool = True, prompt: bool = True):
|
||||
def _require_sdk(*, auto_install: bool = True):
|
||||
"""Import the OTel SDK, lazily installing it on first use if needed.
|
||||
|
||||
Routes through tools.lazy_deps (feature 'export.otlp') so a missing SDK
|
||||
Routes through pm.ensure_import('otlp') so a missing SDK
|
||||
triggers the standard venv install flow — same as every other optional
|
||||
backend — gated by security.allow_lazy_installs and TTY-prompted. Falls back
|
||||
to OTLPUnavailable (with a manual install hint) when the SDK can't be made
|
||||
backend — gated by security.allow_lazy_installs. Falls back to
|
||||
OTLPUnavailable (with a manual install hint) when the SDK can't be made
|
||||
importable (lazy installs disabled, install failed, or auto_install=False).
|
||||
|
||||
``auto_install``: attempt the lazy install when missing (default True).
|
||||
``prompt``: ask before installing when interactive (default True); pass
|
||||
False from non-interactive contexts like the continuous streamer.
|
||||
"""
|
||||
if auto_install:
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("export.otlp", prompt=prompt)
|
||||
except ImportError:
|
||||
pass # lazy_deps unavailable — fall through to the import attempt
|
||||
from pm import ensure_import as _lazy_ensure
|
||||
_lazy_ensure("otlp")
|
||||
except Exception:
|
||||
# FeatureUnavailable (lazy installs disabled / declined / failed) —
|
||||
# fall through; the import below raises OTLPUnavailable with the hint.
|
||||
# Any lazy-install failure — fall through; the import below
|
||||
# raises OTLPUnavailable with the hint.
|
||||
pass
|
||||
try:
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
@@ -240,7 +234,7 @@ def start_streaming(
|
||||
``event_filter`` scopes the exporter to its plane, e.g. gateway-health
|
||||
export, so enabling one plane cannot silently export unrelated events.
|
||||
|
||||
Non-interactive context (startup): attempts a lazy install with prompt=False
|
||||
Non-interactive context (startup): attempts a lazy install
|
||||
so a configured-but-missing SDK is installed once (gated by
|
||||
security.allow_lazy_installs), then streams. If it still can't load, logs and
|
||||
no-ops — never blocks or raises into startup.
|
||||
@@ -248,7 +242,7 @@ def start_streaming(
|
||||
if not is_enabled(config):
|
||||
return None
|
||||
try:
|
||||
_require_sdk(prompt=False)
|
||||
_require_sdk()
|
||||
except OTLPUnavailable:
|
||||
logger.warning("monitoring.export.otlp.enabled but the OTel SDK could not "
|
||||
"be installed/imported; install 'hermes-agent[otlp]'")
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Building the Desktop Installers
|
||||
|
||||
This document tells you how the bundled desktop installers are built, and how
|
||||
to build and test one on your machine. For the app architecture, read
|
||||
`AGENTS.md` in this directory.
|
||||
|
||||
## What a bundle is
|
||||
|
||||
A bundled installer contains the full Hermes runtime. The user installs one
|
||||
file and gets everything. Nothing downloads at first launch.
|
||||
|
||||
The installer contains:
|
||||
|
||||
- The Electron app (the chat surface).
|
||||
- The agent source tree at the release tag, without `.git`.
|
||||
- `uv` and a CPython interpreter for the target architecture.
|
||||
- A ready `site-packages` tree, built from the lockfile.
|
||||
- A Node runtime and the prebuilt JS surfaces (ui-tui, dashboard SPA).
|
||||
- An install stamp (`install-stamp.json`) recording the tag, the commit, the
|
||||
distribution, the update mechanism, and where the managed runtime dir
|
||||
lives.
|
||||
|
||||
The app runs the backend directly from its own resources. This is the
|
||||
"bundled" install axis, built by `pm bundle` against the `pm/lock.json` pin
|
||||
table — every managed tool (uv, python, node, npm, ripgrep, git, chromium,
|
||||
cua-driver) is digest-pinned and staged at build time.
|
||||
|
||||
## The installer for each platform
|
||||
|
||||
| Platform | Artifact | Notes |
|
||||
|---|---|---|
|
||||
| Windows | MSIX `.msix` / `.msixbundle` | The shipping artifact. Signed with Azure Trusted Signing. Out-of-store installs update via the OS App Installer (.appinstaller source; the app checks + prompts, the OS applies). Store-submission builds (HERMES_DESKTOP_VARIANT=store) use the Partner Center identity and update via the Store. |
|
||||
| macOS | `.dmg` | Signed and notarized when the `APPLE_*` / `CSC_*` secrets are set. Updated via electron-updater against the `latest-mac.yml` feed. |
|
||||
| Linux | unpacked / AppImage | Unsigned. |
|
||||
|
||||
NSIS is intentionally dead (D1 decision): Windows ships MSIX only.
|
||||
|
||||
## How the build works
|
||||
|
||||
One script drives the whole build:
|
||||
|
||||
```
|
||||
node scripts/build-bundled-desktop.mjs --tag=vX.Y.Z
|
||||
```
|
||||
|
||||
The script always runs every step:
|
||||
|
||||
1. **Gate the toolchain.** The host `node` and `npm` must satisfy
|
||||
`package.json` engines, and the toolchain pins resolve from
|
||||
`pm/lock.json` (the "Resolve toolchain pins" CI step). The payload embeds
|
||||
these exact pinned versions, so gate == embed.
|
||||
2. **Build the JS surfaces.** ui-tui (with hermes-ink) and the dashboard SPA.
|
||||
3. **Build the desktop app.** `npm run build` in `apps/desktop`: vite,
|
||||
electron-main bundle, native deps, then payload staging.
|
||||
4. **Stage the agent payload** (`pm bundle`). This snapshots the repo at the
|
||||
tag with `git archive`, copies the prebuilt JS surfaces in, installs the
|
||||
pinned CPython and `site-packages`, stages the pinned managed tools, and
|
||||
writes `manifest.json` plus the install stamp. Each staged binary must
|
||||
prove the target architecture in its own version banner. A wrong-
|
||||
architecture binary fails the build.
|
||||
5. **Package with electron-builder.** MSIX on Windows, DMG on macOS.
|
||||
|
||||
Payload staging stays dormant unless `HERMES_DESKTOP_VARIANT=bundled` is
|
||||
set. The build script sets it. A normal `npm run dev` or `npm run pack`
|
||||
without the script does not stage payloads.
|
||||
|
||||
## Code signing (Windows)
|
||||
|
||||
Signing turns on when the `AZURE_SIGN_*` environment variables are set:
|
||||
|
||||
```
|
||||
AZURE_SIGN_ENDPOINT https://cus.codesigning.azure.net
|
||||
AZURE_SIGN_ACCOUNT codesign2
|
||||
AZURE_SIGN_PROFILE hermesagent
|
||||
AZURE_SIGN_PUBLISHER CN=Nous Research Inc., ...
|
||||
AZURE_CLIENT_ID (the OIDC app id)
|
||||
```
|
||||
|
||||
`electron-builder.config.cjs` reads these variables and composes the
|
||||
`win.sign` configuration itself. Do not pass the values as `-c` arguments:
|
||||
the publisher name contains spaces, and spaces do not survive the cmd.exe
|
||||
argument hop. Signing runs through `scripts/sign-msix.mjs`, a plain hook
|
||||
that signs ONLY the .msix package: Windows validates the package signature
|
||||
(AppxSignature.p7x over AppxBlockMap.xml), and inner binaries are covered
|
||||
by the block-map hashes — per-file Authenticode is neither required nor
|
||||
validated.
|
||||
|
||||
On win32, `scripts/after-pack.mjs` runs `sanitize-pe-signatures.mjs` before
|
||||
the MSIX pack: python-build-standalone's `llvm-strip` can leave dangling PE
|
||||
certificate tables, and one dangling table fails the whole package with
|
||||
0x800700C1. This is a hard-fail step, not best-effort.
|
||||
|
||||
## The MSIX manifest
|
||||
|
||||
`assets/msix-manifest.xml` is the stock app-builder-lib template plus the
|
||||
`uap5`/`desktop4` namespaces needed by the CLI execution aliases, and the
|
||||
`uap3` fragment (declared on its own root) that registers the app as a
|
||||
Windows Copilot hardware key provider. The extensions file
|
||||
(`build/msix-extensions.xml`) is generated at config-require time by
|
||||
`writeMsixExtensions()` in `electron-builder.config.cjs`. Keep the manifest
|
||||
in sync with app-builder-lib when electron-builder bumps.
|
||||
|
||||
## Code signing (macOS)
|
||||
|
||||
The macOS build signs and notarizes with electron-builder's builtin
|
||||
notarization when the `APPLE_ID` / `APPLE_APP_SPECIFIC_PASSWORD` /
|
||||
`APPLE_TEAM_ID` secrets are present. The `sign-nested-chromium` path is dead
|
||||
(the browser ships in the payload; nested signing is handled by the
|
||||
electron-builder signing pass).
|
||||
|
||||
## Local build
|
||||
|
||||
```
|
||||
cd apps/desktop
|
||||
npm install
|
||||
npm run dist:win:msix # or dist:mac / dist:linux
|
||||
```
|
||||
|
||||
For a fast dev loop without payload staging:
|
||||
|
||||
```
|
||||
npm run dev # vite + electron against a dev backend
|
||||
```
|
||||
@@ -78,7 +78,7 @@ npm run dev:fake-boot # exercise the startup overlay with deterministic delays
|
||||
|
||||
```bash
|
||||
npm run dist:mac # DMG + zip
|
||||
npm run dist:win # NSIS + MSI
|
||||
npm run dist:win # MSIX
|
||||
npm run dist:linux # AppImage + deb + rpm
|
||||
npm run pack # unpacked app under release/ (no installer)
|
||||
```
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Custom MSIX manifest for Hermes desktop.
|
||||
|
||||
Forked from app-builder-lib's templates/msix/appxmanifest.xml. The PM
|
||||
payload console-script aliases are uap5 extensions. The package root
|
||||
must declare uap5 and list it in IgnorableNamespaces.
|
||||
|
||||
Keep this template in sync with app-builder-lib when electron-builder
|
||||
changes. The only intended delta is the uap5 namespace declaration and
|
||||
IgnorableNamespaces entry.
|
||||
-->
|
||||
<!--suppress XmlUnusedNamespaceDeclaration -->
|
||||
<Package
|
||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"
|
||||
xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
|
||||
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
|
||||
xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4"
|
||||
xmlns:desktop6="http://schemas.microsoft.com/appx/manifest/desktop/windows10/6"
|
||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||
IgnorableNamespaces="uap5 uap10 desktop4 desktop6">
|
||||
<!-- use single quotes to avoid double quotes escaping in the publisher value -->
|
||||
<Identity Name="${identityName}"
|
||||
ProcessorArchitecture="${arch}"
|
||||
Publisher='${publisher}'
|
||||
Version="${version}" />
|
||||
<Properties>
|
||||
<DisplayName>${displayName}</DisplayName>
|
||||
<PublisherDisplayName>${publisherDisplayName}</PublisherDisplayName>
|
||||
<Description>${description}</Description>
|
||||
<Logo>${logo}</Logo>
|
||||
${packageIntegrity}
|
||||
</Properties>
|
||||
<Resources>
|
||||
${resourceLanguages}
|
||||
</Resources>
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="${minVersion}" MaxVersionTested="${maxVersionTested}" />
|
||||
</Dependencies>
|
||||
${capabilities}
|
||||
<Applications>
|
||||
<Application Id="${applicationId}" Executable="${executable}" EntryPoint="Windows.FullTrustApplication">
|
||||
<uap:VisualElements
|
||||
BackgroundColor="${backgroundColor}"
|
||||
DisplayName="${displayName}"
|
||||
Square150x150Logo="${square150x150Logo}"
|
||||
Square44x44Logo="${square44x44Logo}"
|
||||
Description="${description}">
|
||||
${defaultTile}
|
||||
${lockScreen}
|
||||
${splashScreen}
|
||||
</uap:VisualElements>
|
||||
${extensions}
|
||||
</Application>
|
||||
</Applications>
|
||||
</Package>
|
||||
@@ -7,6 +7,10 @@ export interface ActiveRuntimeState {
|
||||
hasValidMarker: boolean
|
||||
shouldUseActiveRuntime: boolean
|
||||
usabilityReason: 'usable' | 'unusable'
|
||||
/** The canonical-root install stamp (written by the bootstrap), when the
|
||||
* active runtime was desktop-installed. Populated by the caller
|
||||
* (main.ts activeRuntimeState); undefined when never set. */
|
||||
canonicalInstallStamp?: { source?: unknown; commit?: unknown; branch?: unknown } | null
|
||||
}
|
||||
|
||||
export function hasValidBootstrapMarker(
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
checkAppInstallerUpdate,
|
||||
PLACEHOLDER_FEED_BASE_URL,
|
||||
triggerAppInstallerUpdate,
|
||||
win32AppInstallerFeedPath
|
||||
} from './app-updater'
|
||||
|
||||
// ── feed hosting + paths ────────────────────────────────────────────
|
||||
|
||||
test('the feed base URL placeholder is a documented dead end', () => {
|
||||
// Overridden by `updates.desktop_feed_base_url` (read inline in main.ts);
|
||||
// the placeholder itself must never resolve to a real host.
|
||||
assert.equal(PLACEHOLDER_FEED_BASE_URL, 'https://updates.invalid/hermes-desktop')
|
||||
})
|
||||
|
||||
test('win32 App Installer feed paths are per-channel and per-variant', () => {
|
||||
assert.equal(win32AppInstallerFeedPath('stable', false), 'win32/stable/')
|
||||
assert.equal(win32AppInstallerFeedPath('nightly', false), 'win32/nightly/')
|
||||
assert.equal(win32AppInstallerFeedPath('stable', true), 'win32/light/stable/')
|
||||
assert.equal(win32AppInstallerFeedPath('nightly', true), 'win32/light/nightly/')
|
||||
})
|
||||
|
||||
// ── win32 arm (OS App Installer checker + trigger) ─────────────────
|
||||
|
||||
function fakePayloadRunner(stdout: string, code = 0) {
|
||||
const calls: string[] = []
|
||||
return {
|
||||
runner: {
|
||||
python: 'C:\\payload\\tools\\cpython\\python.exe',
|
||||
script: 'C:\\payload\\scripts\\check-appinstaller-update.py',
|
||||
run: async (python: string, script: string) => {
|
||||
calls.push(`${python} ${script}`)
|
||||
return { code, stdout }
|
||||
}
|
||||
} as any,
|
||||
calls
|
||||
}
|
||||
}
|
||||
|
||||
test('win32 check reports available when the OS says a newer package exists', async () => {
|
||||
const { runner } = fakePayloadRunner(JSON.stringify({ available: true, availability: 'Available' }))
|
||||
const result = await checkAppInstallerUpdate(runner)
|
||||
|
||||
assert.equal(result.available, true)
|
||||
assert.equal(result.availability, 'Available')
|
||||
})
|
||||
|
||||
test('win32 check reports not-available cleanly', async () => {
|
||||
const { runner } = fakePayloadRunner(JSON.stringify({ available: false, availability: 'NoApplicableUpdate' }))
|
||||
const result = await checkAppInstallerUpdate(runner)
|
||||
|
||||
assert.equal(result.available, false)
|
||||
})
|
||||
|
||||
test('win32 check surfaces an unknown verdict (missing winrt) without crashing', async () => {
|
||||
const { runner } = fakePayloadRunner(JSON.stringify({ available: null, error: 'winrt import failed' }), 1)
|
||||
const result = await checkAppInstallerUpdate(runner)
|
||||
|
||||
assert.equal(result.available, null)
|
||||
assert.match(result.error || '', /winrt import failed/)
|
||||
})
|
||||
|
||||
test('win32 trigger opens ms-appinstaller with the channel .appinstaller after teardown', async () => {
|
||||
const calls: string[] = []
|
||||
const shell = {
|
||||
openExternal: async (url: string) => void calls.push(`open:${url}`)
|
||||
}
|
||||
|
||||
const result = await triggerAppInstallerUpdate(
|
||||
'https://updates.example.com/',
|
||||
'stable',
|
||||
false,
|
||||
shell as any,
|
||||
() => void calls.push('teardown')
|
||||
)
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.deepEqual(calls, [
|
||||
'teardown',
|
||||
'open:ms-appinstaller:?source=https%3A%2F%2Fupdates.example.com%2Fwin32%2Fstable%2Fstable.appinstaller'
|
||||
])
|
||||
})
|
||||
|
||||
test('win32 trigger light+nightly targets the light nightly feed dir', async () => {
|
||||
const calls: string[] = []
|
||||
const shell = {
|
||||
openExternal: async (url: string) => void calls.push(url)
|
||||
}
|
||||
|
||||
await triggerAppInstallerUpdate('https://updates.example.com', 'nightly', true, shell as any)
|
||||
|
||||
assert.equal(calls[0], 'ms-appinstaller:?source=https%3A%2F%2Fupdates.example.com%2Fwin32%2Flight%2Fnightly%2Fnightly.appinstaller')
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
// app-updater.ts — the win32 update arm for bundled desktop installs.
|
||||
//
|
||||
// win32 — the OS App Installer owns the apply. The package was installed
|
||||
// from an .appinstaller, which registered the feed URI as the
|
||||
// package's update source; the OS checks it and swaps the package
|
||||
// wholesale. The app's only job is the checker: ask the OS whether
|
||||
// an update is available (via the bundled payload python's winrt),
|
||||
// show its own prompt, run graceful teardown, then trigger
|
||||
// ms-appinstaller: and quit. Installations that Windows manages
|
||||
// for us — Microsoft Store deployments (process.windowsStore) and
|
||||
// stamps whose updateMechanism is 'external' — get NO in-app
|
||||
// updater at all: the store/steward owns the update loop.
|
||||
//
|
||||
// Source installs never reach this module. The callers gate on the install
|
||||
// stamp first and fall through to the git-based update path.
|
||||
//
|
||||
// NOTE: the darwin electron-updater arm (and its gate/channel/feed helpers —
|
||||
// shouldUseAppUpdater, selectUpdaterArm, resolveUpdaterChannel,
|
||||
// resolveFeedBaseUrl, feedSelection, describeFeedCheck) was ripped out in
|
||||
// wt/darwin-updater: main.ts reimplemented the gate/channel/feed inline, so
|
||||
// the module surface was test-only. To add macOS in-app updates back, restore
|
||||
// the arm from git history (this file @ the parent of that commit) together
|
||||
// with the `electron-updater` dependency and its test block — see the
|
||||
// add-back plan in the wt/darwin-updater commit message.
|
||||
//
|
||||
// The win32 helpers are pure so vitest covers them; the impure pieces
|
||||
// (electron shell, payload python) are injected.
|
||||
|
||||
// ─── feed hosting ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* PLACEHOLDER — the default base URL of the desktop release feeds.
|
||||
*
|
||||
* W3 (R2 hosting) supplies the real value; until it lands, this default is
|
||||
* a documented dead end that config overrides (`updates.desktop_feed_base_url`
|
||||
* in config.yaml — read inline by main.ts). Layout under the base:
|
||||
*
|
||||
* <base>/win32/<channel>/ — App Installer feed dir (.appinstaller
|
||||
* + .msixbundle) for out-of-store MSIX.
|
||||
*
|
||||
* `<channel>` is 'stable' or 'nightly'.
|
||||
*/
|
||||
export const PLACEHOLDER_FEED_BASE_URL = 'https://updates.invalid/hermes-desktop'
|
||||
|
||||
/**
|
||||
* The App Installer feed dir for a channel. The .appinstaller for a channel
|
||||
* lives under this dir; the OS re-reads it on launch (HoursBetweenUpdateChecks)
|
||||
* and swaps the bundle when a newer version is there. The light variant feeds
|
||||
* from its own subtree so the two variants can never serve each other's
|
||||
* packages.
|
||||
*/
|
||||
export function win32AppInstallerFeedPath(
|
||||
channel: 'stable' | 'nightly',
|
||||
light: boolean
|
||||
): string {
|
||||
const variant = light ? 'light/' : ''
|
||||
return `win32/${variant}${channel}/`
|
||||
}
|
||||
|
||||
// ─── win32 arm (OS App Installer checker + trigger) ────────────────────────
|
||||
|
||||
/** The result of an App Installer update check. */
|
||||
export interface AppInstallerCheck {
|
||||
/** null when the check could not run (no winrt, not packaged, error). */
|
||||
available: boolean | null
|
||||
/** Human-readable availability string from the OS, when reported. */
|
||||
availability?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload-python invocation the win32 arm needs. Injectable so vitest
|
||||
* covers the arm without a payload.
|
||||
*/
|
||||
export interface PayloadPythonRunner {
|
||||
/** Absolute path to the bundled payload python (tools/<entry>/python.exe). */
|
||||
python: string
|
||||
/** The checker script's absolute path. */
|
||||
script: string
|
||||
/** Run the script; resolve with {code, stdout}. */
|
||||
run: (python: string, script: string) => Promise<{ code: number; stdout: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* win32 arm: ask the OS whether an App Installer update is available, via
|
||||
* the bundled payload python's winrt (PackageManager.CheckPackageUpdateAvailabilityAsync).
|
||||
* The OS compares the package's registered .appinstaller source against the
|
||||
* installed version; it does NOT download anything.
|
||||
*/
|
||||
export async function checkAppInstallerUpdate(
|
||||
runner: PayloadPythonRunner
|
||||
): Promise<AppInstallerCheck> {
|
||||
const { code, stdout } = await runner.run(runner.python, runner.script)
|
||||
const text = stdout.trim()
|
||||
let parsed: { available?: boolean | null; availability?: string; error?: string; reason?: string } | null = null
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed.available === 'boolean') {
|
||||
return { available: parsed.available, availability: parsed.availability, error: parsed.error }
|
||||
}
|
||||
if (parsed && parsed.available === null) {
|
||||
return { available: null, error: parsed.error || 'checker returned unknown' }
|
||||
}
|
||||
if (code !== 0) {
|
||||
return { available: null, error: parsed?.error || `checker exited ${code}` }
|
||||
}
|
||||
return { available: null, error: 'checker returned no availability' }
|
||||
}
|
||||
|
||||
/**
|
||||
* win32 arm: trigger the OS App Installer to apply the update and quit.
|
||||
* `ms-appinstaller:?source=<feed .appinstaller URL>` makes the OS re-read the
|
||||
* package's update source and install the newer bundle; the app then exits so
|
||||
* the package swap is not contested. `beforeInstall` runs first (backend
|
||||
* teardown while the process is still alive).
|
||||
*/
|
||||
export async function triggerAppInstallerUpdate(
|
||||
feedBaseUrl: string,
|
||||
channel: 'stable' | 'nightly',
|
||||
light: boolean,
|
||||
shell: { openExternal: (url: string) => Promise<void> },
|
||||
beforeInstall?: () => void | Promise<void>
|
||||
): Promise<{ ok: true }> {
|
||||
if (beforeInstall) {
|
||||
await beforeInstall()
|
||||
}
|
||||
|
||||
const appinstallerUrl =
|
||||
`${feedBaseUrl.replace(/\/+$/, '')}/${win32AppInstallerFeedPath(channel, light)}${channel}.appinstaller`
|
||||
await shell.openExternal(`ms-appinstaller:?source=${encodeURIComponent(appinstallerUrl)}`)
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { routeDeepLink } from './deep-link-route'
|
||||
|
||||
describe('routeDeepLink', () => {
|
||||
it('summons quick entry for the Copilot key press', () => {
|
||||
expect(routeDeepLink('copilot-key', 'start')).toBe('quick-entry')
|
||||
})
|
||||
|
||||
it('ignores the Copilot key release so a tap does not undo the summon', () => {
|
||||
expect(routeDeepLink('copilot-key', 'stop')).toBe('ignore')
|
||||
})
|
||||
|
||||
it('ignores unknown copilot-key paths instead of leaking them to the renderer', () => {
|
||||
expect(routeDeepLink('copilot-key', '')).toBe('ignore')
|
||||
expect(routeDeepLink('copilot-key', 'toggle')).toBe('ignore')
|
||||
})
|
||||
|
||||
it('routes every other deep link to the renderer', () => {
|
||||
expect(routeDeepLink('blueprint', 'morning-brief')).toBe('renderer')
|
||||
expect(routeDeepLink('', '')).toBe('renderer')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Where a hermes:// deep link is handled. Pure — main.ts routes on the
|
||||
* result, tests assert the contract.
|
||||
*
|
||||
* 'quick-entry' — summon the ephemeral quick-entry popup in the main
|
||||
* process. Used by the Windows Copilot hardware key: the MSIX manifest
|
||||
* fragment (generated by electron-builder.config.cjs) registers the app
|
||||
* as a copilotkeyprovider whose press activates
|
||||
* <scheme>://copilot-key/start.
|
||||
* A hardware key wants the lightweight composer, not the full window.
|
||||
* 'ignore' — activation noise that must not reach the renderer. The
|
||||
* Copilot key's press-and-hold RELEASE fires copilot-key/stop; a tap
|
||||
* fires start+stop nearly together, so acting on stop would undo the
|
||||
* summon. Unknown copilot-key paths land here too.
|
||||
* 'renderer' — everything else: focus the main window and deliver the
|
||||
* payload to the renderer's deep-link listener (blueprint links etc.).
|
||||
*/
|
||||
export type DeepLinkRoute = 'ignore' | 'quick-entry' | 'renderer'
|
||||
|
||||
export function routeDeepLink(kind: string, name: string): DeepLinkRoute {
|
||||
if (kind === 'copilot-key') {
|
||||
return name === 'start' ? 'quick-entry' : 'ignore'
|
||||
}
|
||||
|
||||
return 'renderer'
|
||||
}
|
||||
@@ -19,12 +19,14 @@
|
||||
*
|
||||
* 1. the Electron dependency is an *exact* version (Electron Builder needs the
|
||||
* installed binary to match ``electronVersion`` / ``electronDist``), and
|
||||
* 2. the dependency, ``build.electronVersion``, and the resolved lockfile entry
|
||||
* all agree — so ``npm ci`` installs exactly what the build packages.
|
||||
* 2. the dependency, electron-builder.config.cjs ``electronVersion``, and the
|
||||
* resolved lockfile entry all agree — so ``npm ci`` installs exactly what
|
||||
* the build packages.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
@@ -67,18 +69,18 @@ test('electron dependency is exactly pinned', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('electron dependency matches build.electronVersion', () => {
|
||||
const pkg = desktopPkg()
|
||||
const spec = electronSpec(pkg)
|
||||
const build = (pkg.build ?? {}) as Record<string, unknown>
|
||||
const builderVersion = build.electronVersion as string | undefined
|
||||
assert.ok(builderVersion, 'build.electronVersion is missing')
|
||||
test('electron dependency matches electron-builder.config.cjs', () => {
|
||||
const spec = electronSpec(desktopPkg())
|
||||
const require = createRequire(import.meta.url)
|
||||
const config = require('../electron-builder.config.cjs') as { electronVersion?: string }
|
||||
const builderVersion = config.electronVersion
|
||||
assert.ok(builderVersion, 'electron-builder.config.cjs electronVersion is missing')
|
||||
assert.equal(
|
||||
spec,
|
||||
builderVersion,
|
||||
`electron dependency ("${spec}") must equal build.electronVersion ` +
|
||||
`("${builderVersion}"); otherwise electron-builder packages a different ` +
|
||||
'version than npm installs into electronDist.'
|
||||
`electron dependency ("${spec}") must equal electron-builder.config.cjs ` +
|
||||
`electronVersion ("${builderVersion}"); otherwise electron-builder packages ` +
|
||||
'a different version than npm installs.'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -14,31 +14,67 @@ import assert from 'node:assert/strict'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
allowedUninstallModes,
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
installKindAllowsCodeRemoval,
|
||||
modeRemovesAgent,
|
||||
modeRemovesUserData,
|
||||
nativeRemovalInstructions,
|
||||
resolveInstallKind,
|
||||
resolveRemovableAppPath,
|
||||
shouldRemoveAppBundle,
|
||||
UNINSTALL_MODES,
|
||||
uninstallArgsForMode
|
||||
} from './desktop-uninstall'
|
||||
|
||||
// --- uninstallArgsForMode ---
|
||||
|
||||
test('uninstallArgsForMode maps each mode to the module-runner argv', () => {
|
||||
assert.deepEqual(uninstallArgsForMode('gui'), ['-m', 'hermes_cli.uninstall', '--mode', 'gui'])
|
||||
assert.deepEqual(uninstallArgsForMode('lite'), ['-m', 'hermes_cli.uninstall', '--mode', 'lite'])
|
||||
assert.deepEqual(uninstallArgsForMode('full'), ['-m', 'hermes_cli.uninstall', '--mode', 'full'])
|
||||
})
|
||||
|
||||
test('uninstallArgsForMode throws on an unknown mode (no silent full wipe)', () => {
|
||||
assert.throws(() => uninstallArgsForMode('nuke'), /Unknown uninstall mode/)
|
||||
assert.throws(() => uninstallArgsForMode(''), /Unknown uninstall mode/)
|
||||
})
|
||||
|
||||
test('UNINSTALL_MODES lists exactly the three supported modes', () => {
|
||||
assert.deepEqual([...UNINSTALL_MODES].sort(), ['full', 'gui', 'lite'])
|
||||
// --- resolveInstallKind / allowedUninstallModes / nativeRemovalInstructions ---
|
||||
|
||||
test('resolveInstallKind reads the stamp: distribution nix wins, payload kind means bundled', () => {
|
||||
assert.equal(resolveInstallKind({ distribution: 'nix' }), 'nix')
|
||||
assert.equal(resolveInstallKind({ source: 'nix' }), 'nix')
|
||||
// distribution beats payload: a nix stamp never becomes 'bundled'.
|
||||
assert.equal(resolveInstallKind({ distribution: 'nix', payload: 'bundled' }), 'nix')
|
||||
assert.equal(resolveInstallKind({ distribution: 'desktop-app', payload: 'bundled' }), 'bundled')
|
||||
assert.equal(resolveInstallKind({ payload: 'bundled' }), 'bundled')
|
||||
// light artifacts have no agent code either — same managed flow.
|
||||
assert.equal(resolveInstallKind({ payload: 'light' }), 'bundled')
|
||||
// bootstrap / no stamp facts at all → the classic source/installer flow.
|
||||
assert.equal(resolveInstallKind({ payload: 'bootstrap' }), 'standard')
|
||||
assert.equal(resolveInstallKind({}), 'standard')
|
||||
assert.equal(resolveInstallKind(), 'standard')
|
||||
})
|
||||
|
||||
test('only standard installs may remove code; managed kinds get data-only', () => {
|
||||
assert.equal(installKindAllowsCodeRemoval('standard'), true)
|
||||
assert.equal(installKindAllowsCodeRemoval('nix'), false)
|
||||
assert.equal(installKindAllowsCodeRemoval('bundled'), false)
|
||||
|
||||
assert.deepEqual(allowedUninstallModes('standard'), ['gui', 'lite', 'full'])
|
||||
assert.deepEqual(allowedUninstallModes('nix'), ['data'])
|
||||
assert.deepEqual(allowedUninstallModes('bundled'), ['data'])
|
||||
})
|
||||
|
||||
test('nativeRemovalInstructions names the steward per kind and OS', () => {
|
||||
assert.match(nativeRemovalInstructions('nix', 'linux'), /installed by Nix/)
|
||||
assert.match(nativeRemovalInstructions('nix', 'darwin'), /flake or profile/)
|
||||
assert.match(nativeRemovalInstructions('bundled', 'win32'), /Installed apps/)
|
||||
assert.match(nativeRemovalInstructions('bundled', 'darwin'), /Trash/)
|
||||
assert.match(
|
||||
nativeRemovalInstructions('bundled', 'linux', '/home/x/Apps/Hermes.AppImage'),
|
||||
/\/home\/x\/Apps\/Hermes\.AppImage/
|
||||
)
|
||||
assert.match(
|
||||
nativeRemovalInstructions('bundled', 'linux', '/opt/hermes/linux-unpacked'),
|
||||
/app directory at \/opt\/hermes\/linux-unpacked/
|
||||
)
|
||||
assert.match(nativeRemovalInstructions('bundled', 'linux'), /wherever you saved it/)
|
||||
})
|
||||
|
||||
// --- modeRemovesAgent / modeRemovesUserData ---
|
||||
@@ -47,10 +83,12 @@ test('mode predicates classify what each mode removes', () => {
|
||||
assert.equal(modeRemovesAgent('gui'), false)
|
||||
assert.equal(modeRemovesAgent('lite'), true)
|
||||
assert.equal(modeRemovesAgent('full'), true)
|
||||
assert.equal(modeRemovesAgent('data'), false)
|
||||
|
||||
assert.equal(modeRemovesUserData('gui'), false)
|
||||
assert.equal(modeRemovesUserData('lite'), false)
|
||||
assert.equal(modeRemovesUserData('full'), true)
|
||||
assert.equal(modeRemovesUserData('data'), true)
|
||||
})
|
||||
|
||||
// --- resolveRemovableAppPath ---
|
||||
|
||||
@@ -28,7 +28,102 @@
|
||||
|
||||
import path from 'node:path'
|
||||
|
||||
const UNINSTALL_MODES = ['gui', 'lite', 'full']
|
||||
import type { ArtifactKind } from './install-stamp'
|
||||
|
||||
const UNINSTALL_MODES = ['gui', 'lite', 'full', 'data']
|
||||
|
||||
// How this desktop app got onto the machine, read from the install stamp
|
||||
// (install-stamp.json) that every packager writes:
|
||||
// 'nix' — a Nix build (stamp distribution 'nix'). The store is
|
||||
// immutable and the install is owned by Nix tooling, so the
|
||||
// app must not remove any code, its own bundle included.
|
||||
// 'bundled' — an embedded artifact (agent payload in resources; the
|
||||
// stamp has payload:true). There is no agent venv under
|
||||
// HERMES_HOME, and the OS owns app removal (Apps & Features
|
||||
// / Trash / delete the AppImage).
|
||||
// 'standard' — everything else: the git-clone install the desktop
|
||||
// installer bootstraps, or a `hermes desktop` source build.
|
||||
// The classic script flow (venv python + rm the bundle) works.
|
||||
//
|
||||
// Only 'standard' installs may remove code. 'nix' and 'bundled' installs
|
||||
// may only remove user data (mode 'data') — the app itself is removed the
|
||||
// native way, per nativeRemovalInstructions().
|
||||
const INSTALL_KINDS = ['nix', 'bundled', 'standard']
|
||||
|
||||
/**
|
||||
* Classify the install from the stamp. Pure so it can be unit-tested:
|
||||
* callers pass the stamp fields (`distribution`, `source`, `payload`).
|
||||
* `distribution` is authoritative; `source` is the schema-1 fallback.
|
||||
* The 'bundled' and 'light' artifact kinds both classify as the managed
|
||||
* 'bundled' flow — neither has agent code the app may remove, and the OS
|
||||
* owns app removal.
|
||||
*/
|
||||
function resolveInstallKind({
|
||||
distribution,
|
||||
source,
|
||||
payload = 'bootstrap'
|
||||
}: { distribution?: string | null; source?: string | null; payload?: ArtifactKind } = {}) {
|
||||
if (distribution === 'nix' || source === 'nix') {
|
||||
return 'nix'
|
||||
}
|
||||
|
||||
if (payload === 'bundled' || payload === 'light') {
|
||||
return 'bundled'
|
||||
}
|
||||
|
||||
return 'standard'
|
||||
}
|
||||
|
||||
/** True when this install kind lets the app remove code (agent / bundle). */
|
||||
function installKindAllowsCodeRemoval(kind) {
|
||||
return kind === 'standard'
|
||||
}
|
||||
|
||||
/**
|
||||
* The modes the uninstall UI may offer for an install kind. Every kind can
|
||||
* remove user data. Only 'standard' may also remove code — there the 'full'
|
||||
* mode already covers data, so 'data' alone is not offered. Managed installs
|
||||
* (nix, bundled) get exactly one destructive action — remove user data —
|
||||
* with app removal handed to the steward via nativeRemovalInstructions().
|
||||
*/
|
||||
function allowedUninstallModes(kind) {
|
||||
return installKindAllowsCodeRemoval(kind) ? ['gui', 'lite', 'full'] : ['data']
|
||||
}
|
||||
|
||||
/**
|
||||
* Human instructions for removing the app itself the native way. Used when
|
||||
* the install kind forbids code removal: Windows owns the bundled app
|
||||
* through Apps & Features, macOS through the Trash, a Linux AppImage is a
|
||||
* single file the user placed somewhere, and a Nix install belongs to the
|
||||
* flake / profile that made it. `appPath` is the resolveRemovableAppPath()
|
||||
* result (the AppImage path on Linux), used only to name the exact file.
|
||||
*/
|
||||
function nativeRemovalInstructions(kind, platform, appPath = null) {
|
||||
if (kind === 'nix') {
|
||||
return (
|
||||
'This Hermes desktop app was installed by Nix. Uninstall it the same way you installed it: ' +
|
||||
'remove hermes-agent from your flake or profile, then rebuild.'
|
||||
)
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
return 'To uninstall, go to Windows Settings → Apps → Installed apps.'
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
return 'Quit the app and drag Hermes.app from Applications to the Trash.'
|
||||
}
|
||||
|
||||
if (appPath && /\.appimage$/i.test(String(appPath))) {
|
||||
return `Delete the AppImage file at ${appPath}.`
|
||||
}
|
||||
|
||||
if (appPath) {
|
||||
return `Delete the app directory at ${appPath}.`
|
||||
}
|
||||
|
||||
return 'Delete the Hermes AppImage (or app directory) from wherever you saved it.'
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an uninstall mode to the `python -m hermes_cli.uninstall` argv (after the
|
||||
@@ -37,7 +132,7 @@ const UNINSTALL_MODES = ['gui', 'lite', 'full']
|
||||
* lite/full delete — see the Finding-3 note in buildWindowsCleanupScript.
|
||||
* Throws on an unknown mode so a typo can't silently become a full wipe.
|
||||
*/
|
||||
function uninstallArgsForMode(mode) {
|
||||
function uninstallArgsForMode(mode: string) {
|
||||
if (!UNINSTALL_MODES.includes(mode)) {
|
||||
throw new Error(`Unknown uninstall mode: ${mode}`)
|
||||
}
|
||||
@@ -45,14 +140,14 @@ function uninstallArgsForMode(mode) {
|
||||
return ['-m', 'hermes_cli.uninstall', '--mode', mode]
|
||||
}
|
||||
|
||||
/** True when `mode` removes the agent (lite/full), false for gui-only. */
|
||||
function modeRemovesAgent(mode) {
|
||||
/** True when `mode` removes the agent code (lite/full), false otherwise. */
|
||||
function modeRemovesAgent(mode: string) {
|
||||
return mode === 'lite' || mode === 'full'
|
||||
}
|
||||
|
||||
/** True when `mode` removes user data (full only). */
|
||||
function modeRemovesUserData(mode) {
|
||||
return mode === 'full'
|
||||
/** True when `mode` removes user data (full and data). */
|
||||
function modeRemovesUserData(mode: string) {
|
||||
return mode === 'full' || mode === 'data'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -257,10 +352,15 @@ function buildWindowsCleanupScript({
|
||||
}
|
||||
|
||||
export {
|
||||
allowedUninstallModes,
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
INSTALL_KINDS,
|
||||
installKindAllowsCodeRemoval,
|
||||
modeRemovesAgent,
|
||||
modeRemovesUserData,
|
||||
nativeRemovalInstructions,
|
||||
resolveInstallKind,
|
||||
resolveRemovableAppPath,
|
||||
shouldRemoveAppBundle,
|
||||
UNINSTALL_MODES,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Tests for electron/external-open.ts — the single route every external URL
|
||||
* open funnels through. All I/O is injected, so the "open failed → notify"
|
||||
* behavior is asserted without loading electron. Run with the electron test
|
||||
* script that runs electron/*.test.ts (same pattern as native-oauth-login).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { type ExternalOpenDeps, openExternalUrl } from './external-open'
|
||||
|
||||
function makeDeps(overrides: Partial<ExternalOpenDeps> = {}) {
|
||||
const calls = {
|
||||
opened: [] as string[],
|
||||
fileOpened: [] as string[],
|
||||
notified: [] as Array<[string, string]>,
|
||||
logged: [] as string[]
|
||||
}
|
||||
|
||||
const deps: ExternalOpenDeps = {
|
||||
isWsl: false,
|
||||
spawn: () => {
|
||||
throw new Error('not used')
|
||||
},
|
||||
openExternal: async url => {
|
||||
calls.opened.push(url)
|
||||
},
|
||||
openFile: async raw => {
|
||||
calls.fileOpened.push(raw)
|
||||
},
|
||||
notifyFailure: (url, message) => calls.notified.push([url, message]),
|
||||
log: line => calls.logged.push(line),
|
||||
...overrides
|
||||
}
|
||||
|
||||
return { deps, calls }
|
||||
}
|
||||
|
||||
test('resolves ok and opens when openExternal succeeds', async () => {
|
||||
const { deps, calls } = makeDeps()
|
||||
|
||||
const result = await openExternalUrl('https://example.com/x', deps)
|
||||
|
||||
assert.deepEqual(result, { ok: true })
|
||||
assert.deepEqual(calls.opened, ['https://example.com/x'])
|
||||
assert.equal(calls.notified.length, 0)
|
||||
})
|
||||
|
||||
test('notifies and resolves failed when openExternal rejects', async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
openExternal: async () => {
|
||||
throw new Error('no method available for opening')
|
||||
}
|
||||
})
|
||||
|
||||
const result = await openExternalUrl('https://example.com', deps)
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
reason: 'failed',
|
||||
message: 'no method available for opening'
|
||||
})
|
||||
assert.deepEqual(calls.notified, [['https://example.com/', 'no method available for opening']])
|
||||
assert.ok(calls.logged.some(line => line.includes('openExternal failed')))
|
||||
})
|
||||
|
||||
test('resolves invalid for a URL the route does not open, with no notify', async () => {
|
||||
const { deps, calls } = makeDeps()
|
||||
|
||||
for (const url of ['', 'not a url', 'ftp://x.com']) {
|
||||
const result = await openExternalUrl(url, deps)
|
||||
assert.equal(result.ok, false)
|
||||
|
||||
if (result.ok === false) {
|
||||
assert.equal(result.reason, 'invalid')
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(calls.opened.length, 0)
|
||||
assert.equal(calls.notified.length, 0)
|
||||
})
|
||||
|
||||
test('dispatches file:// URLs to openFile', async () => {
|
||||
const { deps, calls } = makeDeps()
|
||||
|
||||
const result = await openExternalUrl('file:///C:/x.html', deps)
|
||||
|
||||
assert.deepEqual(result, { ok: true })
|
||||
assert.deepEqual(calls.fileOpened, ['file:///C:/x.html'])
|
||||
})
|
||||
|
||||
test('wsl: spawns cmd.exe and resolves ok on the happy path', async () => {
|
||||
const spawned: string[] = []
|
||||
const proc = new EventEmitter() as unknown as ChildProcess
|
||||
|
||||
const { deps } = makeDeps({
|
||||
isWsl: true,
|
||||
spawn: (cmd, args) => {
|
||||
spawned.push(cmd, ...args)
|
||||
|
||||
return proc
|
||||
}
|
||||
})
|
||||
|
||||
const result = await openExternalUrl('https://example.com', deps)
|
||||
|
||||
assert.deepEqual(result, { ok: true })
|
||||
assert.equal(spawned[0], 'cmd.exe')
|
||||
assert.ok(spawned.some(arg => arg === 'https://example.com/'))
|
||||
})
|
||||
|
||||
test('wsl: falls back to openExternal and notifies when cmd.exe fails to spawn', async () => {
|
||||
const proc = new EventEmitter() as unknown as ChildProcess
|
||||
const { deps, calls } = makeDeps({ isWsl: true, spawn: () => proc })
|
||||
|
||||
deps.openExternal = async url => {
|
||||
calls.opened.push(url)
|
||||
throw new Error('xdg-open missing')
|
||||
}
|
||||
|
||||
const result = await openExternalUrl('https://example.com', deps)
|
||||
assert.deepEqual(result, { ok: true })
|
||||
|
||||
// openExternalUrl's fire-and-forget WSL path resolves before the spawn
|
||||
// error can arrive; drive the error to exercise the fallback.
|
||||
;(proc as unknown as EventEmitter).emit('error', new Error('ENOENT'))
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
|
||||
assert.deepEqual(calls.opened, ['https://example.com/'])
|
||||
assert.deepEqual(calls.notified, [['https://example.com/', 'xdg-open missing']])
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* external-open.ts
|
||||
*
|
||||
* The single route every external URL open in the desktop app flows through.
|
||||
* Electron-free: every side effect (shell.openExternal, spawn, the file
|
||||
* opener, the failure channel) is injected, so the "open failed → notify"
|
||||
* behavior is unit-tested without loading electron — the same shape as
|
||||
* native-oauth-login.ts and connection-config.ts. main.ts wires the real deps.
|
||||
*
|
||||
* A URL is opened exactly once. An open failure is logged and reported
|
||||
* through notifyFailure (the renderer shows a fallback modal with the URL);
|
||||
* the caller reads the result to decide whether an open-failure must also
|
||||
* abort its own flow (the native-OAuth path fails fast, link paths don't).
|
||||
*/
|
||||
|
||||
import type { ChildProcess, SpawnOptions } from 'node:child_process'
|
||||
|
||||
export type ExternalOpenResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: 'invalid' }
|
||||
| { ok: false; reason: 'failed'; message: string }
|
||||
|
||||
export interface ExternalOpenDeps {
|
||||
isWsl: boolean
|
||||
spawn: (cmd: string, args: readonly string[], opts: SpawnOptions) => ChildProcess
|
||||
openExternal: (url: string) => Promise<void>
|
||||
openFile: (rawUrl: string) => Promise<void>
|
||||
notifyFailure: (url: string, message: string) => void
|
||||
log: (line: string) => void
|
||||
}
|
||||
|
||||
const SUPPORTED_WEB = ['http:', 'https:', 'mailto:']
|
||||
|
||||
export function externalOpenErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a URL in the system browser. Resolves `invalid` for a URL the route
|
||||
* does not open (empty, malformed, unsupported scheme). NEVER rejects: an open
|
||||
* failure is logged + reported through notifyFailure and resolves `failed`,
|
||||
* so fire-and-forget callers can `void` the result safely.
|
||||
*/
|
||||
export async function openExternalUrl(
|
||||
rawUrl: string,
|
||||
deps: ExternalOpenDeps
|
||||
): Promise<ExternalOpenResult> {
|
||||
const raw = String(rawUrl || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
return { ok: false, reason: 'invalid' }
|
||||
}
|
||||
|
||||
let parsed: URL
|
||||
|
||||
try {
|
||||
parsed = new URL(raw)
|
||||
} catch {
|
||||
return { ok: false, reason: 'invalid' }
|
||||
}
|
||||
|
||||
if (parsed.protocol === 'file:') {
|
||||
try {
|
||||
await deps.openFile(raw)
|
||||
} catch {
|
||||
// main's openFile handles its own fallback; never surfaced here
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
if (!SUPPORTED_WEB.includes(parsed.protocol)) {
|
||||
return { ok: false, reason: 'invalid' }
|
||||
}
|
||||
|
||||
const url = parsed.toString()
|
||||
|
||||
if (deps.isWsl) {
|
||||
return openViaWsl(url, deps)
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.openExternal(url)
|
||||
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
return failOpen(deps, url, error)
|
||||
}
|
||||
}
|
||||
|
||||
async function openViaWsl(url: string, deps: ExternalOpenDeps): Promise<ExternalOpenResult> {
|
||||
deps.log(`[link] opening via WSL→Windows: ${url}`)
|
||||
|
||||
const proc = deps.spawn('cmd.exe', ['/c', 'start', '""', url], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
// 'error' only fires when the process could not be spawned. In that case
|
||||
// fall back to xdg-open; if that also fails, surface it. The handler runs
|
||||
// asynchronously after this function has already resolved.
|
||||
proc.on('error', error => {
|
||||
deps.log(`[link] cmd.exe start failed: ${error.message}; falling back to xdg-open`)
|
||||
|
||||
deps.openExternal(url).catch(openError => {
|
||||
failOpen(deps, url, openError)
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
proc.unref()
|
||||
} catch {
|
||||
// unref can throw on an already-closed handle in some node versions
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function failOpen(deps: ExternalOpenDeps, url: string, error: unknown): ExternalOpenResult {
|
||||
const message = externalOpenErrorMessage(error)
|
||||
deps.log(`[link] openExternal failed: ${message}`)
|
||||
deps.notifyFailure(url, message)
|
||||
|
||||
return { ok: false, reason: 'failed', message }
|
||||
}
|
||||
@@ -131,3 +131,44 @@ test('remote apply without a waiter has no first-run side effects', async () =>
|
||||
assert.equal(hidden, 0)
|
||||
assert.equal(gate.isLocalBootstrapConfirmed(), true)
|
||||
})
|
||||
|
||||
const bundledDamagedBackend = {
|
||||
activeRoot: '/tmp/hermes-home/hermes-agent',
|
||||
kind: 'bundled-unusable',
|
||||
platform: 'win32',
|
||||
local: 'bundled-damaged'
|
||||
} as const
|
||||
|
||||
test('the gate prompts on bundled-unusable (a damaged bundled payload)', async () => {
|
||||
const prompts = []
|
||||
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })
|
||||
|
||||
const pending = gate.wait(bundledDamagedBackend)
|
||||
|
||||
assert.equal(await settledState(pending), 'pending')
|
||||
assert.equal(prompts.length, 1)
|
||||
assert.equal(gate.hasWaiter(), true)
|
||||
})
|
||||
|
||||
test('continueLocal settles bundled-unusable without any install side effect', async () => {
|
||||
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
|
||||
const pending = gate.wait(bundledDamagedBackend)
|
||||
|
||||
gate.continueLocal()
|
||||
|
||||
assert.equal(await pending, 'continue-local')
|
||||
assert.equal(gate.hasWaiter(), false)
|
||||
assert.equal(gate.isLocalBootstrapConfirmed(), true)
|
||||
})
|
||||
|
||||
test('remote apply on bundled-unusable settles for a remote re-resolution', async () => {
|
||||
let hidden = 0
|
||||
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
|
||||
const pending = gate.wait(bundledDamagedBackend)
|
||||
|
||||
const resumedWaiter = gate.abandonForRemoteApply()
|
||||
|
||||
assert.equal(resumedWaiter, true)
|
||||
assert.equal(hidden, 1)
|
||||
assert.equal(await pending, 'remote-applied')
|
||||
})
|
||||
@@ -2,6 +2,8 @@ interface FirstRunSetupBackend {
|
||||
activeRoot?: string
|
||||
kind?: string
|
||||
platform?: string
|
||||
/** What the local setup card represents: 'none' = installer offer, the rest = use existing. */
|
||||
local?: 'none' | 'installed' | 'bundled' | 'bundled-damaged'
|
||||
}
|
||||
|
||||
interface FirstRunSetupGateOptions {
|
||||
@@ -57,8 +59,15 @@ export function createFirstRunSetupGate({
|
||||
}
|
||||
}
|
||||
|
||||
// Both sentinels need a user decision: 'bootstrap-needed' offers install-or-
|
||||
// connect; 'bundled-unusable' (a bundled install whose payload is damaged)
|
||||
// offers connect-or-reinstall — never install.
|
||||
const shouldGate = (backend?: FirstRunSetupBackend | null) =>
|
||||
Boolean(backend && backend.kind === 'bootstrap-needed' && !localBootstrapConfirmed)
|
||||
Boolean(
|
||||
backend &&
|
||||
(backend.kind === 'bootstrap-needed' || backend.kind === 'bundled-unusable') &&
|
||||
!localBootstrapConfirmed
|
||||
)
|
||||
|
||||
const wait = async (backend?: FirstRunSetupBackend | null) => {
|
||||
if (!shouldGate(backend)) {
|
||||
|
||||
@@ -117,3 +117,108 @@ test('a primary apply without an active first-run gate tears down before reconne
|
||||
assert.deepEqual(teardownPrimaryBackend.mock.calls, [[{ soft: true }]])
|
||||
assert.deepEqual(order, ['clear-failure', 'teardown', 'notify'])
|
||||
})
|
||||
|
||||
const bundledDamagedBackend = {
|
||||
activeRoot: '/tmp/hermes-home/hermes-agent',
|
||||
kind: 'bundled-unusable',
|
||||
platform: 'win32',
|
||||
local: 'bundled-damaged'
|
||||
}
|
||||
|
||||
test('a bundled-unusable continueLocal never reaches an installer — the local step refuses instead', async () => {
|
||||
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
|
||||
|
||||
const runBootstrap = vi.fn()
|
||||
|
||||
// Production ordering (electron/main.ts ensureRuntime): the
|
||||
// bundled-unusable branch throws the reinstall error BEFORE any
|
||||
// runBootstrap call. Model that contract — the installer must never fire
|
||||
// for a bundled install, damaged or not.
|
||||
const ensureLocalRuntime = vi.fn(async (backend: any) => {
|
||||
assert.equal(backend.kind, 'bundled-unusable')
|
||||
assert.equal(backend.local, 'bundled-damaged')
|
||||
|
||||
throw new Error('This app bundles its own Hermes runtime, but the runtime files are missing or damaged. Reinstall Hermes Desktop to restore it.')
|
||||
})
|
||||
|
||||
const pendingConnection = runPrimaryBackendStartup({
|
||||
connectRemote: vi.fn(),
|
||||
ensureLocalRuntime,
|
||||
prepareLocalBackend: vi.fn(async () => bundledDamagedBackend),
|
||||
resolveRemote: vi.fn(async () => null),
|
||||
waitForDecision: gate.wait,
|
||||
waitForLocalStart: vi.fn(async () => {})
|
||||
})
|
||||
|
||||
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
|
||||
|
||||
gate.continueLocal()
|
||||
|
||||
await assert.rejects(pendingConnection, /Reinstall Hermes Desktop to restore it/)
|
||||
assert.equal(ensureLocalRuntime.mock.calls.length, 1)
|
||||
assert.equal(runBootstrap.mock.calls.length, 0)
|
||||
})
|
||||
|
||||
test('a bundled-unusable remote apply connects without ensuring or bootstrapping locally', async () => {
|
||||
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
|
||||
|
||||
const candidateRemote = {
|
||||
authMode: 'token',
|
||||
baseUrl: 'https://gateway.example.com/hermes',
|
||||
source: 'settings',
|
||||
token: 'secret',
|
||||
wsUrl: 'wss://gateway.example.com/hermes/api/ws?token=secret'
|
||||
}
|
||||
|
||||
let savedRemote: typeof candidateRemote | null = null
|
||||
|
||||
const resolveRemote = vi.fn(async () => savedRemote)
|
||||
const connectRemote = vi.fn(async remote => ({ ...remote, mode: 'remote' as const }))
|
||||
const ensureLocalRuntime = vi.fn()
|
||||
const teardownPrimaryBackend = vi.fn(async () => {})
|
||||
const cancelSshBootstrap = vi.fn(async () => {})
|
||||
const teardownSsh = vi.fn(async () => {})
|
||||
const clearLocalBootstrapFailure = vi.fn()
|
||||
const notifyConnectionApplied = vi.fn()
|
||||
|
||||
const pendingConnection = runPrimaryBackendStartup({
|
||||
connectRemote,
|
||||
ensureLocalRuntime,
|
||||
prepareLocalBackend: vi.fn(async () => bundledDamagedBackend),
|
||||
resolveRemote,
|
||||
waitForDecision: gate.wait,
|
||||
waitForLocalStart: vi.fn(async () => {})
|
||||
})
|
||||
|
||||
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
|
||||
|
||||
// Remote is the escape hatch on a damaged bundle: persist + re-home, same
|
||||
// production ordering as the bootstrap-needed path.
|
||||
savedRemote = candidateRemote
|
||||
|
||||
await applyConnectionChange({
|
||||
cancelAndWait: cancelSshBootstrap,
|
||||
isPrimary: true,
|
||||
rehomePrimary: () =>
|
||||
rehomePrimaryConnection({
|
||||
clearLocalBootstrapFailure,
|
||||
mode: 'remote',
|
||||
notifyConnectionApplied,
|
||||
resumeFirstRunRemote: gate.abandonForRemoteApply,
|
||||
teardownPrimaryBackend
|
||||
}),
|
||||
scope: '',
|
||||
sendApplied: notifyConnectionApplied,
|
||||
stopPool: vi.fn(),
|
||||
teardownPrimary: teardownPrimaryBackend,
|
||||
teardownSsh
|
||||
})
|
||||
|
||||
assert.deepEqual(await pendingConnection, {
|
||||
kind: 'remote',
|
||||
connection: { ...candidateRemote, mode: 'remote' }
|
||||
})
|
||||
assert.deepEqual(resolveRemote.mock.calls, [[], []])
|
||||
assert.deepEqual(connectRemote.mock.calls, [[candidateRemote]])
|
||||
assert.equal(ensureLocalRuntime.mock.calls.length, 0)
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
// installShape() — the ONE split every desktop lifecycle decision gates on.
|
||||
//
|
||||
// The stamp is a constant of the artifact; the shape is derived from it
|
||||
// and never from filesystem probes. These tests pin the derivation table
|
||||
// and the probe-independence (a stamp object is enough — no fs access).
|
||||
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { installShape, type InstallStamp } from './install-stamp'
|
||||
|
||||
function stamp(overrides: Partial<InstallStamp>): InstallStamp {
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
commit: 'a'.repeat(40),
|
||||
commitDate: 1755000000,
|
||||
branch: 'main',
|
||||
builtAt: '2026-08-14T00:00:00Z',
|
||||
dirty: false,
|
||||
source: 'ci',
|
||||
distribution: 'desktop-app',
|
||||
updateMechanism: 'electron-updater',
|
||||
baseVersion: '0.21.0',
|
||||
displayVersion: '0.21.0',
|
||||
distance: 0,
|
||||
payload: 'bootstrap',
|
||||
tag: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('installShape', () => {
|
||||
test('a bundled stamp is the bundled shape', () => {
|
||||
expect(installShape(stamp({ payload: 'bundled', tag: 'v0.21.0' }))).toBe('bundled')
|
||||
})
|
||||
|
||||
test('a bootstrap stamp is a checkout — its runtime is a local install', () => {
|
||||
expect(installShape(stamp({ payload: 'bootstrap' }))).toBe('checkout')
|
||||
})
|
||||
|
||||
test('a light stamp is not bundled (no runtime to run at all)', () => {
|
||||
// Light artifacts never resolve a local backend; what matters here is
|
||||
// that they can never be told to run venv machinery as "bundled".
|
||||
expect(installShape(stamp({ payload: 'light' }))).toBe('checkout')
|
||||
})
|
||||
|
||||
test('a dev run (null stamp) is a checkout', () => {
|
||||
expect(installShape(null)).toBe('checkout')
|
||||
})
|
||||
|
||||
test('the shape comes from the stamp alone — no probe can change it', () => {
|
||||
// Same stamp, any filesystem state: the answer is a pure function of
|
||||
// the constant. (The probes live INSIDE the chosen shape as integrity
|
||||
// checks; see resolveHermesBackend.)
|
||||
const bundled = stamp({ payload: 'bundled' })
|
||||
expect(installShape(bundled)).toBe(installShape(bundled))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
// install-stamp.ts — the typed build-time install stamp.
|
||||
//
|
||||
// scripts/write_install_stamp.py writes build/install-stamp.json during
|
||||
// `npm run build`.
|
||||
// bundle-electron-main.mjs bakes that file into the
|
||||
// production bundle by defining the __HERMES_INSTALL_STAMP__ global as
|
||||
// the stamp. The stamp is a constant of the artifact.
|
||||
// It cannot be missing, stale, or edited after signing.
|
||||
//
|
||||
// Dev bundles and test runs define nothing; the typeof guard makes the
|
||||
// stamp null there (a dev run has no artifact to be truthful about).
|
||||
|
||||
/**
|
||||
* The desktop artifact kind — which runtime story this artifact tells:
|
||||
* - 'bootstrap': no runtime in the artifact; first launch bootstraps a
|
||||
* local install (the default; also what non-desktop stamps carry).
|
||||
* - 'bundled': the agent runtime ships inside the artifact resources.
|
||||
* - 'light': no runtime at all; remote connections only.
|
||||
* Selected at build time by HERMES_DESKTOP_VARIANT (unset = bootstrap).
|
||||
*/
|
||||
export type ArtifactKind = 'bootstrap' | 'bundled' | 'light'
|
||||
|
||||
/** Mirrors the dict scripts/write_install_stamp.py::build_stamp returns. */
|
||||
export interface InstallStamp {
|
||||
schemaVersion: number
|
||||
commit: string | null
|
||||
commitDate: number | null
|
||||
branch: string | null
|
||||
builtAt: string | null
|
||||
dirty: boolean
|
||||
/** Build provenance: where the stamp's facts came from. */
|
||||
source: 'build' | 'ci' | 'docker' | 'fallback' | 'git' | 'local' | 'nix' | 'unknown' | null
|
||||
/** The steward of a sealed tree ('desktop-app' | 'docker' | 'nix'), when packaged. */
|
||||
distribution: string | null
|
||||
/** Who applies the next update. Required in every stamp. */
|
||||
updateMechanism: 'self' | 'electron-updater' | 'external'
|
||||
baseVersion: string | null
|
||||
displayVersion: string | null
|
||||
distance: number | null
|
||||
payload: ArtifactKind
|
||||
/** The pinned release tag. Always set for 'bundled' and 'light', never for 'bootstrap'. */
|
||||
tag: string | null
|
||||
}
|
||||
|
||||
declare const __HERMES_INSTALL_STAMP__: InstallStamp | undefined
|
||||
|
||||
/** The baked stamp of this artifact, or null on dev bundles. */
|
||||
export const INSTALL_STAMP: Readonly<InstallStamp> | null =
|
||||
typeof __HERMES_INSTALL_STAMP__ === 'undefined' ? null : Object.freeze(__HERMES_INSTALL_STAMP__)
|
||||
|
||||
/**
|
||||
* The install shape this process runs as — THE single split every
|
||||
* lifecycle decision gates on (mirror of Python's runtime_tree()):
|
||||
* - 'bundled': the runtime ships inside the artifact. Venv machinery,
|
||||
* installers, repair-reinstall escalation and update checkouts must
|
||||
* never run; drift means rebuild, updates mean the steward.
|
||||
* - 'checkout': a git tree with venv machinery, provisioner-on-demand
|
||||
* and `hermes update`.
|
||||
*
|
||||
* Derived from the stamp CONSTANT, never from filesystem probes: a
|
||||
* payload/venv/marker probe answers "is this artifact intact?", not
|
||||
* "which shape am I?" — those probes remain only as integrity checks
|
||||
* inside an already-chosen shape (a bundled stamp with a damaged
|
||||
* payload throws; it must not quietly become a checkout). Dev runs
|
||||
* (null stamp) and bootstrap artifacts are 'checkout': their runtime
|
||||
* is a local install the app bootstraps and maintains.
|
||||
*/
|
||||
export function installShape(stamp: Readonly<InstallStamp> | null = INSTALL_STAMP): 'bundled' | 'checkout' {
|
||||
return stamp?.payload === 'bundled' ? 'bundled' : 'checkout'
|
||||
}
|
||||
@@ -269,7 +269,11 @@ import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
|
||||
import { LEGACY_OAUTH_PARTITION, resolveOauthPartition } from './oauth-partition'
|
||||
import { createParentStartMarkerResolver, parentWatchdogEnv } from './parent-process-identity'
|
||||
import { adoptPayloadVenv, installIdForRoot, isBundledInstall, resolvePayload, type PayloadInfo } from './payload-backend'
|
||||
import { checkAppInstallerUpdate, PLACEHOLDER_FEED_BASE_URL, triggerAppInstallerUpdate } from './app-updater'
|
||||
import { PLACEHOLDER_FEED_BASE_URL } from './app-updater'
|
||||
import { AppInstallerStrategy } from './updater/app-installer'
|
||||
import { ExternalStrategy } from './updater/external'
|
||||
import { resolveUpdaterMechanism } from './updater'
|
||||
import { writePendingRelaunch, consumePendingRelaunch } from './updater/relaunch'
|
||||
import { registerPetOverlayIpc } from './pet-overlay-ipc'
|
||||
import {
|
||||
buildRegistryProfileRoutes,
|
||||
@@ -2856,49 +2860,21 @@ async function resolveHealedBranch(updateRoot, branch) {
|
||||
}
|
||||
|
||||
async function checkUpdates() {
|
||||
// A bundled install has no checkout to pull — the OS App Installer owns
|
||||
// the update loop for out-of-store MSIX installs. Ask the OS whether a
|
||||
// newer package is available on the registered .appinstaller source.
|
||||
// A bundled install has no checkout to pull — resolve the updater
|
||||
// mechanism once and delegate. Out-of-store MSIX asks the OS whether a
|
||||
// newer package is on the registered .appinstaller source; Store installs
|
||||
// report unsupported (the steward owns their update loop).
|
||||
const bundledPayload = resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS })
|
||||
if (bundledPayload) {
|
||||
if (IS_WINDOWS && !isWindowsStore()) {
|
||||
try {
|
||||
const check = await checkAppInstallerUpdate({
|
||||
python: bundledPayload.storePython,
|
||||
// The checker ships inside the payload's repo snapshot (git archive
|
||||
// of the committed tree): <payload>/<repo>/apps/desktop/scripts/.
|
||||
script: path.join(bundledPayload.repoDir, 'apps', 'desktop', 'scripts', 'check-appinstaller-update.py'),
|
||||
run: runPayloadPython
|
||||
})
|
||||
const strategy = resolveBundledUpdateStrategy(bundledPayload)
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
mechanism: 'app-installer',
|
||||
channel: resolveUpdaterChannelFromStamp(),
|
||||
currentVersion: app.getVersion(),
|
||||
latestVersion: null,
|
||||
updateAvailable: check.available === true,
|
||||
// null = unknown (checker unavailable) — surface honestly.
|
||||
error: check.available === null ? (check.error || 'update check unavailable') : undefined,
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
supported: true,
|
||||
mechanism: 'app-installer',
|
||||
error: 'check-failed',
|
||||
message: error?.message || String(error),
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
supported: false,
|
||||
reason: 'bundled-not-appinstaller',
|
||||
message: 'bundled install: updates are applied by the installer (Microsoft Store or App Installer).',
|
||||
return strategy.check().catch(error => ({
|
||||
supported: true,
|
||||
mechanism: strategy.mechanism,
|
||||
error: 'check-failed',
|
||||
message: error?.message || String(error),
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
const updateRoot = resolveUpdateRoot()
|
||||
@@ -3150,6 +3126,42 @@ let updateInFlight = false
|
||||
|
||||
// ── bundled / App Installer helpers ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the updater strategy for a BUNDLED install (payload present).
|
||||
* Dispatch mirrors the pre-strategy ladder exactly: win32 out-of-store →
|
||||
* App Installer arm; Store / other → external (unsupported). The checkout
|
||||
* ladder below appliesUpdates' bundled branch stays in main.ts (it delegates
|
||||
* through the same mechanism resolution via the wire's `mechanism` field).
|
||||
*/
|
||||
function resolveBundledUpdateStrategy(bundledPayload) {
|
||||
const mechanism = resolveUpdaterMechanism({
|
||||
isBundled: true,
|
||||
isWindows: IS_WINDOWS,
|
||||
isWindowsStore: isWindowsStore()
|
||||
})
|
||||
|
||||
if (mechanism === 'app-installer') {
|
||||
return new AppInstallerStrategy({
|
||||
python: bundledPayload.storePython,
|
||||
// The checker ships inside the payload's repo snapshot (git archive of
|
||||
// the committed tree): <payload>/<repo>/apps/desktop/scripts/.
|
||||
script: path.join(bundledPayload.repoDir, 'apps', 'desktop', 'scripts', 'check-appinstaller-update.py'),
|
||||
run: runPayloadPython,
|
||||
channel: resolveUpdaterChannelFromStamp(),
|
||||
light: isLightVariant(),
|
||||
feedBaseUrl: resolveDesktopFeedBaseUrl(),
|
||||
shell: { openExternal: url => shell.openExternal(url) },
|
||||
teardownBundledBackend,
|
||||
emitUpdateProgress,
|
||||
appVersion: app.getVersion(),
|
||||
quit: () => app.quit(),
|
||||
registerPendingRelaunch: fromVersion => writePendingRelaunch(HERMES_HOME, fromVersion)
|
||||
})
|
||||
}
|
||||
|
||||
return new ExternalStrategy()
|
||||
}
|
||||
|
||||
/** True when this process is a Microsoft Store deployment. */
|
||||
function isWindowsStore(): boolean {
|
||||
return Boolean((process as any).windowsStore)
|
||||
@@ -3753,47 +3765,15 @@ async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) {
|
||||
// A bundled install ships its whole runtime as a sealed payload — there
|
||||
// is no checkout to pull or venv to sync. New app release IS the update.
|
||||
if (resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS })) {
|
||||
// Out-of-store MSIX installs are App Installer owned: the OS registered
|
||||
// the .appinstaller URI as the update source at install, so "apply"
|
||||
// means run the graceful teardown (backend children holding install-dir
|
||||
// locks must die first — the same teardown the old in-app updater ran
|
||||
// between download and install), then hand the process to the OS App
|
||||
// Installer and quit.
|
||||
if (IS_WINDOWS && !isWindowsStore()) {
|
||||
const feedBaseUrl = resolveDesktopFeedBaseUrl()
|
||||
// Delegate to the bundled strategy: out-of-store MSIX runs the graceful
|
||||
// teardown, hands the swap to the OS App Installer, registers the
|
||||
// one-shot relaunch marker, and quits; anything else gets the manual
|
||||
// card (reinstall the app release).
|
||||
const strategy = resolveBundledUpdateStrategy(
|
||||
resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS })
|
||||
)
|
||||
|
||||
if (feedBaseUrl) {
|
||||
emitUpdateProgress({
|
||||
stage: 'restart',
|
||||
message: 'Applying the Hermes update — the window will close and the App Installer will finish.',
|
||||
percent: 100
|
||||
})
|
||||
|
||||
await triggerAppInstallerUpdate(
|
||||
feedBaseUrl,
|
||||
resolveUpdaterChannelFromStamp(),
|
||||
isLightVariant(),
|
||||
{ openExternal: url => shell.openExternal(url) },
|
||||
teardownBundledBackend
|
||||
)
|
||||
|
||||
// The OS App Installer now owns the swap. Quit so no process holds
|
||||
// files in the install dir while Windows replaces the package.
|
||||
app.quit()
|
||||
|
||||
return { ok: true, manual: false, bundled: true }
|
||||
}
|
||||
}
|
||||
|
||||
// No App Installer source (a non-MSIX bundled install, or no feed
|
||||
// configured): the manual card remains the fallback.
|
||||
emitUpdateProgress({
|
||||
stage: 'manual',
|
||||
message: 'bundled install: update by installing the new app release',
|
||||
percent: null
|
||||
})
|
||||
|
||||
return { ok: true, manual: true, bundled: true }
|
||||
return strategy.apply(opts)
|
||||
}
|
||||
|
||||
updateInFlight = true
|
||||
@@ -17782,6 +17762,17 @@ app.on('open-url', (event, url) => {
|
||||
})
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// Post-update relaunch detection (App Installer arm): when the previous
|
||||
// version wrote the one-shot pending-relaunch marker before quitting into
|
||||
// an OS package swap, consume it here — the renderer toasts "Hermes
|
||||
// updated to vX.Y.Z" once its bridge is up. Same-version markers (update
|
||||
// never landed) are deleted silently.
|
||||
const relaunchInfo = consumePendingRelaunch(HERMES_HOME, app.getVersion())
|
||||
|
||||
if (relaunchInfo.wasUpdateRelaunch) {
|
||||
rememberLog(`[updates] post-update relaunch detected (from ${relaunchInfo.fromVersion})`)
|
||||
}
|
||||
|
||||
// Warm the login-shell PATH resolution immediately so it usually completes
|
||||
// before the backend start path awaits the same single-flight promise.
|
||||
void ensureLoginShellPath()
|
||||
|
||||
@@ -269,6 +269,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
setDisableF12: blocked => ipcRenderer.send('hermes:devtools:disable-f12', blocked),
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
onExternalOpenFailed: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:external-open-failed', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:external-open-failed', listener)
|
||||
},
|
||||
openPreviewInBrowser: url => ipcRenderer.invoke('hermes:openPreviewInBrowser', url),
|
||||
reachPreviewUrl: url => ipcRenderer.invoke('hermes:preview:reach', url),
|
||||
setActiveConnectionRoute: route => ipcRenderer.send('hermes:connection:active-route', route),
|
||||
@@ -301,6 +307,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
// Fire-and-forget: persists a renderer error-boundary catch (with component
|
||||
// stack) to desktop.log so crashes survive the window (#79428).
|
||||
reportRendererError: report => ipcRenderer.send('hermes:logs:renderer-error', report),
|
||||
logLine: line => ipcRenderer.send('hermes:logs:renderer-line', line),
|
||||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
|
||||
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// product-identity.cjs is the single derivation of the desktop product
|
||||
// identity; electron/product-identity.ts re-exports it. These tests hold
|
||||
// the identity contract: the TS accessor resolves to the .cjs object, and
|
||||
// the two variants disagree on every OS-visible marker (side-by-side
|
||||
// installs must not collide).
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { afterEach, beforeEach, test, vi } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.HERMES_DESKTOP_VARIANT
|
||||
delete process.env.HERMES_PAYLOAD_TAG
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
async function identityForVariant(variant: string | undefined) {
|
||||
if (variant === undefined) {
|
||||
delete process.env.HERMES_DESKTOP_VARIANT
|
||||
} else {
|
||||
process.env.HERMES_DESKTOP_VARIANT = variant
|
||||
}
|
||||
|
||||
delete require.cache[require.resolve('../product-identity.cjs')]
|
||||
vi.resetModules()
|
||||
|
||||
return (await import('./product-identity')).PRODUCT_IDENTITY
|
||||
}
|
||||
|
||||
test('light identity is fully distinct from the full identity', async () => {
|
||||
const full = await identityForVariant(undefined)
|
||||
const light = await identityForVariant('light')
|
||||
|
||||
assert.equal(light.light, true)
|
||||
|
||||
// Enumerate the OS-visible identity markers explicitly rather than looping
|
||||
// Object.keys: `store` is a build-mode flag, legitimately equal (false)
|
||||
// across variants, so a keys-loop would fail for the wrong reason. Only the
|
||||
// markers Windows/electron keys on must differ for side-by-side installs.
|
||||
for (const prop of ['displayName', 'appId', 'appNamePascal', 'msixAppIdWithOrg', 'channel'] as const) {
|
||||
assert.notEqual(light[prop], full[prop], `${prop} must differ between light and full`)
|
||||
}
|
||||
})
|
||||
|
||||
test('a nightly payload tag moves BOTH variants onto their nightly feed channel', async () => {
|
||||
process.env.HERMES_PAYLOAD_TAG = 'v0.28.0-nightly.20260818'
|
||||
const full = await identityForVariant(undefined)
|
||||
assert.equal(full.channel, 'nightly')
|
||||
|
||||
process.env.HERMES_PAYLOAD_TAG = 'v0.28.0-nightly.20260818'
|
||||
const light = await identityForVariant('light')
|
||||
assert.equal(light.channel, 'light-nightly')
|
||||
})
|
||||
|
||||
test('stable tags and tagless dev builds publish to the stable channels', async () => {
|
||||
process.env.HERMES_PAYLOAD_TAG = 'v0.28.0'
|
||||
assert.equal((await identityForVariant(undefined)).channel, 'latest')
|
||||
|
||||
delete process.env.HERMES_PAYLOAD_TAG
|
||||
assert.equal((await identityForVariant('light')).channel, 'light')
|
||||
})
|
||||
|
||||
test('bundled variant keeps the Hermes display name and a distinct appId', async () => {
|
||||
const full = await identityForVariant(undefined)
|
||||
const bundled = await identityForVariant('bundled')
|
||||
|
||||
assert.equal(bundled.light, false)
|
||||
assert.equal(bundled.displayName, full.displayName)
|
||||
assert.equal(bundled.appNamePascal, 'HermesBundled')
|
||||
assert.notEqual(bundled.appId, full.appId)
|
||||
assert.equal(bundled.channel, 'latest')
|
||||
})
|
||||
|
||||
test('store inherits the bundled app identity (shared userData) but swaps the MSIX packaging identity', async () => {
|
||||
const bundled = await identityForVariant('bundled')
|
||||
const store = await identityForVariant('store')
|
||||
|
||||
// Same Electron app: displayName/appNamePascal (-> shared userData dir),
|
||||
// appId, and the out-of-store org-prefixed name are all inherited.
|
||||
assert.equal(store.store, true)
|
||||
assert.equal(store.light, false)
|
||||
assert.equal(store.displayName, bundled.displayName)
|
||||
assert.equal(store.appNamePascal, bundled.appNamePascal)
|
||||
assert.equal(store.appId, bundled.appId)
|
||||
assert.equal(store.msixAppIdWithOrg, bundled.msixAppIdWithOrg)
|
||||
// The store build never publishes to a feed.
|
||||
assert.equal(store.channel, null)
|
||||
})
|
||||
|
||||
test('store carries the Partner Center MSIX identity and no other variant does', async () => {
|
||||
const store = await identityForVariant('store')
|
||||
assert.deepEqual(store.storeMsix, {
|
||||
identityName: 'NousResearchInc.HermesAgent',
|
||||
publisher: 'CN=EE6D86E4-606F-4E38-B940-AD7248C9D519',
|
||||
publisherDisplayName: 'Nous Research Inc.'
|
||||
})
|
||||
|
||||
for (const v of [undefined, 'bundled', 'light'] as const) {
|
||||
assert.equal((await identityForVariant(v)).storeMsix, undefined, `variant ${v} must carry no storeMsix`)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
// product-identity.ts — the typed build-time product identity.
|
||||
//
|
||||
// product-identity.cjs is the single derivation of every name-shaped
|
||||
// value a variant owns (product name, appId, channel). Dev bundles and
|
||||
// test runs import the .cjs itself so the derivation exists in exactly
|
||||
// one place.
|
||||
|
||||
import devIdentity from '../product-identity.cjs'
|
||||
|
||||
/** Mirrors product-identity.cjs (see product-identity.d.cts). */
|
||||
export type ProductIdentity = typeof devIdentity
|
||||
|
||||
declare const __HERMES_PRODUCT_IDENTITY__: ProductIdentity
|
||||
|
||||
/** The baked identity of this artifact (dev bundles derive it live). */
|
||||
export const PRODUCT_IDENTITY: Readonly<ProductIdentity> =
|
||||
typeof __HERMES_PRODUCT_IDENTITY__ === 'undefined'
|
||||
? Object.freeze(devIdentity)
|
||||
: Object.freeze(__HERMES_PRODUCT_IDENTITY__)
|
||||
@@ -0,0 +1,70 @@
|
||||
// updater/app-installer-strategy.test.ts — the apply-flow contract: the
|
||||
// one-shot relaunch marker is written BEFORE the OS hand-off, teardown runs
|
||||
// before the trigger, and quit is unconditional (no relaunch question).
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { AppInstallerStrategy } from './app-installer'
|
||||
import type { AppInstallerStrategyDeps } from './app-installer'
|
||||
|
||||
function makeDeps(over: Partial<AppInstallerStrategyDeps> = {}) {
|
||||
const calls: string[] = []
|
||||
const deps: AppInstallerStrategyDeps = {
|
||||
python: 'python.exe',
|
||||
script: 'check.py',
|
||||
run: async () => ({ code: 0, stdout: '{"available": true}' }),
|
||||
channel: 'stable',
|
||||
light: false,
|
||||
feedBaseUrl: 'https://updates.example/hermes-desktop',
|
||||
shell: { openExternal: async () => { calls.push('openExternal') } },
|
||||
teardownBundledBackend: async () => { calls.push('teardown') },
|
||||
emitUpdateProgress: () => {},
|
||||
appVersion: '0.18.2',
|
||||
quit: () => { calls.push('quit') },
|
||||
registerPendingRelaunch: () => { calls.push('relaunch-marker'); return true },
|
||||
...over
|
||||
}
|
||||
return { deps, calls }
|
||||
}
|
||||
|
||||
describe('AppInstallerStrategy.apply', () => {
|
||||
it('writes the relaunch marker before teardown, trigger, and quit — order is the contract', async () => {
|
||||
const { deps, calls } = makeDeps()
|
||||
const strategy = new AppInstallerStrategy(deps)
|
||||
const result = await strategy.apply({})
|
||||
|
||||
expect(result).toEqual({ ok: true, manual: false, bundled: true, mechanism: 'app-installer' })
|
||||
expect(calls).toEqual(['relaunch-marker', 'teardown', 'openExternal', 'quit'])
|
||||
})
|
||||
|
||||
it('fails open: a marker-write failure never blocks the update', async () => {
|
||||
const { deps, calls } = makeDeps({ registerPendingRelaunch: () => false })
|
||||
const result = await new AppInstallerStrategy(deps).apply({})
|
||||
expect(result.ok).toBe(true)
|
||||
expect(calls).toContain('quit')
|
||||
})
|
||||
|
||||
it('no feed URL → manual card, no teardown, no quit', async () => {
|
||||
const { deps, calls } = makeDeps({ feedBaseUrl: '' })
|
||||
const result = await new AppInstallerStrategy(deps).apply({})
|
||||
expect(result).toEqual({ ok: true, manual: true, bundled: true, mechanism: 'app-installer' })
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppInstallerStrategy.check', () => {
|
||||
it('threads the OS checker result onto the mechanism wire', async () => {
|
||||
const { deps } = makeDeps({ run: async () => ({ code: 0, stdout: '{"available": true}' }) })
|
||||
const status = await new AppInstallerStrategy(deps).check()
|
||||
expect(status.mechanism).toBe('app-installer')
|
||||
expect(status.updateAvailable).toBe(true)
|
||||
expect(status.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('unknown availability is an error on the wire, never "no update"', async () => {
|
||||
const { deps } = makeDeps({ run: async () => ({ code: 1, stdout: '{"available": null, "error": "winrt missing"}' }) })
|
||||
const status = await new AppInstallerStrategy(deps).check()
|
||||
expect(status.updateAvailable).toBe(false)
|
||||
expect(status.error).toBe('winrt missing')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
// updater/app-installer.ts — the win32 out-of-store MSIX strategy.
|
||||
//
|
||||
// The OS App Installer owns the apply. The package was installed from an
|
||||
// .appinstaller, which registered the feed URI as the package's update source;
|
||||
// the OS checks it and swaps the package wholesale. The app's only jobs:
|
||||
// check() ask the OS whether an update is available (via the bundled
|
||||
// payload python's winrt), surfacing UNKNOWN honestly;
|
||||
// apply() run graceful teardown, trigger ms-appinstaller:, quit — and
|
||||
// a pending-relaunch marker so Hermes comes back by itself.
|
||||
//
|
||||
// Pure-injectable: the impure pieces (python runner, shell, quit, relaunch
|
||||
// marker) are injected, so vitest covers the arm without a payload.
|
||||
|
||||
import {
|
||||
checkAppInstallerUpdate,
|
||||
type AppInstallerCheck,
|
||||
type PayloadPythonRunner,
|
||||
triggerAppInstallerUpdate,
|
||||
win32AppInstallerFeedPath
|
||||
} from '../app-updater'
|
||||
import type { UpdaterApplyResultWire, UpdaterStatusWire } from './index'
|
||||
|
||||
export interface AppInstallerStrategyDeps {
|
||||
/** Absolute path to the bundled payload python (tools/<entry>/python.exe). */
|
||||
python: string
|
||||
/** The checker script's absolute path (payload repo snapshot). */
|
||||
script: string
|
||||
run: PayloadPythonRunner['run']
|
||||
/** Channel + variant from the baked install stamp. */
|
||||
channel: 'stable' | 'nightly'
|
||||
light: boolean
|
||||
/** The App Installer feed base URL; empty when nothing configured it. */
|
||||
feedBaseUrl: string
|
||||
shell: { openExternal: (url: string) => Promise<void> }
|
||||
/** Graceful backend teardown before the package swap. */
|
||||
teardownBundledBackend: () => void | Promise<void>
|
||||
/** Progress emitter for the updates overlay. */
|
||||
emitUpdateProgress: (payload: { stage: string; message: string; percent: number | null }) => void
|
||||
/** App version label for the status wire. */
|
||||
appVersion: string
|
||||
/** Quit the app (after handing the swap to the OS). */
|
||||
quit: () => void
|
||||
/**
|
||||
* Register a one-shot post-update relaunch (run-key entry or equivalent)
|
||||
* before quitting, so Hermes reopens on the new version with no user
|
||||
* action. The new version's first run detects + deletes the marker.
|
||||
* Returns false when registration failed (relaunch stays manual).
|
||||
*/
|
||||
registerPendingRelaunch: (targetVersion: string) => boolean
|
||||
}
|
||||
|
||||
export interface CheckOutcome {
|
||||
status: UpdaterStatusWire
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the OS whether an App Installer update is available. `available: null`
|
||||
* (checker unavailable) is surfaced as an honest unknown on the wire —
|
||||
* NEVER as "no update".
|
||||
*/
|
||||
export function appInstallerCheckToStatus(check: AppInstallerCheck, appVersion: string): UpdaterStatusWire {
|
||||
return {
|
||||
supported: true,
|
||||
mechanism: 'app-installer',
|
||||
currentVersion: appVersion,
|
||||
updateAvailable: check.available === true,
|
||||
// null = unknown (checker unavailable) — surface honestly, never "no update".
|
||||
error: check.available === null ? check.error || 'update check unavailable' : undefined,
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
export class AppInstallerStrategy {
|
||||
readonly mechanism = 'app-installer' as const
|
||||
|
||||
constructor(private readonly deps: AppInstallerStrategyDeps) {}
|
||||
|
||||
async check(): Promise<UpdaterStatusWire> {
|
||||
const { code, stdout } = await this.deps.run(this.deps.python, this.deps.script)
|
||||
const check = parseCheckOutput(code, stdout)
|
||||
|
||||
return appInstallerCheckToStatus(check, this.deps.appVersion)
|
||||
}
|
||||
|
||||
async apply(_opts: { stopSafeBlockers?: boolean }): Promise<UpdaterApplyResultWire> {
|
||||
const feedBaseUrl = this.deps.feedBaseUrl
|
||||
|
||||
if (!feedBaseUrl) {
|
||||
this.deps.emitUpdateProgress({
|
||||
stage: 'manual',
|
||||
message: 'bundled install: update by installing the new app release',
|
||||
percent: null
|
||||
})
|
||||
|
||||
return { ok: true, manual: true, bundled: true, mechanism: this.mechanism }
|
||||
}
|
||||
|
||||
this.deps.emitUpdateProgress({
|
||||
stage: 'restart',
|
||||
message: 'Applying the Hermes update — the window will close and the App Installer will finish.',
|
||||
percent: 100
|
||||
})
|
||||
|
||||
// Unconditional relaunch: register the one-shot marker BEFORE the swap so
|
||||
// Hermes comes back on the new version with no user action.
|
||||
this.deps.registerPendingRelaunch(this.deps.appVersion)
|
||||
|
||||
await triggerAppInstallerUpdate(
|
||||
feedBaseUrl,
|
||||
this.deps.channel,
|
||||
this.deps.light,
|
||||
this.deps.shell,
|
||||
this.deps.teardownBundledBackend
|
||||
)
|
||||
|
||||
this.deps.quit()
|
||||
|
||||
return { ok: true, manual: false, bundled: true, mechanism: this.mechanism }
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the payload-python checker's output into an AppInstallerCheck. */
|
||||
export function parseCheckOutput(code: number, stdout: string): AppInstallerCheck {
|
||||
const text = stdout.trim()
|
||||
let parsed: { available?: boolean | null; availability?: string; error?: string; reason?: string } | null = null
|
||||
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed.available === 'boolean') {
|
||||
return { available: parsed.available, availability: parsed.availability, error: parsed.error }
|
||||
}
|
||||
|
||||
if (parsed && parsed.available === null) {
|
||||
return { available: null, error: parsed.error || 'checker returned unknown' }
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
return { available: null, error: parsed?.error || `checker exited ${code}` }
|
||||
}
|
||||
|
||||
return { available: null, error: 'checker returned no availability' }
|
||||
}
|
||||
|
||||
export { win32AppInstallerFeedPath }
|
||||
export type { AppInstallerCheck, PayloadPythonRunner }
|
||||
@@ -0,0 +1,51 @@
|
||||
// updater/checkout.ts — the git-checkout strategies (win + posix + manual).
|
||||
//
|
||||
// These wrap the existing bodies in main.ts rather than re-implementing
|
||||
// them: the checkout update flow is deeply entangled with the god-file's
|
||||
// closure helpers (resolveHealedBranch, venv-lock release, blocker scans,
|
||||
// hand-off marker claims). The strategy adapter delegates; the ladder's
|
||||
// TAIL (manual `hermes update` command card) lives here because it is pure
|
||||
// enough to test.
|
||||
|
||||
import type { UpdaterApplyResultWire, UpdaterStatusWire } from './index'
|
||||
|
||||
export interface CheckoutStrategyDeps {
|
||||
/** The existing checkUpdates git-checkout body (unchanged). */
|
||||
checkBody: () => Promise<UpdaterStatusWire>
|
||||
/** The existing applyUpdates git-checkout body (unchanged). */
|
||||
applyBody: (opts: { stopSafeBlockers?: boolean }) => Promise<UpdaterApplyResultWire>
|
||||
isWindows: boolean
|
||||
}
|
||||
|
||||
export class CheckoutStrategy {
|
||||
readonly mechanism: 'windows-handoff' | 'posix-handoff'
|
||||
|
||||
constructor(private readonly deps: CheckoutStrategyDeps) {
|
||||
this.mechanism = deps.isWindows ? 'windows-handoff' : 'posix-handoff'
|
||||
}
|
||||
|
||||
async check(): Promise<UpdaterStatusWire> {
|
||||
const status = await this.deps.checkBody()
|
||||
status.mechanism = this.mechanism
|
||||
return status
|
||||
}
|
||||
|
||||
async apply(opts: { stopSafeBlockers?: boolean }): Promise<UpdaterApplyResultWire> {
|
||||
const result = await this.deps.applyBody(opts)
|
||||
result.mechanism = this.mechanism
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The manual command card for a checkout with no staged updater: the exact
|
||||
* `hermes update` line to run, branch-pinned to the checkout's current branch
|
||||
* for non-main (bare `hermes update` would silently switch the install
|
||||
* off-branch). Extracted from applyUpdates so the wording contract is
|
||||
* unit-testable.
|
||||
*/
|
||||
export function buildManualUpdateCommand(currentBranch: string | null | undefined): string {
|
||||
return currentBranch && currentBranch !== 'HEAD' && currentBranch !== 'main'
|
||||
? `hermes update --branch ${currentBranch}`
|
||||
: 'hermes update'
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// updater/external.ts — the steward-owned strategy.
|
||||
//
|
||||
// Store deployments (process.windowsStore) and stamps whose updateMechanism
|
||||
// is 'external': the steward (Microsoft Store / App Installer on-launch
|
||||
// re-check) owns the update loop. The app does not check, does not apply —
|
||||
// it only tells the user updates happen outside the app.
|
||||
|
||||
import type { UpdaterApplyResultWire, UpdaterStatusWire } from './index'
|
||||
|
||||
export const EXTERNAL_UNSUPPORTED_MESSAGE =
|
||||
'bundled install: updates are applied by the installer (Microsoft Store or App Installer).'
|
||||
|
||||
export class ExternalStrategy {
|
||||
readonly mechanism = 'external' as const
|
||||
readonly supported = false
|
||||
|
||||
async check(): Promise<UpdaterStatusWire> {
|
||||
return {
|
||||
supported: false,
|
||||
mechanism: 'external',
|
||||
reason: 'bundled-not-appinstaller',
|
||||
message: EXTERNAL_UNSUPPORTED_MESSAGE,
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
async apply(): Promise<UpdaterApplyResultWire> {
|
||||
return { ok: true, manual: true, bundled: true, mechanism: 'external' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// updater/index.ts — the desktop update strategy surface.
|
||||
//
|
||||
// Every install shape updates through a different owner:
|
||||
// app-installer out-of-store MSIX on win32 — the OS App Installer owns
|
||||
// the apply (the .appinstaller feed registered at install).
|
||||
// external Microsoft Store / steward-owned deployments — no in-app
|
||||
// updater at all.
|
||||
// windows-handoff git checkout on win32 — detached updater binary or the
|
||||
// repo hand-off script owns the swap.
|
||||
// posix-handoff git checkout on macOS/Linux — the repo posix hand-off
|
||||
// script owns the swap.
|
||||
// manual checkout with no staged updater — the user runs
|
||||
// `hermes update` themselves.
|
||||
//
|
||||
// The mechanism is resolved ONCE from runtime facts (payload presence,
|
||||
// platform, store flag) by resolveUpdaterMechanism — a pure function, unit
|
||||
// tested — and every strategy reports it on the wire so the renderer can
|
||||
// tailor copy per mechanism without probing the install shape itself.
|
||||
|
||||
export type UpdaterMechanism =
|
||||
| 'app-installer'
|
||||
| 'external'
|
||||
| 'windows-handoff'
|
||||
| 'posix-handoff'
|
||||
| 'manual'
|
||||
|
||||
/** The facts the mechanism dispatch keys on. Pure data — injectable for tests. */
|
||||
export interface MechanismFacts {
|
||||
/** A bundled payload ships inside this artifact (sealed runtime). */
|
||||
isBundled: boolean
|
||||
isWindows: boolean
|
||||
/** This process is a Microsoft Store deployment. */
|
||||
isWindowsStore: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which mechanism owns updates for this install. Precedence mirrors
|
||||
* the historical checkUpdates/applyUpdates ladder in main.ts exactly:
|
||||
* payload-probe first, store-flag second, platform third. Behavior-preserving.
|
||||
*/
|
||||
export function resolveUpdaterMechanism(facts: MechanismFacts): UpdaterMechanism {
|
||||
if (facts.isBundled) {
|
||||
return facts.isWindows && !facts.isWindowsStore ? 'app-installer' : 'external'
|
||||
}
|
||||
|
||||
return facts.isWindows ? 'windows-handoff' : 'posix-handoff'
|
||||
}
|
||||
|
||||
/** The status shape main.ts already sends over `hermes:updates:check`. */
|
||||
export interface UpdaterStatusWire {
|
||||
supported: boolean
|
||||
mechanism?: UpdaterMechanism
|
||||
updateAvailable?: boolean
|
||||
branch?: string
|
||||
currentBranch?: string
|
||||
reason?: string
|
||||
message?: string
|
||||
error?: string
|
||||
behind?: number | null
|
||||
currentSha?: string
|
||||
currentVersion?: string
|
||||
channel?: 'stable' | 'nightly'
|
||||
latestTag?: string | null
|
||||
targetSha?: string
|
||||
commits?: { sha: string; summary: string; author: string; at: number }[]
|
||||
dirty?: boolean
|
||||
hermesRoot?: string
|
||||
fetchedAt?: number
|
||||
}
|
||||
|
||||
/** The result shape main.ts already sends over `hermes:updates:apply`. */
|
||||
export interface UpdaterApplyResultWire {
|
||||
ok: boolean
|
||||
mechanism?: UpdaterMechanism
|
||||
error?: string
|
||||
message?: string
|
||||
manual?: boolean
|
||||
bundled?: boolean
|
||||
command?: string
|
||||
hermesRoot?: string
|
||||
handedOff?: boolean
|
||||
updater?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface UpdaterStrategy {
|
||||
readonly mechanism: UpdaterMechanism
|
||||
check(): Promise<UpdaterStatusWire>
|
||||
apply(opts: { stopSafeBlockers?: boolean }): Promise<UpdaterApplyResultWire>
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// updater/relaunch.ts — unconditional relaunch after an App Installer swap.
|
||||
//
|
||||
// Contract: clicking Update always ends in Hermes reopening on the new
|
||||
// version — no user action between quit and relaunch. Implementation: a
|
||||
// one-shot pending-relaunch marker written before we quit. The OS App
|
||||
// Installer swaps the package; on next launch (whenever it happens) Hermes
|
||||
// reads the marker, shows the "updated" toast, and deletes it.
|
||||
//
|
||||
// The marker is a JSON file under HERMES_HOME (not a run-key / scheduled
|
||||
// task): a registry entry can't be made one-shot safely from a dying process,
|
||||
// and a file survives the package swap (HERMES_HOME lives outside the
|
||||
// package). For the actual relaunch we rely on Windows restarting the app
|
||||
// when an MSIX package update completes while the app registered itself
|
||||
// for restart (RegisterApplicationRestart) — the marker's job is only to
|
||||
// tell the NEW version it was an update relaunch, so it can toast + clean up.
|
||||
//
|
||||
// If the OS does NOT relaunch (update applied later, on next launch), the
|
||||
// marker is still correct: the first launch after the swap detects it and
|
||||
// toasts. A stale marker (update cancelled/failed) self-deletes when its
|
||||
// expected-version no longer matches a later install... no — cancelled
|
||||
// updates mean the version never changed, so we stamp with "expected NEW
|
||||
// version unknown" instead: the marker only records that an update was
|
||||
// STARTED; the new version decides "was it really me?" by comparing versions
|
||||
// recorded at write time vs launch time.
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
export interface PendingRelaunchMarker {
|
||||
schemaVersion: 1
|
||||
/** Version of the app that wrote the marker (pre-update). */
|
||||
fromVersion: string
|
||||
/** Wall-clock ms when the update was triggered. */
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
const MARKER_FILENAME = 'pending-update-relaunch.json'
|
||||
|
||||
function markerPath(hermesHome: string): string {
|
||||
return path.join(hermesHome, MARKER_FILENAME)
|
||||
}
|
||||
|
||||
/** Pure: the filename constant, for tests. */
|
||||
export const PENDING_RELAUNCH_FILENAME = MARKER_FILENAME
|
||||
|
||||
/**
|
||||
* Register the one-shot post-update relaunch marker. Best-effort — a failure
|
||||
* to write never blocks the update; relaunch just stays manual.
|
||||
*/
|
||||
export function writePendingRelaunch(
|
||||
hermesHome: string,
|
||||
fromVersion: string,
|
||||
writeFile: (file: string, contents: string) => void = (f, c) => fs.writeFileSync(f, c)
|
||||
): boolean {
|
||||
try {
|
||||
const marker: PendingRelaunchMarker = { schemaVersion: 1, fromVersion, startedAt: Date.now() }
|
||||
writeFile(markerPath(hermesHome), JSON.stringify(marker))
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConsumedRelaunch {
|
||||
/** True when this launch IS the post-update relaunch (new version). */
|
||||
wasUpdateRelaunch: boolean
|
||||
/** The version the update started from, when the marker was present. */
|
||||
fromVersion?: string
|
||||
}
|
||||
|
||||
export interface RelaunchFsDeps {
|
||||
existsSync?: (file: string) => boolean
|
||||
readFileSync?: (file: string) => string
|
||||
unlinkSync?: (file: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run hook: detect + consume the pending-relaunch marker. Returns
|
||||
* wasUpdateRelaunch=true only when the CURRENT version differs from the
|
||||
* marker's fromVersion — the same version means the update never landed
|
||||
* (cancelled/failed OS install), so the marker is deleted and no toast fires.
|
||||
*/
|
||||
export function consumePendingRelaunch(
|
||||
hermesHome: string,
|
||||
currentVersion: string,
|
||||
deps: RelaunchFsDeps = {}
|
||||
): ConsumedRelaunch {
|
||||
const existsSync = deps.existsSync ?? ((file: string) => fs.existsSync(file))
|
||||
const readFileSync = deps.readFileSync ?? ((file: string) => fs.readFileSync(file, 'utf8'))
|
||||
const unlinkSync = deps.unlinkSync ?? ((file: string) => fs.unlinkSync(file))
|
||||
|
||||
const file = markerPath(hermesHome)
|
||||
|
||||
if (!existsSync(file)) {
|
||||
return { wasUpdateRelaunch: false }
|
||||
}
|
||||
|
||||
let fromVersion: string | undefined
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(file)) as PendingRelaunchMarker
|
||||
fromVersion = typeof raw.fromVersion === 'string' ? raw.fromVersion : undefined
|
||||
} catch {
|
||||
fromVersion = undefined
|
||||
}
|
||||
|
||||
// Consume unconditionally — the marker is one-shot.
|
||||
try {
|
||||
unlinkSync(file)
|
||||
} catch {
|
||||
// Best-effort cleanup; a leftover marker is re-consumed harmlessly.
|
||||
}
|
||||
|
||||
return { wasUpdateRelaunch: fromVersion !== undefined && fromVersion !== currentVersion, fromVersion }
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// updater/updater.test.ts — resolution precedence + wire-shape contracts
|
||||
// for the strategy layer. Pure DI: no Electron, no payload.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveUpdaterMechanism } from './index'
|
||||
import { appInstallerCheckToStatus, parseCheckOutput } from './app-installer'
|
||||
import { buildManualUpdateCommand } from './checkout'
|
||||
import { writePendingRelaunch, consumePendingRelaunch, PENDING_RELAUNCH_FILENAME } from './relaunch'
|
||||
|
||||
describe('resolveUpdaterMechanism — precedence', () => {
|
||||
it('bundled win32 out-of-store → app-installer', () => {
|
||||
expect(resolveUpdaterMechanism({ isBundled: true, isWindows: true, isWindowsStore: false })).toBe('app-installer')
|
||||
})
|
||||
|
||||
it('bundled win32 Store → external (steward owns updates)', () => {
|
||||
expect(resolveUpdaterMechanism({ isBundled: true, isWindows: true, isWindowsStore: true })).toBe('external')
|
||||
})
|
||||
|
||||
it('bundled posix → external', () => {
|
||||
expect(resolveUpdaterMechanism({ isBundled: true, isWindows: false, isWindowsStore: false })).toBe('external')
|
||||
})
|
||||
|
||||
it('checkout win32 → windows-handoff', () => {
|
||||
expect(resolveUpdaterMechanism({ isBundled: false, isWindows: true, isWindowsStore: false })).toBe('windows-handoff')
|
||||
})
|
||||
|
||||
it('checkout posix → posix-handoff', () => {
|
||||
expect(resolveUpdaterMechanism({ isBundled: false, isWindows: false, isWindowsStore: false })).toBe('posix-handoff')
|
||||
})
|
||||
|
||||
it('store flag never downgrades a checkout (probe only fires for bundled)', () => {
|
||||
// isWindowsStore on a checkout is meaningless; the resolver must not
|
||||
// route a win32 checkout to external on a stray true.
|
||||
expect(resolveUpdaterMechanism({ isBundled: false, isWindows: true, isWindowsStore: true })).toBe('windows-handoff')
|
||||
})
|
||||
})
|
||||
|
||||
describe('app-installer check → status wire', () => {
|
||||
it('available true → updateAvailable, no error', () => {
|
||||
const s = appInstallerCheckToStatus({ available: true }, '0.18.2')
|
||||
expect(s.supported).toBe(true)
|
||||
expect(s.mechanism).toBe('app-installer')
|
||||
expect(s.updateAvailable).toBe(true)
|
||||
expect(s.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('available false → updateAvailable false, NO error (honest no-update)', () => {
|
||||
const s = appInstallerCheckToStatus({ available: false }, '0.18.2')
|
||||
expect(s.updateAvailable).toBe(false)
|
||||
expect(s.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('available null → updateAvailable false WITH error (honest unknown, never "no update")', () => {
|
||||
const s = appInstallerCheckToStatus({ available: null, error: 'no winrt' }, '0.18.2')
|
||||
expect(s.updateAvailable).toBe(false)
|
||||
expect(s.error).toBe('no winrt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseCheckOutput', () => {
|
||||
it('parses boolean true/false availability', () => {
|
||||
expect(parseCheckOutput(0, '{"available": true, "availability": "Available"}')).toEqual({
|
||||
available: true,
|
||||
availability: 'Available',
|
||||
error: undefined
|
||||
})
|
||||
expect(parseCheckOutput(0, '{"available": false}')).toEqual({ available: false, availability: undefined, error: undefined })
|
||||
})
|
||||
|
||||
it('available null surfaces the checker error', () => {
|
||||
expect(parseCheckOutput(2, '{"available": null, "error": "winrt import failed"}')).toEqual({
|
||||
available: null,
|
||||
error: 'winrt import failed'
|
||||
})
|
||||
})
|
||||
|
||||
it('non-zero exit without parseable output names the exit code', () => {
|
||||
expect(parseCheckOutput(1, 'boom')).toEqual({ available: null, error: 'checker exited 1' })
|
||||
})
|
||||
|
||||
it('exit 0 with no availability is unknown, not no-update', () => {
|
||||
expect(parseCheckOutput(0, '')).toEqual({ available: null, error: 'checker returned no availability' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildManualUpdateCommand', () => {
|
||||
it('bare command on main and detached HEAD', () => {
|
||||
expect(buildManualUpdateCommand('main')).toBe('hermes update')
|
||||
expect(buildManualUpdateCommand('HEAD')).toBe('hermes update')
|
||||
expect(buildManualUpdateCommand(null)).toBe('hermes update')
|
||||
})
|
||||
|
||||
it('branch-pinned for non-main checkouts', () => {
|
||||
expect(buildManualUpdateCommand('ethie/pm')).toBe('hermes update --branch ethie/pm')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pending relaunch marker', () => {
|
||||
const KEY = PENDING_RELAUNCH_FILENAME
|
||||
|
||||
function fakeFs(files: Record<string, string>) {
|
||||
const has = (f: string) => f.replace(/\\/g, '/').endsWith(`/${KEY}`) || f === KEY
|
||||
return {
|
||||
existsSync: (f: string) => has(f),
|
||||
readFileSync: (f: string) => files[Object.keys(files).find(k => k.replace(/\\/g, '/') === f.replace(/\\/g, '/')) ?? f],
|
||||
unlinkSync: (f: string) => {
|
||||
const key = Object.keys(files).find(k => k.replace(/\\/g, '/') === f.replace(/\\/g, '/'))
|
||||
if (key) delete files[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('write then consume on a different version = update relaunch', () => {
|
||||
const files: Record<string, string> = {}
|
||||
expect(writePendingRelaunch('/home', '0.18.2', (f, c) => { files[f] = c as string })).toBe(true)
|
||||
expect(JSON.parse(Object.values(files)[0]).fromVersion).toBe('0.18.2')
|
||||
|
||||
const r = consumePendingRelaunch('/home', '0.18.3', fakeFs(files))
|
||||
expect(r.wasUpdateRelaunch).toBe(true)
|
||||
expect(r.fromVersion).toBe('0.18.2')
|
||||
// one-shot: consumed
|
||||
expect(Object.keys(files)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('same version = update never landed; marker consumed silently', () => {
|
||||
const files: Record<string, string> = {}
|
||||
writePendingRelaunch('/home', '0.18.2', (f, c) => { files[f] = c as string })
|
||||
|
||||
const r = consumePendingRelaunch('/home', '0.18.2', fakeFs(files))
|
||||
expect(r.wasUpdateRelaunch).toBe(false)
|
||||
expect(Object.keys(files)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('no marker = normal launch', () => {
|
||||
expect(consumePendingRelaunch('/home', '0.18.3', fakeFs({})).wasUpdateRelaunch).toBe(false)
|
||||
})
|
||||
|
||||
it('corrupt marker is consumed and treated as unknown', () => {
|
||||
const files: Record<string, string> = {}
|
||||
writePendingRelaunch('/home', '0.18.2', (f, c) => { files[f] = c as string })
|
||||
// corrupt the contents under the same key
|
||||
for (const k of Object.keys(files)) files[k] = 'not json'
|
||||
|
||||
const r = consumePendingRelaunch('/home', '0.18.3', fakeFs(files))
|
||||
expect(r.wasUpdateRelaunch).toBe(false)
|
||||
expect(Object.keys(files)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
+7
-125
@@ -34,12 +34,13 @@
|
||||
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.mjs",
|
||||
"pack": "npm run build && npm run builder -- --dir --publish never",
|
||||
"dist": "npm run build && npm run builder",
|
||||
"payload": "node scripts/stage-payload.mjs",
|
||||
"dist:bundled": "npm run payload && npm run dist",
|
||||
"dist:mac": "npm run build && npm run builder -- --mac",
|
||||
"dist:mac:dmg": "npm run build && npm run builder -- --mac dmg",
|
||||
"dist:mac:zip": "npm run build && npm run builder -- --mac zip",
|
||||
"dist:win": "npm run build && npm run builder -- --win",
|
||||
"dist:win:msi": "npm run build && npm run builder -- --win msi",
|
||||
"dist:win:nsis": "npm run build && npm run builder -- --win nsis",
|
||||
"dist:win": "npm run build && npm run builder -- --win msix",
|
||||
"dist:win:msix": "npm run build && npm run builder -- --win msix",
|
||||
"dist:linux": "npm run build && npm run builder -- --linux AppImage deb rpm",
|
||||
"perf": "node scripts/perf/run.mjs",
|
||||
"update:shim": "bash ../../scripts/desktop-update/repro.sh shim",
|
||||
@@ -53,10 +54,10 @@
|
||||
"test:desktop": "node scripts/test-desktop.mjs",
|
||||
"test:desktop:all": "node scripts/test-desktop.mjs all",
|
||||
"test:desktop:dmg": "node scripts/test-desktop.mjs dmg",
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:msix": "node scripts/test-desktop.mjs msix",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit && tsc -p tsconfig.e2e.json --noEmit",
|
||||
"typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit && tsc -p tsconfig.e2e.json --noEmit && tsc --ignoreConfig --allowJs --checkJs --noEmit --skipLibCheck --module nodenext --moduleResolution nodenext --target es2022 electron-builder.config.cjs",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'",
|
||||
@@ -173,7 +174,7 @@
|
||||
"concurrently": "10.0.4",
|
||||
"cross-env": "10.1.0",
|
||||
"electron": "40.10.2",
|
||||
"electron-builder": "26.15.3",
|
||||
"electron-builder": "27.0.0-alpha.6",
|
||||
"esbuild": "0.28.1",
|
||||
"jsdom": "29.1.1",
|
||||
"prettier": "3.9.5",
|
||||
@@ -183,124 +184,5 @@
|
||||
"vite": "8.2.0",
|
||||
"vitest": "4.1.10",
|
||||
"wait-on": "9.0.10"
|
||||
},
|
||||
"build": {
|
||||
"electronVersion": "40.10.2",
|
||||
"appId": "com.nousresearch.hermes",
|
||||
"productName": "Hermes",
|
||||
"executableName": "Hermes",
|
||||
"protocols": [
|
||||
{
|
||||
"name": "Hermes Protocol",
|
||||
"schemes": [
|
||||
"hermes"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "Hermes-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "assets/icon",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**",
|
||||
"assets/**",
|
||||
"public/**",
|
||||
"package.json"
|
||||
],
|
||||
"beforeBuild": "scripts/before-build.mjs",
|
||||
"beforePack": "scripts/before-pack.mjs",
|
||||
"afterPack": "scripts/after-pack.mjs",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "build/install-stamp.json",
|
||||
"to": "install-stamp.json"
|
||||
},
|
||||
{
|
||||
"from": "assets/icon.ico",
|
||||
"to": "icon.ico"
|
||||
}
|
||||
],
|
||||
"asar": true,
|
||||
"afterSign": "scripts/notarize.mjs",
|
||||
"asarUnpack": [
|
||||
"**/*.node",
|
||||
"**/prebuilds/**",
|
||||
"dist/**"
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"entitlements": "electron/entitlements.mac.plist",
|
||||
"entitlementsInherit": "electron/entitlements.mac.inherit.plist",
|
||||
"extendInfo": {
|
||||
"CFBundleDisplayName": "Hermes",
|
||||
"CFBundleExecutable": "Hermes",
|
||||
"CFBundleName": "Hermes",
|
||||
"NSAudioCaptureUsageDescription": "Hermes uses audio capture for voice conversations.",
|
||||
"NSCameraUsageDescription": "Hermes uses the camera when a plugin or feature you enable requests it.",
|
||||
"NSMicrophoneUsageDescription": "Hermes uses the microphone for voice input and voice conversations.",
|
||||
"NSCalendarsUsageDescription": "Hermes needs access to Calendar to provide requested meeting and scheduling support.",
|
||||
"NSCalendarsFullAccessUsageDescription": "Hermes needs full access to Calendar to read and manage events when explicitly requested.",
|
||||
"NSRemindersUsageDescription": "Hermes needs access to Reminders to provide requested personal-assistant and scheduling support.",
|
||||
"NSRemindersFullAccessUsageDescription": "Hermes needs full access to Reminders to read and manage reminders when explicitly requested.",
|
||||
"NSScreenCaptureUsageDescription": "Hermes captures the screen when you ask the agent to screenshot or record it.",
|
||||
"NSLocalNetworkUsageDescription": "Hermes connects to devices on your local network when a plugin or feature you enable requests it.",
|
||||
"NSAppleMusicUsageDescription": "Hermes accesses your music library when a plugin or feature you enable requests it."
|
||||
},
|
||||
"gatekeeperAssess": false,
|
||||
"hardenedRuntime": true,
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
]
|
||||
},
|
||||
"dmg": {
|
||||
"title": "Install Hermes",
|
||||
"backgroundColor": "#f5f5f7",
|
||||
"iconSize": 96,
|
||||
"window": {
|
||||
"width": 560,
|
||||
"height": 360
|
||||
},
|
||||
"contents": [
|
||||
{
|
||||
"x": 160,
|
||||
"y": 170,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"x": 400,
|
||||
"y": 170,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"legalTrademarks": "Hermes",
|
||||
"target": [
|
||||
"nsis",
|
||||
"msi"
|
||||
],
|
||||
"signAndEditExecutable": false
|
||||
},
|
||||
"linux": {
|
||||
"category": "Development",
|
||||
"maintainer": "Nous Research <support@nousresearch.com>",
|
||||
"synopsis": "Native desktop shell for Hermes Agent.",
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb",
|
||||
"rpm"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"perMachine": false,
|
||||
"shortcutName": "Hermes",
|
||||
"uninstallDisplayName": "Hermes",
|
||||
"warningsAsErrors": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// The desktop product identity — THE single source for every name-shaped
|
||||
// value a variant owns. HERMES_DESKTOP_VARIANT=light builds "Hermes
|
||||
// Light", the remote-only client; everything else is full "Hermes".
|
||||
//
|
||||
// Consumed at build time by electron-builder.config.cjs (packaging
|
||||
// identity). electron/product-identity.ts is the typed runtime accessor.
|
||||
// @ts-check
|
||||
/// <reference types="node" />
|
||||
'use strict'
|
||||
|
||||
const variants = {
|
||||
'': { display: 'Hermes', kebab: 'hermes', pascal: 'Hermes' },
|
||||
light: {
|
||||
display: 'Hermes Light',
|
||||
kebab: 'hermes-light',
|
||||
pascal: 'HermesLight'
|
||||
},
|
||||
bundled: {
|
||||
display: 'Hermes',
|
||||
kebab: 'hermes-bundled',
|
||||
pascal: 'HermesBundled'
|
||||
}
|
||||
}
|
||||
|
||||
const variant = process.env.HERMES_DESKTOP_VARIANT || ''
|
||||
if (!['', 'light', 'bundled', 'store'].includes(variant)) {
|
||||
throw new Error(`Unknown HERMES_DESKTOP_VARIANT ${variant}. expected one of (empty), light, bundled, store`)
|
||||
}
|
||||
|
||||
// 'store' is a Store-submission packaging identity layered on the bundled
|
||||
// variant: same Electron app (displayName/appId/appNamePascal -> shared
|
||||
// userData + single-instance lock with the out-of-store install), different
|
||||
// MSIX package identity. The Store re-signs on submission.
|
||||
const store = variant === 'store'
|
||||
const light = variant === 'light'
|
||||
const name = variants[store ? 'bundled' : (variant || '')]
|
||||
|
||||
// The electron-updater feed channel this build PUBLISHES to. A nightly
|
||||
// tag (vX.Y.0-nightly.YYYYMMDDHHMMSS) writes nightly.yml / light-nightly.yml;
|
||||
// stable tags write latest.yml / light.yml. Keyed on the payload tag so
|
||||
// the one release workflow serves both channels — a nightly build can
|
||||
// never overwrite the stable feed file, and vice versa.
|
||||
const nightly = /-nightly\.20\d{6}(?:\d{6})?$/.test(process.env.HERMES_PAYLOAD_TAG || '')
|
||||
|
||||
/** @typedef {import("./product-identity.d.cts")} ProductIdentity */
|
||||
|
||||
/** @type {ProductIdentity} */
|
||||
const identity = {
|
||||
store,
|
||||
light,
|
||||
displayName: name.display,
|
||||
appId: `com.nousresearch.${name.kebab}`,
|
||||
// The store build never publishes to a release feed (the Store owns its
|
||||
// updates); null means "no feed" for its publish config.
|
||||
channel: store ? null : light ? (nightly ? 'light-nightly' : 'light') : (nightly ? 'nightly' : 'latest'),
|
||||
appNamePascal: name.pascal,
|
||||
msixAppIdWithOrg: `NousResearch.${name.pascal}`,
|
||||
...(store
|
||||
? {
|
||||
storeMsix: {
|
||||
// Partner Center publisher identity (the account's publisher ID) —
|
||||
// validated + re-signed by the Store on submission.
|
||||
identityName: 'NousResearchInc.HermesAgent',
|
||||
publisher: 'CN=EE6D86E4-606F-4E38-B940-AD7248C9D519',
|
||||
publisherDisplayName: 'Nous Research Inc.'
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
|
||||
module.exports = identity
|
||||
@@ -0,0 +1,27 @@
|
||||
interface ProductIdentity {
|
||||
/** True when this artifact is Hermes Light (remote-only client). */
|
||||
light: boolean
|
||||
/** True for a Store-submission build (Windows Store packaging identity). */
|
||||
store: boolean
|
||||
/** Display name. e.g. "Hermes Light" */
|
||||
displayName: string
|
||||
/** OS-level app identity. e.g. "com.nousresearch.hermes-light" */
|
||||
appId: string
|
||||
/** app name in pascal case. e.g. "HermesLight" */
|
||||
appNamePascal: string
|
||||
/** OS-level app identity w/ org prefix. e.g. "NousResearch.HermesLight" */
|
||||
msixAppIdWithOrg: string
|
||||
/** electron-updater feed channel this build publishes to. Stable tags:
|
||||
* "latest" | "light"; nightly tags: "nightly" | "light-nightly". Null
|
||||
* for a store build (the Store owns its updates; no release feed). */
|
||||
channel: string | null
|
||||
/** Store-submission MSIX packaging identity. Present only when `store`. */
|
||||
storeMsix?: {
|
||||
identityName: string
|
||||
publisher: string
|
||||
publisherDisplayName: string
|
||||
}
|
||||
}
|
||||
|
||||
declare const identity: ProductIdentity
|
||||
export = identity
|
||||
@@ -28,7 +28,39 @@ import { sanitizeTree } from './sanitize-pe-signatures.mjs'
|
||||
import { stampExeIdentity } from './set-exe-identity.mjs'
|
||||
|
||||
export default async function afterPack(context) {
|
||||
if (context.electronPlatformName !== 'win32') {
|
||||
const platform = context.electronPlatformName
|
||||
if (platform === 'darwin') {
|
||||
const payload = findPackedPayload(context.appOutDir, platform)
|
||||
if (payload) {
|
||||
const dropped = stripFetchCache(payload)
|
||||
const n = relativizePayloadLinks(payload)
|
||||
const entitlements = path.join(import.meta.dirname, '..', 'electron', 'entitlements.mac.inherit.plist')
|
||||
const { identity, keychain } = await resolveSigningIdentity(context.packager)
|
||||
const nested = signNestedChromium(payload, { entitlements, identity, keychain })
|
||||
console.log(
|
||||
`[after-pack] dropped ${dropped} fetch- cache dirs; relativized ${n} payload links; repaired ${nested.repaired} framework links; signed ${nested.signed} nested chromium targets` +
|
||||
(identity ? ` as ${identity}` : ' (no Developer ID in the builder keychain)')
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
// Linux has no codesign, but the relocatable venv's bin/python* are
|
||||
// ABSOLUTE symlinks onto the build runner's store interpreter, so they
|
||||
// dangle once the unpacked tree moves (first-boot smoke, or a user
|
||||
// installing to a different path). Rewrite them to RELATIVE symlinks
|
||||
// (the target lives inside the payload) — a relative link survives
|
||||
// relocation AND keeps the interpreter's real prefix resolution (a
|
||||
// copied binary would fall back to the baked build prefix and lose its
|
||||
// stdlib). Out-of-payload targets fail the build.
|
||||
const payload = findPackedPayload(context.appOutDir, platform)
|
||||
if (payload) {
|
||||
const n = relativizePayloadLinks(payload)
|
||||
console.log(`[after-pack] relativized ${n} payload links so the relocatable venv survives relocation`)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -36,6 +68,18 @@ export default async function afterPack(context) {
|
||||
const exe = path.join(context.appOutDir, `${productName}.exe`)
|
||||
const desktopRoot = path.resolve(import.meta.dirname, '..')
|
||||
|
||||
// Repair dangling PE certificate tables BEFORE electron-builder signs the
|
||||
// tree. A stripped-but-still-declared signature makes signtool reject the
|
||||
// file with 0x800700C1, and AppxSIP inspects every PE inside the MSIX, so
|
||||
// one bad payload DLL fails the whole package. Unlike the stamp below this
|
||||
// is NOT best-effort: shipping past it means shipping an unsignable bundle.
|
||||
// this is a hack until https://github.com/astral-sh/python-build-standalone/pull/1217 is merged.
|
||||
const { scanned, repaired } = sanitizeTree(context.appOutDir)
|
||||
console.log(`[after-pack] ${scanned} PEs scanned, ${repaired.length} dangling certificate tables cleared`)
|
||||
for (const file of repaired) {
|
||||
console.log(` ${file}`)
|
||||
}
|
||||
|
||||
try {
|
||||
await stampExeIdentity(exe, desktopRoot)
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
// audit-bundle-arch.mjs: prove every native binary inside a packed desktop
|
||||
// bundle was built for the target architecture.
|
||||
//
|
||||
// Why: wrong-arch binaries run FINE on the build runner (Windows-on-ARM and
|
||||
// Rosetta emulate x64 silently) and only fail — or quietly burn cycles in
|
||||
// emulation — on user machines. One x64 pip launcher shim inside an arm64
|
||||
// payload shipped exactly this way. The build must fail loudly instead.
|
||||
//
|
||||
// The scan walks every file, sniffs the magic bytes (PE / ELF / Mach-O,
|
||||
// including fat headers), and reports any binary whose architecture does
|
||||
// not match the target. Non-binaries are skipped by content, not by file
|
||||
// extension — a mis-extensioned .exe still gets caught.
|
||||
//
|
||||
// CLI:
|
||||
// node audit-bundle-arch.mjs --arch=arm64 --root=release
|
||||
//
|
||||
// --root is scanned for the unpacked app dirs electron-builder produces
|
||||
// (win-unpacked, win-arm64-unpacked, linux-unpacked, mac/, mac-arm64/, …).
|
||||
// Auditing the unpacked tree covers the exact bytes the installer embeds:
|
||||
// MSIX/DMG/AppImage add compression around it, not content.
|
||||
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
import { isMain } from "./utils.mjs"
|
||||
|
||||
// ─── pure classification (unit-tested, no I/O) ─────────────────────────────
|
||||
|
||||
const PE_MACHINES = {
|
||||
0x014c: "ia32",
|
||||
0x01c0: "arm",
|
||||
0x01c4: "arm",
|
||||
0x8664: "x64",
|
||||
0xaa64: "arm64",
|
||||
0xa641: "arm64ec", // ARM64EC: arm64-ABI code, runs only on arm64 Windows
|
||||
}
|
||||
|
||||
const ELF_MACHINES = {
|
||||
0x03: "ia32",
|
||||
0x28: "arm",
|
||||
0x3e: "x64",
|
||||
0xb7: "arm64",
|
||||
}
|
||||
|
||||
const MACHO_CPUTYPES = {
|
||||
0x01000007: "x64",
|
||||
0x0100000c: "arm64",
|
||||
0x00000007: "ia32",
|
||||
0x0000000c: "arm",
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a buffer holding the head of a file (>= 64 bytes is plenty for
|
||||
* every format except PE, whose COFF header lives at an offset named at
|
||||
* 0x3c — pass headerAt for that case; classifyFile below handles it).
|
||||
*
|
||||
* Returns null for non-binaries, or { format, arches } where arches lists
|
||||
* every architecture the file carries (>1 only for Mach-O fat binaries).
|
||||
*/
|
||||
export function classifyHeader(buf) {
|
||||
if (buf.length >= 0x40 && buf[0] === 0x4d && buf[1] === 0x5a) {
|
||||
// PE: the real machine field needs a second read at e_lfanew; caller
|
||||
// resolves it. Report the format and the offset to read.
|
||||
return { format: "pe", peHeaderOffset: buf.readUInt32LE(0x3c) }
|
||||
}
|
||||
if (buf.length >= 20 && buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) {
|
||||
const machine = buf.readUInt16LE(18)
|
||||
return { format: "elf", arches: [ELF_MACHINES[machine] || `unknown(0x${machine.toString(16)})`] }
|
||||
}
|
||||
if (buf.length >= 8) {
|
||||
const be = buf.readUInt32BE(0)
|
||||
// Thin Mach-O, either endianness.
|
||||
if (be === 0xfeedface || be === 0xfeedfacf) {
|
||||
const cputype = buf.readUInt32BE(4)
|
||||
return { format: "macho", arches: [MACHO_CPUTYPES[cputype] || `unknown(0x${cputype.toString(16)})`] }
|
||||
}
|
||||
if (be === 0xcefaedfe || be === 0xcffaedfe) {
|
||||
const cputype = buf.readUInt32LE(4)
|
||||
return { format: "macho", arches: [MACHO_CPUTYPES[cputype] || `unknown(0x${cputype.toString(16)})`] }
|
||||
}
|
||||
// Fat/universal: big-endian header, one arch entry per slice.
|
||||
if (be === 0xcafebabe) {
|
||||
const count = buf.readUInt32BE(4)
|
||||
// A Java .class file shares the magic; its "count" here is the
|
||||
// bytecode version (>= 45). Real fat binaries carry a few slices.
|
||||
if (count > 0 && count < 30) {
|
||||
const arches = []
|
||||
for (let i = 0; i < count && 8 + i * 20 + 4 <= buf.length; i++) {
|
||||
const cputype = buf.readUInt32BE(8 + i * 20)
|
||||
arches.push(MACHO_CPUTYPES[cputype] || `unknown(0x${cputype.toString(16)})`)
|
||||
}
|
||||
return { format: "macho-fat", arches }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PE machine code (read at peHeaderOffset+4) to an arch name.
|
||||
* Split from classifyHeader so the walker can do the second read lazily.
|
||||
*/
|
||||
export function peArch(machineCode) {
|
||||
return PE_MACHINES[machineCode] || `unknown(0x${machineCode.toString(16)})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a classified binary satisfy the target arch?
|
||||
* - exact match, always
|
||||
* - macOS fat binary: any slice matching is enough (universal ships both)
|
||||
* - arm64 targets accept arm64ec PEs (arm64-ABI by definition)
|
||||
* Everything else — including "unknown(...)" — is a mismatch: an
|
||||
* unclassifiable machine code in a shipped binary deserves a loud failure,
|
||||
* not a shrug.
|
||||
*/
|
||||
export function archMatches(arches, targetArch) {
|
||||
return arches.some((a) => a === targetArch || (targetArch === "arm64" && a === "arm64ec"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The unpacked-app directories electron-builder leaves under release/ for
|
||||
* a target arch. Names differ per platform: Windows/Linux use
|
||||
* <platform>[-<arch>]-unpacked with the HOST-default arch unsuffixed; mac
|
||||
* uses mac[-<arch>]/. Match by shape, not an exhaustive list, so a rename
|
||||
* in electron-builder fails the audit (no dirs found) instead of silently
|
||||
* auditing nothing.
|
||||
*/
|
||||
export function findUnpackedDirs(entries) {
|
||||
return entries.filter((name) => /-unpacked$/.test(name) || /^mac(-[a-z0-9]+)?$/.test(name))
|
||||
}
|
||||
|
||||
// ─── impure walker + CLI ────────────────────────────────────────────────────
|
||||
|
||||
function classifyFile(filePath) {
|
||||
const fd = fs.openSync(filePath, "r")
|
||||
try {
|
||||
const head = Buffer.alloc(4096)
|
||||
const n = fs.readSync(fd, head, 0, head.length, 0)
|
||||
const sniffed = classifyHeader(head.subarray(0, n))
|
||||
if (!sniffed) return null
|
||||
if (sniffed.format !== "pe") return sniffed
|
||||
// PE: read the 6 bytes at e_lfanew — "PE\0\0" + machine.
|
||||
const peHead = Buffer.alloc(6)
|
||||
const m = fs.readSync(fd, peHead, 0, 6, sniffed.peHeaderOffset)
|
||||
if (m < 6 || peHead.readUInt32LE(0) !== 0x00004550) {
|
||||
return null // MZ without a PE header: DOS-era stub or corrupt — not a shippable binary format we know.
|
||||
}
|
||||
return { format: "pe", arches: [peArch(peHead.readUInt16LE(4))] }
|
||||
} finally {
|
||||
fs.closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function* walkFiles(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isSymbolicLink()) continue // targets are scanned as files where they live
|
||||
if (entry.isDirectory()) {
|
||||
yield* walkFiles(full)
|
||||
} else if (entry.isFile()) {
|
||||
yield full
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths allowed to carry foreign-arch binaries. pip and setuptools ship
|
||||
* Windows launcher STUB TEMPLATES as package data (distlib t32/t64/w32/
|
||||
* w64.exe, setuptools cli*.exe / gui*.exe). They are not executed from
|
||||
* here; pip copies one to build a console-script shim at install time.
|
||||
* They exist on every platform and are always x86 PEs — inside an arm64
|
||||
* or linux payload they are inert bytes, not a wrong-arch bug.
|
||||
*
|
||||
* Anchored at agent-payload (not the tree root: mac nests the payload
|
||||
* under Hermes.app/Contents/Resources/). The lib segment is `Lib` on
|
||||
* Windows and `lib/python3.11` elsewhere; both separators appear in
|
||||
* relative paths depending on the build host.
|
||||
*
|
||||
* PortableGit carries .NET AnyCPU/MSIL assemblies (Git Credential Manager:
|
||||
* Avalonia.*, Atlassian.*, Microsoft.*, System.*, etc.) across mingw64/bin,
|
||||
* mingw64/lib, and mingw64/libexec/git-core. Their PE machine field is 0x14c
|
||||
* (ia32) because .NET assemblies are format-neutral — the CLR JITs them to
|
||||
* the native arch at load time. It also ships usr/libexec/getprocaddr32.exe,
|
||||
* a 32-bit MSYS2 helper. The staging script already PE-probes cmd/git.exe
|
||||
* itself; the bundle audit does not need to re-audit PortableGit's internal
|
||||
* MSYS2/.NET layout.
|
||||
*/
|
||||
const EXEMPT_PATTERNS = [
|
||||
// pip / setuptools launcher STUB TEMPLATES. x86 PE templates pip copies
|
||||
// when it writes a console-script shim. Present on every platform.
|
||||
// Live under the payload python store entry AND the relocatable venv.
|
||||
/agent-payload[/\\](tools[/\\]python-[^/\\]+[/\\](Lib|lib[/\\]python[\d.]+)|venv[/\\](Lib|lib[/\\]python[\d.]+))[/\\]site-packages[/\\](setuptools|pip[/\\]_vendor[/\\]distlib)[/\\]/i,
|
||||
// PortableGit internals under the payload tool store.
|
||||
/agent-payload[/\\]tools[/\\]git(-[^/\\]+)?[/\\](mingw64|clangarm64|usr|cmd)[/\\]/i,
|
||||
// discord.py ships x64 and x86 opus; opus.py loads by bitness.
|
||||
/agent-payload[/\\]venv[/\\](Lib|lib[/\\]python[\d.]+)[/\\]site-packages[/\\]discord[/\\]bin[/\\]libopus-0\.(x64|x86)\.dll$/i,
|
||||
// pvporcupine ships one native lib per OS/arch in the same wheel.
|
||||
// Only the matching sibling is loaded.
|
||||
/agent-payload[/\\]venv[/\\](Lib|lib[/\\]python[\d.]+)[/\\]site-packages[/\\]pvporcupine[/\\]lib[/\\]/i,
|
||||
// debugpy ships attach helpers for every OS/arch in one wheel.
|
||||
/agent-payload[/\\]venv[/\\](Lib|lib[/\\]python[\d.]+)[/\\]site-packages[/\\]debugpy[/\\]_vendored[/\\]pydevd[/\\]/i,
|
||||
// agent-browser ships no native win-arm64 build; on win32-arm64 the
|
||||
// payload stages the x64 exe, which Windows runs under built-in
|
||||
// emulation (its own postinstall falls back to x64 on arm64).
|
||||
/agent-payload[/\\]tools[/\\]agent-browser-[^/\\]+[/\\]bin[/\\]agent-browser-win32-x64\.exe$/i,
|
||||
// chromium / chromium-headless-shell are pm packages too: CfT publishes
|
||||
// no native win-arm64 build, so on win32-arm64 the payload stages the
|
||||
// win64 (x64) build and Windows runs it under emulation — the same
|
||||
// choice the package code makes (PlaywrightBrowser._CFT maps win32-arm64
|
||||
// to win64). Store entries are named by playwright revision only
|
||||
// (chromium-<rev>, chromium_headless_shell-<rev>, dashes→underscores);
|
||||
// the zips extract in place, so the x64 trees carry a -win64 segment.
|
||||
// Scoping to that segment keeps linux/darwin chromium audited.
|
||||
/agent-payload[/\\]tools[/\\]chromium(_headless_shell)?-[^/\\]+[/\\](chrome|chrome-headless-shell)-win64[/\\]/i,
|
||||
]
|
||||
|
||||
export function isExemptPath(relPath) {
|
||||
return EXEMPT_PATTERNS.some((p) => p.test(relPath))
|
||||
}
|
||||
|
||||
export function auditTree(rootDir, targetArch) {
|
||||
const mismatches = []
|
||||
let binaries = 0
|
||||
let exempted = 0
|
||||
for (const file of walkFiles(rootDir)) {
|
||||
let info
|
||||
try {
|
||||
info = classifyFile(file)
|
||||
} catch {
|
||||
continue // unreadable file: electron-builder would have failed on it already
|
||||
}
|
||||
if (!info) continue
|
||||
binaries += 1
|
||||
if (!archMatches(info.arches, targetArch)) {
|
||||
const rel = path.relative(rootDir, file)
|
||||
if (isExemptPath(rel)) {
|
||||
exempted += 1
|
||||
continue
|
||||
}
|
||||
mismatches.push({ file: rel, format: info.format, arches: info.arches })
|
||||
}
|
||||
}
|
||||
return { binaries, exempted, mismatches }
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = Object.fromEntries(
|
||||
process.argv.slice(2).map((a) => {
|
||||
const [k, ...v] = a.replace(/^--/, "").split("=")
|
||||
return [k, v.join("=")]
|
||||
})
|
||||
)
|
||||
const targetArch = args.arch
|
||||
const root = args.root
|
||||
if (!targetArch || !root) {
|
||||
console.error("usage: audit-bundle-arch.mjs --arch=<x64|arm64> --root=<release dir>")
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const dirs = findUnpackedDirs(fs.readdirSync(root)).map((d) => path.join(root, d))
|
||||
if (dirs.length === 0) {
|
||||
console.error(`audit-bundle-arch: no unpacked app directory under ${root} — nothing was audited, failing`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let failed = false
|
||||
for (const dir of dirs) {
|
||||
const { binaries, exempted, mismatches } = auditTree(dir, targetArch)
|
||||
if (binaries === 0) {
|
||||
console.error(`audit-bundle-arch: ${dir}: found no native binaries at all — the scan is broken, failing`)
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
if (mismatches.length > 0) {
|
||||
failed = true
|
||||
console.error(`audit-bundle-arch: ${dir}: ${mismatches.length} binaries do not match --arch=${targetArch}:`)
|
||||
for (const m of mismatches) {
|
||||
console.error(` [${m.format} ${m.arches.join("+")}] ${m.file}`)
|
||||
}
|
||||
} else {
|
||||
const exemptNote = exempted > 0 ? ` (${exempted} exempt launcher stubs)` : ""
|
||||
console.log(`audit-bundle-arch: ${dir}: ${binaries} native binaries, all ${targetArch}${exemptNote}`)
|
||||
}
|
||||
}
|
||||
process.exit(failed ? 1 : 0)
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
main()
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { archMatches, classifyHeader, findUnpackedDirs, isExemptPath, peArch } from '../scripts/audit-bundle-arch.mjs'
|
||||
|
||||
// ─── header builders: the smallest buffers each format needs ───────────────
|
||||
|
||||
function elfHeader(machine) {
|
||||
const buf = Buffer.alloc(64)
|
||||
buf.write('\x7fELF', 0, 'latin1')
|
||||
buf.writeUInt16LE(machine, 18)
|
||||
return buf
|
||||
}
|
||||
|
||||
function machoThin(cputype, { swapped = false } = {}) {
|
||||
const buf = Buffer.alloc(64)
|
||||
if (swapped) {
|
||||
buf.writeUInt32BE(0xcffaedfe, 0) // magic as stored little-endian on disk
|
||||
buf.writeUInt32LE(cputype, 4)
|
||||
} else {
|
||||
buf.writeUInt32BE(0xfeedfacf, 0)
|
||||
buf.writeUInt32BE(cputype, 4)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
function machoFat(cputypes) {
|
||||
const buf = Buffer.alloc(8 + cputypes.length * 20)
|
||||
buf.writeUInt32BE(0xcafebabe, 0)
|
||||
buf.writeUInt32BE(cputypes.length, 4)
|
||||
cputypes.forEach((t, i) => buf.writeUInt32BE(t, 8 + i * 20))
|
||||
return buf
|
||||
}
|
||||
|
||||
function mzStub(peOffset) {
|
||||
const buf = Buffer.alloc(0x40)
|
||||
buf.write('MZ', 0, 'latin1')
|
||||
buf.writeUInt32LE(peOffset, 0x3c)
|
||||
return buf
|
||||
}
|
||||
|
||||
// ─── classifyHeader ─────────────────────────────────────────────────
|
||||
|
||||
test('classifyHeader names the arch for each executable format', () => {
|
||||
assert.deepEqual(classifyHeader(elfHeader(0x3e)).arches, ['x64'])
|
||||
assert.deepEqual(classifyHeader(elfHeader(0xb7)).arches, ['arm64'])
|
||||
assert.deepEqual(classifyHeader(machoThin(0x0100000c)).arches, ['arm64'])
|
||||
assert.deepEqual(classifyHeader(machoThin(0x01000007, { swapped: true })).arches, ['x64'])
|
||||
assert.deepEqual(classifyHeader(machoFat([0x01000007, 0x0100000c])).arches, ['x64', 'arm64'])
|
||||
})
|
||||
|
||||
test('classifyHeader defers PE to the offset named in the MZ stub', () => {
|
||||
const sniffed = classifyHeader(mzStub(0x180))
|
||||
assert.equal(sniffed.format, 'pe')
|
||||
assert.equal(sniffed.peHeaderOffset, 0x180)
|
||||
// The machine code itself resolves through peArch.
|
||||
assert.equal(peArch(0x8664), 'x64')
|
||||
assert.equal(peArch(0xaa64), 'arm64')
|
||||
assert.equal(peArch(0xa641), 'arm64ec')
|
||||
assert.match(peArch(0xbeef), /unknown/)
|
||||
})
|
||||
|
||||
test('classifyHeader skips non-binaries, tiny files, and Java class files', () => {
|
||||
assert.equal(classifyHeader(Buffer.from('#!/bin/sh\necho hi\n')), null)
|
||||
assert.equal(classifyHeader(Buffer.from('MZ')), null) // too short to carry a PE offset
|
||||
assert.equal(classifyHeader(Buffer.alloc(0)), null)
|
||||
// Java .class: same magic as a fat Mach-O, giant "slice count" (version).
|
||||
const javaClass = Buffer.alloc(16)
|
||||
javaClass.writeUInt32BE(0xcafebabe, 0)
|
||||
javaClass.writeUInt32BE(65, 4)
|
||||
assert.equal(classifyHeader(javaClass), null)
|
||||
})
|
||||
|
||||
// ─── archMatches ─────────────────────────────────────────────────
|
||||
|
||||
test('archMatches: exact match, universal slices, arm64ec, and rejections', () => {
|
||||
assert.ok(archMatches(['arm64'], 'arm64'))
|
||||
assert.ok(archMatches(['x64', 'arm64'], 'arm64')) // universal binary covers the target
|
||||
assert.ok(archMatches(['arm64ec'], 'arm64')) // arm64-ABI by definition
|
||||
assert.ok(!archMatches(['x64'], 'arm64')) // the shipped-x64-shim bug this audit exists for
|
||||
assert.ok(!archMatches(['arm64ec'], 'x64')) // arm64ec does not run on x64 hosts
|
||||
assert.ok(!archMatches(['unknown(0xbeef)'], 'x64')) // unclassifiable ships nowhere
|
||||
})
|
||||
|
||||
// ─── findUnpackedDirs ─────────────────────────────────────────────────
|
||||
|
||||
test('findUnpackedDirs matches electron-builder output shapes only', () => {
|
||||
const dirs = findUnpackedDirs([
|
||||
'win-unpacked', 'win-arm64-unpacked', 'linux-unpacked', 'linux-arm64-unpacked',
|
||||
'mac', 'mac-arm64',
|
||||
'builder-debug.yml', 'Hermes-0.20.0.exe', 'latest.yml', '.icon-ico'
|
||||
])
|
||||
assert.deepEqual(dirs, [
|
||||
'win-unpacked', 'win-arm64-unpacked', 'linux-unpacked', 'linux-arm64-unpacked',
|
||||
'mac', 'mac-arm64'
|
||||
])
|
||||
})
|
||||
|
||||
// ─── the git exemption is PortableGit-shaped, not git-shaped ───────────────
|
||||
|
||||
test('PortableGit internals stay exempt under the payload store', () => {
|
||||
// .NET assemblies report ia32 because they are format-neutral.
|
||||
for (const relPath of [
|
||||
'resources/agent-payload/tools/git-2.53.0-win32-x64/mingw64/bin/Avalonia.dll',
|
||||
'resources\\agent-payload\\tools\\git-2.53.0-win32-x64\\mingw64\\libexec\\git-core\\GitHub.dll',
|
||||
'resources\\agent-payload\\tools\\git-2.53.0-win32-x64\\usr\\libexec\\getprocaddr32.exe',
|
||||
'resources/agent-payload/tools/git-2.54.0-win32-arm64/clangarm64/libexec/git-core/msalruntime.dll'
|
||||
]) {
|
||||
assert.equal(isExemptPath(relPath), true, relPath)
|
||||
}
|
||||
})
|
||||
|
||||
test('a POSIX git store entry is audited', () => {
|
||||
// posix uses system git; a linux/darwin git store has no mingw64/usr/cmd
|
||||
// segment, so a wrong-arch binary there must fail the audit.
|
||||
for (const relPath of [
|
||||
'resources/agent-payload/tools/git-2.53.0-linux-x64/bin/git',
|
||||
'resources/agent-payload/tools/git-2.53.0-darwin-arm64/libexec/git-core/git-remote-https'
|
||||
]) {
|
||||
assert.equal(isExemptPath(relPath), false, relPath)
|
||||
}
|
||||
})
|
||||
|
||||
test('pip and setuptools launcher stubs are exempt in the store and the venv', () => {
|
||||
for (const relPath of [
|
||||
'resources/agent-payload/tools/python-3.11.16+20260814-linux-x64/lib/python3.11/site-packages/pip/_vendor/distlib/t32.exe',
|
||||
'resources/agent-payload/tools/python-3.11.16+20260814-linux-x64/lib/python3.11/site-packages/setuptools/cli.exe',
|
||||
'resources/agent-payload/venv/lib/python3.11/site-packages/setuptools/cli-32.exe',
|
||||
'resources/agent-payload/venv/Lib/site-packages/setuptools/cli.exe'
|
||||
]) {
|
||||
assert.equal(isExemptPath(relPath), true, relPath)
|
||||
}
|
||||
})
|
||||
|
||||
test('discord opus and pvporcupine multi-arch siblings are exempt', () => {
|
||||
for (const relPath of [
|
||||
'resources/agent-payload/venv/lib/python3.11/site-packages/discord/bin/libopus-0.x86.dll',
|
||||
'resources/agent-payload/venv/lib/python3.11/site-packages/discord/bin/libopus-0.x64.dll',
|
||||
'resources/agent-payload/venv/lib/python3.11/site-packages/pvporcupine/lib/mac/arm64/libpv_porcupine.dylib',
|
||||
'resources/agent-payload/venv/lib/python3.11/site-packages/pvporcupine/lib/raspberry-pi/arm11/libpv_porcupine.so',
|
||||
'resources/agent-payload/venv/lib/python3.11/site-packages/debugpy/_vendored/pydevd/pydevd_attach_to_process/attach_linux_amd64.so',
|
||||
'resources/agent-payload/venv/lib/python3.11/site-packages/debugpy/_vendored/pydevd/pydevd_attach_to_process/inject_dll_x86.exe'
|
||||
]) {
|
||||
assert.equal(isExemptPath(relPath), true, relPath)
|
||||
}
|
||||
})
|
||||
|
||||
test('the emulated x64 agent-browser exe is exempt in a win32-arm64 payload', () => {
|
||||
for (const relPath of [
|
||||
'resources/agent-payload/tools/agent-browser-0.35.1-win32-arm64/bin/agent-browser-win32-x64.exe',
|
||||
'resources\\agent-payload\\tools\\agent-browser-0.26.0-win32-arm64\\bin\\agent-browser-win32-x64.exe'
|
||||
]) {
|
||||
assert.equal(isExemptPath(relPath), true, relPath)
|
||||
}
|
||||
// A non-chromium x64 binary in the payload store still fails: the
|
||||
// chromium exemption is scoped to the store entries' -win64 trees, not
|
||||
// to "any x64 exe under tools/".
|
||||
assert.equal(isExemptPath('resources/agent-payload/tools/something-1.0-win32-arm64/bin/thing.exe'), false)
|
||||
})
|
||||
|
||||
test('the emulated x64 chromium trees are exempt in a win32-arm64 payload', () => {
|
||||
// PlaywrightBrowser._CFT maps win32-arm64 to the CfT win64 (x64) build;
|
||||
// store entries are named by playwright revision (dashes→underscores)
|
||||
// and the zip extracts in place, so the x64 trees sit under
|
||||
// chrome-win64 / chrome-headless-shell-win64 inside the entry.
|
||||
for (const relPath of [
|
||||
'resources/agent-payload/tools/chromium-1208/chrome-win64/chrome.exe',
|
||||
'resources\\agent-payload\\tools\\chromium-1208\\chrome-win64\\chrome.dll',
|
||||
'resources/agent-payload/tools/chromium_headless_shell-1208/chrome-headless-shell-win64/chrome-headless-shell.exe'
|
||||
]) {
|
||||
assert.equal(isExemptPath(relPath), true, relPath)
|
||||
}
|
||||
// The linux/darwin chromium trees stay audited: no -win64 segment.
|
||||
assert.equal(isExemptPath('resources/agent-payload/tools/chromium-1208/chrome-linux/chrome'), false)
|
||||
assert.equal(isExemptPath('resources/agent-payload/tools/chromium-1208/chrome-mac-arm64/chrome'), false)
|
||||
})
|
||||
|
||||
test('a random payload binary is not exempt', () => {
|
||||
assert.equal(isExemptPath('resources/agent-payload/hermes-agent/something.exe'), false)
|
||||
})
|
||||
@@ -2,9 +2,20 @@
|
||||
* Desktop bundles ship precompiled renderer assets. Returning false here tells
|
||||
* electron-builder to skip the node_modules collector/install step, which
|
||||
* avoids workspace dependency graph explosions and keeps packaging
|
||||
* deterministic across environments. The Hermes Agent Python payload is no
|
||||
* longer bundled; the Electron app fetches it at first launch via
|
||||
* `install.ps1`'s stage protocol (Windows). See `electron/main.ts`.
|
||||
* deterministic across environments.
|
||||
*
|
||||
* Also guarantees build/agent-payload exists: extraResources copies it on
|
||||
* every build, and electron-builder's behavior for a missing `from` varies
|
||||
* between versions. A bundled build stages the real payload there first
|
||||
* (`hermes pm bundle --out apps/desktop/build/agent-payload`); anything else
|
||||
* gets a stub manifest with external:true, which resolvePayload() treats as
|
||||
* "no payload" so the backend resolver falls through to the runtime rungs.
|
||||
*
|
||||
* Also stages the MSIX build-time assets (the build/appx icon set and the
|
||||
* build/msix-extensions.xml fragment the config points customExtensionsPath
|
||||
* at). These MUST happen here, in the hook electron-builder calls at build
|
||||
* time — not in electron-builder.config.cjs at require time, or every
|
||||
* typecheck/test import of the config would write files.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
@@ -20,6 +31,17 @@ const {
|
||||
} = require('../product-identity.cjs')
|
||||
|
||||
export default async function beforeBuild() {
|
||||
stageMsixAssets()
|
||||
writeMsixExtensions()
|
||||
|
||||
const payloadDir = path.join(import.meta.dirname, '..', 'build', 'agent-payload')
|
||||
const manifest = path.join(payloadDir, 'manifest.json')
|
||||
|
||||
if (!fs.existsSync(manifest)) {
|
||||
fs.mkdirSync(payloadDir, { recursive: true })
|
||||
fs.writeFileSync(manifest, JSON.stringify({ schema: 1, external: true }, null, 2) + '\n')
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -95,17 +117,19 @@ ${aliases}`
|
||||
export function appExecutionAliasExtensions(launchers = CLI_LAUNCHER_SPECS.map((s) => s.name)) {
|
||||
const bs = String.fromCharCode(92)
|
||||
const executable = (name) => ['app', 'resources', 'agent-payload', 'bin', `${name}.exe`].join(bs)
|
||||
return launchers
|
||||
.map(
|
||||
(name) => `<uap5:Extension
|
||||
// ONE windows.appExecutionAlias extension per package — makeappx rejects a
|
||||
// second one with the opaque 0x80080204 (A/B-verified against the 26100
|
||||
// kit). Every launcher alias rides in the same extension's AppExecutionAlias;
|
||||
// the Executable attribute names the exe that serves the aliases.
|
||||
return `<uap5:Extension
|
||||
xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"
|
||||
Category="windows.appExecutionAlias"
|
||||
Executable="${executable(name)}"
|
||||
Executable="${executable(launchers[0])}"
|
||||
EntryPoint="Windows.FullTrustApplication">
|
||||
<uap5:AppExecutionAlias>
|
||||
<uap5:ExecutionAlias Alias="${name}.exe" />
|
||||
${launchers
|
||||
.map((name) => ` <uap5:ExecutionAlias Alias="${name}.exe" />`)
|
||||
.join('\n')}
|
||||
</uap5:AppExecutionAlias>
|
||||
</uap5:Extension>`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
@@ -10,9 +10,12 @@
|
||||
// `electron` and `node-pty` are external (provided by the runtime / staged
|
||||
// separately via stage-native-deps).
|
||||
import { build } from 'esbuild'
|
||||
import { createRequire } from 'node:module'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { mkdirSync, readFileSync } from 'node:fs'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const root = resolve(here, '..')
|
||||
@@ -29,9 +32,31 @@ const external = ['electron', 'node-pty', 'get-windows', 'fs']
|
||||
// behaves like a packaged build. Dev bundles (`--dev`) leave the env alone
|
||||
// so HERMES_DESKTOP_DEV_SERVER / source-tree resolution keep working.
|
||||
const isDev = process.argv.includes('--dev')
|
||||
|
||||
// The install stamp is baked INTO the bundle: the define below sets the
|
||||
// __HERMES_INSTALL_STAMP__ global to the stamp OBJECT literal (the JSON
|
||||
// text is a valid JS expression, so no string round-trip). A baked
|
||||
// constant cannot be missing, stale, or edited after signing.
|
||||
// `npm run build` writes build/install-stamp.json immediately before this
|
||||
// script runs, so a missing file here is a broken build, not a thin one.
|
||||
// Dev bundles bake nothing — install-stamp.ts's typeof guard yields null,
|
||||
// because a dev run has no artifact to be truthful about (a stale stamp
|
||||
// from a previous build would lie about provenance).
|
||||
function bakedInstallStamp() {
|
||||
const raw = readFileSync(resolve(root, 'build/install-stamp.json'), 'utf8')
|
||||
JSON.parse(raw) // fail the build on malformed output, not first launch
|
||||
return raw
|
||||
}
|
||||
|
||||
const define = isDev
|
||||
? {}
|
||||
: { 'process.env.HERMES_DESKTOP_IS_PACKAGED': JSON.stringify(true) }
|
||||
: {
|
||||
'process.env.HERMES_DESKTOP_IS_PACKAGED': JSON.stringify(true),
|
||||
'__HERMES_INSTALL_STAMP__': bakedInstallStamp(),
|
||||
// The product identity (name object, appId, deep-link scheme)
|
||||
// baked the same way electron-builder gets it
|
||||
'__HERMES_PRODUCT_IDENTITY__': JSON.stringify(require('../product-identity.cjs')),
|
||||
}
|
||||
|
||||
// Bundle main.ts → dist/electron-main.mjs
|
||||
await build({
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check for an App Installer update for the running MSIX package.
|
||||
|
||||
The out-of-store desktop installs are App Installer owned: the OS registered
|
||||
the .appinstaller URI as the package's update source at install, so the
|
||||
OS can tell us whether a newer package version is available. The desktop
|
||||
shows its own prompt + tears down before the OS applies the swap.
|
||||
|
||||
Run with the BUNDLED payload python (the winrt package ships there):
|
||||
|
||||
<payload>/tools/<python-entry>/python.exe scripts/check-appinstaller-update.py
|
||||
|
||||
Exit codes:
|
||||
0 no update available (or no App Installer source — nothing to do)
|
||||
2 update available (the caller decides whether to prompt/teardown)
|
||||
1 error (update availability unknown; the caller reports it honestly)
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
from winrt.windows.applicationmodel import Package, PackageUpdateAvailability
|
||||
from winrt.windows.management.deployment import PackageManager
|
||||
except ImportError as exc:
|
||||
# The winrt module is absent (older payload without the dep): the
|
||||
# update check cannot run. Report unknown, not "no update".
|
||||
print(json.dumps({"available": None, "error": f"winrt import failed: {exc}"}))
|
||||
return 1
|
||||
|
||||
try:
|
||||
package = Package.current()
|
||||
except Exception as exc: # noqa: BLE001 — the API raises on non-packaged runs
|
||||
# Not running as a packaged app (dev run) — nothing to check.
|
||||
print(json.dumps({"available": False, "reason": "not-packaged"}))
|
||||
return 0
|
||||
|
||||
package_family_name = package.id.family_name
|
||||
manager = PackageManager()
|
||||
try:
|
||||
result = manager.check_package_update_availability_async(package_family_name).get()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(json.dumps({"available": None, "error": f"check failed: {exc}"}))
|
||||
return 1
|
||||
|
||||
availability = PackageUpdateAvailability(result.availability)
|
||||
# Available / Required both mean "a newer package exists on the source".
|
||||
available = availability in (
|
||||
PackageUpdateAvailability.AVAILABLE,
|
||||
PackageUpdateAvailability.REQUIRED,
|
||||
)
|
||||
print(json.dumps({"available": available, "availability": str(availability)}))
|
||||
return 2 if available else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -87,24 +87,26 @@ test('win wrapper rejects values that still contain placeholders', () => {
|
||||
assert.throws(() => renderWinWrapper({ module: '__HERMES_REPO_REL__', func: 'main' }, RELS.relRepo, RELS.relSite))
|
||||
})
|
||||
|
||||
test('MSIX: one uap5 alias Extension per launcher exe, Executable naming that exe', () => {
|
||||
test('MSIX: ONE uap5 alias Extension naming every launcher alias', () => {
|
||||
const xml = appExecutionAliasExtensions()
|
||||
const blocks = xml.match(/<uap5:Extension\b/g) ?? []
|
||||
assert.equal(blocks.length, 3)
|
||||
// makeappx rejects a second windows.appExecutionAlias Extension with the
|
||||
// opaque 0x80080204 — all launcher aliases must ride in ONE block.
|
||||
assert.equal(blocks.length, 1)
|
||||
// The block's Executable names the exe that serves the aliases (first launcher).
|
||||
const first = ['app', 'resources', 'agent-payload', 'bin', `${CLI_LAUNCHER_SPECS[0].name}.exe`].join('\\')
|
||||
assert.ok(xml.includes(`Executable="${first}"`), 'Executable must name the first launcher exe')
|
||||
// Every launcher exe gets exactly one ExecutionAlias inside the block.
|
||||
for (const spec of CLI_LAUNCHER_SPECS) {
|
||||
const expected = ['app', 'resources', 'agent-payload', 'bin', `${spec.name}.exe`].join('\\')
|
||||
assert.ok(
|
||||
xml.includes(`Executable="${expected}"`),
|
||||
`no alias Extension names ${expected}`
|
||||
)
|
||||
// Backslashes: MSIX Executable must be manifest-backslash form.
|
||||
assert.ok(!xml.includes(`${expected}/`), 'forward slashes are not manifest form')
|
||||
assert.ok(
|
||||
xml.includes(`<uap5:ExecutionAlias Alias="${spec.name}.exe" />`),
|
||||
`no ExecutionAlias for ${spec.name}.exe`
|
||||
)
|
||||
}
|
||||
// The old rust shim's single-exe-argv[0]-dispatch shape is gone: each
|
||||
// block serves exactly ONE alias.
|
||||
for (const block of (xml.match(/<uap5:Extension[\s\S]*?<\/uap5:Extension>/g) ?? [])) {
|
||||
assert.equal((block.match(/<uap5:ExecutionAlias /g) ?? []).length, 1)
|
||||
}
|
||||
assert.equal((xml.match(/<uap5:ExecutionAlias /g) ?? []).length, CLI_LAUNCHER_SPECS.length)
|
||||
})
|
||||
|
||||
test('light variant emits no alias fragments', () => {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// Bound how many fs.open calls are in flight at once, process-wide.
|
||||
//
|
||||
// WHY THIS EXISTS
|
||||
//
|
||||
// @electron/osx-sign decides what to sign by walking the whole .app
|
||||
// (dist/util.js::walk). The walk recurses with
|
||||
// `Promise.all(children.map(...))` and no concurrency bound, and every
|
||||
// regular file it reaches is probed by isbinaryfile, which opens a
|
||||
// descriptor. Peak descriptors therefore track the size of the tree, not
|
||||
// any fixed budget.
|
||||
//
|
||||
// The bundled payload makes that fatal. site-packages/lark_oapi alone is
|
||||
// 11,112 files, so signing died with:
|
||||
//
|
||||
// EMFILE: too many open files, open
|
||||
// '.../agent-payload/site-packages/lark_oapi/api/im/v1/model/reaction.py'
|
||||
//
|
||||
// Raising `ulimit -n` also clears it, but only moves the ceiling: the
|
||||
// walk still asks for as many descriptors as there are files, so the
|
||||
// next payload growth hits the new limit. This caps the demand instead.
|
||||
//
|
||||
// WHAT IT PATCHES
|
||||
//
|
||||
// `fs.open` (callback form) and `fs.promises.open`. That covers the
|
||||
// paths that actually consume descriptors here:
|
||||
// - isbinaryfile captures `promisify(fs.open)` at module load, so the
|
||||
// patch must be installed BEFORE it is required — hence --require.
|
||||
// - fs.createReadStream routes through the public fs.open (verified).
|
||||
//
|
||||
// Deliberately NOT patched: openSync. A synchronous open cannot be
|
||||
// queued without blocking the loop that would drain the queue, and it is
|
||||
// self-limiting anyway — one at a time, by definition.
|
||||
//
|
||||
// WHY IT CANNOT DEADLOCK
|
||||
//
|
||||
// The slot is released when the open() call SETTLES, not when the
|
||||
// descriptor is closed. A task holding ten descriptors occupies zero
|
||||
// slots while it works, so "hold one, open another" always makes
|
||||
// progress. Gating the descriptor's lifetime instead does deadlock —
|
||||
// measured: 4 tasks each holding 1 fd and awaiting a second, under
|
||||
// cap=2, never completes.
|
||||
//
|
||||
// Failures propagate unchanged: a rejected open releases its slot in
|
||||
// `finally` and the original error reaches the caller. This changes
|
||||
// timing only, never behavior.
|
||||
//
|
||||
// HERMES_FS_OPEN_LIMIT overrides the computed cap; 0 disables the patch.
|
||||
|
||||
'use strict'
|
||||
|
||||
const fs = require('fs')
|
||||
|
||||
// The cap must sit well BELOW the process's fd limit, not near it. Each
|
||||
// queued open is held across the caller's read+close (osx-sign's walk
|
||||
// does exactly that), so `LIMIT` descriptors can be live at once, on top
|
||||
// of the fds node already holds for stdio, the module loader, sockets
|
||||
// and spawned children. Measured against the real walk: cap 100 fails at
|
||||
// a 64 limit and passes at 256; cap 16 passes at 64.
|
||||
//
|
||||
// So: a quarter of the soft limit, clamped to a sane band. On the macOS
|
||||
// default of 256 that is 64; on a raised limit it grows, keeping the
|
||||
// walk fast; on a tiny limit it shrinks instead of guaranteeing EMFILE.
|
||||
function defaultLimit() {
|
||||
const override = Number(process.env.HERMES_FS_OPEN_LIMIT)
|
||||
if (Number.isFinite(override) && override >= 0) return override
|
||||
let soft = 0
|
||||
try {
|
||||
soft = process.report.getReport().userLimits.open_files.soft
|
||||
} catch {
|
||||
soft = 0
|
||||
}
|
||||
// `unlimited` reports as -1 (or a huge number); either way the clamp holds.
|
||||
if (!Number.isFinite(soft) || soft <= 0) soft = 256
|
||||
return Math.max(8, Math.min(512, Math.floor(soft / 4)))
|
||||
}
|
||||
|
||||
const LIMIT = defaultLimit()
|
||||
|
||||
// A guard against double-install (nested spawns inheriting NODE_OPTIONS).
|
||||
if (!globalThis.__hermesFsOpenLimited && LIMIT > 0) {
|
||||
globalThis.__hermesFsOpenLimited = true
|
||||
|
||||
let active = 0
|
||||
/** @type {Array<() => void>} */
|
||||
const queue = []
|
||||
|
||||
const pump = () => {
|
||||
while (active < LIMIT && queue.length > 0) {
|
||||
active += 1
|
||||
queue.shift()()
|
||||
}
|
||||
}
|
||||
|
||||
const release = () => {
|
||||
active -= 1
|
||||
pump()
|
||||
}
|
||||
|
||||
// ── fs.open (callback form) ───────────────────────────────────────
|
||||
const realOpen = fs.open
|
||||
fs.open = function open(...args) {
|
||||
const cb = args[args.length - 1]
|
||||
if (typeof cb !== 'function') {
|
||||
// No callback: not the async form we can queue (and node throws on
|
||||
// it anyway). Hand it straight to the real implementation.
|
||||
return realOpen.apply(this, args)
|
||||
}
|
||||
const rest = args.slice(0, -1)
|
||||
queue.push(() => {
|
||||
realOpen.call(fs, ...rest, function (...cbArgs) {
|
||||
// Release BEFORE the callback runs: the descriptor is open, this
|
||||
// call is done, and the caller may synchronously open another.
|
||||
release()
|
||||
cb.apply(this, cbArgs)
|
||||
})
|
||||
})
|
||||
pump()
|
||||
return undefined
|
||||
}
|
||||
// promisify(fs.open) reads these off the function it is handed.
|
||||
Object.defineProperty(fs.open, 'name', { value: 'open' })
|
||||
if (realOpen[Symbol.for('nodejs.util.promisify.custom')]) {
|
||||
fs.open[Symbol.for('nodejs.util.promisify.custom')] =
|
||||
realOpen[Symbol.for('nodejs.util.promisify.custom')]
|
||||
}
|
||||
|
||||
// ── fs.promises.open ──────────────────────────────────────────────
|
||||
const realPromisesOpen = fs.promises.open
|
||||
fs.promises.open = function open(...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
queue.push(() => {
|
||||
realPromisesOpen
|
||||
.apply(fs.promises, args)
|
||||
.then(resolve, reject)
|
||||
.finally(release)
|
||||
})
|
||||
pump()
|
||||
})
|
||||
}
|
||||
|
||||
if (process.env.HERMES_FS_OPEN_LIMIT_DEBUG) {
|
||||
console.log(`[fs-open-limit] fs.open concurrency capped at ${LIMIT}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { test } from 'vitest'
|
||||
|
||||
// ─── fs-open-limit.cjs ─────────────────────────────────────────────
|
||||
//
|
||||
// The preload caps concurrent fs.open calls so @electron/osx-sign's
|
||||
// unbounded walk cannot exhaust the descriptor table (EMFILE during mac
|
||||
// signing). These tests run REAL node processes against REAL trees with a
|
||||
// REAL low `ulimit -n`, because the whole contract is "does this survive
|
||||
// a descriptor limit" — a mocked fs would prove nothing.
|
||||
|
||||
const PRELOAD = path.join(import.meta.dirname, 'fs-open-limit.cjs')
|
||||
const POSIX = process.platform !== 'win32'
|
||||
|
||||
/** Run `body` in a child node process, optionally with the preload + a soft fd limit. */
|
||||
function runNode(body, { preload = false, ulimit = null, env = {} } = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fsopen-test-'))
|
||||
const script = path.join(dir, 'body.mjs')
|
||||
fs.writeFileSync(script, body)
|
||||
const nodeArgs = preload ? ['--require', PRELOAD, script] : [script]
|
||||
// ulimit needs a shell; keep the arg vector explicit either way.
|
||||
const cmd = ulimit
|
||||
? `ulimit -n ${ulimit}; exec "$0" ${nodeArgs.map((a) => `'${a}'`).join(' ')}`
|
||||
: null
|
||||
const res = cmd
|
||||
? spawnSync('/bin/sh', ['-c', cmd, process.execPath], { encoding: 'utf8', env: { ...process.env, ...env } })
|
||||
: spawnSync(process.execPath, nodeArgs, { encoding: 'utf8', env: { ...process.env, ...env } })
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
return res
|
||||
}
|
||||
|
||||
/** Build a tree big enough to exhaust a low fd limit, like the payload does. */
|
||||
function mkTree(fileCount) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fsopen-tree-'))
|
||||
for (let i = 0; i < fileCount; i++) {
|
||||
const d = path.join(root, `pkg${i % 40}`, `mod${i % 15}`)
|
||||
fs.mkdirSync(d, { recursive: true })
|
||||
fs.writeFileSync(path.join(d, `f${i}.py`), '# python\n')
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
/** The osx-sign walk, verbatim in shape: Promise.all + an open per file. */
|
||||
const WALK_BODY = (root) => `
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
async function walk(dir) {
|
||||
const children = await fs.promises.readdir(dir)
|
||||
return Promise.all(children.map(async (child) => {
|
||||
const p = path.resolve(dir, child)
|
||||
const st = await fs.promises.lstat(p)
|
||||
if (st.isFile()) {
|
||||
const fh = await fs.promises.open(p, 'r')
|
||||
try { const b = Buffer.alloc(4); await fh.read(b, 0, 4, 0); return null }
|
||||
finally { await fh.close() }
|
||||
}
|
||||
if (st.isDirectory() && !st.isSymbolicLink()) return walk(p)
|
||||
return null
|
||||
}))
|
||||
}
|
||||
await walk(${JSON.stringify(root)})
|
||||
console.log('WALK_OK')
|
||||
`
|
||||
|
||||
test('the unbounded walk hits EMFILE under a low fd limit', () => {
|
||||
if (!POSIX) return
|
||||
// The bug, reproduced. If this ever stops failing, the test below is
|
||||
// no longer proving anything and the preload could be silently useless.
|
||||
const tree = mkTree(3000)
|
||||
try {
|
||||
const res = runNode(WALK_BODY(tree), { ulimit: 64 })
|
||||
assert.notEqual(res.status, 0, 'expected the unbounded walk to fail')
|
||||
assert.match(res.stderr, /EMFILE/, `expected EMFILE, got: ${res.stderr.slice(0, 300)}`)
|
||||
} finally {
|
||||
fs.rmSync(tree, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('the preload lets the same walk finish under the same limit', () => {
|
||||
if (!POSIX) return
|
||||
const tree = mkTree(3000)
|
||||
try {
|
||||
const res = runNode(WALK_BODY(tree), { preload: true, ulimit: 64 })
|
||||
assert.equal(res.status, 0, `walk failed with preload: ${res.stderr.slice(0, 400)}`)
|
||||
assert.match(res.stdout, /WALK_OK/)
|
||||
} finally {
|
||||
fs.rmSync(tree, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('both fs.open forms are queued, so a huge fan-out survives', () => {
|
||||
if (!POSIX) return
|
||||
// isbinaryfile uses promisify(fs.open) (the callback form) while the
|
||||
// walk itself uses fs.promises.open — the patch has to cover both, or
|
||||
// half the demand escapes the queue.
|
||||
//
|
||||
// Each iteration opens AND CLOSES before the next needs a descriptor,
|
||||
// which is the shape the cap can actually satisfy. (Holding 400
|
||||
// descriptors live at once is impossible under a 64 fd limit no matter
|
||||
// how the opens are scheduled — an earlier version of this test asked
|
||||
// for exactly that and failed for its own reasons, not the patch's.)
|
||||
const body = `
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'peak-'))
|
||||
for (let i = 0; i < 400; i++) fs.writeFileSync(path.join(dir, 'f' + i), 'x')
|
||||
|
||||
// promise form: 400 concurrent open+close cycles.
|
||||
await Promise.all(Array.from({length: 400}, async (_, i) => {
|
||||
const fh = await fs.promises.open(path.join(dir, 'f' + i), 'r')
|
||||
await fh.close()
|
||||
}))
|
||||
|
||||
// callback form: same fan-out, through promisify like isbinaryfile does.
|
||||
import { promisify } from 'node:util'
|
||||
const openAsync = promisify(fs.open)
|
||||
const closeAsync = promisify(fs.close)
|
||||
await Promise.all(Array.from({length: 400}, async (_, i) => {
|
||||
const fd = await openAsync(path.join(dir, 'f' + i), 'r')
|
||||
await closeAsync(fd)
|
||||
}))
|
||||
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
console.log('BOTH_FORMS_OK')
|
||||
`
|
||||
const res = runNode(body, { preload: true, ulimit: 64 })
|
||||
assert.equal(res.status, 0, `failed: ${res.stderr.slice(0, 400)}`)
|
||||
assert.match(res.stdout, /BOTH_FORMS_OK/)
|
||||
})
|
||||
|
||||
test('the callback fan-out DOES fail without the preload', () => {
|
||||
if (!POSIX) return
|
||||
// Guards the test above: if 400 open+close cycles were survivable
|
||||
// unpatched, it would prove nothing about the queue.
|
||||
const body = `
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
const openAsync = promisify(fs.open)
|
||||
const closeAsync = promisify(fs.close)
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'peak-raw-'))
|
||||
for (let i = 0; i < 400; i++) fs.writeFileSync(path.join(dir, 'f' + i), 'x')
|
||||
await Promise.all(Array.from({length: 400}, async (_, i) => {
|
||||
const fd = await openAsync(path.join(dir, 'f' + i), 'r')
|
||||
await closeAsync(fd)
|
||||
}))
|
||||
console.log('UNEXPECTEDLY_OK')
|
||||
`
|
||||
const res = runNode(body, { ulimit: 64 })
|
||||
assert.notEqual(res.status, 0, 'unpatched fan-out should exhaust the fd table')
|
||||
assert.match(res.stderr, /EMFILE/)
|
||||
})
|
||||
|
||||
test('open errors still reach the caller unchanged', () => {
|
||||
const body = `
|
||||
import fs from 'node:fs'
|
||||
let promiseErr = null, cbErr = null
|
||||
try { await fs.promises.open('/definitely/not/here', 'r') } catch (e) { promiseErr = e }
|
||||
await new Promise((res) => fs.open('/definitely/not/here', 'r', (e) => { cbErr = e; res() }))
|
||||
if (promiseErr?.code !== 'ENOENT') throw new Error('promise form lost the error: ' + promiseErr?.code)
|
||||
if (cbErr?.code !== 'ENOENT') throw new Error('callback form lost the error: ' + cbErr?.code)
|
||||
console.log('ERRORS_PROPAGATE')
|
||||
`
|
||||
const res = runNode(body, { preload: true })
|
||||
assert.equal(res.status, 0, res.stderr.slice(0, 400))
|
||||
assert.match(res.stdout, /ERRORS_PROPAGATE/)
|
||||
})
|
||||
|
||||
test('a rejected open releases its slot instead of wedging the queue', () => {
|
||||
// If the failure path forgot to release, the queue drains to zero
|
||||
// permanently and this hangs rather than failing.
|
||||
const body = `
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rej-'))
|
||||
fs.writeFileSync(path.join(dir, 'real'), 'x')
|
||||
// 300 failures (more than the cap of 100) then a real open.
|
||||
await Promise.all(Array.from({length: 300}, () =>
|
||||
fs.promises.open(path.join(dir, 'missing'), 'r').catch(() => null)))
|
||||
const fh = await fs.promises.open(path.join(dir, 'real'), 'r')
|
||||
await fh.close()
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
console.log('QUEUE_STILL_LIVE')
|
||||
`
|
||||
const res = runNode(body, { preload: true })
|
||||
assert.equal(res.status, 0, res.stderr.slice(0, 400))
|
||||
assert.match(res.stdout, /QUEUE_STILL_LIVE/)
|
||||
})
|
||||
|
||||
test('holding descriptors while opening more cannot deadlock', () => {
|
||||
if (!POSIX) return
|
||||
// The slot is released when open() SETTLES, not when the fd closes, so a
|
||||
// task holding many descriptors occupies no slots. Gating the fd lifetime
|
||||
// instead deadlocks — that is the design this asserts against.
|
||||
const body = `
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hold-'))
|
||||
for (let i = 0; i < 600; i++) fs.writeFileSync(path.join(dir, 'f' + i), 'x')
|
||||
// 200 tasks (> the cap of 100) each holding one fd, then asking for two more.
|
||||
const held = []
|
||||
await Promise.all(Array.from({length: 200}, (_, i) => (async () => {
|
||||
const a = await fs.promises.open(path.join(dir, 'f' + i), 'r')
|
||||
const b = await fs.promises.open(path.join(dir, 'f' + (i + 200)), 'r')
|
||||
const c = await fs.promises.open(path.join(dir, 'f' + (i + 400)), 'r')
|
||||
held.push(a, b, c)
|
||||
})()))
|
||||
for (const h of held) await h.close()
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
console.log('NO_DEADLOCK')
|
||||
`
|
||||
const res = runNode(body, { preload: true, ulimit: 1024 })
|
||||
assert.equal(res.status, 0, res.stderr.slice(0, 400))
|
||||
assert.match(res.stdout, /NO_DEADLOCK/)
|
||||
})
|
||||
|
||||
test('HERMES_FS_OPEN_LIMIT=0 disables the patch', () => {
|
||||
// An escape hatch that must genuinely leave fs.open alone.
|
||||
const body = `
|
||||
import fs from 'node:fs'
|
||||
if (fs.open.name !== 'open') throw new Error('unexpected name')
|
||||
if (globalThis.__hermesFsOpenLimited) throw new Error('patch installed despite limit 0')
|
||||
console.log('DISABLED_OK')
|
||||
`
|
||||
const res = runNode(body, { preload: true, env: { HERMES_FS_OPEN_LIMIT: '0' } })
|
||||
assert.equal(res.status, 0, res.stderr.slice(0, 400))
|
||||
assert.match(res.stdout, /DISABLED_OK/)
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env node
|
||||
// gen-appinstaller.mjs — generate the Windows App Installer (.appinstaller)
|
||||
// file for an out-of-store MSIX channel feed.
|
||||
//
|
||||
// The out-of-store distribution is App Installer owned: each stable/nightly
|
||||
// channel dir under the feed host holds a universal .msixbundle plus an
|
||||
// .appinstaller that (a) installs the bundle and (b) records the .appinstaller
|
||||
// URI as the package's update source, so the OS can re-check it on launch.
|
||||
//
|
||||
// The identity comes from product-identity.cjs via scripts/msix-shared.mjs
|
||||
// (the SAME single derivation as the package manifest), so the
|
||||
// .appinstaller's MainPackage Name/Publisher always match the bundle's
|
||||
// manifest. `store` has no appinstaller (the Store owns its distribution).
|
||||
//
|
||||
// Pure buildAppInstaller() lives in scripts/msix-shared.mjs and is
|
||||
// unit-tested; this module is the CLI wrapper:
|
||||
// node apps/desktop/scripts/gen-appinstaller.mjs --out <file> --base-url <url>
|
||||
// Reads HERMES_DESKTOP_VARIANT (bundled|light), HERMES_PAYLOAD_TAG (channel)
|
||||
// and package.json (version) like the rest of the build.
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
import { appIdentity, buildAppInstaller } from '../../../scripts/msix-shared.mjs'
|
||||
|
||||
const desktop = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
const isCli = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
|
||||
|
||||
if (isCli) {
|
||||
// node strips the first '--' (and an immediately-following option) for its
|
||||
// own use; --out lands as a BARE arg. Parse space-separated flag pairs,
|
||||
// not --flag=value, so the CLI survives that mangling.
|
||||
const args = process.argv.slice(2)
|
||||
const flagValue = (name) => {
|
||||
for (let i = 0; i < args.length - 1; i += 1) {
|
||||
if (args[i] === name) return args[i + 1]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const out = flagValue('--out')
|
||||
const baseUrl = flagValue('--base-url') || process.env.CLOUDFLARE_R2_PUBLIC_URL
|
||||
|
||||
if (!out || !baseUrl) {
|
||||
console.error('[gen-appinstaller] --out=<file> and --base-url=<url> (or CLOUDFLARE_R2_PUBLIC_URL) are required')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const { identity, version, name } = appIdentity(desktop, process.env.HERMES_PAYLOAD_TAG)
|
||||
if (identity.store) {
|
||||
console.error('[gen-appinstaller] a store build has no App Installer feed (the Store owns distribution)')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const nightly = /-nightly/.test(process.env.HERMES_PAYLOAD_TAG || '')
|
||||
const variantDir = identity.light ? 'light/' : ''
|
||||
const ch = nightly ? 'nightly' : 'stable'
|
||||
const channelPath = `win32/${variantDir}${ch}`
|
||||
|
||||
const xml = buildAppInstaller({
|
||||
baseUrl: String(baseUrl).replace(/\/+$/, ''),
|
||||
variantChannelPath: channelPath,
|
||||
identityName: identity.msixAppIdWithOrg,
|
||||
version,
|
||||
bundleFilename: `${name}-${version}-win.msixbundle`
|
||||
})
|
||||
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true })
|
||||
fs.writeFileSync(out, xml)
|
||||
console.log(`[gen-appinstaller] wrote ${out} (${channelPath}/${name}-${version}-win.msixbundle)`)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// gen-appinstaller — the .appinstaller document for an out-of-store channel.
|
||||
// The identity inside must match the package manifest (same derivation), and
|
||||
// the bundle/package URLs must resolve under the feed host.
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { describe, test } from 'vitest'
|
||||
|
||||
import { OUT_OF_STORE_PUBLISHER, buildAppInstaller } from '../../../scripts/msix-shared.mjs'
|
||||
|
||||
describe('buildAppInstaller', () => {
|
||||
const base = {
|
||||
baseUrl: 'https://updates.example.com',
|
||||
variantChannelPath: 'win32/stable',
|
||||
identityName: 'NousResearch.HermesBundled',
|
||||
version: '0.3.0.0',
|
||||
bundleFilename: 'HermesBundled-0.3.0.0-win.msixbundle'
|
||||
}
|
||||
|
||||
test('pins the same publisher as the out-of-store manifest (ATS cert subject)', () => {
|
||||
const xml = buildAppInstaller(base)
|
||||
assert.match(xml, /Publisher="CN=Nous Research Inc\., O=Nous Research Inc\., L=Austin, S=Texas, C=US"/)
|
||||
assert.equal(OUT_OF_STORE_PUBLISHER, 'CN=Nous Research Inc., O=Nous Research Inc., L=Austin, S=Texas, C=US')
|
||||
})
|
||||
|
||||
test('MainPackage URI and the canonical Uri point at the bundle + appinstaller under the feed dir', () => {
|
||||
const xml = buildAppInstaller(base)
|
||||
assert.match(xml, /Uri="https:\/\/updates\.example\.com\/win32\/stable\/HermesBundled-0\.3\.0\.0-win\.msixbundle"/)
|
||||
// The AppInstaller's own Uri is the bundle URL with .appinstaller swapped in.
|
||||
assert.match(xml, new RegExp(`^<AppInstaller\\n Uri="[^"]+/win32/stable/${base.bundleFilename.replace(/\.msixbundle$/, '.appinstaller')}"`, 'm'))
|
||||
})
|
||||
|
||||
test('MainPackage Name equals the package identity; version matches everywhere', () => {
|
||||
const xml = buildAppInstaller(base)
|
||||
assert.match(xml, /Name="NousResearch\.HermesBundled"/)
|
||||
const versionCount = (xml.match(/Version="0\.3\.0\.0"/g) || []).length
|
||||
// AppInstaller Version + MainPackage Version = 2 occurrences.
|
||||
assert.equal(versionCount, 2)
|
||||
})
|
||||
|
||||
test('UpdateSettings keeps the OS prompt off (the in-app checker owns the prompt)', () => {
|
||||
const xml = buildAppInstaller(base)
|
||||
assert.match(xml, /<OnLaunch HoursBetweenUpdateChecks="12" ShowPrompt="false" \/>/)
|
||||
})
|
||||
|
||||
test('a variant channel path with a trailing slash still resolves under the host', () => {
|
||||
const xml = buildAppInstaller({ ...base, variantChannelPath: 'win32/nightly/' })
|
||||
assert.match(xml, /https:\/\/updates\.example\.com\/win32\/nightly\//)
|
||||
})
|
||||
|
||||
test('reserved XML characters in identity values are escaped', () => {
|
||||
const xml = buildAppInstaller({ ...base, identityName: 'A&B<App>' })
|
||||
assert.match(xml, /Name="A&B<App>"/)
|
||||
})
|
||||
})
|
||||
@@ -12,13 +12,19 @@ import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, beforeEach, describe, test } from 'vitest'
|
||||
import hostedGitInfo from 'hosted-git-info'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const desktopDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const desktopPkg = require(path.join(desktopDir, 'package.json'))
|
||||
|
||||
const { getPublishConfigs } = require('app-builder-lib/out/publish/PublishManager.js')
|
||||
const { getRepositoryInfo } = require('app-builder-lib/out/util/repositoryInfo.js')
|
||||
// app-builder-lib@27 tightened its "exports" map (deep subpaths are no
|
||||
// longer exported) and moved out/ -> dist/. Resolve the package root from
|
||||
// its main entry, then reach the real resolver files by absolute path so
|
||||
// the behavior under test is the actual installed library.
|
||||
const appBuilderLibDir = path.dirname(path.dirname(require.resolve('app-builder-lib')))
|
||||
const { getPublishConfigs } = require(path.join(appBuilderLibDir, 'dist/publish/PublishManager.js'))
|
||||
const { getRepositoryInfo } = require(path.join(appBuilderLibDir, 'dist/util/repositoryInfo.js'))
|
||||
|
||||
/**
|
||||
* The slice of PlatformPackager that getPublishConfigs actually reads. The
|
||||
@@ -27,20 +33,29 @@ const { getRepositoryInfo } = require('app-builder-lib/out/util/repositoryInfo.j
|
||||
*/
|
||||
function fakePackager(metadata) {
|
||||
const info = {
|
||||
get repositoryInfo() {
|
||||
return getRepositoryInfo(desktopDir, metadata, null)
|
||||
},
|
||||
appInfo: { version: '0.0.0', channel: null, updaterCacheDirName: 'hermes' },
|
||||
config: {},
|
||||
options: {}
|
||||
}
|
||||
return {
|
||||
platformSpecificBuildOptions: {},
|
||||
platformOptions: {}, // v27 API: getPublishConfigs reads platformOptions.publish
|
||||
config: {},
|
||||
info,
|
||||
platform: { name: 'linux' },
|
||||
appInfo: info.appInfo,
|
||||
expandMacro: value => value
|
||||
expandMacro: value => value,
|
||||
// v27 API: getPublishConfigs awaits `packager.repositoryInfo` (top-level),
|
||||
// not the v26 `packager.info.repositoryInfo` nesting. app-builder-lib@27's
|
||||
// own getRepositoryInfo returns {} in this environment (hosted-git-info
|
||||
// named-import interop), so parse metadata.repository directly.
|
||||
get repositoryInfo() {
|
||||
const repo = metadata && metadata.repository
|
||||
if (!repo) return null
|
||||
const parsed = hostedGitInfo.fromUrl(typeof repo === 'string' ? repo : repo.url)
|
||||
if (!parsed) return null
|
||||
return { user: parsed.user, project: parsed.project }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// Rewrite payload-internal python links to RELATIVE symlinks.
|
||||
//
|
||||
// uv venv --relocatable on POSIX makes venv/bin/python* a symlink (or a
|
||||
// hardlink) onto the store interpreter, with an ABSOLUTE target (the build
|
||||
// runner's staging path). An absolute symlink dangles once the unpacked
|
||||
// tree moves (first-boot smoke, or a user installing to a different path),
|
||||
// and a materialized COPY loses the tree identity the interpreter needs to
|
||||
// resolve its stdlib (sys.prefix falls back to the baked build prefix →
|
||||
// "No module named 'encodings'").
|
||||
//
|
||||
// The correct relocatable form is a RELATIVE symlink: the target lives
|
||||
// inside the bundle (resources/agent-payload/tools/<entry>/bin/python3),
|
||||
// so venv/bin/python -> ../tools/<entry>/bin/python3 survives moving the
|
||||
// whole tree, and the interpreter resolves its real prefix through the
|
||||
// link (stdlib found, no /install fallback).
|
||||
//
|
||||
// Only venv/bin is rewritten. Do not walk the rest of the payload: a
|
||||
// file-symlink at Foo.framework/Foo is the framework binary, and
|
||||
// flattening it makes codesign report "bundle format is ambiguous".
|
||||
//
|
||||
// A symlink whose target points OUTSIDE the bundle is a build bug (the
|
||||
// payload's own venv must never reference the builder's machine) — fail
|
||||
// loudly rather than ship a dangling link.
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export function findPackedPayload(appOutDir, platform) {
|
||||
if (!appOutDir) return null
|
||||
const candidates =
|
||||
platform === 'darwin'
|
||||
? [
|
||||
path.join(appOutDir, 'Contents', 'Resources', 'agent-payload'),
|
||||
path.join(appOutDir, 'Hermes.app', 'Contents', 'Resources', 'agent-payload'),
|
||||
path.join(appOutDir, 'HermesBundled.app', 'Contents', 'Resources', 'agent-payload')
|
||||
]
|
||||
: [path.join(appOutDir, 'resources', 'agent-payload')]
|
||||
return candidates.find(p => fs.existsSync(p)) || null
|
||||
}
|
||||
|
||||
// The store keeps verified downloads under fetch-<sha16>/. A sealed
|
||||
// payload already has the unpacked entries. Apple's notary unpacks
|
||||
// every archive it finds and rejects unsigned Mach-O inside them
|
||||
// (uv tarball, leftover chromium). Drop the cache and leftover
|
||||
// chromium store entries before codesign.
|
||||
export function stripFetchCache(root) {
|
||||
if (!root) return 0
|
||||
const tools = path.join(root, 'tools')
|
||||
if (!fs.existsSync(tools)) return 0
|
||||
let removed = 0
|
||||
for (const ent of fs.readdirSync(tools, { withFileTypes: true })) {
|
||||
if (!ent.isDirectory()) continue
|
||||
if (ent.name.startsWith('fetch-') || /^chromium(_headless_shell)?-\d+/.test(ent.name)) {
|
||||
fs.rmSync(path.join(tools, ent.name), { recursive: true, force: true })
|
||||
removed += 1
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite payload venv/bin symlinks to RELATIVE targets that resolve
|
||||
* inside the bundle. Returns the count rewritten.
|
||||
*
|
||||
* The single rule: a bundled venv's links must point at the payload's own
|
||||
* store. Links whose target already resolves inside the payload are
|
||||
* relativized (or left if already relative). Links whose target is the
|
||||
* BUILD staging path (build/agent-payload/tools/<entry>/...) — which
|
||||
* doesn't exist in the unpacked app — are rewritten to the matching
|
||||
* store entry under <payload>/tools/. Anything else fails the build.
|
||||
*/
|
||||
export function relativizePayloadLinks(root) {
|
||||
if (!root || !fs.existsSync(root)) return 0
|
||||
const bin = path.join(root, 'venv', 'bin')
|
||||
if (!fs.existsSync(bin)) return 0
|
||||
const payloadRoot = path.resolve(root)
|
||||
const toolsDir = path.join(payloadRoot, 'tools')
|
||||
let count = 0
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(bin, { withFileTypes: true })
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
for (const ent of entries) {
|
||||
const p = path.join(bin, ent.name)
|
||||
let st
|
||||
try {
|
||||
st = fs.lstatSync(p)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!st.isSymbolicLink()) continue
|
||||
let target
|
||||
try {
|
||||
target = fs.readlinkSync(p)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const absTarget = path.resolve(bin, target)
|
||||
const insidePayload = absTarget !== payloadRoot && absTarget.startsWith(payloadRoot + path.sep)
|
||||
let resolvedTarget = absTarget
|
||||
if (!insidePayload) {
|
||||
// The BUILD staging form: build/agent-payload/tools/<entry>/... .
|
||||
// The tail names a store entry that must exist inside THIS payload.
|
||||
const m = target.match(/(?:^|\/)tools\/(.+)$/)
|
||||
if (!m) {
|
||||
throw new Error(
|
||||
`venv/bin/${ent.name} -> ${target} points outside the payload and does not name ` +
|
||||
'a store entry (tools/<entry>/...); a bundled venv must reference the payload store',
|
||||
)
|
||||
}
|
||||
const inPayload = path.join(toolsDir, m[1])
|
||||
if (!fs.existsSync(inPayload)) {
|
||||
throw new Error(
|
||||
`venv/bin/${ent.name} -> ${target}: store entry ${m[1]} is not present in the payload ` +
|
||||
`(${inPayload}); a bundled venv must reference the payload store`,
|
||||
)
|
||||
}
|
||||
resolvedTarget = inPayload
|
||||
}
|
||||
const rel = path.relative(bin, resolvedTarget)
|
||||
if (target === rel) continue
|
||||
fs.unlinkSync(p)
|
||||
fs.symlinkSync(rel, p)
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { findPackedPayload, relativizePayloadLinks, stripFetchCache } from './materialize-payload-links.mjs'
|
||||
|
||||
function tempRoot() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-relativize-'))
|
||||
}
|
||||
|
||||
test('findPackedPayload locates the mac nested payload', () => {
|
||||
const root = tempRoot()
|
||||
try {
|
||||
const app = path.join(root, 'Hermes.app')
|
||||
const payload = path.join(app, 'Contents', 'Resources', 'agent-payload')
|
||||
fs.mkdirSync(payload, { recursive: true })
|
||||
assert.equal(findPackedPayload(app, 'darwin'), payload)
|
||||
assert.equal(findPackedPayload(root, 'darwin'), payload)
|
||||
assert.equal(findPackedPayload(root, 'linux'), null)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('relativizePayloadLinks rewrites an absolute build-staging symlink to a payload-relative one', () => {
|
||||
if (process.platform === 'win32') return
|
||||
const root = tempRoot()
|
||||
try {
|
||||
const store = path.join(root, 'tools', 'python', 'bin')
|
||||
const venv = path.join(root, 'venv', 'bin')
|
||||
fs.mkdirSync(store, { recursive: true })
|
||||
fs.mkdirSync(venv, { recursive: true })
|
||||
const real = path.join(store, 'python3.11')
|
||||
fs.writeFileSync(real, 'interpreter-bytes')
|
||||
const link = path.join(venv, 'python3')
|
||||
// The absolute build-staging form uv --relocatable writes: points at
|
||||
// build/agent-payload/tools/... which does NOT exist here — but the
|
||||
// store entry it names (tools/python/bin/python3.11) DOES exist in the
|
||||
// payload root, which is what the rewrite keys on.
|
||||
fs.symlinkSync('/somewhere/build/agent-payload/tools/python/bin/python3.11', link)
|
||||
assert.equal(fs.lstatSync(link).isSymbolicLink(), true)
|
||||
|
||||
const n = relativizePayloadLinks(root)
|
||||
assert.equal(n, 1)
|
||||
assert.equal(fs.lstatSync(link).isSymbolicLink(), true)
|
||||
const target = fs.readlinkSync(link)
|
||||
assert.equal(target.startsWith(path.sep), false) // now relative
|
||||
assert.equal(path.resolve(venv, target), real)
|
||||
assert.equal(fs.readFileSync(link, 'utf8'), 'interpreter-bytes') // resolves
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('relativizePayloadLinks throws when the named store entry is missing from the payload', () => {
|
||||
if (process.platform === 'win32') return
|
||||
const root = tempRoot()
|
||||
try {
|
||||
const venv = path.join(root, 'venv', 'bin')
|
||||
fs.mkdirSync(venv, { recursive: true })
|
||||
// Names tools/python/... but no such entry exists under the payload.
|
||||
fs.symlinkSync('/somewhere/build/agent-payload/tools/python/bin/python3.11', path.join(venv, 'python3'))
|
||||
assert.throws(() => relativizePayloadLinks(root), /store entry .* is not present in the payload/)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('relativizePayloadLinks leaves a sibling link (python3 -> python) alone', () => {
|
||||
if (process.platform === 'win32') return
|
||||
const root = tempRoot()
|
||||
try {
|
||||
const store = path.join(root, 'tools', 'python', 'bin')
|
||||
const venv = path.join(root, 'venv', 'bin')
|
||||
fs.mkdirSync(store, { recursive: true })
|
||||
fs.mkdirSync(venv, { recursive: true })
|
||||
const real = path.join(store, 'python3.11')
|
||||
fs.writeFileSync(real, 'interpreter-bytes')
|
||||
// python -> store link; python3 -> python sibling.
|
||||
fs.symlinkSync('/somewhere/build/agent-payload/tools/python/bin/python3.11', path.join(venv, 'python'))
|
||||
fs.symlinkSync('python', path.join(venv, 'python3'))
|
||||
|
||||
const n = relativizePayloadLinks(root)
|
||||
assert.equal(n, 1) // only the store link rewritten
|
||||
assert.equal(fs.readlinkSync(path.join(venv, 'python3')), 'python') // sibling untouched
|
||||
assert.equal(fs.readFileSync(path.join(venv, 'python3'), 'utf8'), 'interpreter-bytes') // resolves via chain
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('relativizePayloadLinks throws when the target does not name a store entry', () => {
|
||||
if (process.platform === 'win32') return
|
||||
const root = tempRoot()
|
||||
try {
|
||||
const venv = path.join(root, 'venv', 'bin')
|
||||
fs.mkdirSync(venv, { recursive: true })
|
||||
fs.symlinkSync('/usr/bin/python3.11', path.join(venv, 'python3'))
|
||||
assert.throws(() => relativizePayloadLinks(root), /does not name a store entry/)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('relativizePayloadLinks leaves already-relative links alone', () => {
|
||||
if (process.platform === 'win32') return
|
||||
const root = tempRoot()
|
||||
try {
|
||||
const store = path.join(root, 'tools', 'python', 'bin')
|
||||
const venv = path.join(root, 'venv', 'bin')
|
||||
fs.mkdirSync(store, { recursive: true })
|
||||
fs.mkdirSync(venv, { recursive: true })
|
||||
const real = path.join(store, 'python3.11')
|
||||
fs.writeFileSync(real, 'interpreter-bytes')
|
||||
const link = path.join(venv, 'python3')
|
||||
fs.symlinkSync(path.relative(venv, real), link)
|
||||
|
||||
assert.equal(relativizePayloadLinks(root), 0)
|
||||
assert.equal(fs.readlinkSync(link), path.relative(venv, real))
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('stripFetchCache removes only fetch- dirs under tools/', () => {
|
||||
const root = tempRoot()
|
||||
try {
|
||||
const tools = path.join(root, 'tools')
|
||||
fs.mkdirSync(path.join(tools, 'fetch-546f7f8a6c70ff13'), { recursive: true })
|
||||
fs.writeFileSync(path.join(tools, 'fetch-546f7f8a6c70ff13', 'uv.tar.gz'), 'x')
|
||||
fs.mkdirSync(path.join(tools, 'uv-0.12.3-darwin-arm64'), { recursive: true })
|
||||
fs.writeFileSync(path.join(tools, 'uv-0.12.3-darwin-arm64', 'uv'), 'bin')
|
||||
assert.equal(stripFetchCache(root), 1)
|
||||
assert.equal(fs.existsSync(path.join(tools, 'fetch-546f7f8a6c70ff13')), false)
|
||||
assert.equal(fs.existsSync(path.join(tools, 'uv-0.12.3-darwin-arm64', 'uv')), true)
|
||||
assert.equal(stripFetchCache(root), 0)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('stripFetchCache also drops leftover chromium store entries', () => {
|
||||
const root = tempRoot()
|
||||
try {
|
||||
const tools = path.join(root, 'tools')
|
||||
fs.mkdirSync(path.join(tools, 'chromium-1208'), { recursive: true })
|
||||
fs.writeFileSync(path.join(tools, 'chromium-1208', 'INSTALLATION_COMPLETE'), '')
|
||||
fs.mkdirSync(path.join(tools, 'chromium_headless_shell-1208'), { recursive: true })
|
||||
fs.mkdirSync(path.join(tools, 'uv-0.12.3-darwin-arm64'), { recursive: true })
|
||||
fs.writeFileSync(path.join(tools, 'uv-0.12.3-darwin-arm64', 'uv'), 'bin')
|
||||
assert.equal(stripFetchCache(root), 2)
|
||||
assert.equal(fs.existsSync(path.join(tools, 'chromium-1208')), false)
|
||||
assert.equal(fs.existsSync(path.join(tools, 'chromium_headless_shell-1208')), false)
|
||||
assert.equal(fs.existsSync(path.join(tools, 'uv-0.12.3-darwin-arm64', 'uv')), true)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('relativizePayloadLinks does not touch a framework file-symlink (not under venv/bin)', () => {
|
||||
if (process.platform === 'win32') return
|
||||
const root = tempRoot()
|
||||
try {
|
||||
const versioned = path.join(root, 'tools', 'chromium-1208', 'F.framework', 'Versions', 'A')
|
||||
fs.mkdirSync(versioned, { recursive: true })
|
||||
const real = path.join(versioned, 'F')
|
||||
fs.writeFileSync(real, 'machO')
|
||||
const link = path.join(root, 'tools', 'chromium-1208', 'F.framework', 'F')
|
||||
fs.symlinkSync(path.join('Versions', 'A', 'F'), link)
|
||||
assert.equal(fs.lstatSync(link).isSymbolicLink(), true)
|
||||
|
||||
assert.equal(relativizePayloadLinks(root), 0)
|
||||
assert.equal(fs.lstatSync(link).isSymbolicLink(), true)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
// msix-shared — the 4-part MSIX version derivation (minutes-since-stable
|
||||
// for nightlies). The git-backed lookup is deterministic here because
|
||||
// node:child_process.execFileSync is mocked; the math it feeds is the
|
||||
// contract App Installer compares.
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { beforeEach, test, vi } from 'vitest'
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFileSync: vi.fn(),
|
||||
}))
|
||||
|
||||
// Re-import AFTER the mock so the module binds the mocked execFileSync.
|
||||
const { execFileSync } = await import('node:child_process')
|
||||
const msix = await import('../../../scripts/msix-shared.mjs')
|
||||
|
||||
// A fake desktop dir with just enough for appIdentity: product-identity.cjs
|
||||
// (the bundled variant) + a package.json version.
|
||||
function makeFakeDesktop(version) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msix-ident-'))
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'product-identity.cjs'),
|
||||
"module.exports = { store: false, light: false, displayName: 'Hermes', appId: 'com.nousresearch.hermes-bundled', channel: 'latest', appNamePascal: 'HermesBundled', msixAppIdWithOrg: 'NousResearch.HermesBundled' }\n"
|
||||
)
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'hermes-desktop', version }))
|
||||
return dir
|
||||
}
|
||||
|
||||
function gitMock(tags, stableEpoch) {
|
||||
execFileSync.mockImplementation((cmd, args) => {
|
||||
if (args[0] === 'tag') return `${tags.join('\n')}\n`
|
||||
if (args[0] === 'log') return `${stableEpoch}\n`
|
||||
throw new Error(`unexpected git call ${cmd} ${args.join(' ')}`)
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execFileSync.mockReset()
|
||||
})
|
||||
|
||||
test('stable tag keeps the 3-part version plus .0', () => {
|
||||
const desktop = makeFakeDesktop('0.27.1')
|
||||
const { version, fileVersion } = msix.appIdentity(desktop, 'v0.27.1')
|
||||
assert.equal(version, '0.27.1.0')
|
||||
assert.equal(fileVersion, '0.27.1')
|
||||
})
|
||||
|
||||
test('nightly version is tag base + minutes since the same-minor stable', () => {
|
||||
const desktop = makeFakeDesktop('0.27.1')
|
||||
// Stable v0.27.1 committed 2026-08-01T00:00:00Z; nightly cut 2026-08-29T01:02:03Z.
|
||||
const stableEpoch = Math.floor(Date.UTC(2026, 7, 1) / 1000)
|
||||
gitMock(['v0.27.1', 'v0.27.2-nightly.20260829010203'], stableEpoch)
|
||||
const { version, fileVersion } = msix.appIdentity(desktop, 'v0.27.2-nightly.20260829010203')
|
||||
const expectedMinutes = Math.floor((Date.UTC(2026, 7, 29, 1, 2, 3) - Date.UTC(2026, 7, 1)) / 60000)
|
||||
assert.equal(version, `0.27.2.${expectedMinutes}`)
|
||||
// The artifact FILENAME carries the full nightly string (appInfo.version),
|
||||
// not the 4-part feed version.
|
||||
assert.equal(fileVersion, '0.27.2-nightly.20260829010203')
|
||||
})
|
||||
|
||||
test('nightlyBuildMinutesFor is pure minutes math', () => {
|
||||
const base = Date.UTC(2026, 7, 1) / 1000
|
||||
const minutes = msix.nightlyBuildMinutesFor('v0.27.2-nightly.20260829010203', base)
|
||||
assert.equal(minutes, Math.floor((Date.UTC(2026, 7, 29, 1, 2, 3) - Date.UTC(2026, 7, 1)) / 60000))
|
||||
})
|
||||
|
||||
test('nightlyBuildMinutesFor returns null for a stable tag', () => {
|
||||
assert.equal(msix.nightlyBuildMinutesFor('v0.27.1', 0), null)
|
||||
})
|
||||
|
||||
test('nightlyBuildMinutesFor rejects a stable base older than 45 days', () => {
|
||||
const base = Date.UTC(2026, 5, 1) / 1000 // 2026-06-01
|
||||
assert.throws(
|
||||
() => msix.nightlyBuildMinutesFor('v0.27.2-nightly.20260829010203', base),
|
||||
/45 days|16-bit/i
|
||||
)
|
||||
})
|
||||
|
||||
test('legacy 8-digit nightly stamp still computes minutes (midnight of that day)', () => {
|
||||
const base = Date.UTC(2026, 7, 1) / 1000
|
||||
const minutes = msix.nightlyBuildMinutesFor('v0.27.2-nightly.20260801', base)
|
||||
assert.equal(minutes, 0)
|
||||
})
|
||||
|
||||
test('buildAppInstaller pins the derived 4-part version everywhere', () => {
|
||||
const xml = msix.buildAppInstaller({
|
||||
baseUrl: 'https://updates.example.com',
|
||||
variantChannelPath: 'win32/nightly',
|
||||
identityName: 'NousResearch.HermesBundled',
|
||||
version: '0.27.2.1234',
|
||||
bundleFilename: 'HermesBundled-0.27.2.1234-win.msixbundle'
|
||||
})
|
||||
assert.match(xml, /Uri="https:\/\/updates\.example\.com\/win32\/nightly\/HermesBundled-0\.27\.2\.1234-win\.msixbundle"/)
|
||||
const versionCount = (xml.match(/Version="0\.27\.2\.1234"/g) || []).length
|
||||
assert.equal(versionCount, 2)
|
||||
})
|
||||
@@ -1,55 +1,63 @@
|
||||
// Resolve electronDist at runtime (#38673, #47917): electron-builder 26.8.x can
|
||||
// re-unpack a broken Electron.app; reusing the installed dist dodges that.
|
||||
// npm workspace hoisting is non-deterministic — require.resolve finds electron
|
||||
// wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let
|
||||
// electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR).
|
||||
// Wraps the electron-builder CLI so the arguments compose in one place, in
|
||||
// the first spawn with no shell in between.
|
||||
//
|
||||
// electron-builder downloads and extracts Electron itself (via electronVersion
|
||||
// + ELECTRON_MIRROR). Passing the local Electron dist makes v27 copy framework
|
||||
// links as regular files. Its archive extraction preserves those links.
|
||||
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { createRequire } from "node:module"
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
function electronDistDir() {
|
||||
try {
|
||||
return path.join(path.dirname(require.resolve("electron/package.json")), "dist")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function distBinary(dist) {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(dist, "Electron.app", "Contents", "MacOS", "Electron")
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return path.join(dist, "electron.exe")
|
||||
}
|
||||
return path.join(dist, "electron")
|
||||
}
|
||||
|
||||
function electronBuilderCli() {
|
||||
const pkgJson = require.resolve("electron-builder/package.json")
|
||||
const bin = require(pkgJson).bin
|
||||
const rel = typeof bin === "string" ? bin : bin["electron-builder"]
|
||||
return path.join(path.dirname(pkgJson), rel)
|
||||
const entry = require.resolve('electron-builder')
|
||||
let dir = path.dirname(entry)
|
||||
while (!fs.existsSync(path.join(dir, 'package.json'))) {
|
||||
const parent = path.dirname(dir)
|
||||
if (parent === dir) {
|
||||
throw new Error('electron-builder package root not found')
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
const bin = require(path.join(dir, 'package.json')).bin
|
||||
const rel = typeof bin === 'string' ? bin : bin['electron-builder']
|
||||
return path.join(dir, rel)
|
||||
}
|
||||
|
||||
const dist = electronDistDir()
|
||||
const args = []
|
||||
if (dist && fs.existsSync(distBinary(dist))) {
|
||||
args.push(`-c.electronDist=${dist}`)
|
||||
} else {
|
||||
console.warn(
|
||||
"[run-electron-builder] no local electron dist; electron-builder will fetch " +
|
||||
"via @electron/get (electronVersion + ELECTRON_MIRROR)."
|
||||
const args = [...process.argv.slice(2)]
|
||||
|
||||
// package.json has no "build" field. Name the config file or electron-builder
|
||||
// would look for one and silently use defaults.
|
||||
if (!args.some(a => a === '--config' || a.startsWith('--config='))) {
|
||||
args.push('--config', 'electron-builder.config.cjs')
|
||||
}
|
||||
|
||||
// Never let electron-builder publish. On a CI tag build it auto-detects
|
||||
// GitHub and demands GH_TOKEN after the artifacts are already built.
|
||||
// The release workflow uploads artifacts in its own step.
|
||||
if (!args.includes('--publish') && !args.some(a => a.startsWith('-p'))) {
|
||||
args.push('--publish', 'never')
|
||||
}
|
||||
|
||||
if (args.includes('--win') && process.env.AZURE_SIGN_ENDPOINT && process.env.AZURE_CLIENT_ID) {
|
||||
console.log(
|
||||
`[run-electron-builder] Windows signing: Azure Trusted Signing at ${process.env.AZURE_SIGN_ENDPOINT}`
|
||||
)
|
||||
}
|
||||
args.push(...process.argv.slice(2))
|
||||
|
||||
const result = spawnSync(process.execPath, [electronBuilderCli(), ...args], {
|
||||
stdio: "inherit",
|
||||
// Cap concurrent fs.open calls in the electron-builder process.
|
||||
// @electron/osx-sign walks the whole .app with Promise.all and no
|
||||
// concurrency bound. The payload (lark_oapi alone is thousands of files)
|
||||
// exhausts the macOS table: EMFILE on a random .py. Raising ulimit only
|
||||
// moves the ceiling. --require, not an import: isbinaryfile captures
|
||||
// promisify(fs.open) at its own load.
|
||||
const preload = path.join(import.meta.dirname, 'fs-open-limit.cjs')
|
||||
|
||||
const result = spawnSync(process.execPath, ['--require', preload, electronBuilderCli(), ...args], {
|
||||
stdio: 'inherit'
|
||||
})
|
||||
if (result.error) {
|
||||
console.error(`[run-electron-builder] spawn failed: ${result.error.message}`)
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* sanitize-pe-signatures.mjs — repair PE certificate tables that point past
|
||||
* the end of their own file, before anything tries to sign the tree.
|
||||
*
|
||||
* Why this exists: python-build-standalone runs `llvm-strip` over every DLL
|
||||
* in its Windows distributions. Stripping drops the trailing Authenticode
|
||||
* blob but leaves IMAGE_DIRECTORY_ENTRY_SECURITY in the optional header
|
||||
* still naming its old offset and size. The result is a PE whose certificate
|
||||
* table begins at (or past) EOF.
|
||||
*
|
||||
* signtool refuses such a file with 0x800700C1 (ERROR_BAD_EXE_FORMAT), and
|
||||
* AppxSIP inspects every PE inside an MSIX, so ONE of them fails the whole
|
||||
* package signing — which is how a payload DLL nobody imports took down both
|
||||
* Windows bundled release jobs. Upstream exempted the MSVC redists from
|
||||
* stripping (python-build-standalone#856) but not tcl/tk, and the same class
|
||||
* can arrive from any stripped third-party dist we bundle.
|
||||
*
|
||||
* The repair is to zero the dangling directory entry: eight bytes that say
|
||||
* "there is no certificate here", which is already true. The bytes the entry
|
||||
* pointed at are gone; nothing else in the file references them. A signature
|
||||
* that is actually present is never touched — a valid entry is left exactly
|
||||
* as it is, so this can never strip a real signature.
|
||||
*/
|
||||
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
import { isMain } from "./utils.mjs"
|
||||
|
||||
const PE_SIGNATURE = 0x00004550
|
||||
const COFF_HEADER_SIZE = 24 // PE signature (4) + COFF file header (20)
|
||||
const PE32_MAGIC = 0x10b
|
||||
const PE32PLUS_MAGIC = 0x20b
|
||||
const SECURITY_DIRECTORY_INDEX = 4
|
||||
const DATA_DIRECTORY_ENTRY_SIZE = 8
|
||||
|
||||
/**
|
||||
* Offset of NumberOfRvaAndSizes within the optional header. PE32 ends its
|
||||
* fixed fields at 92. PE32+ drops BaseOfData (-4) and widens four fields
|
||||
* from 4 to 8 bytes (+16), so its value sits 12 bytes later, at 108.
|
||||
*
|
||||
* @param {number} magic optional header magic (0x10b PE32, 0x20b PE32+)
|
||||
* @returns {number | null} byte offset, or null when the magic is unknown
|
||||
*/
|
||||
export function numberOfRvaAndSizesOffset(magic) {
|
||||
if (magic === PE32PLUS_MAGIC) return 108
|
||||
if (magic === PE32_MAGIC) return 92
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte offset of the security data directory entry, relative to the start
|
||||
* of the file.
|
||||
*
|
||||
* @param {number} peHeaderOffset value of e_lfanew (offset of "PE\0\0")
|
||||
* @param {number} magic optional header magic
|
||||
* @returns {number | null} absolute file offset, or null for an unknown magic
|
||||
*/
|
||||
export function securityDirectoryOffset(peHeaderOffset, magic) {
|
||||
const rvaCount = numberOfRvaAndSizesOffset(magic)
|
||||
if (rvaCount === null) return null
|
||||
return (
|
||||
peHeaderOffset +
|
||||
COFF_HEADER_SIZE +
|
||||
rvaCount +
|
||||
4 + // NumberOfRvaAndSizes itself
|
||||
SECURITY_DIRECTORY_INDEX * DATA_DIRECTORY_ENTRY_SIZE
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what a security directory entry describes. For this directory
|
||||
* alone the "RVA" field is a plain file offset, not a virtual address, so
|
||||
* it is directly comparable against the file size.
|
||||
*
|
||||
* "dangling" is the repairable case and is deliberately narrow: the entry
|
||||
* must name a region that cannot exist in this file. Anything that fits
|
||||
* inside the file is a real signature and is reported as "present" so the
|
||||
* caller leaves it alone.
|
||||
*
|
||||
* @param {{ certOffset: number, certSize: number, fileSize: number }} entry
|
||||
* @returns {"absent" | "present" | "dangling"}
|
||||
*/
|
||||
export function classifySecurityDirectory({ certOffset, certSize, fileSize }) {
|
||||
if (certOffset === 0 && certSize === 0) return "absent"
|
||||
// A zero offset with a nonzero size (or the reverse) names no usable
|
||||
// region either; both halves must be sane for a signature to be readable.
|
||||
if (certOffset === 0 || certSize === 0) return "dangling"
|
||||
if (certOffset >= fileSize) return "dangling"
|
||||
if (certOffset + certSize > fileSize) return "dangling"
|
||||
return "present"
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the security directory of a PE file.
|
||||
*
|
||||
* @param {string} file
|
||||
* @returns {{ offsetInFile: number, certOffset: number, certSize: number, fileSize: number } | null}
|
||||
* null when the file is not a PE with data directories at all.
|
||||
*/
|
||||
export function readSecurityDirectory(file) {
|
||||
const fd = fs.openSync(file, "r")
|
||||
try {
|
||||
const fileSize = fs.fstatSync(fd).size
|
||||
if (fileSize < 0x40) return null
|
||||
|
||||
const mz = Buffer.alloc(0x40)
|
||||
fs.readSync(fd, mz, 0, 0x40, 0)
|
||||
if (mz[0] !== 0x4d || mz[1] !== 0x5a) return null
|
||||
|
||||
const peHeaderOffset = mz.readUInt32LE(0x3c)
|
||||
if (peHeaderOffset <= 0 || peHeaderOffset + COFF_HEADER_SIZE > fileSize) return null
|
||||
|
||||
const coff = Buffer.alloc(COFF_HEADER_SIZE)
|
||||
fs.readSync(fd, coff, 0, COFF_HEADER_SIZE, peHeaderOffset)
|
||||
if (coff.readUInt32LE(0) !== PE_SIGNATURE) return null
|
||||
|
||||
const optionalHeaderSize = coff.readUInt16LE(20)
|
||||
if (optionalHeaderSize === 0) return null // object file: no data directories
|
||||
|
||||
const magicBuf = Buffer.alloc(2)
|
||||
fs.readSync(fd, magicBuf, 0, 2, peHeaderOffset + COFF_HEADER_SIZE)
|
||||
const offsetInFile = securityDirectoryOffset(peHeaderOffset, magicBuf.readUInt16LE(0))
|
||||
if (offsetInFile === null) return null
|
||||
if (offsetInFile + DATA_DIRECTORY_ENTRY_SIZE > peHeaderOffset + COFF_HEADER_SIZE + optionalHeaderSize) {
|
||||
return null // header declares fewer directories than the security slot
|
||||
}
|
||||
|
||||
const entry = Buffer.alloc(DATA_DIRECTORY_ENTRY_SIZE)
|
||||
fs.readSync(fd, entry, 0, DATA_DIRECTORY_ENTRY_SIZE, offsetInFile)
|
||||
return {
|
||||
offsetInFile,
|
||||
certOffset: entry.readUInt32LE(0),
|
||||
certSize: entry.readUInt32LE(4),
|
||||
fileSize,
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero the eight bytes of a security directory entry in place.
|
||||
*
|
||||
* @param {string} file
|
||||
* @param {number} offsetInFile offset of the entry, from readSecurityDirectory
|
||||
* @returns {void}
|
||||
*/
|
||||
export function clearSecurityDirectory(file, offsetInFile) {
|
||||
const fd = fs.openSync(file, "r+")
|
||||
try {
|
||||
fs.writeSync(fd, Buffer.alloc(DATA_DIRECTORY_ENTRY_SIZE), 0, DATA_DIRECTORY_ENTRY_SIZE, offsetInFile)
|
||||
} finally {
|
||||
fs.closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dir
|
||||
* @returns {Generator<string>}
|
||||
*/
|
||||
function* walkFiles(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isSymbolicLink()) continue
|
||||
if (entry.isDirectory()) yield* walkFiles(full)
|
||||
else if (entry.isFile()) yield full
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair every dangling security directory under a tree.
|
||||
*
|
||||
* @param {string} rootDir
|
||||
* @returns {{ scanned: number, repaired: string[] }} repaired paths are
|
||||
* relative to rootDir, in walk order.
|
||||
*/
|
||||
export function sanitizeTree(rootDir) {
|
||||
const repaired = []
|
||||
let scanned = 0
|
||||
for (const file of walkFiles(rootDir)) {
|
||||
let entry
|
||||
try {
|
||||
entry = readSecurityDirectory(file)
|
||||
} catch {
|
||||
continue // unreadable: packaging would already have failed on it
|
||||
}
|
||||
if (!entry) continue
|
||||
scanned += 1
|
||||
if (classifySecurityDirectory(entry) !== "dangling") continue
|
||||
clearSecurityDirectory(file, entry.offsetInFile)
|
||||
repaired.push(path.relative(rootDir, file))
|
||||
}
|
||||
return { scanned, repaired }
|
||||
}
|
||||
|
||||
function main() {
|
||||
const root = process.argv[2]
|
||||
if (!root) {
|
||||
console.error("usage: sanitize-pe-signatures.mjs <dir>")
|
||||
process.exit(2)
|
||||
}
|
||||
const { scanned, repaired } = sanitizeTree(root)
|
||||
console.log(`[sanitize-pe-signatures] ${scanned} PEs scanned, ${repaired.length} repaired`)
|
||||
for (const file of repaired) {
|
||||
console.log(` cleared dangling certificate table: ${file}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
main()
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, test } from 'vitest'
|
||||
|
||||
import {
|
||||
classifySecurityDirectory,
|
||||
clearSecurityDirectory,
|
||||
numberOfRvaAndSizesOffset,
|
||||
readSecurityDirectory,
|
||||
sanitizeTree,
|
||||
} from '../scripts/sanitize-pe-signatures.mjs'
|
||||
|
||||
// ─── fixture builder: a PE with a chosen security directory ────────────────
|
||||
|
||||
const PE_HEADER_OFFSET = 0x80
|
||||
const OPTIONAL_HEADER_SIZE = 240 // enough for 16 data directories in either width
|
||||
|
||||
/**
|
||||
* Build the smallest byte sequence that parses as a PE with data
|
||||
* directories, then append `trailing` bytes to stand in for a signature.
|
||||
* certOffset/certSize are written verbatim so a test can describe a valid
|
||||
* table, a dangling one, or none at all.
|
||||
*/
|
||||
function peFile({ magic = 0x20b, certOffset = 0, certSize = 0, trailing = 0 } = {}) {
|
||||
const headerEnd = PE_HEADER_OFFSET + 24 + OPTIONAL_HEADER_SIZE
|
||||
const buf = Buffer.alloc(headerEnd + trailing)
|
||||
buf.write('MZ', 0, 'latin1')
|
||||
buf.writeUInt32LE(PE_HEADER_OFFSET, 0x3c)
|
||||
buf.writeUInt32LE(0x00004550, PE_HEADER_OFFSET) // "PE\0\0"
|
||||
buf.writeUInt16LE(magic === 0x20b ? 0xaa64 : 0x014c, PE_HEADER_OFFSET + 4) // machine
|
||||
buf.writeUInt16LE(OPTIONAL_HEADER_SIZE, PE_HEADER_OFFSET + 20)
|
||||
buf.writeUInt16LE(magic, PE_HEADER_OFFSET + 24)
|
||||
const rvaCount = numberOfRvaAndSizesOffset(magic)
|
||||
buf.writeUInt32LE(16, PE_HEADER_OFFSET + 24 + rvaCount)
|
||||
const secOff = PE_HEADER_OFFSET + 24 + rvaCount + 4 + 4 * 8
|
||||
buf.writeUInt32LE(certOffset, secOff)
|
||||
buf.writeUInt32LE(certSize, secOff + 4)
|
||||
return { buf, securityDirectoryOffset: secOff, headerEnd }
|
||||
}
|
||||
|
||||
let dir
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pe-sanitize-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** @returns {string} absolute path of the written fixture */
|
||||
function write(name, spec) {
|
||||
const file = path.join(dir, name)
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, peFile(spec).buf)
|
||||
return file
|
||||
}
|
||||
|
||||
// ─── header arithmetic ──────────────────────────────────────────────────────
|
||||
|
||||
test('NumberOfRvaAndSizes sits at 92 for PE32 and 108 for PE32+', () => {
|
||||
// The whole repair keys off this offset: read it wrong and a valid
|
||||
// signature looks dangling (or the reverse).
|
||||
assert.equal(numberOfRvaAndSizesOffset(0x10b), 92)
|
||||
assert.equal(numberOfRvaAndSizesOffset(0x20b), 108)
|
||||
assert.equal(numberOfRvaAndSizesOffset(0x107), null)
|
||||
})
|
||||
|
||||
// ─── classification ─────────────────────────────────────────────────────────
|
||||
|
||||
test('a table wholly inside the file is a real signature', () => {
|
||||
assert.equal(classifySecurityDirectory({ certOffset: 100, certSize: 24, fileSize: 200 }), 'present')
|
||||
// Exactly reaching EOF is the normal shape: signatures live at the end.
|
||||
assert.equal(classifySecurityDirectory({ certOffset: 100, certSize: 100, fileSize: 200 }), 'present')
|
||||
})
|
||||
|
||||
test('a table that cannot fit in the file is dangling', () => {
|
||||
// The stripped-DLL shape: offset equals the (now truncated) file size.
|
||||
assert.equal(classifySecurityDirectory({ certOffset: 200, certSize: 24, fileSize: 200 }), 'dangling')
|
||||
assert.equal(classifySecurityDirectory({ certOffset: 190, certSize: 24, fileSize: 200 }), 'dangling')
|
||||
// Half-populated entries name no readable region either.
|
||||
assert.equal(classifySecurityDirectory({ certOffset: 0, certSize: 24, fileSize: 200 }), 'dangling')
|
||||
assert.equal(classifySecurityDirectory({ certOffset: 100, certSize: 0, fileSize: 200 }), 'dangling')
|
||||
})
|
||||
|
||||
test('an all-zero table means no signature and needs no repair', () => {
|
||||
assert.equal(classifySecurityDirectory({ certOffset: 0, certSize: 0, fileSize: 200 }), 'absent')
|
||||
})
|
||||
|
||||
// ─── reading and clearing ───────────────────────────────────────────────────
|
||||
|
||||
test('readSecurityDirectory locates the entry in both PE widths', () => {
|
||||
for (const magic of [0x10b, 0x20b]) {
|
||||
const spec = { magic, certOffset: 1234, certSize: 16 }
|
||||
const file = write(`w${magic}.dll`, spec)
|
||||
const entry = readSecurityDirectory(file)
|
||||
assert.equal(entry.certOffset, 1234)
|
||||
assert.equal(entry.certSize, 16)
|
||||
assert.equal(entry.offsetInFile, peFile(spec).securityDirectoryOffset)
|
||||
}
|
||||
})
|
||||
|
||||
test('readSecurityDirectory ignores files that are not PEs with directories', () => {
|
||||
const plain = path.join(dir, 'notes.txt')
|
||||
fs.writeFileSync(plain, 'hello')
|
||||
assert.equal(readSecurityDirectory(plain), null)
|
||||
|
||||
// MZ with no PE signature: a DOS-era stub, not a PE.
|
||||
const stub = path.join(dir, 'stub.exe')
|
||||
const buf = Buffer.alloc(0x100)
|
||||
buf.write('MZ', 0, 'latin1')
|
||||
buf.writeUInt32LE(0x80, 0x3c)
|
||||
fs.writeFileSync(stub, buf)
|
||||
assert.equal(readSecurityDirectory(stub), null)
|
||||
})
|
||||
|
||||
test('clearSecurityDirectory zeroes the entry and changes nothing else', () => {
|
||||
const spec = { magic: 0x20b, certOffset: 4096, certSize: 40 }
|
||||
const file = write('cleared.dll', spec)
|
||||
const before = fs.readFileSync(file)
|
||||
|
||||
clearSecurityDirectory(file, peFile(spec).securityDirectoryOffset)
|
||||
|
||||
const after = fs.readFileSync(file)
|
||||
assert.equal(after.length, before.length)
|
||||
const entry = readSecurityDirectory(file)
|
||||
assert.equal(entry.certOffset, 0)
|
||||
assert.equal(entry.certSize, 0)
|
||||
// Byte-for-byte identical everywhere except the eight-byte entry.
|
||||
const off = entry.offsetInFile
|
||||
assert.deepEqual(after.subarray(0, off), before.subarray(0, off))
|
||||
assert.deepEqual(after.subarray(off + 8), before.subarray(off + 8))
|
||||
})
|
||||
|
||||
// ─── the tree pass ──────────────────────────────────────────────────────────
|
||||
|
||||
test('sanitizeTree repairs dangling tables and leaves real signatures alone', () => {
|
||||
// The payload shape that broke the Windows bundled release: a stripped
|
||||
// DLL whose table starts exactly at EOF, beside correctly signed files.
|
||||
const stripped = write('python/DLLs/tcl86t.dll', { certOffset: 464, certSize: 12056 })
|
||||
const signed = write('python/python.exe', { certOffset: 400, certSize: 64, trailing: 200 })
|
||||
const unsigned = write('python/DLLs/_socket.pyd', {})
|
||||
fs.writeFileSync(path.join(dir, 'python', 'LICENSE.txt'), 'not a PE')
|
||||
|
||||
const { scanned, repaired } = sanitizeTree(dir)
|
||||
|
||||
assert.equal(scanned, 3) // the three PEs; the text file is skipped
|
||||
assert.deepEqual(repaired, [path.join('python', 'DLLs', 'tcl86t.dll')])
|
||||
assert.equal(readSecurityDirectory(stripped).certSize, 0)
|
||||
assert.equal(readSecurityDirectory(signed).certSize, 64) // untouched
|
||||
assert.equal(readSecurityDirectory(unsigned).certSize, 0)
|
||||
})
|
||||
|
||||
test('sanitizeTree is idempotent', () => {
|
||||
write('DLLs/tk86t.dll', { certOffset: 464, certSize: 12064 })
|
||||
|
||||
assert.equal(sanitizeTree(dir).repaired.length, 1)
|
||||
assert.deepEqual(sanitizeTree(dir).repaired, [])
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { azureConfigFromEnv, shouldSignFile } from './sign-msix.mjs'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
test('shouldSignFile admits only .msix and .msixbundle artifacts', () => {
|
||||
assert.equal(shouldSignFile('release/Hermes-0.17.0-win32-arm64.msix'), true)
|
||||
assert.equal(shouldSignFile('release/Hermes-0.17.0-win32-x64.msix'), true)
|
||||
assert.equal(shouldSignFile('release/Hermes-0.17.0-win32-arm64.msixbundle'), true)
|
||||
// Case-insensitive — artifactName could emit .MSIX on some host.
|
||||
assert.equal(shouldSignFile('release/Hermes-0.17.0-win32-arm64.MSIX'), true)
|
||||
})
|
||||
|
||||
test('shouldSignFile rejects the Store-submission variant (Partner Center re-signs)', () => {
|
||||
// The Store- msix manifest Publisher is the Partner Center publisher ID
|
||||
// (CN=EE6D86E4-...), which no signable cert subject can match — ATS
|
||||
// cannot customize CN and CA/B requires the legal entity name — so
|
||||
// SignerSign would fail 0x8007000B. Partner Center signs on ingestion.
|
||||
assert.equal(
|
||||
shouldSignFile('release/Store-HermesBundled-0.28.0-nightly.20260828211829-win-x64.msix'),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
shouldSignFile('release/Store-HermesBundled-0.28.0-nightly.20260828211829-win-arm64.msixbundle'),
|
||||
false
|
||||
)
|
||||
// The out-of-store artifacts keep the only signature Windows validates.
|
||||
assert.equal(
|
||||
shouldSignFile('release/HermesBundled-0.28.0-nightly.20260828211829-win-x64.msix'),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('shouldSignFile rejects every non-package file the hook is asked to sign', () => {
|
||||
// The app exe and any payload binary are covered by the package's block
|
||||
// map — signing them is wasted round-trips and would break the hash if
|
||||
// done after makeappx packs the package.
|
||||
assert.equal(shouldSignFile('release/win-unpacked/Hermes.exe'), false)
|
||||
assert.equal(shouldSignFile('C:/work/hermes-agent/release/win-unpacked/Hermes.exe'), false)
|
||||
assert.equal(shouldSignFile('release/Hermes-0.17.0-win32-arm64.nsis.exe'), false)
|
||||
assert.equal(shouldSignFile('release/Hermes-0.17.0-win32-arm64.msixupload'), false)
|
||||
assert.equal(shouldSignFile('release/Hermes-0.17.0-win32-arm64.dll'), false)
|
||||
assert.equal(shouldSignFile(''), false)
|
||||
})
|
||||
|
||||
test('azureConfigFromEnv composes the Azure signing config from the environment', () => {
|
||||
assert.deepEqual(
|
||||
azureConfigFromEnv({
|
||||
AZURE_SIGN_ENDPOINT: 'https://cus.codesigning.azure.net',
|
||||
AZURE_SIGN_ACCOUNT: 'codesign2',
|
||||
AZURE_SIGN_PROFILE: 'hermesagent',
|
||||
AZURE_SIGN_PUBLISHER: 'CN=Nous Research Inc.'
|
||||
}),
|
||||
{
|
||||
type: 'azure',
|
||||
endpoint: 'https://cus.codesigning.azure.net',
|
||||
codeSigningAccountName: 'codesign2',
|
||||
certificateProfileName: 'hermesagent',
|
||||
publisherName: 'CN=Nous Research Inc.'
|
||||
}
|
||||
)
|
||||
// Missing vars stay undefined — the manager's ctor handles that.
|
||||
assert.deepEqual(azureConfigFromEnv({}), {
|
||||
type: 'azure',
|
||||
endpoint: undefined,
|
||||
codeSigningAccountName: undefined,
|
||||
certificateProfileName: undefined,
|
||||
publisherName: undefined
|
||||
})
|
||||
})
|
||||
|
||||
// ─── builder-bump tripwire ──────────────────────────────────────────────────
|
||||
// sign-msix.mjs deep-imports app-builder-lib's WindowsSignAzureManager by
|
||||
// file path (the package exports map exposes only "." and "./internal", so
|
||||
// the dist path is reached the same way run-electron-builder.mjs finds the
|
||||
// CLI: resolve the entry, walk up to the package root, then direct-file
|
||||
// import). If an electron-builder/app-builder-lib bump moves or renames the
|
||||
// class, THIS test is what fails in js-tests — before a release build does.
|
||||
// 60s timeout: the direct-file import drags in app-builder-lib's whole
|
||||
// module graph (toolsets, electronGet, vm), which takes ~30s on a cold
|
||||
// disk cache — far past vitest's 5s default.
|
||||
test('the real WindowsSignAzureManager resolves and constructs against a packager shim', { timeout: 60_000 }, async () => {
|
||||
const entry = require.resolve('app-builder-lib')
|
||||
let root = path.dirname(entry)
|
||||
while (!fs.existsSync(path.join(root, 'package.json'))) {
|
||||
const parent = path.dirname(root)
|
||||
assert.notEqual(parent, root, 'app-builder-lib package root not found')
|
||||
root = parent
|
||||
}
|
||||
const mod = await import(
|
||||
pathToFileURL(path.join(root, 'dist', 'codeSign', 'win', 'windowsSignAzureManager.js')).href
|
||||
)
|
||||
assert.equal(typeof mod.WindowsSignAzureManager, 'function')
|
||||
|
||||
// Constructor contract (verified against 27.0.0-alpha.6): reads
|
||||
// packager.platformOptions.sign (throws unless type === 'azure'),
|
||||
// packager.config.toolsets (optional), and packager.buildResourcesDir
|
||||
// (only stored — WineVmManager's constructor touches no filesystem).
|
||||
const mgr = new mod.WindowsSignAzureManager({
|
||||
platformOptions: {
|
||||
sign: {
|
||||
type: 'azure',
|
||||
endpoint: 'x',
|
||||
codeSigningAccountName: 'y',
|
||||
certificateProfileName: 'z',
|
||||
publisherName: 'p'
|
||||
}
|
||||
},
|
||||
config: {},
|
||||
buildResourcesDir: '/tmp'
|
||||
})
|
||||
assert.equal(typeof mgr.signFile, 'function')
|
||||
assert.equal(typeof mgr.initialize, 'function')
|
||||
})
|
||||
@@ -0,0 +1,225 @@
|
||||
// Sign payload Chromium with our Developer ID before osx-sign seals
|
||||
// Hermes.app. Google's leftover signatures have no timestamp and no
|
||||
// hardened runtime, so Apple's notary rejects them.
|
||||
//
|
||||
// Do not codesign a file that lives inside a .framework: codesign then
|
||||
// treats Versions/A/Foo as a bundle and dies with "code object is not
|
||||
// signed at all" (or "bundle format is ambiguous" on the Foo.framework/Foo
|
||||
// symlink). Sign the enclosing .app with --deep. Loose Mach-O outside
|
||||
// any .app (headless-shell + its dylibs) is signed as a file.
|
||||
//
|
||||
// signIgnore still keeps osx-sign off the chromium trees.
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const MACHO_MAGICS = new Set([
|
||||
0xfeedface, 0xcefaedfe, 0xfeedfacf, 0xcffaedfe, 0xcafebabe, 0xbebafeca
|
||||
])
|
||||
|
||||
export function isMachO(file) {
|
||||
const buf = Buffer.alloc(4)
|
||||
let fd
|
||||
try {
|
||||
fd = fs.openSync(file, 'r')
|
||||
if (fs.readSync(fd, buf, 0, 4, 0) !== 4) return false
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
if (fd != null) fs.closeSync(fd)
|
||||
}
|
||||
return MACHO_MAGICS.has(buf.readUInt32BE(0))
|
||||
}
|
||||
|
||||
export function chromiumRoots(payload) {
|
||||
const tools = path.join(payload, 'tools')
|
||||
if (!fs.existsSync(tools)) return []
|
||||
return fs
|
||||
.readdirSync(tools, { withFileTypes: true })
|
||||
.filter(ent => ent.isDirectory() && /^chromium(_headless_shell)?-\d+/.test(ent.name))
|
||||
.map(ent => path.join(tools, ent.name))
|
||||
}
|
||||
|
||||
export function listTopLevelApps(root) {
|
||||
const apps = []
|
||||
const walk = dir => {
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const ent of entries) {
|
||||
const p = path.join(dir, ent.name)
|
||||
if (ent.isSymbolicLink()) continue
|
||||
if (ent.isDirectory() && p.endsWith('.app')) {
|
||||
apps.push(p)
|
||||
continue
|
||||
}
|
||||
if (ent.isDirectory()) walk(p)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return apps
|
||||
}
|
||||
|
||||
export function listLooseMachO(root) {
|
||||
const out = []
|
||||
const walk = dir => {
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const ent of entries) {
|
||||
const p = path.join(dir, ent.name)
|
||||
if (ent.isSymbolicLink()) continue
|
||||
if (ent.isDirectory()) {
|
||||
if (p.endsWith('.app')) continue
|
||||
walk(p)
|
||||
continue
|
||||
}
|
||||
if (!ent.isFile()) continue
|
||||
if (isMachO(p)) out.push(p)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return out
|
||||
}
|
||||
|
||||
// If Foo.framework/Foo is a regular file and Versions/*/Foo exists,
|
||||
// replace the top-level copy with a symlink. Same for Versions/Current.
|
||||
export function repairFrameworkLinks(root) {
|
||||
let repaired = 0
|
||||
const walk = dir => {
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const ent of entries) {
|
||||
const p = path.join(dir, ent.name)
|
||||
if (ent.isSymbolicLink()) continue
|
||||
if (ent.isDirectory() && ent.name.endsWith('.framework')) {
|
||||
repaired += repairOneFramework(p)
|
||||
continue
|
||||
}
|
||||
if (ent.isDirectory()) walk(p)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return repaired
|
||||
}
|
||||
|
||||
function repairOneFramework(frameworkDir) {
|
||||
let repaired = 0
|
||||
const versions = path.join(frameworkDir, 'Versions')
|
||||
if (!fs.existsSync(versions)) return 0
|
||||
const versionDirs = fs
|
||||
.readdirSync(versions, { withFileTypes: true })
|
||||
.filter(ent => ent.isDirectory() && ent.name !== 'Current')
|
||||
.map(ent => ent.name)
|
||||
if (versionDirs.length === 0) return 0
|
||||
const preferred = versionDirs.includes('A') ? 'A' : versionDirs[0]
|
||||
const current = path.join(versions, 'Current')
|
||||
if (!fs.existsSync(current) || !fs.lstatSync(current).isSymbolicLink()) {
|
||||
if (fs.existsSync(current)) fs.rmSync(current, { recursive: true, force: true })
|
||||
fs.symlinkSync(preferred, current)
|
||||
repaired += 1
|
||||
}
|
||||
const stem = path.basename(frameworkDir, '.framework')
|
||||
for (const name of [stem, 'Resources', 'Libraries', 'Helpers']) {
|
||||
const top = path.join(frameworkDir, name)
|
||||
const target = path.posix.join('Versions', 'Current', name)
|
||||
const versioned = path.join(frameworkDir, 'Versions', preferred, name)
|
||||
if (!fs.existsSync(versioned) && name !== stem) continue
|
||||
if (fs.existsSync(top) && fs.lstatSync(top).isSymbolicLink()) continue
|
||||
if (fs.existsSync(top)) fs.rmSync(top, { recursive: true, force: true })
|
||||
if (fs.existsSync(versioned) || name === stem) {
|
||||
fs.symlinkSync(target, top)
|
||||
repaired += 1
|
||||
}
|
||||
}
|
||||
return repaired
|
||||
}
|
||||
|
||||
export function parseDeveloperId(identityList) {
|
||||
const line = String(identityList || '')
|
||||
.split(/\r?\n/)
|
||||
.find(l => l.includes('Developer ID Application:'))
|
||||
if (!line) return null
|
||||
const quoted = line.match(/"([^"]+)"/)
|
||||
return quoted ? quoted[1] : null
|
||||
}
|
||||
|
||||
export function findDeveloperId(exec = execFileSync, keychain = null) {
|
||||
if (process.env.CSC_NAME) return process.env.CSC_NAME
|
||||
const args = ['find-identity', '-v', '-p', 'codesigning']
|
||||
if (keychain) args.push(keychain)
|
||||
let out
|
||||
try {
|
||||
out = exec('security', args, { encoding: 'utf8' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return parseDeveloperId(out)
|
||||
}
|
||||
|
||||
export async function resolveSigningIdentity(packager, exec = execFileSync) {
|
||||
if (!packager?.codeSigningInfo?.value) {
|
||||
return { identity: findDeveloperId(exec), keychain: null }
|
||||
}
|
||||
let info
|
||||
try {
|
||||
info = await packager.codeSigningInfo.value
|
||||
} catch {
|
||||
return { identity: findDeveloperId(exec), keychain: null }
|
||||
}
|
||||
const keychain = info?.keychainFile || process.env.CSC_KEYCHAIN || null
|
||||
return { identity: findDeveloperId(exec, keychain), keychain }
|
||||
}
|
||||
|
||||
function codesign(exec, identity, entitlements, keychain, target, deep) {
|
||||
const args = [
|
||||
'--force',
|
||||
'--sign',
|
||||
identity,
|
||||
'--timestamp',
|
||||
'--options',
|
||||
'runtime',
|
||||
'--entitlements',
|
||||
entitlements
|
||||
]
|
||||
if (deep) args.push('--deep')
|
||||
if (keychain) args.push('--keychain', keychain)
|
||||
args.push(target)
|
||||
exec('codesign', args, { stdio: 'pipe' })
|
||||
}
|
||||
|
||||
export function signNestedChromium(payload, opts = {}) {
|
||||
const identity = opts.identity
|
||||
if (!identity) return { signed: 0, repaired: 0, identity: null }
|
||||
const entitlements = opts.entitlements
|
||||
if (!entitlements || !fs.existsSync(entitlements)) {
|
||||
throw new Error(`sign-nested-chromium: entitlements missing at ${entitlements}`)
|
||||
}
|
||||
const exec = opts.exec ?? execFileSync
|
||||
const keychain = opts.keychain || null
|
||||
let signed = 0
|
||||
let repaired = 0
|
||||
for (const root of chromiumRoots(payload)) {
|
||||
repaired += repairFrameworkLinks(root)
|
||||
for (const app of listTopLevelApps(root)) {
|
||||
codesign(exec, identity, entitlements, keychain, app, true)
|
||||
signed += 1
|
||||
}
|
||||
for (const file of listLooseMachO(root)) {
|
||||
codesign(exec, identity, entitlements, keychain, file, false)
|
||||
signed += 1
|
||||
}
|
||||
}
|
||||
return { signed, repaired, identity }
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
chromiumRoots,
|
||||
isMachO,
|
||||
listLooseMachO,
|
||||
listTopLevelApps,
|
||||
parseDeveloperId,
|
||||
repairFrameworkLinks,
|
||||
resolveSigningIdentity,
|
||||
signNestedChromium
|
||||
} from './sign-nested-chromium.mjs'
|
||||
|
||||
function tempRoot() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-sign-chrome-'))
|
||||
}
|
||||
|
||||
function machoBuf() {
|
||||
const buf = Buffer.alloc(16)
|
||||
buf.writeUInt32BE(0xfeedfacf, 0)
|
||||
return buf
|
||||
}
|
||||
|
||||
test('isMachO accepts a 64-bit Mach-O magic and rejects text', () => {
|
||||
const dir = tempRoot()
|
||||
try {
|
||||
const bin = path.join(dir, 'a')
|
||||
const txt = path.join(dir, 'b.txt')
|
||||
fs.writeFileSync(bin, machoBuf())
|
||||
fs.writeFileSync(txt, 'not a binary')
|
||||
assert.equal(isMachO(bin), true)
|
||||
assert.equal(isMachO(txt), false)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('chromiumRoots only names chromium store entries', () => {
|
||||
const payload = tempRoot()
|
||||
try {
|
||||
fs.mkdirSync(path.join(payload, 'tools', 'chromium-1208'), { recursive: true })
|
||||
fs.mkdirSync(path.join(payload, 'tools', 'chromium_headless_shell-1208'), { recursive: true })
|
||||
fs.mkdirSync(path.join(payload, 'tools', 'uv-0.12.3-darwin-arm64'), { recursive: true })
|
||||
const roots = chromiumRoots(payload).map(p => path.basename(p)).sort()
|
||||
assert.deepEqual(roots, ['chromium-1208', 'chromium_headless_shell-1208'])
|
||||
} finally {
|
||||
fs.rmSync(payload, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listTopLevelApps finds .app dirs and listLooseMachO skips them', () => {
|
||||
const root = tempRoot()
|
||||
try {
|
||||
const app = path.join(root, 'Google Chrome for Testing.app')
|
||||
fs.mkdirSync(path.join(app, 'Contents', 'MacOS'), { recursive: true })
|
||||
fs.writeFileSync(path.join(app, 'Contents', 'MacOS', 'Chrome'), machoBuf())
|
||||
const loose = path.join(root, 'chrome-headless-shell')
|
||||
fs.writeFileSync(loose, machoBuf())
|
||||
assert.deepEqual(listTopLevelApps(root), [app])
|
||||
assert.deepEqual(listLooseMachO(root), [loose])
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('repairFrameworkLinks turns a flattened Foo.framework/Foo into a symlink', () => {
|
||||
if (process.platform === 'win32') return
|
||||
const root = tempRoot()
|
||||
try {
|
||||
const fw = path.join(root, 'F.framework')
|
||||
const versioned = path.join(fw, 'Versions', 'A')
|
||||
fs.mkdirSync(path.join(versioned, 'Resources'), { recursive: true })
|
||||
fs.writeFileSync(path.join(versioned, 'F'), machoBuf())
|
||||
fs.writeFileSync(path.join(versioned, 'Resources', 'Info.plist'), '<plist/>')
|
||||
fs.writeFileSync(path.join(fw, 'F'), machoBuf())
|
||||
fs.mkdirSync(path.join(fw, 'Resources'))
|
||||
fs.writeFileSync(path.join(fw, 'Resources', 'Info.plist'), '<plist/>')
|
||||
|
||||
const n = repairFrameworkLinks(root)
|
||||
assert.ok(n >= 2)
|
||||
assert.equal(fs.lstatSync(path.join(fw, 'F')).isSymbolicLink(), true)
|
||||
assert.equal(fs.lstatSync(path.join(fw, 'Versions', 'Current')).isSymbolicLink(), true)
|
||||
assert.equal(fs.readFileSync(path.join(fw, 'F')).equals(machoBuf()), true)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('parseDeveloperId takes the first Developer ID Application line', () => {
|
||||
const listing = `
|
||||
1) ABC "Apple Development: someone@example.com (TEAM)"
|
||||
2) DEF "Developer ID Application: Nous Research Inc (763K57MW7Z)"
|
||||
3) GHI "Developer ID Installer: Nous Research Inc (763K57MW7Z)"
|
||||
`
|
||||
assert.equal(parseDeveloperId(listing), 'Developer ID Application: Nous Research Inc (763K57MW7Z)')
|
||||
assert.equal(parseDeveloperId('nothing here'), null)
|
||||
})
|
||||
|
||||
test('signNestedChromium no-ops without an identity', () => {
|
||||
const payload = tempRoot()
|
||||
try {
|
||||
const r = signNestedChromium(payload, { identity: null, entitlements: path.join(payload, 'missing.plist') })
|
||||
assert.deepEqual(r, { signed: 0, repaired: 0, identity: null })
|
||||
} finally {
|
||||
fs.rmSync(payload, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('signNestedChromium --deep signs the .app and file-signs loose Mach-O', () => {
|
||||
const payload = tempRoot()
|
||||
try {
|
||||
const entitlements = path.join(payload, 'entitlements.plist')
|
||||
fs.writeFileSync(entitlements, '<plist/>')
|
||||
const app = path.join(payload, 'tools', 'chromium-1208', 'Google Chrome for Testing.app')
|
||||
fs.mkdirSync(path.join(app, 'Contents', 'MacOS'), { recursive: true })
|
||||
fs.writeFileSync(path.join(app, 'Contents', 'MacOS', 'Chrome'), machoBuf())
|
||||
const looseDir = path.join(payload, 'tools', 'chromium_headless_shell-1208')
|
||||
fs.mkdirSync(looseDir, { recursive: true })
|
||||
const loose = path.join(looseDir, 'chrome-headless-shell')
|
||||
fs.writeFileSync(loose, machoBuf())
|
||||
const calls = []
|
||||
const r = signNestedChromium(payload, {
|
||||
identity: 'Developer ID Application: Test',
|
||||
entitlements,
|
||||
exec: (cmd, args) => {
|
||||
calls.push([cmd, ...args])
|
||||
}
|
||||
})
|
||||
assert.equal(r.signed, 2)
|
||||
assert.equal(calls.length, 2)
|
||||
const appCall = calls.find(c => c.includes(app))
|
||||
const fileCall = calls.find(c => c.includes(loose))
|
||||
assert.ok(appCall.includes('--deep'))
|
||||
assert.ok(!fileCall.includes('--deep'))
|
||||
} finally {
|
||||
fs.rmSync(payload, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveSigningIdentity reads the packager keychain', async () => {
|
||||
const seen = []
|
||||
const packager = {
|
||||
codeSigningInfo: {
|
||||
value: Promise.resolve({ keychainFile: '/tmp/builder.keychain' })
|
||||
}
|
||||
}
|
||||
const listing = ' 1) DEF "Developer ID Application: Nous Research Inc (763K57MW7Z)"\n'
|
||||
const r = await resolveSigningIdentity(packager, (_cmd, args) => {
|
||||
seen.push(args)
|
||||
return listing
|
||||
})
|
||||
assert.equal(r.identity, 'Developer ID Application: Nous Research Inc (763K57MW7Z)')
|
||||
assert.equal(r.keychain, '/tmp/builder.keychain')
|
||||
assert.ok(seen[0].includes('/tmp/builder.keychain'))
|
||||
})
|
||||
@@ -13,10 +13,8 @@ const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)),
|
||||
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
|
||||
const PLATFORM = process.platform
|
||||
|
||||
// Platform-specific packaged-app layout. The thin installer ships an Electron
|
||||
// app shell plus extraResources (install-stamp.json + native-deps/) -- it
|
||||
// no longer bundles the Hermes Agent Python payload (that's fetched at first
|
||||
// launch via install.ps1 / install.sh, per the Phase 1 thin-installer flow).
|
||||
// Platform-specific packaged-app layout. The bundled app ships an Electron
|
||||
// shell and the PM payload under resources/agent-payload.
|
||||
const APP = (() => {
|
||||
if (PLATFORM === 'darwin') {
|
||||
const appPath = path.join(RELEASE_ROOT, `mac-${ARCH}`, 'Hermes.app')
|
||||
@@ -49,17 +47,6 @@ const APP = (() => {
|
||||
}
|
||||
})()
|
||||
|
||||
// Default HERMES_HOME for non-sandboxed runs -- matches main.ts's
|
||||
// resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere
|
||||
// it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own
|
||||
// HERMES_HOME and never touches this.
|
||||
const DEFAULT_HERMES_HOME = (() => {
|
||||
if (PLATFORM === 'win32' && process.env.LOCALAPPDATA) {
|
||||
return path.join(process.env.LOCALAPPDATA, 'hermes')
|
||||
}
|
||||
return path.join(os.homedir(), '.hermes')
|
||||
})()
|
||||
const VENV_ROOT = path.join(DEFAULT_HERMES_HOME, 'hermes-agent', 'venv')
|
||||
const FRESH_SANDBOX_ROOT = path.join(os.tmpdir(), 'hermes-desktop-fresh-install')
|
||||
|
||||
function die(message) {
|
||||
@@ -144,12 +131,11 @@ function resolveDmgPath() {
|
||||
: path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
|
||||
}
|
||||
|
||||
function resolveNsisPath() {
|
||||
// electron-builder NSIS artifactName template is 'Hermes-${version}-${os}-${arch}.${ext}'
|
||||
function resolveMsixPath() {
|
||||
if (!exists(RELEASE_ROOT)) return null
|
||||
const candidates = fs
|
||||
.readdirSync(RELEASE_ROOT)
|
||||
.filter(name => /\.exe$/i.test(name) && /win/i.test(name))
|
||||
.filter(name => /\.msix$/i.test(name) && /win/i.test(name))
|
||||
.sort((a, b) => {
|
||||
const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs
|
||||
const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).mtimeMs
|
||||
@@ -160,7 +146,7 @@ function resolveNsisPath() {
|
||||
|
||||
function ensureDmg() {
|
||||
if (PLATFORM !== 'darwin') {
|
||||
die('DMG mode is macOS-only; on Windows use the `nsis` mode instead.')
|
||||
die('DMG mode is macOS-only; on Windows use the `msix` mode instead.')
|
||||
}
|
||||
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(resolveDmgPath())) {
|
||||
return
|
||||
@@ -168,14 +154,14 @@ function ensureDmg() {
|
||||
run('npm', ['run', 'dist:mac:dmg'])
|
||||
}
|
||||
|
||||
function ensureNsis() {
|
||||
function ensureMsix() {
|
||||
if (PLATFORM !== 'win32') {
|
||||
die('NSIS mode is win32-only; on macOS use the `dmg` mode instead.')
|
||||
die('MSIX mode is win32-only; on macOS use the `dmg` mode instead.')
|
||||
}
|
||||
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && resolveNsisPath()) {
|
||||
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && resolveMsixPath()) {
|
||||
return
|
||||
}
|
||||
run('npm', ['run', 'dist:win:nsis'])
|
||||
run('npm', ['run', 'dist:win:msix'])
|
||||
}
|
||||
|
||||
function openApp() {
|
||||
@@ -278,49 +264,50 @@ function launchFresh() {
|
||||
console.log(` HERMES_HOME: ${hermesHome}`)
|
||||
console.log(` cwd: ${cwd}`)
|
||||
|
||||
return { runtimeRoot: path.join(hermesHome, 'hermes-agent', 'venv') }
|
||||
}
|
||||
|
||||
// Validate the packaged bundle matches the thin-installer architecture:
|
||||
// - The Hermes Agent Python payload is NOT shipped (it's fetched at first
|
||||
// launch via install.ps1's stage protocol).
|
||||
// - install-stamp.json IS shipped in resources/ with a valid commit + branch.
|
||||
// - node-pty IS shipped inside app.asar.unpacked/dist/node_modules/node-pty
|
||||
// with package.json + lib/ + at least one .node binary (the renderer's
|
||||
// integrated terminal needs this; see Phase 1F.6).
|
||||
// - The renderer's dist/index.html is reachable (either unpacked or
|
||||
// inside app.asar).
|
||||
// The packaged app must contain the PM payload, node-pty, and renderer assets.
|
||||
function validateBundle() {
|
||||
if (!exists(APP.binary)) {
|
||||
die(`Missing packaged app binary: ${APP.binary}`)
|
||||
}
|
||||
|
||||
// Negative assertion: the OLD fat-installer factory payload must NOT be
|
||||
// present anymore. If a stray ship of hermes_cli sneaks back in we want
|
||||
// to fail loudly rather than re-introduce the 400MB delta we just removed.
|
||||
const staleFactoryMarker = path.join(APP.resourcesPath, 'hermes-agent', 'hermes_cli', 'main.py')
|
||||
if (exists(staleFactoryMarker)) {
|
||||
die(
|
||||
`Thin-installer regression: factory-payload file should NOT be in the package: ${staleFactoryMarker}`
|
||||
// The payload may be the real pm bundle (staged by build-bundled-desktop /
|
||||
// `hermes pm bundle --out build/agent-payload`) or the external stub
|
||||
// (plain `npm run pack` in the PR/JS lane — the app fetches the runtime at
|
||||
// first launch via the stage protocol). Validate the payload only when a
|
||||
// real one is present; the stub is the thin-installer contract.
|
||||
const payloadRoot = path.join(APP.resourcesPath, 'agent-payload')
|
||||
const payloadManifestPath = path.join(payloadRoot, 'manifest.json')
|
||||
let payloadManifest = null
|
||||
if (exists(payloadManifestPath)) {
|
||||
try {
|
||||
payloadManifest = JSON.parse(fs.readFileSync(payloadManifestPath, 'utf8'))
|
||||
} catch (err) {
|
||||
die(`Bundled payload manifest is not valid JSON: ${err.message}`)
|
||||
}
|
||||
}
|
||||
if (payloadManifest != null && payloadManifest.external !== true) {
|
||||
for (const key of ['repo', 'store', 'venv']) {
|
||||
if (typeof payloadManifest[key] !== 'string') {
|
||||
die(`Bundled payload manifest is missing ${key}: ${JSON.stringify(payloadManifest)}`)
|
||||
}
|
||||
}
|
||||
const payloadPython = path.join(
|
||||
payloadRoot,
|
||||
payloadManifest.venv,
|
||||
PLATFORM === 'win32' ? 'Scripts' : 'bin',
|
||||
PLATFORM === 'win32' ? 'python.exe' : 'python'
|
||||
)
|
||||
}
|
||||
|
||||
// Positive assertion: install-stamp.json carries a sane commit + branch
|
||||
const stampPath = path.join(APP.resourcesPath, 'install-stamp.json')
|
||||
if (!exists(stampPath)) {
|
||||
die(`Missing install-stamp.json (required for first-launch bootstrap pinning): ${stampPath}`)
|
||||
}
|
||||
let stamp
|
||||
try {
|
||||
stamp = JSON.parse(fs.readFileSync(stampPath, 'utf8'))
|
||||
} catch (err) {
|
||||
die(`install-stamp.json is not valid JSON: ${err.message}`)
|
||||
}
|
||||
if (!stamp.commit || typeof stamp.commit !== 'string' || stamp.commit.length < 7) {
|
||||
die(`install-stamp.json is missing a usable commit field: ${JSON.stringify(stamp)}`)
|
||||
}
|
||||
if (!stamp.branch || typeof stamp.branch !== 'string') {
|
||||
die(`install-stamp.json is missing the branch field: ${JSON.stringify(stamp)}`)
|
||||
if (!exists(payloadPython)) {
|
||||
die(`Missing bundled payload Python: ${payloadPython}`)
|
||||
}
|
||||
if (PLATFORM === 'win32') {
|
||||
const payloadShim = path.join(payloadRoot, payloadManifest.venv, 'Scripts', 'hermes.exe')
|
||||
if (!exists(payloadShim)) {
|
||||
die(`Missing bundled payload shim: ${payloadShim}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Positive assertion: node-pty native deps shipped
|
||||
@@ -360,7 +347,7 @@ function validateBundle() {
|
||||
|
||||
// Renderer payload check (either unpacked or in the asar)
|
||||
if (exists(APP.unpackedDistIndex)) {
|
||||
return { stamp, nodeBinaries }
|
||||
return { payloadManifest, nodeBinaries }
|
||||
}
|
||||
if (!exists(APP.asarPath)) {
|
||||
die(`Missing renderer payload: neither ${APP.unpackedDistIndex} nor ${APP.asarPath} exists`)
|
||||
@@ -373,24 +360,22 @@ function validateBundle() {
|
||||
if (!normalized.includes('dist/index.html')) {
|
||||
die(`Missing renderer payload file in app.asar: ${APP.asarPath} (expected dist/index.html)`)
|
||||
}
|
||||
return { stamp, nodeBinaries }
|
||||
return { payloadManifest, nodeBinaries }
|
||||
}
|
||||
|
||||
function printArtifacts(options = {}) {
|
||||
const runtimeRoot = options.runtimeRoot || VENV_ROOT
|
||||
const stamp = options.stamp
|
||||
const payloadManifest = options.payloadManifest
|
||||
|
||||
console.log('\nDesktop artifacts:')
|
||||
console.log(` app: ${APP.appPath}`)
|
||||
if (PLATFORM === 'darwin') {
|
||||
console.log(` dmg: ${resolveDmgPath()}`)
|
||||
} else if (PLATFORM === 'win32') {
|
||||
const exe = resolveNsisPath()
|
||||
if (exe) console.log(` installer: ${exe}`)
|
||||
const msix = resolveMsixPath()
|
||||
if (msix) console.log(` package: ${msix}`)
|
||||
}
|
||||
console.log(` runtime: ${runtimeRoot}`)
|
||||
if (stamp) {
|
||||
console.log(` install-stamp: ${stamp.commit.slice(0, 12)} on ${stamp.branch}`)
|
||||
if (payloadManifest) {
|
||||
console.log(` payload: ${payloadManifest.repo} + ${payloadManifest.venv}`)
|
||||
}
|
||||
if (options.nodeBinaries && options.nodeBinaries.length > 0) {
|
||||
console.log(` node-pty binaries: ${options.nodeBinaries.join(', ')}`)
|
||||
@@ -402,8 +387,8 @@ function help() {
|
||||
npm run test:desktop:existing # build packaged app, launch with normal PATH/existing Hermes
|
||||
npm run test:desktop:fresh # build packaged app, launch with temp userData + HERMES_HOME
|
||||
npm run test:desktop:dmg # (macOS only) build DMG and open it
|
||||
npm run test:desktop:nsis # (win32 only) build NSIS installer
|
||||
npm run test:desktop:all # build installer, validate app payload, print paths
|
||||
npm run test:desktop:msix # (win32 only) build MSIX package
|
||||
npm run test:desktop:all # build the platform package and validate the payload
|
||||
|
||||
Fast rerun (skip rebuild if the packaged app already exists):
|
||||
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:fresh
|
||||
@@ -425,14 +410,14 @@ if (MODE === 'existing') {
|
||||
ensureDmg()
|
||||
openDmg()
|
||||
printArtifacts()
|
||||
} else if (MODE === 'nsis') {
|
||||
ensureNsis()
|
||||
} else if (MODE === 'msix') {
|
||||
ensureMsix()
|
||||
printArtifacts(validateBundle())
|
||||
} else if (MODE === 'all') {
|
||||
if (PLATFORM === 'darwin') {
|
||||
ensureDmg()
|
||||
} else if (PLATFORM === 'win32') {
|
||||
ensureNsis()
|
||||
ensureMsix()
|
||||
} else {
|
||||
ensurePackagedApp()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// windows-file-version.mjs — the Windows VERSIONINFO quad for a release tag.
|
||||
//
|
||||
// Windows VERSIONINFO holds four 16-bit fields, so a nightly's semver
|
||||
// string cannot go in it. resedit splits whatever it is handed on "." and
|
||||
// clamps each token to [0, 65535], so `0.28.0-nightly.20260819171926`
|
||||
// becomes 0.28.0.65535: "0-nightly" parses as NaN and falls to the min,
|
||||
// and the timestamp saturates at the max. Every nightly of a minor then
|
||||
// shows the same meaningless quad on the Details tab.
|
||||
//
|
||||
// The timestamp is the only part worth showing there — the semver line is
|
||||
// identical across a whole nightly series — so pack it as
|
||||
// yyyy.mmdd.hhmm.ss. Every field stays under 65536, and the quad compares
|
||||
// in timestamp order, which is the order Windows sorts versions in.
|
||||
//
|
||||
// Display metadata only: electron-updater keys on the semver version in
|
||||
// extraMetadata, never on this quad.
|
||||
|
||||
/**
|
||||
* The `yyyy.mmdd.hhmm.ss` quad for a nightly tag, or null for a stable tag
|
||||
* (whose version is already four legal fields or fewer, so app-builder-lib's
|
||||
* own handling is correct).
|
||||
*
|
||||
* @param {string} releaseTag A release tag, with the leading `v`.
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function windowsFileVersion(releaseTag) {
|
||||
const stamp = /-nightly\.(20\d{6}(?:\d{6})?)$/.exec(String(releaseTag))?.[1]
|
||||
if (!stamp) {
|
||||
return null
|
||||
}
|
||||
|
||||
// The legacy date-only shape (YYYYMMDD) is midnight of that day.
|
||||
const parts = /^(\d{4})(\d{2})(\d{2})(\d{2})?(\d{2})?(\d{2})?$/.exec(stamp)
|
||||
if (!parts) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [, year, month, day, hour, minute, second] = parts
|
||||
const field = (a, b) => Number(`${a ?? '00'}${b ?? '00'}`)
|
||||
|
||||
return `${Number(year)}.${field(month, day)}.${field(hour, minute)}.${Number(second ?? 0)}`
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { windowsFileVersion } from './windows-file-version.mjs'
|
||||
|
||||
test('stable tags leave VERSIONINFO to electron-builder', () => {
|
||||
assert.equal(windowsFileVersion('v0.20.5'), null)
|
||||
assert.equal(windowsFileVersion('v0.28.0'), null)
|
||||
})
|
||||
|
||||
test('nightly tags pack the timestamp into a legal quad', () => {
|
||||
assert.equal(windowsFileVersion('v0.28.0-nightly.20260819171926'), '2026.819.1719.26')
|
||||
assert.equal(windowsFileVersion('v0.20.5-nightly.20260826'), '2026.826.0.0')
|
||||
})
|
||||
@@ -18,6 +18,7 @@ import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
|
||||
import { ConfirmHost } from '@/components/confirm-host'
|
||||
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
|
||||
import { ExternalOpenFailedDialog } from '@/components/external-open-failed-dialog'
|
||||
import { FindBar } from '@/components/find-bar'
|
||||
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
@@ -1245,6 +1246,10 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
(error card action); renders nothing until requested. */}
|
||||
<SendDiagnosticsHost />
|
||||
|
||||
{/* Fallback modal when opening an external URL fails — carries the URL
|
||||
so a dead system-browser click is never silent. */}
|
||||
<ExternalOpenFailedDialog />
|
||||
|
||||
{/* Petdex floating mascot — renders nothing unless installed + enabled.
|
||||
Never in the HUD: that window is the chat bar and nothing else. */}
|
||||
{!isHudWindow() && !isBrowserWindow() && <FloatingPet />}
|
||||
|
||||
@@ -1,209 +1,53 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { BrandMark } from '@/components/brand-mark'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertTriangle, CheckCircle2, ExternalLink, Loader2, RefreshCw } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$desktopVersion,
|
||||
$updateApply,
|
||||
$updateChecking,
|
||||
$updateStatus,
|
||||
checkUpdates,
|
||||
openUpdatesWindow,
|
||||
refreshDesktopVersion,
|
||||
startActiveUpdate
|
||||
} from '@/store/updates'
|
||||
import { UpdateStatusCard, VersionHero } from '@/components/update-status'
|
||||
import { VersionDetails } from '@/components/version-details'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { RefreshCw } from '@/lib/icons'
|
||||
import { $connection } from '@/store/session'
|
||||
import { $desktopVersion, checkBackendUpdates, refreshDesktopVersion } from '@/store/updates'
|
||||
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
import { SectionHeading, SettingsContent } from './primitives'
|
||||
import { UninstallSection } from './uninstall-section'
|
||||
|
||||
const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases'
|
||||
const INSTALLER_URL = 'https://hermes-agent.nousresearch.com/'
|
||||
|
||||
function relativeTime(ms: number | undefined, a: Translations['settings']['about']) {
|
||||
if (!ms) {
|
||||
return a.never
|
||||
}
|
||||
|
||||
const diff = Date.now() - ms
|
||||
|
||||
if (diff < 60_000) {
|
||||
return a.justNow
|
||||
}
|
||||
|
||||
if (diff < 3_600_000) {
|
||||
return a.minAgo(Math.round(diff / 60_000))
|
||||
}
|
||||
|
||||
if (diff < 86_400_000) {
|
||||
return a.hoursAgo(Math.round(diff / 3_600_000))
|
||||
}
|
||||
|
||||
return a.daysAgo(Math.round(diff / 86_400_000))
|
||||
}
|
||||
|
||||
export function AboutSettings() {
|
||||
const { t } = useI18n()
|
||||
const a = t.settings.about
|
||||
const u = t.updates
|
||||
const version = useStore($desktopVersion)
|
||||
const status = useStore($updateStatus)
|
||||
const apply = useStore($updateApply)
|
||||
const checking = useStore($updateChecking)
|
||||
const [justChecked, setJustChecked] = useState(false)
|
||||
const connection = useStore($connection)
|
||||
const remote = connection?.mode === 'remote'
|
||||
|
||||
// The version atom is loaded once at app boot, which makes About show a
|
||||
// stale number after a self-update (the running binary is current, the
|
||||
// displayed string is not). Re-read on mount so opening About always
|
||||
// reflects the running build.
|
||||
// reflects the running build. In remote mode also seed the backend update
|
||||
// state so the backend card opens answered instead of on "never checked".
|
||||
useEffect(() => {
|
||||
void refreshDesktopVersion()
|
||||
}, [])
|
||||
|
||||
const behind = status?.behind ?? 0
|
||||
// behind is null when the exact count is unknowable (shallow clone): the
|
||||
// backend flags that case via updateAvailable instead of a number.
|
||||
const updateAvailable = behind > 0 || Boolean(status?.updateAvailable)
|
||||
const supported = status?.supported !== false
|
||||
const applying = apply.applying || apply.stage === 'restart'
|
||||
|
||||
const handleCheck = async () => {
|
||||
setJustChecked(false)
|
||||
const next = await checkUpdates()
|
||||
setJustChecked(Boolean(next))
|
||||
}
|
||||
|
||||
let statusLine: string
|
||||
let statusTone: 'idle' | 'available' | 'error' = 'idle'
|
||||
|
||||
if (!supported) {
|
||||
statusLine = status?.message ?? a.cantUpdate
|
||||
statusTone = 'error'
|
||||
} else if (status?.error) {
|
||||
statusLine = a.cantReach
|
||||
statusTone = 'error'
|
||||
} else if (applying) {
|
||||
statusLine = a.installing
|
||||
statusTone = 'available'
|
||||
} else if (updateAvailable) {
|
||||
statusLine = behind > 0 ? a.updateReady(behind) : a.updateReadyUnknown
|
||||
statusTone = 'available'
|
||||
} else if (status) {
|
||||
statusLine = a.onLatest
|
||||
} else {
|
||||
statusLine = a.tapCheck
|
||||
}
|
||||
if (remote) {
|
||||
void checkBackendUpdates()
|
||||
}
|
||||
}, [remote])
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="flex flex-col items-center gap-3 pt-6 pb-2 text-center">
|
||||
<BrandMark className="size-16" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">{a.heading}</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{version?.appVersion ? a.version(version.appVersion) : a.versionUnavailable}
|
||||
</p>
|
||||
</div>
|
||||
{version?.bundleOutOfSync && (
|
||||
<div className="mx-auto w-full max-w-2xl rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-left text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{a.bundleOutOfSync}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{a.bundleOutOfSyncDesc}</p>
|
||||
<Button asChild className="mt-2" size="sm" variant="textStrong">
|
||||
<a
|
||||
href={INSTALLER_URL}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
void window.hermesDesktop?.openExternal?.(INSTALLER_URL)
|
||||
}}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
{a.bundleOutOfSyncAction}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<VersionHero version={version} />
|
||||
|
||||
<div className="mx-auto mt-4 w-full max-w-2xl">
|
||||
<SectionHeading icon={RefreshCw} title={a.updates} />
|
||||
<SectionHeading icon={RefreshCw} title={u.updatesSection} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border px-4 py-3 text-sm',
|
||||
statusTone === 'available' && 'border-primary/30 bg-primary/5 text-foreground',
|
||||
statusTone === 'error' && 'border-destructive/35 bg-destructive/5 text-destructive',
|
||||
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{statusTone === 'available' ? (
|
||||
<Codicon className="mt-0.5 size-4 shrink-0 text-primary" name="cloud-download" size="1rem" />
|
||||
) : statusTone === 'error' ? null : (
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{statusLine}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{a.lastChecked(relativeTime(status?.fetchedAt, a))}
|
||||
{justChecked && !checking ? a.justNowSuffix : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4">
|
||||
<Button
|
||||
disabled={checking || applying || !supported}
|
||||
onClick={() => void handleCheck()}
|
||||
size="sm"
|
||||
variant="textStrong"
|
||||
>
|
||||
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
{checking ? a.checking : a.checkNow}
|
||||
</Button>
|
||||
|
||||
{updateAvailable && supported && !applying && (
|
||||
<>
|
||||
<Button onClick={() => startActiveUpdate()} size="sm">
|
||||
{a.updateNow}
|
||||
</Button>
|
||||
<Button onClick={() => openUpdatesWindow()} size="sm" variant="textStrong">
|
||||
{a.seeWhatsNew}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button asChild className="ml-auto" size="sm" variant="text">
|
||||
<a
|
||||
href={RELEASE_NOTES_URL}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
void window.hermesDesktop?.openExternal?.(RELEASE_NOTES_URL)
|
||||
}}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
{a.releaseNotes}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<UpdateStatusCard target="client" />
|
||||
{/* The desktop client and a remote backend update independently — in
|
||||
remote mode the statusbar shows both pills, so About shows both
|
||||
states too. The backend has no GitHub release notes link of its
|
||||
own; the client card already carries it. */}
|
||||
{remote && <UpdateStatusCard showReleaseNotes={false} target="backend" />}
|
||||
</div>
|
||||
|
||||
<ListRow
|
||||
description={a.automaticUpdatesDesc}
|
||||
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
|
||||
title={a.automaticUpdates}
|
||||
/>
|
||||
{version && <VersionDetails version={version} />}
|
||||
|
||||
<UninstallSection />
|
||||
</div>
|
||||
|
||||
@@ -348,7 +348,9 @@ export function ConnectionsRegistrySection() {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: t.boot.failure.signInIncompleteTitle,
|
||||
message: t.boot.failure.signInIncompleteMessage
|
||||
message: result?.error
|
||||
? `${t.boot.failure.signInIncompleteMessage}: ${result.error}`
|
||||
: t.boot.failure.signInIncompleteMessage
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -622,7 +622,9 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: t.boot.failure.signInIncompleteTitle,
|
||||
message: t.boot.failure.signInIncompleteMessage
|
||||
message: result?.error
|
||||
? `${t.boot.failure.signInIncompleteMessage}: ${result.error}`
|
||||
: t.boot.failure.signInIncompleteMessage
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -14,7 +14,16 @@ import {
|
||||
import { ErrorIcon, ErrorState } from '@/components/ui/error-state'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import type { DesktopUpdateBlocker, DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
|
||||
import { UpdateStatusCard, VersionHero } from '@/components/update-status'
|
||||
import { VersionDetails } from '@/components/version-details'
|
||||
import type {
|
||||
DesktopUpdateBlocker,
|
||||
DesktopUpdateCommit,
|
||||
DesktopUpdateStage,
|
||||
DesktopUpdateStatus,
|
||||
DesktopVersionInfo,
|
||||
UpdaterMechanismClient
|
||||
} from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
|
||||
import { AlertCircle, Check, Copy, Terminal } from '@/lib/icons'
|
||||
@@ -24,6 +33,7 @@ import {
|
||||
$backendUpdateApply,
|
||||
$backendUpdateChecking,
|
||||
$backendUpdateStatus,
|
||||
$desktopVersion,
|
||||
$updateApply,
|
||||
$updateChecking,
|
||||
$updateOverlayOpen,
|
||||
@@ -52,6 +62,7 @@ export function UpdatesOverlay() {
|
||||
const backendStatus = useStore($backendUpdateStatus)
|
||||
const backendChecking = useStore($backendUpdateChecking)
|
||||
const backendApply = useStore($backendUpdateApply)
|
||||
const desktopVersion = useStore($desktopVersion)
|
||||
|
||||
const isBackend = target === 'backend'
|
||||
const status = isBackend ? backendStatus : clientStatus
|
||||
@@ -111,7 +122,9 @@ export function UpdatesOverlay() {
|
||||
onOpenAutoFocus={preventCloseButtonAutoFocus}
|
||||
showCloseButton={phase !== 'applying'}
|
||||
>
|
||||
{phase === 'applying' && <ApplyingView apply={apply} isBackend={isBackend} />}
|
||||
{phase === 'applying' && (
|
||||
<ApplyingView apply={apply} isBackend={isBackend} statusMechanism={status?.mechanism} />
|
||||
)}
|
||||
|
||||
{phase === 'manual' && (
|
||||
<ManualView command={apply.command ?? null} message={apply.message} onDone={() => handleClose(false)} />
|
||||
@@ -142,6 +155,7 @@ export function UpdatesOverlay() {
|
||||
status={status}
|
||||
target={target}
|
||||
updateAvailable={updateAvailable}
|
||||
version={desktopVersion}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
@@ -158,7 +172,8 @@ function IdleView({
|
||||
onRetryCheck,
|
||||
status,
|
||||
target,
|
||||
updateAvailable
|
||||
updateAvailable,
|
||||
version
|
||||
}: {
|
||||
behind: number
|
||||
checking: boolean
|
||||
@@ -169,6 +184,7 @@ function IdleView({
|
||||
status: DesktopUpdateStatus | null
|
||||
target: UpdateTarget
|
||||
updateAvailable: boolean
|
||||
version: DesktopVersionInfo | null
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const u = t.updates
|
||||
@@ -196,38 +212,50 @@ function IdleView({
|
||||
)
|
||||
}
|
||||
|
||||
if (!status.supported) {
|
||||
const details = version ? <VersionDetails version={version} /> : null
|
||||
|
||||
// App-installer check-unknown (OS checker unavailable): NOT "no updates"
|
||||
// and NOT a generic error — the OS also installs updates automatically on
|
||||
// restart (it re-reads the .appinstaller feed on every launch), so the
|
||||
// honest view names that path. Only this mechanism gets it.
|
||||
if (status.mechanism === 'app-installer' && status.error && !status.updateAvailable) {
|
||||
return (
|
||||
<CenteredStatus
|
||||
body={status.message ?? u.unsupportedMessage}
|
||||
icon={<AlertCircle className="size-6 text-muted-foreground" />}
|
||||
title={u.notAvailableTitle}
|
||||
/>
|
||||
<div className="grid gap-4 px-6 pb-6 pt-1 pr-8">
|
||||
<VersionHero
|
||||
renderHeading={heading => (
|
||||
<DialogTitle className="text-lg font-semibold tracking-tight">{heading}</DialogTitle>
|
||||
)}
|
||||
version={version}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<AlertCircle className="size-6 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">{u.checkUnknownTitleAppInstaller}</p>
|
||||
<p className="max-w-prose text-sm leading-5 text-muted-foreground">{u.checkUnknownBodyAppInstaller}</p>
|
||||
</div>
|
||||
<Button onClick={onRetryCheck} size="sm">
|
||||
{u.tryAgain}
|
||||
</Button>
|
||||
{details}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status.error) {
|
||||
// Everything that is NOT the install pitch — unsupported, check error, and
|
||||
// already-latest — is exactly the About page's state: render the shared
|
||||
// hero + status card so the two surfaces cannot drift. The card owns the
|
||||
// check/retry actions (its "Check now" covers the old Try-again button).
|
||||
if (!status.supported || status.error || !updateAvailable) {
|
||||
return (
|
||||
<CenteredStatus
|
||||
action={
|
||||
<Button disabled={checking} onClick={onRetryCheck} size="sm">
|
||||
{u.tryAgain}
|
||||
</Button>
|
||||
}
|
||||
body={u.connectionRetry}
|
||||
icon={<ErrorIcon />}
|
||||
title={u.checkFailedTitle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!updateAvailable) {
|
||||
return (
|
||||
<CenteredStatus
|
||||
body={target === 'backend' ? u.latestBodyBackend : u.latestBody}
|
||||
icon={<BrandMark className="size-12" />}
|
||||
title={u.allSetTitle}
|
||||
/>
|
||||
<div className="grid gap-4 px-6 pb-6 pt-1 pr-8">
|
||||
<VersionHero
|
||||
renderHeading={heading => (
|
||||
<DialogTitle className="text-lg font-semibold tracking-tight">{heading}</DialogTitle>
|
||||
)}
|
||||
version={version}
|
||||
/>
|
||||
<UpdateStatusCard target={target} />
|
||||
{details}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -238,8 +266,16 @@ function IdleView({
|
||||
// Name what's being updated. In remote mode the overlay acts on the connected
|
||||
// backend, not the local client — say so. When there are no commit rows to
|
||||
// show (e.g. pip/non-git backend), degrade to honest "no release notes" copy
|
||||
// instead of generic filler.
|
||||
const { title, body } = resolveUpdateCopy({ target, shownItems, copy: u })
|
||||
// instead of generic filler. On a release-feed channel (stable), name the
|
||||
// release tag instead of commit vocabulary.
|
||||
const { title, body } = resolveUpdateCopy({
|
||||
target,
|
||||
shownItems,
|
||||
channel: status?.channel === 'stable' ? 'stable' : 'main',
|
||||
latestTag: status?.latestTag ?? null,
|
||||
mechanism: status?.mechanism,
|
||||
copy: u
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
|
||||
@@ -382,11 +418,24 @@ function GuiSkewView({ message, onDone }: { message?: string; onDone: () => void
|
||||
)
|
||||
}
|
||||
|
||||
function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend: boolean }) {
|
||||
function ApplyingView({
|
||||
apply,
|
||||
isBackend,
|
||||
statusMechanism
|
||||
}: {
|
||||
apply: UpdateApplyState
|
||||
isBackend: boolean
|
||||
statusMechanism?: UpdaterMechanismClient
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const u = t.updates
|
||||
const label = u.stages[apply.stage as DesktopUpdateStage] ?? u.stages.idle
|
||||
const body = isBackend ? u.applyingBodyBackend : u.applyingBody
|
||||
const isAppInstaller = statusMechanism === 'app-installer'
|
||||
const body = isAppInstaller
|
||||
? u.applyingBodyAppInstaller
|
||||
: isBackend
|
||||
? u.applyingBodyBackend
|
||||
: u.applyingBody
|
||||
const currentMessage = apply.message.trim()
|
||||
const recentLog = apply.log.slice(-4)
|
||||
|
||||
|
||||
@@ -29,7 +29,23 @@ function stubDesktop(config: Record<string, unknown>, overrides: Record<string,
|
||||
const original = window.hermesDesktop
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { getRecentLogs: async () => ({ lines: [] }), getConnectionConfig: async () => config, ...overrides }
|
||||
value: {
|
||||
getRecentLogs: async () => ({ lines: [] }),
|
||||
getConnectionConfig: async () => config,
|
||||
getBootstrapState: async () => ({
|
||||
active: false,
|
||||
manifest: null,
|
||||
stages: {},
|
||||
error: null,
|
||||
log: [],
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
setupChoice: null,
|
||||
unsupportedPlatform: null,
|
||||
bundled: false
|
||||
}),
|
||||
...overrides
|
||||
}
|
||||
})
|
||||
|
||||
return () => Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: original })
|
||||
@@ -227,4 +243,42 @@ describe('BootFailureOverlay', () => {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('swaps Repair for "Reinstall the app" on a bundled install', async () => {
|
||||
const openExternal = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const restore = stubDesktop(
|
||||
{ mode: 'local' },
|
||||
{
|
||||
getBootstrapState: async () => ({
|
||||
active: false,
|
||||
manifest: null,
|
||||
stages: {},
|
||||
error: null,
|
||||
log: [],
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
setupChoice: null,
|
||||
unsupportedPlatform: null,
|
||||
bundled: true
|
||||
}),
|
||||
openExternal
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
render(<BootFailureOverlay />)
|
||||
|
||||
// The bundled artifact has no installer to repair with — the action is
|
||||
// a docs link, and the hint says reinstall instead of re-run installer.
|
||||
expect(await screen.findByRole('button', { name: /reinstall the app/i })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: /repair install/i })).toBeNull()
|
||||
expect(screen.getByText(/reinstall the app to restore/i)).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /reinstall the app/i }))
|
||||
await waitFor(() => expect(openExternal).toHaveBeenCalledWith('https://hermes-agent.nousresearch.com/docs/user-guide/desktop'))
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { Loader } from '@/components/ui/loader'
|
||||
import { LogView } from '@/components/ui/log-view'
|
||||
import type { DesktopConnectionConfig } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { DESKTOP_DOCS_URL } from '@/lib/docs'
|
||||
import { openExternalLink } from '@/lib/external-link'
|
||||
import { ChevronLeft, ExternalLink, FileText, Loader2, LogIn, RefreshCw, SlidersHorizontal, Wrench } from '@/lib/icons'
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
@@ -55,6 +56,10 @@ export function BootFailureOverlay() {
|
||||
// A remote/cloud backend that failed to boot is fixable from gateway settings,
|
||||
// so the escape hatch earns emphasis (local failures keep it as a quiet ghost).
|
||||
const [remoteFailure, setRemoteFailure] = useState(false)
|
||||
// A bundled install (payload ships in-app) has no installer to repair with —
|
||||
// the only recovery is reinstalling the app. Read from the bootstrap state
|
||||
// snapshot so the Repair affordance is replaced by "Reinstall the app".
|
||||
const [bundled, setBundled] = useState(false)
|
||||
// Swap the card body to the embedded Gateway settings panel in place of routing
|
||||
// to the full Settings page (keeps the user on the recovery surface, no z-index
|
||||
// juggling, no second connection form to maintain).
|
||||
@@ -77,6 +82,31 @@ export function BootFailureOverlay() {
|
||||
.catch(() => undefined)
|
||||
}, [boot.error, visible])
|
||||
|
||||
// Bundled installs carry their runtime as an immutable payload — repair
|
||||
// would re-run an installer that must never fire for them. Resolve the
|
||||
// artifact kind from the bootstrap snapshot (set by main.ts on setup-choice
|
||||
// broadcast) so the recovery actions swap Repair → "Reinstall the app".
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
void window.hermesDesktop
|
||||
?.getBootstrapState()
|
||||
.then(snapshot => {
|
||||
if (!cancelled && snapshot) {
|
||||
setBundled(snapshot.bundled)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [boot.error, visible])
|
||||
|
||||
// Resolve whether this boot failure is a remote-gateway reauth so we can
|
||||
// offer the actionable "Sign in" path instead of the local-only recovery
|
||||
// buttons. Runs whenever the overlay becomes visible.
|
||||
@@ -221,7 +251,9 @@ export function BootFailureOverlay() {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: t.boot.failure.signInIncompleteTitle,
|
||||
message: t.boot.failure.signInIncompleteMessage
|
||||
message: result?.error
|
||||
? `${t.boot.failure.signInIncompleteMessage}: ${result.error}`
|
||||
: t.boot.failure.signInIncompleteMessage
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, t.boot.failure.signInFailed)
|
||||
@@ -329,19 +361,29 @@ export function BootFailureOverlay() {
|
||||
} else {
|
||||
// Local failure: Use-local is redundant with Retry (both re-target local), so
|
||||
// it's dropped here; keep it for remote failures where it's the fall-back.
|
||||
// On a bundled install the Repair affordance is replaced by "Reinstall the
|
||||
// app" — the payload is immutable, so there is no installer to re-run.
|
||||
actions = [
|
||||
retryAction,
|
||||
{
|
||||
key: 'repair',
|
||||
label: copy.repairInstall,
|
||||
onClick: () => void repair(),
|
||||
icon: <Wrench />,
|
||||
variant: 'secondary',
|
||||
busy: 'repair'
|
||||
},
|
||||
bundled
|
||||
? {
|
||||
key: 'reinstall',
|
||||
label: copy.reinstallApp,
|
||||
onClick: () => openExternalLink(DESKTOP_DOCS_URL),
|
||||
icon: <ExternalLink />,
|
||||
variant: 'secondary'
|
||||
}
|
||||
: {
|
||||
key: 'repair',
|
||||
label: copy.repairInstall,
|
||||
onClick: () => void repair(),
|
||||
icon: <Wrench />,
|
||||
variant: 'secondary',
|
||||
busy: 'repair'
|
||||
},
|
||||
{ ...settingsAction, variant: 'ghost' }
|
||||
]
|
||||
hint = copy.repairHint
|
||||
hint = bundled ? copy.bundledReinstallHint : copy.repairHint
|
||||
}
|
||||
|
||||
if (view === 'connect') {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { localCardPresentation } from './desktop-install-local-card'
|
||||
|
||||
test('none is the installer offer with the install-to footer', () => {
|
||||
assert.deepEqual(localCardPresentation('none'), {
|
||||
title: 'installLocalTitle',
|
||||
desc: 'installLocalDesc',
|
||||
disabled: false,
|
||||
showInstallTo: true
|
||||
})
|
||||
})
|
||||
|
||||
test('installed uses the existing-runtime copy and hides the install-to footer', () => {
|
||||
assert.deepEqual(localCardPresentation('installed'), {
|
||||
title: 'useLocalTitle',
|
||||
desc: 'useLocalDesc',
|
||||
disabled: false,
|
||||
showInstallTo: false
|
||||
})
|
||||
})
|
||||
|
||||
test('bundled uses the bundled flavor of the existing-runtime copy', () => {
|
||||
assert.deepEqual(localCardPresentation('bundled'), {
|
||||
title: 'useLocalTitle',
|
||||
desc: 'bundledLocalDesc',
|
||||
disabled: false,
|
||||
showInstallTo: false
|
||||
})
|
||||
})
|
||||
|
||||
test('bundled-damaged is disabled and never shows the install-to footer', () => {
|
||||
assert.deepEqual(localCardPresentation('bundled-damaged'), {
|
||||
title: 'bundledDamagedTitle',
|
||||
desc: 'bundledDamagedDesc',
|
||||
disabled: true,
|
||||
showInstallTo: false
|
||||
})
|
||||
})
|
||||
|
||||
test('an absent local field falls back to the installer offer (old backends)', () => {
|
||||
assert.equal(localCardPresentation(undefined).title, 'installLocalTitle')
|
||||
assert.equal(localCardPresentation(undefined).disabled, false)
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* desktop-install-local-card.ts
|
||||
*
|
||||
* Pure presentation derivation for the setup screen's local card. The card
|
||||
* has four states, keyed off the `local` field the backend stamps on every
|
||||
* resolved backend:
|
||||
*
|
||||
* none — no Hermes on this machine: the card IS an install offer.
|
||||
* installed — a runtime resolved (PATH hermes, active root, …): clicking
|
||||
* starts the existing runtime; nothing downloads.
|
||||
* bundled — a healthy bundled install: the payload is the runtime.
|
||||
* bundled-damaged— a bundled install whose payload failed to resolve: the
|
||||
* card is disabled (never an install action); reinstall the
|
||||
* app instead.
|
||||
*
|
||||
* Extracted so the disabled/title/desc/footer logic is unit-testable without
|
||||
* rendering the overlay.
|
||||
*/
|
||||
|
||||
export type LocalCardState = 'none' | 'installed' | 'bundled' | 'bundled-damaged'
|
||||
|
||||
export interface LocalCardPresentation {
|
||||
/** i18n key into `t.install` for the card title. */
|
||||
title: 'installLocalTitle' | 'useLocalTitle' | 'bundledDamagedTitle'
|
||||
/** i18n key into `t.install` for the card body. */
|
||||
desc: 'installLocalDesc' | 'useLocalDesc' | 'bundledLocalDesc' | 'bundledDamagedDesc'
|
||||
/** When true the card cannot be clicked — there is no install to fire. */
|
||||
disabled: boolean
|
||||
/** Whether the "Will install to <root>" footer is accurate for this state. */
|
||||
showInstallTo: boolean
|
||||
}
|
||||
|
||||
export function localCardPresentation(local: LocalCardState | undefined): LocalCardPresentation {
|
||||
switch (local) {
|
||||
case 'installed':
|
||||
return {
|
||||
title: 'useLocalTitle',
|
||||
desc: 'useLocalDesc',
|
||||
disabled: false,
|
||||
showInstallTo: false
|
||||
}
|
||||
|
||||
case 'bundled':
|
||||
return {
|
||||
title: 'useLocalTitle',
|
||||
desc: 'bundledLocalDesc',
|
||||
disabled: false,
|
||||
showInstallTo: false
|
||||
}
|
||||
|
||||
case 'bundled-damaged':
|
||||
return {
|
||||
title: 'bundledDamagedTitle',
|
||||
desc: 'bundledDamagedDesc',
|
||||
disabled: true,
|
||||
showInstallTo: false
|
||||
}
|
||||
|
||||
case 'none':
|
||||
|
||||
default:
|
||||
return {
|
||||
title: 'installLocalTitle',
|
||||
desc: 'installLocalDesc',
|
||||
disabled: false,
|
||||
showInstallTo: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ function bootstrapState(overrides: Partial<DesktopBootstrapState> = {}): Desktop
|
||||
completedAt: null,
|
||||
setupChoice: null,
|
||||
unsupportedPlatform: null,
|
||||
bundled: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
@@ -93,7 +94,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
|
||||
it('shows the remote/local choice without installer progress', async () => {
|
||||
installDesktopMock(
|
||||
bootstrapState({
|
||||
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
|
||||
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent', local: 'none', bundled: false }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -102,6 +103,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
|
||||
expect(await screen.findByText('Set up Hermes Desktop')).toBeTruthy()
|
||||
expect(screen.getByText('Connect to existing Hermes')).toBeTruthy()
|
||||
expect(screen.getByText('Install Hermes locally')).toBeTruthy()
|
||||
expect(screen.getByText(/Will install to/i)).toBeTruthy()
|
||||
expect(screen.queryByText(/steps complete/i)).toBeNull()
|
||||
expect(screen.queryByText(/Fetching installer manifest/i)).toBeNull()
|
||||
})
|
||||
@@ -109,7 +111,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
|
||||
it('continues local bootstrap only when Install Hermes locally is selected', async () => {
|
||||
const desktop = installDesktopMock(
|
||||
bootstrapState({
|
||||
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
|
||||
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent', local: 'none', bundled: false }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -131,7 +133,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
|
||||
it('surfaces a recoverable error when the local-bootstrap bridge is unavailable', async () => {
|
||||
const desktop = installDesktopMock(
|
||||
bootstrapState({
|
||||
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
|
||||
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent', local: 'none', bundled: false }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -150,7 +152,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
|
||||
it('keeps the local-start error when the first snapshot commits under the click', async () => {
|
||||
const desktop = installDesktopMock(
|
||||
bootstrapState({
|
||||
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
|
||||
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent', local: 'none', bundled: false }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -173,7 +175,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
|
||||
it('clears a stale local-start error when a repair presents a different root', async () => {
|
||||
const desktop = installDesktopMock(
|
||||
bootstrapState({
|
||||
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
|
||||
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent', local: 'none', bundled: false }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -200,7 +202,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
|
||||
it('opens the remote connection form from the first-run choice', async () => {
|
||||
installDesktopMock(
|
||||
bootstrapState({
|
||||
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
|
||||
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent', local: 'none', bundled: false }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -216,7 +218,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
|
||||
it('returns from the remote connection form to the first-run choice', async () => {
|
||||
installDesktopMock(
|
||||
bootstrapState({
|
||||
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
|
||||
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent', local: 'none', bundled: false }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -234,7 +236,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
|
||||
it('requires a successful token connection test before applying remote config', async () => {
|
||||
const desktop = installDesktopMock(
|
||||
bootstrapState({
|
||||
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
|
||||
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent', local: 'none', bundled: false }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -304,7 +306,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
|
||||
it('ignores a completed probe after the gateway URL becomes invalid', async () => {
|
||||
const desktop = installDesktopMock(
|
||||
bootstrapState({
|
||||
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
|
||||
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent', local: 'none', bundled: false }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -348,7 +350,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
|
||||
it('does not enable Apply when credentials change during a connection test', async () => {
|
||||
const desktop = installDesktopMock(
|
||||
bootstrapState({
|
||||
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
|
||||
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent', local: 'none', bundled: false }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -401,7 +403,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
|
||||
it('restores remote apply controls when applying the tested connection fails', async () => {
|
||||
const desktop = installDesktopMock(
|
||||
bootstrapState({
|
||||
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
|
||||
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent', local: 'none', bundled: false }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -448,7 +450,7 @@ describe('DesktopInstallOverlay first-run setup', () => {
|
||||
it('signs in, tests, and applies a password-style remote gateway', async () => {
|
||||
const desktop = installDesktopMock(
|
||||
bootstrapState({
|
||||
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
|
||||
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent', local: 'none', bundled: false }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -574,3 +576,81 @@ describe('DesktopInstallOverlay first-run setup', () => {
|
||||
expect(screen.queryByText('Hermes needs a one-time install')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DesktopInstallOverlay bundled / already-installed cards', () => {
|
||||
it('shows the use-existing card for an installed runtime and hides the install-to footer', async () => {
|
||||
installDesktopMock(
|
||||
bootstrapState({
|
||||
setupChoice: {
|
||||
platform: 'win32',
|
||||
activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent',
|
||||
local: 'installed',
|
||||
bundled: false
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
render(<DesktopInstallOverlay />)
|
||||
|
||||
expect(await screen.findByText('Use Hermes on this computer')).toBeTruthy()
|
||||
expect(screen.getByText(/already installed here/i)).toBeTruthy()
|
||||
expect(screen.queryByText(/Will install to/i)).toBeNull()
|
||||
expect(screen.queryByText('Install Hermes locally')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the bundled flavor for a healthy bundled install', async () => {
|
||||
installDesktopMock(
|
||||
bootstrapState({
|
||||
setupChoice: {
|
||||
platform: 'win32',
|
||||
activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent',
|
||||
local: 'bundled',
|
||||
bundled: true
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
render(<DesktopInstallOverlay />)
|
||||
|
||||
expect(await screen.findByText('Use Hermes on this computer')).toBeTruthy()
|
||||
expect(screen.getByText(/included with this app/i)).toBeTruthy()
|
||||
expect(screen.queryByText(/Will install to/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('disables the local card for a damaged bundled payload — never an install action', async () => {
|
||||
const desktop = installDesktopMock(
|
||||
bootstrapState({
|
||||
setupChoice: {
|
||||
platform: 'win32',
|
||||
activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent',
|
||||
local: 'bundled-damaged',
|
||||
bundled: true
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
render(<DesktopInstallOverlay />)
|
||||
|
||||
expect(await screen.findByText('Use Hermes bundled with this app')).toBeTruthy()
|
||||
expect(screen.getByText(/missing or damaged/i)).toBeTruthy()
|
||||
|
||||
const localCard = screen.getByText('Use Hermes bundled with this app').closest('button')
|
||||
expect(localCard).toBeTruthy()
|
||||
expect(localCard?.getAttribute('aria-disabled')).toBe('true')
|
||||
|
||||
// Clicking the disabled card must never fire the local bootstrap bridge.
|
||||
fireEvent.click(screen.getByText('Use Hermes bundled with this app'))
|
||||
expect(desktop.continueBootstrapLocal).not.toHaveBeenCalled()
|
||||
|
||||
// The docs "Reinstall the app" affordance is present and opens the docs
|
||||
// site — not an installer.
|
||||
const reinstall = screen.getByText('Reinstall the app')
|
||||
expect(reinstall).toBeTruthy()
|
||||
fireEvent.click(reinstall)
|
||||
expect(desktop.openExternal).toHaveBeenCalledWith('https://hermes-agent.nousresearch.com/docs/user-guide/desktop')
|
||||
|
||||
// The remote card stays clickable — connecting remotely is the working path.
|
||||
fireEvent.click(screen.getByText('Connect to existing Hermes'))
|
||||
expect(await screen.findByText('Gateway URL')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -15,10 +15,12 @@ import type {
|
||||
DesktopBootstrapState
|
||||
} from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { DESKTOP_DOCS_URL } from '@/lib/docs'
|
||||
import { AlertCircle, ChevronDown, ChevronRight, Globe, iconSize, Loader2, Monitor } from '@/lib/icons'
|
||||
import { capitalize } from '@/lib/text'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { localCardPresentation } from './desktop-install-local-card'
|
||||
import { FirstRunRemoteForm } from './first-run-remote-form'
|
||||
|
||||
/**
|
||||
@@ -170,7 +172,8 @@ const EMPTY_STATE: DesktopBootstrapState = {
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
setupChoice: null,
|
||||
unsupportedPlatform: null
|
||||
unsupportedPlatform: null,
|
||||
bundled: false
|
||||
}
|
||||
|
||||
function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): DesktopBootstrapState {
|
||||
@@ -188,10 +191,13 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
|
||||
setupChoice: ev.active
|
||||
? {
|
||||
platform: ev.platform || state.setupChoice?.platform || 'unknown',
|
||||
activeRoot: ev.activeRoot || state.setupChoice?.activeRoot || ''
|
||||
activeRoot: ev.activeRoot || state.setupChoice?.activeRoot || '',
|
||||
local: ev.local || state.setupChoice?.local || 'none',
|
||||
bundled: Boolean(ev.bundled)
|
||||
}
|
||||
: null,
|
||||
unsupportedPlatform: null
|
||||
unsupportedPlatform: null,
|
||||
bundled: Boolean(ev.bundled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,6 +403,13 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
}
|
||||
|
||||
if (state.setupChoice) {
|
||||
// The local card's copy + behavior derive from what the backend found on
|
||||
// this machine: 'none' is an install offer, the rest say "use what's
|
||||
// already here" — and 'bundled-damaged' disables the card entirely
|
||||
// (there is no install to fire; reinstall the app instead).
|
||||
const localState = state.setupChoice.local
|
||||
const localPres = localCardPresentation(localState)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
|
||||
<div className="w-full max-w-2xl rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
|
||||
@@ -404,7 +417,9 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
<BrandMark className="size-11 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold tracking-tight">{copy.setupChoiceTitle}</h2>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{copy.setupChoiceDesc}</p>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">
|
||||
{localState === 'none' ? copy.setupChoiceDesc : copy.setupChoiceDescLocal}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -422,8 +437,9 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-4 text-left transition hover:bg-(--chrome-action-hover) disabled:cursor-wait disabled:opacity-60"
|
||||
disabled={localStarting}
|
||||
aria-disabled={localPres.disabled}
|
||||
className="rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-4 text-left transition hover:bg-(--chrome-action-hover) disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={localStarting || localPres.disabled}
|
||||
onClick={async () => {
|
||||
setLocalStart({ root: activeRoot, starting: true, error: null })
|
||||
|
||||
@@ -447,12 +463,25 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
) : (
|
||||
<Monitor className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
<span>{copy.installLocalTitle}</span>
|
||||
<span>{copy[localPres.title]}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-5 text-muted-foreground">{copy.installLocalDesc}</p>
|
||||
<p className="mt-2 text-sm leading-5 text-muted-foreground">{copy[localPres.desc]}</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{localPres.disabled ? (
|
||||
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<AlertCircle className="size-4 shrink-0" />
|
||||
<button
|
||||
className="text-(--ui-text-secondary) underline underline-offset-2 hover:text-foreground"
|
||||
onClick={() => window.hermesDesktop?.openExternal?.(DESKTOP_DOCS_URL)}
|
||||
type="button"
|
||||
>
|
||||
{copy.reinstallApp}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{localStartError ? (
|
||||
<div className="mt-4 flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
@@ -460,10 +489,12 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 text-xs text-muted-foreground">
|
||||
{copy.installTo}{' '}
|
||||
<code className="font-mono text-(--ui-text-secondary)">{state.setupChoice.activeRoot}</code>
|
||||
</div>
|
||||
{localPres.showInstallTo ? (
|
||||
<div className="mt-6 text-xs text-muted-foreground">
|
||||
{copy.installTo}{' '}
|
||||
<code className="font-mono text-(--ui-text-secondary)">{state.setupChoice.activeRoot}</code>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ExternalOpenFailedDialog } from './external-open-failed-dialog'
|
||||
|
||||
const windowsMock = vi.hoisted(() => ({
|
||||
isHudWindow: vi.fn(() => false),
|
||||
isBrowserWindow: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/windows', () => windowsMock)
|
||||
|
||||
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
|
||||
const initialHermesDesktop = desktopWindow.hermesDesktop
|
||||
|
||||
function installBridge() {
|
||||
const onExternalOpenFailed = vi.fn()
|
||||
const writeClipboard = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
desktopWindow.hermesDesktop = {
|
||||
onExternalOpenFailed,
|
||||
writeClipboard
|
||||
} as unknown as Window['hermesDesktop']
|
||||
|
||||
return { onExternalOpenFailed, writeClipboard }
|
||||
}
|
||||
|
||||
function fail(listener: unknown, url: string, message?: string) {
|
||||
act(() => {
|
||||
;(listener as (payload: { url: string; message?: string }) => void)({ url, message })
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
windowsMock.isHudWindow.mockReturnValue(false)
|
||||
windowsMock.isBrowserWindow.mockReturnValue(false)
|
||||
vi.restoreAllMocks()
|
||||
cleanup()
|
||||
|
||||
if (initialHermesDesktop) {
|
||||
desktopWindow.hermesDesktop = initialHermesDesktop
|
||||
} else {
|
||||
delete desktopWindow.hermesDesktop
|
||||
}
|
||||
})
|
||||
|
||||
describe('ExternalOpenFailedDialog', () => {
|
||||
it('subscribes to open-failure events on mount', () => {
|
||||
const { onExternalOpenFailed } = installBridge()
|
||||
render(<ExternalOpenFailedDialog />)
|
||||
expect(onExternalOpenFailed).toHaveBeenCalledTimes(1)
|
||||
expect(typeof onExternalOpenFailed.mock.calls[0][0]).toBe('function')
|
||||
})
|
||||
|
||||
it('shows the URL when an open-failure event fires', () => {
|
||||
const { onExternalOpenFailed } = installBridge()
|
||||
render(<ExternalOpenFailedDialog />)
|
||||
|
||||
const listener = onExternalOpenFailed.mock.calls[0][0]
|
||||
expect(screen.queryByText('https://example.com/dead')).toBeNull()
|
||||
fail(listener, 'https://example.com/dead')
|
||||
expect(screen.getByText('https://example.com/dead')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('copies the URL when the copy action is used', async () => {
|
||||
const { onExternalOpenFailed, writeClipboard } = installBridge()
|
||||
render(<ExternalOpenFailedDialog />)
|
||||
|
||||
const listener = onExternalOpenFailed.mock.calls[0][0]
|
||||
fail(listener, 'https://example.com/dead')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /copy/i }))
|
||||
|
||||
await waitFor(() => expect(writeClipboard).toHaveBeenCalledWith('https://example.com/dead'))
|
||||
})
|
||||
|
||||
it('closes when dismissed', async () => {
|
||||
const { onExternalOpenFailed } = installBridge()
|
||||
render(<ExternalOpenFailedDialog />)
|
||||
|
||||
const listener = onExternalOpenFailed.mock.calls[0][0]
|
||||
fail(listener, 'https://example.com/dead')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('https://example.com/dead')).toBeNull())
|
||||
})
|
||||
|
||||
it('renders nothing in a HUD window', () => {
|
||||
installBridge()
|
||||
windowsMock.isHudWindow.mockReturnValue(true)
|
||||
|
||||
const view = render(<ExternalOpenFailedDialog />)
|
||||
|
||||
expect(view.container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import type { ExternalOpenFailedPayload } from '@/global.d'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { isBrowserWindow, isHudWindow } from '@/store/windows'
|
||||
|
||||
// Global fallback for a failed external URL open. main's openExternalUrl
|
||||
// reports every `shell.openExternal` rejection over a dedicated IPC event
|
||||
// (e.g. no https handler registered on Linux — a dead click otherwise). The
|
||||
// dialog shows the URL so the user can copy it and open it manually.
|
||||
export function ExternalOpenFailedDialog() {
|
||||
const { t } = useI18n()
|
||||
const [failure, setFailure] = useState<ExternalOpenFailedPayload | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.hermesDesktop?.onExternalOpenFailed) {return}
|
||||
|
||||
return window.hermesDesktop.onExternalOpenFailed(setFailure)
|
||||
}, [])
|
||||
|
||||
if (isHudWindow() || isBrowserWindow()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const copy = t.externalOpenFailed
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={open => (!open ? setFailure(null) : undefined)} open={Boolean(failure)}>
|
||||
<DialogContent className="max-w-[30rem]" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.title}</DialogTitle>
|
||||
<DialogDescription>{copy.message}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-40 overflow-auto break-all rounded-md border bg-muted p-3 select-all">
|
||||
{failure?.url}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{failure ? <CopyButton text={failure.url}>{copy.copyUrl}</CopyButton> : null}
|
||||
<Button onClick={() => setFailure(null)} variant="ghost">
|
||||
{copy.close}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -138,7 +138,7 @@ export function FirstRunRemoteForm({ onBack }: FirstRunRemoteFormProps) {
|
||||
setOauthConnected(Boolean(result.connected))
|
||||
|
||||
if (!result.connected) {
|
||||
setError(copy.signInIncomplete)
|
||||
setError(result.error ? `${copy.signInIncomplete}: ${result.error}` : copy.signInIncomplete)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { DesktopUpdateStatus } from '@/global'
|
||||
import { en } from '@/i18n/en'
|
||||
import type { UpdateApplyState } from '@/store/updates'
|
||||
|
||||
import { deriveUpdateStatus } from './update-status'
|
||||
|
||||
const IDLE_APPLY: UpdateApplyState = {
|
||||
applying: false,
|
||||
stage: 'idle',
|
||||
message: '',
|
||||
percent: null,
|
||||
error: null,
|
||||
command: null,
|
||||
log: []
|
||||
}
|
||||
|
||||
function derive(status: DesktopUpdateStatus | null, apply: UpdateApplyState = IDLE_APPLY, checking = false) {
|
||||
return deriveUpdateStatus({ apply, checking, status, target: 'client', u: en.updates })
|
||||
}
|
||||
|
||||
describe('deriveUpdateStatus', () => {
|
||||
it('never-checked: idle tone, invites a check', () => {
|
||||
const view = derive(null)
|
||||
|
||||
expect(view.tone).toBe('idle')
|
||||
expect(view.updateAvailable).toBe(false)
|
||||
expect(view.line).toBe(en.updates.tapCheck)
|
||||
})
|
||||
|
||||
it('unsupported wins over everything else and keeps the backend message', () => {
|
||||
const view = derive({ supported: false, message: 'managed install', behind: 5, updateAvailable: true })
|
||||
|
||||
expect(view.tone).toBe('unsupported')
|
||||
expect(view.line).toBe('managed install')
|
||||
expect(view.supported).toBe(false)
|
||||
})
|
||||
|
||||
it('check error surfaces the tone and the transport message separately', () => {
|
||||
const view = derive({ supported: true, error: 'check-failed', message: 'ECONNREFUSED' })
|
||||
|
||||
expect(view.tone).toBe('error')
|
||||
expect(view.line).toBe(en.updates.cantReach)
|
||||
expect(view.error).toBe('ECONNREFUSED')
|
||||
})
|
||||
|
||||
it('applying beats available so the card cannot offer a second install', () => {
|
||||
const view = derive({ supported: true, behind: 3 }, { ...IDLE_APPLY, applying: true, stage: 'update' })
|
||||
|
||||
expect(view.applying).toBe(true)
|
||||
expect(view.tone).toBe('available')
|
||||
expect(view.line).toBe(en.updates.installing)
|
||||
})
|
||||
|
||||
it('restart stage counts as applying even when the applying flag already dropped', () => {
|
||||
const view = derive({ supported: true }, { ...IDLE_APPLY, applying: false, stage: 'restart' })
|
||||
|
||||
expect(view.applying).toBe(true)
|
||||
})
|
||||
|
||||
it('behind count renders counted copy; count-free updateAvailable falls back', () => {
|
||||
expect(derive({ supported: true, behind: 4 }).line).toBe(en.updates.updateReady(4))
|
||||
|
||||
// Shallow clone: exact count unknowable, flagged via updateAvailable.
|
||||
const unknown = derive({ supported: true, behind: 0, updateAvailable: true })
|
||||
|
||||
expect(unknown.line).toBe(en.updates.updateReadyUnknown)
|
||||
expect(unknown.updateAvailable).toBe(true)
|
||||
})
|
||||
|
||||
it('up to date: idle tone with the latest-version line', () => {
|
||||
const view = derive({ supported: true, behind: 0 })
|
||||
|
||||
expect(view.tone).toBe('idle')
|
||||
expect(view.updateAvailable).toBe(false)
|
||||
expect(view.line).toBe(en.updates.latestBody)
|
||||
})
|
||||
|
||||
it('backend target says the backend is current, not "you"', () => {
|
||||
const view = deriveUpdateStatus({
|
||||
apply: IDLE_APPLY,
|
||||
checking: false,
|
||||
status: { supported: true, behind: 0 },
|
||||
target: 'backend',
|
||||
u: en.updates
|
||||
})
|
||||
|
||||
expect(view.line).toBe(en.updates.latestBodyBackend)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
|
||||
import { BrandMark } from '@/components/brand-mark'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import type { DesktopUpdateStatus, DesktopVersionInfo } from '@/global'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertTriangle, CheckCircle2, ExternalLink, Loader2, RefreshCw } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$backendUpdateApply,
|
||||
$backendUpdateChecking,
|
||||
$backendUpdateStatus,
|
||||
$updateApply,
|
||||
$updateChecking,
|
||||
$updateStatus,
|
||||
checkBackendUpdates,
|
||||
checkUpdates,
|
||||
openUpdateOverlayFor,
|
||||
startActiveUpdate,
|
||||
type UpdateApplyState,
|
||||
type UpdateTarget
|
||||
} from '@/store/updates'
|
||||
|
||||
const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases'
|
||||
const INSTALLER_URL = 'https://hermes-agent.nousresearch.com/'
|
||||
|
||||
export type UpdateStatusTone = 'idle' | 'available' | 'error' | 'unsupported'
|
||||
|
||||
export interface UpdateStatusView {
|
||||
line: string
|
||||
tone: UpdateStatusTone
|
||||
error?: string
|
||||
updateAvailable: boolean
|
||||
applying: boolean
|
||||
supported: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* One status derivation for every "am I up to date?" surface (About page,
|
||||
* updates overlay). Pure so the tone/copy contract is unit-testable.
|
||||
*/
|
||||
export function deriveUpdateStatus({
|
||||
apply,
|
||||
checking,
|
||||
status,
|
||||
target,
|
||||
u
|
||||
}: {
|
||||
apply: UpdateApplyState
|
||||
checking: boolean
|
||||
status: DesktopUpdateStatus | null
|
||||
target: UpdateTarget
|
||||
u: Translations['updates']
|
||||
}): UpdateStatusView {
|
||||
const behind = status?.behind ?? 0
|
||||
// behind is null when the exact count is unknowable (shallow clone): the
|
||||
// backend flags that case via updateAvailable instead of a number.
|
||||
const updateAvailable = behind > 0 || Boolean(status?.updateAvailable)
|
||||
const supported = status?.supported !== false
|
||||
const applying = apply.applying || apply.stage === 'restart'
|
||||
|
||||
if (!supported) {
|
||||
return { applying, line: status?.message ?? u.unsupportedMessage, supported, tone: 'unsupported', updateAvailable }
|
||||
}
|
||||
|
||||
if (status?.error) {
|
||||
return {
|
||||
applying,
|
||||
error: status.message,
|
||||
line: u.cantReach,
|
||||
supported,
|
||||
tone: 'error',
|
||||
updateAvailable
|
||||
}
|
||||
}
|
||||
|
||||
if (applying) {
|
||||
return { applying, line: u.installing, supported, tone: 'available', updateAvailable }
|
||||
}
|
||||
|
||||
if (updateAvailable) {
|
||||
return {
|
||||
applying,
|
||||
line: behind > 0 ? u.updateReady(behind) : u.updateReadyUnknown,
|
||||
supported,
|
||||
tone: 'available',
|
||||
updateAvailable
|
||||
}
|
||||
}
|
||||
|
||||
if (status) {
|
||||
return {
|
||||
applying,
|
||||
line: target === 'backend' ? u.latestBodyBackend : u.latestBody,
|
||||
supported,
|
||||
tone: 'idle',
|
||||
updateAvailable
|
||||
}
|
||||
}
|
||||
|
||||
return { applying, line: checking ? u.checking : u.tapCheck, supported, tone: 'idle', updateAvailable }
|
||||
}
|
||||
|
||||
function relativeTime(ms: number | undefined, u: Translations['updates']): string {
|
||||
if (!ms) {
|
||||
return u.never
|
||||
}
|
||||
|
||||
const diff = Date.now() - ms
|
||||
|
||||
if (diff < 60_000) {
|
||||
return u.justNow
|
||||
}
|
||||
|
||||
if (diff < 3_600_000) {
|
||||
return u.minAgo(Math.round(diff / 60_000))
|
||||
}
|
||||
|
||||
if (diff < 86_400_000) {
|
||||
return u.hoursAgo(Math.round(diff / 3_600_000))
|
||||
}
|
||||
|
||||
return u.daysAgo(Math.round(diff / 86_400_000))
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Hermes Desktop / version / brand mark" hero shared by the About page
|
||||
* and the updates overlay, including the bundle-out-of-sync warning. The
|
||||
* heading render is injectable so a dialog surface can emit a DialogTitle
|
||||
* for its accessible name while About keeps a plain h2.
|
||||
*/
|
||||
export function VersionHero({
|
||||
renderHeading,
|
||||
version
|
||||
}: {
|
||||
renderHeading?: (heading: string) => ReactNode
|
||||
version: DesktopVersionInfo | null
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const u = t.updates
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 pt-6 pb-2 text-center">
|
||||
<BrandMark className="size-16" />
|
||||
<div>
|
||||
{renderHeading ? (
|
||||
renderHeading(u.appName)
|
||||
) : (
|
||||
<h2 className="text-lg font-semibold tracking-tight">{u.appName}</h2>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{version?.appVersion ? u.version(version.appVersion) : u.versionUnavailable}
|
||||
</p>
|
||||
</div>
|
||||
{version?.bundleOutOfSync && (
|
||||
<div className="mx-auto w-full max-w-2xl rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-left text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{u.bundleOutOfSync}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{u.bundleOutOfSyncDesc}</p>
|
||||
<Button asChild className="mt-2" size="sm" variant="textStrong">
|
||||
<a
|
||||
href={INSTALLER_URL}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
void window.hermesDesktop?.openExternal?.(INSTALLER_URL)
|
||||
}}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
{u.bundleOutOfSyncAction}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bordered update-state card (status line, last-checked age, check /
|
||||
* update / release-notes actions) in the About page's visual language.
|
||||
* Target-aware: 'client' reads the desktop self-update atoms, 'backend' the
|
||||
* remote backend's. The actual apply flow stays in the updates overlay —
|
||||
* "Update now" opens it and starts the install there.
|
||||
*/
|
||||
export function UpdateStatusCard({
|
||||
showReleaseNotes = true,
|
||||
target
|
||||
}: {
|
||||
showReleaseNotes?: boolean
|
||||
target: UpdateTarget
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const u = t.updates
|
||||
const isBackend = target === 'backend'
|
||||
const status = useStore(isBackend ? $backendUpdateStatus : $updateStatus)
|
||||
const checking = useStore(isBackend ? $backendUpdateChecking : $updateChecking)
|
||||
const apply = useStore(isBackend ? $backendUpdateApply : $updateApply)
|
||||
const [justChecked, setJustChecked] = useState(false)
|
||||
|
||||
const view = deriveUpdateStatus({ apply, checking, status, target, u })
|
||||
|
||||
const handleCheck = async (): Promise<void> => {
|
||||
setJustChecked(false)
|
||||
const next = await (isBackend ? checkBackendUpdates() : checkUpdates())
|
||||
setJustChecked(Boolean(next))
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border px-4 py-3 text-sm',
|
||||
view.tone === 'available' && 'border-primary/30 bg-primary/5 text-foreground',
|
||||
view.tone === 'error' && 'border-destructive/35 bg-destructive/5 text-destructive',
|
||||
(view.tone === 'idle' || view.tone === 'unsupported') && 'border-border/70 bg-muted/20 text-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{view.tone === 'available' ? (
|
||||
<Codicon className="mt-0.5 size-4 shrink-0 text-primary" name="cloud-download" size="1rem" />
|
||||
) : view.tone === 'error' || view.tone === 'unsupported' ? null : (
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{view.line}</p>
|
||||
{view.error && <p className="mt-1 text-xs text-muted-foreground">{view.error}</p>}
|
||||
{view.tone !== 'unsupported' && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{u.lastChecked(relativeTime(status?.fetchedAt, u))}
|
||||
{justChecked && !checking ? u.justNowSuffix : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view.tone !== 'unsupported' && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4">
|
||||
<Button
|
||||
disabled={checking || view.applying}
|
||||
onClick={() => void handleCheck()}
|
||||
size="sm"
|
||||
variant="textStrong"
|
||||
>
|
||||
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
{checking ? u.checkingShort : u.checkNow}
|
||||
</Button>
|
||||
|
||||
{view.updateAvailable && view.supported && !view.applying && (
|
||||
<>
|
||||
<Button onClick={() => startActiveUpdate()} size="sm">
|
||||
{u.updateNow}
|
||||
</Button>
|
||||
<Button onClick={() => openUpdateOverlayFor(target)} size="sm" variant="textStrong">
|
||||
{u.seeWhatsNew}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showReleaseNotes && (
|
||||
<Button asChild className="ml-auto" size="sm" variant="text">
|
||||
<a
|
||||
href={RELEASE_NOTES_URL}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
void window.hermesDesktop?.openExternal?.(RELEASE_NOTES_URL)
|
||||
}}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
{u.releaseNotes}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { DesktopVersionInfo } from '@/global'
|
||||
import { I18nProvider } from '@/i18n'
|
||||
|
||||
import { VersionDetails } from './version-details'
|
||||
|
||||
const baseVersion: DesktopVersionInfo = {
|
||||
appVersion: '0.19.0',
|
||||
electronVersion: '37.0.0',
|
||||
hermesRoot: '/tmp/hermes',
|
||||
nodeVersion: '22.0.0',
|
||||
platform: 'linux'
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('VersionDetails', () => {
|
||||
it('omits the branch suffix when no branch is present', () => {
|
||||
render(
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<VersionDetails version={{ ...baseVersion, source: 'ci', branch: null }} />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Build Origin')).toBeTruthy()
|
||||
expect(screen.getByText('CI')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows a literal branch named unknown inline', () => {
|
||||
render(
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<VersionDetails version={{ ...baseVersion, source: 'ci', branch: 'unknown' }} />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('CI (unknown)')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the Nix source and distribution from the stamp', () => {
|
||||
render(
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<VersionDetails version={{ ...baseVersion, source: 'nix', distribution: 'nix' }} />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Build Origin')).toBeTruthy()
|
||||
expect(screen.getAllByText('Nix')).toHaveLength(2)
|
||||
expect(screen.getByText('Distribution')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('distinguishes CI provenance from the Docker distribution', () => {
|
||||
render(
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<VersionDetails version={{ ...baseVersion, source: 'ci', distribution: 'docker' }} />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('CI')).toBeTruthy()
|
||||
expect(screen.getByText('Distribution')).toBeTruthy()
|
||||
expect(screen.getByText('Docker')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the runtime row for an embedded build running its payload', () => {
|
||||
render(
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<VersionDetails
|
||||
version={{
|
||||
...baseVersion,
|
||||
distribution: 'desktop-app',
|
||||
hermesRuntime: { type: 'embedded' }
|
||||
}}
|
||||
/>
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Runtime')).toBeTruthy()
|
||||
expect(screen.getByText('Embedded runtime')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the runtime source with its location when an external build runs a machine runtime', () => {
|
||||
render(
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<VersionDetails
|
||||
version={{
|
||||
...baseVersion,
|
||||
hermesRuntime: { type: 'external', source: { type: 'git', root: '/home/u/.hermes/hermes-agent' } }
|
||||
}}
|
||||
/>
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Runtime')).toBeTruthy()
|
||||
expect(screen.getByText('git (/home/u/.hermes/hermes-agent)')).toBeTruthy()
|
||||
expect(screen.queryByText('External (uses the machine runtime)')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the generic external label before the first backend spawn', () => {
|
||||
render(
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<VersionDetails version={{ ...baseVersion, hermesRuntime: { type: 'external' } }} />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Runtime')).toBeTruthy()
|
||||
expect(screen.getByText('External (uses the machine runtime)')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { DesktopVersionInfo, RuntimeSource } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ExternalLink } from '@/lib/external-link'
|
||||
|
||||
/**
|
||||
* Human label for an external build's runtime source: the resolution rung
|
||||
* plus the location it resolved from, when there is one.
|
||||
*/
|
||||
function runtimeSourceLabel(source: RuntimeSource): string {
|
||||
const where = 'root' in source && source.root ? source.root : 'command' in source ? source.command : null
|
||||
|
||||
return where ? `${source.type} (${where})` : source.type
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared build-provenance display. Reads from `$desktopVersion`
|
||||
* (populated from the build stamp / `hermes:version` IPC), so every
|
||||
* surface — the About settings page, the updates overlay — shows the
|
||||
* same version, branch, commit, distribution, runtime, and install id
|
||||
* from one source of truth.
|
||||
*/
|
||||
export function VersionDetails({ version }: { version: DesktopVersionInfo }) {
|
||||
const { t } = useI18n()
|
||||
const u = t.updates
|
||||
|
||||
const source =
|
||||
version.source === 'ci' ? 'CI' : version.source ? version.source[0].toUpperCase() + version.source.slice(1) : null
|
||||
|
||||
const distribution =
|
||||
version.distribution === 'nix'
|
||||
? 'Nix'
|
||||
: version.distribution === 'docker'
|
||||
? 'Docker'
|
||||
: version.distribution === 'desktop-app'
|
||||
? u.versionDetailsDistributionDesktop
|
||||
: null
|
||||
|
||||
const runtime =
|
||||
version.hermesRuntime?.type === 'embedded'
|
||||
? u.versionDetailsRuntimeEmbedded
|
||||
: version.hermesRuntime?.type === 'external' && version.hermesRuntime.source
|
||||
? runtimeSourceLabel(version.hermesRuntime.source)
|
||||
: version.hermesRuntime?.type === 'external'
|
||||
? u.versionDetailsRuntimeExternal
|
||||
: null
|
||||
|
||||
return (
|
||||
<dl className="grid gap-2 rounded-lg border border-border/70 bg-muted/20 px-3 py-3 text-sm">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-muted-foreground">{u.versionDetailsVersion}</dt>
|
||||
<dd>
|
||||
v{version.appVersion}
|
||||
{version.dirty && <span className="text-warning"> (!)</span>}
|
||||
</dd>
|
||||
</div>
|
||||
{version.commit && (
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-muted-foreground">{u.versionDetailsCommit}</dt>
|
||||
<dd className="break-all text-right">
|
||||
<ExternalLink
|
||||
className="break-all font-mono text-xs"
|
||||
href={`https://github.com/NousResearch/hermes-agent/commit/${version.commit}`}
|
||||
>
|
||||
{version.commit.slice(0, 14)}
|
||||
</ExternalLink>
|
||||
{version.dirty && <span className="text-warning"> {u.versionDetailsUncommittedChanges}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{source && (
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-muted-foreground">{u.versionDetailsBuildOrigin}</dt>
|
||||
<dd>
|
||||
{source}
|
||||
{version.branch && version.branch !== 'main' ? ` (${version.branch})` : ''}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{distribution && (
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-muted-foreground">{u.versionDetailsDistribution}</dt>
|
||||
<dd>{distribution}</dd>
|
||||
</div>
|
||||
)}
|
||||
{runtime && (
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-muted-foreground">{u.versionDetailsRuntime}</dt>
|
||||
<dd className="break-all text-right">{runtime}</dd>
|
||||
</div>
|
||||
)}
|
||||
{version.installId && (
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-muted-foreground">{u.versionDetailsInstallId}</dt>
|
||||
<dd className="break-all text-right font-mono text-xs">
|
||||
{version.installId} ({version.hermesRoot})
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
Vendored
+67
@@ -299,6 +299,7 @@ declare global {
|
||||
setDisableF12?: (blocked: boolean) => void
|
||||
setPreviewShortcutActive?: (active: boolean) => void
|
||||
openExternal: (url: string) => Promise<void>
|
||||
onExternalOpenFailed?: (callback: (payload: ExternalOpenFailedPayload) => void) => () => void
|
||||
openPreviewInBrowser?: (url: string) => Promise<void>
|
||||
fetchLinkTitle: (url: string) => Promise<string>
|
||||
/** A site's icon as a data URL, or '' when it has none we can read.
|
||||
@@ -326,6 +327,8 @@ declare global {
|
||||
message: string
|
||||
componentStack: string
|
||||
}) => void
|
||||
/** Append one raw line to desktop.log (fire-and-forget, notifyError path). */
|
||||
logLine?: (line: string) => void
|
||||
readDir: (path: string) => Promise<HermesReadDirResult>
|
||||
gitRoot?: (path: string) => Promise<string | null>
|
||||
// Reveal a path in the OS file manager (Finder / Explorer).
|
||||
@@ -558,8 +561,44 @@ export interface DesktopVersionInfo {
|
||||
bundleOutOfSync?: boolean
|
||||
/** Commits under apps/desktop/ the running bundle is missing (null unknown). */
|
||||
bundleCommitsBehind?: null | number
|
||||
/** Build provenance from the install stamp (empty for packaged builds with
|
||||
* no stamp / a bare `app.getVersion()` fallback). */
|
||||
baseVersion?: string
|
||||
branch?: string | null
|
||||
commit?: string | null
|
||||
distance?: number
|
||||
dirty?: boolean
|
||||
source?: 'build' | 'ci' | 'docker' | 'fallback' | 'git' | 'local' | 'nix' | 'unknown'
|
||||
distribution?: 'desktop-app' | 'docker' | 'nix'
|
||||
/** sha16 of the canonical install-root path — the per-install channel key and
|
||||
* the shape `hermes update --install-id` prints. */
|
||||
installId?: string
|
||||
/** What this build carries (embedded / light / external) and where an
|
||||
* external backend resolved from. Bundled artifacts run their payload; light
|
||||
* artifacts have no runtime and only reach remote backends. */
|
||||
hermesRuntime?: { type: 'embedded' } | { type: 'light' } | { type: 'external'; source?: RuntimeSource }
|
||||
}
|
||||
|
||||
/** Where an external build's backend came from. Mirrors the resolution ladder
|
||||
* in `resolveHermesBackend()`: `git` / `source` / sealed stewards are the
|
||||
* Python install methods from `installation.tree.install_method()`; the
|
||||
* Electron-only rungs (`hermes-root`, `path`, `system-python`, `bootstrap`)
|
||||
* are resolution facts the backend cannot see. Each variant carries the
|
||||
* location it resolved from, when there is one. */
|
||||
export type RuntimeSource =
|
||||
| { type: 'hermes-root'; root: string } // HERMES_DESKTOP_HERMES_ROOT — explicit developer override
|
||||
| { type: 'git'; root: string } // checkout at a managed install root, $HERMES_HOME/hermes-agent
|
||||
| { type: 'source'; root: string } // a git checkout anywhere else
|
||||
| { type: 'docker'; root: string | null } // sealed tree stewarded by Docker
|
||||
| { type: 'nix'; root: string | null } // sealed tree stewarded by Nix
|
||||
| { type: 'desktop-app'; root: string | null } // sealed tree stewarded by the desktop bundle
|
||||
| { type: 'desktop-bootstrap'; root: string } // canonical install created by the desktop first-launch bootstrap
|
||||
| { type: 'unknown' } // no stamp, no .git — provenance cannot be told
|
||||
| { type: 'path'; command: string } // an existing `hermes` CLI found on PATH
|
||||
| { type: 'system-python'; command: string } // pip-installed hermes_cli on system Python
|
||||
| { type: 'bootstrap' } // nothing usable yet; the first-launch installer runs
|
||||
|
||||
|
||||
export type DesktopUninstallMode = 'full' | 'gui' | 'lite'
|
||||
|
||||
export interface DesktopUninstallSummary {
|
||||
@@ -591,8 +630,17 @@ export interface DesktopUpdateCommit {
|
||||
at: number
|
||||
}
|
||||
|
||||
export type UpdaterMechanismClient =
|
||||
| 'app-installer'
|
||||
| 'external'
|
||||
| 'windows-handoff'
|
||||
| 'posix-handoff'
|
||||
| 'manual'
|
||||
|
||||
export interface DesktopUpdateStatus {
|
||||
supported: boolean
|
||||
/** Which mechanism owns updates for this install (see electron/updater). */
|
||||
mechanism?: UpdaterMechanismClient
|
||||
updateAvailable?: boolean
|
||||
branch?: string
|
||||
currentBranch?: string
|
||||
@@ -606,6 +654,11 @@ export interface DesktopUpdateStatus {
|
||||
currentSha?: string
|
||||
/** Backend only: the version string the backend reports for itself. */
|
||||
currentVersion?: string
|
||||
/** Release feed the check read from ('stable'/'nightly'); when set, the
|
||||
* update is a release and `latestTag` names it instead of a commit count. */
|
||||
channel?: 'stable' | 'nightly'
|
||||
/** The latest release tag on a release-feed channel, e.g. `v0.18.0`. */
|
||||
latestTag?: string | null
|
||||
targetSha?: string
|
||||
commits?: DesktopUpdateCommit[]
|
||||
dirty?: boolean
|
||||
@@ -1015,10 +1068,16 @@ export interface DesktopConnectionProbeResult {
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface ExternalOpenFailedPayload {
|
||||
url: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface DesktopOauthLoginResult {
|
||||
ok: boolean
|
||||
baseUrl: string
|
||||
connected: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface DesktopOauthLogoutResult {
|
||||
@@ -1127,6 +1186,10 @@ export interface DesktopBootstrapUnsupportedPlatform {
|
||||
export interface DesktopBootstrapSetupChoice {
|
||||
platform: string
|
||||
activeRoot: string
|
||||
/** What the local card represents: 'none' = installer offer; the rest = use existing. */
|
||||
local: 'none' | 'installed' | 'bundled' | 'bundled-damaged'
|
||||
/** This artifact is a bundled install (payload ships in-app). */
|
||||
bundled: boolean
|
||||
}
|
||||
|
||||
export interface DesktopBootstrapState {
|
||||
@@ -1139,6 +1202,8 @@ export interface DesktopBootstrapState {
|
||||
completedAt: number | null
|
||||
setupChoice: DesktopBootstrapSetupChoice | null
|
||||
unsupportedPlatform: DesktopBootstrapUnsupportedPlatform | null
|
||||
/** This artifact is a bundled install (payload ships in-app). */
|
||||
bundled: boolean
|
||||
}
|
||||
|
||||
export type DesktopBootstrapEvent =
|
||||
@@ -1148,6 +1213,8 @@ export type DesktopBootstrapEvent =
|
||||
active: boolean
|
||||
platform?: string
|
||||
activeRoot?: string
|
||||
local?: DesktopBootstrapSetupChoice['local']
|
||||
bundled?: boolean
|
||||
}
|
||||
| { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null }
|
||||
| {
|
||||
|
||||
+45
-32
@@ -1,6 +1,13 @@
|
||||
import { defineLocale } from './define-locale'
|
||||
|
||||
export const ar = defineLocale({
|
||||
externalOpenFailed: {
|
||||
title: 'تعذّر فتح هذا الرابط',
|
||||
message: 'لا يوجد متصفح مسجل لفتح هذا العنوان. انسخ الرابط وافتحه يدويًا.',
|
||||
copyUrl: 'نسخ الرابط',
|
||||
close: 'إغلاق'
|
||||
},
|
||||
|
||||
sendDiagnostics: {
|
||||
title: 'إرسال التشخيصات إلى Nous',
|
||||
privacyNotice:
|
||||
@@ -663,38 +670,6 @@ export const ar = defineLocale({
|
||||
'updates.nonInteractiveLocalChanges':
|
||||
'عندما يحدّث Hermes نفسه من التطبيق دون موجه طرفية، احتفظ بتعديلات المصدر المحلية أو تجاهلها.'
|
||||
},
|
||||
about: {
|
||||
heading: 'حول Hermes',
|
||||
version: value => `الإصدار ${value}`,
|
||||
versionUnavailable: 'الإصدار غير متاح',
|
||||
bundleOutOfSync: 'إصدار التطبيق قديم',
|
||||
bundleOutOfSyncDesc:
|
||||
'تم تحديث وقت تشغيل Hermes، لكن تطبيق سطح المكتب نفسه لا يزال إصدارًا قديمًا — لن تظهر ميزات الواجهة الجديدة (مثل Bot Mode) حتى يتم تحديث التطبيق. شغّل التحديث أدناه لإعادة بناء التطبيق. إذا لم يختفِ هذا التحذير، فأعد التثبيت من أحدث مثبّت لسطح المكتب.',
|
||||
bundleOutOfSyncAction: 'الحصول على المثبّت',
|
||||
updates: 'التحديثات',
|
||||
checkNow: 'التحقق الآن',
|
||||
checking: 'جار التحقق...',
|
||||
seeWhatsNew: 'عرض الجديد',
|
||||
updateNow: 'تحديث الآن',
|
||||
releaseNotes: 'ملاحظات الإصدار',
|
||||
onLatest: 'أنت على أحدث إصدار',
|
||||
installing: 'جار التثبيت...',
|
||||
cantUpdate: 'تعذر التحديث',
|
||||
cantReach: 'تعذر الوصول لخدمة التحديث',
|
||||
tapCheck: 'اضغط للتحقق من التحديثات.',
|
||||
updateReady: count => `${count} تحديث متاح`,
|
||||
updateReadyUnknown: 'تحديث جديد جاهز.',
|
||||
lastChecked: age => `آخر تحقق ${age}`,
|
||||
justNowSuffix: 'الآن',
|
||||
automaticUpdates: 'التحديثات التلقائية',
|
||||
automaticUpdatesDesc: 'اسمح لـ Hermes بالتحقق من التحديثات وتثبيتها.',
|
||||
branchCommit: (branch, commit) => `${branch} عند ${commit}`,
|
||||
never: 'أبدا',
|
||||
justNow: 'الآن',
|
||||
minAgo: count => `قبل ${count} دقيقة`,
|
||||
hoursAgo: count => `قبل ${count} ساعة`,
|
||||
daysAgo: count => `قبل ${count} يوم`
|
||||
},
|
||||
config: {
|
||||
none: 'لا شيء',
|
||||
noneParen: '(لا شيء)',
|
||||
@@ -2008,6 +1983,42 @@ export const ar = defineLocale({
|
||||
tryAgain: 'إعادة المحاولة',
|
||||
notAvailableTitle: 'التحديث غير متاح',
|
||||
unsupportedMessage: 'لا يمكن لهذا الإصدار من Hermes تحديث نفسه من داخل التطبيق.',
|
||||
appName: 'Hermes Desktop',
|
||||
version: value => `الإصدار ${value}`,
|
||||
versionUnavailable: 'الإصدار غير متاح',
|
||||
bundleOutOfSync: 'إصدار التطبيق قديم',
|
||||
bundleOutOfSyncDesc:
|
||||
'تم تحديث وقت تشغيل Hermes، لكن تطبيق سطح المكتب نفسه لا يزال إصدارًا قديمًا — لن تظهر ميزات الواجهة الجديدة (مثل Bot Mode) حتى يتم تحديث التطبيق. شغّل التحديث أدناه لإعادة بناء التطبيق. إذا لم يختفِ هذا التحذير، فأعد التثبيت من أحدث مثبّت لسطح المكتب.',
|
||||
bundleOutOfSyncAction: 'الحصول على المثبّت',
|
||||
updatesSection: 'التحديثات',
|
||||
checkNow: 'التحقق الآن',
|
||||
checkingShort: 'جار التحقق...',
|
||||
seeWhatsNew: 'عرض الجديد',
|
||||
releaseNotes: 'ملاحظات الإصدار',
|
||||
installing: 'جار التثبيت...',
|
||||
cantReach: 'تعذر الوصول لخدمة التحديث',
|
||||
tapCheck: 'اضغط للتحقق من التحديثات.',
|
||||
updateReady: count => `${count} تحديث متاح`,
|
||||
updateReadyUnknown: 'تحديث جديد جاهز.',
|
||||
lastChecked: age => `آخر تحقق ${age}`,
|
||||
justNowSuffix: 'الآن',
|
||||
never: 'أبدا',
|
||||
justNow: 'الآن',
|
||||
minAgo: count => `قبل ${count} دقيقة`,
|
||||
hoursAgo: count => `قبل ${count} ساعة`,
|
||||
daysAgo: count => `قبل ${count} يوم`,
|
||||
versionDetailsTitle: 'تفاصيل الإصدار',
|
||||
versionDetailsBody: 'هذا التثبيت مُدار خارج التطبيق. حدّثه بالطريقة نفسها التي استخدمتها لتثبيته.',
|
||||
versionDetailsVersion: 'الإصدار',
|
||||
versionDetailsCommit: 'الالتزام',
|
||||
versionDetailsBuildOrigin: 'مصدر البناء',
|
||||
versionDetailsDistribution: 'التوزيع',
|
||||
versionDetailsDistributionDesktop: 'تطبيق سطح المكتب',
|
||||
versionDetailsRuntimeEmbedded: 'وقت تشغيل مضمّن',
|
||||
versionDetailsRuntimeExternal: 'خارجي (يستخدم وقت تشغيل الجهاز)',
|
||||
versionDetailsRuntime: 'وقت التشغيل',
|
||||
versionDetailsInstallId: 'معرّف التثبيت',
|
||||
versionDetailsUncommittedChanges: '(+ تغييرات غير ملتزمة)',
|
||||
connectionRetry: 'تحقق من اتصالك وأعد المحاولة.',
|
||||
latestBody: 'أنت تستخدم أحدث إصدار.',
|
||||
latestBodyBackend: 'الواجهة الخلفية تعمل بأحدث إصدار.',
|
||||
@@ -2017,6 +2028,7 @@ export const ar = defineLocale({
|
||||
availableTitleBackend: 'يتوفر تحديث للواجهة الخلفية',
|
||||
availableBodyBackend: 'إصدار أحدث من واجهة Hermes الخلفية المتصلة جاهز للتثبيت.',
|
||||
availableBodyNoChangelog: 'إصدار أحدث جاهز. ملاحظات الإصدار غير متاحة لنوع التثبيت هذا.',
|
||||
availableBodyRelease: tag => `Hermes ${tag} جاهز للتثبيت.`,
|
||||
updateNow: 'التحديث الآن',
|
||||
maybeLater: 'ربما لاحقا',
|
||||
moreChanges: count => `+ ${count} تغيير${count === 1 ? '' : 'ات'} إضافي مُضمَّن.`,
|
||||
@@ -2255,6 +2267,7 @@ export const ar = defineLocale({
|
||||
update: 'تحديث',
|
||||
updateInProgress: 'التحديث جار',
|
||||
commitsBehind: (count, branch) => `${count} commits خلف ${branch}`,
|
||||
releaseAvailable: tag => `الإصدار ${tag} متاح`,
|
||||
desktopVersion: version => `سطح المكتب ${version}`,
|
||||
backendVersion: version => `الخلفية ${version}`,
|
||||
clientLabel: version => `العميل ${version}`,
|
||||
|
||||
@@ -142,6 +142,8 @@ export const en: Translations = {
|
||||
updateReadyTitle: 'Update ready',
|
||||
updateReadyMessage: count => `${count} new change${count === 1 ? '' : 's'} available.`,
|
||||
updateReadyMessageUnknown: 'A new update is available.',
|
||||
updateReadyMessageAppInstaller:
|
||||
'A new version of Hermes is ready. Update now and Windows will finish it for you.',
|
||||
seeWhatsNew: "See what's new",
|
||||
mcp: {
|
||||
needsAuthTitle: 'MCP server needs re-authentication',
|
||||
@@ -2594,6 +2596,8 @@ export const en: Translations = {
|
||||
availableTitleBackend: 'Backend update available',
|
||||
availableBodyBackend: 'A newer version of the connected Hermes backend is ready to install.',
|
||||
availableBodyNoChangelog: 'A newer version is ready. Release notes aren’t available for this install type.',
|
||||
availableBodyAppInstaller:
|
||||
'A new version of Hermes is ready. Hermes will close, Windows will finish the update, and Hermes will reopen on its own.',
|
||||
updateNow: 'Update now',
|
||||
maybeLater: 'Maybe later',
|
||||
moreChanges: count => `+ ${count} more change${count === 1 ? '' : 's'} included.`,
|
||||
@@ -2611,6 +2615,12 @@ export const en: Translations = {
|
||||
applyingBodyBackend:
|
||||
'The remote backend is applying the update and will restart. Hermes reconnects automatically when it’s back.',
|
||||
applyingClose: 'This window will close while the update runs, then Hermes reopens on its own.',
|
||||
applyingBodyAppInstaller:
|
||||
'Hermes will close and Windows will finish the update. Hermes will reopen when it’s done — you don’t need to do anything.',
|
||||
applyingCloseAppInstaller: 'This window will close, Windows finishes the update, and Hermes reopens on its own.',
|
||||
checkUnknownTitleAppInstaller: 'Couldn’t check for updates',
|
||||
checkUnknownBodyAppInstaller:
|
||||
'Windows couldn’t check for updates right now. Updates also install automatically when you restart Hermes.',
|
||||
errorTitle: 'Update didn’t finish',
|
||||
errorBody: 'No worries — nothing was lost. You can try again now.',
|
||||
blockerTitle: 'Close local previews to update Hermes?',
|
||||
|
||||
@@ -183,6 +183,7 @@ export interface Translations {
|
||||
updateReadyTitle: string
|
||||
updateReadyMessage: (count: number) => string
|
||||
updateReadyMessageUnknown: string
|
||||
updateReadyMessageAppInstaller: string
|
||||
seeWhatsNew: string
|
||||
mcp: {
|
||||
needsAuthTitle: string
|
||||
@@ -2194,6 +2195,7 @@ export interface Translations {
|
||||
availableTitleBackend: string
|
||||
availableBodyBackend: string
|
||||
availableBodyNoChangelog: string
|
||||
availableBodyAppInstaller: string
|
||||
updateNow: string
|
||||
maybeLater: string
|
||||
moreChanges: (count: number) => string
|
||||
@@ -2210,6 +2212,10 @@ export interface Translations {
|
||||
applyingBody: string
|
||||
applyingBodyBackend: string
|
||||
applyingClose: string
|
||||
applyingBodyAppInstaller: string
|
||||
applyingCloseAppInstaller: string
|
||||
checkUnknownTitleAppInstaller: string
|
||||
checkUnknownBodyAppInstaller: string
|
||||
errorTitle: string
|
||||
errorBody: string
|
||||
blockerTitle: string
|
||||
|
||||
@@ -138,6 +138,7 @@ export const zh: Translations = {
|
||||
updateReadyTitle: '有可用更新',
|
||||
updateReadyMessage: count => `有 ${count} 项新更改可用。`,
|
||||
updateReadyMessageUnknown: '有新更新可用。',
|
||||
updateReadyMessageAppInstaller: 'Hermes 新版本已就绪。现在更新,Windows 会自动完成。',
|
||||
seeWhatsNew: '查看更新内容',
|
||||
mcp: {
|
||||
needsAuthTitle: 'MCP 服务器需要重新认证',
|
||||
@@ -2772,6 +2773,7 @@ export const zh: Translations = {
|
||||
availableTitleBackend: '后端有可用更新',
|
||||
availableBodyBackend: '已连接的 Hermes 后端有新版本可安装。',
|
||||
availableBodyNoChangelog: '已有新版本可用。此安装方式无法显示更新日志。',
|
||||
availableBodyAppInstaller: 'Hermes 新版本已就绪。Hermes 会关闭,Windows 完成更新后自动重新打开。',
|
||||
updateNow: '立即更新',
|
||||
maybeLater: '稍后再说',
|
||||
moreChanges: count => `另有 ${count} 项更改。`,
|
||||
@@ -2788,6 +2790,10 @@ export const zh: Translations = {
|
||||
'Hermes 更新器会在自己的窗口中接管,并在完成后自动重新打开 Hermes。更新期间请不要自行重新打开 Hermes。',
|
||||
applyingBodyBackend: '远程后端正在应用更新并将重启。恢复后 Hermes 会自动重新连接。',
|
||||
applyingClose: '此窗口会在更新期间关闭,随后 Hermes 会自动重新打开。',
|
||||
applyingBodyAppInstaller: 'Hermes 会关闭,Windows 会完成更新,然后 Hermes 自动重新打开——无需任何操作。',
|
||||
applyingCloseAppInstaller: '此窗口会关闭,Windows 完成更新后 Hermes 自动重新打开。',
|
||||
checkUnknownTitleAppInstaller: '无法检查更新',
|
||||
checkUnknownBodyAppInstaller: 'Windows 目前无法检查更新。重启 Hermes 时也会自动安装更新。',
|
||||
errorTitle: '更新未完成',
|
||||
errorBody: '没有数据丢失。你可以现在重试。',
|
||||
blockerTitle: '关闭本地预览以更新 Hermes?',
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* docs.ts — canonical docs-site links used by the renderer's recovery and
|
||||
* setup surfaces. Single authority so the "reinstall the app" affordance
|
||||
* (bundled installs with a damaged payload) and any future doc links point
|
||||
* at the same place.
|
||||
*/
|
||||
export const DESKTOP_DOCS_URL = 'https://hermes-agent.nousresearch.com/docs/user-guide/desktop'
|
||||
@@ -7,7 +7,10 @@ const copy = {
|
||||
availableBody: 'A new version of Hermes is ready to install.',
|
||||
availableTitleBackend: 'Backend update available',
|
||||
availableBodyBackend: 'A newer version of the connected Hermes backend is ready to install.',
|
||||
availableBodyNoChangelog: 'A newer version is ready. Release notes aren’t available for this install type.'
|
||||
availableBodyNoChangelog: 'A newer version is ready. Release notes aren’t available for this install type.',
|
||||
availableBodyRelease: (tag: string) => `Hermes ${tag} is ready to install.`,
|
||||
availableBodyAppInstaller:
|
||||
'A new version of Hermes is ready. Hermes will close, Windows will finish the update, and Hermes will reopen on its own.'
|
||||
}
|
||||
|
||||
describe('resolveUpdateCopy', () => {
|
||||
@@ -35,4 +38,42 @@ describe('resolveUpdateCopy', () => {
|
||||
expect(r.title).toBe('New update available')
|
||||
expect(r.body).toBe(copy.availableBodyNoChangelog)
|
||||
})
|
||||
|
||||
it('stable channel with a known tag names the release, not the commit count', () => {
|
||||
const r = resolveUpdateCopy({
|
||||
target: 'client',
|
||||
shownItems: 0,
|
||||
channel: 'stable',
|
||||
latestTag: 'v0.18.0',
|
||||
copy
|
||||
})
|
||||
|
||||
expect(r.body).toBe('Hermes v0.18.0 is ready to install.')
|
||||
})
|
||||
|
||||
it('stable channel without a tag falls back to the generic release body', () => {
|
||||
const r = resolveUpdateCopy({ target: 'client', shownItems: 0, channel: 'stable', copy })
|
||||
expect(r.body).toBe(copy.availableBody)
|
||||
})
|
||||
|
||||
it('app-installer mechanism uses the Windows-finishes-it body, not commit vocabulary', () => {
|
||||
const r = resolveUpdateCopy({ target: 'client', shownItems: 5, mechanism: 'app-installer', copy })
|
||||
expect(r.body).toBe(copy.availableBodyAppInstaller)
|
||||
})
|
||||
|
||||
it('app-installer mechanism with a known tag names the release', () => {
|
||||
const r = resolveUpdateCopy({
|
||||
target: 'client',
|
||||
shownItems: 0,
|
||||
mechanism: 'app-installer',
|
||||
latestTag: 'v0.18.3',
|
||||
copy
|
||||
})
|
||||
expect(r.body).toBe('Hermes v0.18.3 is ready to install.')
|
||||
})
|
||||
|
||||
it('other mechanisms keep the commit vocabulary', () => {
|
||||
const r = resolveUpdateCopy({ target: 'client', shownItems: 5, mechanism: 'windows-handoff', copy })
|
||||
expect(r.body).toBe(copy.availableBody)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,8 @@
|
||||
* Names the update target (client vs the connected backend in remote mode) and
|
||||
* degrades honestly when there's no commit changelog to show (e.g. a pip /
|
||||
* non-git backend where `git log` yields nothing) instead of generic filler.
|
||||
* On a release-feed channel it names the release tag instead of commit
|
||||
* vocabulary, because a release feed carries no commit rows by construction.
|
||||
*
|
||||
* Extracted from updates-overlay.tsx so the wording logic is unit-testable.
|
||||
*/
|
||||
@@ -16,12 +18,29 @@ export interface UpdateCopyStrings {
|
||||
availableTitleBackend: string
|
||||
availableBodyBackend: string
|
||||
availableBodyNoChangelog: string
|
||||
/** Stable channel: names the release instead of describing commits. */
|
||||
availableBodyRelease: (tag: string) => string
|
||||
/** App-installer mechanism: the OS owns the apply, so the body says so. */
|
||||
availableBodyAppInstaller: string
|
||||
}
|
||||
|
||||
export interface ResolveUpdateCopyInput {
|
||||
target: UpdateTarget
|
||||
/** Number of commit rows actually shown in the changelog. 0 → no notes. */
|
||||
shownItems: number
|
||||
/**
|
||||
* 'stable': the update is a release, so the body names its tag and the
|
||||
* commit-changelog wording never appears (a release feed carries no
|
||||
* commit rows anyway). 'main' or absent: commit vocabulary as before.
|
||||
*/
|
||||
channel?: 'stable' | 'main'
|
||||
/** Stable channel: the release tag the update moves to, when known. */
|
||||
latestTag?: null | string
|
||||
/**
|
||||
* 'app-installer': the OS App Installer owns the apply (out-of-store MSIX)
|
||||
* — the body names Windows as the finisher, never commit vocabulary.
|
||||
*/
|
||||
mechanism?: 'app-installer' | 'external' | 'windows-handoff' | 'posix-handoff' | 'manual'
|
||||
copy: UpdateCopyStrings
|
||||
}
|
||||
|
||||
@@ -30,9 +49,29 @@ export interface UpdateCopyResult {
|
||||
body: string
|
||||
}
|
||||
|
||||
export function resolveUpdateCopy({ target, shownItems, copy }: ResolveUpdateCopyInput): UpdateCopyResult {
|
||||
export function resolveUpdateCopy({
|
||||
target,
|
||||
shownItems,
|
||||
channel = 'main',
|
||||
latestTag = null,
|
||||
mechanism,
|
||||
copy
|
||||
}: ResolveUpdateCopyInput): UpdateCopyResult {
|
||||
const title = target === 'backend' ? copy.availableTitleBackend : copy.availableTitle
|
||||
|
||||
// App-installer: the OS owns the apply, and there is no commit list by
|
||||
// construction (the OS checker answers yes/no). Name Windows as the
|
||||
// finisher; the tag is named when known.
|
||||
if (mechanism === 'app-installer') {
|
||||
return { title, body: latestTag ? copy.availableBodyRelease(latestTag) : copy.availableBodyAppInstaller }
|
||||
}
|
||||
|
||||
if (channel === 'stable') {
|
||||
// No-changelog copy would be wrong here: the absence of commit rows is
|
||||
// structural on a release feed, not a degraded install type.
|
||||
return { title, body: latestTag ? copy.availableBodyRelease(latestTag) : copy.availableBody }
|
||||
}
|
||||
|
||||
const body =
|
||||
shownItems === 0
|
||||
? copy.availableBodyNoChangelog
|
||||
|
||||
@@ -94,4 +94,22 @@ describe('resolveVersionStatus', () => {
|
||||
it('hides a backend row that has no version at all', () => {
|
||||
expect(backend().unknown).toBe(true)
|
||||
})
|
||||
|
||||
it('stable channel: releases use the update word, never a commit count', () => {
|
||||
const label = client({ behind: 4, updateAvailable: true, channel: 'stable', version: '0.4.2' }).label
|
||||
expect(label).toBe('v0.4.2 (update)')
|
||||
})
|
||||
|
||||
it('stable channel tooltip names the release tag', () => {
|
||||
const tooltip = client({ behind: 1, channel: 'stable', latestTag: 'v0.18.0', version: '0.4.2' }).tooltip
|
||||
expect(tooltip).toContain(`${copy.releaseAvailable('v0.18.0')}`)
|
||||
})
|
||||
|
||||
it('stable channel never names a branch (a stable checkout sits on a tag)', () => {
|
||||
const tooltip = client({ behind: 1, branch: 'main', channel: 'stable', latestTag: 'v0.18.0', version: '0.4.2' })
|
||||
.tooltip
|
||||
|
||||
expect(tooltip).not.toContain('main')
|
||||
expect(tooltip).toContain(`${copy.releaseAvailable('v0.18.0')}`)
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,8 @@ export interface VersionStatusCopy {
|
||||
commit: (sha: string) => string
|
||||
commitsBehind: (count: number, branch: string) => string
|
||||
desktopVersion: (version: string) => string
|
||||
/** Stable channel: a newer release exists ("v0.21.0 is available"). */
|
||||
releaseAvailable: (tag: string) => string
|
||||
restart: string
|
||||
unknown: string
|
||||
update: string
|
||||
@@ -31,7 +33,17 @@ export interface VersionStatusInput {
|
||||
applyMessage?: string
|
||||
behind?: number
|
||||
branch?: string
|
||||
/**
|
||||
* The update channel of the target. 'main' (the default) speaks in
|
||||
* commits behind a branch. 'stable' speaks in releases: the label hint
|
||||
* is the update word, never a commit count, and the tooltip names the
|
||||
* newer release tag. The channel changes the vocabulary only — the
|
||||
* apply mechanism is the caller's concern.
|
||||
*/
|
||||
channel?: 'stable' | 'main'
|
||||
copy: VersionStatusCopy
|
||||
/** Stable channel: the newest release tag, when the check found one. */
|
||||
latestTag?: null | string
|
||||
/** Remote mode: the client is one of two versions on screen, so it says so. */
|
||||
remote: boolean
|
||||
/** The apply reached the restart stage — labels `restart`, not `update`. */
|
||||
@@ -60,7 +72,9 @@ export function resolveVersionStatus({
|
||||
applying,
|
||||
behind = 0,
|
||||
branch,
|
||||
channel = 'main',
|
||||
copy,
|
||||
latestTag = null,
|
||||
remote,
|
||||
restarting,
|
||||
sha = null,
|
||||
@@ -75,6 +89,7 @@ export function resolveVersionStatus({
|
||||
// SSH-official presence-only checks, and pip installs. It applies to BOTH
|
||||
// targets — the client statusbar item is how a shallow desktop install
|
||||
// learns it's stale at all.
|
||||
const stable = channel === 'stable'
|
||||
const available = behind > 0 || !!updateAvailable
|
||||
|
||||
// A client with no version still identifies itself by sha; a backend can't.
|
||||
@@ -86,17 +101,22 @@ export function resolveVersionStatus({
|
||||
? copy.clientLabel(named)
|
||||
: (version && `v${version}`) || named
|
||||
|
||||
// Commits behind is the precise diff; `(update)` is the fallback for a
|
||||
// backend that knows it's stale but can't count (pip, non-git checkout).
|
||||
const hint = busy ? '' : behind > 0 ? ` (+${behind})` : available ? ` (${copy.update})` : ''
|
||||
// Main channel: commits behind is the precise diff. Stable channel: a
|
||||
// count of commits is the wrong vocabulary — a release is one step, so
|
||||
// the hint is always the update word. `(update)` also covers a backend
|
||||
// that knows it's stale but can't count (pip, non-git checkout).
|
||||
const hint = busy ? '' : !stable && behind > 0 ? ` (+${behind})` : available ? ` (${copy.update})` : ''
|
||||
|
||||
const tooltip = [
|
||||
busy && (applyMessage || copy.updateInProgress),
|
||||
!busy && behind > 0 && copy.commitsBehind(behind, (client ? branch : 'main') || '...'),
|
||||
!busy && behind <= 0 && available && copy.update,
|
||||
!busy && available && stable && latestTag && copy.releaseAvailable(latestTag),
|
||||
!busy && !stable && behind > 0 && copy.commitsBehind(behind, (client ? branch : 'main') || '...'),
|
||||
!busy && available && (stable ? !latestTag : behind <= 0) && copy.update,
|
||||
version && (client ? copy.desktopVersion(version) : copy.backendVersion(version)),
|
||||
client && sha && copy.commit(sha),
|
||||
client && branch && copy.branch(branch)
|
||||
// The branch line is main-channel vocabulary; a stable checkout sits on
|
||||
// a tag, and naming a branch would contradict the release line.
|
||||
client && !stable && branch && copy.branch(branch)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, expect, test } from 'vitest'
|
||||
import { beforeEach, expect, test, vi } from 'vitest'
|
||||
|
||||
import { $notifications, clearNotifications, isDiskFullErrorMessage, notifyError } from './notifications'
|
||||
|
||||
@@ -55,3 +55,29 @@ test('session storage write failure is treated as disk-full class', () => {
|
||||
|
||||
expect(lastMessage()).toMatch(/Disk full/i)
|
||||
})
|
||||
|
||||
test('notifyError posts the full error to desktop.log, not the summary', () => {
|
||||
const logLine = vi.fn()
|
||||
|
||||
const previous = (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
|
||||
;(window as unknown as { hermesDesktop: { logLine: typeof logLine } }).hermesDesktop = { logLine }
|
||||
|
||||
try {
|
||||
const error = new Error('sqlite3.OperationalError: database is locked')
|
||||
error.stack = 'Error: sqlite3.OperationalError: database is locked\n at saveSession (session.ts:12)'
|
||||
|
||||
notifyError(error, 'Prompt failed')
|
||||
|
||||
expect(logLine).toHaveBeenCalledTimes(1)
|
||||
expect(logLine.mock.calls[0][0]).toContain('Prompt failed')
|
||||
expect(logLine.mock.calls[0][0]).toContain('database is locked')
|
||||
expect(logLine.mock.calls[0][0]).toContain('session.ts:12')
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
} else {
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = previous
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -189,8 +189,24 @@ export function notify(input: NotificationInput): string {
|
||||
return id
|
||||
}
|
||||
|
||||
// The toast only ever shows the summarized copy (possibly a fallback at 180
|
||||
// chars). desktop.log gets the FULL error — message and stack — so a bug
|
||||
// report carries the actual failure, not the summary. Fire-and-forget: the
|
||||
// toast must never depend on this round-trip.
|
||||
function logErrorToDesktopLog(error: unknown, fallback: string) {
|
||||
try {
|
||||
const label = new URLSearchParams(window.location.search).get('win') ?? 'main'
|
||||
const raw = error instanceof Error ? (error.stack ?? error.message) : typeof error === 'string' ? error : fallback
|
||||
|
||||
window.hermesDesktop?.logLine?.(`[renderer error:${label}] ${fallback}: ${raw}`)
|
||||
} catch {
|
||||
// Logging must never break the toast.
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyError(error: unknown, fallback: string): string {
|
||||
const readable = readableError(error, fallback)
|
||||
logErrorToDesktopLog(error, fallback)
|
||||
|
||||
return notify({
|
||||
kind: 'error',
|
||||
|
||||
@@ -207,14 +207,14 @@ export function reportInstallMethodWarning(message: string | undefined): void {
|
||||
* on every new commit. The snooze is persisted, so it survives relaunches too.
|
||||
*/
|
||||
export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
if (!status || status.supported === false || status.error || !status.targetSha) {
|
||||
// Either signal means "update ready": behind > 0 (git checkout) or
|
||||
// updateAvailable (app-installer, shallow clone).
|
||||
if (!status || status.supported === false || status.error) {
|
||||
return
|
||||
}
|
||||
|
||||
const behind = typeof status.behind === 'number' ? status.behind : null
|
||||
|
||||
// behind === null means "update available, exact count unknown" (shallow
|
||||
// clone). That still deserves the toast — just with count-free copy.
|
||||
if ((behind ?? 0) <= 0 && !status.updateAvailable) {
|
||||
return
|
||||
}
|
||||
@@ -240,9 +240,11 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
id: UPDATE_TOAST_ID,
|
||||
kind: 'info',
|
||||
message:
|
||||
behind !== null && behind > 0
|
||||
? translateNow('notifications.updateReadyMessage', behind)
|
||||
: translateNow('notifications.updateReadyMessageUnknown'),
|
||||
status.mechanism === 'app-installer'
|
||||
? translateNow('notifications.updateReadyMessageAppInstaller')
|
||||
: behind !== null && behind > 0
|
||||
? translateNow('notifications.updateReadyMessage', behind)
|
||||
: translateNow('notifications.updateReadyMessageUnknown'),
|
||||
onDismiss: () => snoozeUpdateToast(),
|
||||
title: translateNow('notifications.updateReadyTitle')
|
||||
})
|
||||
|
||||
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user