Add desktop (Electron) packaging and release distribution (#1147)
* Add desktop packaging workflow * Add desktop package homepage * Fix desktop default credential prompt * Suppress default credential prompt on desktop * Publish desktop artifacts on release; reduce CI to PR smoke test Add desktop-release.yml triggered on release publish (mirroring docker-publish.yml) to build all platforms and upload .dmg/.exe/ .AppImage/.deb to the GitHub Release. Trim build.yml desktop job to a PR-only Linux x64 smoke test, since release artifacts are now produced by desktop-release.yml. This drops per-push and macOS/Windows packaging from regular CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix desktop Hermes data home on Windows --------- Co-authored-by: xingzhi <chuzihao.czh@alibaba-inc.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -41,3 +41,70 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
# Smoke test only: verify desktop packaging still works on pull requests.
|
||||
# Full multi-platform release artifacts are built by desktop-release.yml on release.
|
||||
desktop:
|
||||
name: Desktop smoke test (${{ matrix.label }})
|
||||
needs: build
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- label: Linux x64
|
||||
runner: ubuntu-22.04
|
||||
target_os: linux
|
||||
target_arch: x64
|
||||
electron_target: "--linux AppImage deb --x64"
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
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
|
||||
npm rebuild node-pty
|
||||
|
||||
- name: Build web UI
|
||||
run: npm run build
|
||||
|
||||
- name: Keep production web UI dependencies only
|
||||
run: npm prune --omit=dev --no-audit --no-fund
|
||||
|
||||
- name: Install desktop dependencies
|
||||
run: npm ci --prefix packages/desktop --no-audit --no-fund
|
||||
|
||||
- name: Prepare bundled Python
|
||||
env:
|
||||
TARGET_OS: ${{ matrix.target_os }}
|
||||
TARGET_ARCH: ${{ matrix.target_arch }}
|
||||
run: npm --prefix packages/desktop run prepare:python
|
||||
|
||||
- name: Build desktop artifact
|
||||
run: npm --prefix packages/desktop run dist -- ${{ matrix.electron_target }} --publish never
|
||||
|
||||
- name: Upload desktop artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-${{ matrix.target_os }}-${{ matrix.target_arch }}
|
||||
path: |
|
||||
packages/desktop/release/*.AppImage
|
||||
packages/desktop/release/*.deb
|
||||
packages/desktop/release/latest*.yml
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
name: Publish Desktop Artifacts to Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing release tag to attach artifacts to (e.g. v0.6.5)"
|
||||
required: true
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: desktop-release-${{ github.event.release.tag_name || github.event.inputs.tag || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
desktop:
|
||||
name: Desktop (${{ matrix.label }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- label: macOS arm64
|
||||
runner: macos-14
|
||||
target_os: darwin
|
||||
target_arch: arm64
|
||||
electron_target: "--mac dmg --arm64"
|
||||
- label: macOS x64
|
||||
runner: macos-15-intel
|
||||
target_os: darwin
|
||||
target_arch: x64
|
||||
electron_target: "--mac dmg --x64"
|
||||
- label: Windows x64
|
||||
runner: windows-latest
|
||||
target_os: win32
|
||||
target_arch: x64
|
||||
electron_target: "--win nsis --x64"
|
||||
- label: Linux x64
|
||||
runner: ubuntu-22.04
|
||||
target_os: linux
|
||||
target_arch: x64
|
||||
electron_target: "--linux AppImage deb --x64"
|
||||
- label: Linux arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
target_os: linux
|
||||
target_arch: arm64
|
||||
electron_target: "--linux AppImage --arm64"
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name || github.event.inputs.tag }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
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
|
||||
npm rebuild node-pty
|
||||
|
||||
- name: Build web UI
|
||||
run: npm run build
|
||||
|
||||
- name: Keep production web UI dependencies only
|
||||
run: npm prune --omit=dev --no-audit --no-fund
|
||||
|
||||
- name: Install desktop dependencies
|
||||
run: npm ci --prefix packages/desktop --no-audit --no-fund
|
||||
|
||||
- name: Prepare bundled Python
|
||||
env:
|
||||
TARGET_OS: ${{ matrix.target_os }}
|
||||
TARGET_ARCH: ${{ matrix.target_arch }}
|
||||
run: npm --prefix packages/desktop run prepare:python
|
||||
|
||||
- name: Build desktop artifact
|
||||
run: npm --prefix packages/desktop run dist -- ${{ matrix.electron_target }} --publish never
|
||||
|
||||
- name: Upload artifacts to release
|
||||
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/*.dmg
|
||||
packages/desktop/release/*.dmg.blockmap
|
||||
packages/desktop/release/*.exe
|
||||
packages/desktop/release/*.exe.blockmap
|
||||
packages/desktop/release/*.AppImage
|
||||
packages/desktop/release/*.deb
|
||||
packages/desktop/release/latest*.yml
|
||||
@@ -24,6 +24,10 @@ pnpm-workspace.yaml
|
||||
packages/server/data/
|
||||
packages/server/node_modules/
|
||||
.hermes-web-ui/
|
||||
packages/desktop/dist/
|
||||
packages/desktop/release/
|
||||
packages/desktop/resources/python/
|
||||
packages/desktop/node_modules/
|
||||
|
||||
# Hermes config files (should be in user data directory, not project root)
|
||||
config.yaml
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
"dev:website": "vite --config vite.config.website.ts",
|
||||
"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-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",
|
||||
"openapi:generate": "node scripts/generate-openapi.mjs",
|
||||
"claude": "claude --dangerously-skip-permissions"
|
||||
},
|
||||
|
||||
@@ -19,7 +19,16 @@ function dismissalKey(userId: number): string {
|
||||
return `hermes_default_credentials_prompt_dismissed_${userId}`;
|
||||
}
|
||||
|
||||
function isDesktopShell(): boolean {
|
||||
return (window as typeof window & { hermesDesktop?: { isDesktop?: boolean } }).hermesDesktop?.isDesktop === true;
|
||||
}
|
||||
|
||||
async function checkDefaultCredentials() {
|
||||
if (isDesktopShell()) {
|
||||
show.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (route.name === "login") {
|
||||
show.value = false;
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Hermes Desktop
|
||||
|
||||
Electron desktop distribution for Hermes Web UI.
|
||||
|
||||
## Data directories
|
||||
|
||||
Hermes Agent data is stored in the same platform-specific location as native
|
||||
Hermes installs:
|
||||
|
||||
- Windows: `%LOCALAPPDATA%\hermes` (falls back to `%APPDATA%\hermes`)
|
||||
- macOS/Linux: `~/.hermes`
|
||||
|
||||
The desktop wrapper's own Web UI state is stored separately in
|
||||
`~/.hermes-web-ui` unless `HERMES_WEB_UI_HOME` is set.
|
||||
|
||||
## China mirror environment
|
||||
|
||||
These mirrors are optional and are not required in CI:
|
||||
|
||||
```sh
|
||||
export NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
|
||||
export ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||
export ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
```
|
||||
|
||||
If GitHub release downloads are slow, `fetch-python.mjs` can also use a compatible
|
||||
python-build-standalone release mirror:
|
||||
|
||||
```sh
|
||||
export PBS_BASE_URL=https://github.com/astral-sh/python-build-standalone/releases/download
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,71 @@
|
||||
appId: com.hermesagent.desktop
|
||||
productName: Hermes Desktop
|
||||
copyright: Copyright © 2026
|
||||
|
||||
directories:
|
||||
output: release
|
||||
buildResources: build
|
||||
|
||||
# Don't auto-prune our root node_modules; we curate `files` and `extraResources` ourselves.
|
||||
buildDependenciesFromSource: false
|
||||
nodeGypRebuild: false
|
||||
npmRebuild: false
|
||||
|
||||
files:
|
||||
- "dist/**/*"
|
||||
- "package.json"
|
||||
- "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme,LICENSE,LICENSE.txt,license,*.d.ts}"
|
||||
- "!**/node_modules/.bin"
|
||||
- "!**/{.DS_Store,.git,.gitignore,.eslintrc*,.prettierrc*,*.map,tsconfig.json}"
|
||||
|
||||
# Web UI source (built dist) and bundled Python live outside the asar.
|
||||
# This package lives at packages/desktop, so ../.. is the hermes-web-ui repo root.
|
||||
extraResources:
|
||||
- from: "../.."
|
||||
to: "webui"
|
||||
filter:
|
||||
- "package.json"
|
||||
- "dist/**"
|
||||
- "node_modules/**"
|
||||
# Drop other-platform node-pty prebuilds (saves ~45MB)
|
||||
- "!node_modules/node-pty/prebuilds/!(${platform}-${arch})/**"
|
||||
- "!node_modules/node-pty/build/**"
|
||||
- "!packages/desktop/**"
|
||||
- "!**/{.git,.github,docs,tests,playwright.config.ts,*.md,README*,scripts,*.map}"
|
||||
- from: "resources/python/${os}-${arch}"
|
||||
to: "python"
|
||||
filter:
|
||||
- "**/*"
|
||||
|
||||
asarUnpack:
|
||||
- "**/*.node"
|
||||
|
||||
mac:
|
||||
target:
|
||||
- target: dmg
|
||||
arch: [arm64, x64]
|
||||
category: public.app-category.developer-tools
|
||||
hardenedRuntime: false
|
||||
gatekeeperAssess: false
|
||||
identity: null
|
||||
artifactName: "${productName}-${version}-${arch}.${ext}"
|
||||
|
||||
win:
|
||||
target:
|
||||
- target: nsis
|
||||
arch: [x64]
|
||||
artifactName: "${productName}-${version}-${arch}.${ext}"
|
||||
|
||||
linux:
|
||||
target:
|
||||
- target: AppImage
|
||||
arch: [x64, arm64]
|
||||
- target: deb
|
||||
arch: [x64] # fpm has no arm64 binary; deb only on x64
|
||||
category: Development
|
||||
artifactName: "${productName}-${version}-${arch}.${ext}"
|
||||
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
perMachine: false
|
||||
Generated
+4616
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "hermes-desktop",
|
||||
"version": "0.6.5",
|
||||
"description": "Desktop distribution for Hermes Web UI with bundled Python runtime and hermes-agent",
|
||||
"homepage": "https://ekkolearnai.com",
|
||||
"author": {
|
||||
"name": "Hermes Desktop Contributors",
|
||||
"email": "noreply@hermes-desktop.local"
|
||||
},
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"main": "dist/main/index.js",
|
||||
"scripts": {
|
||||
"build:main": "tsc -p tsconfig.json",
|
||||
"build": "npm run build:main",
|
||||
"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",
|
||||
"prune:python": "node scripts/prune-python.mjs",
|
||||
"dev": "npm run build:main && electron .",
|
||||
"dist": "npm run build && electron-builder",
|
||||
"dist:mac": "npm run build && electron-builder --mac",
|
||||
"dist:win": "npm run build && electron-builder --win",
|
||||
"dist:linux": "npm run build && electron-builder --linux"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.2",
|
||||
"electron": "^42.3.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"typescript": "~5.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.3.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env node
|
||||
// Apply locally-curated patches to hermes-agent inside the bundled venv.
|
||||
// Each patch is idempotent: a marker string is searched for first, and the
|
||||
// edit is skipped if the patch is already in place.
|
||||
//
|
||||
// Run after `install-hermes.mjs`. Designed to be safe to re-run.
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { platform as osPlatform, arch as osArch } from 'node:os'
|
||||
|
||||
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 PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||
|
||||
// Allow the CI sanity-check path to point at a temp install dir without
|
||||
// the full bundled-Python layout (e.g. `pip install --target /tmp/foo`).
|
||||
const sitePkgs = process.env.HERMES_AGENT_SITE_PACKAGES ?? (
|
||||
TARGET_OS === 'win32'
|
||||
? join(PY_DIR, 'Lib', 'site-packages')
|
||||
: (() => {
|
||||
const libDir = join(PY_DIR, 'lib')
|
||||
if (!existsSync(libDir)) throw new Error(`No lib dir at ${libDir}`)
|
||||
const py = readdirSync(libDir).find(n => /^python\d+\.\d+$/.test(n))
|
||||
if (!py) throw new Error(`Could not locate pythonX.Y under ${libDir}`)
|
||||
return join(libDir, py, 'site-packages')
|
||||
})()
|
||||
)
|
||||
|
||||
const dtPath = join(sitePkgs, 'gateway', 'platforms', 'dingtalk.py')
|
||||
if (!existsSync(dtPath)) {
|
||||
console.error(`dingtalk.py not found at ${dtPath} — is hermes-agent installed?`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let src = readFileSync(dtPath, 'utf-8')
|
||||
const before = src
|
||||
let applied = 0
|
||||
let skipped = 0
|
||||
|
||||
function patch(id, marker, find, replace) {
|
||||
if (src.includes(marker)) {
|
||||
console.log(` · ${id} (already applied)`)
|
||||
skipped++
|
||||
return
|
||||
}
|
||||
if (!src.includes(find)) {
|
||||
console.log(` ✗ ${id} (anchor not found — upstream changed?)`)
|
||||
return
|
||||
}
|
||||
src = src.replace(find, replace)
|
||||
console.log(` ✓ ${id}`)
|
||||
applied++
|
||||
}
|
||||
|
||||
console.log(`Patching ${dtPath}`)
|
||||
|
||||
// NOTE: the former `dt-pre-start` patch was retired — hermes-agent now ships
|
||||
// `_IncomingHandler.pre_start()` natively (present in 0.15.x and on main), so
|
||||
// re-adding it just injected a duplicate method.
|
||||
|
||||
// ── dt-card-tpl-env ─────────────────────────────────────────────
|
||||
// Fall back to DINGTALK_CARD_TEMPLATE_ID env var.
|
||||
patch(
|
||||
'dt-card-tpl-env',
|
||||
'# patch:dt-card-tpl-env',
|
||||
` self._card_template_id: Optional[str] = extra.get("card_template_id")`,
|
||||
` # patch:dt-card-tpl-env — env var fallback
|
||||
self._card_template_id: Optional[str] = (
|
||||
extra.get("card_template_id") or os.getenv("DINGTALK_CARD_TEMPLATE_ID")
|
||||
)`,
|
||||
)
|
||||
|
||||
// ── dt-card-before-webhook ──────────────────────────────────────
|
||||
// Try AI Card *before* validating session_webhook — Card SDK does not need
|
||||
// a webhook URL. Move the lookup of `current_message` and the AI Card block
|
||||
// up before the webhook gate.
|
||||
patch(
|
||||
'dt-card-before-webhook',
|
||||
'# patch:dt-card-before-webhook',
|
||||
` # Check metadata first (for direct webhook sends)
|
||||
session_webhook = metadata.get("session_webhook")
|
||||
if not session_webhook:
|
||||
webhook_info = self._get_valid_webhook(chat_id)
|
||||
if not webhook_info:
|
||||
logger.warning(
|
||||
"[%s] No valid session_webhook for chat_id=%s",
|
||||
self.name, chat_id,
|
||||
)
|
||||
return SendResult(
|
||||
success=False,
|
||||
error="No valid session_webhook available. Reply must follow an incoming message.",
|
||||
)
|
||||
session_webhook, _ = webhook_info
|
||||
|
||||
if not self._http_client:
|
||||
return SendResult(success=False, error="HTTP client not initialized")
|
||||
|
||||
# Look up the inbound message for this chat (for AI Card routing)
|
||||
current_message = self._message_contexts.get(chat_id)`,
|
||||
` # patch:dt-card-before-webhook — try AI Card first; webhook gate moved below.
|
||||
if not self._http_client:
|
||||
return SendResult(success=False, error="HTTP client not initialized")
|
||||
|
||||
# Look up the inbound message for this chat (for AI Card routing)
|
||||
current_message = self._message_contexts.get(chat_id)
|
||||
session_webhook = metadata.get("session_webhook")`,
|
||||
)
|
||||
|
||||
// The above leaves the existing AI Card block intact; we still need to add
|
||||
// the deferred webhook gate AFTER the AI Card attempt. The original code
|
||||
// had `logger.debug("[%s] Sending via webhook", self.name)` immediately
|
||||
// after the AI Card fallback log. Insert the gate right before that.
|
||||
patch(
|
||||
'dt-card-before-webhook-gate',
|
||||
'# patch:dt-card-before-webhook-gate',
|
||||
` logger.warning("[%s] AI Card send failed, falling back to webhook", self.name)
|
||||
|
||||
logger.debug("[%s] Sending via webhook", self.name)`,
|
||||
` logger.warning("[%s] AI Card send failed, falling back to webhook", self.name)
|
||||
|
||||
# patch:dt-card-before-webhook-gate — webhook required only for fallback path
|
||||
if not session_webhook:
|
||||
webhook_info = self._get_valid_webhook(chat_id)
|
||||
if not webhook_info:
|
||||
logger.warning(
|
||||
"[%s] No valid session_webhook for chat_id=%s",
|
||||
self.name, chat_id,
|
||||
)
|
||||
return SendResult(
|
||||
success=False,
|
||||
error="No valid session_webhook available. Reply must follow an incoming message.",
|
||||
)
|
||||
session_webhook, _ = webhook_info
|
||||
|
||||
logger.debug("[%s] Sending via webhook", self.name)`,
|
||||
)
|
||||
|
||||
// ── dt-dm-robot-code ────────────────────────────────────────────
|
||||
patch(
|
||||
'dt-dm-robot-code',
|
||||
'# patch:dt-dm-robot-code',
|
||||
` im_robot_open_deliver_model=(
|
||||
dingtalk_card_models.DeliverCardRequestImRobotOpenDeliverModel(
|
||||
space_type="IM_ROBOT",
|
||||
)
|
||||
),`,
|
||||
` im_robot_open_deliver_model=(
|
||||
dingtalk_card_models.DeliverCardRequestImRobotOpenDeliverModel(
|
||||
space_type="IM_ROBOT",
|
||||
robot_code=self._robot_code, # patch:dt-dm-robot-code
|
||||
)
|
||||
),`,
|
||||
)
|
||||
|
||||
// ── dt-card-autolayout ──────────────────────────────────────────
|
||||
patch(
|
||||
'dt-card-autolayout',
|
||||
'# patch:dt-card-autolayout',
|
||||
` card_data=dingtalk_card_models.CreateCardRequestCardData(
|
||||
card_param_map={"content": ""},
|
||||
),`,
|
||||
` card_data=dingtalk_card_models.CreateCardRequestCardData(
|
||||
# patch:dt-card-autolayout — wide-screen via sys_full_json_obj
|
||||
card_param_map={
|
||||
"content": "",
|
||||
"sys_full_json_obj": json.dumps({"config": {"autoLayout": True}}),
|
||||
},
|
||||
),`,
|
||||
)
|
||||
|
||||
if (src !== before) {
|
||||
writeFileSync(dtPath, src)
|
||||
}
|
||||
console.log(`Done. Applied ${applied}, skipped ${skipped}.`)
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
// Download python-build-standalone for the current (or target) platform/arch
|
||||
// and extract into resources/python/<os>-<arch>/
|
||||
import { mkdirSync, existsSync, createWriteStream, rmSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { tmpdir, platform as osPlatform, arch as osArch } from 'node:os'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(__dirname, '..')
|
||||
|
||||
// Pin a known-good python-build-standalone release. Bump intentionally.
|
||||
const PBS_TAG = process.env.PBS_TAG || '20260510'
|
||||
const PYTHON_VERSION = process.env.PBS_PY || '3.12.13'
|
||||
|
||||
const TARGET_OS = process.env.TARGET_OS || osPlatform() // darwin | win32 | linux
|
||||
const TARGET_ARCH = process.env.TARGET_ARCH || osArch() // arm64 | x64
|
||||
|
||||
const TRIPLE_MAP = {
|
||||
'darwin-arm64': 'aarch64-apple-darwin',
|
||||
'darwin-x64': 'x86_64-apple-darwin',
|
||||
'win32-x64': 'x86_64-pc-windows-msvc',
|
||||
'linux-x64': 'x86_64-unknown-linux-gnu',
|
||||
'linux-arm64': 'aarch64-unknown-linux-gnu',
|
||||
}
|
||||
|
||||
const key = `${TARGET_OS}-${TARGET_ARCH}`
|
||||
const triple = TRIPLE_MAP[key]
|
||||
if (!triple) {
|
||||
console.error(`Unsupported target: ${key}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// electron-builder uses `mac`/`win`/`linux` for `${os}` — match that
|
||||
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||
const OUT_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||
const FLAVOR = 'install_only_stripped'
|
||||
const FILE = `cpython-${PYTHON_VERSION}+${PBS_TAG}-${triple}-${FLAVOR}.tar.gz`
|
||||
const PBS_BASE_URL = (process.env.PBS_BASE_URL || 'https://github.com/astral-sh/python-build-standalone/releases/download').replace(/\/$/, '')
|
||||
const URL = `${PBS_BASE_URL}/${PBS_TAG}/${FILE}`
|
||||
|
||||
if (existsSync(resolve(OUT_DIR, 'python')) || existsSync(resolve(OUT_DIR, 'bin', 'python3'))) {
|
||||
console.log(`✓ Python already present at ${OUT_DIR}, skipping`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true })
|
||||
const tarPath = resolve(tmpdir(), FILE)
|
||||
|
||||
console.log(`→ Fetching ${URL}`)
|
||||
const curl = spawnSync('curl', ['-fL', '--retry', '3', '-o', tarPath, URL], { stdio: 'inherit' })
|
||||
if (curl.status !== 0) {
|
||||
console.error('curl failed')
|
||||
process.exit(curl.status ?? 1)
|
||||
}
|
||||
|
||||
console.log(`→ Extracting into ${OUT_DIR}`)
|
||||
// PBS tarballs unpack to a top-level "python/" directory; --strip-components=1 flattens it
|
||||
const tar = spawnSync('tar', ['-xzf', tarPath, '-C', OUT_DIR, '--strip-components=1'], { stdio: 'inherit' })
|
||||
if (tar.status !== 0) {
|
||||
console.error('tar failed')
|
||||
process.exit(tar.status ?? 1)
|
||||
}
|
||||
|
||||
rmSync(tarPath, { force: true })
|
||||
console.log(`✓ Python ready at ${OUT_DIR}`)
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env node
|
||||
// Install hermes-agent into the bundled Python at resources/python/<os>-<arch>/.
|
||||
// Prefers `uv` (10-100x faster, more deterministic) and falls back to pip.
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { platform as osPlatform, arch as osArch } from 'node:os'
|
||||
|
||||
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 OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||
const PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||
|
||||
const pyBin = TARGET_OS === 'win32'
|
||||
? resolve(PY_DIR, 'python.exe')
|
||||
: resolve(PY_DIR, 'bin', 'python3')
|
||||
|
||||
if (!existsSync(pyBin)) {
|
||||
console.error(`Python not found at ${pyBin}. Run: npm run fetch:python`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function hasUv() {
|
||||
const r = spawnSync('uv', ['--version'], { stdio: 'ignore' })
|
||||
return r.status === 0
|
||||
}
|
||||
|
||||
let r
|
||||
if (hasUv()) {
|
||||
console.log(`→ Installing hermes-agent==${HERMES_VERSION} via uv`)
|
||||
r = spawnSync('uv', [
|
||||
'pip', 'install',
|
||||
'--python', pyBin,
|
||||
`hermes-agent==${HERMES_VERSION}`,
|
||||
], { stdio: 'inherit' })
|
||||
} else {
|
||||
console.log(`→ Installing hermes-agent==${HERMES_VERSION} via pip`)
|
||||
r = spawnSync(pyBin, [
|
||||
'-m', 'pip', 'install',
|
||||
`hermes-agent==${HERMES_VERSION}`,
|
||||
'--no-warn-script-location',
|
||||
'--disable-pip-version-check',
|
||||
], { stdio: 'inherit' })
|
||||
}
|
||||
if (r.status !== 0) process.exit(r.status ?? 1)
|
||||
|
||||
const hermesBin = TARGET_OS === 'win32'
|
||||
? resolve(PY_DIR, 'Scripts', 'hermes.exe')
|
||||
: resolve(PY_DIR, 'bin', 'hermes')
|
||||
|
||||
if (!existsSync(hermesBin)) {
|
||||
console.error(`hermes binary not found at ${hermesBin} after install`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// hermes-web-ui's agent-bridge searches for `run_agent.py` at <python_root>/run_agent.py
|
||||
// (and a few neighbouring dirs). pip places it at site-packages/run_agent.py — surface
|
||||
// it at the venv root with a *relative* symlink so the venv stays portable when copied
|
||||
// into the packaged .app/.exe (an absolute symlink would break the moment the bundle
|
||||
// is moved to /Applications/...).
|
||||
const { readdirSync, symlinkSync, copyFileSync, unlinkSync, lstatSync } = await import('node:fs')
|
||||
function siteRunAgentRelative() {
|
||||
if (TARGET_OS === 'win32') {
|
||||
return ['Lib', 'site-packages', 'run_agent.py'].join('\\')
|
||||
}
|
||||
const libDir = resolve(PY_DIR, 'lib')
|
||||
const py = readdirSync(libDir).find(n => /^python\d+\.\d+$/.test(n))
|
||||
return ['lib', py, 'site-packages', 'run_agent.py'].join('/')
|
||||
}
|
||||
{
|
||||
const relSrc = siteRunAgentRelative()
|
||||
const absSrc = resolve(PY_DIR, relSrc)
|
||||
const dst = resolve(PY_DIR, 'run_agent.py')
|
||||
if (existsSync(absSrc)) {
|
||||
try { lstatSync(dst); unlinkSync(dst) } catch {}
|
||||
if (TARGET_OS === 'win32') copyFileSync(absSrc, dst)
|
||||
else symlinkSync(relSrc, dst)
|
||||
console.log(`✓ run_agent.py linked at venv root (relative → ${relSrc})`)
|
||||
} else {
|
||||
console.warn(`! run_agent.py not found at ${absSrc} — agent-bridge may fail`)
|
||||
}
|
||||
}
|
||||
|
||||
// Relocate: replace the pip-generated launcher (which embeds an absolute
|
||||
// shebang to the build-time Python path) with a relative wrapper so the
|
||||
// bundled venv works after being moved into the .app/.exe payload.
|
||||
const { writeFileSync, chmodSync } = await import('node:fs')
|
||||
if (TARGET_OS === 'win32') {
|
||||
// Windows: pip generates a .exe launcher that embeds a relative shebang
|
||||
// already. Add a .cmd wrapper that prefers the colocated python.exe.
|
||||
const cmdPath = resolve(PY_DIR, 'Scripts', 'hermes.cmd')
|
||||
writeFileSync(
|
||||
cmdPath,
|
||||
[
|
||||
'@echo off',
|
||||
'set "PY=%~dp0..\\python.exe"',
|
||||
'"%PY%" -m hermes_cli.main %*',
|
||||
].join('\r\n'),
|
||||
)
|
||||
} else {
|
||||
const launcher = [
|
||||
'#!/bin/sh',
|
||||
'DIR="$(cd "$(dirname "$0")" && pwd)"',
|
||||
'exec "$DIR/python3" -m hermes_cli.main "$@"',
|
||||
'',
|
||||
].join('\n')
|
||||
writeFileSync(hermesBin, launcher, { mode: 0o755 })
|
||||
chmodSync(hermesBin, 0o755)
|
||||
// Same for hermes-agent / hermes-acp (they all just dispatch into modules)
|
||||
for (const [name, mod] of [
|
||||
['hermes-agent', 'run_agent'],
|
||||
['hermes-acp', 'acp_adapter.entry'],
|
||||
]) {
|
||||
const p = resolve(PY_DIR, 'bin', name)
|
||||
if (existsSync(p)) {
|
||||
writeFileSync(p, launcher.replace('hermes_cli.main', mod), { mode: 0o755 })
|
||||
chmodSync(p, 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✓ hermes installed at ${hermesBin} (relocatable launcher)`)
|
||||
|
||||
r = spawnSync(hermesBin, ['--version'], { stdio: 'inherit' })
|
||||
if (r.status !== 0) {
|
||||
console.error('hermes --version failed')
|
||||
process.exit(r.status ?? 1)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
// Merge two per-arch `latest-mac.yml` manifests (arm64 + x64) into a single
|
||||
// manifest whose `files:` array lists BOTH dmgs, so electron-updater can pick
|
||||
// the right architecture.
|
||||
//
|
||||
// Why this exists: our Release workflow builds macOS arm64 and x64 in separate
|
||||
// matrix jobs, each emitting its own `latest-mac.yml`. When the publish job
|
||||
// flattens the artifacts they collide and only one arch survives — leaving the
|
||||
// other arch's users served a mismatched dmg (runs under Rosetta / fails the
|
||||
// updater signature check). Merging the `files` lists fixes that.
|
||||
//
|
||||
// Usage: node merge-mac-latest-yml.mjs <a.yml> <b.yml> > latest-mac.yml
|
||||
//
|
||||
// The manifest shape electron-builder emits is small and regular, so we parse
|
||||
// it with a focused extractor rather than pulling in a YAML dependency.
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
function parse(path) {
|
||||
const text = readFileSync(path, 'utf-8')
|
||||
const version = (text.match(/^version:\s*(.+)$/m) || [])[1]?.trim()
|
||||
const releaseDate = (text.match(/^releaseDate:\s*(.+)$/m) || [])[1]?.trim()
|
||||
// Each entry under `files:` is `- url: ...` then indented sha512/size lines.
|
||||
const files = []
|
||||
const re = /- url:\s*(\S+)\s*\n\s*sha512:\s*(\S+)\s*\n\s*size:\s*(\d+)/g
|
||||
let m
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
files.push({ url: m[1], sha512: m[2], size: Number(m[3]) })
|
||||
}
|
||||
if (!version || files.length === 0) {
|
||||
throw new Error(`Could not parse manifest at ${path} (version=${version}, files=${files.length})`)
|
||||
}
|
||||
return { version, releaseDate, files }
|
||||
}
|
||||
|
||||
const [, , aPath, bPath] = process.argv
|
||||
if (!aPath || !bPath) {
|
||||
console.error('Usage: merge-mac-latest-yml.mjs <a.yml> <b.yml>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const a = parse(aPath)
|
||||
const b = parse(bPath)
|
||||
|
||||
if (a.version !== b.version) {
|
||||
console.error(`Version mismatch: ${aPath}=${a.version} vs ${bPath}=${b.version}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Dedupe by url, preserving order (a first, then b).
|
||||
const seen = new Set()
|
||||
const files = []
|
||||
for (const f of [...a.files, ...b.files]) {
|
||||
if (seen.has(f.url)) continue
|
||||
seen.add(f.url)
|
||||
files.push(f)
|
||||
}
|
||||
|
||||
// Top-level path/sha512/size are the legacy single-file fields; point them at
|
||||
// the first entry (arm64 when arm64 is passed first). electron-updater >=6
|
||||
// selects from `files` by arch; these remain as a fallback for old clients.
|
||||
const head = files[0]
|
||||
const releaseDate = a.releaseDate || b.releaseDate
|
||||
|
||||
const lines = [`version: ${a.version}`, 'files:']
|
||||
for (const f of files) {
|
||||
lines.push(` - url: ${f.url}`)
|
||||
lines.push(` sha512: ${f.sha512}`)
|
||||
lines.push(` size: ${f.size}`)
|
||||
}
|
||||
lines.push(`path: ${head.url}`)
|
||||
lines.push(`sha512: ${head.sha512}`)
|
||||
if (releaseDate) lines.push(`releaseDate: ${releaseDate}`)
|
||||
process.stdout.write(lines.join('\n') + '\n')
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
// Strip __pycache__, *.pyc, tests, idle, tkinter from bundled Python to shrink the installer.
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { readdirSync, statSync, rmSync, existsSync } from 'node:fs'
|
||||
import { platform as osPlatform, arch as osArch } from 'node:os'
|
||||
|
||||
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 PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||
|
||||
if (!existsSync(PY_DIR)) {
|
||||
console.error(`No bundled python at ${PY_DIR}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const PRUNE_DIR_NAMES = new Set(['__pycache__', 'test', 'tests', 'idle_test', 'idlelib', 'turtledemo', 'tkinter', 'ensurepip'])
|
||||
const PRUNE_FILE_SUFFIXES = ['.pyc', '.pyo']
|
||||
|
||||
let bytesFreed = 0
|
||||
function walk(dir) {
|
||||
let entries
|
||||
try { entries = readdirSync(dir) } catch { return }
|
||||
for (const name of entries) {
|
||||
const p = join(dir, name)
|
||||
let st
|
||||
try { st = statSync(p) } catch { continue }
|
||||
if (st.isDirectory()) {
|
||||
if (PRUNE_DIR_NAMES.has(name)) {
|
||||
bytesFreed += dirSize(p)
|
||||
rmSync(p, { recursive: true, force: true })
|
||||
} else {
|
||||
walk(p)
|
||||
}
|
||||
} else if (PRUNE_FILE_SUFFIXES.some(s => name.endsWith(s))) {
|
||||
bytesFreed += st.size
|
||||
rmSync(p, { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
function dirSize(dir) {
|
||||
let total = 0
|
||||
try {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name)
|
||||
const st = statSync(p)
|
||||
total += st.isDirectory() ? dirSize(p) : st.size
|
||||
}
|
||||
} catch {}
|
||||
return total
|
||||
}
|
||||
|
||||
walk(PY_DIR)
|
||||
console.log(`✓ Pruned ~${(bytesFreed / 1024 / 1024).toFixed(1)} MB from ${PY_DIR}`)
|
||||
@@ -0,0 +1,132 @@
|
||||
import { app, BrowserWindow, Menu, shell, ipcMain } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { startWebUiServer, stopWebUiServer, getToken } from './webui-server'
|
||||
import { hermesBinExists, hermesBin } from './paths'
|
||||
import { initAutoUpdater } from './updater'
|
||||
|
||||
const PORT = Number(process.env.HERMES_DESKTOP_PORT) || 8648
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let serverUrl: string | null = null
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 820,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
title: 'Hermes Desktop',
|
||||
backgroundColor: '#1a1a1a',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '..', 'preload', 'index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
})
|
||||
|
||||
// External links → system browser
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith('http://127.0.0.1') || url.startsWith('http://localhost')) {
|
||||
return { action: 'allow' }
|
||||
}
|
||||
shell.openExternal(url).catch(() => undefined)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// If the Web UI server is already up (re-opening window after close on
|
||||
// macOS), go straight to it. Otherwise show a loading splash; bootstrap()
|
||||
// will swap in the real URL once the server is ready.
|
||||
if (serverUrl) {
|
||||
mainWindow.loadURL(serverUrl)
|
||||
} else {
|
||||
mainWindow.loadURL(splashHtml())
|
||||
}
|
||||
}
|
||||
|
||||
function splashHtml(): string {
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Hermes Desktop</title>
|
||||
<style>
|
||||
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}
|
||||
.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}}
|
||||
.row{display:flex;gap:8px}
|
||||
.row .dot:nth-child(2){animation-delay:.2s}.row .dot:nth-child(3){animation-delay:.4s}
|
||||
.label{font-size:14px;color:#999}
|
||||
h1{font-weight:500;margin:0;font-size:18px}
|
||||
</style></head><body><div class="wrap">
|
||||
<h1>Hermes Desktop</h1>
|
||||
<div class="row"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>
|
||||
<div class="label">Starting local services…</div>
|
||||
</div></body></html>`
|
||||
return 'data:text/html;charset=utf-8,' + encodeURIComponent(html)
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
if (!hermesBinExists()) {
|
||||
console.error(`hermes binary missing at ${hermesBin()}`)
|
||||
console.error('Run: npm run prepare:python (to bundle Python + hermes-agent)')
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await startWebUiServer(PORT)
|
||||
serverUrl = url
|
||||
if (mainWindow) await mainWindow.loadURL(url)
|
||||
} catch (err) {
|
||||
console.error('Failed to start Web UI server:', 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 start local services</h2><pre style="white-space:pre-wrap;color:#f88">${msg}</pre>
|
||||
</body></html>`,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes-desktop:get-token', () => getToken())
|
||||
|
||||
const gotLock = app.requestSingleInstanceLock()
|
||||
if (!gotLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// Drop the default File/Edit/View/Window menu on Windows/Linux. The web
|
||||
// UI provides its own in-page controls, so the native menu bar is just
|
||||
// visual clutter. macOS keeps a menu (system requirement) but Electron's
|
||||
// default is fine there.
|
||||
if (process.platform !== 'darwin') Menu.setApplicationMenu(null)
|
||||
createWindow()
|
||||
bootstrap()
|
||||
initAutoUpdater()
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
} else if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
app.on('before-quit', async (e) => {
|
||||
e.preventDefault()
|
||||
await stopWebUiServer().catch(() => undefined)
|
||||
app.exit(0)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { app } from 'electron'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { homedir, platform, arch } from 'node:os'
|
||||
|
||||
const isWin = platform() === 'win32'
|
||||
const osLabel = isWin ? 'win' : platform() === 'darwin' ? 'mac' : platform() // mac | linux | win
|
||||
const archLabel = arch() // arm64 | x64
|
||||
|
||||
export function isPackaged() {
|
||||
return app.isPackaged
|
||||
}
|
||||
|
||||
// Bundled web-ui directory.
|
||||
// dev: <repo root> (or HERMES_WEB_UI_DIR)
|
||||
// prod: <resources>/webui
|
||||
export function webuiDir(): string {
|
||||
if (app.isPackaged) return resolve(process.resourcesPath, 'webui')
|
||||
return process.env.HERMES_WEB_UI_DIR?.trim() || resolve(app.getAppPath(), '..', '..')
|
||||
}
|
||||
|
||||
export function webuiServerEntry(): string {
|
||||
return join(webuiDir(), 'dist', 'server', 'index.js')
|
||||
}
|
||||
|
||||
// Bundled Python directory.
|
||||
// dev: packages/desktop/resources/python/<os>-<arch>
|
||||
// prod: <resources>/python
|
||||
export function pythonDir(): string {
|
||||
if (app.isPackaged) return resolve(process.resourcesPath, 'python')
|
||||
return resolve(app.getAppPath(), 'resources', 'python', `${osLabel}-${archLabel}`)
|
||||
}
|
||||
|
||||
export function hermesBin(): string {
|
||||
const dir = pythonDir()
|
||||
return isWin ? join(dir, 'Scripts', 'hermes.exe') : join(dir, 'bin', 'hermes')
|
||||
}
|
||||
|
||||
export function hermesBinExists(): boolean {
|
||||
return existsSync(hermesBin())
|
||||
}
|
||||
|
||||
export function webUiHome(): string {
|
||||
return process.env.HERMES_WEB_UI_HOME?.trim() || resolve(homedir(), '.hermes-web-ui')
|
||||
}
|
||||
|
||||
export function hermesHome(): string {
|
||||
const override = process.env.HERMES_HOME?.trim()
|
||||
if (override) return resolve(override)
|
||||
|
||||
if (isWin) {
|
||||
const localAppData = process.env.LOCALAPPDATA?.trim() || process.env.APPDATA?.trim()
|
||||
if (localAppData) return resolve(localAppData, 'hermes')
|
||||
}
|
||||
|
||||
return resolve(homedir(), '.hermes')
|
||||
}
|
||||
|
||||
export function tokenFile(): string {
|
||||
return join(webUiHome(), '.token')
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { app, dialog } from 'electron'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
|
||||
let initialized = false
|
||||
|
||||
export function initAutoUpdater() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
if (!app.isPackaged) return // dev mode: skip
|
||||
if (process.env.HERMES_DESKTOP_ENABLE_AUTO_UPDATE !== 'true') return
|
||||
|
||||
autoUpdater.autoDownload = true
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
|
||||
autoUpdater.on('update-available', info => {
|
||||
console.log(`[updater] update available: ${info.version}`)
|
||||
})
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
console.log('[updater] up to date')
|
||||
})
|
||||
autoUpdater.on('error', err => {
|
||||
console.error('[updater] error:', err)
|
||||
})
|
||||
autoUpdater.on('update-downloaded', async info => {
|
||||
const { response } = await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Update ready',
|
||||
message: `Hermes Desktop ${info.version} is ready to install.`,
|
||||
detail: 'Restart now to apply the update, or it will be installed on next quit.',
|
||||
buttons: ['Restart now', 'Later'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
if (response === 0) autoUpdater.quitAndInstall()
|
||||
})
|
||||
|
||||
autoUpdater.checkForUpdates().catch(err => {
|
||||
console.error('[updater] initial check failed:', err)
|
||||
})
|
||||
|
||||
// Recheck every 6h while app is running
|
||||
setInterval(() => {
|
||||
autoUpdater.checkForUpdates().catch(() => undefined)
|
||||
}, 6 * 60 * 60 * 1000)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { ChildProcess, spawn } from 'node:child_process'
|
||||
import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync } from 'node:fs'
|
||||
import { dirname, delimiter, join } from 'node:path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { app } from 'electron'
|
||||
import { webuiServerEntry, webuiDir, hermesBin, webUiHome, hermesHome, tokenFile, pythonDir } from './paths'
|
||||
|
||||
const DEFAULT_PORT = 8648
|
||||
const READY_TIMEOUT_MS = 30_000
|
||||
|
||||
let serverProc: ChildProcess | null = null
|
||||
let cachedToken: string | null = null
|
||||
|
||||
function ensureToken(): string {
|
||||
if (cachedToken) return cachedToken
|
||||
const file = tokenFile()
|
||||
mkdirSync(dirname(file), { recursive: true })
|
||||
if (existsSync(file)) {
|
||||
cachedToken = readFileSync(file, 'utf-8').trim()
|
||||
if (cachedToken) return cachedToken
|
||||
}
|
||||
cachedToken = randomBytes(32).toString('hex')
|
||||
writeFileSync(file, cachedToken + '\n', { mode: 0o600 })
|
||||
return cachedToken
|
||||
}
|
||||
|
||||
// node-pty ships per-platform prebuilds with a `spawn-helper` binary that
|
||||
// loses its +x bit when copied across some filesystems. Restore it.
|
||||
function ensureNativeModules() {
|
||||
try {
|
||||
const helper = join(
|
||||
webuiDir(),
|
||||
'node_modules',
|
||||
'node-pty',
|
||||
'prebuilds',
|
||||
`${process.platform}-${process.arch}`,
|
||||
'spawn-helper',
|
||||
)
|
||||
if (existsSync(helper)) chmodSync(helper, 0o755)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(): string {
|
||||
return ensureToken()
|
||||
}
|
||||
|
||||
export function getServerUrl(port = DEFAULT_PORT): string {
|
||||
return `http://127.0.0.1:${port}`
|
||||
}
|
||||
|
||||
export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
||||
ensureNativeModules()
|
||||
const token = ensureToken()
|
||||
const entry = webuiServerEntry()
|
||||
if (!existsSync(entry)) {
|
||||
throw new Error(`Web UI server entry not found at ${entry}. Run: npm run build:webui`)
|
||||
}
|
||||
|
||||
const home = webUiHome()
|
||||
const agentHome = hermesHome()
|
||||
mkdirSync(home, { recursive: true })
|
||||
mkdirSync(agentHome, { recursive: true })
|
||||
|
||||
// Tell agent-bridge to use the bundled Python directly. Otherwise the
|
||||
// bridge auto-detects Python from HERMES_BIN's shebang — which on our
|
||||
// setup is a #!/bin/sh wrapper, not a python interpreter, so detection
|
||||
// resolves to /bin/sh and the bridge crashes (exit code 2) immediately.
|
||||
const isWin = process.platform === 'win32'
|
||||
const bundledPython = isWin
|
||||
? join(pythonDir(), 'python.exe')
|
||||
: join(pythonDir(), 'bin', 'python3')
|
||||
|
||||
// Run via Electron's "run as Node" mode — Electron binary doubles as Node.
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
NODE_ENV: 'production',
|
||||
HERMES_DESKTOP: 'true',
|
||||
HERMES_BIN: hermesBin(),
|
||||
HERMES_AGENT_BRIDGE_PYTHON: bundledPython,
|
||||
HERMES_AGENT_ROOT: pythonDir(),
|
||||
// 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
|
||||
// identically and avoids the issue cross-platform.
|
||||
HERMES_AGENT_BRIDGE_ENDPOINT: 'tcp://127.0.0.1:18765',
|
||||
// Force TCP for worker endpoints too (upstream #1106). Same EDR/sandbox
|
||||
// reason as above — default ipc:// unix sockets in /tmp get killed.
|
||||
HERMES_AGENT_BRIDGE_WORKER_TRANSPORT: 'tcp',
|
||||
// And for preview-mode bridges spawned by the in-app update controller.
|
||||
HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_TRANSPORT: 'tcp',
|
||||
// Suppress the npm-registry update prompt (upstream #1105). hermes-web-ui
|
||||
// is bundled here; users can't `npm i -g` to upgrade, they have to wait
|
||||
// for the wrapper app to ship a new release.
|
||||
HERMES_WEB_UI_DISABLE_UPDATE_CHECK: 'true',
|
||||
// Single-user desktop install: open the gateway's user allowlist by
|
||||
// default. Otherwise the gateway silently drops every inbound platform
|
||||
// message (DingTalk/Slack/Telegram) with a startup warning. Users can
|
||||
// still override by setting GATEWAY_ALLOW_ALL_USERS=false in their
|
||||
// HERMES_HOME/.env or by configuring per-platform allowlists.
|
||||
GATEWAY_ALLOW_ALL_USERS: process.env.GATEWAY_ALLOW_ALL_USERS ?? 'true',
|
||||
// Keep the bundled Hermes Agent, bridge, gateway, and Web UI path helpers
|
||||
// on the same data directory. Native Windows uses %LOCALAPPDATA%\hermes;
|
||||
// macOS/Linux keep the standard ~/.hermes layout.
|
||||
HERMES_HOME: agentHome,
|
||||
HERMES_WEB_UI_HOME: home,
|
||||
AUTH_TOKEN: token,
|
||||
PORT: String(port),
|
||||
// Prepend bundled Python's bin to PATH so any incidental `python` resolution lands on ours
|
||||
PATH: [dirname(hermesBin()), process.env.PATH].filter(Boolean).join(delimiter),
|
||||
}
|
||||
|
||||
serverProc = spawn(process.execPath, [entry], {
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
serverProc.stdout?.on('data', (chunk: Buffer) => {
|
||||
process.stdout.write(`[webui] ${chunk}`)
|
||||
})
|
||||
serverProc.stderr?.on('data', (chunk: Buffer) => {
|
||||
process.stderr.write(`[webui] ${chunk}`)
|
||||
})
|
||||
serverProc.on('exit', (code, signal) => {
|
||||
console.error(`[webui] server exited code=${code} signal=${signal}`)
|
||||
serverProc = null
|
||||
if (!app.isReady() || code !== 0) {
|
||||
// Best-effort: if server dies abnormally during startup, surface to user
|
||||
}
|
||||
})
|
||||
|
||||
await waitForReady(port, READY_TIMEOUT_MS)
|
||||
return getServerUrl(port)
|
||||
}
|
||||
|
||||
async function waitForReady(port: number, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const url = `http://127.0.0.1:${port}/api/health`
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(1000) })
|
||||
if (res.ok || res.status === 401) return // 401 = up but auth-gated, server is alive
|
||||
} catch {
|
||||
/* not ready yet */
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
}
|
||||
throw new Error(`Web UI server did not become ready within ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
export function stopWebUiServer(): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
if (!serverProc || serverProc.killed) return resolve()
|
||||
const proc = serverProc
|
||||
const timer = setTimeout(() => {
|
||||
try { proc.kill('SIGKILL') } catch { /* */ }
|
||||
resolve()
|
||||
}, 3000)
|
||||
proc.once('exit', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
try { proc.kill('SIGTERM') } catch { resolve() }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getToken: (): Promise<string> => ipcRenderer.invoke('hermes-desktop:get-token'),
|
||||
platform: process.platform,
|
||||
isDesktop: true,
|
||||
})
|
||||
|
||||
const API_KEY_LS = 'hermes_api_key'
|
||||
const DEFAULT_USERNAME = 'admin'
|
||||
const DEFAULT_PASSWORD = '123456'
|
||||
|
||||
// Auto-login the bundled web UI so users don't see a login screen on launch.
|
||||
// We POST to /api/auth/login with the well-known default credentials, using
|
||||
// the server's AUTH_TOKEN as the bearer (the server requires *some* auth on
|
||||
// /api/auth/login from a packaged client). The returned JWT is dropped into
|
||||
// localStorage where the Vue client expects it.
|
||||
async function autoLogin(token: string): Promise<void> {
|
||||
if (localStorage.getItem(API_KEY_LS)) return
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ username: DEFAULT_USERNAME, password: DEFAULT_PASSWORD }),
|
||||
})
|
||||
if (!res.ok) return
|
||||
const body = await res.json().catch(() => null) as { token?: string; jwt?: string } | null
|
||||
const jwt = body?.token || body?.jwt
|
||||
if (jwt) localStorage.setItem(API_KEY_LS, jwt)
|
||||
} catch {
|
||||
/* ignore — first-load race or server still starting */
|
||||
}
|
||||
}
|
||||
|
||||
// Silently strip the "你必须修改默认密码" flag from /api/auth/me responses on
|
||||
// desktop. Users on a single-machine install don't benefit from a managed
|
||||
// password. The Web UI client uses BOTH fetch and axios (which goes through
|
||||
// XMLHttpRequest), so we patch both code paths.
|
||||
function isAuthMeUrl(url: string): boolean {
|
||||
return /\/api\/auth\/me(?:\?|$)/.test(url)
|
||||
}
|
||||
|
||||
function stripCredentialFlag(text: string): string {
|
||||
try {
|
||||
const data = JSON.parse(text)
|
||||
if (data?.user && data.user.requiresCredentialChange) {
|
||||
data.user.requiresCredentialChange = false
|
||||
return JSON.stringify(data)
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
return text
|
||||
}
|
||||
|
||||
function installFetchPatch(): void {
|
||||
const origFetch = window.fetch.bind(window)
|
||||
window.fetch = async (input, init) => {
|
||||
const res = await origFetch(input, init)
|
||||
try {
|
||||
const url = typeof input === 'string' ? input : (input as Request).url
|
||||
if (url && isAuthMeUrl(url) && res.ok) {
|
||||
const text = await res.clone().text()
|
||||
const patched = stripCredentialFlag(text)
|
||||
if (patched !== text) {
|
||||
return new Response(patched, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: res.headers,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return res
|
||||
}
|
||||
|
||||
const OrigXHR = window.XMLHttpRequest
|
||||
type XHRWithDesktop = XMLHttpRequest & { __hermesDesktopUrl?: string }
|
||||
const origOpen = OrigXHR.prototype.open
|
||||
OrigXHR.prototype.open = function (
|
||||
this: XHRWithDesktop,
|
||||
method: string,
|
||||
url: string | URL,
|
||||
...rest: unknown[]
|
||||
) {
|
||||
this.__hermesDesktopUrl = String(url)
|
||||
// @ts-expect-error — forwarding variadic
|
||||
return origOpen.call(this, method, url, ...rest)
|
||||
}
|
||||
const origGetResponse = Object.getOwnPropertyDescriptor(OrigXHR.prototype, 'response')
|
||||
const origGetResponseText = Object.getOwnPropertyDescriptor(OrigXHR.prototype, 'responseText')
|
||||
if (origGetResponse?.get && origGetResponseText?.get) {
|
||||
Object.defineProperty(OrigXHR.prototype, 'responseText', {
|
||||
configurable: true,
|
||||
get(this: XHRWithDesktop) {
|
||||
const raw = origGetResponseText.get!.call(this) as string
|
||||
if (this.__hermesDesktopUrl && isAuthMeUrl(this.__hermesDesktopUrl) && typeof raw === 'string') {
|
||||
return stripCredentialFlag(raw)
|
||||
}
|
||||
return raw
|
||||
},
|
||||
})
|
||||
Object.defineProperty(OrigXHR.prototype, 'response', {
|
||||
configurable: true,
|
||||
get(this: XHRWithDesktop) {
|
||||
const raw = origGetResponse.get!.call(this)
|
||||
if (this.__hermesDesktopUrl && isAuthMeUrl(this.__hermesDesktopUrl)) {
|
||||
if (typeof raw === 'string') return stripCredentialFlag(raw)
|
||||
if (raw && typeof raw === 'object' && (raw as { user?: { requiresCredentialChange?: boolean } }).user?.requiresCredentialChange) {
|
||||
return { ...(raw as object), user: { ...(raw as { user: object }).user, requiresCredentialChange: false } }
|
||||
}
|
||||
}
|
||||
return raw
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
installFetchPatch()
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
const token = await ipcRenderer.invoke('hermes-desktop:get-token')
|
||||
if (token) {
|
||||
try { localStorage.setItem('AUTH_TOKEN', token) } catch { /* */ }
|
||||
await autoLogin(token)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -53,7 +53,9 @@ export async function currentUser(ctx: Context) {
|
||||
created_at: user.created_at,
|
||||
updated_at: user.updated_at,
|
||||
last_login_at: user.last_login_at,
|
||||
requiresCredentialChange: user.username === DEFAULT_USERNAME && verifyPassword(DEFAULT_PASSWORD, user.password_hash),
|
||||
requiresCredentialChange: process.env.HERMES_DESKTOP === 'true'
|
||||
? false
|
||||
: user.username === DEFAULT_USERNAME && verifyPassword(DEFAULT_PASSWORD, user.password_hash),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user