mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop,cli,docker): serve the desktop renderer at /app on the gateway
The web bridge (previous commit) made the renderer boot in a browser; this makes an instance actually serve it, end to end, plus the Phase 1 UI gating. Web bridge + renderer: - petOverlay/terminal/updates/uninstall/themes become OPTIONAL bridge members, absent on web rather than stubbed inert — every caller already ?.-guards, so features hide through existing branches (typecheck-verified) - Gateway settings nav entry absent on web (connection IS the serving origin); About hides the Updates section behind a capability check on window.hermesDesktop?.updates (version + release notes stay) - notify requests Notification permission at point of first use - getVersion reports the renderer package version via a vite define Serving (web_server.py): - APP_DIST/mount_app: /app/assets static mount, /app 307-canonicalized to /app/ (relative asset URLs), index served with the same bootstrap-global injection contract as the dashboard SPA (token in loopback mode, cookie + ws-ticket in gated mode); same traversal guard, same auth-gate coverage - Mounts ONLY when HERMES_APP_DIST is explicitly set: no path fallback, so a regular desktop build leaving a dist at apps/desktop/dist can't silently enable /app. Env set but dist missing logs a WARNING (a Docker image with a broken frontend build should not surface as a mystery 404) Build + image: - apps/desktop build:web script: renderer-only build (typecheck + vite), no Electron packaging steps - Dockerfile: desktop manifest joins the cached npm-install layer (ELECTRON_SKIP_BINARY_DOWNLOAD=1 keeps the ~100MB binary out), build:web appended to the frontend-build layer, HERMES_APP_DIST set at runtime - .dockerignore: apps/desktop source enters the build context; node_modules/ dist/build/release re-excluded after the negation (last-match-wins would otherwise re-include them — a stale local dist would clobber the image-built one via COPY . ., and a Windows node_modules would clobber the image's Linux natives) Verified: build:web dist served at /app/ from a real gateway (token injected server-side, host:'web', gateway WS connected, zero console errors); mount contract curl-verified in both directions (no env var = /app falls through to the dashboard SPA even with a dist present on disk). Image build itself pending a docker-capable machine. No-op for every install that doesn't set HERMES_APP_DIST; zero behavior change for Electron users.
This commit is contained in:
parent
a014dec94a
commit
9f61698369
10 changed files with 277 additions and 144 deletions
|
|
@ -66,12 +66,28 @@ runtime/
|
|||
|
||||
# ---------- Not needed inside the Docker image ----------
|
||||
|
||||
# Desktop app source (Tauri/Electron); never installed in the container.
|
||||
# apps/shared is the dashboard↔desktop websocket helper and is linked from
|
||||
# web/package.json as a file: workspace dep — keep it in the build context.
|
||||
# Desktop app source (Tauri/Electron): only apps/shared and apps/desktop are
|
||||
# needed in the container.
|
||||
# - apps/shared is the dashboard↔desktop websocket helper, linked from
|
||||
# web/package.json as a file: workspace dep.
|
||||
# - apps/desktop is the desktop renderer, built browser-only inside the image
|
||||
# (`npm run build:web`) and served at /app (HERMES_APP_DIST). Its local
|
||||
# build outputs are re-excluded below so a developer's stale dist can't
|
||||
# overwrite the image-built one via `COPY . .` (same failure mode as
|
||||
# ui-tui/dist/ above), and release/ (packaged Electron apps, ~GBs) never
|
||||
# belongs in a build context.
|
||||
apps/
|
||||
!apps/shared/
|
||||
!apps/shared/**
|
||||
!apps/desktop/
|
||||
!apps/desktop/**
|
||||
# Re-exclusions: !apps/desktop/** above re-includes ANYTHING under the tree —
|
||||
# including node_modules already excluded by the global rule at the top
|
||||
# (last matching rule wins). Each build output must be re-excluded here.
|
||||
apps/desktop/node_modules/
|
||||
apps/desktop/dist/
|
||||
apps/desktop/build/
|
||||
apps/desktop/release/
|
||||
|
||||
# Test suite — not shipped in production images
|
||||
tests/
|
||||
|
|
|
|||
39
Dockerfile
39
Dockerfile
|
|
@ -118,6 +118,7 @@ WORKDIR /opt/hermes
|
|||
COPY package.json package-lock.json ./
|
||||
COPY web/package.json web/
|
||||
COPY ui-tui/package.json ui-tui/
|
||||
COPY apps/desktop/package.json apps/desktop/
|
||||
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
|
||||
# apps/shared/ is copied IN FULL because web/package.json references it as a
|
||||
# `file:` workspace dependency (same pattern as hermes-ink above).
|
||||
|
|
@ -135,6 +136,14 @@ COPY apps/shared/ apps/shared/
|
|||
# guards against a future regression if the source npm version changes.
|
||||
ENV npm_config_install_links=false
|
||||
|
||||
# `ELECTRON_SKIP_BINARY_DOWNLOAD=1`: the desktop workspace lists `electron` as
|
||||
# a devDependency, but the image only ever runs its *renderer* as a browser
|
||||
# bundle (`build:web` below) — the ~100MB Electron runtime binary would be
|
||||
# downloaded, never executed, and baked into the layer. Skipping the download
|
||||
# keeps the postinstall a no-op; node-pty's native compile still runs (the
|
||||
# gcc/make toolchain is already present for python-olm above).
|
||||
ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1
|
||||
|
||||
RUN npm install --prefer-offline --no-audit && \
|
||||
npx playwright install --with-deps chromium --only-shell && \
|
||||
npm cache clean --force
|
||||
|
|
@ -188,8 +197,14 @@ RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra
|
|||
COPY web/ web/
|
||||
COPY ui-tui/ ui-tui/
|
||||
COPY apps/shared/ apps/shared/
|
||||
# The desktop renderer, built browser-only (no Electron packaging steps) and
|
||||
# served by the gateway at /app — HERMES_APP_DIST below points at this dist.
|
||||
# .dockerignore excludes any local dist/build/release so this build is always
|
||||
# from source.
|
||||
COPY apps/desktop/ apps/desktop/
|
||||
RUN cd web && npm run build && \
|
||||
cd ../ui-tui && npm run build
|
||||
cd ../ui-tui && npm run build && \
|
||||
cd ../apps/desktop && npm run build:web
|
||||
|
||||
# ---------- Source code ----------
|
||||
# .dockerignore excludes node_modules, so the installs above survive.
|
||||
|
|
@ -274,6 +289,13 @@ COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-r
|
|||
|
||||
# ---------- Runtime ----------
|
||||
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
|
||||
# The desktop renderer web build (Layer above: `apps/desktop && npm run
|
||||
# build:web`), served by the gateway at /app. Explicitly opting in via this
|
||||
# env var is the ONLY thing that mounts /app — web_server.py has no path
|
||||
# fallback — so the browser UI is a fact of the image, never of a source
|
||||
# checkout. The dist survives `COPY . .` because .dockerignore excludes
|
||||
# apps/desktop/dist from the build context.
|
||||
ENV HERMES_APP_DIST=/opt/hermes/apps/desktop/dist
|
||||
# Point the TUI launcher at the prebuilt bundle baked at build time (Layer 8:
|
||||
# `ui-tui && npm run build`). This makes _make_tui_argv take the prebuilt-bundle
|
||||
# fast path (`node --expose-gc /opt/hermes/ui-tui/dist/entry.js`) and skip the
|
||||
|
|
@ -282,13 +304,14 @@ ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
|
|||
#
|
||||
# Why this is required (not just an optimization): the root package-lock.json
|
||||
# describes the WHOLE monorepo workspace set (root + web + ui-tui + apps/*),
|
||||
# but the image only installs root/web/ui-tui (apps/* — the desktop app — is
|
||||
# never `npm install`ed here). So the actualized node_modules permanently
|
||||
# disagrees with the canonical lock, _tui_need_npm_install() returns True on
|
||||
# every launch, and the runtime `npm install` it triggers (a) can never
|
||||
# converge against the partial monorepo and (b) races itself across concurrent
|
||||
# embedded-chat (/api/pty) connections → ENOTEMPTY → the chat tab dies with a
|
||||
# 502 / "[session ended]". Pointing at the prebuilt bundle sidesteps the whole
|
||||
# but the image's npm install only actualizes the workspaces present in the
|
||||
# build context (apps/desktop joined for the /app renderer build; any other
|
||||
# apps/* stay excluded). So the actualized node_modules can disagree with the
|
||||
# canonical lock, _tui_need_npm_install() returns True on every launch, and
|
||||
# the runtime `npm install` it triggers (a) can never converge against the
|
||||
# partial monorepo and (b) races itself across concurrent embedded-chat
|
||||
# (/api/pty) connections → ENOTEMPTY → the chat tab dies with a 502 /
|
||||
# "[session ended]". Pointing at the prebuilt bundle sidesteps the whole
|
||||
# check. (A separate launcher hardening is tracked independently.)
|
||||
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
|
||||
ENV HERMES_HOME=/opt/data
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"start": "npm run build && electron .",
|
||||
"prebuild": "tsc -b . --clean",
|
||||
"build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs",
|
||||
"build:web": "tsc -b tsconfig.electron.json && npm run typecheck && vite build",
|
||||
"postbuild": "node scripts/assert-dist-built.mjs",
|
||||
"prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs",
|
||||
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.mjs",
|
||||
|
|
|
|||
|
|
@ -65,6 +65,10 @@ export function AboutSettings() {
|
|||
const behind = status?.behind ?? 0
|
||||
const supported = status?.supported !== false
|
||||
const applying = apply.applying || apply.stage === 'restart'
|
||||
// On the web bridge there is no self-updater (the instance updates
|
||||
// server-side) — hide the update controls entirely rather than render a
|
||||
// check button that can never do anything. Version + release notes remain.
|
||||
const hasUpdater = Boolean(window.hermesDesktop?.updates)
|
||||
|
||||
const handleCheck = async () => {
|
||||
setJustChecked(false)
|
||||
|
|
@ -106,75 +110,79 @@ export function AboutSettings() {
|
|||
</div>
|
||||
|
||||
<div className="mx-auto mt-4 w-full max-w-2xl">
|
||||
<SectionHeading icon={RefreshCw} title={a.updates} />
|
||||
{hasUpdater && (
|
||||
<>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useI18n } from '@/i18n'
|
|||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, Bell, Download, Globe, Info, KeyRound, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { isWebHost } from '@/web-bridge'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { OverlayIconButton } from '../overlays/overlay-chrome'
|
||||
|
|
@ -156,13 +157,19 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||
label: t.settings.nav.providers,
|
||||
onSelect: () => setActiveView('providers')
|
||||
},
|
||||
{
|
||||
active: activeView === 'gateway',
|
||||
icon: Globe,
|
||||
id: 'gateway',
|
||||
label: t.settings.nav.gateway,
|
||||
onSelect: () => setActiveView('gateway')
|
||||
},
|
||||
// On web the connection IS the serving origin — there is nothing to
|
||||
// configure, so the Gateway panel is absent (RFC D7 stubbed-surface gating).
|
||||
...(isWebHost()
|
||||
? []
|
||||
: [
|
||||
{
|
||||
active: activeView === 'gateway',
|
||||
icon: Globe,
|
||||
id: 'gateway',
|
||||
label: t.settings.nav.gateway,
|
||||
onSelect: () => setActiveView('gateway')
|
||||
}
|
||||
]),
|
||||
{
|
||||
active: activeView === 'keys',
|
||||
children: [
|
||||
|
|
|
|||
10
apps/desktop/src/global.d.ts
vendored
10
apps/desktop/src/global.d.ts
vendored
|
|
@ -41,7 +41,7 @@ declare global {
|
|||
// The pop-out pet overlay: a transparent always-on-top window hosting only
|
||||
// the mascot. The main renderer drives it (open/close/drag + state push);
|
||||
// the overlay sends control messages back (pop-in, composer submit).
|
||||
petOverlay: {
|
||||
petOverlay?: {
|
||||
open: (request: PetOverlayOpenRequest) => Promise<{ ok: boolean; bounds?: PetOverlayBounds }>
|
||||
close: () => Promise<{ ok: boolean }>
|
||||
setBounds: (bounds: PetOverlayBounds) => void
|
||||
|
|
@ -167,7 +167,7 @@ declare global {
|
|||
// Repo-first discovery: scan bounded roots for git repos (depth-capped).
|
||||
scanRepos: (roots: string[], options?: { maxDepth?: number }) => Promise<{ root: string; label: string }[]>
|
||||
}
|
||||
terminal: {
|
||||
terminal?: {
|
||||
dispose: (id: string) => Promise<boolean>
|
||||
onData: (id: string, callback: (payload: string) => void) => () => void
|
||||
onExit: (id: string, callback: (payload: HermesTerminalExit) => void) => () => void
|
||||
|
|
@ -198,18 +198,18 @@ declare global {
|
|||
onBootstrapEvent: (callback: (payload: DesktopBootstrapEvent) => void) => () => void
|
||||
getVersion: () => Promise<DesktopVersionInfo>
|
||||
getRemoteDisplayReason?: () => Promise<string | null>
|
||||
updates: {
|
||||
updates?: {
|
||||
check: () => Promise<DesktopUpdateStatus>
|
||||
apply: (opts?: DesktopUpdateApplyOptions) => Promise<DesktopUpdateApplyResult>
|
||||
getBranch: () => Promise<{ branch: string }>
|
||||
setBranch: (name: string) => Promise<{ branch: string }>
|
||||
onProgress: (callback: (payload: DesktopUpdateProgress) => void) => () => void
|
||||
}
|
||||
uninstall: {
|
||||
uninstall?: {
|
||||
summary: () => Promise<DesktopUninstallSummary>
|
||||
run: (mode: DesktopUninstallMode) => Promise<DesktopUninstallResult>
|
||||
}
|
||||
themes: {
|
||||
themes?: {
|
||||
// Download a VS Code Marketplace extension and return the raw color
|
||||
// theme files it contributes. The renderer converts + persists them.
|
||||
fetchMarketplace: (id: string) => Promise<DesktopMarketplaceThemeResult>
|
||||
|
|
|
|||
4
apps/desktop/src/vite-env.d.ts
vendored
4
apps/desktop/src/vite-env.d.ts
vendored
|
|
@ -1 +1,5 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
// Injected by vite.config.ts `define` — the renderer package version, used by
|
||||
// the web bridge's getVersion (no Electron main process to ask on web).
|
||||
declare const __HERMES_RENDERER_VERSION__: string
|
||||
|
|
|
|||
|
|
@ -43,14 +43,13 @@ export const WEB_STUBBED_SURFACE: Record<string, string> = {
|
|||
fetchLinkTitle: 'needs a cross-origin fetch proxy; PrettyLink falls back to the URL',
|
||||
getRecentLogs: 'main-process log buffer does not exist on web',
|
||||
normalizePreviewTarget: 'main-process path/URL normalization; preview falls back',
|
||||
notify: 'delivered via the Notification API when permitted',
|
||||
notify: 'delivered via the Notification API (permission requested at first use)',
|
||||
oauthLoginConnectionConfig: 'sign-in happens on the gateway login page itself',
|
||||
oauthLogoutConnectionConfig: 'sign-out happens on the gateway login page itself',
|
||||
onBackendExit: 'no child process to observe',
|
||||
onBootProgress: 'no main-process boot pipeline; snapshot comes from getBootProgress',
|
||||
onBootstrapEvent: 'no first-launch bootstrap in a browser',
|
||||
onPreviewFileChanged: 'no file watcher on web (Phase 2 candidate)',
|
||||
petOverlay: 'no OS overlay windows in a browser',
|
||||
probeConnectionConfig: 'connection is fixed to the serving origin',
|
||||
profile: 'a hosted instance serves one profile',
|
||||
repairBootstrap: 'no first-launch bootstrap in a browser',
|
||||
|
|
@ -61,11 +60,7 @@ export const WEB_STUBBED_SURFACE: Record<string, string> = {
|
|||
selectPaths: 'native file picker; gateway-fs picker is Phase 2',
|
||||
settings: 'default project dir is a local-machine concept',
|
||||
stopPreviewFileWatch: 'no file watcher on web (Phase 2 candidate)',
|
||||
terminal: 'PTY-over-WebSocket lands in Phase 2 (/api/pty)',
|
||||
testConnectionConfig: 'connection is fixed to the serving origin',
|
||||
themes: 'marketplace fetch needs a proxy endpoint; deferred (RFC D6)',
|
||||
uninstall: 'nothing installed locally',
|
||||
updates: 'the instance updates server-side',
|
||||
watchPreviewFile: 'no file watcher on web (Phase 2 candidate)'
|
||||
}
|
||||
|
||||
|
|
@ -86,13 +81,18 @@ export const WEB_OMITTED_SURFACE: Record<string, string> = {
|
|||
onOpenUpdatesRequested: 'no app menu to emit it',
|
||||
onPowerResume: "browsers have no resume signal; the boot path's online/visibilitychange listeners cover wake",
|
||||
onWindowStateChanged: 'no native window chrome to report',
|
||||
petOverlay: 'no OS overlay windows in a browser; the overlay app never mounts (?win=overlay is Electron-launched)',
|
||||
renamePath: 'no gateway rename endpoint yet; project-tree rename hides',
|
||||
revealPath: 'no OS file manager to reveal into',
|
||||
setNativeTheme: 'no native window chrome to theme',
|
||||
setPreviewShortcutActive: 'no global shortcut registration',
|
||||
setTitleBarTheme: 'no native title bar',
|
||||
setTranslucency: 'no compositor-backed window translucency',
|
||||
terminal: 'PTY-over-WebSocket lands in Phase 2 (/api/pty); absence renders terminal tabs as closed',
|
||||
themes: 'marketplace fetch needs a proxy endpoint; deferred (RFC D6); absence empties the theme search',
|
||||
trashPath: 'no OS trash; destructive delete needs its own web decision',
|
||||
uninstall: 'nothing installed locally; UninstallSection self-hides without the bridge',
|
||||
updates: 'the instance updates server-side; About hides the update controls on web',
|
||||
zoom: 'the browser owns page zoom (Ctrl +/-)'
|
||||
}
|
||||
|
||||
|
|
@ -290,7 +290,9 @@ export function createWebBridge(): HermesDesktopBridge {
|
|||
remoteUrl: `${window.location.origin}${basePath()}`
|
||||
}),
|
||||
getVersion: async () => ({
|
||||
appVersion: 'web',
|
||||
// Vite-define'd renderer package version; guarded so a non-vite harness
|
||||
// (node --test, plain vitest transform) doesn't throw on the bare const.
|
||||
appVersion: typeof __HERMES_RENDERER_VERSION__ === 'string' ? __HERMES_RENDERER_VERSION__ : 'web',
|
||||
electronVersion: 'web',
|
||||
hermesRoot: '',
|
||||
nodeVersion: 'web',
|
||||
|
|
@ -455,10 +457,20 @@ export function createWebBridge(): HermesDesktopBridge {
|
|||
set: async () => ({ profile: null })
|
||||
},
|
||||
notify: async payload => {
|
||||
if (!('Notification' in window) || Notification.permission !== 'granted') {
|
||||
if (!('Notification' in window) || Notification.permission === 'denied') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Point-of-use permission: the first real notification asks. 'default'
|
||||
// (never asked) resolves here; a denial simply reports undelivered.
|
||||
if (Notification.permission !== 'granted') {
|
||||
const permission = await Notification.requestPermission()
|
||||
|
||||
if (permission !== 'granted') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
new Notification(payload.title ?? 'Hermes', { body: payload.body, silent: payload.silent })
|
||||
|
||||
return true
|
||||
|
|
@ -476,60 +488,13 @@ export function createWebBridge(): HermesDesktopBridge {
|
|||
},
|
||||
revealLogs: async () => ({ error: 'Not available on web.', ok: false, path: '' }),
|
||||
getRecentLogs: async () => ({ lines: [], path: '' }),
|
||||
petOverlay: {
|
||||
close: async () => ({ ok: false }),
|
||||
control: () => {},
|
||||
onControl: unsubscribe,
|
||||
onState: unsubscribe,
|
||||
open: async () => ({ ok: false }),
|
||||
pushState: () => {},
|
||||
setBounds: () => {},
|
||||
setFocusable: () => {},
|
||||
setIgnoreMouse: () => {}
|
||||
},
|
||||
terminal: {
|
||||
dispose: async () => false,
|
||||
onData: unsubscribe,
|
||||
onExit: unsubscribe,
|
||||
resize: async () => false,
|
||||
start: async () => {
|
||||
throw new Error('The embedded terminal is not available on web yet.')
|
||||
},
|
||||
write: async () => false
|
||||
},
|
||||
onPreviewFileChanged: unsubscribe,
|
||||
onBackendExit: unsubscribe,
|
||||
onBootProgress: unsubscribe,
|
||||
onBootstrapEvent: unsubscribe,
|
||||
resetBootstrap: async () => ({ ok: false }),
|
||||
repairBootstrap: async () => ({ ok: false }),
|
||||
cancelBootstrap: async () => ({ cancelled: false, ok: false }),
|
||||
updates: {
|
||||
apply: async () => ({ error: 'Updates are managed server-side on web.', ok: false }),
|
||||
check: async () => ({ reason: 'web', supported: false }),
|
||||
getBranch: async () => ({ branch: '' }),
|
||||
onProgress: unsubscribe,
|
||||
setBranch: async name => ({ branch: name })
|
||||
},
|
||||
uninstall: {
|
||||
run: async () => ({ error: 'Not available on web.', ok: false }),
|
||||
summary: async () => ({
|
||||
agent_installed: false,
|
||||
gui_installed: false,
|
||||
hermes_home: '',
|
||||
packaged_app_paths: [],
|
||||
platform: 'web',
|
||||
source_built_artifacts: [],
|
||||
userdata_dir: '',
|
||||
userdata_exists: false
|
||||
})
|
||||
},
|
||||
themes: {
|
||||
fetchMarketplace: async () => {
|
||||
throw new Error('Theme marketplace is not available on web yet.')
|
||||
},
|
||||
searchMarketplace: async () => []
|
||||
}
|
||||
cancelBootstrap: async () => ({ cancelled: false, ok: false })
|
||||
}
|
||||
|
||||
return bridge
|
||||
|
|
|
|||
|
|
@ -27,6 +27,14 @@ const fsAllow = [
|
|||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
// The renderer's own package version, for surfaces with no Electron main
|
||||
// process to ask (the web bridge's getVersion). Electron ignores this and
|
||||
// reports the running app's version over IPC.
|
||||
define: {
|
||||
__HERMES_RENDERER_VERSION__: JSON.stringify(
|
||||
JSON.parse(fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf8')).version ?? ''
|
||||
)
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
css: {
|
||||
// Pin an explicit (empty) PostCSS config. Tailwind is handled entirely by
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ try:
|
|||
WebSocket, WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
|
@ -108,7 +108,7 @@ except ImportError:
|
|||
WebSocket, WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
|
@ -119,6 +119,13 @@ except ImportError:
|
|||
)
|
||||
|
||||
WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist"
|
||||
|
||||
# The desktop renderer built for the browser (apps/desktop `npm run build:web`).
|
||||
# Mounted at /app ONLY when HERMES_APP_DIST is explicitly set and the dist
|
||||
# exists — the Docker image sets it; local testing sets it per-session. No env
|
||||
# var means /app is never mounted, so a regular desktop build leaving a dist at
|
||||
# apps/desktop/dist can't silently turn this on.
|
||||
APP_DIST = Path(os.environ["HERMES_APP_DIST"]) if "HERMES_APP_DIST" in os.environ else None
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -15699,6 +15706,99 @@ def _normalise_prefix(raw: Optional[str]) -> str:
|
|||
return normalise_prefix(raw)
|
||||
|
||||
|
||||
def mount_app(application: FastAPI):
|
||||
"""Mount the desktop renderer's web build at ``/app``.
|
||||
|
||||
The same UI the Electron desktop ships, built browser-only
|
||||
(``apps/desktop`` ``npm run build:web``) and served same-origin by this
|
||||
gateway, so a Nous Portal user can open a hosted instance with no install
|
||||
(docs/plans/2026-07-10-001). The renderer's web bridge reads the same
|
||||
bootstrap globals as the dashboard SPA (session token / auth-required /
|
||||
base path), so both auth modes work unchanged:
|
||||
|
||||
* loopback / token: ``__HERMES_SESSION_TOKEN__`` injected below.
|
||||
* gated / OAuth: no token; the auth gate has already required a session
|
||||
cookie before this handler runs (``/app`` is NOT in the gate's public
|
||||
prefixes), and the bridge mints WS tickets over that cookie.
|
||||
|
||||
The desktop bundle is built with vite ``base: './'`` (relative asset
|
||||
URLs), so under ``/app/`` the browser requests ``/app/assets/...`` — no
|
||||
absolute-URL rewriting needed. Skipped entirely when headless, when
|
||||
``HERMES_APP_DIST`` is unset (``APP_DIST is None`` — the default for every
|
||||
install that isn't the Docker image), or when the dist was never built:
|
||||
regular installs never see these routes.
|
||||
"""
|
||||
_headless = os.environ.get("HERMES_SERVE_HEADLESS") == "1"
|
||||
if _headless or APP_DIST is None:
|
||||
return
|
||||
_index_path = APP_DIST / "index.html"
|
||||
if not _index_path.exists():
|
||||
# Env set but no dist: the operator asked for /app (e.g. the Docker
|
||||
# image sets HERMES_APP_DIST unconditionally) but the build isn't
|
||||
# there. Warn loudly instead of silently not mounting — otherwise
|
||||
# a broken image build surfaces as an unexplained 404 on /app.
|
||||
_log.warning(
|
||||
"HERMES_APP_DIST is set but no dist found at %s — /app will not "
|
||||
"be served. Build it with: cd apps/desktop && npm run build:web",
|
||||
APP_DIST,
|
||||
)
|
||||
return
|
||||
|
||||
def _serve_app_index() -> HTMLResponse:
|
||||
html = _index_path.read_text(encoding="utf-8")
|
||||
gated = bool(getattr(app.state, "auth_required", False))
|
||||
gated_js = "true" if gated else "false"
|
||||
if gated:
|
||||
bootstrap_script = (
|
||||
f"<script>"
|
||||
f'window.__HERMES_BASE_PATH__="";'
|
||||
f"window.__HERMES_AUTH_REQUIRED__={gated_js};"
|
||||
f"</script>"
|
||||
)
|
||||
else:
|
||||
bootstrap_script = (
|
||||
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";'
|
||||
f'window.__HERMES_BASE_PATH__="";'
|
||||
f"window.__HERMES_AUTH_REQUIRED__={gated_js};"
|
||||
f"</script>"
|
||||
)
|
||||
html = html.replace("</head>", f"{bootstrap_script}</head>", 1)
|
||||
return HTMLResponse(
|
||||
html,
|
||||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||||
)
|
||||
|
||||
application.mount(
|
||||
"/app/assets", StaticFiles(directory=APP_DIST / "assets"), name="app-assets"
|
||||
)
|
||||
|
||||
@application.get("/app")
|
||||
async def serve_app_bare():
|
||||
# The bundle uses relative asset URLs (vite base './'), which resolve
|
||||
# against the document URL: at /app they'd hit the dashboard's
|
||||
# /assets/. Canonicalize to /app/ so they resolve to /app/assets/.
|
||||
return RedirectResponse(url="/app/", status_code=307)
|
||||
|
||||
@application.get("/app/")
|
||||
async def serve_app_root():
|
||||
return _serve_app_index()
|
||||
|
||||
@application.get("/app/{full_path:path}")
|
||||
async def serve_app(full_path: str):
|
||||
file_path = APP_DIST / full_path
|
||||
# Same traversal guard as the dashboard SPA mount below.
|
||||
if (
|
||||
full_path
|
||||
and file_path.resolve().is_relative_to(APP_DIST.resolve())
|
||||
and file_path.exists()
|
||||
and file_path.is_file()
|
||||
):
|
||||
return FileResponse(file_path)
|
||||
# HashRouter: every client route lives after '#', so any non-file path
|
||||
# is just the shell.
|
||||
return _serve_app_index()
|
||||
|
||||
|
||||
def mount_spa(application: FastAPI):
|
||||
"""Mount the built SPA. Falls back to index.html for client-side routing.
|
||||
|
||||
|
|
@ -16859,6 +16959,7 @@ _mount_plugin_api_routes()
|
|||
from hermes_cli.dashboard_auth.routes import router as _dashboard_auth_router # noqa: E402
|
||||
app.include_router(_dashboard_auth_router)
|
||||
|
||||
mount_app(app)
|
||||
mount_spa(app)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue