diff --git a/apps/bootstrap-installer/public/nous-girl.jpg b/apps/bootstrap-installer/public/nous-girl.jpg new file mode 100644 index 00000000000..19861544bbb Binary files /dev/null and b/apps/bootstrap-installer/public/nous-girl.jpg differ 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