wip thin client

This commit is contained in:
ethernet 2026-06-26 19:30:29 -04:00
parent cdb1dfbc49
commit 4dce531189
20 changed files with 507 additions and 241 deletions

View file

@ -0,0 +1,20 @@
'use strict'
/**
* build-mode.cjs — pure helper for the desktop's thin-vs-thick build mode.
*
* The desktop ships in two shapes:
* - thick (default): bundles the first-launch bootstrap installer, can
* spawn a local Hermes backend, and supports in-app self-update.
* - thin: no bootstrap, no local backend, no self-update. Connects ONLY
* to a remote gateway. Used for sandboxed/package-managed deployments
* (Flatpak, Snap, etc.) where the agent lives elsewhere.
*
* The esbuild bundler bakes this env var into the source code, so it's read at build time, not runtime.
*/
function isThinClient() {
return process.env.HERMES_DESKTOP_BUILD_MODE === 'thin'
}
module.exports = { isThinClient }

View file

@ -0,0 +1,41 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
// We test build-mode.cjs by controlling process.env directly. The module
// reads process.env.HERMES_DESKTOP_BUILD_MODE at call time (not import time),
// so we can mutate the env and re-require to exercise both modes.
function freshModule() {
// Bust the require cache so the module re-evaluates with the current env.
delete require.cache[require.resolve('./build-mode.cjs')]
return require('./build-mode.cjs')
}
test('isThinClient returns false by default (thick mode)', () => {
const prev = process.env.HERMES_DESKTOP_BUILD_MODE
delete process.env.HERMES_DESKTOP_BUILD_MODE
const { isThinClient } = freshModule()
assert.equal(isThinClient(), false)
process.env.HERMES_DESKTOP_BUILD_MODE = prev
})
test('isThinClient returns true when HERMES_DESKTOP_BUILD_MODE=thin', () => {
const prev = process.env.HERMES_DESKTOP_BUILD_MODE
process.env.HERMES_DESKTOP_BUILD_MODE = 'thin'
const { isThinClient } = freshModule()
assert.equal(isThinClient(), true)
process.env.HERMES_DESKTOP_BUILD_MODE = prev
})
test('isThinClient returns false for non-thin values', () => {
const prev = process.env.HERMES_DESKTOP_BUILD_MODE
process.env.HERMES_DESKTOP_BUILD_MODE = 'thick'
const { isThinClient } = freshModule()
assert.equal(isThinClient(), false)
process.env.HERMES_DESKTOP_BUILD_MODE = 'thick-client'
const { isThinClient: isThin2 } = freshModule()
assert.equal(isThin2(), false)
process.env.HERMES_DESKTOP_BUILD_MODE = prev
})

View file

@ -24,6 +24,7 @@ const https = require('node:https')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
const { isThinClient } = require('./build-mode.cjs')
const { installEmbedReferer } = require('./embed-referer.cjs')
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { runBootstrap } = require('./bootstrap-runner.cjs')
@ -1839,6 +1840,17 @@ async function resolveHealedBranch(updateRoot, branch) {
}
async function checkUpdates() {
// Thin client: no local checkout to git-pull, no bundled updater. Updates
// come from the package manager (Flatpak, Snap, etc.), not in-app self-update.
if (isThinClient()) {
return {
supported: false,
reason: 'thin-client',
message: 'Updates are managed by your package manager. This is a thin client build.',
fetchedAt: Date.now()
}
}
const updateRoot = resolveUpdateRoot()
let { branch } = readDesktopUpdateConfig()
const gitDir = path.join(updateRoot, '.git')
@ -1972,6 +1984,10 @@ let updateInFlight = false
// updater isn't staged (e.g. a dev/source run that never went through the
// installer); callers degrade gracefully.
function resolveUpdaterBinary() {
// Thin client: no staged Tauri updater — the packaged app is managed
// externally. Returning null lets the existing callers degrade gracefully
// (the manual-command path), though applyUpdates already short-circuits.
if (isThinClient()) return null
const name = IS_WINDOWS ? 'hermes-setup.exe' : 'hermes-setup'
const candidate = path.join(HERMES_HOME, name)
return fileExists(candidate) ? candidate : null
@ -2130,6 +2146,16 @@ async function releaseBackendLock(updateRoot, tag) {
// Detection (checkUpdates / commit changelog / "N behind") stays in the UI;
// only this apply action changed.
async function applyUpdates(opts = {}) {
// Thin client: self-update is not supported. The packaged app is managed
// externally (Flatpak, Snap, etc.).
if (isThinClient()) {
return {
ok: false,
error: 'unsupported',
message: 'Self-update is not available in thin client builds. Use your package manager to update.'
}
}
if (updateInFlight) {
throw new Error('An update is already in progress.')
}
@ -2831,6 +2857,23 @@ function createActiveBackend(dashboardArgs) {
}
function resolveHermesBackend(dashboardArgs) {
// Thin client: no local backend, no bootstrap. The only valid path is a
// remote gateway connection. Returning the bootstrap-needed sentinel here
// would kick off install.ps1, which the thin build doesn't ship. Instead
// return a dedicated sentinel so startHermes() can produce a clear error
// directing the user to configure a remote gateway.
if (isThinClient()) {
return {
kind: 'thin-client-no-local',
label: 'Thin client build — remote gateway required',
command: null,
args: dashboardArgs,
bootstrap: false,
env: {},
shell: false
}
}
// 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer
// checkout. Honour it as-is (no bootstrap; the user is driving).
const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT)
@ -2974,6 +3017,19 @@ function resolveHermesBackend(dashboardArgs) {
}
async function ensureRuntime(backend) {
// Thin client: resolveHermesBackend returned a sentinel telling us there
// is no local backend to spawn. Rather than crashing on a null command
// spawn, throw a clear error so startHermes() catches it and the boot-
// failure overlay surfaces the "configure a remote gateway" message.
if (backend.kind === 'thin-client-no-local') {
const err = new Error(
'This is a thin client build with no bundled Hermes agent. ' +
'Go to Settings → Gateway and configure a remote gateway URL.'
)
err.isThinClientNoLocal = true
throw err
}
if (!backend.bootstrap) {
await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32)
return applyWindowsNoConsoleSpawnHints(backend)
@ -4985,6 +5041,10 @@ async function testDesktopConnectionConfig(input = {}) {
if (authMode !== 'oauth') {
token = decryptDesktopSecret(block.token)
}
} else if (isThinClient()) {
// Thin client: no local backend to test against. A "local" connection
// test is meaningless — there's no bundled agent to reach.
throw new Error('Local connection test is not available in thin client builds. Configure a remote gateway URL.')
} else {
const remote = (await resolveRemoteBackend(key)) || (await startHermes())
baseUrl = remote.baseUrl
@ -5401,6 +5461,17 @@ async function startHermes() {
}
}
// Thin client: the remote check above was the only path. If we get here,
// no remote was configured — refuse to spawn a local backend.
if (isThinClient()) {
const err = new Error(
'No remote gateway configured. This thin client build requires a remote gateway. ' +
'Go to Settings → Gateway to configure one.'
)
err.isThinClientNoRemote = true
throw err
}
// Mutual exclusion with an in-app update (#50238). If this instance was
// relaunched while the Tauri updater is still applying an update, spawning
// a local backend now re-locks the venv shim and gets killed by the
@ -6120,6 +6191,10 @@ ipcMain.on('hermes:pet-overlay:control', (_event, payload) => {
mainWindow.webContents.send('hermes:pet-overlay:control', payload)
})
ipcMain.handle('hermes:bootstrap:reset', async () => {
// Thin client: no bootstrap to reset. Just reload the window.
if (isThinClient()) {
return { ok: true }
}
// Renderer's "Reload and retry" path. Clear the latched failure and
// reset connection state so the next startHermes() call restarts the
// full backend flow (including a fresh runBootstrap pass).
@ -6140,6 +6215,10 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
return { ok: true }
})
ipcMain.handle('hermes:bootstrap:repair', async () => {
// Thin client: no local install to repair.
if (isThinClient()) {
return { ok: true }
}
// Forceful repair: drop the bootstrap-complete marker so the next
// startHermes() re-runs the full installer (refreshing a broken/partial
// venv), and clear any latched failure + live connection. The renderer
@ -7157,6 +7236,23 @@ function uninstallVenvPython() {
}
async function getUninstallSummary() {
// Thin client: no local agent install to uninstall. Return a minimal
// summary so the settings UI can show "nothing to remove" instead of
// probing for a venv that doesn't exist.
if (isThinClient()) {
return {
hermes_home: HERMES_HOME,
agent_installed: false,
gui_installed: true,
source_built_artifacts: [],
packaged_app_paths: [],
userdata_dir: app.getPath('userData'),
userdata_exists: true,
platform: process.platform,
probe: 'thin-client'
}
}
const py = uninstallVenvPython()
const agentRoot = ACTIVE_HERMES_ROOT
// Fast JS-side fallback used when the agent venv is gone (lite client) or the
@ -7329,6 +7425,11 @@ async function runDesktopUninstall(mode) {
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
// Thin client: no local agent to uninstall. The packaged app is managed
// by the OS package manager.
if (isThinClient()) {
return { ok: false, error: 'unsupported', message: 'Uninstall is handled by your package manager in thin client builds.' }
}
const mode = payload && typeof payload === 'object' ? payload.mode : payload
return runDesktopUninstall(String(mode || ''))
})
@ -7421,6 +7522,7 @@ function registerDeepLinkProtocol() {
// whole new app instead of routing into the running one.
const _gotSingleInstanceLock = app.requestSingleInstanceLock()
if (!_gotSingleInstanceLock) {
console.log("Hermes Desktop is already running, exiting.")
app.quit()
} else {
app.on('second-instance', (_event, argv) => {

View file

@ -18,11 +18,13 @@
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && npm run postbuild",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && npm run postbuild",
"build:thin": "cross-env HERMES_DESKTOP_BUILD_MODE=thin npm run build",
"postbuild": "node scripts/assert-dist-built.cjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs",
"pack": "npm run build && npm run builder -- --dir",
"pack:thin": "cross-env HERMES_DESKTOP_BUILD_MODE=thin npm run pack",
"dist": "npm run build && npm run builder",
"dist:mac": "npm run build && npm run builder -- --mac",
"dist:mac:dmg": "npm run build && npm run builder -- --mac dmg",
@ -31,13 +33,15 @@
"dist:win:msi": "npm run build && npm run builder -- --win msi",
"dist:win:nsis": "npm run build && npm run builder -- --win nsis",
"dist:linux": "npm run build && npm run builder -- --linux AppImage deb rpm",
"dist:thin": "cross-env HERMES_DESKTOP_BUILD_MODE=thin npm run dist",
"dist:linux:thin": "cross-env HERMES_DESKTOP_BUILD_MODE=thin npm run dist:linux",
"test:desktop": "node scripts/test-desktop.mjs",
"test:desktop:all": "node scripts/test-desktop.mjs all",
"test:desktop:dmg": "node scripts/test-desktop.mjs dmg",
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/build-mode.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",

View file

@ -2,10 +2,32 @@
* Desktop bundles ship precompiled renderer assets. Returning false here tells
* electron-builder to skip the node_modules collector/install step, which
* avoids workspace dependency graph explosions and keeps packaging
* deterministic across environments. The Hermes Agent Python payload is no
* longer bundled; the Electron app fetches it at first launch via
* `install.ps1`'s stage protocol (Windows). See `electron/main.cjs`.
* deterministic across environments.
*
* In thin-client builds we also strip the install-stamp and native-deps
* extraResources entries — no bootstrap, no local PTY.
*/
module.exports = async function beforeBuild() {
const path = require('node:path')
const THIN_CLIENT = process.env.HERMES_DESKTOP_BUILD_MODE === 'thin'
module.exports = async function beforeBuild(context) {
if (THIN_CLIENT && context.packager) {
// Strip install-stamp.json and native-deps from extraResources — neither
// exists in a thin build (write-build-stamp and stage-native-deps are
// skipped in build:thin).
const buildConfig = context.packager.config
if (Array.isArray(buildConfig.extraResources)) {
buildConfig.extraResources = buildConfig.extraResources.filter(
entry => {
const to = typeof entry === 'object' && entry ? entry.to : null
if (to === 'install-stamp.json' || to === 'native-deps') {
return false
}
return true
}
)
}
}
return false
}

View file

@ -16,6 +16,11 @@ const root = resolve(here, '..')
const entry = resolve(root, 'electron/main.cjs')
const tmp = resolve(root, 'electron/main.bundled.cjs')
// Thin-client builds strip bootstrap, local backend, and self-update.
// Bake the flag into the bundle so build-mode.cjs's process.env read resolves
// to a string literal at runtime (no env var needed in the packaged app).
const thinClient = process.env.HERMES_DESKTOP_BUILD_MODE === 'thin'
await build({
entryPoints: [entry],
bundle: true,
@ -24,6 +29,9 @@ await build({
target: 'node20',
outfile: tmp,
external: ['electron', 'node-pty'],
define: {
'process.env.HERMES_DESKTOP_BUILD_MODE': JSON.stringify(thinClient ? 'thin' : '')
},
logLevel: 'info'
})

View file

@ -1,4 +1,4 @@
"use strict"
'use strict'
/**
* Writes apps/desktop/build/install-stamp.json with the git ref the desktop
@ -17,29 +17,30 @@
* }
*
* Source preference order:
* 1. CI env vars ($GITHUB_SHA / $GITHUB_REF_NAME) -- avoid edge cases with
* 1. BUILD_STAMP env var (json)
* 2. CI env vars ($GITHUB_SHA / $GITHUB_REF_NAME) -- avoid edge cases with
* shallow clones, detached HEADs, etc. in CI.
* 2. Local `git rev-parse` against the parent repo (../..).
* 3. Local `git rev-parse` against the parent repo (../..).
*
* Dev / out-of-repo builds without git produce an explicit error rather than
* silently writing an unstamped manifest -- the packaged app refuses to
* bootstrap without a stamp.
*/
const fs = require("fs")
const path = require("path")
const { execSync } = require("child_process")
const fs = require('fs')
const path = require('path')
const { execSync } = require('child_process')
const STAMP_SCHEMA_VERSION = 1
const DESKTOP_ROOT = path.resolve(__dirname, "..")
const REPO_ROOT = path.resolve(DESKTOP_ROOT, "..", "..")
const OUT_DIR = path.join(DESKTOP_ROOT, "build")
const OUT_FILE = path.join(OUT_DIR, "install-stamp.json")
const DESKTOP_ROOT = path.resolve(__dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
const OUT_DIR = path.join(DESKTOP_ROOT, 'build')
const OUT_FILE = path.join(OUT_DIR, 'install-stamp.json')
function tryExec(cmd, opts) {
try {
return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...opts }).trim()
return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], ...opts }).trim()
} catch {
return null
}
@ -53,52 +54,79 @@ function fromCI() {
commit: sha,
branch: branch,
dirty: false, // CI builds from a checkout-of-ref by definition
source: "ci"
source: 'ci'
}
}
function fromLocalGit() {
const sha = tryExec("git rev-parse HEAD", { cwd: REPO_ROOT })
const sha = tryExec('git rev-parse HEAD', { cwd: REPO_ROOT })
if (!sha) return null
const branch = tryExec("git rev-parse --abbrev-ref HEAD", { cwd: REPO_ROOT })
const branch = tryExec('git rev-parse --abbrev-ref HEAD', { cwd: REPO_ROOT })
// `git status --porcelain -uno` is empty iff tracked files match HEAD.
// We exclude untracked files (-uno) intentionally: a developer who's
// checked out an installer scratch dir alongside the repo shouldn't
// poison every local build with a [DIRTY] stamp. We DO care about
// tracked-but-modified files because those mean the .exe content
// differs from the commit being pinned.
const status = tryExec("git status --porcelain -uno", { cwd: REPO_ROOT })
const status = tryExec('git status --porcelain -uno', { cwd: REPO_ROOT })
const dirty = status !== null && status.length > 0
return {
commit: sha,
branch: branch === "HEAD" ? null : branch, // detached HEAD -> null
branch: branch === 'HEAD' ? null : branch, // detached HEAD -> null
dirty: dirty,
source: "local"
source: 'local'
}
}
function fromEnv() {
const stamp = process.env.BUILD_STAMP
if (!stamp) return null
const json = JSON.parse(stamp)
if (json.schemaVersion !== 1) {
throw new Error('Schema version !== 1')
}
if (typeof json.branch !== 'string') {
throw new Error('Expected branch to be string')
}
if (typeof json.commit !== 'string') {
throw new Error('Expected commit to be string')
}
if (typeof json.builtAt !== 'string') {
throw new Error('Expected builtAt to be string')
}
if (typeof json.dirty !== 'boolean') {
throw new Error('Expected dirty to be boolean')
}
if (typeof json.source !== 'string') {
throw new Error('Expected source to be string')
}
return json
}
function main() {
const stamp = fromCI() || fromLocalGit()
const stamp = fromEnv() || fromCI() || fromLocalGit()
if (!stamp || !stamp.commit) {
console.error(
"[write-build-stamp] ERROR: could not determine git commit.\n" +
" - $GITHUB_SHA not set\n" +
" - `git rev-parse HEAD` failed at " +
'[write-build-stamp] ERROR: could not determine git commit.\n' +
' - $GITHUB_SHA not set\n' +
' - `git rev-parse HEAD` failed at ' +
REPO_ROOT +
"\n" +
"Packaged builds require a git ref to pin first-launch install.ps1\n" +
"against. Run from a git checkout or set $GITHUB_SHA explicitly."
'\n' +
'Packaged builds require a git ref to pin first-launch install.ps1\n' +
'against. Run from a git checkout or set $GITHUB_SHA explicitly.'
)
process.exit(1)
}
if (stamp.dirty) {
console.warn(
"[write-build-stamp] WARNING: working tree is dirty.\n" +
" Pinning to " +
'[write-build-stamp] WARNING: working tree is dirty.\n' +
' Pinning to ' +
stamp.commit.slice(0, 12) +
" but the packaged code may differ from that commit.\n" +
" Commit your changes before publishing this build."
' but the packaged code may differ from that commit.\n' +
' Commit your changes before publishing this build.'
)
}
@ -112,14 +140,14 @@ function main() {
}
fs.mkdirSync(OUT_DIR, { recursive: true })
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8")
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + '\n', 'utf8')
console.log(
"[write-build-stamp] wrote " +
'[write-build-stamp] wrote ' +
path.relative(REPO_ROOT, OUT_FILE) +
" -> " +
' -> ' +
stamp.commit.slice(0, 12) +
(stamp.branch ? " (" + stamp.branch + ")" : "") +
(stamp.dirty ? " [DIRTY]" : "")
(stamp.branch ? ' (' + stamp.branch + ')' : '') +
(stamp.dirty ? ' [DIRTY]' : '')
)
}

View file

@ -5,6 +5,7 @@ import { BrandMark } from '@/components/brand-mark'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { type Translations, useI18n } from '@/i18n'
import { isThinClient } from '@/lib/build-mode'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
@ -106,75 +107,79 @@ export function AboutSettings() {
</div>
<div className="mx-auto mt-4 w-full max-w-2xl">
<SectionHeading icon={RefreshCw} title={a.updates} />
{isThinClient() ? null : (
<>
<SectionHeading icon={RefreshCw} title={a.updates} />
<div
className={cn(
'rounded-xl border px-4 py-3 text-sm',
statusTone === 'available' && 'border-primary/30 bg-primary/5 text-foreground',
statusTone === 'error' && 'border-destructive/35 bg-destructive/5 text-destructive',
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
)}
>
<div className="flex items-start gap-2">
{statusTone === 'available' ? (
<Codicon className="mt-0.5 size-4 shrink-0 text-primary" name="cloud-download" size="1rem" />
) : statusTone === 'error' ? null : (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
)}
<div className="min-w-0">
<p className="font-medium">{statusLine}</p>
<p className="mt-1 text-xs text-muted-foreground">
{a.lastChecked(relativeTime(status?.fetchedAt, a))}
{justChecked && !checking ? a.justNowSuffix : ''}
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-4">
<Button
disabled={checking || applying || !supported}
onClick={() => void handleCheck()}
size="sm"
variant="textStrong"
<div
className={cn(
'rounded-xl border px-4 py-3 text-sm',
statusTone === 'available' && 'border-primary/30 bg-primary/5 text-foreground',
statusTone === 'error' && 'border-destructive/35 bg-destructive/5 text-destructive',
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
)}
>
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{checking ? a.checking : a.checkNow}
</Button>
<div className="flex items-start gap-2">
{statusTone === 'available' ? (
<Codicon className="mt-0.5 size-4 shrink-0 text-primary" name="cloud-download" size="1rem" />
) : statusTone === 'error' ? null : (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
)}
<div className="min-w-0">
<p className="font-medium">{statusLine}</p>
<p className="mt-1 text-xs text-muted-foreground">
{a.lastChecked(relativeTime(status?.fetchedAt, a))}
{justChecked && !checking ? a.justNowSuffix : ''}
</p>
</div>
</div>
{behind > 0 && supported && !applying && (
<>
<Button onClick={() => startActiveUpdate()} size="sm">
{a.updateNow}
<div className="mt-3 flex flex-wrap items-center gap-4">
<Button
disabled={checking || applying || !supported}
onClick={() => void handleCheck()}
size="sm"
variant="textStrong"
>
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{checking ? a.checking : a.checkNow}
</Button>
<Button onClick={() => openUpdatesWindow()} size="sm" variant="textStrong">
{a.seeWhatsNew}
{behind > 0 && supported && !applying && (
<>
<Button onClick={() => startActiveUpdate()} size="sm">
{a.updateNow}
</Button>
<Button onClick={() => openUpdatesWindow()} size="sm" variant="textStrong">
{a.seeWhatsNew}
</Button>
</>
)}
<Button asChild className="ml-auto" size="sm" variant="text">
<a
href={RELEASE_NOTES_URL}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal?.(RELEASE_NOTES_URL)
}}
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-3" />
{a.releaseNotes}
</a>
</Button>
</>
)}
</div>
</div>
<Button asChild className="ml-auto" size="sm" variant="text">
<a
href={RELEASE_NOTES_URL}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal?.(RELEASE_NOTES_URL)
}}
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-3" />
{a.releaseNotes}
</a>
</Button>
</div>
</div>
<ListRow
description={a.automaticUpdatesDesc}
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
title={a.automaticUpdates}
/>
<ListRow
description={a.automaticUpdatesDesc}
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
title={a.automaticUpdates}
/>
</>
)}
<UninstallSection />
</div>

View file

@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
import { useI18n } from '@/i18n'
import { isThinClient } from '@/lib/build-mode'
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@ -466,16 +467,18 @@ export function GatewaySettings() {
) : null}
<div className="grid gap-3 sm:grid-cols-2">
{isThinClient() ? null : (
<ModeCard
active={state.mode === 'local'}
description={g.localDesc}
disabled={state.envOverride}
icon={Monitor}
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
title={g.localTitle}
/>
)}
<ModeCard
active={state.mode === 'local'}
description={g.localDesc}
disabled={state.envOverride}
icon={Monitor}
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
title={g.localTitle}
/>
<ModeCard
active={state.mode === 'remote'}
active={state.mode === 'remote' || isThinClient()}
description={g.remoteDesc}
disabled={state.envOverride}
icon={Globe}

View file

@ -6,6 +6,7 @@ import { ErrorIcon } from '@/components/ui/error-state'
import { LogView } from '@/components/ui/log-view'
import type { DesktopConnectionConfig } from '@/global'
import { useI18n } from '@/i18n'
import { isThinClient } from '@/lib/build-mode'
import { FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons'
import { $desktopBoot } from '@/store/boot'
import { notify, notifyError } from '@/store/notifications'
@ -205,16 +206,18 @@ export function BootFailureOverlay() {
{copy.retry}
</Button>
)}
{!remoteReauth ? (
{remoteReauth ? null : !isThinClient() ? (
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="secondary">
{busy === 'repair' ? <Loader2 className="animate-spin" /> : <Wrench />}
{copy.repairInstall}
</Button>
) : null}
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="secondary">
{busy === 'local' ? <Loader2 className="animate-spin" /> : null}
{copy.useLocalGateway}
</Button>
{isThinClient() ? null : (
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="secondary">
{busy === 'local' ? <Loader2 className="animate-spin" /> : null}
{copy.useLocalGateway}
</Button>
)}
<Button onClick={openLogs} variant="ghost">
<FileText />
{copy.openLogs}

View file

@ -0,0 +1,25 @@
/**
* Build-mode flag for the renderer.
*
* `__VITE_THIN_CLIENT__` is injected at compile time by vite.config.ts
* (see `define`). In dev it reads the live env var so
* `HERMES_DESKTOP_BUILD_MODE=thin npm run dev` works for testing.
*
* Thin-client builds:
* - No first-launch bootstrap (the app has no bundled installer)
* - No local backend spawn (the app connects ONLY to a remote gateway)
* - No in-app self-update (updates come from the package manager)
*/
// In a packaged build, __VITE_THIN_CLIENT__ is replaced with a literal
// boolean by vite's `define`. In dev (no define) the typeof check falls
// through to the live env-var read so `HERMES_DESKTOP_BUILD_MODE=thin npm
// run dev` works.
const THIN_CLIENT =
typeof __VITE_THIN_CLIENT__ !== 'undefined'
? __VITE_THIN_CLIENT__
: false
export function isThinClient(): boolean {
return THIN_CLIENT
}

View file

@ -14,6 +14,7 @@ import type {
DesktopVersionInfo
} from '@/global'
import { checkHermesUpdate, getActionStatus, updateHermes } from '@/hermes'
import { isThinClient } from '@/lib/build-mode'
import { translateNow } from '@/i18n'
import { persistString, storedString } from '@/lib/storage'
import { dismissNotification, notify } from '@/store/notifications'
@ -194,7 +195,8 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
}
export function openUpdatesWindow(): void {
openUpdateOverlayFor(isRemoteMode() ? 'backend' : 'client')
// Thin client: no client self-update — always target the backend (remote).
openUpdateOverlayFor(isThinClient() || isRemoteMode() ? 'backend' : 'client')
}
/**
@ -205,7 +207,7 @@ export function openUpdatesWindow(): void {
* only be able to open the changelog overlay.
*/
export function startActiveUpdate(): void {
const target: UpdateTarget = isRemoteMode() ? 'backend' : 'client'
const target: UpdateTarget = isThinClient() || isRemoteMode() ? 'backend' : 'client'
$updateOverlayTarget.set(target)
$updateOverlayOpen.set(true)
void (target === 'backend' ? applyBackendUpdate() : applyUpdates())
@ -611,9 +613,18 @@ export function startUpdatePoller(): void {
}
pollerStarted = true
// Thin client: no client self-update, but still poll for backend updates
// (the remote gateway may have new versions available).
if (!isThinClient()) {
void checkUpdates()
}
void checkBackendUpdates()
void refreshDesktopVersion()
bridge.onProgress(ingestProgress)
if (!isThinClient()) {
bridge.onProgress(ingestProgress)
}
// The poller starts at mount, before the gateway connects — so the first
// backend check above sees mode≠remote and no-ops. Re-check once the

View file

@ -1 +1,5 @@
/// <reference types="vite/client" />
// Build-time constant injected by vite.config.ts `define`.
// True for thin-client builds (no bootstrap, no local backend, no self-update).
declare const __VITE_THIN_CLIENT__: boolean

View file

@ -3,8 +3,16 @@ import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
// Thin-client builds strip bootstrap, local backend, and self-update so the
// app connects ONLY to a remote gateway. Set HERMES_DESKTOP_BUILD_MODE=thin
// at build time; the renderer reads the baked-in VITE_THIN_CLIENT constant.
const THIN_CLIENT = process.env.HERMES_DESKTOP_BUILD_MODE === 'thin'
export default defineConfig({
base: './',
define: {
__VITE_THIN_CLIENT__: JSON.stringify(THIN_CLIENT)
},
plugins: [react(), tailwindcss()],
css: {
// Pin an explicit (empty) PostCSS config. Tailwind is handled entirely by

View file

@ -13,20 +13,21 @@
makeWrapper,
hermesNpmLib,
electron,
hermesAgent,
hermesAgent ? null,
...
}:
let
npm = hermesNpmLib.mkNpmPassthru {
folder = "apps/desktop";
attr = "desktop";
pname = "hermes-desktop";
};
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/apps/desktop/package.json"));
version = packageJson.version;
# Build the renderer (dist/ + electron/ + package.json).
isThinClient = hermesAgent == null;
# Build the renderqer (dist/ + electron/ + package.json).
renderer = pkgs.buildNpmPackage (
npm
// {
@ -34,6 +35,9 @@ let
inherit version;
doCheck = true;
HERMES_DESKTOP_BUILD_MODE = if isThinClient then "thin" else "";
BUILD_STAMP = ''{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix","builtAt":"0"}'';
buildPhase = ''
runHook preBuild
@ -41,7 +45,6 @@ let
# at first-launch to pin the install.ps1 git ref; informational in
# nix builds (the backend comes from the derivation directly).
mkdir -p apps/desktop/build
echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > apps/desktop/build/install-stamp.json
# patch shebangs in node_modules/.bin so npm exec can find the
# nix-store equivalents of /usr/bin/env (which doesn't exist in the sandbox)
@ -50,18 +53,7 @@ let
pushd apps/desktop
# stage node-pty native binaries into build/native-deps for the final nix output
npm rebuild node-pty --build-from-source
node scripts/stage-native-deps.cjs
npm exec tsc -b
npm exec vite build
# Bundle the electron main into a single self-contained file so
# the nix output doesn't need node_modules/. simple-git (the only
# external runtime dep of the electron main) gets inlined; electron
# and node-pty are external (provided by the runtime / native-deps).
# preload.cjs stays separate — Electron loads it via __dirname, not
# require(), so it must remain a standalone file.
node scripts/bundle-electron-main.mjs
npm run build
popd
runHook postBuild
@ -72,8 +64,6 @@ let
pushd apps/desktop
npm run postbuild
# validate staged node-pty native binary is present
STAGED_PTY_NODE="./build/native-deps/node-pty/build/Release/pty.node"
@ -131,15 +121,15 @@ stdenv.mkDerivation {
substituteInPlace $out/share/hermes-desktop/electron/main.cjs \
--replace-fail "process.resourcesPath" "'$out/share/hermes-desktop'"
# Wrap the nixpkgs electron binary to launch our app. Set
# HERMES_DESKTOP_HERMES to the absolute path of the nix-built `hermes`
# binary so the desktop's resolver step 4 ("existing Hermes CLI on
# PATH") uses our fully wrapped binary — venv with all deps,
# Wrap the nixpkgs electron binary to launch our app.
# If we're building thick, set HERMES_DESKTOP_HERMES to the absolute path of the
# nix-built `hermes` binary so the desktop's resolver step 4
# ("existing Hermes CLI on PATH") uses our fully wrapped binary & venv with all deps,
# bundled skills/plugins, runtime PATH (ripgrep/git/ffmpeg/etc).
# No reimplementation of the agent resolver in the wrapper.
makeWrapper ${lib.getExe electron} $out/bin/hermes-desktop \
--add-flags "$out/share/hermes-desktop" \
--set HERMES_DESKTOP_HERMES "${lib.getExe hermesAgent}" \
${if isThinClient then "" else ''--set HERMES_DESKTOP_HERMES "${lib.getExe hermesAgent}"''}\
--set ELECTRON_IS_DEV 0
runHook postInstall

View file

@ -10,14 +10,15 @@
makeWrapper,
callPackage,
python312,
nodejs_22,
electron,
ripgrep,
git,
openssh,
ffmpeg,
tirith,
# for building stuff w/ consistent node versions
hermesNpmLib,
# linux-only deps
wl-clipboard,
xclip,
@ -26,7 +27,6 @@
uv2nix,
pyproject-nix,
pyproject-build-systems,
npm-lockfile-fix,
# Locked git revision of the flake source — embedded so banner.py can
# check for updates without needing a local .git directory. Null for
# impure / dirty builds where flakes can't determine a rev.
@ -36,16 +36,11 @@
extraDependencyGroups ? [ ],
}:
let
nodejs = nodejs_22;
hermesVenv = callPackage ./python.nix {
inherit uv2nix pyproject-nix pyproject-build-systems;
dependency-groups = [ "all" ] ++ extraDependencyGroups;
};
hermesNpmLib = callPackage ./lib.nix {
inherit npm-lockfile-fix nodejs;
};
hermesTui = callPackage ./tui.nix {
inherit hermesNpmLib;
};
@ -83,7 +78,7 @@ let
bundledLocales = lib.cleanSource ../locales;
runtimeDeps = [
nodejs
hermesNpmLib.nodejs
ripgrep
git
openssh
@ -182,7 +177,7 @@ stdenv.mkDerivation (finalAttrs: {
--set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \
--set HERMES_TUI_DIR $out/ui-tui \
--set HERMES_PYTHON ${hermesVenv}/bin/python3 \
--set HERMES_NODE ${lib.getExe nodejs} \
--set HERMES_NODE ${lib.getExe hermesNpmLib.nodejs} \
${lib.optionalString (rev != null) ''--set HERMES_REVISION ${rev} \''}
${lib.optionalString (extraPythonPackages != [ ]) ''--suffix PYTHONPATH : "${pythonPath}"''}
'')
@ -204,24 +199,10 @@ stdenv.mkDerivation (finalAttrs: {
passthru = {
inherit
hermesTui
hermesWeb
hermesNpmLib
hermesVenv
;
# `hermesDesktop` references `finalAttrs.finalPackage` (this whole
# derivation, after all overrides are applied) so the desktop wrapper
# can prepend its `/bin` to PATH. The desktop's resolver step 4
# ("existing hermes on PATH") then picks up the fully wrapped
# `hermes` binary — venv with all deps, bundled skills/plugins,
# runtime PATH (ripgrep/git/ffmpeg/etc). No re-implementation
# of the agent resolution in the desktop wrapper.
hermesDesktop = callPackage ./desktop.nix {
inherit hermesNpmLib electron;
hermesAgent = finalAttrs.finalPackage;
};
devShellHook = ''
STAMP=".nix-stamps/hermes-agent"
STAMP_VALUE="${pyprojectHash}:${uvLockHash}"

View file

@ -11,8 +11,6 @@
# root to update the lockfile, then `npm ci` if the lockfile changed.
{
pkgs,
npm-lockfile-fix,
nodejs,
}:
let
# The workspace root — where the single package-lock.json lives.
@ -23,17 +21,19 @@ let
# lockfile is the single source of truth — no separate dependency hash to
# keep in sync with it.
npmDeps = pkgs.importNpmLock.importNpmLock { npmRoot = src; };
nodejs = pkgs.nodejs_22;
in
{
inherit nodejs;
# Returns a buildNpmPackage-compatible attrs set that provides:
# src, npmDeps, npmRoot — workspace source + importNpmLock dep set
# npmConfigHook — importNpmLock's offline `npm install` hook
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
# passthru.packageJsonPath — relative path to this workspace's package.json
# nodejs — fixed nodejs version for all packages we use in the repo
#
# Usage:
# npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
# npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; pname = "hermes-tui"; };
# pkgs.buildNpmPackage (npm // {
# sourceRoot = "ui-tui";
# buildPhase = '' ... '';
@ -42,7 +42,6 @@ in
mkNpmPassthru =
{
folder, # repo-relative folder with package.json, e.g. "ui-tui"
attr, # flake package attr, e.g. "tui"
...
}:
let
@ -61,24 +60,6 @@ in
ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
nativeBuildInputs = [
(pkgs.writeShellScriptBin "update_${attr}_lockfile" ''
set -euox pipefail
REPO_ROOT=$(git rev-parse --show-toplevel)
# All workspace packages share the root lockfile.
cd "$REPO_ROOT"
rm -rf node_modules/
${pkgs.lib.getExe' nodejs "npm"} cache clean --force
CI=true ${pkgs.lib.getExe' nodejs "npm"} install --workspaces
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
nix build .#${attr}
echo "Lockfile updated and build verified for .#${attr}"
'')
];
passthru = {
packageJsonPath = "${folder}/package.json";
};

View file

@ -2,15 +2,34 @@
{ inputs, ... }:
{
perSystem =
{ pkgs, lib, inputs', ... }:
{
pkgs,
lib,
...
}:
let
hermesNpmLib = pkgs.callPackage ./lib.nix { };
hermesAgent = pkgs.callPackage ./hermes-agent.nix {
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default;
# Only embed clean revs — dirtyRev doesn't represent any upstream
inherit (inputs)
uv2nix
pyproject-nix
pyproject-build-systems
;
inherit
hermesNpmLib
;
# Only embed clean revs. dirtyRev doesn't represent any upstream
# commit, so comparing it would always claim "update available".
rev = inputs.self.rev or null;
};
desktop = pkgs.callPackage ./desktop.nix {
inherit hermesAgent hermesNpmLib;
};
desktop-thin = pkgs.callPackage ./desktop.nix {
inherit hermesNpmLib;
};
in
{
packages = {
@ -44,12 +63,11 @@
"parallel-web"
"tts-premium"
"voice"
] ++ lib.optionals pkgs.stdenv.isLinux [ "matrix" ];
]
++ lib.optionals pkgs.stdenv.isLinux [ "matrix" ];
};
tui = hermesAgent.hermesTui;
web = hermesAgent.hermesWeb;
desktop = hermesAgent.hermesDesktop;
inherit desktop desktop-thin;
};
};
}

View file

@ -1,33 +1,39 @@
# nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled
{ pkgs, hermesNpmLib, ... }:
let
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
npm = hermesNpmLib.mkNpmPassthru {
folder = "ui-tui";
pname = "hermes-tui";
};
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/ui-tui/package.json"));
version = packageJson.version;
in
pkgs.buildNpmPackage (npm // {
pname = "hermes-tui";
inherit version;
pkgs.buildNpmPackage (
npm
// {
pname = "hermes-tui";
inherit version;
doCheck = false;
doCheck = false;
buildPhase = ''
# esbuild bundles everything — no need for tsc or vite.
# Run from the workspace root where node_modules/ lives.
node ui-tui/scripts/build.mjs
'';
buildPhase = ''
# esbuild bundles everything — no need for tsc or vite.
# Run from the workspace root where node_modules/ lives.
node ui-tui/scripts/build.mjs
'';
installPhase = ''
runHook preInstall
installPhase = ''
runHook preInstall
mkdir -p $out/lib/hermes-tui
# esbuild writes to ui-tui/dist/ from the source root (no cd).
cp -r ui-tui/dist $out/lib/hermes-tui/dist
mkdir -p $out/lib/hermes-tui
# esbuild writes to ui-tui/dist/ from the source root (no cd).
cp -r ui-tui/dist $out/lib/hermes-tui/dist
# package.json kept for "type": "module" resolution on `node dist/entry.js`.
cp ui-tui/package.json $out/lib/hermes-tui/
# package.json kept for "type": "module" resolution on `node dist/entry.js`.
cp ui-tui/package.json $out/lib/hermes-tui/
runHook postInstall
'';
})
runHook postInstall
'';
}
)

View file

@ -1,34 +1,40 @@
# nix/web.nix — Hermes Web Dashboard (Vite/React) frontend build
{ pkgs, hermesNpmLib, ... }:
let
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
npm = hermesNpmLib.mkNpmPassthru {
folder = "web";
pname = "hermes-web";
};
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/web/package.json"));
version = packageJson.version;
in
pkgs.buildNpmPackage (npm // {
pname = "hermes-web";
inherit version;
pkgs.buildNpmPackage (
npm
// {
pname = "hermes-web";
inherit version;
doCheck = false;
doCheck = false;
buildPhase = ''
# Build from web/ so vite.config.ts and tsconfig resolve correctly.
# The workspace root's node_modules/ is at ../node_modules/.
cd web
node ../node_modules/typescript/bin/tsc -b
# outDir in vite.config.ts points to ../hermes_cli/web_dist for the
# monorepo layout. Override with --outDir dist for the nix build.
node ../node_modules/vite/bin/vite.js build --outDir dist
buildPhase = ''
# Build from web/ so vite.config.ts and tsconfig resolve correctly.
# The workspace root's node_modules/ is at ../node_modules/.
cd web
node ../node_modules/typescript/bin/tsc -b
# outDir in vite.config.ts points to ../hermes_cli/web_dist for the
# monorepo layout. Override with --outDir dist for the nix build.
node ../node_modules/vite/bin/vite.js build --outDir dist
# Return to source root so installPhase paths are correct.
cd ..
'';
# Return to source root so installPhase paths are correct.
cd ..
'';
installPhase = ''
runHook preInstall
# vite writes to web/dist/ (we cd'd there, overrode outDir, then cd'd back).
cp -r web/dist $out
runHook postInstall
'';
})
installPhase = ''
runHook preInstall
# vite writes to web/dist/ (we cd'd there, overrode outDir, then cd'd back).
cp -r web/dist $out
runHook postInstall
'';
}
)