From 5e20a2becae3ba292fb8809ea3e37fe6e99a1f23 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Tue, 7 Apr 2026 17:21:27 -0500 Subject: [PATCH 1/8] feat: onboarding flow with permissions + intro screens Adds a full onboarding experience gated on macOS Accessibility and Screen Recording permissions, with a post-grant intro screen. Key design decisions: - Startup reads DB stage to detect Complete; then checks live permission APIs (AXIsProcessTrusted + CGWindowListCopyWindowInfo) to determine Permissions vs. Intro. This avoids dependency on DB write durability across process restarts and works around macOS 15 API unreliability. - quit_and_relaunch writes "intro" stage to DB and issues PRAGMA wal_checkpoint(FULL) before exit to survive std::process::exit. - PermissionsStep owns all runtime permission detection via mount-time checks and polling. Startup never gates on permission APIs beyond the initial Permissions-vs-Intro decision. - Onboarding window: 460x640px, centered, native shadow re-enabled. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Logan Nguyen --- src-tauri/src/commands.rs | 21 +- src-tauri/src/database.rs | 78 ++- src-tauri/src/lib.rs | 48 +- src-tauri/src/onboarding.rs | 135 ++++ src-tauri/src/permissions.rs | 26 +- src/App.tsx | 21 +- src/__tests__/App.test.tsx | 2 +- src/__tests__/OnboardingView.test.tsx | 54 +- src/view/OnboardingView.tsx | 616 +----------------- src/view/onboarding/IntroStep.tsx | 405 ++++++++++++ src/view/onboarding/PermissionsStep.tsx | 612 +++++++++++++++++ .../onboarding/__tests__/IntroStep.test.tsx | 59 ++ src/view/onboarding/__tests__/index.test.tsx | 22 + src/view/onboarding/index.tsx | 26 + 14 files changed, 1462 insertions(+), 663 deletions(-) create mode 100644 src-tauri/src/onboarding.rs create mode 100644 src/view/onboarding/IntroStep.tsx create mode 100644 src/view/onboarding/PermissionsStep.tsx create mode 100644 src/view/onboarding/__tests__/IntroStep.test.tsx create mode 100644 src/view/onboarding/__tests__/index.test.tsx create mode 100644 src/view/onboarding/index.tsx diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a4d1f3f..6120be8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3,7 +3,7 @@ use std::sync::Mutex; use futures_util::StreamExt; use serde::{Deserialize, Serialize}; -use tauri::{ipc::Channel, State}; +use tauri::{ipc::Channel, Manager, State}; use tokio_util::sync::CancellationToken; /// Default configuration constants as the application currently lacks a Settings UI. @@ -355,6 +355,25 @@ pub fn reset_conversation(history: State<'_, ConversationHistory>) { history.messages.lock().unwrap().clear(); } +/// Marks onboarding as complete and hides the window so the next hotkey press +/// shows the normal overlay. Called by the frontend when the user clicks +/// "Get Started" on the intro screen. +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg_attr(not(coverage), tauri::command)] +pub fn finish_onboarding( + db: State<'_, crate::history::Database>, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + let conn = db.0.lock().map_err(|e| format!("db lock poisoned: {e}"))?; + crate::onboarding::set_stage(&conn, &crate::onboarding::OnboardingStage::Complete) + .map_err(|e| format!("db write failed: {e}"))?; + drop(conn); + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.hide(); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index f873303..652f6e1 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -9,7 +9,7 @@ * behind a `Mutex`. */ -use rusqlite::{params, Connection, Result as SqlResult}; +use rusqlite::{params, Connection, OptionalExtension, Result as SqlResult}; use serde::Serialize; /// Summary of a conversation for the history dropdown list. @@ -120,6 +120,8 @@ fn run_migrations(conn: &Connection) -> SqlResult<()> { " ON messages(conversation_id, created_at);", "CREATE INDEX IF NOT EXISTS idx_conversations_updated", " ON conversations(updated_at DESC);", + "CREATE TABLE IF NOT EXISTS app_config (", + " key TEXT PRIMARY KEY, value TEXT NOT NULL);", ); conn.execute_batch(SCHEMA_DDL)?; @@ -139,6 +141,28 @@ fn run_migrations(conn: &Connection) -> SqlResult<()> { Ok(()) } +// ─── App config key-value store ───────────────────────────────────────────── + +/// Reads a value from the app_config table. Returns `None` if the key is absent. +pub fn get_config(conn: &Connection, key: &str) -> SqlResult> { + conn.query_row( + "SELECT value FROM app_config WHERE key = ?1", + rusqlite::params![key], + |row| row.get(0), + ) + .optional() +} + +/// Inserts or replaces a value in the app_config table. +pub fn set_config(conn: &Connection, key: &str, value: &str) -> SqlResult<()> { + conn.execute( + "INSERT INTO app_config (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + rusqlite::params![key, value], + )?; + Ok(()) +} + // ─── Conversation CRUD ────────────────────────────────────────────────────── /// Inserts a new conversation row and returns its UUID. @@ -679,6 +703,58 @@ mod tests { fs::remove_dir_all(&tmp).unwrap(); } + #[test] + fn app_config_table_exists_after_migration() { + let conn = open_in_memory().unwrap(); + let tables: Vec = conn + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + assert!(tables.contains(&"app_config".to_string())); + } + + #[test] + fn get_config_returns_none_for_missing_key() { + let conn = open_in_memory().unwrap(); + let val = get_config(&conn, "onboarding_stage").unwrap(); + assert!(val.is_none()); + } + + #[test] + fn set_and_get_config_round_trips() { + let conn = open_in_memory().unwrap(); + set_config(&conn, "onboarding_stage", "intro").unwrap(); + let val = get_config(&conn, "onboarding_stage").unwrap(); + assert_eq!(val.as_deref(), Some("intro")); + } + + #[test] + fn set_config_overwrites_existing_value() { + let conn = open_in_memory().unwrap(); + set_config(&conn, "onboarding_stage", "intro").unwrap(); + set_config(&conn, "onboarding_stage", "complete").unwrap(); + let val = get_config(&conn, "onboarding_stage").unwrap(); + assert_eq!(val.as_deref(), Some("complete")); + } + + #[test] + fn set_config_independent_keys_do_not_interfere() { + let conn = open_in_memory().unwrap(); + set_config(&conn, "onboarding_stage", "intro").unwrap(); + set_config(&conn, "other_key", "other_value").unwrap(); + assert_eq!( + get_config(&conn, "onboarding_stage").unwrap().as_deref(), + Some("intro") + ); + assert_eq!( + get_config(&conn, "other_key").unwrap().as_deref(), + Some("other_value") + ); + } + #[test] fn migrate_legacy_db_skips_when_target_exists() { let tmp = std::env::temp_dir().join(format!("thuki-migrate-{}", uuid::Uuid::new_v4())); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cf8d2be..9b88a10 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,7 @@ pub mod commands; pub mod database; pub mod history; pub mod images; +pub mod onboarding; pub mod screenshot; #[cfg(target_os = "macos")] @@ -78,7 +79,7 @@ const ONBOARDING_EVENT: &str = "thuki://onboarding"; /// Content fits tightly; native macOS shadow is re-enabled for onboarding /// so it renders outside the window boundary without extra transparent padding. const ONBOARDING_LOGICAL_WIDTH: f64 = 460.0; -const ONBOARDING_LOGICAL_HEIGHT: f64 = 540.0; +const ONBOARDING_LOGICAL_HEIGHT: f64 = 640.0; /// Tracks the intended visibility state of the overlay, preventing race conditions /// between the frontend exit animation and rapid activation toggles. @@ -373,14 +374,30 @@ fn notify_overlay_hidden() { /// the frontend listener registration. #[tauri::command] #[cfg_attr(coverage_nightly, coverage(off))] -fn notify_frontend_ready(app_handle: tauri::AppHandle) { +fn notify_frontend_ready(app_handle: tauri::AppHandle, db: tauri::State) { if LAUNCH_SHOW_PENDING.swap(false, Ordering::SeqCst) { #[cfg(target_os = "macos")] { - let ax = permissions::is_accessibility_granted(); - let sc = permissions::is_screen_recording_granted(); - if permissions::needs_onboarding(ax, sc) { - show_onboarding_window(&app_handle); + // If onboarding is already complete, skip it entirely. + let is_complete = + db.0.lock() + .ok() + .and_then(|conn| onboarding::get_stage(&conn).ok()) + .map(|s| s == onboarding::OnboardingStage::Complete) + .unwrap_or(false); + + if !is_complete { + // Derive which screen to show from live permission state. + // Both permissions granted → intro; otherwise → permissions. + // This is independent of DB persistence across restarts. + let ax = permissions::is_accessibility_granted(); + let sr = permissions::check_screen_recording_tcc_granted(); + let stage = if ax && sr { + onboarding::OnboardingStage::Intro + } else { + onboarding::OnboardingStage::Permissions + }; + show_onboarding_window(&app_handle, stage); return; } } @@ -445,7 +462,7 @@ fn init_panel(app_handle: &tauri::AppHandle) { /// frontend receives the event before the window is visible. #[cfg(target_os = "macos")] #[cfg_attr(coverage_nightly, coverage(off))] -fn show_onboarding_window(app_handle: &tauri::AppHandle) { +fn show_onboarding_window(app_handle: &tauri::AppHandle, stage: onboarding::OnboardingStage) { let handle = app_handle.clone(); let _ = app_handle.run_on_main_thread(move || { if let Some(window) = handle.get_webview_window("main") { @@ -463,8 +480,7 @@ fn show_onboarding_window(app_handle: &tauri::AppHandle) { // it for the overlay to avoid the key/non-key shadow flicker, // but for onboarding the native shadow looks professional and // renders outside the window boundary — no transparent padding - // needed. The app always restarts after onboarding completes, - // so this does not affect the overlay session. + // needed. panel.set_has_shadow(true); panel.show_and_make_key(); } @@ -474,10 +490,16 @@ fn show_onboarding_window(app_handle: &tauri::AppHandle) { } } } - let _ = handle.emit(ONBOARDING_EVENT, ()); + let _ = handle.emit(ONBOARDING_EVENT, OnboardingPayload { stage }); }); } +/// Payload emitted to the frontend for every onboarding transition. +#[derive(Clone, serde::Serialize)] +struct OnboardingPayload { + stage: onboarding::OnboardingStage, +} + // ─── Image cleanup ────────────────────────────────────────────────────────── /// Interval between periodic orphaned-image cleanup sweeps. @@ -676,7 +698,9 @@ pub fn run() { #[cfg(not(coverage))] permissions::check_screen_recording_tcc_granted, #[cfg(not(coverage))] - permissions::quit_and_relaunch + permissions::quit_and_relaunch, + #[cfg(not(coverage))] + commands::finish_onboarding ]) .build(tauri::generate_context!()) .expect("error while building tauri application") @@ -744,7 +768,7 @@ mod tests { #[test] fn onboarding_logical_dimensions() { assert_eq!(ONBOARDING_LOGICAL_WIDTH, 460.0); - assert_eq!(ONBOARDING_LOGICAL_HEIGHT, 540.0); + assert_eq!(ONBOARDING_LOGICAL_HEIGHT, 640.0); } #[test] diff --git a/src-tauri/src/onboarding.rs b/src-tauri/src/onboarding.rs new file mode 100644 index 0000000..192a9d7 --- /dev/null +++ b/src-tauri/src/onboarding.rs @@ -0,0 +1,135 @@ +/*! + * Onboarding stage management. + * + * Tracks the user's progress through the onboarding flow using a single + * persisted value in the `app_config` table. + * + * Stages progress linearly: + * "permissions" -> "intro" -> "complete" + * + * "permissions" is the implicit default when no value has been written yet. + * Once "complete", onboarding is never shown again regardless of permissions. + */ + +use rusqlite::Connection; + +use crate::database::{get_config, set_config}; + +/// The config key used to store the onboarding stage. +const STAGE_KEY: &str = "onboarding_stage"; + +/// Serializable stage value sent to the frontend via the onboarding event. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum OnboardingStage { + Permissions, + Intro, + Complete, +} + +/// Reads the persisted onboarding stage. Returns `Permissions` if no value +/// has been written yet (i.e. first-ever launch). +pub fn get_stage(conn: &Connection) -> rusqlite::Result { + match get_config(conn, STAGE_KEY)?.as_deref() { + Some("intro") => Ok(OnboardingStage::Intro), + Some("complete") => Ok(OnboardingStage::Complete), + _ => Ok(OnboardingStage::Permissions), + } +} + +/// Persists the onboarding stage. +pub fn set_stage(conn: &Connection, stage: &OnboardingStage) -> rusqlite::Result<()> { + let value = match stage { + OnboardingStage::Permissions => "permissions", + OnboardingStage::Intro => "intro", + OnboardingStage::Complete => "complete", + }; + set_config(conn, STAGE_KEY, value) +} + +/// Returns which onboarding stage to show at startup, or `None` if onboarding +/// is complete. +/// +/// Reads only the persisted stage — no permission API calls. macOS 15 broke +/// CGPreflightScreenCaptureAccess and CGWindowListCopyWindowInfo so neither is +/// reliable at launch time. The PermissionsStep component owns all permission +/// detection via its own mount-time and polling checks. The startup path just +/// trusts the DB. +pub fn compute_startup_stage(conn: &Connection) -> rusqlite::Result> { + match get_stage(conn)? { + OnboardingStage::Complete => Ok(None), + stage => Ok(Some(stage)), + } +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::open_in_memory; + + #[test] + fn get_stage_defaults_to_permissions_on_first_launch() { + let conn = open_in_memory().unwrap(); + assert_eq!(get_stage(&conn).unwrap(), OnboardingStage::Permissions); + } + + #[test] + fn set_and_get_stage_round_trips_intro() { + let conn = open_in_memory().unwrap(); + set_stage(&conn, &OnboardingStage::Intro).unwrap(); + assert_eq!(get_stage(&conn).unwrap(), OnboardingStage::Intro); + } + + #[test] + fn set_and_get_stage_round_trips_complete() { + let conn = open_in_memory().unwrap(); + set_stage(&conn, &OnboardingStage::Complete).unwrap(); + assert_eq!(get_stage(&conn).unwrap(), OnboardingStage::Complete); + } + + #[test] + fn set_stage_overwrites_previous_value() { + let conn = open_in_memory().unwrap(); + set_stage(&conn, &OnboardingStage::Intro).unwrap(); + set_stage(&conn, &OnboardingStage::Complete).unwrap(); + assert_eq!(get_stage(&conn).unwrap(), OnboardingStage::Complete); + } + + #[test] + fn compute_startup_stage_returns_none_when_complete() { + let conn = open_in_memory().unwrap(); + set_stage(&conn, &OnboardingStage::Complete).unwrap(); + assert_eq!(compute_startup_stage(&conn).unwrap(), None); + } + + #[test] + fn compute_startup_stage_shows_permissions_when_not_granted() { + let conn = open_in_memory().unwrap(); + // Default stage is "permissions" on first launch. + let result = compute_startup_stage(&conn).unwrap(); + assert_eq!(result, Some(OnboardingStage::Permissions)); + // Stage must not have been modified. + assert_eq!(get_stage(&conn).unwrap(), OnboardingStage::Permissions); + } + + #[test] + fn compute_startup_stage_shows_intro_when_stage_is_intro() { + let conn = open_in_memory().unwrap(); + set_stage(&conn, &OnboardingStage::Intro).unwrap(); + let result = compute_startup_stage(&conn).unwrap(); + assert_eq!(result, Some(OnboardingStage::Intro)); + } + + #[test] + fn compute_startup_stage_trusts_intro_stage_even_if_permissions_check_fails() { + let conn = open_in_memory().unwrap(); + // Startup trusts the persisted stage entirely. No permission API is + // called. CGPreflightScreenCaptureAccess can return false on macOS 15 + // even after a successful grant+restart, so startup never gates on it. + set_stage(&conn, &OnboardingStage::Intro).unwrap(); + let result = compute_startup_stage(&conn).unwrap(); + assert_eq!(result, Some(OnboardingStage::Intro)); + } +} diff --git a/src-tauri/src/permissions.rs b/src-tauri/src/permissions.rs index 5f7816e..6250cde 100644 --- a/src-tauri/src/permissions.rs +++ b/src-tauri/src/permissions.rs @@ -160,10 +160,34 @@ pub fn check_screen_recording_tcc_granted() -> bool { /// /// Called after the user grants Screen Recording permission. macOS requires /// a full process restart before the new permission takes effect. +/// +/// Advances the onboarding stage to "intro" before restarting so the next +/// launch shows the intro screen rather than re-checking permissions via +/// CGPreflightScreenCaptureAccess, which can return false on macOS 15 even +/// after a successful grant. #[tauri::command] #[cfg(target_os = "macos")] #[cfg_attr(coverage_nightly, coverage(off))] -pub fn quit_and_relaunch(app_handle: tauri::AppHandle) { +pub fn quit_and_relaunch( + app_handle: tauri::AppHandle, + db: tauri::State<'_, crate::history::Database>, +) { + match db.0.lock() { + Ok(conn) => { + match crate::onboarding::set_stage(&conn, &crate::onboarding::OnboardingStage::Intro) { + Ok(()) => { + // Force WAL checkpoint so the write survives the + // std::process::exit(0) inside app_handle.restart(), + // which skips destructors and may leave uncheckpointed + // WAL pages in the OS page cache. + let _ = conn.execute_batch("PRAGMA wal_checkpoint(FULL);"); + eprintln!("[thuki] quit_and_relaunch: stage advanced to intro (checkpointed)"); + } + Err(e) => eprintln!("[thuki] quit_and_relaunch: db write failed: {e}"), + } + } + Err(e) => eprintln!("[thuki] quit_and_relaunch: mutex poisoned: {e}"), + } app_handle.restart(); } diff --git a/src/App.tsx b/src/App.tsx index 7d98966..b18641a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,8 @@ import type { Message } from './hooks/useOllama'; import { useConversationHistory } from './hooks/useConversationHistory'; import { ConversationView } from './view/ConversationView'; import { AskBarView, MAX_IMAGES } from './view/AskBarView'; -import { OnboardingView } from './view/OnboardingView'; +import { OnboardingView } from './view/onboarding/index'; +import type { OnboardingStage } from './view/onboarding/index'; import { HistoryPanel } from './components/HistoryPanel'; import { ImagePreviewModal } from './components/ImagePreviewModal'; import type { AttachedImage } from './types/image'; @@ -62,8 +63,9 @@ type OverlayState = 'visible' | 'hidden' | 'hiding'; function App() { const [query, setQuery] = useState(''); const [overlayState, setOverlayState] = useState('hidden'); - /** True once the backend signals that one or more permissions are missing. */ - const [showOnboarding, setShowOnboarding] = useState(false); + /** Non-null when the backend signals onboarding is needed; holds the current stage. */ + const [onboardingStage, setOnboardingStage] = + useState(null); /** * Whether the ask-bar history panel is currently open. @@ -1025,9 +1027,12 @@ function App() { requestHideOverlay(); }, ); - unlistenOnboarding = await listen(ONBOARDING_EVENT, () => { - setShowOnboarding(true); - }); + unlistenOnboarding = await listen<{ stage: OnboardingStage }>( + ONBOARDING_EVENT, + ({ payload }) => { + setOnboardingStage(payload.stage); + }, + ); // Both listeners registered — safe to let Rust decide what to show on launch. await invoke('notify_frontend_ready'); }; @@ -1140,8 +1145,8 @@ function App() { ); }, []); - if (showOnboarding) { - return ; + if (onboardingStage !== null) { + return ; } return ( diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index a27fcce..f6025bf 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -3219,7 +3219,7 @@ describe('App', () => { await act(async () => {}); await act(async () => { - emitTauriEvent('thuki://onboarding', undefined); + emitTauriEvent('thuki://onboarding', { stage: 'permissions' }); }); expect(screen.getByText("Let's get Thuki set up")).toBeInTheDocument(); diff --git a/src/__tests__/OnboardingView.test.tsx b/src/__tests__/OnboardingView.test.tsx index 46f93c8..8baefbd 100644 --- a/src/__tests__/OnboardingView.test.tsx +++ b/src/__tests__/OnboardingView.test.tsx @@ -1,6 +1,6 @@ import { render, screen, fireEvent, act } from '@testing-library/react'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { OnboardingView } from '../view/OnboardingView'; +import { PermissionsStep } from '../view/onboarding/PermissionsStep'; import { invoke } from '../testUtils/mocks/tauri'; describe('OnboardingView', () => { @@ -25,7 +25,7 @@ describe('OnboardingView', () => { it('shows step 1 as active when accessibility is not granted', async () => { setupPermissions(false); - render(); + render(); await act(async () => {}); expect(screen.getByText('Accessibility')).toBeInTheDocument(); @@ -36,7 +36,7 @@ describe('OnboardingView', () => { it('shows the onboarding title', async () => { setupPermissions(false); - render(); + render(); await act(async () => {}); expect(screen.getByText("Let's get Thuki set up")).toBeInTheDocument(); @@ -44,7 +44,7 @@ describe('OnboardingView', () => { it('skips to step 2 when accessibility is already granted on mount', async () => { setupPermissions(true); - render(); + render(); await act(async () => {}); expect( @@ -57,7 +57,7 @@ describe('OnboardingView', () => { it('clicking grant accessibility invokes request command', async () => { setupPermissions(false); - render(); + render(); await act(async () => {}); await act(async () => { @@ -71,7 +71,7 @@ describe('OnboardingView', () => { it('shows spinner while polling after grant request', async () => { setupPermissions(false); - render(); + render(); await act(async () => {}); await act(async () => { @@ -95,7 +95,7 @@ describe('OnboardingView', () => { if (cmd === 'open_accessibility_settings') return; }); - render(); + render(); await act(async () => {}); await act(async () => { @@ -134,7 +134,7 @@ describe('OnboardingView', () => { if (cmd === 'open_accessibility_settings') return; }); - render(); + render(); await act(async () => {}); // Click grant @@ -166,7 +166,7 @@ describe('OnboardingView', () => { if (cmd === 'open_accessibility_settings') return; }); - render(); + render(); await act(async () => {}); await act(async () => { @@ -185,7 +185,7 @@ describe('OnboardingView', () => { it('clicking open screen recording settings registers app and opens settings', async () => { setupPermissions(true); - render(); + render(); await act(async () => {}); await act(async () => { @@ -201,7 +201,7 @@ describe('OnboardingView', () => { it('shows spinner while polling after opening screen recording settings', async () => { setupPermissions(true); - render(); + render(); await act(async () => {}); await act(async () => { @@ -219,7 +219,7 @@ describe('OnboardingView', () => { it('does not show quit and reopen immediately after clicking screen recording button', async () => { setupPermissions(true); - render(); + render(); await act(async () => {}); await act(async () => { @@ -242,7 +242,7 @@ describe('OnboardingView', () => { if (cmd === 'check_screen_recording_tcc_granted') return tccGranted; }); - render(); + render(); await act(async () => {}); await act(async () => { @@ -278,7 +278,7 @@ describe('OnboardingView', () => { if (cmd === 'check_screen_recording_tcc_granted') return true; }); - render(); + render(); await act(async () => {}); await act(async () => { @@ -305,7 +305,7 @@ describe('OnboardingView', () => { if (cmd === 'check_screen_recording_tcc_granted') return true; }); - render(); + render(); await act(async () => {}); await act(async () => { @@ -327,7 +327,7 @@ describe('OnboardingView', () => { it('shows screen recording step info', async () => { setupPermissions(true); - render(); + render(); await act(async () => {}); expect(screen.getByText('Screen Recording')).toBeInTheDocument(); @@ -335,7 +335,7 @@ describe('OnboardingView', () => { it('shows both steps regardless of current active step', async () => { setupPermissions(false); - render(); + render(); await act(async () => {}); expect(screen.getByText('Accessibility')).toBeInTheDocument(); @@ -346,7 +346,7 @@ describe('OnboardingView', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); setupPermissions(false); - const { unmount } = render(); + const { unmount } = render(); await act(async () => {}); await act(async () => { @@ -377,7 +377,7 @@ describe('OnboardingView', () => { if (cmd === 'check_screen_recording_tcc_granted') return false; }); - const { unmount } = render(); + const { unmount } = render(); await act(async () => {}); await act(async () => { @@ -398,7 +398,7 @@ describe('OnboardingView', () => { it('hovering the CTA button applies brightness filter when enabled', async () => { setupPermissions(false); - render(); + render(); await act(async () => {}); const btn = screen.getByRole('button', { name: /grant accessibility/i }); @@ -412,7 +412,7 @@ describe('OnboardingView', () => { it('hovering a disabled CTA button does not apply brightness filter', async () => { setupPermissions(false); - render(); + render(); await act(async () => {}); await act(async () => { @@ -451,7 +451,7 @@ describe('OnboardingView', () => { return Promise.resolve(); }); - const { unmount } = render(); + const { unmount } = render(); // useEffect has fired; initial invoke is in-flight (resolveInitial is set). act(() => unmount()); // mountedRef → false @@ -479,7 +479,7 @@ describe('OnboardingView', () => { return Promise.resolve(); }); - render(); + render(); await act(async () => {}); // initial check done await act(async () => { @@ -520,7 +520,7 @@ describe('OnboardingView', () => { return Promise.resolve(); }); - const { unmount } = render(); + const { unmount } = render(); await act(async () => {}); await act(async () => { @@ -561,7 +561,7 @@ describe('OnboardingView', () => { return Promise.resolve(); }); - const { unmount } = render(); + const { unmount } = render(); await act(async () => {}); // accessibility granted // Flush microtasks so the handler advances past the first await @@ -600,7 +600,7 @@ describe('OnboardingView', () => { return Promise.resolve(); }); - render(); + render(); await act(async () => {}); await act(async () => { @@ -636,7 +636,7 @@ describe('OnboardingView', () => { return Promise.resolve(); }); - const { unmount } = render(); + const { unmount } = render(); await act(async () => {}); await act(async () => { diff --git a/src/view/OnboardingView.tsx b/src/view/OnboardingView.tsx index 495cb60..8e0fe4d 100644 --- a/src/view/OnboardingView.tsx +++ b/src/view/OnboardingView.tsx @@ -1,612 +1,4 @@ -import { motion } from 'framer-motion'; -import type React from 'react'; -import { useState, useEffect, useRef, useCallback } from 'react'; -import { invoke } from '@tauri-apps/api/core'; -import thukiLogo from '../../src-tauri/icons/128x128.png'; - -/** How often to poll for permission grants after the user requests them. */ -const POLL_INTERVAL_MS = 500; - -type AccessibilityStatus = 'pending' | 'requesting' | 'granted'; -type ScreenRecordingStatus = 'idle' | 'polling' | 'granted'; - -/** Inline macOS-style keyboard key chip for showing hotkey symbols. */ -const KeyChip = ({ label }: { label: string }) => ( - - {label} - -); - -/** Checkmark icon for the granted step state. */ -const CheckIcon = () => ( - -); - -/** Keyboard/accessibility icon for the active step 1. */ -const KeyboardIcon = () => ( - -); - -/** Screen/camera icon for step 2. */ -const ScreenIcon = ({ active }: { active: boolean }) => ( - -); - -/** Minimal animated spinner. */ -const Spinner = () => ( - - - - - -); - -/** - * Onboarding screen shown at first launch when required macOS permissions - * (Accessibility and Screen Recording) have not yet been granted. - * - * Follows a sequential flow: Accessibility first (polls until granted, - * no restart needed), then Screen Recording (registers app via - * CGRequestScreenCaptureAccess, polls TCC until granted, then prompts - * quit+reopen since macOS requires a restart for the permission to take effect). - * - * Visual direction: Warm Ambient — dark base with a warm orange radial glow. - * The outer container is transparent so the rounded panel corners are visible - * against the macOS desktop. - */ -export function OnboardingView() { - const [accessibilityStatus, setAccessibilityStatus] = - useState('pending'); - const [screenRecordingStatus, setScreenRecordingStatus] = - useState('idle'); - const axPollRef = useRef | null>(null); - const screenPollRef = useRef | null>(null); - // Guards that prevent a new poll tick from firing while a previous invoke - // call is still in-flight. Without these, a slow IPC response (> POLL_INTERVAL_MS) - // could queue multiple concurrent permission checks. - const axInFlightRef = useRef(false); - const screenInFlightRef = useRef(false); - // Prevents state updates from resolving in-flight invocations after unmount. - const mountedRef = useRef(true); - - const stopAxPolling = useCallback(() => { - if (axPollRef.current !== null) { - clearInterval(axPollRef.current); - axPollRef.current = null; - } - }, []); - - const stopScreenPolling = useCallback(() => { - if (screenPollRef.current !== null) { - clearInterval(screenPollRef.current); - screenPollRef.current = null; - } - }, []); - - // On mount: check whether Accessibility is already granted so we can skip - // step 1 and show step 2 immediately. - useEffect(() => { - // Reset on every mount so that a remount after unmount gets a fresh guard. - mountedRef.current = true; - void invoke('check_accessibility_permission').then((granted) => { - if (!mountedRef.current) return; - if (granted) { - setAccessibilityStatus('granted'); - } - }); - return () => { - mountedRef.current = false; - stopAxPolling(); - stopScreenPolling(); - }; - }, [stopAxPolling, stopScreenPolling]); - - const handleGrantAccessibility = useCallback(async () => { - setAccessibilityStatus('requesting'); - await invoke('open_accessibility_settings'); - axPollRef.current = setInterval(async () => { - if (axInFlightRef.current) return; - axInFlightRef.current = true; - try { - const granted = await invoke('check_accessibility_permission'); - if (!mountedRef.current) return; - if (granted) { - stopAxPolling(); - setAccessibilityStatus('granted'); - } - } finally { - axInFlightRef.current = false; - } - }, POLL_INTERVAL_MS); - }, [stopAxPolling]); - - const handleOpenScreenRecording = useCallback(async () => { - // Register Thuki in TCC (adds it to the Screen Recording list) then open - // System Settings directly so the user can toggle it on without hunting. - // The registration call may briefly show a macOS system prompt on first use. - await invoke('request_screen_recording_access'); - await invoke('open_screen_recording_settings'); - if (!mountedRef.current) return; - setScreenRecordingStatus('polling'); - screenPollRef.current = setInterval(async () => { - if (screenInFlightRef.current) return; - screenInFlightRef.current = true; - try { - const granted = await invoke( - 'check_screen_recording_tcc_granted', - ); - if (!mountedRef.current) return; - if (granted) { - stopScreenPolling(); - setScreenRecordingStatus('granted'); - } - } finally { - screenInFlightRef.current = false; - } - }, POLL_INTERVAL_MS); - }, [stopScreenPolling]); - - const handleQuitAndRelaunch = useCallback(async () => { - await invoke('quit_and_relaunch'); - }, []); - - const accessibilityGranted = accessibilityStatus === 'granted'; - const isAxRequesting = accessibilityStatus === 'requesting'; - const isScreenPolling = screenRecordingStatus === 'polling'; - const screenGranted = screenRecordingStatus === 'granted'; - - return ( - // Transparent outer container so the rounded panel corners show through - // against the macOS desktop (window has transparent: true in tauri.conf.json). -
- - {/* Top edge highlight */} -
- - {/* Logo mark + title — drag region so the user can reposition the - onboarding window when it overlaps System Settings. */} -
- Thuki -
- - {/* Title */} -

- {"Let's get Thuki set up"} -

- - {/* Steps */} -
- {/* Step 1: Accessibility */} - -
- {accessibilityGranted ? : } -
-
-
- Accessibility -
-
- Lets Thuki respond to activator key ( - ) -
-
- {accessibilityGranted && ( -
- Granted -
- )} -
- - {/* Step 2: Screen Recording */} - -
- -
-
-
- Screen Recording -
-
- Needed for /screen to capture your entire screen -
-
-
-
- - {/* Step 1 CTA: Grant Accessibility */} - {!accessibilityGranted && ( - - {isAxRequesting ? 'Checking...' : 'Grant Accessibility Access'} - - )} - - {/* Step 2 CTAs: Open Settings (with polling) + Quit & Reopen */} - {accessibilityGranted && ( - <> - {!screenGranted && ( - - {isScreenPolling - ? 'Checking...' - : 'Open Screen Recording Settings'} - - )} - {screenGranted && ( - <> - - Quit & Reopen Thuki - -

- macOS requires a restart for Screen Recording to take effect -

- - )} - - )} - -
- ); -} - -// ─── Sub-components ───────────────────────────────────────────────────────── - -interface CTAButtonProps { - onClick?: React.MouseEventHandler; - disabled?: boolean; - 'aria-label'?: string; - loading?: boolean; - children: React.ReactNode; -} - -/** Primary action button with a subtle lift-and-brighten hover effect. */ -function CTAButton({ - onClick, - disabled, - 'aria-label': ariaLabel, - loading, - children, -}: CTAButtonProps) { - const [hovered, setHovered] = useState(false); - - const isDisabled = disabled || loading; - - return ( - - ); -} - -interface StepCardProps { - active: boolean; - done: boolean; - children: React.ReactNode; -} - -function StepCard({ active, done, children }: StepCardProps) { - const borderColor = done - ? 'rgba(34,197,94,0.2)' - : active - ? 'rgba(255,141,92,0.4)' - : 'rgba(255,255,255,0.06)'; - - const background = done - ? 'rgba(34,197,94,0.05)' - : active - ? 'rgba(255,141,92,0.07)' - : 'rgba(255,255,255,0.03)'; - - return ( -
- {children} -
- ); -} - -interface BadgeProps { - color: 'green'; - children: React.ReactNode; -} - -function Badge({ color, children }: BadgeProps) { - const styles: Record = { - green: { - color: '#22c55e', - background: 'rgba(34,197,94,0.1)', - border: '1px solid rgba(34,197,94,0.2)', - }, - }; - - return ( - - {children} - - ); -} +// Re-export from the onboarding module for backwards compatibility. +// App.tsx will be updated in the next step to import directly. +export { OnboardingView } from './onboarding/index'; +export type { OnboardingStage } from './onboarding/index'; diff --git a/src/view/onboarding/IntroStep.tsx b/src/view/onboarding/IntroStep.tsx new file mode 100644 index 0000000..0b80fd0 --- /dev/null +++ b/src/view/onboarding/IntroStep.tsx @@ -0,0 +1,405 @@ +import { motion } from 'framer-motion'; +import { invoke } from '@tauri-apps/api/core'; +import thukiLogo from '../../../src-tauri/icons/128x128.png'; + +interface Props { + onComplete: () => void; +} + +export function IntroStep({ onComplete }: Props) { + const handleGetStarted = async () => { + await invoke('finish_onboarding'); + onComplete(); + }; + + return ( +
+ + {/* Logo */} + Thuki + + {/* Header */} +
+

