diff --git a/.github/workflows/desktop-manual-build.yml b/.github/workflows/desktop-manual-build.yml index 0d6b393..a27f457 100644 --- a/.github/workflows/desktop-manual-build.yml +++ b/.github/workflows/desktop-manual-build.yml @@ -24,6 +24,10 @@ on: description: "Optional release tag to attach artifacts to" required: false type: string + runtime_release_tag: + description: "Optional runtime release tag embedded into the desktop app" + required: false + type: string permissions: contents: write @@ -121,9 +125,6 @@ jobs: package-lock.json packages/desktop/package-lock.json - - name: Install uv - uses: astral-sh/setup-uv@v3 - - name: Install web UI dependencies run: | npm ci --ignore-scripts @@ -138,11 +139,11 @@ jobs: - name: Install desktop dependencies run: npm ci --prefix packages/desktop --no-audit --no-fund - - name: Prepare bundled Python + - name: Write runtime release metadata + shell: bash env: - TARGET_OS: ${{ needs.validate.outputs.target_os }} - TARGET_ARCH: ${{ needs.validate.outputs.target_arch }} - run: npm --prefix packages/desktop run prepare:python + RUNTIME_RELEASE_TAG: ${{ github.event.inputs.runtime_release_tag }} + run: npm --prefix packages/desktop run write:runtime-release - name: Configure macOS signing if: needs.validate.outputs.target_os == 'darwin' diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index c52f673..945e604 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -86,9 +86,6 @@ jobs: package-lock.json packages/desktop/package-lock.json - - name: Install uv - uses: astral-sh/setup-uv@v3 - - name: Install web UI dependencies run: | npm ci --ignore-scripts @@ -103,11 +100,11 @@ jobs: - name: Install desktop dependencies run: npm ci --prefix packages/desktop --no-audit --no-fund - - name: Prepare bundled Python + - name: Write runtime release metadata + shell: bash env: - TARGET_OS: ${{ matrix.target_os }} - TARGET_ARCH: ${{ matrix.target_arch }} - run: npm --prefix packages/desktop run prepare:python + HERMES_DESKTOP_RUNTIME_RELEASE_TAG: ${{ vars.HERMES_DESKTOP_RUNTIME_RELEASE_TAG }} + run: npm --prefix packages/desktop run write:runtime-release - name: Configure macOS signing if: matrix.target_os == 'darwin' diff --git a/.github/workflows/desktop-runtime.yml b/.github/workflows/desktop-runtime.yml new file mode 100644 index 0000000..2bf6d13 --- /dev/null +++ b/.github/workflows/desktop-runtime.yml @@ -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 }} diff --git a/package.json b/package.json index 5ddba0b..288842e 100644 --- a/package.json +++ b/package.json @@ -50,11 +50,12 @@ "build:website": "vite build --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:prepare-runtime": "npm --prefix packages/desktop run prepare:runtime", "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:mac": "npm run build && npm run desktop:install && npm run desktop:prepare-python && 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:linux": "npm run build && npm run desktop:install && npm run desktop:prepare-python && npm --prefix packages/desktop run dist -- --linux --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 --prefix packages/desktop run dist -- --mac --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 --prefix packages/desktop run dist -- --linux --publish never", "openapi:generate": "node scripts/generate-openapi.mjs", "claude": "claude --dangerously-skip-permissions" }, @@ -131,4 +132,4 @@ "vue-virtual-scroller": "^3.0.4", "ws": "^8.20.0" } -} \ No newline at end of file +} diff --git a/packages/desktop/build/runtime-release.json b/packages/desktop/build/runtime-release.json new file mode 100644 index 0000000..c5b3b64 --- /dev/null +++ b/packages/desktop/build/runtime-release.json @@ -0,0 +1,3 @@ +{ + "tag": "hermes-0.15.2-runtime" +} diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index d927442..c7189e6 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -22,7 +22,8 @@ files: - "!**/node_modules/.bin" - "!**/{.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. extraResources: - from: "build" @@ -32,6 +33,7 @@ extraResources: - "icon.ico" - "trayTemplate.png" - "trayWindows.png" + - "runtime-release.json" - from: "../.." to: "webui" filter: @@ -44,10 +46,6 @@ extraResources: - "!packages/desktop/**" - "!**/{.git,.github,docs,tests,playwright.config.ts,README*,scripts,*.map}" - "!node_modules/**/*.md" - - from: "resources/python/${os}-${arch}" - to: "python" - filter: - - "**/*" asarUnpack: - "**/*.node" diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index a4bf9c1..85f67e2 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "hermes-studio", "version": "0.6.7", - "license": "MIT", + "license": "BSL-1.1", "dependencies": { "electron-updater": "^6.3.9" }, @@ -4613,4 +4613,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/desktop/package.json b/packages/desktop/package.json index aa032ec..bf18918 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -7,16 +7,22 @@ "name": "Hermes Studio Contributors", "email": "noreply@hermes-studio.local" }, - "license": "MIT", + "license": "BSL-1.1", "private": true, "main": "dist/main/index.js", "scripts": { "build:main": "tsc -p tsconfig.json", "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", "install:hermes": "node scripts/install-hermes.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", "dev": "npm run build:main && electron .", "dist": "npm run build && electron-builder", @@ -33,4 +39,4 @@ "dependencies": { "electron-updater": "^6.3.9" } -} \ No newline at end of file +} diff --git a/packages/desktop/scripts/fetch-git.mjs b/packages/desktop/scripts/fetch-git.mjs new file mode 100644 index 0000000..5a4bf24 --- /dev/null +++ b/packages/desktop/scripts/fetch-git.mjs @@ -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}`) diff --git a/packages/desktop/scripts/fetch-node.mjs b/packages/desktop/scripts/fetch-node.mjs new file mode 100644 index 0000000..724be1e --- /dev/null +++ b/packages/desktop/scripts/fetch-node.mjs @@ -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/-/. +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}`) diff --git a/packages/desktop/scripts/install-hermes.mjs b/packages/desktop/scripts/install-hermes.mjs index 4a510ed..5ac1199 100644 --- a/packages/desktop/scripts/install-hermes.mjs +++ b/packages/desktop/scripts/install-hermes.mjs @@ -18,13 +18,14 @@ import { basename, resolve, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { spawnSync } from 'node:child_process' 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 ROOT = resolve(__dirname, '..') const TARGET_OS = process.env.TARGET_OS || osPlatform() 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. // Telegram, Discord, and Slack are covered by "messaging". We intentionally // 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 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 AGENT_BROWSER_HOME = resolve(PY_DIR, 'agent-browser') const PLAYWRIGHT_BROWSERS_PATH = resolve(PY_DIR, 'ms-playwright') @@ -96,7 +98,8 @@ function optionalRun(command, args, options = {}) { function commandInvocation(command) { 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: [] } } @@ -109,15 +112,25 @@ function optionalRunInvocation(invocation, 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) { if (packages.length === 0) return + const env = pythonBuildEnv() if (hasUv()) { console.log(`→ Installing ${label} via uv: ${packages.join(' ')}`) run('uv', [ - 'pip', 'install', - '--python', pyBin, + 'pip', 'install', + '--python', pyBin, ...packages, - ]) + ], { env }) } else { console.log(`→ Installing ${label} via pip: ${packages.join(' ')}`) run(pyBin, [ @@ -125,17 +138,21 @@ function installPythonPackages(packages, label) { ...packages, '--no-warn-script-location', '--disable-pip-version-check', - ]) + ], { env }) } } function npmCommand() { + const bundled = TARGET_OS === 'win32' + ? resolve(NODE_DIR, 'npm.cmd') + : resolve(NODE_DIR, 'bin', 'npm') const candidates = TARGET_OS === 'win32' - ? ['npm.cmd', 'npm.exe', 'npm'] - : ['npm'] + ? [bundled, 'npm.cmd', 'npm.exe', 'npm'] + : [bundled, 'npm'] for (const candidate of candidates) { + if (candidate === bundled && !existsSync(candidate)) continue 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 } return null @@ -148,16 +165,24 @@ function agentBrowserCommand() { 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' ? NODE_PREFIX : resolve(NODE_PREFIX, 'bin') const inheritedPath = process.env.PATH || process.env.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 = { ...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, PLAYWRIGHT_BROWSERS_PATH, } @@ -447,4 +472,8 @@ if (!SKIP_BROWSER_RUNTIME) { ], { 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') +} diff --git a/packages/desktop/scripts/package-runtime.mjs b/packages/desktop/scripts/package-runtime.mjs new file mode 100644 index 0000000..4783ebf --- /dev/null +++ b/packages/desktop/scripts/package-runtime.mjs @@ -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 }) +} diff --git a/packages/desktop/scripts/runtime-asset-name.mjs b/packages/desktop/scripts/runtime-asset-name.mjs new file mode 100644 index 0000000..65e1173 --- /dev/null +++ b/packages/desktop/scripts/runtime-asset-name.mjs @@ -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) +} diff --git a/packages/desktop/scripts/runtime-config.mjs b/packages/desktop/scripts/runtime-config.mjs new file mode 100644 index 0000000..58ca082 --- /dev/null +++ b/packages/desktop/scripts/runtime-config.mjs @@ -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` +} diff --git a/packages/desktop/scripts/write-runtime-release.mjs b/packages/desktop/scripts/write-runtime-release.mjs new file mode 100644 index 0000000..0096669 --- /dev/null +++ b/packages/desktop/scripts/write-runtime-release.mjs @@ -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}`) diff --git a/packages/desktop/src/main/hermes-cli.ts b/packages/desktop/src/main/hermes-cli.ts index 50f5143..7eeb8f3 100644 --- a/packages/desktop/src/main/hermes-cli.ts +++ b/packages/desktop/src/main/hermes-cli.ts @@ -1,8 +1,20 @@ import { spawn } from 'node:child_process' import { existsSync, mkdirSync } from 'node:fs' -import { delimiter, dirname } from 'node:path' -import { bundledPython, hermesBin, hermesHome, pythonDir, webUiHome } from './paths' +import { delimiter, dirname, join } from 'node:path' +import { + bundledBrowserExecutable, + bundledGit, + bundledNode, + bundledPython, + gitPathDirs, + hermesBin, + hermesHome, + nodeBinDir, + pythonDir, + webUiHome, +} from './paths' import { HERMES_CLI_ARG } from './cli-constants' +import { ensureDesktopRuntime } from './runtime-manager' export function parseHermesCliArgs(argv: string[] = process.argv): string[] | null { 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 { + try { + await ensureDesktopRuntime() + } catch (err) { + console.error(`Failed to prepare Hermes runtime: ${err instanceof Error ? err.message : String(err)}`) + return 1 + } + const command = hermesBin() if (!existsSync(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 } @@ -22,7 +41,20 @@ export async function runBundledHermesCli(args: string[]): Promise { mkdirSync(hermesHome(), { recursive: true }) 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 = { ...process.env, HERMES_DESKTOP: 'true', @@ -30,6 +62,12 @@ export async function runBundledHermesCli(args: string[]): Promise { HERMES_AGENT_BRIDGE_PYTHON: bundledPython(), HERMES_AGENT_CLI_PYTHON: bundledPython(), 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_WEB_UI_HOME: webUiHome(), HERMES_WEBUI_STATE_DIR: webUiHome(), diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 19f08e9..d0d319a 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -6,6 +6,7 @@ import { checkForDesktopUpdates, initAutoUpdater } from './updater' import { t } from './desktop-i18n' import { installHermesStudioCliShim } from './cli-shim' import { parseHermesCliArgs, runBundledHermesCli } from './hermes-cli' +import { ensureDesktopRuntime, type RuntimeProgress } from './runtime-manager' const PORT = Number(process.env.HERMES_DESKTOP_PORT) || 8748 const START_HIDDEN = process.argv.includes('--hidden') @@ -15,6 +16,7 @@ let mainWindow: BrowserWindow | null = null let serverUrl: string | null = null let tray: Tray | null = null let isQuitting = false +let isBootstrapping = false function showMainWindow() { if (!mainWindow) { @@ -168,25 +170,91 @@ function splashHtml(): string { const html = `Hermes Studio

Hermes Studio

-
Starting local services…
+
Starting local services...
+
+
` 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() { + 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( + ` +

Failed to prepare Hermes runtime

${msg}
+ + + `, + )) + } + isBootstrapping = false + return + } + if (!hermesBinExists()) { 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 { @@ -203,10 +271,20 @@ async function bootstrap() { `, )) } + } finally { + isBootstrapping = false } } 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() { const gotLock = app.requestSingleInstanceLock() diff --git a/packages/desktop/src/main/paths.ts b/packages/desktop/src/main/paths.ts index 376a41d..b722ec4 100644 --- a/packages/desktop/src/main/paths.ts +++ b/packages/desktop/src/main/paths.ts @@ -23,12 +23,69 @@ export function webuiServerEntry(): string { 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/- -// prod: /python +// prod: /python when present, otherwise downloaded runtime cache. export function pythonDir(): string { - if (app.isPackaged) return resolve(process.resourcesPath, 'python') - return resolve(app.getAppPath(), 'resources', 'python', `${osLabel}-${archLabel}`) + if (app.isPackaged) { + 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 { diff --git a/packages/desktop/src/main/runtime-manager.ts b/packages/desktop/src/main/runtime-manager.ts new file mode 100644 index 0000000..eb511a4 --- /dev/null +++ b/packages/desktop/src/main/runtime-manager.ts @@ -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(url: string): Promise { + 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 { + 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(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 { + 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 { + const hash = createHash('sha256') + await new Promise((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 { + 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 { + 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}`) +} diff --git a/packages/desktop/src/main/webui-server.ts b/packages/desktop/src/main/webui-server.ts index 6e6292b..ae584a7 100644 --- a/packages/desktop/src/main/webui-server.ts +++ b/packages/desktop/src/main/webui-server.ts @@ -8,11 +8,15 @@ import { promisify } from 'node:util' import { app } from 'electron' import { bundledBrowserExecutable, + bundledGit, + bundledNode, + gitPathDirs, webuiServerEntry, webuiDir, hermesBin, webUiHome, hermesHome, + nodeBinDir, tokenFile, pythonDir, } from './paths' @@ -296,22 +300,28 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise { const bundledPython = isWin ? join(pythonDir(), 'python.exe') : join(pythonDir(), 'bin', 'python3') - const bundledNodeBin = isWin + const bundledAgentBrowserBin = isWin ? join(pythonDir(), 'node') : join(pythonDir(), 'node', 'bin') + const bundledNodeBin = nodeBinDir() + const bundledGitPath = gitPathDirs().join(delimiter) const bridgePort = await getFreeTcpPort() const workerPortBase = await getFreeTcpPortInRange(20000, 59000) const loginShellPath = await getLoginShellPath() const nvmNodeBinPaths = getNvmNodeBinPaths() const runtimePath = mergePathEntries( dirname(hermesBin()), + bundledAgentBrowserBin, bundledNodeBin, + bundledGitPath, loginShellPath, nvmNodeBinPaths, process.env.PATH, + process.env.Path, COMMON_USER_BIN_DIRS.join(delimiter), ) 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. const env: NodeJS.ProcessEnv = { @@ -326,9 +336,12 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise { HERMES_AGENT_BRIDGE_PYTHON: bundledPython, HERMES_AGENT_CLI_PYTHON: bundledPython, 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'), ...(browserExecutable ? { AGENT_BROWSER_EXECUTABLE_PATH: browserExecutable } : {}), 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/...` // 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 diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index f9157d5..547db69 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron' contextBridge.exposeInMainWorld('hermesDesktop', { getToken: (): Promise => ipcRenderer.invoke('hermes-desktop:get-token'), + retryBootstrap: (): Promise => ipcRenderer.invoke('hermes-desktop:retry-bootstrap'), platform: process.platform, isDesktop: true, }) diff --git a/scripts/harness-check.mjs b/scripts/harness-check.mjs index 7d3a5ab..c26db24 100644 --- a/scripts/harness-check.mjs +++ b/scripts/harness-check.mjs @@ -117,9 +117,14 @@ if (!buildWorkflow.includes('npm run harness:check')) { } 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 desktopPackageJson = await readText('packages/desktop/package.json') const desktopInstallHermes = await readText('packages/desktop/scripts/install-hermes.mjs') 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 }}')) { 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') } +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 [ 'websockets', 'agent-browser@^0.26.0', @@ -160,6 +201,8 @@ for (const phrase of [ for (const phrase of [ 'bundledNodeBin', + 'HERMES_AGENT_NODE', + 'HERMES_AGENT_GIT', 'PLAYWRIGHT_BROWSERS_PATH', '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) { console.error('Harness check failed:') for (const failure of failures) {