diff --git a/AGENT.md b/AGENT.md index f4ce4ee..dd69486 100644 --- a/AGENT.md +++ b/AGENT.md @@ -8,25 +8,32 @@ The Tauri v2 + React/TypeScript rewrite of the Omnideck desktop app, replacing t **Current status: sequencing steps 2–4 done** (Tauri mechanics, read-only dashboard, and lifecycle actions — see `desktop_tauri_rewrite.md`'s Sequencing section). `src-tauri/` and `src/` build clean (`cargo build`, `cargo test`, `cargo clippy -- -D warnings`, `npm run build` all pass). `cli_bridge.rs` + `commands.rs` cover `list`/`status`/`logs`/`start`/`stop`/`restart`/`add`/`remove` against the real CLI JSON/NDJSON contract, with `cargo test` fixtures pinning the JSON shapes. The frontend has an app shell (Dashboard/Settings nav) on the ported SIGNAL tokens: Dashboard polls `list --json` with per-row Start/Stop/Restart/Logs/Remove, a New Deck form streaming `add --json` progress, a Remove confirmation dialog with the CLI's required explicit keep/delete + backup choices, and a blocking screen for CLI-missing/contract-mismatch. Every backend command was verified against the real CLI directly (not just typechecked) before being trusted. `update_instance`, instance detail drill-in (DESIGN.md #6), and Open UI instance webview tabs (DESIGN.md #7) are done — the "still to do" list below was stale about these. **Hardening-migration Phases 1–4 are done** (see `reference/desktop-hardening-migration-PLAN.md`): sidecar pinned+checksummed against real CLI `v0.10.0` (`vendor-manifest.json`, `fetch:sidecars`/`verify:sidecars`), `EXPECTED_JSON_CONTRACT` corrected to `2` with a `MINIMUM_CLI_VERSION` floor check, the dashboard capability replaced with an enumerated `dashboard-bridge` allowlist, `cli_bridge.rs` now bounds stdout/stderr and enforces per-operation timeouts via a unified `run_cli` helper with correct NDJSON line reassembly across chunk boundaries, `tauri-plugin-single-instance` is wired up, and the AppImage runtime fixes were confirmed still intact and still build clean. **Hardening-migration Phase 5 is also done**: `bootstrap.rs` drives the shared Podman runtime's readiness via `cli_bridge::runtime_status`/`runtime_ensure` (correcting this doc's earlier claim that the -CLI had no equivalent — it does, as of `v0.10.0`) and owns the 4-command IPC surface -(`bootstrap`/`begin_setup`/`open_dashboard`/`run_action`) for the isolated `"onboarding"` window, created -hidden via `WebviewWindowBuilder` in `lib.rs`'s `setup()` hook and scoped to its own `onboarding-bridge` -capability (never `"main"`, which is the dashboard here — see the module's own doc comment and -`reference/desktop-hardening-migration-PLAN.md`'s "Decisions from review" for why the sibling's window -labels don't map 1:1). The onboarding UI itself (`public/onboarding/{index.html,setup.css,setup.js, -host-adapter.js}`) is vanilla JS/CSS, ported from the sibling's `web/` and adapted to this repo's two real -bootstrap phases (`software`/`environment`, matching the CLI's own `runtime ensure` stages exactly — no -`download`/`startup` phase here, since pulling an image and creating a Deck is the dashboard's separate, -already-built `add_instance` flow). No resume-record file — `bootstrap.rs`'s doc comment explains why one -isn't needed here. Test coverage ported alongside: `tests/policy.test.mjs` (security-posture assertions, -`node --test`, wired into `npm run test:policy` and the `verify` composite), `tests/manual/*.md` -(clean-first-run and recovery-lifecycle procedures), and `tests/hardware/validate-proof.mjs` (packaged-build -smoke check, `OMNIDECK_DESKTOP_SMOKE_FILE`-gated in `lib.rs`). Verified end-to-end this session: `npm run -verify` clean (fetch/verify sidecars, policy tests, typecheck, fmt, Rust tests, clippy), a real `npm run -dev:app` launch with both windows live and no errors, a real `npm run build:appimage` + `run:appimage` -launch with no crashes/coredumps, and a caught-and-fixed regression (`bootstrap()` was unconditionally -showing the onboarding window on every launch before the fix — `tests/policy.test.mjs` now guards this -shape). Still to do: onboarding visual polish/copy review, migration (legacy Electron data → CLI-managed +CLI had no equivalent — it does, as of `v0.10.0`) and owns the 3-command IPC surface +(`bootstrap`/`begin_setup`/`run_action`), folded into the dashboard's own `dashboard-bridge` capability +and called from the single `"main"` window. **This wasn't the original design** — onboarding first +shipped as a second, isolated `"onboarding"` window (own capability, hidden via `WebviewWindowBuilder`, +vanilla JS/CSS UI ported from the sibling's `web/`), on the theory that window-scoped capabilities are a +real security boundary worth having. That held up until real hardware testing found it caused a genuine +bug: creating two GTK/WebKit windows at startup (one hidden) failed EGL/GPU-driver init +(`EGL_BAD_PARAMETER`, blank white dashboard) on a real Intel Iris Xe/Mesa 26.1.4 combination, reproduced +independently in the sibling app's own build too — i.e. not something specific to this repo's port. Fixed +by removing the second window entirely: onboarding is now just another React screen +(`src/components/OnboardingView.tsx`, `src/hooks/useBootstrap.ts`) that `App.tsx` swaps in for the +dashboard until the shared runtime is ready, using the exact same `SetupState` push model +(`tauri::ipc::Channel`) as before. **Real, knowingly-accepted tradeoff**: the bootstrap commands are no +longer isolated behind a separate capability grant the way a second window enforced — see `bootstrap.rs`'s +module doc comment and `reference/desktop-hardening-migration-PLAN.md`'s "Decisions from review" for the +full history of both the original design and the reversal. Two real bootstrap phases +(`software`/`environment`, matching the CLI's own `runtime ensure` stages exactly — no `download`/`startup` +phase here, since pulling an image and creating a Deck is the dashboard's separate, already-built +`add_instance` flow). No resume-record file — `bootstrap.rs`'s doc comment explains why one isn't needed +here. Test coverage: `tests/policy.test.mjs` (security-posture assertions, `node --test`, wired into +`npm run test:policy` and the `verify` composite), `tests/manual/*.md` (clean-first-run and +recovery-lifecycle procedures), and `tests/hardware/validate-proof.mjs` (packaged-build smoke check, +`OMNIDECK_DESKTOP_SMOKE_FILE`-gated in `lib.rs`). Verified end-to-end: `npm run verify` clean (fetch/verify +sidecars, policy tests, typecheck, fmt, Rust tests, clippy), a real `npm run dev:app` launch and a real +`npm run build:appimage` + `run:appimage` launch with no crashes/coredumps/EGL errors, single window +confirmed. Still to do: onboarding visual polish/copy review, migration (legacy Electron data → CLI-managed instance — untouched by this session, still the highest-risk remaining path per this doc's rules above), and Phase 6/7 of the hardening plan (CI, release engineering — explicitly deferred in that doc until this repo actually gets CI / cuts a first release, not preemptive work). Update this file as decisions firm up; diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b77d8e6..e74a8ac 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -13,7 +13,7 @@ Detailed setup, testing, and command reference. See [`README.md`](./README.md) f npm run verify:sidecars # re-checksums without re-downloading (cheap, e.g. in CI) ``` `src-tauri/binaries/vendor-manifest.json` records the pinned tag/commit/checksums (committed); the fetched binaries themselves are gitignored. `OMNIDECK_CLI_ARCHIVE_DIR=/path/to/archives` points `fetch:sidecars` at pre-downloaded release archives for an offline/sandboxed build — the pinned hashes are still enforced either way. -- **Podman**, installed — needed for the dashboard to show real Deck data and for the onboarding flow's "already ready" path to actually be ready. If Podman genuinely isn't ready, the app's own onboarding window is what sets it up; you don't need to pre-provision it by hand to develop against this repo, just to see the dashboard's populated state instead of an empty list. +- **Podman**, installed — needed for the dashboard to show real Deck data and for the onboarding flow's "already ready" path to actually be ready. If Podman genuinely isn't ready, the app's own onboarding screen is what sets it up; you don't need to pre-provision it by hand to develop against this repo, just to see the dashboard's populated state instead of an empty list. ### Linux on an immutable/atomic distro (Fedora Silverblue, Bluefin, etc.) @@ -39,7 +39,7 @@ npm run dev # frontend only, no Rust/webview (fast iteration on Re `npm run dev:app` is the correct entrypoint on every platform — it's a thin wrapper (`scripts/dev.sh`) that only does the toolbox dance on Linux; macOS/Windows/non-atomic-Linux just get `npm run tauri dev` directly. Don't run `npm run tauri dev` by hand on an atomic-Linux dev box. -The dashboard window opens immediately showing a real "Checking your setup…" state, then either the populated Dashboard or a blocking screen if the CLI sidecar is missing/version-mismatched — see `AGENT.md`'s "instant open" rule if you're touching startup code. If the shared Podman runtime isn't ready yet, the onboarding window appears instead; see below for how to preview its screens without needing an actually-unprovisioned machine. +The app window opens immediately showing a real "Checking your setup…" state, then either the populated Dashboard or a blocking screen if the CLI sidecar is missing/version-mismatched — see `AGENT.md`'s "instant open" rule if you're touching startup code. If the shared Podman runtime isn't ready yet, the onboarding screen (`src/components/OnboardingView.tsx`) renders in place of the dashboard instead; see below for how to preview its screens without needing an actually-unprovisioned machine. ### Testing the onboarding flow @@ -52,7 +52,7 @@ OMNIDECK_DEBUG_ONBOARDING_STAGE=ready npm run dev:app OMNIDECK_DEBUG_ONBOARDING_STAGE=error npm run dev:app ``` -Each value forces the onboarding window to open showing that exact screen — real render, real buttons, no real Podman calls made for the check itself. `welcome`'s "Set up Omnideck" button still calls the real `begin_setup`, though: since your Podman is presumably actually ready, that resolves to the real `ready` state almost instantly, which is a nice free integration check of the real `runtime ensure` idempotent-no-op path. +Each value forces `OnboardingView` to render showing that exact screen — real render, real buttons, no real Podman calls made for the check itself. `welcome`'s "Set up Omnideck" button still calls the real `begin_setup`, though: since your Podman is presumably actually ready, that resolves to the real `ready` state almost instantly, which is a nice free integration check of the real `runtime ensure` idempotent-no-op path. This is `debug_forced_state()` in `src-tauri/src/bootstrap.rs`, `#[cfg(debug_assertions)]`-gated — the function and the env var read don't exist at all in a release build (`cargo build --release`/`tauri build`), so there's no flag to accidentally ship enabled. @@ -69,7 +69,7 @@ If you need to test the *real* first-run install path (not just the screens), th cargo clippy -- -D warnings cargo fmt ``` -- **Policy tests** (`npm run test:policy`, `tests/policy.test.mjs`): security-posture assertions on the capability files, the onboarding window's authorization checks, the CLI sidecar pin, and the process hardening (output bounds, timeouts) — so a future PR can't silently widen the attack surface without a test failing. Uses Node's built-in test runner, no extra dependency. +- **Policy tests** (`npm run test:policy`, `tests/policy.test.mjs`): security-posture assertions on the capability files, the bootstrap commands' `window.label() == "main"` authorization checks, the CLI sidecar pin, and the process hardening (output bounds, timeouts) — so a future PR can't silently widen the attack surface without a test failing. Uses Node's built-in test runner, no extra dependency. - **Manual/integration verification against the real CLI** — the most reliable way to confirm a `cli_bridge.rs` change actually matches what the CLI emits, since fixture tests only catch drift from *known* shapes: 1. Run the CLI command by hand with the exact same args your Rust code constructs, e.g. `src-tauri/binaries/omnideck- add --name test --port 46177 --json`, and diff the output against what your Rust structs expect. 2. For anything destructive (`remove`, or actions against instances you care about), test against a disposable instance you create and remove yourself, or a low-stakes existing one — never a production Deck. `omnideck list --json` shows what's currently installed before you touch anything. diff --git a/README.md b/README.md index e5bf9c0..c7b4ed9 100644 --- a/README.md +++ b/README.md @@ -18,26 +18,25 @@ A Tauri v2 + React/TypeScript desktop app that manages one or more local Omnidec ## Architecture ``` -┌──────────────────────┐ ┌──────────────────────────┐ -│ "main" (dashboard) │ │ "onboarding" (hidden │ -│ │ │ until actually needed) │ -│ React + TypeScript │ │ vanilla JS/HTML/CSS │ -│ Deck list, start/ │ │ first-run/repair Podman │ -│ stop/update/remove │ │ runtime setup │ -└───────────┬───────────┘ └────────────┬──────────────┘ - │ │ - └───────────────┬─────────────────┘ - ▼ - src-tauri/src/cli_bridge.rs - (owns all `omnideck` CLI subprocess I/O — - spawn, JSON/NDJSON parsing, bounds, timeouts) - │ - ▼ - bundled `omnideck` CLI sidecar - (pinned by version + checksum, never PATH) + single "main" window +┌───────────────────────────────────────────────────────────┐ +│ React + TypeScript │ +│ OnboardingView Dashboard view │ +│ first-run/repair ⇄ Deck list, start/ │ +│ Podman runtime setup stop/update/remove │ +│ (shown until ready) (shown once ready) │ +└──────────────────────────────┬──────────────────────────────┘ + ▼ + src-tauri/src/cli_bridge.rs + (owns all `omnideck` CLI subprocess I/O — + spawn, JSON/NDJSON parsing, bounds, timeouts) + │ + ▼ + bundled `omnideck` CLI sidecar + (pinned by version + checksum, never PATH) ``` -Two windows, two frontend stacks, deliberately — the dashboard is React with a broad command surface; onboarding is an isolated, minimally-privileged vanilla-JS window with its own Tauri capability, unreachable from the dashboard. See [`src-tauri/src/bootstrap.rs`](./src-tauri/src/bootstrap.rs)'s module doc comment for the full rationale, and [`reference/desktop-hardening-migration-PLAN.md`](./reference/desktop-hardening-migration-PLAN.md) for how this repo's security posture got here. +One window, one React app — onboarding and the dashboard are two screens `App.tsx` swaps between client-side based on whether the shared Podman runtime is ready, not two OS-level windows. That wasn't the original design (onboarding first shipped as a second, hidden, minimally-privileged window with its own Tauri capability); it was reverted after real hardware testing found that creating two GTK/WebKit windows at startup broke EGL/GPU-driver init on at least one real Intel/Mesa combination. See [`src-tauri/src/bootstrap.rs`](./src-tauri/src/bootstrap.rs)'s module doc comment for the full rationale — including the security-isolation tradeoff that reversal knowingly accepts — and [`reference/desktop-hardening-migration-PLAN.md`](./reference/desktop-hardening-migration-PLAN.md) for how this repo's security posture got here. ## Commands @@ -65,7 +64,7 @@ npm run fetch:sidecars npm run dev:app ``` -The dashboard opens immediately. If you don't have a ready Podman runtime yet, an onboarding window will guide you through setting one up. +The app window opens immediately. If you don't have a ready Podman runtime yet, an onboarding screen guides you through setting one up before handing off to the dashboard. ## Documentation diff --git a/TESTING.md b/TESTING.md index fc21a72..8abe204 100644 --- a/TESTING.md +++ b/TESTING.md @@ -28,17 +28,16 @@ release goes out to actual users — see `RELEASING.md`. Source tests (`tests/policy.test.mjs`) keep the following invariants release-blocking: -- the dashboard window's capability is an enumerated allowlist - (`dashboard-bridge`), never `core:default`; -- the onboarding window's capability (`onboarding-bridge`) is scoped to - `"windows": ["onboarding"]` only — never `"main"`, which is the dashboard - here (this exact mistake was caught once already; see the policy test's - own comment); -- every onboarding command re-checks `window.label() == "onboarding"` - server-side, not just the capability grant; -- `bootstrap()` only reveals the onboarding window when setup is actually - needed — never unconditionally (this was a real, caught-and-fixed - regression); +- the dashboard's (single `"main"` window's) capability is an enumerated + allowlist (`dashboard-bridge`), never `core:default`; +- every bootstrap command (`bootstrap`/`begin_setup`/`run_action`) + re-checks `window.label() == "main"` server-side, not just the + capability grant; +- `bootstrap.rs` never manages window visibility — asserted by absence, so + a future onboarding tweak can't quietly reintroduce the second window + that caused a real EGL/GPU-driver startup bug on some hardware (see + `AGENT.md` and `bootstrap.rs`'s doc comment for the full story; onboarding + is now a plain React screen `App.tsx` swaps in, not a separate window); - the CLI sidecar is pinned by checksum for all 6 target triples, and the runtime version check is a floor (`>= v0.10.0`), not an exact match; - sidecar process output is bounded and every operation has a timeout. diff --git a/package.json b/package.json index afe54ce..c8a077f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "omnideck-desktop", "private": true, - "version": "0.5.0-alpha.2", + "version": "0.5.0-alpha.3", "type": "module", "scripts": { "dev": "vite", diff --git a/public/onboarding/host-adapter.js b/public/onboarding/host-adapter.js deleted file mode 100644 index af22348..0000000 --- a/public/onboarding/host-adapter.js +++ /dev/null @@ -1,58 +0,0 @@ -// Thin adapter between the Tauri IPC bridge and setup.js's render logic — -// ported from the sibling repo's web/host-adapter.js. Keeping this -// separation means setup.js's DOM/render code can be tested (or read) -// without needing a real Tauri runtime; only this file touches -// `window.__TAURI__`. -(() => { - let listener = null; - let running = false; - - function core() { - const value = window.__TAURI__?.core; - if (!value?.invoke || !value?.Channel) { - throw new Error("The Tauri host bridge is unavailable."); - } - return value; - } - - function stateChannel() { - const channel = new (core().Channel)(); - channel.onmessage = (state) => listener?.(state); - return channel; - } - - async function run(command, args = {}) { - if (running) return; - running = true; - try { - return await core().invoke(command, args); - } finally { - running = false; - } - } - - function beginSetup() { - return run("begin_setup", { onEvent: stateChannel() }); - } - - window.omnideckHost = Object.freeze({ - beginSetup, - retry: beginSetup, - openDashboard: () => run("open_dashboard"), - runAction: (action) => run("run_action", { action }), - onState(callback) { - listener = callback; - return () => { - if (listener === callback) listener = null; - }; - }, - }); - - window.addEventListener( - "DOMContentLoaded", - () => { - setTimeout(() => void run("bootstrap", { onEvent: stateChannel() }), 0); - }, - { once: true }, - ); -})(); diff --git a/public/onboarding/index.html b/public/onboarding/index.html deleted file mode 100644 index fa3122b..0000000 --- a/public/onboarding/index.html +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - Omnideck Setup - - - -
-
- -

omnideck

-
- -
- -

STARTING

-

Starting Omnideck

-

Checking your environment…

- - - - - - - - - - - - -
- - -
- - - - diff --git a/public/onboarding/setup.css b/public/onboarding/setup.css deleted file mode 100644 index 8911823..0000000 --- a/public/onboarding/setup.css +++ /dev/null @@ -1,478 +0,0 @@ -/* ═══════════════════════════════════════════════════════ - SIGNAL Design Language — onboarding surface - - Token names/values/scales are ported verbatim from this repo's - src/styles/tokens.css (itself ported from the production app's setup - surface — see that file's own header comment and AGENT.md's "Visual - design" convention). Duplicated here, not imported, because this window - is intentionally a separate, isolated, build-step-free surface from the - React app (see reference/desktop-hardening-migration-PLAN.md's "Decisions - from review") — Vite never touches this directory. - ═══════════════════════════════════════════════════════ */ - -:root { - --canvas: #f8f9fb; - --surface: #eef0f5; - --elevated: #ffffff; - - --text-primary: #0f172a; - --text-secondary: #475569; - --text-tertiary: #94a3b8; - - --border: #e2e8f0; - --border-subtle: #f1f5f9; - --border-strong: #cbd5e1; - - --accent: #2563eb; - --accent-hover: #1d4ed8; - --accent-muted: rgba(37, 99, 235, 0.08); - --accent-glow: rgba(37, 99, 235, 0.15); - - --success: #16a34a; - --success-muted: rgba(22, 163, 74, 0.08); - --warning: #d97706; - --warning-muted: rgba(217, 119, 6, 0.08); - --danger: #dc2626; - --danger-muted: rgba(220, 38, 38, 0.08); - - --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05); - --shadow-glow: 0 0 0 3px var(--accent-glow); - - --terminal-bg: #edf1fb; - --terminal-border: #c8d4ec; - --terminal-text: #1e293b; - - --radius-sm: 4px; - --radius-md: 6px; - --radius-lg: 8px; - --radius-full: 999px; - - --sp-1: 4px; - --sp-2: 8px; - --sp-3: 12px; - --sp-4: 16px; - --sp-6: 24px; - --sp-8: 32px; - --sp-10: 40px; - - --font-brand: "Roboto Mono", "Consolas", monospace; - --font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; - --font-code: "JetBrains Mono", "Fira Mono", "Consolas", monospace; - - --ease: cubic-bezier(0.4, 0, 0.2, 1); - --ease-out: cubic-bezier(0, 0, 0.2, 1); - - color-scheme: light dark; -} - -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) { - --canvas: #0c0e14; - --surface: #151821; - --elevated: #1e2130; - --text-primary: #e8ecf4; - --text-secondary: #8892a8; - --text-tertiary: #4a5168; - --border: #252a3a; - --border-subtle: #1a1e2a; - --border-strong: #363d52; - --accent: #3b82f6; - --accent-hover: #60a5fa; - --accent-muted: rgba(59, 130, 246, 0.12); - --accent-glow: rgba(59, 130, 246, 0.2); - --success: #4ade80; - --success-muted: rgba(74, 222, 128, 0.12); - --warning: #fbbf24; - --warning-muted: rgba(251, 191, 36, 0.12); - --danger: #f87171; - --danger-muted: rgba(248, 113, 113, 0.12); - --terminal-bg: #111318; - --terminal-border: #252a3a; - --terminal-text: #c8cdd8; - } -} - -:root[data-theme="dark"] { - --canvas: #0c0e14; - --surface: #151821; - --elevated: #1e2130; - --text-primary: #e8ecf4; - --text-secondary: #8892a8; - --text-tertiary: #4a5168; - --border: #252a3a; - --border-subtle: #1a1e2a; - --border-strong: #363d52; - --accent: #3b82f6; - --accent-hover: #60a5fa; - --accent-muted: rgba(59, 130, 246, 0.12); - --accent-glow: rgba(59, 130, 246, 0.2); - --success: #4ade80; - --success-muted: rgba(74, 222, 128, 0.12); - --warning: #fbbf24; - --warning-muted: rgba(251, 191, 36, 0.12); - --danger: #f87171; - --danger-muted: rgba(248, 113, 113, 0.12); - --terminal-bg: #111318; - --terminal-border: #252a3a; - --terminal-text: #c8cdd8; -} - -*, -*::before, -*::after { - box-sizing: border-box; -} - -html, -body { - height: 100%; - margin: 0; -} - -body { - overflow: hidden; - background: var(--surface); - color: var(--text-primary); - font-family: var(--font-body); - -webkit-font-smoothing: antialiased; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -@keyframes progress-slide { - 0% { - transform: translateX(-100%); - } - 100% { - transform: translateX(300%); - } -} - -@media (prefers-reduced-motion: reduce) { - * { - animation-duration: 0.001ms !important; - } -} - -.panel { - display: flex; - flex-direction: column; - height: 100%; - padding: var(--sp-8) var(--sp-8) var(--sp-6); -} - -.identity { - display: flex; - align-items: center; - gap: var(--sp-3); -} - -.mark { - display: flex; - align-items: end; - justify-content: center; - gap: 3px; - width: 34px; - height: 34px; - padding: var(--sp-2); - background: var(--accent); - border-radius: var(--radius-lg); -} - -.mark span { - display: block; - width: 3px; - background: #fff; - border-radius: var(--radius-full); -} - -.mark span:nth-child(1) { - height: 8px; -} -.mark span:nth-child(2) { - height: 16px; -} -.mark span:nth-child(3) { - height: 12px; -} - -.brand, -.eyebrow { - margin: 0; - color: var(--text-secondary); - font: 500 11px/1 var(--font-brand); - letter-spacing: 0.16em; -} - -.brand { - font-weight: 600; -} - -.status { - display: flex; - flex-direction: column; - gap: var(--sp-4); - margin: auto 0; - padding: var(--sp-10) 0 var(--sp-6); -} - -.eyebrow { - color: var(--accent-hover); -} - -h1 { - max-width: 26ch; - margin: 0; - font-size: 30px; - font-weight: 600; - line-height: 1.15; - letter-spacing: -0.02em; - text-wrap: balance; -} - -.detail { - margin: 0; - color: var(--text-secondary); - font-size: 14px; - line-height: 1.6; -} - -.spinner { - width: 20px; - height: 20px; - border: 2px solid var(--border-strong); - border-top-color: var(--accent); - border-radius: var(--radius-full); - animation: spin 700ms linear infinite; -} - -.spinner[hidden] { - display: none; -} - -.progress-wrap { - margin: 0; -} - -.progress-wrap[hidden] { - display: none; -} - -.progress-track { - height: 4px; - overflow: hidden; - background: var(--border); - border-radius: var(--radius-full); -} - -.progress { - width: 0; - height: 100%; - background: var(--accent); - border-radius: inherit; - transition: width 180ms var(--ease-out); -} - -.progress-wrap.is-indeterminate .progress { - width: 34%; - animation: progress-slide 1.3s var(--ease) infinite; - transition: none; -} - -.diagnostics { - display: grid; - gap: var(--sp-2); - padding: var(--sp-3); - background: var(--elevated); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); -} - -.diagnostics[hidden] { - display: none; -} - -.diagnostics__heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--sp-3); -} - -.diagnostics__heading span { - color: var(--text-secondary); - font: 500 10px/1 var(--font-brand); - letter-spacing: 0.14em; -} - -.diagnostic-list { - display: grid; - gap: 1px; -} - -.diagnostic-row { - display: grid; - grid-template-columns: 16px minmax(0, 1fr) auto; - align-items: center; - gap: var(--sp-2); - min-height: 22px; - color: var(--text-secondary); - font-size: 12px; - white-space: nowrap; -} - -.diagnostic-icon { - display: grid; - width: 16px; - height: 16px; - place-items: center; - color: var(--text-secondary); - background: transparent; - border-radius: var(--radius-full); - font: 600 10px/1 var(--font-brand); -} - -.diagnostic-row[data-status="pass"] .diagnostic-icon { - color: var(--success); - background: var(--success-muted); -} - -.diagnostic-row[data-status="issue"] .diagnostic-icon { - color: var(--danger); - background: var(--danger-muted); -} - -.diagnostic-row[data-status="issue"] .diagnostic-value { - color: var(--danger); -} - -.diagnostic-value { - overflow: hidden; - color: var(--text-secondary); - font-size: 11px; - text-align: right; - text-overflow: ellipsis; -} - -.diagnostics details { - padding-top: var(--sp-2); - border-top: 1px solid var(--border-subtle); -} - -.diagnostics summary { - color: var(--text-secondary); - cursor: pointer; - font-size: 11px; - font-weight: 600; -} - -.diagnostics summary:focus-visible { - outline: none; - border-radius: var(--radius-sm); - box-shadow: var(--shadow-glow); -} - -.diagnostics pre { - max-height: 92px; - overflow: auto; - margin: var(--sp-2) 0 0; - padding: var(--sp-2); - color: var(--terminal-text); - white-space: pre-wrap; - overflow-wrap: anywhere; - background: var(--terminal-bg); - border: 1px solid var(--terminal-border); - border-radius: var(--radius-md); - font: 400 10px/1.5 var(--font-code); -} - -button { - width: 100%; - min-height: 36px; - padding: var(--sp-2) var(--sp-4); - border: none; - border-radius: var(--radius-md); - font: 500 13px/1.2 var(--font-body); - cursor: pointer; - transition: - background 150ms var(--ease), - color 150ms var(--ease), - box-shadow 150ms var(--ease), - transform 60ms var(--ease); -} - -button[hidden] { - display: none; -} - -button:disabled { - cursor: not-allowed; - opacity: 0.5; -} - -button:active:not(:disabled) { - transform: translateY(1px); -} - -button:focus-visible { - outline: none; - box-shadow: var(--shadow-glow); -} - -.primary { - color: #fff; - background: var(--accent); -} - -.primary:hover:not(:disabled) { - background: var(--accent-hover); -} - -.secondary { - color: var(--text-secondary); - background: transparent; -} - -.secondary:hover:not(:disabled) { - color: var(--text-primary); - background: var(--border-subtle); -} - -.activity { - margin: 0; - color: var(--text-primary); - font-size: 13px; - font-weight: 500; - line-height: 1.5; -} - -.activity[hidden] { - display: none; -} - -.action-error { - margin: 0; - color: var(--danger); - font-size: 12px; - line-height: 1.5; -} - -.action-error[hidden] { - display: none; -} - -.footnote { - margin: 0; - color: var(--text-secondary); - font-size: 12px; - line-height: 1.5; -} - -.footnote[hidden] { - display: none; -} diff --git a/public/onboarding/setup.js b/public/onboarding/setup.js deleted file mode 100644 index 25bcc72..0000000 --- a/public/onboarding/setup.js +++ /dev/null @@ -1,162 +0,0 @@ -// Render logic for the onboarding window — ported from the sibling repo's -// web/setup.js, adapted to this repo's smaller SetupState shape (no -// setupReason/update/resume/repair distinctions, since there's no resume -// record here — see bootstrap.rs's doc comment for why). One render(state) -// function fully re-derives the DOM from each pushed state; no separate -// imperative "now show screen X" calls scattered through the flow. -const title = document.getElementById("title"); -const detail = document.getElementById("detail"); -const activity = document.getElementById("activity"); -const eyebrow = document.getElementById("eyebrow"); -const primary = document.getElementById("primary"); -const secondary = document.getElementById("secondary"); -const spinner = document.getElementById("spinner"); -const progressWrap = document.getElementById("progress-wrap"); -const progressTrack = progressWrap.querySelector('[role="progressbar"]'); -const progress = document.getElementById("progress"); -const footnote = document.getElementById("footnote"); -const diagnosticsPanel = document.getElementById("diagnostics"); -const technicalDetails = diagnosticsPanel.querySelector("details"); -const diagnosticList = document.getElementById("diagnostic-list"); -const technicalOutput = document.getElementById("technical-output"); -const actionError = document.getElementById("action-error"); - -let currentState = { stage: "welcome" }; - -// This window runs from the filesystem under its own capability, a -// different origin/surface from the dashboard — it can't read the -// dashboard's persisted theme choice, only the OS preference. -const darkQuery = window.matchMedia?.("(prefers-color-scheme: dark)"); - -function applyTheme() { - document.documentElement.dataset.theme = darkQuery?.matches ? "dark" : "light"; -} - -applyTheme(); -darkQuery?.addEventListener?.("change", applyTheme); - -const DIAGNOSTIC_ICONS = { pass: "✓", issue: "!", waiting: "·" }; -const STAGE_EYEBROWS = { - welcome: "WELCOME", - preparing: "SETTING UP", - ready: "READY", - error: "SETUP NEEDS ATTENTION", -}; -// Stages where a wait is actually happening, so the note about it being -// one-time is true — not the opening splash, which a returning user (whose -// runtime is already ready) passes through in an instant. -const FOOTNOTE_STAGES = ["preparing"]; -// Stages showing an outcome rather than work in progress — the spinner is -// redundant (or wrong) on any of these even if progress isn't shown either. -const SETTLED_STAGES = ["welcome", "ready", "error"]; - -function renderDiagnostics(state) { - const diagnostics = Array.isArray(state.diagnostics) ? state.diagnostics : []; - // Shown only on failure — during setup the activity line and progress bar - // already say where things are; a list of phase names only invites "what - // is that." - const failed = state.stage === "error"; - diagnosticsPanel.hidden = !failed || diagnostics.length === 0; - diagnosticList.replaceChildren(); - if (diagnosticsPanel.hidden) return; - - technicalDetails.open = false; - technicalOutput.textContent = state.technical || "No further detail available."; - diagnosticList.replaceChildren( - ...diagnostics.map((diagnostic) => { - const row = document.createElement("div"); - row.className = "diagnostic-row"; - row.dataset.status = diagnostic.status; - - const icon = document.createElement("span"); - icon.className = "diagnostic-icon"; - icon.textContent = DIAGNOSTIC_ICONS[diagnostic.status] || "–"; - const label = document.createElement("span"); - label.textContent = diagnostic.label; - row.append(icon, label); - return row; - }), - ); -} - -function render(state) { - currentState = state; - document.documentElement.dataset.stage = state.stage; - title.textContent = state.title; - detail.textContent = state.detail; - eyebrow.textContent = STAGE_EYEBROWS[state.stage] || "OMNIDECK"; - activity.textContent = state.activity || ""; - activity.hidden = !state.activity; - - primary.hidden = !(state.canStart || state.canRetry || state.canOpen || state.primaryAction); - primary.textContent = - state.primaryLabel || - (state.canOpen ? "Continue" : state.canRetry ? "Try again" : "Set up Omnideck"); - primary.disabled = false; - actionError.hidden = true; - - secondary.hidden = !state.secondaryAction; - secondary.textContent = state.secondaryLabel || ""; - renderDiagnostics(state); - - const hasProgress = Number.isFinite(state.progress); - const hasIndeterminateProgress = Boolean(state.indeterminate); - progressWrap.hidden = !(hasProgress || hasIndeterminateProgress); - progressWrap.classList.toggle("is-indeterminate", hasIndeterminateProgress); - progress.style.width = hasProgress ? `${Math.round(state.progress * 100)}%` : ""; - if (hasProgress) { - progressTrack.setAttribute("aria-valuenow", String(Math.round(state.progress * 100))); - progressTrack.removeAttribute("aria-valuetext"); - } else { - progressTrack.removeAttribute("aria-valuenow"); - if (hasIndeterminateProgress) progressTrack.setAttribute("aria-valuetext", "In progress"); - else progressTrack.removeAttribute("aria-valuetext"); - } - spinner.hidden = !progressWrap.hidden || SETTLED_STAGES.includes(state.stage); - footnote.hidden = !FOOTNOTE_STAGES.includes(state.stage); -} - -function runPrimaryAction() { - if (currentState.primaryAction) { - return window.omnideckHost.runAction(currentState.primaryAction); - } - if (currentState.canOpen) return window.omnideckHost.openDashboard(); - if (currentState.canRetry) return window.omnideckHost.retry(); - return window.omnideckHost.beginSetup(); -} - -function reportActionFailure(error) { - actionError.textContent = String(error?.message || error); - actionError.hidden = false; -} - -// Re-enabling in a `finally` matters: the button is only otherwise -// re-enabled by the next state push, and a rejected action doesn't always -// produce one — that would leave the only control on the screen dead with -// nothing explaining why. -primary.addEventListener("click", async () => { - primary.disabled = true; - actionError.hidden = true; - try { - await runPrimaryAction(); - } catch (error) { - reportActionFailure(error); - } finally { - primary.disabled = false; - } -}); - -secondary.addEventListener("click", async () => { - if (!currentState.secondaryAction) return; - secondary.disabled = true; - actionError.hidden = true; - try { - await window.omnideckHost.runAction(currentState.secondaryAction); - } catch (error) { - reportActionFailure(error); - } finally { - secondary.disabled = false; - } -}); - -window.omnideckHost.onState(render); diff --git a/reference/desktop-hardening-migration-PLAN.md b/reference/desktop-hardening-migration-PLAN.md index 7984c72..8b2f952 100644 --- a/reference/desktop-hardening-migration-PLAN.md +++ b/reference/desktop-hardening-migration-PLAN.md @@ -73,6 +73,55 @@ if anything downstream still contradicts these, this section wins: have been written to match it, but if anything elsewhere in this doc still says `"main"` for the onboarding surface, that's stale and Phase 5's version wins. +## Reversal (2026-08-09): onboarding is a React screen, not a second window + +The isolated-webview decision above shipped, built clean, and passed its own policy tests — but real +hardware testing found it caused a genuine, reproducible bug: creating two GTK/WebKit windows at startup +(the visible `"main"` dashboard plus the hidden `"onboarding"` window) failed EGL/GPU-driver +initialization (`Could not create default EGL display: EGL_BAD_PARAMETER`, blank white dashboard) on a +real Intel Iris Xe / Mesa 26.1.4 combination. Root-caused by process of elimination over roughly a dozen +hypotheses (NVIDIA-specific, Wayland-vs-X11, WebKit compositing mode, the DMA-BUF renderer, +software-only Mesa rendering, individual bundled shared libraries) — all ruled out with real evidence, +including the strong signal that `LIBGL_ALWAYS_SOFTWARE=1` still failed identically, which should have +bypassed any hardware-driver-specific cause. What actually fixed it: disabling creation of the second +window entirely. That fix was independently reproduced against the *sibling* app's own build too (same +two-window-at-startup pattern, same failure) — meaning this is a real bug in the pattern itself on +certain hardware, not something specific to this repo's port. + +Given that, the user asked to keep onboarding as a visually distinct *screen* but drop the second +*window* — this doc's "Onboarding is an isolated webview" and "vanilla JS/HTML/CSS, not React" bullets +above are superseded: + +- **One window, one React app.** `src-tauri/src/bootstrap.rs`'s `create_onboarding_window`/ + `show_onboarding`/`show_dashboard`/`open_dashboard` are deleted outright, not feature-flagged — there's + no window left to show or hide. `bootstrap`/`begin_setup`/`run_action` (3 commands, `open_dashboard` + dropped — nothing left for it to hand off to) are folded into the dashboard's existing + `dashboard-bridge` capability and called from the single `"main"` window. +- **Onboarding is now `src/components/OnboardingView.tsx` + `src/hooks/useBootstrap.ts`**, a straight + port of `public/onboarding/setup.js`'s `render(state)` logic and `setup.css`'s visual design (now + reusing `src/styles/tokens.css` instead of duplicating its tokens) into React. `App.tsx` calls + `bootstrap` on mount and renders `OnboardingView` in place of the dashboard until the runtime is ready + *and* the user has clicked through ("Continue" is purely a local `App.tsx` state change now, not an + IPC call — there's nothing left for a command to show/hide). The vanilla-JS `public/onboarding/` and + `withGlobalTauri` (only needed for that unbundled JS to reach `window.__TAURI__`) are both deleted. +- **Real, knowingly-accepted security tradeoff.** The whole point of the original isolated-window design + was a capability boundary the OS/Tauri enforced independently of application code — a compromised + dashboard couldn't invoke bootstrap commands, and vice versa, because they lived behind different + window-scoped capability grants. That boundary is gone: `bootstrap`/`begin_setup`/`run_action` are now + reachable from the same capability grant as the rest of the dashboard's command surface. What's kept: + `bootstrap.rs`'s own `window.label() == "main"` check (now the *only* enforcement, not + defense-in-depth alongside a capability boundary) and the server-side `offered_actions` allowlist for + `run_action`. This is a deliberate choice, not an oversight — a real, hardware-triggered startup crash + was judged worse than losing this specific isolation boundary. If the isolation matters enough to + re-add later, revisit with either a fix for the underlying two-window EGL bug (not attempted — root + cause is in Mesa/WebKit's window-creation path, well outside this app's control) or a *lazily created* + second window (created only once onboarding is actually needed, not unconditionally at startup — this + wasn't tried, so it's unknown whether it would still trigger the same bug). +- **Tests**: `tests/policy.test.mjs`'s onboarding-window-isolation assertions were replaced with + equivalents for the single-window model (dashboard-bridge's command list includes the 3 bootstrap + commands, `authorize_main` checks `"main"`, and a new assertion that `bootstrap.rs` contains no window- + management symbols at all — asserted by absence, so this exact pattern can't quietly creep back in). + --- ## Phase 1 — Sidecar integrity (do this before shipping any real build) @@ -194,6 +243,13 @@ that's their problem to port back, not this repo's problem to solve. For *this* ## Phase 5 — Bootstrap/onboarding state machine and its isolated webview +**Superseded by "Reversal (2026-08-09)" above**: this phase's window-creation steps (`create_onboarding_window`, +the `"onboarding"` capability/permission files, the vanilla-JS `public/onboarding/` bundle) were built, +shipped, then removed after a real hardware bug. Left unedited below as the historical record of the +original design and why it was chosen — the checkboxes are still `[x]` because the work described *was* +done, just later reverted. Do not use this phase as a guide for the current architecture; use the Reversal +section and `bootstrap.rs`'s own doc comment instead. + `AGENT.md` already names `bootstrap.rs` (podman/docker detection+install, WSL2 setup, `podman machine` lifecycle) as the one legitimate non-CLI-delegated logic in this repo's target architecture, and it's still unwritten. Per "Decisions from review," onboarding is its own isolated, vanilla-JS webview — the diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2fa51e6..0e42fb4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2269,7 +2269,7 @@ dependencies = [ [[package]] name = "omnideck-desktop" -version = "0.5.0-alpha.2" +version = "0.5.0-alpha.3" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6462b49..e0ba9d2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "omnideck-desktop" -version = "0.5.0-alpha.2" +version = "0.5.0-alpha.3" description = "Omnideck desktop app — a thin GUI shell over the omnideck CLI" authors = ["Omnideck"] edition = "2021" diff --git a/src-tauri/capabilities/onboarding.json b/src-tauri/capabilities/onboarding.json deleted file mode 100644 index e763717..0000000 --- a/src-tauri/capabilities/onboarding.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "onboarding", - "description": "Capability for the isolated onboarding window. In this repo \"main\" is the dashboard, not the setup window (unlike the sibling app this pattern is modeled on) — this capability is deliberately scoped to \"onboarding\" only, never \"main\". No core:* or opener:* permissions: onboarding never loads remote content or opens external links.", - "windows": [ - "onboarding" - ], - "local": true, - "permissions": [ - "onboarding-bridge" - ] -} diff --git a/src-tauri/permissions/dashboard-bridge.toml b/src-tauri/permissions/dashboard-bridge.toml index 577f62e..8028dd7 100644 --- a/src-tauri/permissions/dashboard-bridge.toml +++ b/src-tauri/permissions/dashboard-bridge.toml @@ -21,4 +21,7 @@ commands.allow = [ "suggest_new_deck_defaults", "add_instance", "remove_instance", + "bootstrap", + "begin_setup", + "run_action", ] diff --git a/src-tauri/permissions/onboarding-bridge.toml b/src-tauri/permissions/onboarding-bridge.toml deleted file mode 100644 index 3845cf2..0000000 --- a/src-tauri/permissions/onboarding-bridge.toml +++ /dev/null @@ -1,8 +0,0 @@ -# The exact 4-command IPC surface the isolated onboarding window may drive. -# Every command here re-checks window.label() == "onboarding" itself -# (bootstrap.rs::authorize_onboarding) — this capability grant is the outer -# layer, not the only one. -[[permission]] -identifier = "onboarding-bridge" -description = "Allows the isolated onboarding window to drive the typed omnideck runtime bootstrap." -commands.allow = ["bootstrap", "begin_setup", "open_dashboard", "run_action"] diff --git a/src-tauri/src/bootstrap.rs b/src-tauri/src/bootstrap.rs index 5e26ba1..3129f2c 100644 --- a/src-tauri/src/bootstrap.rs +++ b/src-tauri/src/bootstrap.rs @@ -1,8 +1,8 @@ //! Drives the *shared* (not per-instance) Podman runtime's first-run/repair -//! bootstrap, and owns the 4-command IPC surface the isolated "onboarding" -//! window uses to run it — modeled on `omnideck/desktop` (sibling repo)'s -//! setup flow, adapted per `reference/desktop-hardening-migration-PLAN.md`'s -//! Phase 5. +//! bootstrap, and owns the 3-command IPC surface (`bootstrap`/`begin_setup`/ +//! `run_action`) the dashboard's React app uses to run it — modeled on +//! `omnideck/desktop` (sibling repo)'s setup flow, adapted per +//! `reference/desktop-hardening-migration-PLAN.md`'s Phase 5. //! //! Correcting this repo's earlier assumption (see AGENT.md): the CLI does //! have an equivalent for "detect/install podman, WSL2, podman machine" as @@ -12,6 +12,27 @@ //! error-code vocabulary into [`SetupState`] — it does not reimplement any //! platform-specific installer logic itself. //! +//! **No separate window** (a real change from an earlier version of this +//! module, not the original design): onboarding was originally an isolated +//! window, hidden until needed, mirroring the sibling's `hosted-app`/setup +//! window split. That caused a real, confirmed bug — creating two GTK/ +//! WebKit windows at startup (one hidden) failed EGL/GPU-driver +//! initialization on at least one real Intel/Mesa combination, with a +//! blank white dashboard as the symptom, and was independently reproduced +//! in the sibling repo's own build too (same two-window-at-startup +//! pattern). Fixed by dropping the second window entirely: `bootstrap`/ +//! `begin_setup`/`run_action` now run from the single `"main"` window, and +//! `src/components/OnboardingView.tsx` / `DashboardView.tsx` are just two +//! screens React swaps between based on the pushed [`SetupState`] — no +//! window to show or hide, so no `open_dashboard` command either. This is +//! a real, knowingly-accepted security tradeoff: these commands are no +//! longer isolated from the dashboard's own broader command surface the +//! way a separate window's capability grant would enforce. What's kept: +//! the state machine itself, and the server-side offered-actions allowlist +//! (`run_action` only accepts what the *last pushed state* actually +//! offered) — see `reference/desktop-hardening-migration-PLAN.md`'s +//! "Decisions from review" for the full history of this decision. +//! //! Scope boundary: this module's job ends once the shared runtime is ready. //! Creating a Deck (pulling the omnideck image, provisioning a container) is //! a separate, already-built flow (`NewDeckForm.tsx` → `add_instance`) that @@ -36,7 +57,7 @@ use std::{ Arc, RwLock, }, }; -use tauri::{ipc::Channel, AppHandle, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; +use tauri::{ipc::Channel, AppHandle, WebviewWindow}; /// Phases in weighted-progress order, matching `runtime ensure`'s own stage /// vocabulary exactly (`engine.SetupStageSoftware`/`SetupStageEnvironment` @@ -69,10 +90,11 @@ pub struct Diagnostic { pub status: String, } -/// Pushed from Rust to the onboarding webview over a Tauri [`Channel`]. One -/// `render(state)`-style function on the JS side fully re-derives the DOM -/// from each pushed state — see `public/onboarding/setup.js`. Modeled on the -/// sibling's `SetupState` in `parity.rs`. +/// Pushed from Rust to the dashboard's React app over a Tauri [`Channel`]. +/// `OnboardingView`'s `render(state)`-style logic fully re-derives what it +/// shows from each pushed state; `App.tsx` decides whether to render +/// `OnboardingView` or `DashboardView` based on `stage`/`canOpen`. Modeled +/// on the sibling's `SetupState` in `parity.rs`. #[derive(Clone, Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SetupState { @@ -245,24 +267,23 @@ fn error_state(error: &CliError, reached_phase: usize) -> SetupState { state } -/// Every failure mode of the 4 onboarding commands themselves — distinct +/// Every failure mode of the 3 bootstrap commands themselves — distinct /// from [`CliError`] (which is specifically about CLI subprocess failures), -/// since these also cover the window-authorization and action-allowlist -/// checks that have nothing to do with the CLI. +/// since these also cover the origin and action-allowlist checks that have +/// nothing to do with the CLI. #[derive(Debug, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum BootstrapError { - /// The caller wasn't the isolated "onboarding" window — the whole point - /// of Phase 2's capability split is that this bridge isn't reachable - /// from the dashboard or any instance webview. + /// The caller wasn't the `"main"` window. With no separate onboarding + /// window left to enforce this via a capability grant, this check is + /// the only remaining guard — see this module's doc comment for why + /// that's a real, accepted tradeoff, not an oversight. OriginDenied, Cli(CliError), - /// The requested `run_action`/`open_dashboard` wasn't in the *current* - /// state's offered actions (or, for `open_dashboard`, setup isn't - /// actually done yet) — stops a compromised/buggy webview invoking - /// something the current state never offered. + /// The requested `run_action` wasn't in the *current* state's offered + /// actions — stops a compromised/buggy webview invoking something the + /// current state never offered. ActionDenied, - WindowMissing, StateLockPoisoned, StateDeliveryFailed, } @@ -273,8 +294,8 @@ impl From for BootstrapError { } } -fn authorize_onboarding(window: &WebviewWindow) -> Result<(), BootstrapError> { - if window.label() != "onboarding" { +fn authorize_main(window: &WebviewWindow) -> Result<(), BootstrapError> { + if window.label() != "main" { return Err(BootstrapError::OriginDenied); } Ok(()) @@ -283,7 +304,6 @@ fn authorize_onboarding(window: &WebviewWindow) -> Result<(), BootstrapError> { #[derive(Clone)] pub struct BootstrapState { setup_running: Arc, - ready: Arc, offered_actions: Arc>>, } @@ -291,7 +311,6 @@ impl Default for BootstrapState { fn default() -> Self { Self { setup_running: Arc::new(AtomicBool::new(false)), - ready: Arc::new(AtomicBool::new(false)), offered_actions: Arc::new(RwLock::new(HashSet::new())), } } @@ -302,7 +321,6 @@ fn send_state( channel: &Channel, setup_state: SetupState, ) -> Result<(), BootstrapError> { - state.ready.store(setup_state.can_open, Ordering::Release); let mut actions = state .offered_actions .write() @@ -360,17 +378,11 @@ fn debug_forced_state() -> Option { } /// Checks the shared runtime once and reports whether onboarding needs to -/// run — called by the onboarding window's own script on load, mirroring -/// the sibling's `setup.js` calling `bootstrap` immediately. Never mutates -/// anything. -/// -/// Only reveals the onboarding window when it's actually needed (not ready, -/// or the check itself failed) — the dashboard (`"main"`) is already -/// visible by default on every launch (AGENT.md's rule, unchanged by any of -/// this), so the ready case must do nothing further and leave onboarding -/// hidden. Getting this wrong would mean onboarding popping up on *every* -/// launch even when the runtime is already ready, which defeats the entire -/// point of checking first. +/// run — called by the dashboard's React app on mount, mirroring the +/// sibling's `setup.js` calling `bootstrap` immediately (just from the +/// dashboard's own window now, not a separate one). Never mutates anything. +/// The frontend decides what to render purely from the pushed +/// [`SetupState`] — this command doesn't show or hide anything itself. #[tauri::command] pub async fn bootstrap( app: AppHandle, @@ -378,11 +390,10 @@ pub async fn bootstrap( state: tauri::State<'_, BootstrapState>, on_event: Channel, ) -> Result<(), BootstrapError> { - authorize_onboarding(&window)?; + authorize_main(&window)?; if let Some(forced) = debug_forced_state() { send_state(&state, &on_event, forced)?; - show_onboarding(&app)?; return Ok(()); } @@ -392,17 +403,15 @@ pub async fn bootstrap( } Ok(_) => { send_state(&state, &on_event, welcome_state())?; - show_onboarding(&app)?; } Err(error) => { send_state(&state, &on_event, error_state(&error, 0))?; - show_onboarding(&app)?; } } Ok(()) } -/// Drives `runtime ensure`, streaming progress into the onboarding window +/// Drives `runtime ensure`, streaming progress to the dashboard's React app /// until the shared runtime is ready or setup fails. Re-entrant calls while /// already running are ignored (matches the sibling's `setup_running` swap). #[tauri::command] @@ -412,7 +421,7 @@ pub async fn begin_setup( state: tauri::State<'_, BootstrapState>, on_event: Channel, ) -> Result<(), BootstrapError> { - authorize_onboarding(&window)?; + authorize_main(&window)?; if state.setup_running.swap(true, Ordering::AcqRel) { return Ok(()); } @@ -464,23 +473,6 @@ pub async fn begin_setup( Ok(()) } -/// Hands off to the dashboard once setup is actually done — the reverse of -/// `bootstrap`'s `show_onboarding`. Renamed from the sibling's `open_app` -/// (which shows a single hosted instance's webview); here it just reveals -/// the multi-instance dashboard, which manages its own Decks from there. -#[tauri::command] -pub fn open_dashboard( - app: AppHandle, - window: WebviewWindow, - state: tauri::State<'_, BootstrapState>, -) -> Result<(), BootstrapError> { - authorize_onboarding(&window)?; - if !state.ready.load(Ordering::Acquire) { - return Err(BootstrapError::ActionDenied); - } - show_dashboard(&app) -} - /// Runs a recovery action, but only one the *last state pushed to this /// window* actually offered (checked via `offered_actions`) — see /// [`BootstrapError::ActionDenied`]'s doc comment for why. Deliberately a @@ -498,7 +490,7 @@ pub fn run_action( state: tauri::State<'_, BootstrapState>, action: String, ) -> Result<(), BootstrapError> { - authorize_onboarding(&window)?; + authorize_main(&window)?; if !state .offered_actions .read() @@ -516,51 +508,6 @@ pub fn run_action( } } -fn show_onboarding(app: &AppHandle) -> Result<(), BootstrapError> { - let onboarding = app - .get_webview_window("onboarding") - .ok_or(BootstrapError::WindowMissing)?; - onboarding - .show() - .map_err(|_| BootstrapError::WindowMissing)?; - onboarding - .set_focus() - .map_err(|_| BootstrapError::WindowMissing) -} - -fn show_dashboard(app: &AppHandle) -> Result<(), BootstrapError> { - if let Some(onboarding) = app.get_webview_window("onboarding") { - let _ = onboarding.hide(); - } - let main = app - .get_webview_window("main") - .ok_or(BootstrapError::WindowMissing)?; - main.show().map_err(|_| BootstrapError::WindowMissing)?; - main.set_focus().map_err(|_| BootstrapError::WindowMissing) -} - -/// Creates the isolated onboarding window, hidden by default (mirrors the -/// sibling's `hosted-app` window default) — [`bootstrap`] reveals it only if -/// setup actually turns out to be needed. Serves from `public/onboarding/` -/// (Vite copies that directory into `dist/` untouched, alongside the -/// React-bundled `index.html` at the dist root — no build-pipeline changes -/// for the dashboard). Call once from `lib.rs`'s `setup()` hook, after the -/// config-declared `"main"` window already exists. -pub fn create_onboarding_window(app: &tauri::App) -> tauri::Result<()> { - WebviewWindowBuilder::new( - app, - "onboarding", - WebviewUrl::App("onboarding/index.html".into()), - ) - .title("Omnideck Setup") - .inner_size(720.0, 560.0) - .min_inner_size(640.0, 480.0) - .resizable(false) - .visible(false) - .build()?; - Ok(()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 16a03a1..80d6f8d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -63,18 +63,11 @@ async fn run_packaged_smoke(app: tauri::AppHandle) { pub fn run() { tauri::Builder::default() // Must be the first plugin registered (Tauri's own requirement, load-order - // sensitive on Windows). A second launch focuses whichever of "onboarding"/ - // "main" is currently visible instead of opening a second process — mirrors - // the sibling repo's "hosted-app if visible, else main" selection, with these - // labels swapped in (see bootstrap.rs's doc comment for why the labels don't - // map 1:1 to the sibling's). No-op on macOS, which already prevents a second - // instance at the OS level. + // sensitive on Windows). A second launch just focuses the single "main" + // window instead of opening a second process. No-op on macOS, which + // already prevents a second instance at the OS level. .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { - let active = app - .get_webview_window("onboarding") - .filter(|window| window.is_visible().unwrap_or(false)) - .or_else(|| app.get_webview_window("main")); - if let Some(window) = active { + if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); @@ -99,11 +92,9 @@ pub fn run() { commands::remove_instance, bootstrap::bootstrap, bootstrap::begin_setup, - bootstrap::open_dashboard, bootstrap::run_action, ]) .setup(|app| { - bootstrap::create_onboarding_window(app)?; if std::env::var_os("OMNIDECK_DESKTOP_SMOKE_FILE").is_some() { let handle = app.handle().clone(); tauri::async_runtime::spawn(run_packaged_smoke(handle)); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 16b7c3c..528206a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Omnideck", - "version": "0.5.0-alpha.2", + "version": "0.5.0-alpha.3", "identifier": "dev.omnideck.desktop", "build": { "beforeDevCommand": "npm run dev", @@ -10,7 +10,6 @@ "frontendDist": "../dist" }, "app": { - "withGlobalTauri": true, "windows": [ { "label": "main", diff --git a/src/App.tsx b/src/App.tsx index 2267a4a..1821d98 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,11 +4,15 @@ import BlockingScreen from "./components/BlockingScreen"; import DashboardView from "./components/dashboard/DashboardView"; import ExternalPage from "./components/ExternalPage"; import InstanceWebviewTab from "./components/InstanceWebviewTab"; +import OnboardingView from "./components/OnboardingView"; import SettingsView from "./components/SettingsView"; +import { useBootstrap } from "./hooks/useBootstrap"; import { useCliVersion } from "./hooks/useCliVersion"; export default function App() { const cliVersion = useCliVersion(); + const bootstrap = useBootstrap(cliVersion.status === "ok"); + const [onboardingComplete, setOnboardingComplete] = useState(false); const [view, setView] = useState("dashboard"); const [openTabs, setOpenTabs] = useState([]); @@ -38,6 +42,35 @@ export default function App() { return ; } + // Skip the onboarding screen entirely once the shared runtime was already + // ready on the very first bootstrap check (the normal day-to-day launch) + // — only a first run or a repair actually shows it. Once shown, it stays + // dismissed only via the user's own "Continue" click (onboardingComplete), + // not by the state simply reaching "ready" — see OnboardingView's doc + // comment for why an automatic swap away from a just-finished screen + // would be jarring. + const showOnboarding = !bootstrap.initiallyReady && !onboardingComplete; + if (showOnboarding) { + if (!bootstrap.state) { + return ( +
+
+

Checking your setup…

+
+ ); + } + return ( + setOnboardingComplete(true)} + /> + ); + } + const activeTab = openTabs.find((t) => view === instanceView(t.name)); return ( diff --git a/src/components/OnboardingView.tsx b/src/components/OnboardingView.tsx new file mode 100644 index 0000000..96fb6fa --- /dev/null +++ b/src/components/OnboardingView.tsx @@ -0,0 +1,164 @@ +import { useState } from "react"; +import type { SetupState } from "../types/setup"; + +const DIAGNOSTIC_ICONS: Record = { pass: "✓", issue: "!", waiting: "·" }; +const STAGE_EYEBROWS: Record = { + welcome: "WELCOME", + preparing: "SETTING UP", + ready: "READY", + error: "SETUP NEEDS ATTENTION", +}; +// Stages where a wait is actually happening, so the note about it being +// one-time is true — not the opening splash, which a returning user (whose +// runtime is already ready) never even sees (App.tsx skips straight to the +// dashboard for that case). +const FOOTNOTE_STAGES = new Set(["preparing"]); +// Stages showing an outcome rather than work in progress — a spinner is +// redundant (or wrong) on any of these even when no progress bar is shown. +const SETTLED_STAGES = new Set(["welcome", "ready", "error"]); + +interface OnboardingViewProps { + state: SetupState; + actionError: string | null; + actionPending: boolean; + onBeginSetup: () => void; + onRunAction: (action: string) => void; + /** Purely local — dismisses this screen in favor of the dashboard. Never + * a Tauri command: there's no window to show/hide anymore, so "Continue" + * is just App.tsx swapping which component it renders. */ + onContinue: () => void; +} + +/** Ported from the sibling repo's web/setup.js render(state) function (by + * way of this repo's earlier vanilla-JS `public/onboarding/setup.js`) — + * same state → DOM mapping, now state → JSX. One render pass fully + * re-derives what's shown from each pushed SetupState; no separate + * imperative "now show screen X" calls scattered through the flow. */ +export default function OnboardingView({ + state, + actionError, + actionPending, + onBeginSetup, + onRunAction, + onContinue, +}: OnboardingViewProps) { + const [technicalOpen, setTechnicalOpen] = useState(false); + + function runPrimaryAction() { + if (state.primaryAction) return onRunAction(state.primaryAction); + if (state.canOpen) return onContinue(); + return onBeginSetup(); + } + + const primaryVisible = state.canStart || state.canRetry || state.canOpen || Boolean(state.primaryAction); + const primaryLabel = + state.primaryLabel || (state.canOpen ? "Continue" : state.canRetry ? "Try again" : "Set up Omnideck"); + + const hasProgress = typeof state.progress === "number"; + const hasIndeterminateProgress = state.indeterminate; + const progressVisible = hasProgress || hasIndeterminateProgress; + const spinnerVisible = !progressVisible && !SETTLED_STAGES.has(state.stage); + + const diagnostics = state.stage === "error" ? (state.diagnostics ?? []) : []; + const diagnosticsVisible = diagnostics.length > 0; + + return ( +
+
+
+ +

omnideck

+
+ +
+

{STAGE_EYEBROWS[state.stage] ?? "OMNIDECK"}

+

{state.title}

+

{state.detail}

+ + {state.activity &&

{state.activity}

} + + {diagnosticsVisible && ( +
+
+ DIAGNOSTICS +
+
+ {diagnostics.map((diagnostic) => ( +
+ + {DIAGNOSTIC_ICONS[diagnostic.status] ?? "–"} + + {diagnostic.label} +
+ ))} +
+
setTechnicalOpen(event.currentTarget.open)}> + Technical details +
{state.technical || "No further detail available."}
+
+
+ )} + + {progressVisible && ( +
+
+
+
+
+ )} + + {spinnerVisible &&
} + + {primaryVisible && ( + + )} + {state.secondaryAction && ( + + )} + {actionError && ( +

+ {actionError} +

+ )} +
+ + {FOOTNOTE_STAGES.has(state.stage) && ( +

This only needs to happen once.

+ )} +
+
+ ); +} diff --git a/src/hooks/useBootstrap.ts b/src/hooks/useBootstrap.ts new file mode 100644 index 0000000..4511af5 --- /dev/null +++ b/src/hooks/useBootstrap.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Channel, invoke } from "@tauri-apps/api/core"; +import type { SetupState } from "../types/setup"; + +export interface BootstrapController { + state: SetupState | null; + /** True once the initial `bootstrap` check has resolved and found the + * shared runtime already ready — lets App.tsx skip the onboarding screen + * entirely on a normal day-to-day launch. Distinct from `state.canOpen`: + * that's also true after `begin_setup` finishes mid-flow, where a + * "Continue" click is wanted (see OnboardingView) rather than an + * automatic skip. */ + initiallyReady: boolean; + actionError: string | null; + actionPending: boolean; + beginSetup: () => void; + runAction: (action: string) => void; +} + +function errorMessage(error: unknown): string { + if (error && typeof error === "object" && "message" in error) { + return String((error as { message?: unknown }).message); + } + return String(error); +} + +/** Drives the shared-runtime bootstrap flow (bootstrap.rs) — the same 3 + * commands (`bootstrap`/`begin_setup`/`run_action`) an earlier, isolated + * onboarding window used, now called from the dashboard's own "main" + * window (see bootstrap.rs's doc comment for why there's no window to + * show/hide anymore). `enabled` gates the initial `bootstrap` call so it + * only fires once useCliVersion has confirmed the CLI itself is reachable + * — a broken CLI already produces its own, more specific BlockingScreen; + * this hook is only meaningful once talking to the CLI already works. */ +export function useBootstrap(enabled: boolean): BootstrapController { + const [state, setState] = useState(null); + const [initiallyReady, setInitiallyReady] = useState(false); + const [actionError, setActionError] = useState(null); + const [actionPending, setActionPending] = useState(false); + const startedRef = useRef(false); + + const invokeWithChannel = useCallback( + async (command: string, onFirst?: (first: SetupState) => void) => { + const channel = new Channel(); + let seenFirst = false; + channel.onmessage = (next) => { + setState(next); + if (!seenFirst) { + seenFirst = true; + onFirst?.(next); + } + }; + await invoke(command, { onEvent: channel }); + }, + [], + ); + + useEffect(() => { + if (!enabled || startedRef.current) return; + startedRef.current = true; + void invokeWithChannel("bootstrap", (first) => { + if (first.stage === "ready") setInitiallyReady(true); + }).catch((error) => setActionError(errorMessage(error))); + }, [enabled, invokeWithChannel]); + + const beginSetup = useCallback(() => { + setActionPending(true); + setActionError(null); + invokeWithChannel("begin_setup") + .catch((error) => setActionError(errorMessage(error))) + .finally(() => setActionPending(false)); + }, [invokeWithChannel]); + + const runAction = useCallback((action: string) => { + setActionPending(true); + setActionError(null); + invoke("run_action", { action }) + .catch((error) => setActionError(errorMessage(error))) + .finally(() => setActionPending(false)); + }, []); + + return { state, initiallyReady, actionError, actionPending, beginSetup, runAction }; +} diff --git a/src/styles/app.css b/src/styles/app.css index 762cd55..9990152 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -184,6 +184,248 @@ button:disabled { line-height: 1.6; } +/* ═══════════════════════════════════════════════════════ + Onboarding screen — no app chrome yet, shown in place of the + dashboard until the shared Podman runtime is ready. Ported from the + original isolated onboarding window's public/onboarding/setup.css + (SIGNAL tokens now come from tokens.css instead of being duplicated). + ═══════════════════════════════════════════════════════ */ + +@keyframes onboarding-progress-slide { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(300%); + } +} + +.onboarding-screen { + display: grid; + place-items: center; + width: 100vw; + height: 100vh; + background: var(--surface); +} + +.onboarding-screen__panel { + display: flex; + flex-direction: column; + width: min(420px, 90vw); + height: min(560px, 90vh); + padding: var(--sp-8) var(--sp-8) var(--sp-6); + background: var(--canvas); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); +} + +.onboarding-screen__identity { + display: flex; + align-items: center; + gap: var(--sp-3); +} + +.onboarding-mark { + display: flex; + align-items: end; + justify-content: center; + gap: 3px; + width: 34px; + height: 34px; + padding: var(--sp-2); + background: var(--accent); + border-radius: var(--radius-lg); +} + +.onboarding-mark span { + display: block; + width: 3px; + background: #fff; + border-radius: var(--radius-full); +} + +.onboarding-mark span:nth-child(1) { + height: 8px; +} +.onboarding-mark span:nth-child(2) { + height: 16px; +} +.onboarding-mark span:nth-child(3) { + height: 12px; +} + +.onboarding-brand { + margin: 0; + color: var(--text-secondary); + font: 600 11px/1 var(--font-brand); + letter-spacing: 0.16em; +} + +.onboarding-screen__status { + display: flex; + flex-direction: column; + gap: var(--sp-4); + margin: auto 0; + padding: var(--sp-10) 0 var(--sp-6); +} + +.onboarding-screen__status h1 { + max-width: 26ch; + margin: 0; + font-size: 30px; + font-weight: 600; + line-height: 1.15; + letter-spacing: -0.02em; + text-wrap: balance; +} + +.onboarding-detail { + margin: 0; + color: var(--text-secondary); + font-size: 14px; + line-height: 1.6; +} + +.onboarding-activity { + margin: 0; + color: var(--text-primary); + font-size: 13px; + font-weight: 500; + line-height: 1.5; +} + +.onboarding-progress-wrap { + margin: 0; +} + +.onboarding-progress-track { + height: 4px; + overflow: hidden; + background: var(--border); + border-radius: var(--radius-full); +} + +.onboarding-progress { + width: 0; + height: 100%; + background: var(--accent); + border-radius: inherit; + transition: width 180ms var(--ease-out); +} + +.onboarding-progress-wrap.is-indeterminate .onboarding-progress { + width: 34%; + animation: onboarding-progress-slide 1.3s var(--ease) infinite; + transition: none; +} + +.onboarding-diagnostics { + display: grid; + gap: var(--sp-2); + padding: var(--sp-3); + background: var(--elevated); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.onboarding-diagnostics__heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); +} + +.onboarding-diagnostics__heading span { + color: var(--text-secondary); + font: 500 10px/1 var(--font-brand); + letter-spacing: 0.14em; +} + +.onboarding-diagnostic-list { + display: grid; + gap: 1px; +} + +.onboarding-diagnostic-row { + display: grid; + grid-template-columns: 16px minmax(0, 1fr) auto; + align-items: center; + gap: var(--sp-2); + min-height: 22px; + color: var(--text-secondary); + font-size: 12px; + white-space: nowrap; +} + +.onboarding-diagnostic-icon { + display: grid; + width: 16px; + height: 16px; + place-items: center; + color: var(--text-secondary); + background: transparent; + border-radius: var(--radius-full); + font: 600 10px/1 var(--font-brand); +} + +.onboarding-diagnostic-row[data-status="pass"] .onboarding-diagnostic-icon { + color: var(--success); + background: var(--success-muted); +} + +.onboarding-diagnostic-row[data-status="issue"] .onboarding-diagnostic-icon { + color: var(--danger); + background: var(--danger-muted); +} + +.onboarding-diagnostics details { + padding-top: var(--sp-2); + border-top: 1px solid var(--border-subtle); +} + +.onboarding-diagnostics summary { + color: var(--text-secondary); + cursor: pointer; + font-size: 11px; + font-weight: 600; +} + +.onboarding-diagnostics pre { + max-height: 92px; + overflow: auto; + margin: var(--sp-2) 0 0; + padding: var(--sp-2); + color: var(--terminal-text); + white-space: pre-wrap; + overflow-wrap: anywhere; + background: var(--terminal-bg); + border: 1px solid var(--terminal-border); + border-radius: var(--radius-md); + font: 400 10px/1.5 var(--font-code); +} + +.onboarding-primary, +.onboarding-secondary { + width: 100%; + min-height: 36px; +} + +.onboarding-action-error { + margin: 0; + color: var(--danger); + font-size: 12px; + line-height: 1.5; +} + +.onboarding-footnote { + margin: 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; +} + /* ═══════════════════════════════════════════════════════ App shell ═══════════════════════════════════════════════════════ */ diff --git a/src/types/setup.ts b/src/types/setup.ts new file mode 100644 index 0000000..d4f47d9 --- /dev/null +++ b/src/types/setup.ts @@ -0,0 +1,27 @@ +// Mirrors src-tauri/src/bootstrap.rs's SetupState/Diagnostic (serde +// rename_all = "camelCase"). Keep these two in sync by hand, same +// convention as types/cli.ts. + +export interface Diagnostic { + id: string; + label: string; + status: "pass" | "issue" | "waiting"; +} + +export interface SetupState { + stage: "welcome" | "preparing" | "ready" | "error"; + title: string; + detail: string; + progress: number | null; + indeterminate: boolean; + canStart: boolean; + canRetry: boolean; + canOpen: boolean; + activity: string | null; + primaryAction: string | null; + primaryLabel: string | null; + secondaryAction: string | null; + secondaryLabel: string | null; + diagnostics: Diagnostic[] | null; + technical: string | null; +} diff --git a/tests/policy.test.mjs b/tests/policy.test.mjs index 30d4e58..08acb9b 100644 --- a/tests/policy.test.mjs +++ b/tests/policy.test.mjs @@ -1,16 +1,26 @@ -// Security-posture assertions for the isolated onboarding window and the -// dashboard's capability allowlist — ported from the sibling repo's -// tests/policy.test.mjs, keeping only the security-assertion half. The -// byte-for-byte-Electron-parity half is intentionally not ported: there's -// no Electron app here to diff against, and this repo isn't diffing against -// the sibling either (which is itself post-Electron) — see -// reference/desktop-hardening-migration-PLAN.md's "Explicitly NOT being -// ported". +// Security-posture assertions for the dashboard's capability allowlist — +// ported from the sibling repo's tests/policy.test.mjs, keeping only the +// security-assertion half. The byte-for-byte-Electron-parity half is +// intentionally not ported: there's no Electron app here to diff against, +// and this repo isn't diffing against the sibling either (which is itself +// post-Electron) — see reference/desktop-hardening-migration-PLAN.md's +// "Explicitly NOT being ported". +// +// Onboarding used to run from a second, isolated "onboarding" window with +// its own capability grant — reverted to a single "main" window (React +// screen-swap instead of a window swap) after a real EGL/GPU-driver bug was +// root-caused to the two-window-at-startup pattern itself (see +// bootstrap.rs's doc comment and reference/desktop-hardening-migration-PLAN.md's +// "Decisions from review" for the full history). The onboarding-specific +// isolation tests that used to live here no longer apply — there's no +// second window or capability left to isolate — but the dashboard-bridge +// allowlist assertions below now also cover the 3 bootstrap commands folded +// into it. // // These assertions exist so a future PR can't silently widen the attack // surface (a broader capability grant, a new command added to an allowlist -// without review, `"main"` used where `"onboarding"` was meant) without a -// test failing. +// without review, a command handler skipping the window-origin check) without +// a test failing. import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; @@ -20,14 +30,12 @@ const read = (path) => readFile(new URL(path, import.meta.url), "utf8"); const packageJson = JSON.parse(await read("../package.json")); const tauriConf = JSON.parse(await read("../src-tauri/tauri.conf.json")); const dashboardCapability = JSON.parse(await read("../src-tauri/capabilities/default.json")); -const onboardingCapability = JSON.parse(await read("../src-tauri/capabilities/onboarding.json")); const dashboardPermission = await read("../src-tauri/permissions/dashboard-bridge.toml"); -const onboardingPermission = await read("../src-tauri/permissions/onboarding-bridge.toml"); const vendor = JSON.parse(await read("../src-tauri/binaries/vendor-manifest.json")); const libRust = await read("../src-tauri/src/lib.rs"); const bootstrapRust = await read("../src-tauri/src/bootstrap.rs"); const cliBridgeRust = await read("../src-tauri/src/cli_bridge.rs"); -const hostAdapter = await read("../public/onboarding/host-adapter.js"); +const useBootstrapTs = await read("../src/hooks/useBootstrap.ts"); test("bundles exactly one target-qualified logical sidecar", () => { assert.deepEqual(tauriConf.bundle.externalBin, ["binaries/omnideck"]); @@ -42,9 +50,20 @@ test("bundles exactly one target-qualified logical sidecar", () => { ]); }); +test("there is exactly one window, and no withGlobalTauri escape hatch", () => { + // withGlobalTauri existed only for the old vanilla-JS onboarding window + // (window.__TAURI__, no bundler). The React app imports @tauri-apps/api + // properly, so re-adding it would just widen what any loaded content can + // reach for no reason. + assert.deepEqual( + tauriConf.app.windows.map((w) => w.label), + ["main"], + ); + assert.equal(tauriConf.app.withGlobalTauri, undefined); +}); + test("dashboard capability is an enumerated allowlist, not core:default", () => { assert.deepEqual(dashboardCapability.windows, ["main"]); - assert.equal(dashboardCapability.windows.includes("onboarding"), false); assert.ok(dashboardCapability.permissions.includes("dashboard-bridge")); assert.equal(dashboardCapability.permissions.includes("core:default"), false); assert.doesNotMatch( @@ -53,30 +72,6 @@ test("dashboard capability is an enumerated allowlist, not core:default", () => ); }); -test("onboarding capability is local, scoped to \"onboarding\" only, and never \"main\"", () => { - // The regression this guards against is specific and easy to reintroduce - // by copy-paste from the sibling app: there, "main" *is* the setup - // window, so its capability correctly says "main". Here "main" is the - // dashboard — if this capability ever says "main" instead of - // "onboarding", the bridge silently grants the wrong window instead of - // the isolated one, or grants nothing at all. - assert.equal(onboardingCapability.local, true); - assert.deepEqual(onboardingCapability.windows, ["onboarding"]); - assert.equal(onboardingCapability.windows.includes("main"), false); - // Check the permissions grant specifically, not the whole file — the - // capability's own description text legitimately mentions "core:"/ - // "opener:" while explaining their absence. - assert.deepEqual(onboardingCapability.permissions, ["onboarding-bridge"]); -}); - -test("onboarding permission exposes only the four typed lifecycle commands", () => { - assert.match( - onboardingPermission, - /commands\.allow = \["bootstrap", "begin_setup", "open_dashboard", "run_action"\]/, - ); - assert.doesNotMatch(onboardingPermission, /spawn|execute|shell|filesystem|process/i); -}); - test("dashboard permission's command list matches lib.rs's invoke_handler exactly", () => { const declared = [...dashboardPermission.matchAll(/^\s*"([a-z_]+)",?$/gm)].map((m) => m[1]); const dashboardCommands = [ @@ -93,60 +88,63 @@ test("dashboard permission's command list matches lib.rs's invoke_handler exactl "suggest_new_deck_defaults", "add_instance", "remove_instance", + "bootstrap", + "begin_setup", + "run_action", ]; assert.deepEqual(declared.sort(), [...dashboardCommands].sort()); for (const command of dashboardCommands) { assert.match( libRust, - new RegExp(`commands::${command}\\b`), + new RegExp(`(commands::${command}|bootstrap::${command})\\b`), `${command} must be registered in lib.rs's invoke_handler!`, ); } + // "open_dashboard" was the isolated onboarding window's window-swap + // command — there's nothing left for it to do once there's only one + // window, so it was removed rather than kept as a no-op. + assert.doesNotMatch(dashboardPermission, /open_dashboard/); + assert.doesNotMatch(libRust, /open_dashboard/); }); -test("onboarding's IPC surface only calls its four allowed commands", () => { - const invoked = [...hostAdapter.matchAll(/run\("([^"]+)"/g)].map((m) => m[1]); - assert.deepEqual([...new Set(invoked)].sort(), [ - "begin_setup", - "bootstrap", - "open_dashboard", - "run_action", - ]); - assert.doesNotMatch(hostAdapter, /plugin-shell|Command\.sidecar|executable|argv|workingDirectory/); +test("the frontend only calls bootstrap's three commands, never a shell/process API", () => { + const invoked = [...useBootstrapTs.matchAll(/invoke(?:WithChannel)?\(\s*"([^"]+)"/g)].map((m) => m[1]); + assert.deepEqual([...new Set(invoked)].sort(), ["begin_setup", "bootstrap", "run_action"]); + assert.doesNotMatch(useBootstrapTs, /plugin-shell|Command\.sidecar|executable|argv|workingDirectory/); }); -test("every onboarding command authorizes window.label() == \"onboarding\", never \"main\"", () => { - assert.match(bootstrapRust, /fn authorize_onboarding\(window: &WebviewWindow\)/); - assert.match(bootstrapRust, /window\.label\(\) != "onboarding"/); - // The exact bug this repo's own review caught: copying the sibling's - // `window.label() != "main"` literally would authorize the wrong window, - // since "main" is the dashboard here. - assert.doesNotMatch(bootstrapRust, /window\.label\(\) != "main"/); -}); - -test("bootstrap only reveals the onboarding window when setup is actually needed", () => { - // A narrow, deliberately fragile regex: extracts the `Ok(status) if - // status.ready` match arm's body and asserts it does NOT call - // show_onboarding. This is the exact shape of a real regression this - // session found and fixed — bootstrap() unconditionally showing - // onboarding on every launch, even when the runtime was already ready. - const readyArm = bootstrapRust.match( - /Ok\(status\) if status\.ready => \{([\s\S]*?)\}\n\s*Ok\(_\)/, - ); - assert.ok(readyArm, "expected to find bootstrap()'s ready match arm"); - assert.doesNotMatch(readyArm[1], /show_onboarding/); +test("every bootstrap command authorizes window.label() == \"main\"", () => { + assert.match(bootstrapRust, /fn authorize_main\(window: &WebviewWindow\)/); + assert.match(bootstrapRust, /window\.label\(\) != "main"/); + for (const command of ["bootstrap", "begin_setup", "run_action"]) { + const fn = bootstrapRust.match(new RegExp(`pub (?:async )?fn ${command}\\([\\s\\S]*?\\n\\}`)); + assert.ok(fn, `expected to find ${command}()`); + assert.match(fn[0], /authorize_main\(&window\)\?/, `${command} must call authorize_main`); + } }); -test("the onboarding window is created hidden by default", () => { - assert.match(bootstrapRust, /"onboarding",\s*\n\s*WebviewUrl::App\("onboarding\/index\.html"\.into\(\)\)/); - assert.match(bootstrapRust, /\.visible\(false\)/); +test("bootstrap.rs no longer manages window visibility", () => { + // The regression this guards against is the one this repo actually hit: a + // second, initially-hidden window created at startup broke EGL/GPU-driver + // init on at least one real Intel/Mesa combination. Asserting the + // window-management surface is gone (not just unused) keeps it from + // creeping back in as part of some future onboarding tweak. + for (const symbol of [ + "show_onboarding", + "show_dashboard", + "create_onboarding_window", + "WebviewWindowBuilder", + "WebviewUrl", + ]) { + assert.doesNotMatch(bootstrapRust, new RegExp(symbol), `${symbol} should not reappear in bootstrap.rs`); + } }); -test("single-instance plugin is registered first and picks onboarding over main when visible", () => { +test("single-instance plugin is registered first and focuses the single main window", () => { const pluginOrder = [...libRust.matchAll(/\.plugin\((\w[\w:]*)/g)].map((m) => m[1]); assert.equal(pluginOrder[0], "tauri_plugin_single_instance::init"); - assert.match(libRust, /get_webview_window\("onboarding"\)/); - assert.match(libRust, /filter\(\|window\| window\.is_visible\(\)\.unwrap_or\(false\)\)/); + assert.match(libRust, /get_webview_window\("main"\)/); + assert.doesNotMatch(libRust, /get_webview_window\("onboarding"\)/); }); test("CLI sidecar is pinned by version + checksum for all six targets, floor-checked not exact-matched", () => {