From e51106eba9ccd785b893ba773d8267a248d121eb Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 26 Aug 2026 14:23:41 +0100 Subject: [PATCH 01/23] chore(desktop): start onboarding v3 collaboration Signed-off-by: kenny lopez From 55af8df0da82b590fe282afa56bae839fcf1bbaa Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 26 Aug 2026 18:26:10 +0100 Subject: [PATCH 02/23] Prototype Buzz onboarding V3 workshop flow Signed-off-by: kenny lopez --- Justfile | 35 + desktop/src-tauri/src/app_state.rs | 24 +- desktop/src-tauri/src/lib.rs | 8 + desktop/src-tauri/src/onboarding_preview.rs | 55 ++ .../onboarding/onboardingPreview.test.mjs | 45 + .../features/onboarding/onboardingPreview.ts | 31 + .../src/features/onboarding/ui/BackupStep.tsx | 11 +- .../onboarding/ui/JoinPolicyNotice.tsx | 59 +- .../onboarding/ui/NostrKeyImportForm.tsx | 15 +- .../onboarding/ui/OnboardingPreviewApp.tsx | 839 ++++++++++++++++++ .../onboarding/ui/SetupRuntimeCard.tsx | 301 +++++++ .../src/features/onboarding/ui/SetupStep.tsx | 353 ++------ .../onboarding/ui/SetupStepPreview.tsx | 272 ++++++ desktop/src/main.tsx | 19 + desktop/src/shared/ui/checkbox.tsx | 75 +- 15 files changed, 1802 insertions(+), 340 deletions(-) create mode 100644 desktop/src-tauri/src/onboarding_preview.rs create mode 100644 desktop/src/features/onboarding/onboardingPreview.test.mjs create mode 100644 desktop/src/features/onboarding/onboardingPreview.ts create mode 100644 desktop/src/features/onboarding/ui/OnboardingPreviewApp.tsx create mode 100644 desktop/src/features/onboarding/ui/SetupRuntimeCard.tsx create mode 100644 desktop/src/features/onboarding/ui/SetupStepPreview.tsx diff --git a/Justfile b/Justfile index 6c7740bc7ac..3a775351c0b 100644 --- a/Justfile +++ b/Justfile @@ -569,6 +569,41 @@ desktop-standalone *ARGS: _ensure-sidecar-stubs echo "Starting standalone desktop on Vite port ${BUZZ_VITE_PORT}; no relay services were started" pnpm exec tauri dev --config "$BUZZ_TAURI_CONFIG" {{ARGS}} +# Launch a development-only onboarding workshop. The frontend uses React-only +# state and fixed mock data; native setup stops before migrations, identity +# persistence, keychain access, relay sync, or agent restore. +onboarding-preview *ARGS: _ensure-sidecar-stubs + #!/usr/bin/env bash + set -euo pipefail + export PATH="{{justfile_directory()}}/bin:$PATH" + cd {{desktop_dir}} + [[ -d node_modules ]] || pnpm install + unset BUZZ_AUTH_TAG BUZZ_RESET_WEBVIEW_STATE BUZZ_SHARE_IDENTITY + export BUZZ_ONBOARDING_PREVIEW=1 + export BUZZ_RELAY_URL="ws://127.0.0.1:9" + # Fixed public test fixture: build_app_state never generates an identity, + # while the preview UI never receives or exposes this value. + export BUZZ_PRIVATE_KEY="3dbaebadb5dfd777ff25149ee230d907a15a9e1294b40b830661e65bb42f6c03" + source ../scripts/instance-env.sh + export BUZZ_PREVIEW_VITE_PORT=$((20001 + (BUZZ_VITE_PORT % 40000))) + BUZZ_TAURI_CONFIG=$(node -e ' + const config = JSON.parse(process.env.BUZZ_TAURI_CONFIG); + const url = new URL(config.build.devUrl); + url.port = process.env.BUZZ_PREVIEW_VITE_PORT; + url.searchParams.set("onboardingPreview", "1"); + config.build.devUrl = url.toString(); + config.build.beforeDevCommand = `exec ./node_modules/.bin/vite --port ${process.env.BUZZ_PREVIEW_VITE_PORT} --strictPort`; + config.identifier = `xyz.block.buzz.onboarding-preview.p${process.env.BUZZ_PREVIEW_VITE_PORT}`; + config.productName = "Buzz Onboarding Preview"; + process.stdout.write(JSON.stringify(config)); + ') + export BUZZ_TAURI_CONFIG + export BUZZ_DEV_KEYRING_SERVICE="buzz-desktop-onboarding-preview.${BUZZ_PREVIEW_VITE_PORT}" + INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)") + trap '../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true' EXIT + echo "Starting non-persistent onboarding preview on Vite port ${BUZZ_PREVIEW_VITE_PORT}; external relay access is disabled" + pnpm exec tauri dev --config "$BUZZ_TAURI_CONFIG" {{ARGS}} + # Run the desktop app against the internal staging relay (installs deps + builds agent tools automatically) staging *ARGS: bootstrap _ensure-sidecar-stubs #!/usr/bin/env bash diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 9cbb4444ab3..de0bc6dc05e 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -176,18 +176,18 @@ pub fn build_media_fetch_client() -> reqwest::Result { } pub fn build_app_state() -> AppState { - // Env var takes precedence (dev/CI). If absent, resolve_persisted_identity() - // in setup() will replace the ephemeral placeholder with a persisted key. - let (keys, identity_storage) = match identity_from_env() { - Some(keys) => { - eprintln!( - "buzz-desktop: configured identity pubkey {}", - keys.public_key().to_hex() - ); - (keys, IdentityStorage::Environment) - } - None => (Keys::generate(), IdentityStorage::Ephemeral), - }; + // Env var takes precedence; setup() otherwise resolves a persisted identity. + let (keys, identity_storage) = + match crate::onboarding_preview::require_fixed_identity(identity_from_env()) { + Some(keys) => { + eprintln!( + "buzz-desktop: configured identity pubkey {}", + keys.public_key().to_hex() + ); + (keys, IdentityStorage::Environment) + } + None => (Keys::generate(), IdentityStorage::Ephemeral), + }; AppState { keys: Mutex::new(keys), diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 613040b8095..7efe6471cff 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -33,6 +33,7 @@ mod native_websocket_batch; mod nostr_bind; pub mod nostr_convert; mod observed_unread; +mod onboarding_preview; mod persona_catalog; mod prevent_sleep; mod ptt_shortcut; @@ -161,6 +162,10 @@ pub fn run() { // commit the startup surface before revealing it. let window = webview.window(); + if onboarding_preview::reveal_window(&window) { + return; + } + #[cfg(target_os = "macos")] { set_initial_window_backing(&window); @@ -238,6 +243,9 @@ pub fn run() { .manage(channel_head_cache::ChannelHeadCacheStore::default()) .setup(move |app| { let app_handle = app.handle().clone(); + if onboarding_preview::skip_native_setup() { + return Ok(()); + } #[cfg(target_os = "macos")] { tray_menu::init(&app_handle)?; diff --git a/desktop/src-tauri/src/onboarding_preview.rs b/desktop/src-tauri/src/onboarding_preview.rs new file mode 100644 index 00000000000..cee220027c6 --- /dev/null +++ b/desktop/src-tauri/src/onboarding_preview.rs @@ -0,0 +1,55 @@ +const ONBOARDING_PREVIEW_ENV: &str = "BUZZ_ONBOARDING_PREVIEW"; + +fn enabled_for(debug_build: bool, value: Option<&str>) -> bool { + debug_build && value == Some("1") +} + +/// Whether the development-only native onboarding workshop is active. +pub(crate) fn enabled() -> bool { + enabled_for( + cfg!(debug_assertions), + std::env::var(ONBOARDING_PREVIEW_ENV).ok().as_deref(), + ) +} + +/// Fail closed before app state could generate a placeholder identity. +pub(crate) fn require_fixed_identity(identity: Option) -> Option { + assert!( + identity.is_some() || !enabled(), + "BUZZ_ONBOARDING_PREVIEW requires fixed mock identity data" + ); + identity +} + +/// Stop native setup before migrations, persistence, networking, or agents. +pub(crate) fn skip_native_setup() -> bool { + if !enabled() { + return false; + } + eprintln!( + "buzz-desktop: onboarding preview safety mode; skipped migrations, identity persistence, relay sync, and agent restore" + ); + true +} + +/// Preview does not wait for the normal app's first-render event. +pub(crate) fn reveal_window(window: &tauri::Window) -> bool { + if !enabled() { + return false; + } + crate::initial_window::reveal_initial_window(window); + true +} + +#[cfg(test)] +mod tests { + use super::enabled_for; + + #[test] + fn preview_is_debug_only_and_requires_exact_opt_in() { + assert!(enabled_for(true, Some("1"))); + assert!(!enabled_for(true, Some("true"))); + assert!(!enabled_for(true, None)); + assert!(!enabled_for(false, Some("1"))); + } +} diff --git a/desktop/src/features/onboarding/onboardingPreview.test.mjs b/desktop/src/features/onboarding/onboardingPreview.test.mjs new file mode 100644 index 00000000000..c53ae307ff8 --- /dev/null +++ b/desktop/src/features/onboarding/onboardingPreview.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveOnboardingPreviewMode } from "./onboardingPreview.ts"; + +test("preview requires an exact development opt-in", () => { + assert.equal( + resolveOnboardingPreviewMode({ + dev: true, + mode: "development", + search: "?onboardingPreview=1", + }), + true, + ); + assert.equal( + resolveOnboardingPreviewMode({ + dev: true, + mode: "development", + search: "?onboardingPreview=true", + }), + false, + ); +}); + +test("preview is unavailable in production", () => { + assert.equal( + resolveOnboardingPreviewMode({ + dev: false, + mode: "production", + search: "?onboardingPreview=1", + }), + false, + ); +}); + +test("preview is available to the explicit E2E build", () => { + assert.equal( + resolveOnboardingPreviewMode({ + dev: false, + mode: "e2e", + search: "?onboardingPreview=1", + }), + true, + ); +}); diff --git a/desktop/src/features/onboarding/onboardingPreview.ts b/desktop/src/features/onboarding/onboardingPreview.ts new file mode 100644 index 00000000000..a11a2edf55f --- /dev/null +++ b/desktop/src/features/onboarding/onboardingPreview.ts @@ -0,0 +1,31 @@ +const ONBOARDING_PREVIEW_PARAM = "onboardingPreview"; +const ONBOARDING_PREVIEW_VALUE = "1"; + +type OnboardingPreviewEnvironment = { + dev: boolean; + mode: string; + search: string; +}; + +/** Resolve the explicit, non-production onboarding workshop route. */ +export function resolveOnboardingPreviewMode({ + dev, + mode, + search, +}: OnboardingPreviewEnvironment) { + if (!dev && mode !== "e2e") return false; + return ( + new URLSearchParams(search).get(ONBOARDING_PREVIEW_PARAM) === + ONBOARDING_PREVIEW_VALUE + ); +} + +/** True only for a development or explicit E2E preview boot. */ +export function onboardingPreviewRequested() { + if (typeof window === "undefined") return false; + return resolveOnboardingPreviewMode({ + dev: import.meta.env.DEV, + mode: import.meta.env.MODE, + search: window.location.search, + }); +} diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index 8793eda7942..192de327f53 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -27,6 +27,7 @@ import { ONBOARDING_KEY_TEXT_CLASS } from "./NsecMaskedDisplay"; * pause sells the creation moment. */ const INTRO_HOLD_MS = 1400; +const PREVIEW_KEY_TEXT = `nsec1${"q".repeat(58)}`; /** * The creation moment should only be sold once per app session. Module-level @@ -53,6 +54,7 @@ type BackupStepProps = { onOpenPasswordBackup: () => void; onShowOptions: () => void; optionsExpanded: boolean; + previewMode?: boolean; returningFromSecurity: boolean; }; @@ -69,6 +71,7 @@ export function BackupStep({ onOpenPasswordBackup, onShowOptions, optionsExpanded, + previewMode = false, returningFromSecurity, }: BackupStepProps) { const reduceMotion = useReducedMotion() ?? false; @@ -112,7 +115,7 @@ export function BackupStep({ setCopyState("copying"); setCopyError(null); try { - const value = nsec ?? (await getNsec()); + const value = nsec ?? (previewMode ? PREVIEW_KEY_TEXT : await getNsec()); await writeTextToClipboard(value); if (cancelledRef.current) return; setCopyState("copied"); @@ -128,7 +131,7 @@ export function BackupStep({ err instanceof Error ? err.message : "Failed to retrieve private key.", ); } - }, [nsec]); + }, [nsec, previewMode]); const toggleReveal = React.useCallback(async () => { if (isRevealed) { @@ -138,7 +141,7 @@ export function BackupStep({ setCopyError(null); try { // The raw key enters the DOM only after this explicit reveal action. - const value = nsec ?? (await getNsec()); + const value = nsec ?? (previewMode ? PREVIEW_KEY_TEXT : await getNsec()); if (cancelledRef.current) return; setNsec(value); setIsRevealed(true); @@ -148,7 +151,7 @@ export function BackupStep({ err instanceof Error ? err.message : "Failed to retrieve private key.", ); } - }, [isRevealed, nsec]); + }, [isRevealed, nsec, previewMode]); // Fixed-length decorative mask (nsec keys are 63 chars) so no key material // is fetched just to render the blurred row. Bullets are joined with a diff --git a/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx b/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx index 4d5e6771b6a..4308d35467d 100644 --- a/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx +++ b/desktop/src/features/onboarding/ui/JoinPolicyNotice.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { joinPolicyDocumentUrl, type JoinPolicy } from "@/shared/api/invites"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Checkbox } from "@/shared/ui/checkbox"; @@ -13,25 +14,31 @@ type JoinPolicyNoticeProps = { policy: JoinPolicy; /** Relay hosting the policy documents the links below point at. */ relayWsUrl: string; + /** Optional safe host for surfaces that must not open external documents. */ + onOpenDocument?: (document: "terms" | "privacy") => void; + /** Use primary page text where a surface needs stronger consent contrast. */ + textTone?: "muted" | "foreground"; }; /** * Join-policy consent block shown on every join surface. * - * The Terms/Privacy links open the relay-hosted document pages - * (`/api/join-policy/terms|privacy`) in the system browser via the OS - * opener. They must NOT navigate or render in-app: these surfaces exist - * before onboarding completes, where the router (required by the message - * Markdown component) is not mounted — an in-app render tears down the - * whole React tree. + * The Terms/Privacy links normally open the relay-hosted document pages + * (`/api/join-policy/terms|privacy`) in the system browser via the OS opener. + * A non-networked host may intercept those actions. They must NOT navigate or + * render in-app: these surfaces exist before onboarding completes, where the + * router (required by the message Markdown component) is not mounted — an + * in-app render tears down the whole React tree. */ export function JoinPolicyNotice({ ageConfirmed, agreementConfirmed, onAgeConfirmedChange, onAgreementConfirmedChange, + onOpenDocument, policy, relayWsUrl, + textTone = "muted", }: JoinPolicyNoticeProps) { const ageConfirmationId = React.useId(); const agreementConfirmationId = React.useId(); @@ -49,7 +56,12 @@ export function JoinPolicyNotice({ } />