feat(installer): Tauri bootstrap installer for first-time onboarding
Hermes-Setup.exe is a small signed Rust+Tauri binary that drives
scripts/install.ps1 stage-by-stage with a native UI matching the
desktop's design language. Replaces the chicken-and-egg pattern of
shipping a 200MB Electron app whose first launch existed only to
run install.ps1.
The architecture:
Rust backend (src-tauri/):
bootstrap.rs orchestrator -- Tauri commands, stage iteration
install_script.rs resolve install.ps1 (dev checkout, cache, GitHub raw)
powershell.rs spawn powershell, line-stream stdout/stderr, parse JSON
events.rs BootstrapEvent types -- mirror bootstrap-runner.cjs
paths.rs HERMES_HOME resolution + tracing log setup
build.rs bakes BUILD_PIN_COMMIT / BUILD_PIN_BRANCH from
'git rev-parse HEAD' at compile time
React frontend (src/):
Tauri webview rendering 4 screens (welcome / progress / success /
failure), driven by nanostores subscribing to the Rust event stream.
Visual layer reuses the desktop's styles.css wholesale via @import
so the installer and desktop never drift visually.
Distribution:
targets = ['app', 'dmg', 'appimage'] -- no NSIS/MSI wrapper. The
raw target/release/Hermes-Setup.exe IS the artifact on Windows;
.dmg + .app on macOS; AppImage on Linux. One file, double-click,
no installer-installing-an-installer pattern.
Compile-time pinning:
build.rs reads 'git rev-parse HEAD' and emits
cargo:rustc-env=BUILD_PIN_COMMIT=<sha> + BUILD_PIN_BRANCH=<branch>.
bootstrap.rs's option_env!() picks these up so the binary fetches
install.ps1 from the exact SHA it was tested against. CI / release
builds can override via HERMES_BUILD_PIN_COMMIT env var.
Windows manifest:
hermes-setup.manifest declares level='asInvoker' so the
productName 'Hermes Setup' doesn't trip Windows's installer-
detection heuristic and refuse to launch without elevation.
Also declares PerMonitorV2 DPI + UTF-8 active code page + Common
Controls v6.
Limitations of this initial version:
* No code signing -- Windows SmartScreen will warn once on Hermes-Setup.exe
('More info -> Run anyway'). The downstream binaries it produces
(Hermes.exe in win-unpacked/, the hermes CLI) are locally-built and
therefore don't carry MOTW, so they launch without SmartScreen
intervention. Cert procurement tracked separately.
* macOS and Linux build paths defined but untested -- Windows-only V1.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect } from 'react'
|
||||
import { $route, $bootstrap, initialize } from './store'
|
||||
import Welcome from './routes/welcome'
|
||||
import Progress from './routes/progress'
|
||||
import Success from './routes/success'
|
||||
import Failure from './routes/failure'
|
||||
|
||||
/*
|
||||
* App shell — Hermes Setup.
|
||||
*
|
||||
* No header chrome (the OS title bar already says "Hermes Setup"; an
|
||||
* in-window repeat of the H mark + words was redundant slop).
|
||||
*
|
||||
* Route state lives in a single $route atom — 4 screens, no react-router.
|
||||
*/
|
||||
export default function App() {
|
||||
const route = useStore($route)
|
||||
const bootstrap = useStore($bootstrap)
|
||||
|
||||
useEffect(() => {
|
||||
void initialize()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col overflow-hidden bg-background text-foreground">
|
||||
<main className="relative z-10 flex flex-1 flex-col overflow-hidden">
|
||||
{route === 'welcome' && <Welcome />}
|
||||
{route === 'progress' && <Progress bootstrap={bootstrap} />}
|
||||
{route === 'success' && <Success />}
|
||||
{route === 'failure' && <Failure bootstrap={bootstrap} />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Slot } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
/*
|
||||
* Button — copied verbatim from apps/desktop/src/components/ui/button.tsx.
|
||||
*
|
||||
* We import the desktop's local shadcn-style Button rather than
|
||||
* @nous-research/ui's <Button>, because the DS Button uses bg-midground /
|
||||
* text-background-base utilities that resolve to the DS's hardcoded
|
||||
* gold/brown brand defaults (#ffac02 / #170d02) unless overridden in
|
||||
* runtime. The desktop never sets those vars; it routes through its
|
||||
* own --dt-* token chain via shadcn classes like bg-primary. We do
|
||||
* the same so visuals match exactly.
|
||||
*/
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-xs':
|
||||
"size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ComponentProps<'button'>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const Comp = asChild ? Slot.Root : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
data-size={size}
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { buttonVariants }
|
||||
@@ -0,0 +1,12 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
/*
|
||||
* cn — Tailwind-aware class merger. Same util the desktop and dashboard
|
||||
* use. clsx handles conditional classes; twMerge resolves utility
|
||||
* conflicts so `cn('px-2', condition && 'px-4')` ends up with px-4 only,
|
||||
* not both.
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './app.tsx'
|
||||
import './styles.css'
|
||||
|
||||
// Default to LIGHT mode — matches the Hermes desktop's default. The
|
||||
// desktop's runtime theme system can switch to .dark later, but our
|
||||
// installer ships in light mode only since we don't carry the theme
|
||||
// provider machinery.
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
import { type CSSProperties } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Button } from '../components/button'
|
||||
import {
|
||||
$logPath,
|
||||
openLogDir,
|
||||
startInstall,
|
||||
type BootstrapStateModel
|
||||
} from '../store'
|
||||
import { RefreshCw, FileText } from 'lucide-react'
|
||||
|
||||
interface FailureProps {
|
||||
bootstrap: BootstrapStateModel
|
||||
}
|
||||
|
||||
/*
|
||||
* Failure screen. Same hero treatment as Welcome/Success — the wordmark
|
||||
* carries the brand, so we keep it across every terminal state.
|
||||
*
|
||||
* The actual error message lives below in muted text. Two clear
|
||||
* affordances: Retry (primary) and Open log folder (secondary).
|
||||
*/
|
||||
export default function Failure({ bootstrap }: FailureProps) {
|
||||
const logPath = useStore($logPath)
|
||||
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-6 px-12 py-10">
|
||||
<div className="w-full max-w-2xl min-w-0 text-center">
|
||||
<p
|
||||
className="fit-text mx-auto mb-4 w-full font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-destructive mix-blend-plus-lighter dark:text-destructive/90"
|
||||
style={
|
||||
{
|
||||
'--fit-text-line-height': '0.9',
|
||||
'--fit-text-max': '5rem',
|
||||
'--fit-text-min': '2.25rem'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>Install didn’t finish</span>
|
||||
</span>
|
||||
<span aria-hidden="true">Install didn’t finish</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 mx-auto max-w-xl text-center text-sm leading-normal tracking-tight text-muted-foreground">
|
||||
{bootstrap.error ?? 'Something went wrong during installation.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => void startInstall()}
|
||||
size="lg"
|
||||
className="inline-flex items-center gap-2 px-6"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Retry install
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => void openLogDir()}
|
||||
className="inline-flex items-center gap-2"
|
||||
>
|
||||
<FileText size={16} />
|
||||
Open log folder
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{logPath && (
|
||||
<p className="max-w-lg text-center text-xs text-muted-foreground/70">
|
||||
Log: <code className="font-mono">{logPath}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Button } from '../components/button'
|
||||
import {
|
||||
cancelInstall,
|
||||
$progress,
|
||||
type BootstrapStateModel,
|
||||
type StageState
|
||||
} from '../store'
|
||||
import { Check, X, ChevronRight, FileText, Loader2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ProgressProps {
|
||||
bootstrap: BootstrapStateModel
|
||||
}
|
||||
|
||||
/*
|
||||
* Progress screen — drives a stage list + collapsible log panel. Uses
|
||||
* the DS <Progress> for the top bar so its motion + ring match the rest
|
||||
* of the product.
|
||||
*/
|
||||
export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
||||
const progress = useStore($progress)
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
const logEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (showLogs && logEndRef.current) {
|
||||
logEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [bootstrap.logs.length, showLogs])
|
||||
|
||||
const currentStage =
|
||||
bootstrap.currentStage != null
|
||||
? bootstrap.stages[bootstrap.currentStage]
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col">
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<div className="mb-3 flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
{bootstrap.status === 'running' && (
|
||||
<Loader2 size={12} className="animate-spin text-primary" />
|
||||
)}
|
||||
<span>
|
||||
{bootstrap.status === 'running'
|
||||
? currentStage
|
||||
? currentStage.info.title
|
||||
: 'Preparing\u2026'
|
||||
: bootstrap.status === 'completed'
|
||||
? 'Done'
|
||||
: 'Installing'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{progress.done} of {progress.total} steps
|
||||
</div>
|
||||
</div>
|
||||
{/* Top progress bar — plain HTML, derived from --primary so it
|
||||
tracks the theme accent. */}
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.max(2, progress.fraction * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<ol className="space-y-1">
|
||||
{bootstrap.stageOrder.map((name) => {
|
||||
const rec = bootstrap.stages[name]
|
||||
if (!rec) return null
|
||||
return (
|
||||
<li
|
||||
key={name}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
|
||||
rec.state === 'running' && 'bg-card text-foreground',
|
||||
rec.state === 'succeeded' && 'text-foreground/80',
|
||||
rec.state === 'skipped' && 'text-muted-foreground',
|
||||
rec.state === 'failed' &&
|
||||
'bg-destructive/10 text-destructive',
|
||||
!rec.state && 'text-muted-foreground/60'
|
||||
)}
|
||||
>
|
||||
<StateIcon state={rec.state ?? null} />
|
||||
<span className="flex-1 truncate">{rec.info.title}</span>
|
||||
{rec.durationMs != null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDuration(rec.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{showLogs && (
|
||||
<div className="flex w-1/2 flex-col border-l border-border bg-card/40">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
|
||||
<div className="text-xs font-medium text-foreground/80">
|
||||
Live output
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{bootstrap.logs.length} lines
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[11px] leading-relaxed">
|
||||
{bootstrap.logs.map((entry, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={clsx(
|
||||
'whitespace-pre-wrap',
|
||||
entry.line.startsWith('stderr:')
|
||||
? 'text-destructive'
|
||||
: 'text-foreground/70'
|
||||
)}
|
||||
>
|
||||
{entry.line}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-border px-6 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLogs((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<FileText size={14} />
|
||||
{showLogs ? 'Hide details' : 'Show details'}
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={clsx(
|
||||
'transition-transform',
|
||||
showLogs && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{bootstrap.status === 'running' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void cancelInstall()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StateIcon({ state }: { state: StageState | null }) {
|
||||
if (state === 'running') {
|
||||
return <Loader2 size={14} className="animate-spin text-primary" />
|
||||
}
|
||||
if (state === 'succeeded') {
|
||||
return <Check size={14} className="text-emerald-400" />
|
||||
}
|
||||
if (state === 'skipped') {
|
||||
return <ChevronRight size={14} className="text-muted-foreground/70" />
|
||||
}
|
||||
if (state === 'failed') {
|
||||
return <X size={14} className="text-destructive" />
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="h-[6px] w-[6px] rounded-full bg-muted-foreground/40"
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
const m = Math.floor(ms / 60000)
|
||||
const s = Math.round((ms % 60000) / 1000)
|
||||
return `${m}m ${s}s`
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { type CSSProperties } from 'react'
|
||||
import { Button } from '../components/button'
|
||||
import { launchHermesDesktop } from '../store'
|
||||
import { Rocket } from 'lucide-react'
|
||||
|
||||
/*
|
||||
* Success screen. HERMES AGENT wordmark stays as the visual anchor
|
||||
* (same Collapse Bold treatment as Welcome + the desktop chat intro),
|
||||
* with a status line below.
|
||||
*
|
||||
* No install-path footer — same rationale as Welcome.
|
||||
*/
|
||||
export default function Success() {
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-8 px-12 py-10">
|
||||
<div className="w-full max-w-2xl min-w-0 text-center">
|
||||
<p
|
||||
className="fit-text mx-auto mb-4 w-full font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={
|
||||
{
|
||||
'--fit-text-line-height': '0.9',
|
||||
'--fit-text-max': '5rem',
|
||||
'--fit-text-min': '2.25rem'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>Hermes is ready</span>
|
||||
</span>
|
||||
<span aria-hidden="true">Hermes is ready</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 text-center text-base leading-normal tracking-tight text-muted-foreground">
|
||||
You can launch from here, or any time from your terminal with{' '}
|
||||
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-sm">
|
||||
hermes desktop
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => void launchHermesDesktop()}
|
||||
size="lg"
|
||||
className="inline-flex items-center gap-2 px-6"
|
||||
>
|
||||
<Rocket size={18} />
|
||||
Launch Hermes
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { type CSSProperties } from 'react'
|
||||
import { Button } from '../components/button'
|
||||
import { startInstall } from '../store'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
/*
|
||||
* Welcome screen.
|
||||
*
|
||||
* Mirrors the desktop's chat intro (apps/desktop/src/components/chat/intro.tsx):
|
||||
* - HERMES AGENT wordmark rendered in Collapse Bold, uppercase, tracked
|
||||
* - mix-blend-plus-lighter so the type "glows" on the canvas
|
||||
* - fit-text utility so the wordmark sizes itself to the column
|
||||
*
|
||||
* No install-path footer. The default install location is correct for
|
||||
* 99% of users; the rest will use the CLI installer with a -HermesHome
|
||||
* flag. Showing %LOCALAPPDATA% to grandma is developer-brain.
|
||||
*/
|
||||
export default function Welcome() {
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-10 px-12 py-10">
|
||||
{/* Hero — same recipe the desktop's chat/intro.tsx uses */}
|
||||
<div className="w-full max-w-2xl min-w-0 text-center">
|
||||
<p
|
||||
className="fit-text mx-auto mb-4 w-full font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={
|
||||
{
|
||||
'--fit-text-line-height': '0.9',
|
||||
'--fit-text-max': '6rem',
|
||||
'--fit-text-min': '2.5rem'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>HERMES AGENT</span>
|
||||
</span>
|
||||
<span aria-hidden="true">HERMES AGENT</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 text-center text-base leading-normal tracking-tight text-muted-foreground">
|
||||
The agent that grows with you. We’ll set things up in the
|
||||
background — takes a few minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => void startInstall()}
|
||||
size="lg"
|
||||
className="group inline-flex items-center gap-2 px-6"
|
||||
>
|
||||
Install Hermes
|
||||
<ArrowRight
|
||||
size={18}
|
||||
className="transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { atom, computed } from 'nanostores'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
/*
|
||||
* Bootstrap state store — single source of truth for installer screens.
|
||||
*
|
||||
* Lives in nanostores per the project's TypeScript guidelines (apps/desktop
|
||||
* AGENTS.md): "Prefer small nanostores over component state when state is
|
||||
* shared, reused, or read by distant UI."
|
||||
*
|
||||
* One channel from Rust ('bootstrap' event), discriminated by payload.type.
|
||||
* We translate those events into typed atom updates here so the rest of
|
||||
* the app only deals with React-friendly state.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — mirror src-tauri/src/events.rs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StageInfo {
|
||||
name: string
|
||||
title: string
|
||||
category: string
|
||||
needs_user_input: boolean
|
||||
}
|
||||
|
||||
export type StageState = 'running' | 'succeeded' | 'skipped' | 'failed'
|
||||
|
||||
export interface StageRecord {
|
||||
info: StageInfo
|
||||
state: StageState | null
|
||||
durationMs?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BootstrapStateModel {
|
||||
status: 'idle' | 'running' | 'completed' | 'failed'
|
||||
protocolVersion: number | null
|
||||
stages: Record<string, StageRecord>
|
||||
stageOrder: string[]
|
||||
currentStage: string | null
|
||||
installRoot: string | null
|
||||
error: string | null
|
||||
logs: Array<{ stage?: string; line: string }>
|
||||
}
|
||||
|
||||
const INITIAL: BootstrapStateModel = {
|
||||
status: 'idle',
|
||||
protocolVersion: null,
|
||||
stages: {},
|
||||
stageOrder: [],
|
||||
currentStage: null,
|
||||
installRoot: null,
|
||||
error: null,
|
||||
logs: []
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Route = 'welcome' | 'progress' | 'success' | 'failure'
|
||||
|
||||
export const $route = atom<Route>('welcome')
|
||||
export const $bootstrap = atom<BootstrapStateModel>(INITIAL)
|
||||
export const $logPath = atom<string | null>(null)
|
||||
export const $hermesHome = atom<string | null>(null)
|
||||
|
||||
export const $progress = computed($bootstrap, (b) => {
|
||||
const total = b.stageOrder.length
|
||||
if (total === 0) return { done: 0, total: 0, fraction: 0 }
|
||||
let done = 0
|
||||
for (const name of b.stageOrder) {
|
||||
const s = b.stages[name]?.state
|
||||
if (s === 'succeeded' || s === 'skipped' || s === 'failed') done += 1
|
||||
}
|
||||
return { done, total, fraction: done / total }
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri event subscription
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BootstrapManifestEvent {
|
||||
type: 'manifest'
|
||||
stages: StageInfo[]
|
||||
protocolVersion: number | null
|
||||
}
|
||||
|
||||
interface BootstrapStageEvent {
|
||||
type: 'stage'
|
||||
name: string
|
||||
state: StageState
|
||||
durationMs?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface BootstrapLogEvent {
|
||||
type: 'log'
|
||||
stage?: string
|
||||
line: string
|
||||
}
|
||||
|
||||
interface BootstrapCompleteEvent {
|
||||
type: 'complete'
|
||||
installRoot: string
|
||||
marker: unknown
|
||||
}
|
||||
|
||||
interface BootstrapFailedEvent {
|
||||
type: 'failed'
|
||||
stage?: string
|
||||
error: string
|
||||
}
|
||||
|
||||
type BootstrapEvent =
|
||||
| BootstrapManifestEvent
|
||||
| BootstrapStageEvent
|
||||
| BootstrapLogEvent
|
||||
| BootstrapCompleteEvent
|
||||
| BootstrapFailedEvent
|
||||
|
||||
let unlisten: UnlistenFn | null = null
|
||||
|
||||
export async function initialize(): Promise<void> {
|
||||
if (unlisten) return
|
||||
|
||||
// Pull static info on mount for the diagnostics footer.
|
||||
try {
|
||||
const [logPath, hermesHome] = await Promise.all([
|
||||
invoke<string>('get_log_path'),
|
||||
invoke<string>('get_hermes_home')
|
||||
])
|
||||
$logPath.set(logPath)
|
||||
$hermesHome.set(hermesHome)
|
||||
} catch (err) {
|
||||
console.warn('failed to fetch installer paths', err)
|
||||
}
|
||||
|
||||
unlisten = await listen<BootstrapEvent>('bootstrap', (event) => {
|
||||
const payload = event.payload
|
||||
const cur = $bootstrap.get()
|
||||
switch (payload.type) {
|
||||
case 'manifest': {
|
||||
const stages: Record<string, StageRecord> = {}
|
||||
const order: string[] = []
|
||||
for (const s of payload.stages) {
|
||||
stages[s.name] = { info: s, state: null }
|
||||
order.push(s.name)
|
||||
}
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
status: 'running',
|
||||
protocolVersion: payload.protocolVersion,
|
||||
stages,
|
||||
stageOrder: order,
|
||||
currentStage: null,
|
||||
installRoot: null,
|
||||
error: null,
|
||||
logs: []
|
||||
})
|
||||
$route.set('progress')
|
||||
break
|
||||
}
|
||||
case 'stage': {
|
||||
const existing = cur.stages[payload.name]
|
||||
if (!existing) {
|
||||
console.warn('stage event for unknown stage', payload.name)
|
||||
break
|
||||
}
|
||||
const next: StageRecord = {
|
||||
...existing,
|
||||
state: payload.state,
|
||||
durationMs: payload.durationMs,
|
||||
error: payload.error
|
||||
}
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
stages: { ...cur.stages, [payload.name]: next },
|
||||
currentStage:
|
||||
payload.state === 'running' ? payload.name : cur.currentStage
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'log': {
|
||||
const logs = [...cur.logs, { stage: payload.stage, line: payload.line }]
|
||||
// Keep the rolling buffer bounded so the UI doesn't get OOM'd
|
||||
// during a long install (playwright chromium download is ~10k lines).
|
||||
const trimmed = logs.length > 2000 ? logs.slice(-2000) : logs
|
||||
$bootstrap.set({ ...cur, logs: trimmed })
|
||||
break
|
||||
}
|
||||
case 'complete':
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
status: 'completed',
|
||||
installRoot: payload.installRoot,
|
||||
currentStage: null
|
||||
})
|
||||
$route.set('success')
|
||||
break
|
||||
case 'failed':
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
status: 'failed',
|
||||
error: payload.error,
|
||||
currentStage: null
|
||||
})
|
||||
$route.set('failure')
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function startInstall(opts?: { branch?: string }): Promise<void> {
|
||||
// Reset before kicking off so a retry from the failure screen clears
|
||||
// the previous run's state.
|
||||
$bootstrap.set(INITIAL)
|
||||
$route.set('progress')
|
||||
await invoke('start_bootstrap', {
|
||||
args: {
|
||||
commit: null,
|
||||
branch: opts?.branch ?? null,
|
||||
include_desktop: true,
|
||||
hermes_home: null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function cancelInstall(): Promise<void> {
|
||||
await invoke('cancel_bootstrap')
|
||||
}
|
||||
|
||||
export async function launchHermesDesktop(): Promise<void> {
|
||||
const installRoot = $bootstrap.get().installRoot
|
||||
if (!installRoot) throw new Error('no install root')
|
||||
await invoke('launch_hermes_desktop', { installRoot })
|
||||
}
|
||||
|
||||
export async function openLogDir(): Promise<void> {
|
||||
await invoke('open_log_dir')
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Hermes Setup — defer entirely to the desktop's styles.css.
|
||||
*
|
||||
* Rather than re-implement the Hermes design system (and inevitably drift
|
||||
* from it), we import apps/desktop/src/styles.css wholesale. The desktop
|
||||
* is the canonical source of truth for fonts, color tokens, button chrome,
|
||||
* scrollbars, layout utilities, and animations. Any change to the
|
||||
* Hermes look propagates here automatically with no copy-paste maintenance.
|
||||
*
|
||||
* Path resolution caveats:
|
||||
* - Tailwind v4's `@import` resolves relative to this file. The desktop's
|
||||
* `@source '../../../node_modules/...'` declarations therefore re-resolve
|
||||
* against apps/bootstrap-installer/src/. Since both apps live two levels
|
||||
* deep under the same repo root, `../../../node_modules` lands in the
|
||||
* same place. (Verify if either app ever moves.)
|
||||
* - The desktop's `@font-face url('../../../node_modules/...')` references
|
||||
* are baked into the *imported* stylesheet; CSS resolves url()s relative
|
||||
* to the file that contains them, so they continue to point at the
|
||||
* correct node_modules path even from here.
|
||||
*
|
||||
* Forced light mode: the desktop ships with a runtime theme switcher
|
||||
* (ThemeProvider + applyTheme) that can flip to dark via document.documentElement.
|
||||
* The installer has no UI for theme switching, so we stay on the desktop's
|
||||
* default light surface (Nous-blue accent on near-white chrome).
|
||||
*/
|
||||
@import '../../desktop/src/styles.css';
|
||||
|
||||
/* Installer-only additions: a fade-in animation and a warm radial glow
|
||||
for the welcome screen. Everything else inherits from the desktop. */
|
||||
@keyframes hermes-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hermes-fade-in {
|
||||
animation: hermes-fade-in 0.45s ease-out both;
|
||||
}
|
||||
|
||||
.hermes-glow {
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
color-mix(in srgb, var(--ui-warm) 18%, transparent) 0%,
|
||||
transparent 60%
|
||||
);
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user