+ Good to know +

+

+ {"You'll get the hang of it quickly."} +

+
+ + {/* Facts */} +
+ } + title={ + <> + Double-tap {' '} + to summon + + } + desc="Press Control twice from any app, any time" + /> + } + title={ + <> + Select text, then double-tap + + } + desc="It opens with your selection already quoted as context" + /> + } + title="Drop in any image" + desc="Paste, drag, or clip a screenshot straight into the conversation." + /> + } + title={ + <> + Type /screen{' '} + for context + + } + desc="Captures your display so Thuki can see what you see" + /> + } + title="Floats above everything" + desc="No app switching, no broken flow. Summon it, use it, toss the chat and get straight back to work." + last + /> +
+ + {/* Divider */} +
+ + {/* CTA */} + + + {/* Footer */} +

+ Private by default · All inference runs on your machine +

+ +
+ ); +} + +// ─── Sub-components ────────────────────────────────────────────────────────── + +interface FactProps { + icon: React.ReactNode; + title: React.ReactNode; + desc: string; + last?: boolean; +} + +function Fact({ icon, title, desc, last = false }: FactProps) { + return ( +
+
+ {icon} +
+
+
+ {title} +
+
+ {desc} +
+
+
+ ); +} + +function KeyChip({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function MonoChip({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function KeyboardIcon() { + return ( + + + + + ); +} + +function SelectionIcon() { + return ( + + + + + + + ); +} + +function ImageIcon() { + return ( + + + + + + ); +} + +function ScreenIcon() { + return ( + + + + + ); +} + +function FloatIcon() { + return ( + + + + + ); +} diff --git a/src/view/onboarding/PermissionsStep.tsx b/src/view/onboarding/PermissionsStep.tsx new file mode 100644 index 0000000..406c780 --- /dev/null +++ b/src/view/onboarding/PermissionsStep.tsx @@ -0,0 +1,612 @@ +import { motion } from 'framer-motion'; +import type React from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import thukiLogo from '../../../src-tauri/icons/128x128.png'; + +/** How often to poll for permission grants after the user requests them. */ +const POLL_INTERVAL_MS = 500; + +type AccessibilityStatus = 'pending' | 'requesting' | 'granted'; +type ScreenRecordingStatus = 'idle' | 'polling' | 'granted'; + +/** Inline macOS-style keyboard key chip for showing hotkey symbols. */ +const KeyChip = ({ label }: { label: string }) => ( + + {label} + +); + +/** Checkmark icon for the granted step state. */ +const CheckIcon = () => ( + +); + +/** Keyboard/accessibility icon for the active step 1. */ +const KeyboardIcon = () => ( + +); + +/** Screen/camera icon for step 2. */ +const ScreenIcon = ({ active }: { active: boolean }) => ( + +); + +/** Minimal animated spinner. */ +const Spinner = () => ( + + + + + +); + +/** + * Onboarding screen shown at first launch when required macOS permissions + * (Accessibility and Screen Recording) have not yet been granted. + * + * Follows a sequential flow: Accessibility first (polls until granted, + * no restart needed), then Screen Recording (registers app via + * CGRequestScreenCaptureAccess, polls TCC until granted, then prompts + * quit+reopen since macOS requires a restart for the permission to take effect). + * + * Visual direction: Warm Ambient — dark base with a warm orange radial glow. + * The outer container is transparent so the rounded panel corners are visible + * against the macOS desktop. + */ +export function PermissionsStep() { + const [accessibilityStatus, setAccessibilityStatus] = + useState('pending'); + const [screenRecordingStatus, setScreenRecordingStatus] = + useState('idle'); + const axPollRef = useRef | null>(null); + const screenPollRef = useRef | null>(null); + // Guards that prevent a new poll tick from firing while a previous invoke + // call is still in-flight. Without these, a slow IPC response (> POLL_INTERVAL_MS) + // could queue multiple concurrent permission checks. + const axInFlightRef = useRef(false); + const screenInFlightRef = useRef(false); + // Prevents state updates from resolving in-flight invocations after unmount. + const mountedRef = useRef(true); + + const stopAxPolling = useCallback(() => { + if (axPollRef.current !== null) { + clearInterval(axPollRef.current); + axPollRef.current = null; + } + }, []); + + const stopScreenPolling = useCallback(() => { + if (screenPollRef.current !== null) { + clearInterval(screenPollRef.current); + screenPollRef.current = null; + } + }, []); + + // On mount: check whether Accessibility is already granted so we can skip + // step 1 and show step 2 immediately. + useEffect(() => { + // Reset on every mount so that a remount after unmount gets a fresh guard. + mountedRef.current = true; + void invoke('check_accessibility_permission').then((granted) => { + if (!mountedRef.current) return; + if (granted) { + setAccessibilityStatus('granted'); + } + }); + return () => { + mountedRef.current = false; + stopAxPolling(); + stopScreenPolling(); + }; + }, [stopAxPolling, stopScreenPolling]); + + const handleGrantAccessibility = useCallback(async () => { + setAccessibilityStatus('requesting'); + await invoke('open_accessibility_settings'); + axPollRef.current = setInterval(async () => { + if (axInFlightRef.current) return; + axInFlightRef.current = true; + try { + const granted = await invoke('check_accessibility_permission'); + if (!mountedRef.current) return; + if (granted) { + stopAxPolling(); + setAccessibilityStatus('granted'); + } + } finally { + axInFlightRef.current = false; + } + }, POLL_INTERVAL_MS); + }, [stopAxPolling]); + + const handleOpenScreenRecording = useCallback(async () => { + // Register Thuki in TCC (adds it to the Screen Recording list) then open + // System Settings directly so the user can toggle it on without hunting. + // The registration call may briefly show a macOS system prompt on first use. + await invoke('request_screen_recording_access'); + await invoke('open_screen_recording_settings'); + if (!mountedRef.current) return; + setScreenRecordingStatus('polling'); + screenPollRef.current = setInterval(async () => { + if (screenInFlightRef.current) return; + screenInFlightRef.current = true; + try { + const granted = await invoke( + 'check_screen_recording_tcc_granted', + ); + if (!mountedRef.current) return; + if (granted) { + stopScreenPolling(); + setScreenRecordingStatus('granted'); + } + } finally { + screenInFlightRef.current = false; + } + }, POLL_INTERVAL_MS); + }, [stopScreenPolling]); + + const handleQuitAndRelaunch = useCallback(async () => { + await invoke('quit_and_relaunch'); + }, []); + + const accessibilityGranted = accessibilityStatus === 'granted'; + const isAxRequesting = accessibilityStatus === 'requesting'; + const isScreenPolling = screenRecordingStatus === 'polling'; + const screenGranted = screenRecordingStatus === 'granted'; + + return ( + // Transparent outer container so the rounded panel corners show through + // against the macOS desktop (window has transparent: true in tauri.conf.json). +
+ + {/* Top edge highlight */} +
+ + {/* Logo mark + title — drag region so the user can reposition the + onboarding window when it overlaps System Settings. */} +
+ Thuki +
+ + {/* Title */} +

+ {"Let's get Thuki set up"} +

+ + {/* Steps */} +
+ {/* Step 1: Accessibility */} + +
+ {accessibilityGranted ? : } +
+
+
+ Accessibility +
+
+ Lets Thuki respond to activator key ( + ) +
+
+ {accessibilityGranted && ( +
+ Granted +
+ )} +
+ + {/* Step 2: Screen Recording */} + +
+ +
+
+
+ Screen Recording +
+
+ Needed for /screen to capture your entire screen +
+
+
+
+ + {/* Step 1 CTA: Grant Accessibility */} + {!accessibilityGranted && ( + + {isAxRequesting ? 'Checking...' : 'Grant Accessibility Access'} + + )} + + {/* Step 2 CTAs: Open Settings (with polling) + Quit & Reopen */} + {accessibilityGranted && ( + <> + {!screenGranted && ( + + {isScreenPolling + ? 'Checking...' + : 'Open Screen Recording Settings'} + + )} + {screenGranted && ( + <> + + Quit & Reopen Thuki + +

+ macOS requires a restart for Screen Recording to take effect +

+ + )} + + )} + +
+ ); +} + +// ─── Sub-components ───────────────────────────────────────────────────────── + +interface CTAButtonProps { + onClick?: React.MouseEventHandler; + disabled?: boolean; + 'aria-label'?: string; + loading?: boolean; + children: React.ReactNode; +} + +/** Primary action button with a subtle lift-and-brighten hover effect. */ +function CTAButton({ + onClick, + disabled, + 'aria-label': ariaLabel, + loading, + children, +}: CTAButtonProps) { + const [hovered, setHovered] = useState(false); + + const isDisabled = disabled || loading; + + return ( + + ); +} + +interface StepCardProps { + active: boolean; + done: boolean; + children: React.ReactNode; +} + +function StepCard({ active, done, children }: StepCardProps) { + const borderColor = done + ? 'rgba(34,197,94,0.2)' + : active + ? 'rgba(255,141,92,0.4)' + : 'rgba(255,255,255,0.06)'; + + const background = done + ? 'rgba(34,197,94,0.05)' + : active + ? 'rgba(255,141,92,0.07)' + : 'rgba(255,255,255,0.03)'; + + return ( +
+ {children} +
+ ); +} + +interface BadgeProps { + color: 'green'; + children: React.ReactNode; +} + +function Badge({ color, children }: BadgeProps) { + const styles: Record = { + green: { + color: '#22c55e', + background: 'rgba(34,197,94,0.1)', + border: '1px solid rgba(34,197,94,0.2)', + }, + }; + + return ( + + {children} + + ); +} diff --git a/src/view/onboarding/__tests__/IntroStep.test.tsx b/src/view/onboarding/__tests__/IntroStep.test.tsx new file mode 100644 index 0000000..fbd56fc --- /dev/null +++ b/src/view/onboarding/__tests__/IntroStep.test.tsx @@ -0,0 +1,59 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { IntroStep } from '../IntroStep'; +import { invoke } from '../../../testUtils/mocks/tauri'; + +describe('IntroStep', () => { + beforeEach(() => { + invoke.mockClear(); + }); + + it('renders the title', () => { + render(); + expect(screen.getByText('Good to know')).toBeInTheDocument(); + }); + + it('renders the subtitle', () => { + render(); + expect( + screen.getByText("You'll get the hang of it quickly."), + ).toBeInTheDocument(); + }); + + it('renders all 5 facts', () => { + render(); + expect(screen.getByText('Double-tap')).toBeInTheDocument(); + expect(screen.getByText('to summon')).toBeInTheDocument(); + expect( + screen.getByText('Select text, then double-tap'), + ).toBeInTheDocument(); + expect(screen.getByText('Drop in any image')).toBeInTheDocument(); + expect(screen.getByText('for context')).toBeInTheDocument(); + expect(screen.getByText('Floats above everything')).toBeInTheDocument(); + }); + + it('renders the Get Started button', () => { + render(); + expect( + screen.getByRole('button', { name: /get started/i }), + ).toBeInTheDocument(); + }); + + it('renders the footer note', () => { + render(); + expect(screen.getByText(/private by default/i)).toBeInTheDocument(); + }); + + it('calls finish_onboarding and onComplete when Get Started is clicked', async () => { + const onComplete = vi.fn(); + invoke.mockResolvedValue(undefined); + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /get started/i })); + }); + + expect(invoke).toHaveBeenCalledWith('finish_onboarding'); + expect(onComplete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/view/onboarding/__tests__/index.test.tsx b/src/view/onboarding/__tests__/index.test.tsx new file mode 100644 index 0000000..7f5b520 --- /dev/null +++ b/src/view/onboarding/__tests__/index.test.tsx @@ -0,0 +1,22 @@ +import { render, screen, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { OnboardingView } from '../index'; +import { invoke } from '../../../testUtils/mocks/tauri'; + +describe('OnboardingView (orchestrator)', () => { + beforeEach(() => { + invoke.mockClear(); + invoke.mockResolvedValue(undefined); + }); + + it('renders PermissionsStep when stage is permissions', async () => { + render(); + await act(async () => {}); + expect(screen.getByText("Let's get Thuki set up")).toBeInTheDocument(); + }); + + it('renders IntroStep when stage is intro', () => { + render(); + expect(screen.getByText('Good to know')).toBeInTheDocument(); + }); +}); diff --git a/src/view/onboarding/index.tsx b/src/view/onboarding/index.tsx new file mode 100644 index 0000000..1c28607 --- /dev/null +++ b/src/view/onboarding/index.tsx @@ -0,0 +1,26 @@ +import { IntroStep } from './IntroStep'; +import { PermissionsStep } from './PermissionsStep'; + +export type OnboardingStage = 'permissions' | 'intro'; + +interface Props { + stage: OnboardingStage; +} + +/** + * Onboarding module orchestrator. + * + * Renders the correct step based on the persisted onboarding stage emitted + * by the backend at startup. The stage advances on the backend: + * + * permissions -> (quit+reopen) -> intro -> complete (normal app) + * + * When stage is "complete" the backend never emits the onboarding event, + * so this component is never rendered. + */ +export function OnboardingView({ stage }: Props) { + if (stage === 'intro') { + return {}} />; + } + return ; +} From de178dc5c43950ef10d28355fa091af437f7021b Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Tue, 7 Apr 2026 18:24:40 -0500 Subject: [PATCH 2/8] fix(onboarding): implement correct permission-gated stage machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Startup now checks permissions unconditionally on every launch: - Both missing: reset stage to "permissions", show permissions screen - Both granted + stage not "complete": advance to "intro", show intro screen - Both granted + stage "complete": show Ask Bar directly - Permission revoked after completion: resets to "permissions" flow finish_onboarding moved from commands.rs to lib.rs so it can restore NSPanel to overlay mode and call show_overlay immediately after the user clicks "Get Started" — no relaunch needed. quit_and_relaunch simplified to just app_handle.restart(); the stage stays "permissions" across the restart and startup handles the transition. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Logan Nguyen --- src-tauri/src/commands.rs | 21 +---- src-tauri/src/lib.rs | 94 +++++++++++++++----- src-tauri/src/permissions.rs | 25 ++---- src/App.tsx | 7 +- src/view/onboarding/__tests__/index.test.tsx | 6 +- src/view/onboarding/index.tsx | 5 +- 6 files changed, 90 insertions(+), 68 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6120be8..a4d1f3f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3,7 +3,7 @@ use std::sync::Mutex; use futures_util::StreamExt; use serde::{Deserialize, Serialize}; -use tauri::{ipc::Channel, Manager, State}; +use tauri::{ipc::Channel, State}; use tokio_util::sync::CancellationToken; /// Default configuration constants as the application currently lacks a Settings UI. @@ -355,25 +355,6 @@ pub fn reset_conversation(history: State<'_, ConversationHistory>) { history.messages.lock().unwrap().clear(); } -/// Marks onboarding as complete and hides the window so the next hotkey press -/// shows the normal overlay. Called by the frontend when the user clicks -/// "Get Started" on the intro screen. -#[cfg_attr(coverage_nightly, coverage(off))] -#[cfg_attr(not(coverage), tauri::command)] -pub fn finish_onboarding( - db: State<'_, crate::history::Database>, - app_handle: tauri::AppHandle, -) -> Result<(), String> { - let conn = db.0.lock().map_err(|e| format!("db lock poisoned: {e}"))?; - crate::onboarding::set_stage(&conn, &crate::onboarding::OnboardingStage::Complete) - .map_err(|e| format!("db write failed: {e}"))?; - drop(conn); - if let Some(window) = app_handle.get_webview_window("main") { - let _ = window.hide(); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9b88a10..8eb1a71 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -378,26 +378,39 @@ fn notify_frontend_ready(app_handle: tauri::AppHandle, db: tauri::State { + // Onboarding finished previously — fall through to + // show the normal overlay. + } + _ => { + // Stage is "permissions" (first launch after grant) + // or "intro" (resumed). Advance to intro and show it. + let _ = onboarding::set_stage(&conn, &onboarding::OnboardingStage::Intro); + show_onboarding_window(&app_handle, onboarding::OnboardingStage::Intro); + return; + } + } + } else { + // Mutex poisoned — safe fallback. + show_onboarding_window(&app_handle, onboarding::OnboardingStage::Permissions); return; } } @@ -405,6 +418,44 @@ fn notify_frontend_ready(app_handle: tauri::AppHandle, db: tauri::State, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + let conn = db.0.lock().map_err(|e| format!("db lock poisoned: {e}"))?; + onboarding::set_stage(&conn, &onboarding::OnboardingStage::Complete) + .map_err(|e| format!("db write failed: {e}"))?; + drop(conn); + + // Restore panel to overlay configuration and show the Ask Bar. + // Must run on the macOS main thread because NSPanel APIs are not thread-safe. + let handle = app_handle.clone(); + let _ = app_handle.run_on_main_thread(move || { + // Resize the window back to the collapsed overlay dimensions before + // positioning, so the overlay appears at the correct size. + if let Some(window) = handle.get_webview_window("main") { + let _ = window.set_size(tauri::Size::Logical(tauri::LogicalSize::new( + OVERLAY_LOGICAL_WIDTH, + OVERLAY_LOGICAL_HEIGHT_COLLAPSED, + ))); + } + // Restore NSPanel level, shadow, and style that show_onboarding_window + // changed for the onboarding appearance. + #[cfg(target_os = "macos")] + init_panel(&handle); + show_overlay(&handle, crate::context::ActivationContext::empty()); + }); + + Ok(()) +} + // ─── NSPanel initialisation ───────────────────────────────────────────────── /// Converts the main Tauri window into an NSPanel and applies the overlay @@ -699,8 +750,7 @@ pub fn run() { permissions::check_screen_recording_tcc_granted, #[cfg(not(coverage))] permissions::quit_and_relaunch, - #[cfg(not(coverage))] - commands::finish_onboarding + finish_onboarding ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/src-tauri/src/permissions.rs b/src-tauri/src/permissions.rs index 6250cde..b49f97f 100644 --- a/src-tauri/src/permissions.rs +++ b/src-tauri/src/permissions.rs @@ -168,26 +168,11 @@ pub fn check_screen_recording_tcc_granted() -> bool { #[tauri::command] #[cfg(target_os = "macos")] #[cfg_attr(coverage_nightly, coverage(off))] -pub fn quit_and_relaunch( - app_handle: tauri::AppHandle, - db: tauri::State<'_, crate::history::Database>, -) { - match db.0.lock() { - Ok(conn) => { - match crate::onboarding::set_stage(&conn, &crate::onboarding::OnboardingStage::Intro) { - Ok(()) => { - // Force WAL checkpoint so the write survives the - // std::process::exit(0) inside app_handle.restart(), - // which skips destructors and may leave uncheckpointed - // WAL pages in the OS page cache. - let _ = conn.execute_batch("PRAGMA wal_checkpoint(FULL);"); - eprintln!("[thuki] quit_and_relaunch: stage advanced to intro (checkpointed)"); - } - Err(e) => eprintln!("[thuki] quit_and_relaunch: db write failed: {e}"), - } - } - Err(e) => eprintln!("[thuki] quit_and_relaunch: mutex poisoned: {e}"), - } +pub fn quit_and_relaunch(app_handle: tauri::AppHandle) { + // No DB write needed here. The onboarding stage remains "permissions" + // across the restart. On the next launch, notify_frontend_ready detects + // that both permissions are now granted and stage is still "permissions", + // advances the stage to "intro", and shows the intro screen. app_handle.restart(); } diff --git a/src/App.tsx b/src/App.tsx index b18641a..7ccdb1c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1146,7 +1146,12 @@ function App() { }, []); if (onboardingStage !== null) { - return ; + return ( + setOnboardingStage(null)} + /> + ); } return ( diff --git a/src/view/onboarding/__tests__/index.test.tsx b/src/view/onboarding/__tests__/index.test.tsx index 7f5b520..1f35ff8 100644 --- a/src/view/onboarding/__tests__/index.test.tsx +++ b/src/view/onboarding/__tests__/index.test.tsx @@ -1,5 +1,5 @@ import { render, screen, act } from '@testing-library/react'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { OnboardingView } from '../index'; import { invoke } from '../../../testUtils/mocks/tauri'; @@ -10,13 +10,13 @@ describe('OnboardingView (orchestrator)', () => { }); it('renders PermissionsStep when stage is permissions', async () => { - render(); + render(); await act(async () => {}); expect(screen.getByText("Let's get Thuki set up")).toBeInTheDocument(); }); it('renders IntroStep when stage is intro', () => { - render(); + render(); expect(screen.getByText('Good to know')).toBeInTheDocument(); }); }); diff --git a/src/view/onboarding/index.tsx b/src/view/onboarding/index.tsx index 1c28607..9b2987e 100644 --- a/src/view/onboarding/index.tsx +++ b/src/view/onboarding/index.tsx @@ -5,6 +5,7 @@ export type OnboardingStage = 'permissions' | 'intro'; interface Props { stage: OnboardingStage; + onComplete: () => void; } /** @@ -18,9 +19,9 @@ interface Props { * When stage is "complete" the backend never emits the onboarding event, * so this component is never rendered. */ -export function OnboardingView({ stage }: Props) { +export function OnboardingView({ stage, onComplete }: Props) { if (stage === 'intro') { - return {}} />; + return ; } return ; } From 4a22d6e4f40323afac86be8e7579ce3196c878fe Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Tue, 7 Apr 2026 18:31:50 -0500 Subject: [PATCH 3/8] style(onboarding): align intro screen style with permissions screen Match card background, border, border-radius, padding, and box-shadow to PermissionsStep for a consistent onboarding UI. Rename title from "Good to know" to "Before you dive in". Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Logan Nguyen --- src/view/onboarding/IntroStep.tsx | 12 ++++++------ src/view/onboarding/__tests__/IntroStep.test.tsx | 2 +- src/view/onboarding/__tests__/index.test.tsx | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/view/onboarding/IntroStep.tsx b/src/view/onboarding/IntroStep.tsx index 0b80fd0..eaefa5e 100644 --- a/src/view/onboarding/IntroStep.tsx +++ b/src/view/onboarding/IntroStep.tsx @@ -30,11 +30,11 @@ export function IntroStep({ onComplete }: Props) { style={{ width: 420, background: - 'radial-gradient(ellipse 70% 45% at 50% 0%, rgba(255,141,92,0.10) 0%, transparent 65%), rgba(22,18,15,0.98)', - border: '1px solid rgba(255,255,255,0.07)', - borderTop: '1px solid rgba(255,141,92,0.18)', - borderRadius: 20, - padding: '28px 32px 24px', + 'radial-gradient(ellipse 80% 55% at 50% 0%, rgba(255,141,92,0.14) 0%, rgba(28,24,20,0.97) 60%), rgba(28,24,20,0.97)', + border: '1px solid rgba(255, 141, 92, 0.2)', + borderRadius: 24, + padding: '32px 26px 26px', + boxShadow: '0 0 40px rgba(255,100,40,0.07)', position: 'relative', }} > @@ -64,7 +64,7 @@ export function IntroStep({ onComplete }: Props) { margin: '0 0 6px', }} > - Good to know + Before you dive in

{ it('renders the title', () => { render(); - expect(screen.getByText('Good to know')).toBeInTheDocument(); + expect(screen.getByText('Before you dive in')).toBeInTheDocument(); }); it('renders the subtitle', () => { diff --git a/src/view/onboarding/__tests__/index.test.tsx b/src/view/onboarding/__tests__/index.test.tsx index 1f35ff8..751fe40 100644 --- a/src/view/onboarding/__tests__/index.test.tsx +++ b/src/view/onboarding/__tests__/index.test.tsx @@ -17,6 +17,6 @@ describe('OnboardingView (orchestrator)', () => { it('renders IntroStep when stage is intro', () => { render(); - expect(screen.getByText('Good to know')).toBeInTheDocument(); + expect(screen.getByText('Before you dive in')).toBeInTheDocument(); }); }); From e634285e872740a47c4d3713b07c1045899e7043 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Tue, 7 Apr 2026 20:55:42 -0500 Subject: [PATCH 4/8] fix(permissions): replace CGWindowListCopyWindowInfo false positive with CGPreflightScreenCaptureAccess The screen recording permission check was returning true immediately after opening System Settings, before the user actually granted access. Root cause: CGWindowListCopyWindowInfo(0, 0) with option 0 always returns a non-null array because it includes the caller's own windows, making it useless as a TCC permission check. Fix: check_screen_recording_tcc_granted now delegates to is_screen_recording_granted (CGPreflightScreenCaptureAccess), a genuine TCC snapshot that correctly returns false when not granted. The startup check in notify_frontend_ready also switched to is_screen_recording_granted, reserving the faster path for launch. Removed the ObjC/ScreenCaptureKit compilation path (screen_recording_check.m) that was attempted as an alternative: SCShareableContent.getWithCompletionHandler does not resolve under the macOS 26 SDK used by this build environment. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Logan Nguyen --- src-tauri/build.rs | 1 + src-tauri/src/lib.rs | 8 +++++++- src-tauri/src/permissions.rs | 32 +++++++------------------------- 3 files changed, 15 insertions(+), 26 deletions(-) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 09a6266..051614a 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -2,5 +2,6 @@ fn main() { // Register cfg flags set by cargo-llvm-cov so rustc doesn't warn about unknown cfgs. println!("cargo::rustc-check-cfg=cfg(coverage)"); println!("cargo::rustc-check-cfg=cfg(coverage_nightly)"); + tauri_build::build() } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8eb1a71..662bc71 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -379,7 +379,13 @@ fn notify_frontend_ready(app_handle: tauri::AppHandle, db: tauri::State bool { - use std::ffi::c_void; - #[link(name = "CoreGraphics", kind = "framework")] - extern "C" { - // option: kCGWindowListOptionAll = 0, relativeToWindow: kCGNullWindowID = 0 - fn CGWindowListCopyWindowInfo(option: u32, relative_to_window: u32) -> *const c_void; - } - #[link(name = "CoreFoundation", kind = "framework")] - extern "C" { - fn CFRelease(cf: *const c_void); - } - unsafe { - let list = CGWindowListCopyWindowInfo(0, 0); - if list.is_null() { - return false; - } - CFRelease(list); - true - } + is_screen_recording_granted() } /// Quits Thuki and immediately relaunches it. From c66575ec57c920a7a51b10bae1bf5de388be0d94 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Tue, 7 Apr 2026 21:20:32 -0500 Subject: [PATCH 5/8] fix(onboarding): address code review: trust DB at startup, not live permission APIs Three issues from code review on PR #65: 1. notify_frontend_ready now calls onboarding::compute_startup_stage instead of CGPreflightScreenCaptureAccess. Permission APIs return stale results immediately after a process restart on macOS 15+, which could trap users in an infinite permissions loop. The DB stage is the single source of truth at startup. 2. quit_and_relaunch now writes "intro" to the DB before restarting. compute_startup_stage reads this on the next launch and shows the intro screen without needing to call any permission API. The previous approach relied on the live permission check (unreliable post-restart) to advance the stage; the DB write eliminates that dependency. 3. finish_onboarding now delegates the DB write to onboarding::mark_complete, a testable pure function, making the Tauri command a thin wrapper as required by CLAUDE.md. Two new tests cover mark_complete. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 54 +++++++++++++----------------------- src-tauri/src/onboarding.rs | 34 +++++++++++++++++++---- src-tauri/src/permissions.rs | 18 ++++++------ 3 files changed, 58 insertions(+), 48 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 662bc71..ca8a2b9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -378,44 +378,31 @@ fn notify_frontend_ready(app_handle: tauri::AppHandle, db: tauri::State { - // Onboarding finished previously — fall through to - // show the normal overlay. + match onboarding::compute_startup_stage(&conn) { + Ok(Some(stage)) => { + show_onboarding_window(&app_handle, stage); + return; } - _ => { - // Stage is "permissions" (first launch after grant) - // or "intro" (resumed). Advance to intro and show it. - let _ = onboarding::set_stage(&conn, &onboarding::OnboardingStage::Intro); - show_onboarding_window(&app_handle, onboarding::OnboardingStage::Intro); + Ok(None) => {} // Complete: fall through to show the overlay. + Err(_) => { + // DB read failed; fall back to the permissions screen. + show_onboarding_window( + &app_handle, + onboarding::OnboardingStage::Permissions, + ); return; } } } else { - // Mutex poisoned — safe fallback. + // Mutex poisoned; safe fallback. show_onboarding_window(&app_handle, onboarding::OnboardingStage::Permissions); return; } @@ -436,8 +423,7 @@ fn finish_onboarding( app_handle: tauri::AppHandle, ) -> Result<(), String> { let conn = db.0.lock().map_err(|e| format!("db lock poisoned: {e}"))?; - onboarding::set_stage(&conn, &onboarding::OnboardingStage::Complete) - .map_err(|e| format!("db write failed: {e}"))?; + onboarding::mark_complete(&conn).map_err(|e| format!("db write failed: {e}"))?; drop(conn); // Restore panel to overlay configuration and show the Ask Bar. diff --git a/src-tauri/src/onboarding.rs b/src-tauri/src/onboarding.rs index 192a9d7..45f6aea 100644 --- a/src-tauri/src/onboarding.rs +++ b/src-tauri/src/onboarding.rs @@ -50,11 +50,11 @@ pub fn set_stage(conn: &Connection, stage: &OnboardingStage) -> rusqlite::Result /// Returns which onboarding stage to show at startup, or `None` if onboarding /// is complete. /// -/// Reads only the persisted stage — no permission API calls. macOS 15 broke -/// CGPreflightScreenCaptureAccess and CGWindowListCopyWindowInfo so neither is -/// reliable at launch time. The PermissionsStep component owns all permission -/// detection via its own mount-time and polling checks. The startup path just -/// trusts the DB. +/// Reads only the persisted stage: no permission API calls. Permission APIs +/// (CGPreflightScreenCaptureAccess) can return stale results immediately after +/// a process restart on macOS 15+. PermissionsStep owns live permission +/// detection via its own polling checks. quit_and_relaunch writes "intro" to +/// the DB before restarting so this path sees the correct stage on next launch. pub fn compute_startup_stage(conn: &Connection) -> rusqlite::Result> { match get_stage(conn)? { OnboardingStage::Complete => Ok(None), @@ -62,6 +62,15 @@ pub fn compute_startup_stage(conn: &Connection) -> rusqlite::Result rusqlite::Result<()> { + set_stage(conn, &OnboardingStage::Complete) +} + // ─── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -97,6 +106,21 @@ mod tests { assert_eq!(get_stage(&conn).unwrap(), OnboardingStage::Complete); } + #[test] + fn mark_complete_sets_stage_to_complete() { + let conn = open_in_memory().unwrap(); + mark_complete(&conn).unwrap(); + assert_eq!(get_stage(&conn).unwrap(), OnboardingStage::Complete); + } + + #[test] + fn mark_complete_overwrites_any_prior_stage() { + let conn = open_in_memory().unwrap(); + set_stage(&conn, &OnboardingStage::Intro).unwrap(); + mark_complete(&conn).unwrap(); + assert_eq!(get_stage(&conn).unwrap(), OnboardingStage::Complete); + } + #[test] fn compute_startup_stage_returns_none_when_complete() { let conn = open_in_memory().unwrap(); diff --git a/src-tauri/src/permissions.rs b/src-tauri/src/permissions.rs index fad748f..89b9360 100644 --- a/src-tauri/src/permissions.rs +++ b/src-tauri/src/permissions.rs @@ -143,18 +143,18 @@ pub fn check_screen_recording_tcc_granted() -> bool { /// Called after the user grants Screen Recording permission. macOS requires /// a full process restart before the new permission takes effect. /// -/// Advances the onboarding stage to "intro" before restarting so the next -/// launch shows the intro screen rather than re-checking permissions via -/// CGPreflightScreenCaptureAccess, which can return false on macOS 15 even -/// after a successful grant. +/// Writes "intro" to the DB before restarting so `notify_frontend_ready` +/// shows the intro screen on the next launch without calling any permission +/// API. Permission APIs (CGPreflightScreenCaptureAccess) can return stale +/// results immediately after a restart on macOS 15+; trusting the DB stage +/// avoids that unreliability entirely. #[tauri::command] #[cfg(target_os = "macos")] #[cfg_attr(coverage_nightly, coverage(off))] -pub fn quit_and_relaunch(app_handle: tauri::AppHandle) { - // No DB write needed here. The onboarding stage remains "permissions" - // across the restart. On the next launch, notify_frontend_ready detects - // that both permissions are now granted and stage is still "permissions", - // advances the stage to "intro", and shows the intro screen. +pub fn quit_and_relaunch(app_handle: tauri::AppHandle, db: tauri::State) { + if let Ok(conn) = db.0.lock() { + let _ = crate::onboarding::set_stage(&conn, &crate::onboarding::OnboardingStage::Intro); + } app_handle.restart(); } From 352c269293657129953c66519d5ac290e3a01496 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Tue, 7 Apr 2026 21:36:56 -0500 Subject: [PATCH 6/8] fix(onboarding): restore permission check at startup for revocation detection The previous commit (trust DB exclusively) broke the case where a user revokes a permission after completing onboarding: the DB stage is "complete" so compute_startup_stage returns None, and the app goes straight to the Ask Bar with non-functional features. Fix: check live permissions for the "permissions" and "complete" stages as before, but skip the check specifically for the "intro" stage. "intro" is written by quit_and_relaunch immediately before restarting, which proves the user just granted all permissions. Skipping the live check there prevents CGPreflightScreenCaptureAccess from returning a stale false negative on macOS 15+ and looping the user back to the permissions screen. Stage-by-stage behaviour at startup: permissions -> check live permissions (standard first-launch path) intro -> trust DB, show intro (post-grant restart, macOS 15 safe) complete -> check live permissions (revocation detection) Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 55 +++++++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ca8a2b9..0a7994c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -378,29 +378,42 @@ fn notify_frontend_ready(app_handle: tauri::AppHandle, db: tauri::State { - show_onboarding_window(&app_handle, stage); - return; - } - Ok(None) => {} // Complete: fall through to show the overlay. - Err(_) => { - // DB read failed; fall back to the permissions screen. - show_onboarding_window( - &app_handle, - onboarding::OnboardingStage::Permissions, - ); - return; - } + let stage = onboarding::get_stage(&conn) + .unwrap_or(onboarding::OnboardingStage::Permissions); + + // The "intro" stage means quit_and_relaunch already wrote it + // before restarting, confirming the user just granted all + // permissions. Skip the live permission check here: on macOS 15+ + // CGPreflightScreenCaptureAccess can return a stale false negative + // immediately after a restart, which would wrongly loop the user + // back to the permissions screen. + if matches!(stage, onboarding::OnboardingStage::Intro) { + show_onboarding_window(&app_handle, onboarding::OnboardingStage::Intro); + return; + } + + // For the "permissions" and "complete" stages, check live + // permissions. "permissions" is the standard first-launch path. + // "complete" detects revocation: if a user revokes a permission + // after finishing onboarding, they should see the permissions + // screen again on the next launch. + let ax = permissions::is_accessibility_granted(); + let sr = permissions::is_screen_recording_granted(); + + if !ax || !sr { + let _ = onboarding::set_stage(&conn, &onboarding::OnboardingStage::Permissions); + show_onboarding_window(&app_handle, onboarding::OnboardingStage::Permissions); + return; + } + + // Both permissions granted. If not yet complete, show intro. + if !matches!(stage, onboarding::OnboardingStage::Complete) { + let _ = onboarding::set_stage(&conn, &onboarding::OnboardingStage::Intro); + show_onboarding_window(&app_handle, onboarding::OnboardingStage::Intro); + return; } + // Complete: fall through to show the overlay. } else { // Mutex poisoned; safe fallback. show_onboarding_window(&app_handle, onboarding::OnboardingStage::Permissions); From c25ec7f3405f1719c2ae2381f18eec6418f92311 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Tue, 7 Apr 2026 21:42:08 -0500 Subject: [PATCH 7/8] test(coverage): delete dead OnboardingView shim, cover onComplete callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete src/view/OnboardingView.tsx: it was a temporary re-export shim whose comment said "App.tsx will be updated in the next step". That update already shipped, leaving the file unreachable and unexercisable, causing 0% coverage on all metrics for the file. - Add App test: "dismisses onboarding and shows ask bar when onComplete is called" — exercises the inline arrow on line 1152 of App.tsx that calls setOnboardingStage(null) after the user clicks "Get Started". Restores 100% frontend coverage across lines, functions, branches, and statements. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Logan Nguyen --- src/__tests__/App.test.tsx | 19 +++++++++++++++++++ src/view/OnboardingView.tsx | 4 ---- 2 files changed, 19 insertions(+), 4 deletions(-) delete mode 100644 src/view/OnboardingView.tsx diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index f6025bf..c3eb3c5 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -3244,5 +3244,24 @@ describe('App', () => { screen.getByPlaceholderText('Ask Thuki anything...'), ).toBeInTheDocument(); }); + + it('dismisses onboarding and shows ask bar when onComplete is called', async () => { + invoke.mockResolvedValue(undefined); + + render(); + await act(async () => {}); + + await act(async () => { + emitTauriEvent('thuki://onboarding', { stage: 'intro' }); + }); + + expect(screen.getByText('Before you dive in')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /get started/i })); + }); + + expect(screen.queryByText('Before you dive in')).toBeNull(); + }); }); }); diff --git a/src/view/OnboardingView.tsx b/src/view/OnboardingView.tsx deleted file mode 100644 index 8e0fe4d..0000000 --- a/src/view/OnboardingView.tsx +++ /dev/null @@ -1,4 +0,0 @@ -// Re-export from the onboarding module for backwards compatibility. -// App.tsx will be updated in the next step to import directly. -export { OnboardingView } from './onboarding/index'; -export type { OnboardingStage } from './onboarding/index'; From 731c22fed01d77c4794e604ececa2fc671beb7e2 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Tue, 7 Apr 2026 21:54:46 -0500 Subject: [PATCH 8/8] test(backend): add missing coverage for set_stage(Permissions) and set_config error path Covers the two lines that blocked CI at 100% line coverage: - onboarding.rs: add set_and_get_stage_round_trips_permissions to exercise the OnboardingStage::Permissions arm of set_stage - database.rs: add set_config_returns_error_when_table_missing to hit the error branch of conn.execute() inside set_config Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Logan Nguyen --- src-tauri/src/database.rs | 9 +++++++++ src-tauri/src/onboarding.rs | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index 652f6e1..ab66de4 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -740,6 +740,15 @@ mod tests { assert_eq!(val.as_deref(), Some("complete")); } + #[test] + fn set_config_returns_error_when_table_missing() { + let conn = open_in_memory().unwrap(); + // Drop the table to force a SQL error on the next write. + conn.execute_batch("DROP TABLE app_config").unwrap(); + let result = set_config(&conn, "key", "value"); + assert!(result.is_err()); + } + #[test] fn set_config_independent_keys_do_not_interfere() { let conn = open_in_memory().unwrap(); diff --git a/src-tauri/src/onboarding.rs b/src-tauri/src/onboarding.rs index 45f6aea..18b1bc5 100644 --- a/src-tauri/src/onboarding.rs +++ b/src-tauri/src/onboarding.rs @@ -84,6 +84,13 @@ mod tests { assert_eq!(get_stage(&conn).unwrap(), OnboardingStage::Permissions); } + #[test] + fn set_and_get_stage_round_trips_permissions() { + let conn = open_in_memory().unwrap(); + set_stage(&conn, &OnboardingStage::Permissions).unwrap(); + assert_eq!(get_stage(&conn).unwrap(), OnboardingStage::Permissions); + } + #[test] fn set_and_get_stage_round_trips_intro() { let conn = open_in_memory().unwrap();