Codex/pr 1217 (#1226)

* bundle node and windows git runtimes

* split desktop runtime into release package

* fix desktop runtime packaging ci

* embed desktop runtime release tag

* show desktop runtime download progress

* fix desktop runtime release handling

* refactor desktop runtime version config

* fix desktop package license

---------

Co-authored-by: xingzhi <chuzihao.czh@alibaba-inc.com>
Co-authored-by: ekko <fqsy1416@gmail.com>
This commit is contained in:
sir1st
2026-06-02 08:55:17 +08:00
committed by GitHub
parent 7440da9d23
commit 00ea452310
22 changed files with 1077 additions and 55 deletions
+8 -7
View File
@@ -24,6 +24,10 @@ on:
description: "Optional release tag to attach artifacts to" description: "Optional release tag to attach artifacts to"
required: false required: false
type: string type: string
runtime_release_tag:
description: "Optional runtime release tag embedded into the desktop app"
required: false
type: string
permissions: permissions:
contents: write contents: write
@@ -121,9 +125,6 @@ jobs:
package-lock.json package-lock.json
packages/desktop/package-lock.json packages/desktop/package-lock.json
- name: Install uv
uses: astral-sh/setup-uv@v3
- name: Install web UI dependencies - name: Install web UI dependencies
run: | run: |
npm ci --ignore-scripts npm ci --ignore-scripts
@@ -138,11 +139,11 @@ jobs:
- name: Install desktop dependencies - name: Install desktop dependencies
run: npm ci --prefix packages/desktop --no-audit --no-fund run: npm ci --prefix packages/desktop --no-audit --no-fund
- name: Prepare bundled Python - name: Write runtime release metadata
shell: bash
env: env:
TARGET_OS: ${{ needs.validate.outputs.target_os }} RUNTIME_RELEASE_TAG: ${{ github.event.inputs.runtime_release_tag }}
TARGET_ARCH: ${{ needs.validate.outputs.target_arch }} run: npm --prefix packages/desktop run write:runtime-release
run: npm --prefix packages/desktop run prepare:python
- name: Configure macOS signing - name: Configure macOS signing
if: needs.validate.outputs.target_os == 'darwin' if: needs.validate.outputs.target_os == 'darwin'
+4 -7
View File
@@ -86,9 +86,6 @@ jobs:
package-lock.json package-lock.json
packages/desktop/package-lock.json packages/desktop/package-lock.json
- name: Install uv
uses: astral-sh/setup-uv@v3
- name: Install web UI dependencies - name: Install web UI dependencies
run: | run: |
npm ci --ignore-scripts npm ci --ignore-scripts
@@ -103,11 +100,11 @@ jobs:
- name: Install desktop dependencies - name: Install desktop dependencies
run: npm ci --prefix packages/desktop --no-audit --no-fund run: npm ci --prefix packages/desktop --no-audit --no-fund
- name: Prepare bundled Python - name: Write runtime release metadata
shell: bash
env: env:
TARGET_OS: ${{ matrix.target_os }} HERMES_DESKTOP_RUNTIME_RELEASE_TAG: ${{ vars.HERMES_DESKTOP_RUNTIME_RELEASE_TAG }}
TARGET_ARCH: ${{ matrix.target_arch }} run: npm --prefix packages/desktop run write:runtime-release
run: npm --prefix packages/desktop run prepare:python
- name: Configure macOS signing - name: Configure macOS signing
if: matrix.target_os == 'darwin' if: matrix.target_os == 'darwin'
+125
View File
@@ -0,0 +1,125 @@
name: Publish Desktop Runtime to Release
on:
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to attach runtime assets to"
required: true
release:
types: [published]
permissions:
contents: write
concurrency:
group: desktop-runtime-${{ github.event.release.tag_name || github.event.inputs.tag || github.ref }}
cancel-in-progress: false
jobs:
runtime:
name: Runtime (${{ matrix.label }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- label: macOS arm64
runner: macos-14
target_os: darwin
target_arch: arm64
- label: macOS x64
runner: macos-15-intel
target_os: darwin
target_arch: x64
- label: Windows x64
runner: windows-latest
target_os: win32
target_arch: x64
- label: Linux x64
runner: ubuntu-22.04
target_os: linux
target_arch: x64
- label: Linux arm64
runner: ubuntu-22.04-arm
target_os: linux
target_arch: arm64
skip_browser_runtime: true
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name || github.event.inputs.tag }}
- name: Resolve runtime asset names
id: names
shell: bash
env:
TARGET_OS: ${{ matrix.target_os }}
TARGET_ARCH: ${{ matrix.target_arch }}
run: |
echo "asset=$(node packages/desktop/scripts/runtime-asset-name.mjs)" >> "$GITHUB_OUTPUT"
echo "manifest=$(node packages/desktop/scripts/runtime-asset-name.mjs --manifest)" >> "$GITHUB_OUTPUT"
- name: Check existing release assets
id: check
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.release.tag_name || github.event.inputs.tag }}
ASSET: ${{ steps.names.outputs.asset }}
MANIFEST: ${{ steps.names.outputs.manifest }}
run: |
assets="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' || true)"
if printf '%s\n' "$assets" | grep -Fx "$ASSET" >/dev/null \
&& printf '%s\n' "$assets" | grep -Fx "$MANIFEST" >/dev/null; then
echo "missing=false" >> "$GITHUB_OUTPUT"
echo "Runtime asset already exists: $ASSET"
else
echo "missing=true" >> "$GITHUB_OUTPUT"
echo "Runtime asset missing: $ASSET or $MANIFEST"
fi
- name: Setup Node.js
if: steps.check.outputs.missing == 'true'
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: packages/desktop/package-lock.json
- name: Install uv
if: steps.check.outputs.missing == 'true'
uses: astral-sh/setup-uv@v3
- name: Install desktop dependencies
if: steps.check.outputs.missing == 'true'
run: npm ci --prefix packages/desktop --no-audit --no-fund
- name: Prepare runtime resources
if: steps.check.outputs.missing == 'true'
env:
TARGET_OS: ${{ matrix.target_os }}
TARGET_ARCH: ${{ matrix.target_arch }}
GH_TOKEN: ${{ github.token }}
HERMES_SKIP_BROWSER_RUNTIME: ${{ matrix.skip_browser_runtime || 'false' }}
run: npm --prefix packages/desktop run prepare:runtime
- name: Package runtime
if: steps.check.outputs.missing == 'true'
env:
TARGET_OS: ${{ matrix.target_os }}
TARGET_ARCH: ${{ matrix.target_arch }}
run: npm --prefix packages/desktop run package:runtime
- name: Upload runtime assets to release
if: steps.check.outputs.missing == 'true'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.release.tag_name || github.event.inputs.tag }}
fail_on_unmatched_files: true
files: |
packages/desktop/release/runtime/${{ steps.names.outputs.asset }}
packages/desktop/release/runtime/${{ steps.names.outputs.asset }}.sha256
packages/desktop/release/runtime/${{ steps.names.outputs.manifest }}
+5 -4
View File
@@ -50,11 +50,12 @@
"build:website": "vite build --config vite.config.website.ts", "build:website": "vite build --config vite.config.website.ts",
"preview:website": "vite preview --config vite.config.website.ts", "preview:website": "vite preview --config vite.config.website.ts",
"desktop:install": "npm ci --prefix packages/desktop --no-audit --no-fund", "desktop:install": "npm ci --prefix packages/desktop --no-audit --no-fund",
"desktop:prepare-runtime": "npm --prefix packages/desktop run prepare:runtime",
"desktop:prepare-python": "npm --prefix packages/desktop run prepare:python", "desktop:prepare-python": "npm --prefix packages/desktop run prepare:python",
"build:desktop": "npm run build && npm run desktop:install && npm run desktop:prepare-python && npm --prefix packages/desktop run dist -- --publish never", "build:desktop": "npm run build && npm run desktop:install && npm --prefix packages/desktop run dist -- --publish never",
"build:desktop:mac": "npm run build && npm run desktop:install && npm run desktop:prepare-python && npm --prefix packages/desktop run dist -- --mac --publish never", "build:desktop:mac": "npm run build && npm run desktop:install && npm --prefix packages/desktop run dist -- --mac --publish never",
"build:desktop:win": "npm run build && npm run desktop:install && npm run desktop:prepare-python && npm --prefix packages/desktop run dist -- --win --publish never", "build:desktop:win": "npm run build && npm run desktop:install && npm --prefix packages/desktop run dist -- --win --publish never",
"build:desktop:linux": "npm run build && npm run desktop:install && npm run desktop:prepare-python && npm --prefix packages/desktop run dist -- --linux --publish never", "build:desktop:linux": "npm run build && npm run desktop:install && npm --prefix packages/desktop run dist -- --linux --publish never",
"openapi:generate": "node scripts/generate-openapi.mjs", "openapi:generate": "node scripts/generate-openapi.mjs",
"claude": "claude --dangerously-skip-permissions" "claude": "claude --dangerously-skip-permissions"
}, },
@@ -0,0 +1,3 @@
{
"tag": "hermes-0.15.2-runtime"
}
+3 -5
View File
@@ -22,7 +22,8 @@ files:
- "!**/node_modules/.bin" - "!**/node_modules/.bin"
- "!**/{.DS_Store,.git,.gitignore,.eslintrc*,.prettierrc*,*.map,tsconfig.json}" - "!**/{.DS_Store,.git,.gitignore,.eslintrc*,.prettierrc*,*.map,tsconfig.json}"
# Web UI source (built dist) and bundled Python live outside the asar. # Web UI source (built dist) lives outside the asar. Python/Node/Git runtime
# assets are downloaded into the user's Web UI home on first launch.
# This package lives at packages/desktop, so ../.. is the hermes-web-ui repo root. # This package lives at packages/desktop, so ../.. is the hermes-web-ui repo root.
extraResources: extraResources:
- from: "build" - from: "build"
@@ -32,6 +33,7 @@ extraResources:
- "icon.ico" - "icon.ico"
- "trayTemplate.png" - "trayTemplate.png"
- "trayWindows.png" - "trayWindows.png"
- "runtime-release.json"
- from: "../.." - from: "../.."
to: "webui" to: "webui"
filter: filter:
@@ -44,10 +46,6 @@ extraResources:
- "!packages/desktop/**" - "!packages/desktop/**"
- "!**/{.git,.github,docs,tests,playwright.config.ts,README*,scripts,*.map}" - "!**/{.git,.github,docs,tests,playwright.config.ts,README*,scripts,*.map}"
- "!node_modules/**/*.md" - "!node_modules/**/*.md"
- from: "resources/python/${os}-${arch}"
to: "python"
filter:
- "**/*"
asarUnpack: asarUnpack:
- "**/*.node" - "**/*.node"
+1 -1
View File
@@ -7,7 +7,7 @@
"": { "": {
"name": "hermes-studio", "name": "hermes-studio",
"version": "0.6.7", "version": "0.6.7",
"license": "MIT", "license": "BSL-1.1",
"dependencies": { "dependencies": {
"electron-updater": "^6.3.9" "electron-updater": "^6.3.9"
}, },
+8 -2
View File
@@ -7,16 +7,22 @@
"name": "Hermes Studio Contributors", "name": "Hermes Studio Contributors",
"email": "noreply@hermes-studio.local" "email": "noreply@hermes-studio.local"
}, },
"license": "MIT", "license": "BSL-1.1",
"private": true, "private": true,
"main": "dist/main/index.js", "main": "dist/main/index.js",
"scripts": { "scripts": {
"build:main": "tsc -p tsconfig.json", "build:main": "tsc -p tsconfig.json",
"build": "npm run build:main", "build": "npm run build:main",
"fetch:node": "node scripts/fetch-node.mjs",
"fetch:git": "node scripts/fetch-git.mjs",
"fetch:python": "node scripts/fetch-python.mjs", "fetch:python": "node scripts/fetch-python.mjs",
"install:hermes": "node scripts/install-hermes.mjs", "install:hermes": "node scripts/install-hermes.mjs",
"patch:hermes": "node scripts/apply-hermes-patches.mjs", "patch:hermes": "node scripts/apply-hermes-patches.mjs",
"prepare:python": "npm run fetch:python && npm run install:hermes && npm run patch:hermes && npm run prune:python", "write:runtime-release": "node scripts/write-runtime-release.mjs",
"prepare:runtime": "npm run fetch:node && npm run fetch:git && npm run fetch:python && npm run install:hermes && npm run patch:hermes && npm run prune:python",
"prepare:python": "npm run prepare:runtime",
"package:runtime": "node scripts/package-runtime.mjs",
"runtime:asset-name": "node scripts/runtime-asset-name.mjs",
"prune:python": "node scripts/prune-python.mjs", "prune:python": "node scripts/prune-python.mjs",
"dev": "npm run build:main && electron .", "dev": "npm run build:main && electron .",
"dist": "npm run build && electron-builder", "dist": "npm run build && electron-builder",
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env node
// Download Git for Windows MinGit for Windows builds. Other platforms create
// an empty resource directory so electron-builder can use the same resource map.
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { arch as osArch, platform as osPlatform, tmpdir } from 'node:os'
import { spawnSync } from 'node:child_process'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, '..')
const TARGET_OS = process.env.TARGET_OS || osPlatform()
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
const OUT_DIR = resolve(ROOT, 'resources', 'git', `${OS_LABEL}-${TARGET_ARCH}`)
mkdirSync(OUT_DIR, { recursive: true })
if (TARGET_OS !== 'win32') {
writeFileSync(resolve(OUT_DIR, '.placeholder'), 'Git for Windows is only bundled on Windows.\n')
console.log(`Git resource placeholder ready at ${OUT_DIR}`)
process.exit(0)
}
if (TARGET_ARCH !== 'x64') {
console.error(`Unsupported Git for Windows target: ${TARGET_OS}-${TARGET_ARCH}`)
process.exit(1)
}
if (existsSync(resolve(OUT_DIR, 'cmd', 'git.exe'))) {
console.log(`Git for Windows already present at ${OUT_DIR}, skipping`)
process.exit(0)
}
async function latestMinGitUrl() {
if (process.env.GIT_FOR_WINDOWS_URL?.trim()) return process.env.GIT_FOR_WINDOWS_URL.trim()
const headers = { 'User-Agent': 'hermes-studio-desktop-build' }
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
if (token?.trim()) headers.Authorization = `Bearer ${token.trim()}`
const response = await fetch('https://api.github.com/repos/git-for-windows/git/releases/latest', {
headers,
})
if (!response.ok) {
throw new Error(`GitHub API returned ${response.status}`)
}
const release = await response.json()
const asset = release.assets?.find(candidate =>
typeof candidate?.name === 'string'
&& /^MinGit-.*-64-bit\.zip$/.test(candidate.name)
&& typeof candidate.browser_download_url === 'string',
)
if (!asset) throw new Error('Could not find MinGit 64-bit zip in latest Git for Windows release')
return asset.browser_download_url
}
let url
try {
url = await latestMinGitUrl()
} catch (err) {
console.error(`Failed to resolve Git for Windows download URL: ${err instanceof Error ? err.message : String(err)}`)
process.exit(1)
}
const file = url.split('/').pop() || 'mingit.zip'
const archivePath = resolve(tmpdir(), file)
console.log(`Fetching ${url}`)
const curl = spawnSync('curl', ['-fL', '--retry', '3', '-o', archivePath, url], { stdio: 'inherit' })
if (curl.status !== 0) {
console.error('curl failed')
process.exit(curl.status ?? 1)
}
console.log(`Extracting into ${OUT_DIR}`)
const extract = spawnSync('tar', ['-xf', archivePath, '-C', OUT_DIR], { stdio: 'inherit' })
if (extract.status !== 0) {
console.error('extract failed')
process.exit(extract.status ?? 1)
}
rmSync(archivePath, { force: true })
console.log(`Git for Windows ready at ${OUT_DIR}`)
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
// Download a portable Node.js runtime for the current (or target) platform/arch
// and extract into resources/node/<os>-<arch>/.
import { existsSync, mkdirSync, rmSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { arch as osArch, platform as osPlatform, tmpdir } from 'node:os'
import { spawnSync } from 'node:child_process'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, '..')
const TARGET_OS = process.env.TARGET_OS || osPlatform()
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
const NODE_VERSION = (process.env.HERMES_DESKTOP_NODE_VERSION || process.env.NODE_VERSION || process.versions.node).replace(/^v/, '')
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
const OUT_DIR = resolve(ROOT, 'resources', 'node', `${OS_LABEL}-${TARGET_ARCH}`)
const DIST_PLATFORM = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'darwin' : TARGET_OS
const DIST_ARCH = TARGET_ARCH === 'x64' ? 'x64' : TARGET_ARCH === 'arm64' ? 'arm64' : ''
if (!DIST_ARCH || !['win', 'darwin', 'linux'].includes(DIST_PLATFORM)) {
console.error(`Unsupported target: ${TARGET_OS}-${TARGET_ARCH}`)
process.exit(1)
}
const ext = TARGET_OS === 'win32' ? 'zip' : 'tar.gz'
const file = `node-v${NODE_VERSION}-${DIST_PLATFORM}-${DIST_ARCH}.${ext}`
const baseUrl = (process.env.NODE_DIST_BASE_URL || 'https://nodejs.org/dist').replace(/\/$/, '')
const url = `${baseUrl}/v${NODE_VERSION}/${file}`
const marker = TARGET_OS === 'win32' ? 'node.exe' : join('bin', 'node')
if (existsSync(resolve(OUT_DIR, marker))) {
console.log(`Node.js already present at ${OUT_DIR}, skipping`)
process.exit(0)
}
mkdirSync(OUT_DIR, { recursive: true })
const archivePath = resolve(tmpdir(), file)
console.log(`Fetching ${url}`)
const curl = spawnSync('curl', ['-fL', '--retry', '3', '-o', archivePath, url], { stdio: 'inherit' })
if (curl.status !== 0) {
console.error('curl failed')
process.exit(curl.status ?? 1)
}
console.log(`Extracting into ${OUT_DIR}`)
let extract
if (TARGET_OS === 'win32') {
extract = spawnSync('tar', ['-xf', archivePath, '-C', OUT_DIR, '--strip-components=1'], { stdio: 'inherit' })
} else {
extract = spawnSync('tar', ['-xzf', archivePath, '-C', OUT_DIR, '--strip-components=1'], { stdio: 'inherit' })
}
if (extract.status !== 0) {
console.error('extract failed')
process.exit(extract.status ?? 1)
}
rmSync(archivePath, { force: true })
console.log(`Node.js ready at ${OUT_DIR}`)
+42 -13
View File
@@ -18,13 +18,14 @@ import { basename, resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process' import { spawnSync } from 'node:child_process'
import { platform as osPlatform, arch as osArch, homedir as osHomedir } from 'node:os' import { platform as osPlatform, arch as osArch, homedir as osHomedir } from 'node:os'
import { hermesVersion } from './runtime-config.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, '..') const ROOT = resolve(__dirname, '..')
const TARGET_OS = process.env.TARGET_OS || osPlatform() const TARGET_OS = process.env.TARGET_OS || osPlatform()
const TARGET_ARCH = process.env.TARGET_ARCH || osArch() const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
const HERMES_VERSION = process.env.HERMES_VERSION || '0.15.2' const HERMES_VERSION = hermesVersion()
// Match the packaged runtime to the channel list exposed at /hermes/channels. // Match the packaged runtime to the channel list exposed at /hermes/channels.
// Telegram, Discord, and Slack are covered by "messaging". We intentionally // Telegram, Discord, and Slack are covered by "messaging". We intentionally
// install Matrix's plaintext deps below instead of using the "matrix" extra: // install Matrix's plaintext deps below instead of using the "matrix" extra:
@@ -59,6 +60,7 @@ const SKIP_BROWSER_RUNTIME = process.env.HERMES_SKIP_BROWSER_RUNTIME === '1'
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
const PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`) const PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
const NODE_DIR = resolve(ROOT, 'resources', 'node', `${OS_LABEL}-${TARGET_ARCH}`)
const NODE_PREFIX = resolve(PY_DIR, 'node') const NODE_PREFIX = resolve(PY_DIR, 'node')
const AGENT_BROWSER_HOME = resolve(PY_DIR, 'agent-browser') const AGENT_BROWSER_HOME = resolve(PY_DIR, 'agent-browser')
const PLAYWRIGHT_BROWSERS_PATH = resolve(PY_DIR, 'ms-playwright') const PLAYWRIGHT_BROWSERS_PATH = resolve(PY_DIR, 'ms-playwright')
@@ -96,7 +98,8 @@ function optionalRun(command, args, options = {}) {
function commandInvocation(command) { function commandInvocation(command) {
if (TARGET_OS === 'win32' && command.toLowerCase().endsWith('.cmd')) { if (TARGET_OS === 'win32' && command.toLowerCase().endsWith('.cmd')) {
return { command: 'cmd.exe', argsPrefix: ['/d', '/s', '/c', command] } const cmdCommand = /[\s&()[\]{}^=;!'+,`~]/.test(command) ? `"${command}"` : command
return { command: 'cmd.exe', argsPrefix: ['/d', '/s', '/c', cmdCommand] }
} }
return { command, argsPrefix: [] } return { command, argsPrefix: [] }
} }
@@ -109,15 +112,25 @@ function optionalRunInvocation(invocation, args, options = {}) {
return optionalRun(invocation.command, [...invocation.argsPrefix, ...args], options) return optionalRun(invocation.command, [...invocation.argsPrefix, ...args], options)
} }
function pythonBuildEnv() {
if (TARGET_OS !== 'darwin') return process.env
const env = { ...process.env }
if (!env.AR && existsSync('/usr/bin/ar')) env.AR = '/usr/bin/ar'
if (!env.RANLIB && existsSync('/usr/bin/ranlib')) env.RANLIB = '/usr/bin/ranlib'
return env
}
function installPythonPackages(packages, label) { function installPythonPackages(packages, label) {
if (packages.length === 0) return if (packages.length === 0) return
const env = pythonBuildEnv()
if (hasUv()) { if (hasUv()) {
console.log(`→ Installing ${label} via uv: ${packages.join(' ')}`) console.log(`→ Installing ${label} via uv: ${packages.join(' ')}`)
run('uv', [ run('uv', [
'pip', 'install', 'pip', 'install',
'--python', pyBin, '--python', pyBin,
...packages, ...packages,
]) ], { env })
} else { } else {
console.log(`→ Installing ${label} via pip: ${packages.join(' ')}`) console.log(`→ Installing ${label} via pip: ${packages.join(' ')}`)
run(pyBin, [ run(pyBin, [
@@ -125,17 +138,21 @@ function installPythonPackages(packages, label) {
...packages, ...packages,
'--no-warn-script-location', '--no-warn-script-location',
'--disable-pip-version-check', '--disable-pip-version-check',
]) ], { env })
} }
} }
function npmCommand() { function npmCommand() {
const bundled = TARGET_OS === 'win32'
? resolve(NODE_DIR, 'npm.cmd')
: resolve(NODE_DIR, 'bin', 'npm')
const candidates = TARGET_OS === 'win32' const candidates = TARGET_OS === 'win32'
? ['npm.cmd', 'npm.exe', 'npm'] ? [bundled, 'npm.cmd', 'npm.exe', 'npm']
: ['npm'] : [bundled, 'npm']
for (const candidate of candidates) { for (const candidate of candidates) {
if (candidate === bundled && !existsSync(candidate)) continue
const invocation = commandInvocation(candidate) const invocation = commandInvocation(candidate)
const result = optionalRunInvocation(invocation, ['--version'], { stdio: 'ignore' }) const result = optionalRunInvocation(invocation, ['--version'], { stdio: 'ignore', env: browserRuntimeEnv(false) })
if (result.status === 0) return invocation if (result.status === 0) return invocation
} }
return null return null
@@ -148,16 +165,24 @@ function agentBrowserCommand() {
return resolve(NODE_PREFIX, 'bin', 'agent-browser') return resolve(NODE_PREFIX, 'bin', 'agent-browser')
} }
function browserRuntimeEnv() { function browserRuntimeEnv(includeAgentBrowser = true) {
const bundledNodeBin = TARGET_OS === 'win32'
? NODE_DIR
: resolve(NODE_DIR, 'bin')
const nodePath = TARGET_OS === 'win32' const nodePath = TARGET_OS === 'win32'
? NODE_PREFIX ? NODE_PREFIX
: resolve(NODE_PREFIX, 'bin') : resolve(NODE_PREFIX, 'bin')
const inheritedPath = process.env.PATH || process.env.Path || '' const inheritedPath = process.env.PATH || process.env.Path || ''
const pathKey = TARGET_OS === 'win32' ? 'Path' : 'PATH' const pathKey = TARGET_OS === 'win32' ? 'Path' : 'PATH'
const browserExecutable = ensureBundledBrowserExecutable() const browserExecutable = includeAgentBrowser ? ensureBundledBrowserExecutable() : null
const pathEntries = includeAgentBrowser
? [nodePath, bundledNodeBin, inheritedPath]
: [bundledNodeBin, inheritedPath]
const env = { const env = {
...process.env, ...process.env,
[pathKey]: [nodePath, inheritedPath].filter(Boolean).join(TARGET_OS === 'win32' ? ';' : ':'), [pathKey]: pathEntries.filter(Boolean).join(TARGET_OS === 'win32' ? ';' : ':'),
HERMES_AGENT_NODE: TARGET_OS === 'win32' ? resolve(NODE_DIR, 'node.exe') : resolve(NODE_DIR, 'bin', 'node'),
HERMES_AGENT_NODE_ROOT: NODE_DIR,
AGENT_BROWSER_HOME, AGENT_BROWSER_HOME,
PLAYWRIGHT_BROWSERS_PATH, PLAYWRIGHT_BROWSERS_PATH,
} }
@@ -447,4 +472,8 @@ if (!SKIP_BROWSER_RUNTIME) {
], { env: browserRuntimeEnv() }) ], { env: browserRuntimeEnv() })
} }
console.log('✓ hermes Python, MCP, websockets, agent-browser, and Chromium checks passed') if (SKIP_BROWSER_RUNTIME) {
console.log('✓ hermes Python, MCP, and websockets checks passed; browser runtime skipped')
} else {
console.log('✓ hermes Python, MCP, websockets, agent-browser, and Chromium checks passed')
}
@@ -0,0 +1,121 @@
#!/usr/bin/env node
// Package prepared Python/Node/Git runtime resources into a release asset.
import {
cpSync,
createReadStream,
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs'
import { createHash } from 'node:crypto'
import { arch as osArch, platform as osPlatform, tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, '..')
const TARGET_OS = process.env.TARGET_OS || osPlatform()
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
const PLATFORM = `${OS_LABEL}-${TARGET_ARCH}`
const OUT_DIR = resolve(ROOT, 'release', 'runtime')
const PY_DIR = resolve(ROOT, 'resources', 'python', PLATFORM)
const NODE_DIR = resolve(ROOT, 'resources', 'node', PLATFORM)
const GIT_DIR = resolve(ROOT, 'resources', 'git', PLATFORM)
const pyBin = TARGET_OS === 'win32'
? resolve(PY_DIR, 'python.exe')
: resolve(PY_DIR, 'bin', 'python3')
function run(command, args, options = {}) {
const result = spawnSync(command, args, { stdio: 'inherit', ...options })
if (result.status !== 0) process.exit(result.status ?? 1)
return result
}
function output(command, args) {
const result = spawnSync(command, args, { encoding: 'utf-8' })
if (result.status !== 0) {
process.stderr.write(result.stderr || result.stdout || '')
process.exit(result.status ?? 1)
}
return result.stdout.trim()
}
async function sha256File(file) {
const hash = createHash('sha256')
await new Promise((resolvePromise, rejectPromise) => {
const stream = createReadStream(file)
stream.on('data', chunk => hash.update(chunk))
stream.on('end', resolvePromise)
stream.on('error', rejectPromise)
})
return hash.digest('hex')
}
for (const dir of [PY_DIR, NODE_DIR]) {
if (!existsSync(dir)) {
console.error(`Runtime directory missing: ${dir}`)
process.exit(1)
}
}
const hermesAgentVersion = output(pyBin, [
'-c',
'import importlib.metadata as m; print(m.version("hermes-agent"))',
])
const assetName = `hermes-runtime-hermes-agent-${hermesAgentVersion}-${PLATFORM}.tar.gz`
const manifestName = `hermes-runtime-${PLATFORM}.json`
mkdirSync(OUT_DIR, { recursive: true })
const stage = mkdtempSync(join(tmpdir(), `hermes-runtime-${PLATFORM}-`))
try {
cpSync(PY_DIR, join(stage, 'python'), { recursive: true, force: true, verbatimSymlinks: true })
cpSync(NODE_DIR, join(stage, 'node'), { recursive: true, force: true, verbatimSymlinks: true })
if (existsSync(GIT_DIR)) {
cpSync(GIT_DIR, join(stage, 'git'), { recursive: true, force: true, verbatimSymlinks: true })
} else {
mkdirSync(join(stage, 'git'), { recursive: true })
writeFileSync(join(stage, 'git', '.placeholder'), 'Git for Windows is only bundled on Windows.\n')
}
const runtimeManifest = {
schema: 1,
platform: PLATFORM,
targetOs: TARGET_OS,
targetArch: TARGET_ARCH,
hermesAgentVersion,
asset: {
name: assetName,
},
}
writeFileSync(join(stage, 'runtime-manifest.json'), JSON.stringify(runtimeManifest, null, 2) + '\n')
const assetPath = resolve(OUT_DIR, assetName)
rmSync(assetPath, { force: true })
run('tar', ['-czf', assetPath, '-C', stage, '.'])
const sha256 = await sha256File(assetPath)
writeFileSync(`${assetPath}.sha256`, `${sha256} ${assetName}\n`)
const platformManifest = {
...runtimeManifest,
createdAt: new Date().toISOString(),
asset: {
name: assetName,
sha256,
size: statSync(assetPath).size,
},
}
writeFileSync(resolve(OUT_DIR, manifestName), JSON.stringify(platformManifest, null, 2) + '\n')
console.log(`Runtime asset: ${assetPath}`)
console.log(`Runtime manifest: ${resolve(OUT_DIR, manifestName)}`)
} finally {
rmSync(stage, { recursive: true, force: true })
}
@@ -0,0 +1,28 @@
#!/usr/bin/env node
import { arch as osArch, platform as osPlatform } from 'node:os'
import { hermesVersion, runtimeReleaseTag } from './runtime-config.mjs'
const TARGET_OS = process.env.TARGET_OS || osPlatform()
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
const HERMES_VERSION = hermesVersion()
const RUNTIME_RELEASE_TAG = runtimeReleaseTag()
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
if (!['win', 'mac', 'linux'].includes(OS_LABEL) || !['x64', 'arm64'].includes(TARGET_ARCH)) {
console.error(`Unsupported runtime target: ${TARGET_OS}-${TARGET_ARCH}`)
process.exit(1)
}
const platform = `${OS_LABEL}-${TARGET_ARCH}`
const asset = `hermes-runtime-hermes-agent-${HERMES_VERSION}-${platform}.tar.gz`
const manifest = `hermes-runtime-${platform}.json`
if (process.argv.includes('--manifest')) {
console.log(manifest)
} else if (process.argv.includes('--platform')) {
console.log(platform)
} else if (process.argv.includes('--release-tag')) {
console.log(RUNTIME_RELEASE_TAG)
} else {
console.log(asset)
}
@@ -0,0 +1,12 @@
export const DEFAULT_HERMES_VERSION = '0.15.2'
export function hermesVersion(env = process.env) {
return env.HERMES_VERSION || DEFAULT_HERMES_VERSION
}
export function runtimeReleaseTag(env = process.env) {
const version = hermesVersion(env)
return env.HERMES_DESKTOP_RUNTIME_RELEASE_TAG
|| env.RUNTIME_RELEASE_TAG
|| `hermes-${version}-runtime`
}
@@ -0,0 +1,14 @@
#!/usr/bin/env node
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { runtimeReleaseTag } from './runtime-config.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, '..')
const outFile = resolve(ROOT, 'build', 'runtime-release.json')
const tag = runtimeReleaseTag()
mkdirSync(dirname(outFile), { recursive: true })
writeFileSync(outFile, JSON.stringify({ tag }, null, 2) + '\n')
console.log(`Runtime release metadata: ${tag}`)
+42 -4
View File
@@ -1,8 +1,20 @@
import { spawn } from 'node:child_process' import { spawn } from 'node:child_process'
import { existsSync, mkdirSync } from 'node:fs' import { existsSync, mkdirSync } from 'node:fs'
import { delimiter, dirname } from 'node:path' import { delimiter, dirname, join } from 'node:path'
import { bundledPython, hermesBin, hermesHome, pythonDir, webUiHome } from './paths' import {
bundledBrowserExecutable,
bundledGit,
bundledNode,
bundledPython,
gitPathDirs,
hermesBin,
hermesHome,
nodeBinDir,
pythonDir,
webUiHome,
} from './paths'
import { HERMES_CLI_ARG } from './cli-constants' import { HERMES_CLI_ARG } from './cli-constants'
import { ensureDesktopRuntime } from './runtime-manager'
export function parseHermesCliArgs(argv: string[] = process.argv): string[] | null { export function parseHermesCliArgs(argv: string[] = process.argv): string[] | null {
const index = argv.indexOf(HERMES_CLI_ARG) const index = argv.indexOf(HERMES_CLI_ARG)
@@ -11,10 +23,17 @@ export function parseHermesCliArgs(argv: string[] = process.argv): string[] | nu
} }
export async function runBundledHermesCli(args: string[]): Promise<number> { export async function runBundledHermesCli(args: string[]): Promise<number> {
try {
await ensureDesktopRuntime()
} catch (err) {
console.error(`Failed to prepare Hermes runtime: ${err instanceof Error ? err.message : String(err)}`)
return 1
}
const command = hermesBin() const command = hermesBin()
if (!existsSync(command)) { if (!existsSync(command)) {
console.error(`hermes binary missing at ${command}`) console.error(`hermes binary missing at ${command}`)
console.error('Run: npm run prepare:python (to bundle Python + hermes-agent)') console.error('Run: npm run prepare:runtime (to build a local Hermes runtime)')
return 127 return 127
} }
@@ -22,7 +41,20 @@ export async function runBundledHermesCli(args: string[]): Promise<number> {
mkdirSync(hermesHome(), { recursive: true }) mkdirSync(hermesHome(), { recursive: true })
const binDir = dirname(command) const binDir = dirname(command)
const pathValue = process.env.PATH ? `${binDir}${delimiter}${process.env.PATH}` : binDir const bundledNodeBin = nodeBinDir()
const bundledAgentBrowserBin = process.platform === 'win32'
? join(pythonDir(), 'node')
: join(pythonDir(), 'node', 'bin')
const inheritedPath = process.env.PATH || process.env.Path || ''
const pathValue = [
binDir,
bundledAgentBrowserBin,
bundledNodeBin,
gitPathDirs().join(delimiter),
inheritedPath,
].filter(Boolean).join(delimiter)
const gitBin = bundledGit()
const browserExecutable = process.env.AGENT_BROWSER_EXECUTABLE_PATH?.trim() || bundledBrowserExecutable()
const env: NodeJS.ProcessEnv = { const env: NodeJS.ProcessEnv = {
...process.env, ...process.env,
HERMES_DESKTOP: 'true', HERMES_DESKTOP: 'true',
@@ -30,6 +62,12 @@ export async function runBundledHermesCli(args: string[]): Promise<number> {
HERMES_AGENT_BRIDGE_PYTHON: bundledPython(), HERMES_AGENT_BRIDGE_PYTHON: bundledPython(),
HERMES_AGENT_CLI_PYTHON: bundledPython(), HERMES_AGENT_CLI_PYTHON: bundledPython(),
HERMES_AGENT_ROOT: pythonDir(), HERMES_AGENT_ROOT: pythonDir(),
HERMES_AGENT_NODE: bundledNode(),
HERMES_AGENT_NODE_ROOT: process.platform === 'win32' ? bundledNodeBin : dirname(bundledNodeBin),
AGENT_BROWSER_HOME: process.env.AGENT_BROWSER_HOME?.trim() || join(hermesHome(), 'agent-browser'),
...(browserExecutable ? { AGENT_BROWSER_EXECUTABLE_PATH: browserExecutable } : {}),
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || join(pythonDir(), 'ms-playwright'),
...(gitBin ? { HERMES_AGENT_GIT: gitBin } : {}),
HERMES_HOME: hermesHome(), HERMES_HOME: hermesHome(),
HERMES_WEB_UI_HOME: webUiHome(), HERMES_WEB_UI_HOME: webUiHome(),
HERMES_WEBUI_STATE_DIR: webUiHome(), HERMES_WEBUI_STATE_DIR: webUiHome(),
+82 -4
View File
@@ -6,6 +6,7 @@ import { checkForDesktopUpdates, initAutoUpdater } from './updater'
import { t } from './desktop-i18n' import { t } from './desktop-i18n'
import { installHermesStudioCliShim } from './cli-shim' import { installHermesStudioCliShim } from './cli-shim'
import { parseHermesCliArgs, runBundledHermesCli } from './hermes-cli' import { parseHermesCliArgs, runBundledHermesCli } from './hermes-cli'
import { ensureDesktopRuntime, type RuntimeProgress } from './runtime-manager'
const PORT = Number(process.env.HERMES_DESKTOP_PORT) || 8748 const PORT = Number(process.env.HERMES_DESKTOP_PORT) || 8748
const START_HIDDEN = process.argv.includes('--hidden') const START_HIDDEN = process.argv.includes('--hidden')
@@ -15,6 +16,7 @@ let mainWindow: BrowserWindow | null = null
let serverUrl: string | null = null let serverUrl: string | null = null
let tray: Tray | null = null let tray: Tray | null = null
let isQuitting = false let isQuitting = false
let isBootstrapping = false
function showMainWindow() { function showMainWindow() {
if (!mainWindow) { if (!mainWindow) {
@@ -168,25 +170,91 @@ function splashHtml(): string {
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Hermes Studio</title> const html = `<!doctype html><html><head><meta charset="utf-8"><title>Hermes Studio</title>
<style> <style>
html,body{margin:0;height:100%;background:#1a1a1a;color:#e5e5e5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;} html,body{margin:0;height:100%;background:#1a1a1a;color:#e5e5e5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;}
.wrap{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:24px} .wrap{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:20px}
.dot{width:10px;height:10px;border-radius:50%;background:#888;animation:pulse 1.2s ease-in-out infinite} .dot{width:10px;height:10px;border-radius:50%;background:#888;animation:pulse 1.2s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:.3}50%{opacity:1}} @keyframes pulse{0%,100%{opacity:.3}50%{opacity:1}}
.row{display:flex;gap:8px} .row{display:flex;gap:8px}
.row .dot:nth-child(2){animation-delay:.2s}.row .dot:nth-child(3){animation-delay:.4s} .row .dot:nth-child(2){animation-delay:.2s}.row .dot:nth-child(3){animation-delay:.4s}
.label{font-size:14px;color:#999} .label{font-size:14px;color:#b8b8b8}
.detail{min-height:18px;font-size:12px;color:#7f7f7f}
.progress{width:320px;height:6px;border-radius:999px;background:#2b2b2b;overflow:hidden}
.bar{width:0;height:100%;background:#d8d8d8;transition:width .18s ease}
h1{font-weight:500;margin:0;font-size:18px} h1{font-weight:500;margin:0;font-size:18px}
</style></head><body><div class="wrap"> </style></head><body><div class="wrap">
<h1>Hermes Studio</h1> <h1>Hermes Studio</h1>
<div class="row"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div> <div class="row"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>
<div class="label">Starting local services</div> <div id="label" class="label">Starting local services...</div>
<div class="progress"><div id="bar" class="bar"></div></div>
<div id="detail" class="detail"></div>
</div></body></html>` </div></body></html>`
return 'data:text/html;charset=utf-8,' + encodeURIComponent(html) return 'data:text/html;charset=utf-8,' + encodeURIComponent(html)
} }
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB']
let value = bytes / 1024
let unit = units[0]
for (let i = 1; i < units.length && value >= 1024; i += 1) {
value /= 1024
unit = units[i]
}
return `${value.toFixed(value >= 100 ? 0 : 1)} ${unit}`
}
function updateSplash(progress: RuntimeProgress) {
if (!mainWindow || mainWindow.isDestroyed()) return
const label = progress.message
const percent = typeof progress.percent === 'number' ? Math.round(progress.percent) : null
let detail = ''
if (progress.receivedBytes && progress.totalBytes) {
detail = `${formatBytes(progress.receivedBytes)} / ${formatBytes(progress.totalBytes)}`
if (percent !== null) detail += ` (${percent}%)`
} else if (percent !== null) {
detail = `${percent}%`
}
mainWindow.webContents.executeJavaScript(`
{
const label = document.getElementById('label');
const detail = document.getElementById('detail');
const bar = document.getElementById('bar');
if (label) label.textContent = ${JSON.stringify(label)};
if (detail) detail.textContent = ${JSON.stringify(detail)};
if (bar) bar.style.width = ${JSON.stringify(percent === null ? '100%' : `${percent}%`)};
}
`).catch(() => undefined)
}
async function bootstrap() { async function bootstrap() {
if (isBootstrapping) return
isBootstrapping = true
try {
await ensureDesktopRuntime(updateSplash)
} catch (err) {
console.error('Failed to prepare Hermes runtime:', err)
if (mainWindow) {
const msg = String(err instanceof Error ? err.message : err).replace(/[<>]/g, '')
mainWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(
`<html><body style="font-family:system-ui;padding:32px;background:#1a1a1a;color:#eee">
<h2>Failed to prepare Hermes runtime</h2><pre style="white-space:pre-wrap;color:#f88">${msg}</pre>
<button id="retry" style="margin-top:16px;padding:8px 14px;border:1px solid #555;border-radius:6px;background:#2b2b2b;color:#eee;cursor:pointer">Retry</button>
<script>
document.getElementById('retry')?.addEventListener('click', () => {
window.hermesDesktop?.retryBootstrap?.()
})
</script>
</body></html>`,
))
}
isBootstrapping = false
return
}
if (!hermesBinExists()) { if (!hermesBinExists()) {
console.error(`hermes binary missing at ${hermesBin()}`) console.error(`hermes binary missing at ${hermesBin()}`)
console.error('Run: npm run prepare:python (to bundle Python + hermes-agent)') console.error('Run: npm run prepare:runtime (to build a local Hermes runtime)')
} }
try { try {
@@ -203,10 +271,20 @@ async function bootstrap() {
</body></html>`, </body></html>`,
)) ))
} }
} finally {
isBootstrapping = false
} }
} }
ipcMain.handle('hermes-desktop:get-token', () => getToken()) ipcMain.handle('hermes-desktop:get-token', () => getToken())
ipcMain.handle('hermes-desktop:retry-bootstrap', async () => {
if (serverUrl) {
await mainWindow?.loadURL(serverUrl)
return
}
await mainWindow?.loadURL(splashHtml())
await bootstrap()
})
function runDesktopApp() { function runDesktopApp() {
const gotLock = app.requestSingleInstanceLock() const gotLock = app.requestSingleInstanceLock()
+61 -4
View File
@@ -23,12 +23,69 @@ export function webuiServerEntry(): string {
return join(webuiDir(), 'dist', 'server', 'index.js') return join(webuiDir(), 'dist', 'server', 'index.js')
} }
// Bundled Python directory. export function runtimePlatformKey(): string {
return `${osLabel}-${archLabel}`
}
export function desktopRuntimeDir(): string {
const override = process.env.HERMES_DESKTOP_RUNTIME_DIR?.trim()
if (override) return resolve(override)
return join(webUiHome(), 'desktop-runtime', runtimePlatformKey())
}
function packagedResourceDir(name: string): string {
return resolve(process.resourcesPath, name)
}
// dev: packages/desktop/resources/python/<os>-<arch> // dev: packages/desktop/resources/python/<os>-<arch>
// prod: <resources>/python // prod: <resources>/python when present, otherwise downloaded runtime cache.
export function pythonDir(): string { export function pythonDir(): string {
if (app.isPackaged) return resolve(process.resourcesPath, 'python') if (app.isPackaged) {
return resolve(app.getAppPath(), 'resources', 'python', `${osLabel}-${archLabel}`) const packaged = packagedResourceDir('python')
return existsSync(packaged) ? packaged : join(desktopRuntimeDir(), 'python')
}
return resolve(app.getAppPath(), 'resources', 'python', runtimePlatformKey())
}
export function nodeDir(): string {
if (app.isPackaged) {
const packaged = packagedResourceDir('node')
return existsSync(packaged) ? packaged : join(desktopRuntimeDir(), 'node')
}
return resolve(app.getAppPath(), 'resources', 'node', runtimePlatformKey())
}
export function nodeBinDir(): string {
const dir = nodeDir()
return isWin ? dir : join(dir, 'bin')
}
export function bundledNode(): string {
return isWin ? join(nodeDir(), 'node.exe') : join(nodeBinDir(), 'node')
}
export function gitDir(): string {
if (app.isPackaged) {
const packaged = packagedResourceDir('git')
return existsSync(packaged) ? packaged : join(desktopRuntimeDir(), 'git')
}
return resolve(app.getAppPath(), 'resources', 'git', runtimePlatformKey())
}
export function gitPathDirs(): string[] {
if (!isWin) return []
const dir = gitDir()
return [
join(dir, 'cmd'),
join(dir, 'mingw64', 'bin'),
join(dir, 'usr', 'bin'),
].filter(existsSync)
}
export function bundledGit(): string | undefined {
if (!isWin) return undefined
const git = join(gitDir(), 'cmd', 'git.exe')
return existsSync(git) ? git : undefined
} }
export function bundledAgentBrowserHome(): string { export function bundledAgentBrowserHome(): string {
@@ -0,0 +1,297 @@
import { execFile } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
createReadStream,
createWriteStream,
existsSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs'
import { get as httpGet } from 'node:http'
import { get as httpsGet } from 'node:https'
import { basename, dirname, join } from 'node:path'
import { promisify } from 'node:util'
import { app } from 'electron'
import {
bundledGit,
bundledNode,
desktopRuntimeDir,
hermesBinExists,
runtimePlatformKey,
} from './paths'
const execFileAsync = promisify(execFile)
const DEFAULT_RUNTIME_BASE_URL = 'https://download.ekkolearnai.com'
const RUNTIME_MANIFEST_NAME = 'runtime-manifest.json'
const PACKAGED_RUNTIME_RELEASE_NAME = 'runtime-release.json'
type RuntimeManifest = {
schema: number
platform: string
hermesAgentVersion?: string
asset?: {
name: string
url?: string
sha256?: string
size?: number
}
}
type RuntimeDescriptor = {
name: string
url: string
sha256?: string
hermesAgentVersion?: string
}
export type RuntimeProgress = {
stage: 'resolve' | 'download' | 'verify' | 'extract' | 'ready'
message: string
percent?: number
receivedBytes?: number
totalBytes?: number
}
type RuntimeProgressHandler = (progress: RuntimeProgress) => void
function runtimeReady(): boolean {
const gitReady = process.platform !== 'win32' || !!bundledGit()
return hermesBinExists() && existsSync(bundledNode()) && gitReady
}
function releaseTagCandidates(): string[] {
const override = process.env.HERMES_DESKTOP_RUNTIME_RELEASE_TAG?.trim()
if (override) return [override]
const version = app.getVersion()
const candidates = [packagedRuntimeReleaseTag(), version, `v${version}`, 'latest']
return Array.from(new Set(candidates.filter((tag): tag is string => typeof tag === 'string' && tag.length > 0)))
}
function packagedRuntimeReleaseTag(): string | null {
const candidates = app.isPackaged
? [join(process.resourcesPath, 'build', PACKAGED_RUNTIME_RELEASE_NAME)]
: [join(app.getAppPath(), 'build', PACKAGED_RUNTIME_RELEASE_NAME)]
for (const candidate of candidates) {
if (!existsSync(candidate)) continue
try {
const metadata = JSON.parse(readFileSync(candidate, 'utf-8')) as { tag?: unknown }
if (typeof metadata.tag === 'string' && metadata.tag.trim()) return metadata.tag.trim()
} catch {}
}
return null
}
function runtimeAssetUrl(assetName: string, tag: string): string {
const repo = process.env.HERMES_DESKTOP_RUNTIME_REPO?.trim()
if (repo) {
if (tag === 'latest') {
return `https://github.com/${repo}/releases/latest/download/${encodeURIComponent(assetName)}`
}
return `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(assetName)}`
}
const template = process.env.HERMES_DESKTOP_RUNTIME_BASE_URL?.trim() || DEFAULT_RUNTIME_BASE_URL
if (template.includes('{asset}') || template.includes('{tag}')) {
return template
.replace(/\{asset\}/g, encodeURIComponent(assetName))
.replace(/\{tag\}/g, encodeURIComponent(tag))
}
return `${template.replace(/\/$/, '')}/${encodeURIComponent(tag)}/${encodeURIComponent(assetName)}`
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`GET ${url} returned ${response.status}`)
}
return await response.json() as T
}
async function resolveRuntimeDescriptor(): Promise<RuntimeDescriptor> {
const directUrl = process.env.HERMES_DESKTOP_RUNTIME_URL?.trim()
if (directUrl) {
return { name: basename(new URL(directUrl).pathname) || 'hermes-runtime.tar.gz', url: directUrl }
}
const platformManifestName = `hermes-runtime-${runtimePlatformKey()}.json`
const manifestOverride = process.env.HERMES_DESKTOP_RUNTIME_MANIFEST_URL?.trim()
const candidates = manifestOverride
? [{ tag: '', url: manifestOverride }]
: releaseTagCandidates().map(tag => ({ tag, url: runtimeAssetUrl(platformManifestName, tag) }))
let lastError: Error | null = null
for (const candidate of candidates) {
try {
const manifest = await fetchJson<RuntimeManifest>(candidate.url)
if (!manifest.asset?.name) {
throw new Error(`runtime manifest is missing asset.name: ${candidate.url}`)
}
return {
name: manifest.asset.name,
url: manifest.asset.url || runtimeAssetUrl(manifest.asset.name, candidate.tag),
sha256: manifest.asset.sha256,
hermesAgentVersion: manifest.hermesAgentVersion,
}
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err))
}
}
throw lastError || new Error('Unable to resolve Hermes desktop runtime package')
}
function readCachedRuntimeManifest(root: string): RuntimeManifest | null {
const file = join(root, RUNTIME_MANIFEST_NAME)
if (!existsSync(file)) return null
try {
return JSON.parse(readFileSync(file, 'utf-8')) as RuntimeManifest
} catch {
return null
}
}
function cachedRuntimeMatches(root: string, descriptor: RuntimeDescriptor): boolean {
if (!runtimeReady()) return false
const manifest = readCachedRuntimeManifest(root)
if (!manifest?.asset?.name) return true
return manifest.asset.name === descriptor.name
}
function downloadFile(
url: string,
target: string,
onProgress?: RuntimeProgressHandler,
redirects = 5,
): Promise<void> {
return new Promise((resolve, reject) => {
const parsed = new URL(url)
const getter = parsed.protocol === 'http:' ? httpGet : httpsGet
const req = getter(parsed, response => {
const status = response.statusCode || 0
const location = response.headers.location
if (status >= 300 && status < 400 && location && redirects > 0) {
response.resume()
downloadFile(new URL(location, url).toString(), target, onProgress, redirects - 1).then(resolve, reject)
return
}
if (status < 200 || status >= 300) {
response.resume()
reject(new Error(`GET ${url} returned ${status}`))
return
}
const totalBytes = Number(response.headers['content-length']) || undefined
let receivedBytes = 0
response.on('data', chunk => {
receivedBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk)
onProgress?.({
stage: 'download',
message: 'Downloading Hermes runtime...',
percent: totalBytes ? Math.min(100, (receivedBytes / totalBytes) * 100) : undefined,
receivedBytes,
totalBytes,
})
})
const file = createWriteStream(target)
response.pipe(file)
file.on('finish', () => file.close(() => resolve()))
file.on('error', reject)
})
req.on('error', reject)
})
}
async function sha256File(file: string): Promise<string> {
const hash = createHash('sha256')
await new Promise<void>((resolve, reject) => {
const stream = createReadStream(file)
stream.on('data', chunk => hash.update(chunk))
stream.on('end', resolve)
stream.on('error', reject)
})
return hash.digest('hex')
}
async function extractRuntimeArchive(archive: string, targetRoot: string): Promise<void> {
const parent = dirname(targetRoot)
const tempRoot = join(parent, `.runtime-${process.pid}-${Date.now()}`)
rmSync(tempRoot, { recursive: true, force: true })
mkdirSync(tempRoot, { recursive: true })
try {
await execFileAsync(process.platform === 'win32' ? 'tar.exe' : 'tar', ['-xzf', archive, '-C', tempRoot], {
windowsHide: true,
})
for (const required of ['python', 'node']) {
if (!existsSync(join(tempRoot, required))) {
throw new Error(`Runtime archive did not contain ${required}/`)
}
}
rmSync(targetRoot, { recursive: true, force: true })
mkdirSync(parent, { recursive: true })
renameSync(tempRoot, targetRoot)
} catch (err) {
rmSync(tempRoot, { recursive: true, force: true })
throw err
}
}
export async function ensureDesktopRuntime(onProgress?: RuntimeProgressHandler): Promise<void> {
const runtimeRoot = desktopRuntimeDir()
mkdirSync(runtimeRoot, { recursive: true })
let descriptor: RuntimeDescriptor
try {
onProgress?.({ stage: 'resolve', message: 'Checking Hermes runtime...' })
descriptor = await resolveRuntimeDescriptor()
} catch (err) {
if (runtimeReady() && !process.env.HERMES_DESKTOP_RUNTIME_FORCE_UPDATE) {
console.warn(`[runtime] using cached Hermes runtime because update check failed: ${err instanceof Error ? err.message : String(err)}`)
return
}
throw err
}
if (cachedRuntimeMatches(runtimeRoot, descriptor) && !process.env.HERMES_DESKTOP_RUNTIME_FORCE_UPDATE) return
const archive = join(dirname(runtimeRoot), `${descriptor.name}.download`)
console.log(`[runtime] downloading Hermes runtime ${descriptor.name}`)
onProgress?.({ stage: 'download', message: `Downloading ${descriptor.name}...` })
await downloadFile(descriptor.url, archive, onProgress)
if (descriptor.sha256) {
onProgress?.({ stage: 'verify', message: 'Verifying Hermes runtime...' })
const actual = await sha256File(archive)
if (actual !== descriptor.sha256) {
throw new Error(`Runtime checksum mismatch for ${descriptor.name}`)
}
}
onProgress?.({ stage: 'extract', message: 'Extracting Hermes runtime...' })
await extractRuntimeArchive(archive, runtimeRoot)
const archiveSize = statSync(archive).size
rmSync(archive, { force: true })
const manifestPath = join(runtimeRoot, RUNTIME_MANIFEST_NAME)
if (!existsSync(manifestPath)) {
writeFileSync(manifestPath, JSON.stringify({
schema: 1,
platform: runtimePlatformKey(),
hermesAgentVersion: descriptor.hermesAgentVersion,
asset: {
name: descriptor.name,
sha256: descriptor.sha256,
size: archiveSize,
},
}, null, 2))
}
onProgress?.({ stage: 'ready', message: 'Hermes runtime ready.' })
console.log(`[runtime] Hermes runtime ready at ${runtimeRoot}`)
}
+14 -1
View File
@@ -8,11 +8,15 @@ import { promisify } from 'node:util'
import { app } from 'electron' import { app } from 'electron'
import { import {
bundledBrowserExecutable, bundledBrowserExecutable,
bundledGit,
bundledNode,
gitPathDirs,
webuiServerEntry, webuiServerEntry,
webuiDir, webuiDir,
hermesBin, hermesBin,
webUiHome, webUiHome,
hermesHome, hermesHome,
nodeBinDir,
tokenFile, tokenFile,
pythonDir, pythonDir,
} from './paths' } from './paths'
@@ -296,22 +300,28 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
const bundledPython = isWin const bundledPython = isWin
? join(pythonDir(), 'python.exe') ? join(pythonDir(), 'python.exe')
: join(pythonDir(), 'bin', 'python3') : join(pythonDir(), 'bin', 'python3')
const bundledNodeBin = isWin const bundledAgentBrowserBin = isWin
? join(pythonDir(), 'node') ? join(pythonDir(), 'node')
: join(pythonDir(), 'node', 'bin') : join(pythonDir(), 'node', 'bin')
const bundledNodeBin = nodeBinDir()
const bundledGitPath = gitPathDirs().join(delimiter)
const bridgePort = await getFreeTcpPort() const bridgePort = await getFreeTcpPort()
const workerPortBase = await getFreeTcpPortInRange(20000, 59000) const workerPortBase = await getFreeTcpPortInRange(20000, 59000)
const loginShellPath = await getLoginShellPath() const loginShellPath = await getLoginShellPath()
const nvmNodeBinPaths = getNvmNodeBinPaths() const nvmNodeBinPaths = getNvmNodeBinPaths()
const runtimePath = mergePathEntries( const runtimePath = mergePathEntries(
dirname(hermesBin()), dirname(hermesBin()),
bundledAgentBrowserBin,
bundledNodeBin, bundledNodeBin,
bundledGitPath,
loginShellPath, loginShellPath,
nvmNodeBinPaths, nvmNodeBinPaths,
process.env.PATH, process.env.PATH,
process.env.Path,
COMMON_USER_BIN_DIRS.join(delimiter), COMMON_USER_BIN_DIRS.join(delimiter),
) )
const browserExecutable = process.env.AGENT_BROWSER_EXECUTABLE_PATH?.trim() || bundledBrowserExecutable() const browserExecutable = process.env.AGENT_BROWSER_EXECUTABLE_PATH?.trim() || bundledBrowserExecutable()
const gitBin = bundledGit()
// Run via Electron's "run as Node" mode — Electron binary doubles as Node. // Run via Electron's "run as Node" mode — Electron binary doubles as Node.
const env: NodeJS.ProcessEnv = { const env: NodeJS.ProcessEnv = {
@@ -326,9 +336,12 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
HERMES_AGENT_BRIDGE_PYTHON: bundledPython, HERMES_AGENT_BRIDGE_PYTHON: bundledPython,
HERMES_AGENT_CLI_PYTHON: bundledPython, HERMES_AGENT_CLI_PYTHON: bundledPython,
HERMES_AGENT_ROOT: pythonDir(), HERMES_AGENT_ROOT: pythonDir(),
HERMES_AGENT_NODE: bundledNode(),
HERMES_AGENT_NODE_ROOT: isWin ? bundledNodeBin : dirname(bundledNodeBin),
AGENT_BROWSER_HOME: process.env.AGENT_BROWSER_HOME?.trim() || join(agentHome, 'agent-browser'), AGENT_BROWSER_HOME: process.env.AGENT_BROWSER_HOME?.trim() || join(agentHome, 'agent-browser'),
...(browserExecutable ? { AGENT_BROWSER_EXECUTABLE_PATH: browserExecutable } : {}), ...(browserExecutable ? { AGENT_BROWSER_EXECUTABLE_PATH: browserExecutable } : {}),
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || join(pythonDir(), 'ms-playwright'), PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || join(pythonDir(), 'ms-playwright'),
...(gitBin ? { HERMES_AGENT_GIT: gitBin } : {}),
// Force TCP loopback for the agent bridge. The default `ipc:///tmp/...` // Force TCP loopback for the agent bridge. The default `ipc:///tmp/...`
// unix socket is rejected on macOS in some EDR/sandbox setups (silent // unix socket is rejected on macOS in some EDR/sandbox setups (silent
// SIGKILL of the bridge child within ~150ms). TCP on 127.0.0.1 works // SIGKILL of the bridge child within ~150ms). TCP on 127.0.0.1 works
+1
View File
@@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('hermesDesktop', { contextBridge.exposeInMainWorld('hermesDesktop', {
getToken: (): Promise<string> => ipcRenderer.invoke('hermes-desktop:get-token'), getToken: (): Promise<string> => ipcRenderer.invoke('hermes-desktop:get-token'),
retryBootstrap: (): Promise<void> => ipcRenderer.invoke('hermes-desktop:retry-bootstrap'),
platform: process.platform, platform: process.platform,
isDesktop: true, isDesktop: true,
}) })
+57
View File
@@ -117,9 +117,14 @@ if (!buildWorkflow.includes('npm run harness:check')) {
} }
const desktopReleaseWorkflow = await readText('.github/workflows/desktop-release.yml') const desktopReleaseWorkflow = await readText('.github/workflows/desktop-release.yml')
const desktopRuntimeWorkflow = await readText('.github/workflows/desktop-runtime.yml')
const electronBuilderConfig = await readText('packages/desktop/electron-builder.yml') const electronBuilderConfig = await readText('packages/desktop/electron-builder.yml')
const desktopPackageJson = await readText('packages/desktop/package.json')
const desktopInstallHermes = await readText('packages/desktop/scripts/install-hermes.mjs') const desktopInstallHermes = await readText('packages/desktop/scripts/install-hermes.mjs')
const desktopWebuiServer = await readText('packages/desktop/src/main/webui-server.ts') const desktopWebuiServer = await readText('packages/desktop/src/main/webui-server.ts')
const desktopRuntimeManager = await readText('packages/desktop/src/main/runtime-manager.ts')
const desktopPaths = await readText('packages/desktop/src/main/paths.ts')
const desktopRuntimeAssetName = await readText('packages/desktop/scripts/runtime-asset-name.mjs')
if (!desktopReleaseWorkflow.includes('files: ${{ matrix.artifact_files }}')) { if (!desktopReleaseWorkflow.includes('files: ${{ matrix.artifact_files }}')) {
fail('desktop-release.yml must upload matrix-specific artifact_files') fail('desktop-release.yml must upload matrix-specific artifact_files')
} }
@@ -144,6 +149,42 @@ if (!desktopReleaseWorkflow.includes('fail_on_unmatched_files: true')) {
fail('desktop-release.yml must keep fail_on_unmatched_files: true') fail('desktop-release.yml must keep fail_on_unmatched_files: true')
} }
for (const phrase of [
'resources/python/${os}-${arch}',
'resources/node/${os}-${arch}',
'resources/git/${os}-${arch}',
]) {
if (electronBuilderConfig.includes(phrase)) {
fail(`electron-builder.yml must not bundle desktop runtime resource: ${phrase}`)
}
}
for (const phrase of [
'"fetch:node"',
'"fetch:git"',
'"prepare:runtime"',
'"package:runtime"',
'"runtime:asset-name"',
]) {
if (!desktopPackageJson.includes(phrase)) {
fail(`packages/desktop/package.json must support runtime package publishing: ${phrase}`)
}
}
for (const phrase of [
'steps.check.outputs.missing',
'npm --prefix packages/desktop run prepare:runtime',
'npm --prefix packages/desktop run package:runtime',
]) {
if (!desktopRuntimeWorkflow.includes(phrase)) {
fail(`desktop-runtime.yml must build and publish missing runtime package assets: ${phrase}`)
}
}
if (!desktopRuntimeAssetName.includes('hermes-runtime-hermes-agent-')) {
fail('runtime asset naming must include hermes-agent version')
}
for (const phrase of [ for (const phrase of [
'websockets', 'websockets',
'agent-browser@^0.26.0', 'agent-browser@^0.26.0',
@@ -160,6 +201,8 @@ for (const phrase of [
for (const phrase of [ for (const phrase of [
'bundledNodeBin', 'bundledNodeBin',
'HERMES_AGENT_NODE',
'HERMES_AGENT_GIT',
'PLAYWRIGHT_BROWSERS_PATH', 'PLAYWRIGHT_BROWSERS_PATH',
'ms-playwright', 'ms-playwright',
]) { ]) {
@@ -168,6 +211,20 @@ for (const phrase of [
} }
} }
for (const phrase of [
'HERMES_DESKTOP_RUNTIME_URL',
'HERMES_DESKTOP_RUNTIME_BASE_URL',
'runtime-manifest.json',
]) {
if (!desktopRuntimeManager.includes(phrase)) {
fail(`desktop runtime manager must support downloadable runtime packages: ${phrase}`)
}
}
if (!desktopPaths.includes('HERMES_DESKTOP_RUNTIME_DIR')) {
fail('desktop paths must allow HERMES_DESKTOP_RUNTIME_DIR override')
}
if (failures.length > 0) { if (failures.length > 0) {
console.error('Harness check failed:') console.error('Harness check failed:')
for (const failure of failures) { for (const failure of failures) {