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/database.rs b/src-tauri/src/database.rs index f873303..ab66de4 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,67 @@ 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_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(); + 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..0a7994c 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,49 @@ 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 let Ok(conn) = db.0.lock() { + 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); return; } } @@ -388,6 +424,43 @@ fn notify_frontend_ready(app_handle: tauri::AppHandle) { } } +// ─── Onboarding completion ─────────────────────────────────────────────────── + +/// Called when the user clicks "Get Started" on the intro screen. +/// Marks onboarding complete in the DB, restores the window to overlay mode, +/// and immediately shows the Ask Bar — no relaunch required. +#[tauri::command] +#[cfg_attr(coverage_nightly, coverage(off))] +fn finish_onboarding( + db: tauri::State, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + let conn = db.0.lock().map_err(|e| format!("db lock poisoned: {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. + // 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 @@ -445,7 +518,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 +536,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 +546,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 +754,8 @@ pub fn run() { #[cfg(not(coverage))] permissions::check_screen_recording_tcc_granted, #[cfg(not(coverage))] - permissions::quit_and_relaunch + permissions::quit_and_relaunch, + finish_onboarding ]) .build(tauri::generate_context!()) .expect("error while building tauri application") @@ -744,7 +823,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..18b1bc5 --- /dev/null +++ b/src-tauri/src/onboarding.rs @@ -0,0 +1,166 @@ +/*! + * 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. 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), + stage => Ok(Some(stage)), + } +} + +/// Persists the `Complete` stage, marking onboarding as finished. +/// +/// Called by the `finish_onboarding` Tauri command after the user clicks +/// "Get Started". Extracted so the DB write is covered by tests independently +/// of the Tauri command wrapper. +pub fn mark_complete(conn: &Connection) -> rusqlite::Result<()> { + set_stage(conn, &OnboardingStage::Complete) +} + +// ─── 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_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(); + 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 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(); + 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..89b9360 100644 --- a/src-tauri/src/permissions.rs +++ b/src-tauri/src/permissions.rs @@ -124,46 +124,37 @@ pub fn request_screen_recording_access() { } } -/// Returns `true` if Screen Recording has been granted in TCC without requiring -/// a process restart. +/// Returns `true` if Screen & System Audio Recording permission is currently +/// granted. Delegates to `CGPreflightScreenCaptureAccess`, which correctly +/// returns `false` when the permission has not been granted, fixing the +/// historical false-positive from `CGWindowListCopyWindowInfo(0, 0)`. /// -/// `CGPreflightScreenCaptureAccess` only returns `true` after a full process -/// restart. `CGWindowListCopyWindowInfo` returns a non-null array as soon as -/// TCC records the grant, making it suitable for polling during onboarding so -/// the "Quit and Reopen" prompt appears immediately when the user toggles the -/// permission on in System Settings. +/// Called by PermissionsStep during onboarding polling so the "Quit & Reopen" +/// prompt appears once the user toggles the permission on in System Settings. #[tauri::command] #[cfg(target_os = "macos")] #[cfg_attr(coverage_nightly, coverage(off))] pub fn check_screen_recording_tcc_granted() -> 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. /// /// Called after the user grants Screen Recording permission. macOS requires /// a full process restart before the new permission takes effect. +/// +/// 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) { +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(); } diff --git a/src/App.tsx b/src/App.tsx index 7d98966..7ccdb1c 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,13 @@ function App() { ); }, []); - if (showOnboarding) { - return ; + if (onboardingStage !== null) { + return ( + setOnboardingStage(null)} + /> + ); } return ( diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index a27fcce..c3eb3c5 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(); @@ -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/__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/onboarding/IntroStep.tsx b/src/view/onboarding/IntroStep.tsx new file mode 100644 index 0000000..eaefa5e --- /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 */} +
+

+ Before you dive in +

+

+ {"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/OnboardingView.tsx b/src/view/onboarding/PermissionsStep.tsx similarity index 99% rename from src/view/OnboardingView.tsx rename to src/view/onboarding/PermissionsStep.tsx index 495cb60..406c780 100644 --- a/src/view/OnboardingView.tsx +++ b/src/view/onboarding/PermissionsStep.tsx @@ -2,7 +2,7 @@ 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'; +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; @@ -150,7 +150,7 @@ const Spinner = () => ( * The outer container is transparent so the rounded panel corners are visible * against the macOS desktop. */ -export function OnboardingView() { +export function PermissionsStep() { const [accessibilityStatus, setAccessibilityStatus] = useState('pending'); const [screenRecordingStatus, setScreenRecordingStatus] = diff --git a/src/view/onboarding/__tests__/IntroStep.test.tsx b/src/view/onboarding/__tests__/IntroStep.test.tsx new file mode 100644 index 0000000..0b12593 --- /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('Before you dive in')).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..751fe40 --- /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, vi } 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('Before you dive in')).toBeInTheDocument(); + }); +}); diff --git a/src/view/onboarding/index.tsx b/src/view/onboarding/index.tsx new file mode 100644 index 0000000..9b2987e --- /dev/null +++ b/src/view/onboarding/index.tsx @@ -0,0 +1,27 @@ +import { IntroStep } from './IntroStep'; +import { PermissionsStep } from './PermissionsStep'; + +export type OnboardingStage = 'permissions' | 'intro'; + +interface Props { + stage: OnboardingStage; + onComplete: () => void; +} + +/** + * 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, onComplete }: Props) { + if (stage === 'intro') { + return ; + } + return ; +}