Skip to content

feat: onboarding flow with permission-gated stage machine#65

Merged
quiet-node merged 8 commits intomainfrom
worktree-structured-discovering-catmull
Apr 8, 2026
Merged

feat: onboarding flow with permission-gated stage machine#65
quiet-node merged 8 commits intomainfrom
worktree-structured-discovering-catmull

Conversation

@quiet-node
Copy link
Copy Markdown
Owner

Summary

  • Implements the full onboarding state machine: launch checks live permissions first, routes to permissions screen if either is missing, advances to intro screen once both are granted, and shows the Ask Bar after "Get Started"
  • Fixes screen recording permission false positive where the status went green immediately after opening System Settings (root cause: CGWindowListCopyWindowInfo(0, 0) always returns non-null; fix: CGPreflightScreenCaptureAccess)
  • Aligns intro screen visual style with the permissions screen (matching card, amber glow, typography)
  • "Get Started" now immediately shows the Ask Bar without requiring a relaunch (restores NSPanel level and calls show_overlay directly)

Flow

Launch
  -> check accessibility + screen recording
  -> missing? -> permissions screen
  -> both granted + stage != complete? -> intro screen
  -> stage == complete -> Ask Bar directly

Permissions screen
  -> polls check_screen_recording_tcc_granted every ~1s
  -> Quit & Reopen triggers app_handle.restart()
  -> on relaunch, permissions now granted -> intro screen shown

Intro screen
  -> Get Started -> writes stage=complete to DB, shows Ask Bar

Test plan

  • Fresh install: launches to permissions screen when either permission missing
  • Grant accessibility only: screen recording row stays red, no false positive
  • Grant both, quit and reopen: shows intro screen
  • Click Get Started: Ask Bar opens immediately, no relaunch needed
  • Subsequent launches with both granted + stage=complete: goes straight to Ask Bar
  • Revoke screen recording mid-session, relaunch: returns to permissions screen
  • bun run test && bun run validate-build pass clean

🤖 Generated with Claude Code

quiet-node and others added 4 commits April 7, 2026 17:21
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 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
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 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
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 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
…ith 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 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
@quiet-node
Copy link
Copy Markdown
Owner Author

Code review

Found 3 issues:

  1. notify_frontend_ready ignores compute_startup_stage and calls live permission APIs at startuponboarding.rs defines compute_startup_stage with an explicit doc comment: "Reads only the persisted stage — no permission API calls. macOS 15 broke CGPreflightScreenCaptureAccess..." But notify_frontend_ready never calls it; instead it calls is_accessibility_granted() and is_screen_recording_granted() inline. On macOS 15, where CGPreflightScreenCaptureAccess can return false after a successful grant, this means a user who completed onboarding gets bounced back to the permissions screen on every relaunch — a permanent loop. compute_startup_stage was specifically written to prevent this and is dead code.

thuki/src-tauri/src/lib.rs

Lines 378 to 413 in e634285

if LAUNCH_SHOW_PENDING.swap(false, Ordering::SeqCst) {
#[cfg(target_os = "macos")]
{
let ax = permissions::is_accessibility_granted();
// Use CGPreflightScreenCaptureAccess for the startup check: it is
// fast (no blocking) and accurate after a process restart, which is
// the only path that reaches this code with a fresh TCC decision.
// check_screen_recording_tcc_granted (SCShareableContent) is used
// only during onboarding polling where live state is needed without
// requiring a restart.
let sr = permissions::is_screen_recording_granted();
if let Ok(conn) = db.0.lock() {
if !ax || !sr {
// Permissions missing. Reset stage to "permissions" so
// the next launch after granting will advance to intro.
// This also handles revocation after a completed onboarding.
let _ = onboarding::set_stage(&conn, &onboarding::OnboardingStage::Permissions);
show_onboarding_window(&app_handle, onboarding::OnboardingStage::Permissions);
return;
}
// Both permissions granted. Read the persisted stage.
let stage = onboarding::get_stage(&conn)
.unwrap_or(onboarding::OnboardingStage::Permissions);
match stage {
onboarding::OnboardingStage::Complete => {
// 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);

  1. check_screen_recording_tcc_granted now uses CGPreflightScreenCaptureAccess for polling, which the PR's own comments say is unreliable on macOS 15PermissionsStep polls this command every ~500ms to detect when Screen Recording is granted and show the "Quit & Reopen" prompt. But the PR's own quit_and_relaunch doc comment says "CGPreflightScreenCaptureAccess, which can return false on macOS 15 even after a successful grant," and onboarding.rs repeats this. So on macOS 15, the polling loop will never detect the grant — the "Quit & Reopen" button never appears and the user is stuck.

/// Returns whether Screen Recording permission has been granted.
#[tauri::command]
#[cfg(target_os = "macos")]
#[cfg_attr(coverage_nightly, coverage(off))]
pub fn check_screen_recording_permission() -> bool {
is_screen_recording_granted()
}
/// Opens System Settings to the Screen Recording privacy pane so the user
/// can enable the permission without navigating there manually.
#[tauri::command]
#[cfg(target_os = "macos")]
#[cfg_attr(coverage_nightly, coverage(off))]
pub fn open_screen_recording_settings() -> Result<(), String> {
std::process::Command::new("open")

  1. finish_onboarding is exempt from coverage but is not a thin wrapper (CLAUDE.md says "Functions excluded from coverage with #[cfg_attr(coverage_nightly, coverage(off))] must be thin wrappers (Tauri commands, filesystem I/O) whose logic is tested through the functions they delegate to") — The function locks the DB, writes the stage, drops the lock, then in a run_on_main_thread closure resizes the window, calls init_panel, and calls show_overlay. None of that orchestration logic is exercised by any test.

thuki/src-tauri/src/lib.rs

Lines 424 to 460 in e634285

}
}
// ─── 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<history::Database>,
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());
});


🤖 Generated with Claude Code

If this code review was useful, please react with 👍. Otherwise, react with 👎.

quiet-node and others added 4 commits April 7, 2026 21:20
…ermission 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 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
…etection

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 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
…lback

- 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 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
…t_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 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
@quiet-node quiet-node merged commit 35497cb into main Apr 8, 2026
3 checks passed
@quiet-node quiet-node deleted the worktree-structured-discovering-catmull branch April 8, 2026 03:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant