Skip to content
Merged
1 change: 1 addition & 0 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
87 changes: 86 additions & 1 deletion src-tauri/src/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)?;

Expand All @@ -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<Option<String>> {
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.
Expand Down Expand Up @@ -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<String> = 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()));
Expand Down
103 changes: 91 additions & 12 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -373,21 +374,93 @@ 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<history::Database>) {
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;
}
}
show_overlay(&app_handle, crate::context::ActivationContext::empty());
}
}

// ─── 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::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
Expand Down Expand Up @@ -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") {
Expand All @@ -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();
}
Expand All @@ -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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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]
Expand Down
Loading