From 9e4ed4d7a922844710a5ebd9e551a72e5090eca2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 14:46:28 -0500 Subject: [PATCH] =?UTF-8?q?feat(installer):=20redesign=20the=20Tauri=20set?= =?UTF-8?q?up=20shim=20=E2=80=94=20design=20system,=20OS=20theme,=20granul?= =?UTF-8?q?ar=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring Hermes-Setup.exe's UI onto the shared design tokens (self-contained, no desktop-component coupling) and add two capabilities: - design: flat stage rows (running step opaque, rest muted), neutral check / destructive cross, running fourier-flow Loader, hairline --stroke-nous borders, fill-less log panel; ported BrandMark (nous-girl) + HackeryButton + Loader standalone; re-synced button variants; de-boxed success/failure. - theme: follow the OS light/dark via the authoritative Tauri window theme (theme.ts + onThemeChanged, core:window:allow-theme), with Nous dark seed colors in styles.css so the --ui-*/--dt-* chain derives correctly. - updates: split the monolithic "Updating" bar into handoff -> download -> rebuild (+ install on macOS) stages via a shared update_stages() builder, a live elapsed timer on the running stage, and a dev-only fake-boot preview (gated on import.meta.env.DEV, stripped from the shipped bundle). --- apps/bootstrap-installer/public/nous-girl.jpg | Bin 0 -> 20026 bytes .../src-tauri/capabilities/default.json | 1 + .../src-tauri/src/update.rs | 95 ++++++--- .../src/components/brand-mark.tsx | 13 ++ .../src/components/button.tsx | 31 +-- .../src/components/hackery-button.tsx | 36 ++++ .../src/components/loader.tsx | 136 +++++++++++++ apps/bootstrap-installer/src/main.tsx | 10 +- .../src/routes/failure.tsx | 23 +-- .../src/routes/progress.tsx | 171 ++++++++-------- .../src/routes/success.tsx | 33 ++- .../src/routes/welcome.tsx | 15 +- apps/bootstrap-installer/src/store.ts | 192 ++++++++++++++++-- apps/bootstrap-installer/src/styles.css | 45 +++- apps/bootstrap-installer/src/theme.ts | 51 +++++ 15 files changed, 658 insertions(+), 194 deletions(-) create mode 100644 apps/bootstrap-installer/public/nous-girl.jpg create mode 100644 apps/bootstrap-installer/src/components/brand-mark.tsx create mode 100644 apps/bootstrap-installer/src/components/hackery-button.tsx create mode 100644 apps/bootstrap-installer/src/components/loader.tsx create mode 100644 apps/bootstrap-installer/src/theme.ts diff --git a/apps/bootstrap-installer/public/nous-girl.jpg b/apps/bootstrap-installer/public/nous-girl.jpg new file mode 100644 index 0000000000000000000000000000000000000000..19861544bbb77223d2d8b2330f553146ef316fad GIT binary patch literal 20026 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 Rk=1-dLxO!=ZC|ht|JhQpIHCXm literal 0 HcmV?d00001 diff --git a/apps/bootstrap-installer/src-tauri/capabilities/default.json b/apps/bootstrap-installer/src-tauri/capabilities/default.json index e07617ce0ce..9500e4b6204 100644 --- a/apps/bootstrap-installer/src-tauri/capabilities/default.json +++ b/apps/bootstrap-installer/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ "core:default", "core:window:allow-close", "core:window:allow-minimize", + "core:window:allow-theme", "core:event:default", "opener:default", "dialog:default", diff --git a/apps/bootstrap-installer/src-tauri/src/update.rs b/apps/bootstrap-installer/src-tauri/src/update.rs index 539f69e9f78..c085ef60a4b 100644 --- a/apps/bootstrap-installer/src-tauri/src/update.rs +++ b/apps/bootstrap-installer/src-tauri/src/update.rs @@ -12,8 +12,10 @@ //! 4. launch the freshly-built desktop (reuses bootstrap::launch logic). //! //! We reuse the `BootstrapEvent` channel + the existing progress UI by -//! emitting a synthetic two-stage manifest ("update", "rebuild"). To the -//! frontend an update looks like a short bootstrap. +//! emitting a synthetic multi-stage manifest (handoff → update → rebuild, plus +//! an install stage on macOS). To the frontend an update looks like a short +//! bootstrap, broken into the real operations run_update performs so the user +//! sees discrete steps (with the live log underneath) instead of one bar. //! //! Cross-platform note: `hermes update` already handles macOS/Linux (git/pip). //! The only OS-specific bits here are the venv shim path (resolve_hermes) and @@ -70,17 +72,10 @@ pub async fn start_update(app: AppHandle) -> Result<(), String> { } else { None }; - let mut stages = vec![ - stage_info("update", "Updating Hermes"), - stage_info("rebuild", "Rebuilding the desktop app"), - ]; - if cfg!(target_os = "macos") && target_app.is_some() { - stages.push(stage_info("install", "Installing the updated app")); - } emit( &app, BootstrapEvent::Manifest { - stages, + stages: update_stages(target_app.is_some()), protocol_version: None, }, ); @@ -183,32 +178,35 @@ async fn run_update(app: AppHandle) -> Result<()> { anyhow!(msg) })?; - // Synthetic manifest so the existing progress UI renders our two stages. - let mut stages = vec![ - stage_info("update", "Updating Hermes"), - stage_info("rebuild", "Rebuilding the desktop app"), - ]; - if cfg!(target_os = "macos") && target_app.is_some() { - stages.push(stage_info("install", "Installing the updated app")); - } - + // Synthetic manifest so the existing progress UI renders our stages. emit( &app, BootstrapEvent::Manifest { - stages, + stages: update_stages(target_app.is_some()), protocol_version: None, }, ); - // ---- pre-step: wait for the old desktop to die ----------------------- + // ---- stage 1: wait for the old desktop to die ------------------------ // The desktop exec'd us then called app.exit(), but process teardown is // async on Windows. If it still holds the venv shim, `hermes update` // aborts with exit 2. If it still holds the packaged app.asar, // install.ps1's repair/re-clone path cannot move/remove the install tree. - // Give both handles a bounded window to clear. - wait_for_install_locks_free(&install_root, &app, "update").await; + // Give both handles a bounded window to clear. Surfaced as its own stage + // (rather than a silent pre-step) so a slow close / force-kill reads as + // real progress instead of a frozen first bar. + let started = Instant::now(); + emit_stage(&app, "handoff", StageState::Running, None, None); + wait_for_install_locks_free(&install_root, &app, "handoff").await; + emit_stage( + &app, + "handoff", + StageState::Succeeded, + Some(started.elapsed().as_millis() as u64), + None, + ); - // ---- stage 1: hermes update ----------------------------------------- + // ---- stage 2: hermes update ----------------------------------------- // Pass --branch so `hermes update` targets the branch this installer was // built/pinned against (BUILD_PIN_BRANCH), NOT its built-in default of // `main`. The install was a detached-HEAD checkout of a specific commit; @@ -332,7 +330,7 @@ async fn run_update(app: AppHandle) -> Result<()> { } } - // ---- stage 2: hermes desktop --build-only ---------------------------- + // ---- stage 3: hermes desktop --build-only ---------------------------- // `hermes update` deliberately does NOT build apps/desktop (it installs // repo-root deps with --workspaces=false). This is the rebuild it skips. emit_stage(&app, "rebuild", StageState::Running, None, None); @@ -953,6 +951,23 @@ fn stage_info(name: &str, title: &str) -> StageInfo { } } +/// The synthetic update manifest. Mirrors the real operations `run_update` +/// performs so the progress UI shows them as discrete steps (with the live log +/// underneath) instead of one monolithic bar. `include_install` adds the macOS +/// app-swap stage. Both the happy path and the re-entrancy guard build the +/// manifest here so the two can never drift apart. +fn update_stages(include_install: bool) -> Vec { + let mut stages = vec![ + stage_info("handoff", "Preparing to update"), + stage_info("update", "Downloading the latest version"), + stage_info("rebuild", "Rebuilding the desktop app"), + ]; + if include_install { + stages.push(stage_info("install", "Installing the update")); + } + stages +} + // option_env! only accepts string literals, so the build-time pins are read // by their literal names here. Mirrors bootstrap.rs's helper of the same name // (kept local rather than shared because option_env! can't be parameterized). @@ -1101,6 +1116,36 @@ mod tests { assert_eq!(update_branch_from_args(["--update"]), None); } + #[test] + fn update_manifest_leads_with_handoff_and_gates_install() { + let base = update_stages(false); + assert_eq!( + base.first().map(|s| s.name.as_str()), + Some("handoff"), + "the lock-wait must surface as the first visible step" + ); + assert!( + base.iter().any(|s| s.name == "update") && base.iter().any(|s| s.name == "rebuild"), + "update + rebuild remain distinct stages" + ); + assert!( + base.iter().all(|s| s.name != "install"), + "no app-swap stage unless an install target was passed" + ); + + let with_install = update_stages(true); + assert_eq!( + with_install.last().map(|s| s.name.as_str()), + Some("install"), + "the macOS app-swap is the final stage when present" + ); + assert_eq!( + with_install.len(), + base.len() + 1, + "include_install adds exactly one stage" + ); + } + #[test] fn rebuild_retries_only_on_failure() { assert!(!rebuild_needs_retry(Some(0)), "a clean rebuild must not retry"); diff --git a/apps/bootstrap-installer/src/components/brand-mark.tsx b/apps/bootstrap-installer/src/components/brand-mark.tsx new file mode 100644 index 00000000000..b6a20e47cd4 --- /dev/null +++ b/apps/bootstrap-installer/src/components/brand-mark.tsx @@ -0,0 +1,13 @@ +import { cn } from '../lib/utils' + +const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}` + +// Brand badge: nous-girl mark on a white tile, identical in light/dark. +// Ported from apps/desktop's BrandMark; asset lives in this app's public/. +export function BrandMark({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + + + ) +} diff --git a/apps/bootstrap-installer/src/components/button.tsx b/apps/bootstrap-installer/src/components/button.tsx index 41cee22f3cc..5b076527d8e 100644 --- a/apps/bootstrap-installer/src/components/button.tsx +++ b/apps/bootstrap-installer/src/components/button.tsx @@ -17,7 +17,7 @@ import { cn } from '../lib/utils' */ 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", + "inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-[2.5px] text-xs leading-4 font-medium whitespace-nowrap shadow-none transition-all duration-100 outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-default 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-3.5", { variants: { variant: { @@ -25,23 +25,24 @@ const buttonVariants = cva( 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', + 'bg-transparent text-(--ui-text-primary) shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ui-stroke-secondary)_50%,transparent)] hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)', 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' + 'bg-(--ui-bg-quaternary) text-(--ui-text-primary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)', + ghost: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)', + link: 'text-primary underline-offset-4 decoration-current/20 hover:underline', + text: 'text-muted-foreground underline-offset-4 hover:text-foreground hover:underline', + textStrong: 'font-semibold text-muted-foreground underline underline-offset-4 hover:text-foreground' }, 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' + default: 'px-3 py-1.5 has-[>svg]:px-2.5', + xs: "gap-1 px-2 py-0.5 text-[0.6875rem] leading-4 has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: 'px-2.5 py-1 has-[>svg]:px-2', + lg: 'px-5 py-2 text-sm leading-5 has-[>svg]:px-4', + inline: 'h-auto gap-1 p-0 has-[>svg]:px-0', + icon: 'size-9 rounded-[4px]', + 'icon-xs': "size-6 rounded-[4px] [&_svg:not([class*='size-'])]:size-3", + 'icon-sm': 'size-8 rounded-[4px]', + 'icon-lg': 'size-10 rounded-[4px]' } }, defaultVariants: { diff --git a/apps/bootstrap-installer/src/components/hackery-button.tsx b/apps/bootstrap-installer/src/components/hackery-button.tsx new file mode 100644 index 00000000000..a314dc02e47 --- /dev/null +++ b/apps/bootstrap-installer/src/components/hackery-button.tsx @@ -0,0 +1,36 @@ +import { Loader2 } from 'lucide-react' + +import { cn } from '../lib/utils' + +/* + * HackeryButton — the onboarding "Begin" CTA, ported standalone. + * + * Bracketed [ LABEL ], mono/uppercase, primary accent on a --stroke-nous hairline. + * Lifted from apps/desktop's desktop-onboarding-overlay.tsx (sans the exit-scramble + * choreography, which is overlay-specific). Self-contained: cn + lucide only. + */ +export function HackeryButton({ + className, + label, + loading, + ...props +}: Omit, 'children'> & { label: React.ReactNode; loading?: boolean }) { + return ( + + ) +} diff --git a/apps/bootstrap-installer/src/components/loader.tsx b/apps/bootstrap-installer/src/components/loader.tsx new file mode 100644 index 00000000000..4dc2ec8934d --- /dev/null +++ b/apps/bootstrap-installer/src/components/loader.tsx @@ -0,0 +1,136 @@ +import { type ComponentProps, useEffect, useRef } from 'react' + +import { cn } from '../lib/utils' + +/* + * Loader — the desktop's "Fourier Flow" curve, ported standalone. + * + * The shim can't import apps/desktop's 559-line multi-curve (cross-app + * coupling + bundle bloat that defeats the point of a lightweight installer), so + * this is just the one curve the installer uses. Math + tuning lifted verbatim + * from apps/desktop/src/components/ui/loader.tsx ('fourier-flow'); rotation is + * dropped because that curve never rotates. Keep the constants in sync if the + * desktop's curve is retuned. + */ + +const TWO_PI = Math.PI * 2 + +const CURVE = { + durationMs: 2200, + particleCount: 92, + pulseDurationMs: 2000, + strokeWidth: 4.2, + trailSpan: 0.31, + point(progress: number, detailScale: number) { + const t = progress * TWO_PI + const mix = 1 + detailScale * 0.16 + const x = 17 * Math.cos(t) + 7.5 * Math.cos(3 * t + 0.6 * mix) + 3.2 * Math.sin(5 * t - 0.4) + const y = 15 * Math.sin(t) + 8.2 * Math.sin(2 * t + 0.25) - 4.2 * Math.cos(4 * t - 0.5 * mix) + + return { x: 50 + x, y: 50 + y } + } +} + +const norm = (progress: number) => ((progress % 1) + 1) % 1 + +function detailScaleFor(time: number, phaseOffset: number) { + const p = ((time + phaseOffset * CURVE.pulseDurationMs) % CURVE.pulseDurationMs) / CURVE.pulseDurationMs + + return 0.52 + ((Math.sin(p * TWO_PI + 0.55) + 1) / 2) * 0.48 +} + +function buildPath(detailScale: number, steps: number) { + return Array.from({ length: steps + 1 }, (_, i) => { + const { x, y } = CURVE.point(i / steps, detailScale) + + return `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}` + }).join(' ') +} + +function particleFor(index: number, progress: number, detailScale: number, strokeScale: number) { + const tail = index / (CURVE.particleCount - 1) + const { x, y } = CURVE.point(norm(progress - tail * CURVE.trailSpan), detailScale) + const fade = (1 - tail) ** 0.56 + + return { x, y, opacity: 0.04 + fade * 0.96, radius: (0.9 + fade * 2.7) * strokeScale } +} + +interface LoaderProps extends Omit, 'children'> { + label?: string + pathSteps?: number + strokeScale?: number +} + +export function Loader({ + className, + label = 'Loading', + pathSteps = 240, + role = 'status', + strokeScale = 1, + ...props +}: LoaderProps) { + const particleRefs = useRef>([]) + const pathRef = useRef(null) + + useEffect(() => { + let frame = 0 + const startedAt = performance.now() + const phaseOffset = Math.random() + particleRefs.current.length = CURVE.particleCount + + const render = (now: number) => { + const time = now - startedAt + const progress = ((time + phaseOffset * CURVE.durationMs) % CURVE.durationMs) / CURVE.durationMs + const detailScale = detailScaleFor(time, phaseOffset) + + pathRef.current?.setAttribute('d', buildPath(detailScale, pathSteps)) + + particleRefs.current.forEach((node, index) => { + if (!node) { + return + } + + const p = particleFor(index, progress, detailScale, strokeScale) + node.setAttribute('cx', p.x.toFixed(2)) + node.setAttribute('cy', p.y.toFixed(2)) + node.setAttribute('r', p.radius.toFixed(2)) + node.setAttribute('opacity', p.opacity.toFixed(3)) + }) + + frame = window.requestAnimationFrame(render) + } + + render(performance.now()) + + return () => window.cancelAnimationFrame(frame) + }, [pathSteps, strokeScale]) + + return ( +
+ +
+ ) +} diff --git a/apps/bootstrap-installer/src/main.tsx b/apps/bootstrap-installer/src/main.tsx index aa1f7f1d532..5b744d5d67e 100644 --- a/apps/bootstrap-installer/src/main.tsx +++ b/apps/bootstrap-installer/src/main.tsx @@ -2,11 +2,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './app.tsx' import './styles.css' +import { watchTheme } from './theme' + +// Follow the OS light/dark appearance. theme.ts paints the first frame on +// import (synchronously, from the media query); this subscribes to live OS +// theme changes via the authoritative Tauri window theme. +void watchTheme() -// 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( diff --git a/apps/bootstrap-installer/src/routes/failure.tsx b/apps/bootstrap-installer/src/routes/failure.tsx index 4125e0b5b3c..13b7e16f0b5 100644 --- a/apps/bootstrap-installer/src/routes/failure.tsx +++ b/apps/bootstrap-installer/src/routes/failure.tsx @@ -19,8 +19,8 @@ interface FailureProps { * 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). + * The actual error message lives below in muted text. Two affordances on + * shared Button tokens: Retry (primary) and Open logs (quiet text link). */ export default function Failure({ bootstrap }: FailureProps) { const logPath = useStore($logPath) @@ -55,22 +55,13 @@ export default function Failure({ bootstrap }: FailureProps) {
- -
diff --git a/apps/bootstrap-installer/src/routes/progress.tsx b/apps/bootstrap-installer/src/routes/progress.tsx index 4a1dc2569fc..30f48de42f3 100644 --- a/apps/bootstrap-installer/src/routes/progress.tsx +++ b/apps/bootstrap-installer/src/routes/progress.tsx @@ -3,12 +3,15 @@ import { useStore } from '@nanostores/react' import { Button } from '../components/button' import { cancelInstall, + $mode, $progress, type BootstrapStateModel, type StageState } from '../store' -import { Check, X, ChevronRight, FileText, Loader2 } from 'lucide-react' +import { Check, X, ChevronRight, FileText } from 'lucide-react' import clsx from 'clsx' +import { BrandMark } from '../components/brand-mark' +import { Loader } from '../components/loader' interface ProgressProps { bootstrap: BootstrapStateModel @@ -21,7 +24,9 @@ interface ProgressProps { */ export default function ProgressScreen({ bootstrap }: ProgressProps) { const progress = useStore($progress) + const mode = useStore($mode) const [showLogs, setShowLogs] = useState(false) + const [now, setNow] = useState(() => Date.now()) const logEndRef = useRef(null) useEffect(() => { @@ -30,69 +35,82 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) { } }, [bootstrap.logs.length, showLogs]) - const currentStage = - bootstrap.currentStage != null - ? bootstrap.stages[bootstrap.currentStage] - : null + // Tick once a second while the run is in flight so the active step shows a + // live elapsed timer — a long single step (e.g. the dependency download) + // reads as working, not frozen. Stops when nothing is running. + useEffect(() => { + if (bootstrap.status !== 'running') { + return + } + const id = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(id) + }, [bootstrap.status]) + + const isUpdate = mode === 'update' + const title = bootstrap.status === 'completed' ? 'Done' : isUpdate ? 'Updating Hermes' : 'Setting up Hermes Agent' + const description = isUpdate + ? 'Hermes is updating to the latest version — this only takes a moment.' + : 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. Subsequent launches will skip this step.' + const pct = Math.round(progress.fraction * 100) return (
-
-
-
- {bootstrap.status === 'running' && ( - - )} - - {bootstrap.status === 'running' - ? currentStage - ? currentStage.info.title - : 'Preparing\u2026' - : bootstrap.status === 'completed' - ? 'Done' - : 'Installing'} - -
-
- {progress.done} of {progress.total} steps -
-
- {/* Top progress bar — plain HTML, derived from --primary so it - tracks the theme accent. */} -
-
+ {/* Header: brand + title + description, matching the desktop install overlay. */} +
+ +
+

{title}

+

{description}

-
-
    +
    + {/* Progress line + bar; the count shimmers while the install runs. + pt-2 matches the log header's py-2 so the "steps complete" line and + the "Live output" header share a baseline. */} +
    +
    + + {progress.done} of {progress.total} steps complete + + {pct}% +
    +
    +
    +
    +
    + + {/* Flat stage list: only the running step is opaque; the rest read as + muted. Running loader overhangs left so labels stay aligned; the + terminal check/cross sits right of the label. */} +
      {bootstrap.stageOrder.map((name) => { const rec = bootstrap.stages[name] if (!rec) return null + const meta = + rec.state === 'running' && rec.startedAt != null + ? formatElapsed(now - rec.startedAt) + : rec.durationMs != null && rec.state !== 'failed' + ? formatDuration(rec.durationMs) + : null return (
    1. - + {rec.state === 'running' && } {rec.info.title} - {rec.durationMs != null && ( - - {formatDuration(rec.durationMs)} - - )} + {meta && {meta}} +
    2. ) })} @@ -100,16 +118,12 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
    {showLogs && ( -
    -
    -
    - Live output -
    -
    - {bootstrap.logs.length} lines -
    +
    +
    + Live output + {bootstrap.logs.length} lines
    -
    +
    {bootstrap.logs.map((entry, idx) => (
    -
    +
    {bootstrap.status === 'running' && ( - )} @@ -158,25 +162,20 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) { ) } +// Terminal-state markers, neutral by design: a muted check for done/skipped +// (no celebratory green), a destructive cross for failure. Running renders its +// spinner on the left; pending stays icon-less. function StateIcon({ state }: { state: StageState | null }) { - if (state === 'running') { - return - } if (state === 'succeeded') { - return + return } if (state === 'skipped') { - return + return } if (state === 'failed') { - return + return } - return ( -
    - ) + return null } function formatDuration(ms: number): string { @@ -186,3 +185,11 @@ function formatDuration(ms: number): string { const s = Math.round((ms % 60000) / 1000) return `${m}m ${s}s` } + +// Live elapsed for a running stage: bare seconds under a minute, then m:ss. +function formatElapsed(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)) + if (s < 60) return `${s}s` + const m = Math.floor(s / 60) + return `${m}:${String(s - m * 60).padStart(2, '0')}` +} diff --git a/apps/bootstrap-installer/src/routes/success.tsx b/apps/bootstrap-installer/src/routes/success.tsx index 3b0c17d5050..339291d2aa6 100644 --- a/apps/bootstrap-installer/src/routes/success.tsx +++ b/apps/bootstrap-installer/src/routes/success.tsx @@ -1,8 +1,8 @@ import { useState } from 'react' import { type CSSProperties } from 'react' -import { Button } from '../components/button' +import { HackeryButton } from '../components/hackery-button' import { launchHermesDesktop } from '../store' -import { Rocket, AlertCircle } from 'lucide-react' +import { AlertCircle } from 'lucide-react' /* * Success screen. HERMES AGENT wordmark stays as the visual anchor @@ -53,32 +53,23 @@ export default function Success() {

    You can launch from here, or any time from your terminal with{' '} - - hermes desktop - - . + hermes desktop.

    - + label={launching ? 'Launching' : 'Launch'} + loading={launching} + onClick={() => void handleLaunch()} + /> {error && ( -
    - +
    +
    -
    Couldn’t launch the desktop app
    -
    {error}
    +
    Couldn’t launch the desktop app
    +
    {error}
    )} diff --git a/apps/bootstrap-installer/src/routes/welcome.tsx b/apps/bootstrap-installer/src/routes/welcome.tsx index 535954af148..c09080cfcfb 100644 --- a/apps/bootstrap-installer/src/routes/welcome.tsx +++ b/apps/bootstrap-installer/src/routes/welcome.tsx @@ -1,7 +1,6 @@ import { type CSSProperties } from 'react' -import { Button } from '../components/button' +import { HackeryButton } from '../components/hackery-button' import { startInstall } from '../store' -import { ArrowRight } from 'lucide-react' /* * Welcome screen. @@ -42,17 +41,7 @@ export default function Welcome() {

    - + void startInstall()} />
    ) } diff --git a/apps/bootstrap-installer/src/store.ts b/apps/bootstrap-installer/src/store.ts index cb4c1e6212c..d2235886781 100644 --- a/apps/bootstrap-installer/src/store.ts +++ b/apps/bootstrap-installer/src/store.ts @@ -31,6 +31,10 @@ export interface StageRecord { info: StageInfo state: StageState | null durationMs?: number + /** Wall-clock time the stage entered `running`, stamped client-side so the UI + * can tick a live elapsed timer for long steps. Preserved across repeated + * running events. */ + startedAt?: number error?: string } @@ -84,6 +88,34 @@ export const $progress = computed($bootstrap, (b) => { return { done, total, fraction: done / total } }) +/** Apply a stage transition: stamp `startedAt` on the running edge, track the + * active stage. Shared by the live Rust handler and the fake-boot preview so the + * two behave identically. */ +function withStageState( + cur: BootstrapStateModel, + name: string, + state: StageState, + durationMs?: number, + error?: string +): BootstrapStateModel { + const existing = cur.stages[name] + if (!existing) return cur + return { + ...cur, + stages: { + ...cur.stages, + [name]: { + ...existing, + state, + startedAt: state === 'running' ? (existing.startedAt ?? Date.now()) : existing.startedAt, + durationMs, + error + } + }, + currentStage: state === 'running' ? name : cur.currentStage + } +} + // --------------------------------------------------------------------------- // Tauri event subscription // --------------------------------------------------------------------------- @@ -133,6 +165,19 @@ let unlisten: UnlistenFn | null = null export async function initialize(): Promise { if (unlisten) return + // Dev-only isolated preview (see runFakeBoot): drive the screens in a plain + // browser, no Tauri backend, no real install. + const fake = fakeMode() + if (fake) { + unlisten = () => {} + $logPath.set('~/.hermes/logs/bootstrap-installer.log') + $hermesHome.set('~/.hermes') + $mode.set(fake === 'update' ? 'update' : 'install') + // Update auto-runs (it's a hand-off); install/failure wait for the welcome click. + if (fake === 'update') void runFakeBoot('update') + return + } + // Pull static info on mount for the diagnostics footer. try { const [logPath, hermesHome, mode] = await Promise.all([ @@ -173,23 +218,13 @@ export async function initialize(): Promise { break } case 'stage': { - const existing = cur.stages[payload.name] - if (!existing) { + if (!cur.stages[payload.name]) { 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 - }) + $bootstrap.set( + withStageState(cur, payload.name, payload.state, payload.durationMs, payload.error) + ) break } case 'log': { @@ -240,6 +275,11 @@ export async function initialize(): Promise { // --------------------------------------------------------------------------- export async function startInstall(opts?: { branch?: string }): Promise { + const fake = fakeMode() + if (fake) { + void runFakeBoot(fake === 'failure' ? 'failure' : 'install') + return + } // Reset before kicking off so a retry from the failure screen clears // the previous run's state. $bootstrap.set(INITIAL) @@ -255,6 +295,10 @@ export async function startInstall(opts?: { branch?: string }): Promise { } export async function startUpdate(): Promise { + if (fakeMode()) { + void runFakeBoot('update') + return + } // Update is driven by the desktop handing off (Hermes-Setup.exe --update); // there's no welcome click. Reset + jump straight to progress, then let the // Rust side stream the synthetic update manifest. @@ -264,15 +308,135 @@ export async function startUpdate(): Promise { } export async function cancelInstall(): Promise { + if (fakeMode()) { + fakeCancelled = true + return + } await invoke('cancel_bootstrap') } export async function launchHermesDesktop(): Promise { + if (fakeMode()) throw new Error('Preview mode — launching is disabled.') const installRoot = $bootstrap.get().installRoot if (!installRoot) throw new Error('no install root') await invoke('launch_hermes_desktop', { installRoot }) } export async function openLogDir(): Promise { + if (fakeMode()) return await invoke('open_log_dir') } + +// --------------------------------------------------------------------------- +// Dev-only isolated preview ("fake boot") +// +// Synthesises the manifest + stage/log events Rust normally streams, so the +// whole reskin can be reviewed in a plain browser (`npm run dev`): +// ?fake=install welcome → [ INSTALL ] → success +// ?fake=update auto-runs the granular update flow +// ?fake=failure install that fails partway +// Gated on import.meta.env.DEV → stripped from the shipped Tauri bundle. +// --------------------------------------------------------------------------- + +type FakeMode = 'install' | 'update' | 'failure' + +function fakeMode(): FakeMode | null { + if (!import.meta.env.DEV || typeof window === 'undefined') return null + const v = new URLSearchParams(window.location.search).get('fake') + return v === 'install' || v === 'update' || v === 'failure' ? v : null +} + +interface FakeStage { + name: string + title: string +} + +const FAKE_INSTALL_STAGES: FakeStage[] = [ + { name: 'system-packages', title: 'System packages' }, + { name: 'uv', title: 'uv' }, + { name: 'python', title: 'Python environment' }, + { name: 'repo', title: 'Hermes repository' }, + { name: 'dependencies', title: 'Python dependencies' }, + { name: 'node', title: 'Node runtime' }, + { name: 'desktop', title: 'Desktop app' } +] + +const FAKE_UPDATE_STAGES: FakeStage[] = [ + { name: 'handoff', title: 'Preparing to update' }, + { name: 'update', title: 'Downloading the latest version' }, + { name: 'rebuild', title: 'Rebuilding the desktop app' }, + { name: 'install', title: 'Installing the update' } +] + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +let fakeRunning = false +let fakeCancelled = false + +const fakeStage = (name: string, state: StageState, durationMs?: number, error?: string) => + $bootstrap.set(withStageState($bootstrap.get(), name, state, durationMs, error)) + +const fakeLog = (stage: string, line: string) => + $bootstrap.set({ ...$bootstrap.get(), logs: [...$bootstrap.get().logs, { stage, line, stream: 'stdout' }] }) + +const fakeFail = (error: string) => + $bootstrap.set({ ...$bootstrap.get(), status: 'failed', error, currentStage: null }) + +async function runFakeBoot(kind: FakeMode): Promise { + if (fakeRunning) return + fakeRunning = true + fakeCancelled = false + try { + const stages = kind === 'update' ? FAKE_UPDATE_STAGES : FAKE_INSTALL_STAGES + const cancelled = () => { + if (!fakeCancelled) return false + fakeFail(kind === 'update' ? 'Update cancelled.' : 'Install cancelled.') + $route.set('failure') + return true + } + + $bootstrap.set({ + ...INITIAL, + status: 'running', + stageOrder: stages.map((s) => s.name), + stages: Object.fromEntries( + stages.map((s): [string, StageRecord] => [ + s.name, + { info: { ...s, category: kind, needs_user_input: false }, state: null } + ]) + ) + }) + $route.set('progress') + + // Blow up midway in the failure preview so the failure screen shows. + const failAt = kind === 'failure' ? stages[Math.floor(stages.length / 2)]?.name : null + + for (const s of stages) { + if (cancelled()) return + fakeStage(s.name, 'running') + + const durationMs = 700 + Math.floor(Math.random() * 2200) + const lines = Math.max(2, Math.round(durationMs / 450)) + for (let l = 0; l < lines; l++) { + await sleep(durationMs / lines) + if (cancelled()) return + fakeLog(s.name, `[${s.name}] ${s.title.toLowerCase()} — step ${l + 1}/${lines}…`) + } + + if (s.name === failAt) { + fakeStage(s.name, 'failed', durationMs, 'Simulated failure for preview.') + fakeFail('Simulated failure for preview (fake boot).') + $route.set('failure') + return + } + fakeStage(s.name, 'succeeded', durationMs) + } + + $bootstrap.set({ ...$bootstrap.get(), status: 'completed', currentStage: null }) + // Install lands on success; update stays on progress (the real updater + // relaunches the desktop and exits from there). + if (kind !== 'update') $route.set('success') + } finally { + fakeRunning = false + } +} diff --git a/apps/bootstrap-installer/src/styles.css b/apps/bootstrap-installer/src/styles.css index 3171b8c073e..c999a20b319 100644 --- a/apps/bootstrap-installer/src/styles.css +++ b/apps/bootstrap-installer/src/styles.css @@ -18,10 +18,12 @@ * 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). + * Follows the OS appearance: the installer has no in-app theme switcher, so + * src/theme.ts tracks the Tauri window theme and toggles `.dark` on + * . The desktop's runtime applyTheme() normally PAINTS the dark seed + * colors inline (its imported :root.dark below only flips the per-mode mix + * knobs + neutral chrome), so we supply the Nous *dark* seeds ourselves in the + * :root.dark block at the end of this file. */ @import '../../desktop/src/styles.css'; @@ -49,3 +51,38 @@ transparent 60% ); } + +/* + * Dark appearance — Nous dark seeds. + * + * The imported desktop :root.dark only flips the per-mode mix knobs + neutral + * chrome; the seed COLORS are normally painted at runtime by the desktop's + * applyTheme(). The installer has no theme runtime, so we mirror them here from + * apps/desktop/src/themes/presets.ts (nousTheme.darkColors). The whole + * --ui-* / --dt-* chain in the imported stylesheet derives from these seeds, so + * flipping them is enough — we only additionally override the few tokens + * applyTheme() sets inline that DON'T derive from a seed (primary-foreground on + * the cream accent, destructive). Unlayered on purpose so it wins over the + * imported @layer base :root light seeds. Keep in sync with nousTheme.darkColors + * if that palette is retuned. + */ +:root.dark { + color-scheme: dark; + + --theme-foreground: #ffe6cb; + --theme-primary: #ffe6cb; + --theme-secondary: #1b45a4; + --theme-accent-soft: #1540b1; + --theme-midground: #0053fd; + --theme-warm: #ffe6cb; + --theme-background-seed: #0d2f86; + --theme-sidebar-seed: #09286f; + --theme-card-seed: #12378f; + --theme-elevated-seed: #123a96; + --theme-bubble-seed: #143b91; + + /* Non-derived shadcn tokens applyTheme() paints inline (Nous dark values). */ + --dt-primary-foreground: #0d2f86; + --dt-destructive: #c0473a; + --dt-destructive-foreground: #fef2f2; +} diff --git a/apps/bootstrap-installer/src/theme.ts b/apps/bootstrap-installer/src/theme.ts new file mode 100644 index 00000000000..ed1fd3f21fe --- /dev/null +++ b/apps/bootstrap-installer/src/theme.ts @@ -0,0 +1,51 @@ +import { getCurrentWindow, type Theme } from '@tauri-apps/api/window' + +/* + * OS appearance follower. + * + * The installer ships no in-app theme switcher, so it tracks the system the + * way the desktop overlays do. Two Tauri realities shape this: + * + * 1. The strict `script-src 'self'` CSP (tauri.conf.json) forbids an inline + * pre-paint