mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
feat(installer): redesign the Tauri setup shim — design system, OS theme, granular updates
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🪟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).
This commit is contained in:
parent
8fe8c2d6c4
commit
9e4ed4d7a9
15 changed files with 658 additions and 194 deletions
BIN
apps/bootstrap-installer/public/nous-girl.jpg
Normal file
BIN
apps/bootstrap-installer/public/nous-girl.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<StageInfo> {
|
||||
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");
|
||||
|
|
|
|||
13
apps/bootstrap-installer/src/components/brand-mark.tsx
Normal file
13
apps/bootstrap-installer/src/components/brand-mark.tsx
Normal file
|
|
@ -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 (
|
||||
<span className={cn('inline-flex size-14 shrink-0 items-center justify-center bg-white', className)} {...props}>
|
||||
<img alt="" className="size-full object-contain" src={assetPath('nous-girl.jpg')} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
36
apps/bootstrap-installer/src/components/hackery-button.tsx
Normal file
36
apps/bootstrap-installer/src/components/hackery-button.tsx
Normal file
|
|
@ -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<React.ComponentProps<'button'>, 'children'> & { label: React.ReactNode; loading?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
'group inline-flex cursor-pointer items-center gap-2 rounded-md border border-(--stroke-nous) px-6 py-2.5',
|
||||
'font-mono text-xs font-semibold uppercase text-primary',
|
||||
'transition-all duration-150 hover:border-primary/60 hover:bg-primary/[0.06]',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-primary/40 transition-colors group-hover:text-primary">[</span>
|
||||
{loading ? <Loader2 className="size-3 animate-spin" /> : null}
|
||||
<span className="-mr-[0.25em] pl-[0.25em] tracking-[0.25em]">{label}</span>
|
||||
<span className="text-primary/40 transition-colors group-hover:text-primary">]</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
136
apps/bootstrap-installer/src/components/loader.tsx
Normal file
136
apps/bootstrap-installer/src/components/loader.tsx
Normal file
|
|
@ -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 <Loader> (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<ComponentProps<'div'>, 'children'> {
|
||||
label?: string
|
||||
pathSteps?: number
|
||||
strokeScale?: number
|
||||
}
|
||||
|
||||
export function Loader({
|
||||
className,
|
||||
label = 'Loading',
|
||||
pathSteps = 240,
|
||||
role = 'status',
|
||||
strokeScale = 1,
|
||||
...props
|
||||
}: LoaderProps) {
|
||||
const particleRefs = useRef<Array<SVGCircleElement | null>>([])
|
||||
const pathRef = useRef<SVGPathElement | null>(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 (
|
||||
<div
|
||||
{...props}
|
||||
aria-label={props['aria-label'] ?? label}
|
||||
className={cn('inline-grid size-10 place-items-center text-primary', className)}
|
||||
role={role}
|
||||
>
|
||||
<svg aria-hidden="true" className="size-full overflow-visible" fill="none" viewBox="0 0 100 100">
|
||||
<path
|
||||
opacity="0.1"
|
||||
ref={pathRef}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={CURVE.strokeWidth * strokeScale}
|
||||
/>
|
||||
{Array.from({ length: CURVE.particleCount }, (_, index) => (
|
||||
<circle
|
||||
fill="currentColor"
|
||||
key={index}
|
||||
ref={node => {
|
||||
particleRefs.current[index] = node
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => void (isUpdate ? startUpdate() : startInstall())}
|
||||
size="lg"
|
||||
className="inline-flex items-center gap-2 px-6"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
<Button onClick={() => void (isUpdate ? startUpdate() : startInstall())} className="gap-1.5">
|
||||
<RefreshCw />
|
||||
{isUpdate ? 'Retry update' : '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 variant="text" onClick={() => void openLogDir()} className="gap-1.5">
|
||||
<FileText />
|
||||
Open logs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<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)}%` }}
|
||||
/>
|
||||
{/* Header: brand + title + description, matching the desktop install overlay. */}
|
||||
<div className="flex shrink-0 items-start gap-4 px-6 pt-6 pb-4">
|
||||
<BrandMark className="size-11" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{description}</p>
|
||||
</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">
|
||||
<div className="flex-1 overflow-y-auto px-6 pt-2 pb-4">
|
||||
{/* 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. */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className={clsx(bootstrap.status === 'running' && 'shimmer')}>
|
||||
{progress.done} of {progress.total} steps complete
|
||||
</span>
|
||||
<span className="tabular-nums">{pct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.max(2, progress.fraction * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<ol className="space-y-0.5">
|
||||
{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 (
|
||||
<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'
|
||||
'flex items-center gap-2.5 px-3 py-1.5 text-sm',
|
||||
rec.state === 'running'
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<StateIcon state={rec.state ?? null} />
|
||||
{rec.state === 'running' && <Loader className="-ml-2 size-6 shrink-0" />}
|
||||
<span className="flex-1 truncate">{rec.info.title}</span>
|
||||
{rec.durationMs != null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDuration(rec.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
{meta && <span className="text-xs tabular-nums text-muted-foreground/70">{meta}</span>}
|
||||
<StateIcon state={rec.state ?? null} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
|
@ -100,16 +118,12 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
|||
</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 className="flex w-1/2 flex-col border-l border-(--stroke-nous)">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-(--stroke-nous) px-3 py-2 text-xs">
|
||||
<span className="font-medium text-foreground/80">Live output</span>
|
||||
<span className="tabular-nums text-muted-foreground">{bootstrap.logs.length} lines</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[11px] leading-relaxed">
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[10.5px] leading-relaxed">
|
||||
{bootstrap.logs.map((entry, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
|
|
@ -127,29 +141,19 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-border px-6 py-3">
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-(--stroke-nous) 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"
|
||||
className="inline-flex cursor-pointer 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'
|
||||
)}
|
||||
/>
|
||||
<ChevronRight size={12} className={clsx('transition-transform', showLogs && 'rotate-90')} />
|
||||
</button>
|
||||
|
||||
{bootstrap.status === 'running' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void cancelInstall()}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => void cancelInstall()}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -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 <Loader2 size={14} className="animate-spin text-primary" />
|
||||
}
|
||||
if (state === 'succeeded') {
|
||||
return <Check size={14} className="text-emerald-400" />
|
||||
return <Check size={13} className="shrink-0 text-muted-foreground" />
|
||||
}
|
||||
if (state === 'skipped') {
|
||||
return <ChevronRight size={14} className="text-muted-foreground/70" />
|
||||
return <Check size={13} className="shrink-0 text-muted-foreground/50" />
|
||||
}
|
||||
if (state === 'failed') {
|
||||
return <X size={14} className="text-destructive" />
|
||||
return <X size={13} className="shrink-0 text-destructive" />
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="h-[6px] w-[6px] rounded-full bg-muted-foreground/40"
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
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')}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
|
||||
<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>
|
||||
.
|
||||
<code className="font-mono text-sm text-foreground/80">hermes desktop</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => void handleLaunch()}
|
||||
size="lg"
|
||||
<HackeryButton
|
||||
disabled={launching}
|
||||
className="inline-flex items-center gap-2 px-6"
|
||||
>
|
||||
<Rocket size={18} />
|
||||
{launching ? 'Launching…' : 'Launch Hermes'}
|
||||
</Button>
|
||||
label={launching ? 'Launching' : 'Launch'}
|
||||
loading={launching}
|
||||
onClick={() => void handleLaunch()}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex max-w-2xl items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||
<div role="alert" className="flex max-w-2xl items-start gap-2 text-sm">
|
||||
<AlertCircle size={16} className="mt-0.5 shrink-0 text-destructive" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">Couldn’t launch the desktop app</div>
|
||||
<div className="mt-1 text-destructive/80">{error}</div>
|
||||
<div className="font-medium text-destructive">Couldn’t launch the desktop app</div>
|
||||
<div className="mt-0.5 text-muted-foreground">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</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>
|
||||
<HackeryButton label="Install" onClick={() => void startInstall()} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
|||
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<void> {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function startInstall(opts?: { branch?: string }): Promise<void> {
|
||||
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<void> {
|
|||
}
|
||||
|
||||
export async function startUpdate(): Promise<void> {
|
||||
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<void> {
|
|||
}
|
||||
|
||||
export async function cancelInstall(): Promise<void> {
|
||||
if (fakeMode()) {
|
||||
fakeCancelled = true
|
||||
return
|
||||
}
|
||||
await invoke('cancel_bootstrap')
|
||||
}
|
||||
|
||||
export async function launchHermesDesktop(): Promise<void> {
|
||||
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<void> {
|
||||
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<void>((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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* <html>. 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;
|
||||
}
|
||||
|
|
|
|||
51
apps/bootstrap-installer/src/theme.ts
Normal file
51
apps/bootstrap-installer/src/theme.ts
Normal file
|
|
@ -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 <script> in index.html, so the earliest hook we get is this
|
||||
* bundled module.
|
||||
* 2. The webview's `prefers-color-scheme` is not reliable across WebView2 /
|
||||
* WebKitGTK. The authoritative signal in a Tauri window is the window's
|
||||
* OWN theme — `getCurrentWindow().theme()` + `onThemeChanged` — so we read
|
||||
* that and fall back to the media query only outside Tauri (e.g. plain
|
||||
* `vite preview`).
|
||||
*
|
||||
* We only flip the `.dark` class + `color-scheme`; the dark seed values live in
|
||||
* styles.css (:root.dark), mirroring apps/desktop's applyTheme() palette.
|
||||
*/
|
||||
|
||||
const prefersDark = (): boolean => window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
function paint(theme: Theme): void {
|
||||
const dark = theme === 'dark'
|
||||
const root = document.documentElement
|
||||
root.classList.toggle('dark', dark)
|
||||
root.style.colorScheme = dark ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
// Best-effort synchronous first paint from the media query so the very first
|
||||
// frame is already in the right mode. Refined below by the authoritative Tauri
|
||||
// window theme once its IPC resolves.
|
||||
paint(prefersDark() ? 'dark' : 'light')
|
||||
|
||||
/** Adopt the Tauri window theme and keep tracking live OS appearance changes. */
|
||||
export async function watchTheme(): Promise<void> {
|
||||
try {
|
||||
const win = getCurrentWindow()
|
||||
const current = await win.theme()
|
||||
|
||||
if (current) {
|
||||
paint(current)
|
||||
}
|
||||
|
||||
await win.onThemeChanged(({ payload }) => paint(payload))
|
||||
} catch {
|
||||
// Non-Tauri context (e.g. `vite preview`): keep the media query live.
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => paint(e.matches ? 'dark' : 'light'))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue