setup wizard & macOS accessibility thing

This commit is contained in:
2026-09-01 21:30:59 +08:00
parent 4198f8b4d5
commit 9041283b65
24 changed files with 1109 additions and 42 deletions
+57
View File
@@ -0,0 +1,57 @@
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::{AppHandle, Manager};
#[derive(Default)]
struct ApplicationState {
started: AtomicBool,
}
pub async fn init(handle: &AppHandle) -> Result<(), String> {
handle.manage(ApplicationState::default());
let accessibility_permission_granted = crate::macos::init(handle).await?;
let settings = crate::onboarding::get(&handle.state())
.await
.map_err(crate::db::command_error)?;
if settings.onboarding_done {
start(handle);
if cfg!(target_os = "macos") && !accessibility_permission_granted {
crate::ui::onboarding::show_accessibility_page(handle)?;
}
} else {
crate::ui::onboarding::show_initial(handle)?;
}
Ok(())
}
pub fn start(handle: &AppHandle) {
let state = handle.state::<ApplicationState>();
if state
.started
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return;
}
crate::ui::init(handle);
reconcile_cursor(handle);
}
pub fn is_started(handle: &AppHandle) -> bool {
handle
.state::<ApplicationState>()
.started
.load(Ordering::Acquire)
}
pub fn reconcile_cursor(handle: &AppHandle) {
if !is_started(handle) || !crate::macos::accessibility_permission_granted(handle) {
return;
}
if let Err(error) = crate::cursor::init(handle) {
eprintln!("failed to initialize cursor tracking; will retry: {error}");
}
}
+19 -7
View File
@@ -77,7 +77,10 @@ fn current_position(handle: &AppHandle) -> Result<CursorPositions, String> {
.primary_monitor()
.map_err(|error| error.to_string())?
.ok_or("Primary monitor is unavailable")?;
let position = DeviceState::new().get_mouse().coords;
let position = DeviceState::checked_new()
.ok_or_else(|| "System cursor access is unavailable".to_owned())?
.get_mouse()
.coords;
#[cfg(target_os = "windows")]
let raw = CursorPosition {
@@ -111,20 +114,23 @@ lazy_static! {
}
/// Initialize cursor tracking.
pub fn init(app: &AppHandle) {
pub fn init(app: &AppHandle) -> Result<(), String> {
println!("init_cursor_tracking called");
if !crate::macos::accessibility_permission_granted(app) {
return Err("macOS Accessibility permission has not been granted".to_owned());
}
let mut tracker = match CURSOR_TASK.lock() {
Ok(tracker) => tracker,
Err(e) => {
println!("Failed to lock cursor tracker state: {}", e);
return;
return Err(format!("Failed to lock cursor tracker state: {e}"));
}
};
if tracker.is_some() {
println!("Cursor tracking already initialized");
return;
return Ok(());
}
match current_position(app) {
@@ -138,8 +144,8 @@ pub fn init(app: &AppHandle) {
let primary_monitor = app
.primary_monitor()
.expect("Failed to resolve primary monitor")
.expect("Failed to resolve primary monitor");
.map_err(|error| error.to_string())?
.ok_or_else(|| "Failed to resolve primary monitor".to_owned())?;
let handle = app.clone();
let task = tauri::async_runtime::spawn(async move {
@@ -150,6 +156,7 @@ pub fn init(app: &AppHandle) {
*tracker = Some(CursorTask { stop_tx, task });
println!("EVENT: Cursor Tracker Enabled");
Ok(())
}
#[inline]
@@ -320,6 +327,7 @@ async fn init_cursor_tracking_i(
// Create a channel to decouple event generation (producer) from processing (consumer).
// Capacity 100 is plenty for 500ms polling (2Hz).
let (tx, mut rx) = mpsc::channel::<CursorPositions>(100);
let permission_handle = handle.clone();
// Spawn the consumer task
// This task handles WebSocket reporting and local position projection updates.
@@ -345,6 +353,10 @@ async fn init_cursor_tracking_i(
// The producer closure moves `tx` into it.
// device_query runs this closure on its own thread.
let _guard = device_state.on_mouse_move(move |position: &(i32, i32)| {
if !crate::macos::accessibility_permission_granted(&permission_handle) {
return;
}
#[cfg(target_os = "windows")]
let raw = CursorPosition {
x: position.0 as f64,
+8 -2
View File
@@ -1,3 +1,4 @@
mod application;
mod cursor;
mod db;
mod friends;
@@ -5,7 +6,9 @@ mod images;
mod interactions;
mod keypair;
mod live_data;
mod macos;
mod network;
mod onboarding;
mod profile;
mod puppet;
mod remotes;
@@ -30,9 +33,8 @@ async fn launch_app(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>>
app.manage(puppet::PuppetStateStore::default());
network::init(handle).await?;
ufa::init(handle);
cursor::init(handle);
puppet::init(handle)?;
ui::init(handle);
application::init(handle).await?;
Ok(())
}
@@ -92,6 +94,9 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
puppet::list_puppet_states,
scene_configuration::get_scene_configuration,
scene_configuration::update_scene_configuration,
onboarding::get_onboarding_status,
onboarding::complete_onboarding,
macos::request_accessibility_permission,
ui::control_panel::open_action_window,
ui::scene::update_scene_hitboxes,
])
@@ -107,6 +112,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
interactions::FriendInteractionReceived,
puppet::PuppetStatesChanged,
scene_configuration::SceneConfigurationChanged,
onboarding::OnboardingStatus,
])
}
+108
View File
@@ -0,0 +1,108 @@
use std::{
sync::atomic::{AtomicBool, Ordering},
time::Duration,
};
use tauri::{AppHandle, Manager};
pub struct AccessibilityPermissionState(AtomicBool);
impl AccessibilityPermissionState {
fn new(granted: bool) -> Self {
Self(AtomicBool::new(granted))
}
fn replace(&self, granted: bool) -> bool {
self.0.swap(granted, Ordering::AcqRel) != granted
}
fn granted(&self) -> bool {
self.0.load(Ordering::Acquire)
}
}
#[cfg(target_os = "macos")]
fn system_accessibility_permission_granted() -> bool {
macos_accessibility_client::accessibility::application_is_trusted()
}
#[cfg(not(target_os = "macos"))]
fn system_accessibility_permission_granted() -> bool {
true
}
#[cfg(target_os = "macos")]
fn request_system_accessibility_permission() -> bool {
macos_accessibility_client::accessibility::application_is_trusted_with_prompt()
}
#[cfg(not(target_os = "macos"))]
fn request_system_accessibility_permission() -> bool {
true
}
pub fn accessibility_permission_granted(handle: &AppHandle) -> bool {
if !cfg!(target_os = "macos") {
return true;
}
handle.state::<AccessibilityPermissionState>().granted()
}
async fn apply_permission_state(handle: &AppHandle, granted: bool) -> Result<bool, String> {
if handle.state::<AccessibilityPermissionState>().granted() == granted {
crate::application::reconcile_cursor(handle);
return Ok(false);
}
if !handle
.state::<AccessibilityPermissionState>()
.replace(granted)
{
return Ok(false);
}
let database = handle.state();
let status = crate::onboarding::emit_status(handle, &database).await?;
if status.onboarding_done {
if granted {
crate::application::reconcile_cursor(handle);
} else {
crate::ui::onboarding::show_accessibility_page(handle)?;
}
}
Ok(true)
}
pub async fn init(handle: &AppHandle) -> Result<bool, String> {
let granted = system_accessibility_permission_granted();
handle.manage(AccessibilityPermissionState::new(granted));
#[cfg(target_os = "macos")]
{
let handle = handle.clone();
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(1));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
interval.tick().await;
loop {
interval.tick().await;
let granted = system_accessibility_permission_granted();
if let Err(error) = apply_permission_state(&handle, granted).await {
eprintln!("failed to update macOS Accessibility permission: {error}");
}
}
});
}
Ok(granted)
}
#[tauri::command]
#[specta::specta]
pub async fn request_accessibility_permission(handle: AppHandle) -> Result<bool, String> {
let granted = tauri::async_runtime::spawn_blocking(request_system_accessibility_permission)
.await
.map_err(|error| error.to_string())?;
apply_permission_state(&handle, granted).await?;
Ok(granted)
}
+128
View File
@@ -0,0 +1,128 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::{AppHandle, State};
use tauri_specta::Event;
use crate::db::{self, AppDatabase};
const SETTINGS_ID: i64 = 1;
#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
pub struct AppSettings {
pub onboarding_done: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type, Event)]
#[serde(rename_all = "camelCase")]
pub struct OnboardingStatus {
pub onboarding_done: bool,
pub macos_accessibility_permission_granted: bool,
pub requires_accessibility_permission: bool,
}
impl OnboardingStatus {
fn new(handle: &AppHandle, settings: AppSettings) -> Self {
Self {
onboarding_done: settings.onboarding_done,
macos_accessibility_permission_granted: crate::macos::accessibility_permission_granted(
handle,
),
requires_accessibility_permission: cfg!(target_os = "macos"),
}
}
}
pub async fn get(database: &AppDatabase) -> Result<AppSettings, sqlx::Error> {
sqlx::query_as::<_, AppSettings>("SELECT onboarding_done FROM app_settings WHERE id = ?1")
.bind(SETTINGS_ID)
.fetch_one(database.pool())
.await
}
async fn set_onboarding_done(
database: &AppDatabase,
onboarding_done: bool,
) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE app_settings SET onboarding_done = ?1 WHERE id = ?2")
.bind(onboarding_done)
.bind(SETTINGS_ID)
.execute(database.pool())
.await?;
Ok(())
}
pub(crate) async fn emit_status(
handle: &AppHandle,
database: &AppDatabase,
) -> Result<OnboardingStatus, String> {
let status = OnboardingStatus::new(handle, get(database).await.map_err(db::command_error)?);
status.clone().emit(handle).map_err(db::command_error)?;
Ok(status)
}
#[tauri::command]
#[specta::specta]
pub async fn get_onboarding_status(
handle: AppHandle,
database: State<'_, AppDatabase>,
) -> Result<OnboardingStatus, String> {
let settings = get(&database).await.map_err(db::command_error)?;
Ok(OnboardingStatus::new(&handle, settings))
}
#[tauri::command]
#[specta::specta]
pub async fn complete_onboarding(
handle: AppHandle,
database: State<'_, AppDatabase>,
) -> Result<(), String> {
if cfg!(target_os = "macos") && !crate::macos::accessibility_permission_granted(&handle) {
return Err("Accessibility access must be granted before setup can finish.".to_owned());
}
set_onboarding_done(&database, true)
.await
.map_err(db::command_error)?;
emit_status(&handle, &database).await?;
crate::application::start(&handle);
Ok(())
}
#[cfg(test)]
mod tests {
use sqlx::sqlite::SqlitePoolOptions;
use super::*;
async fn database() -> AppDatabase {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("connect to in-memory SQLite");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("run database migrations");
AppDatabase::new(pool)
}
#[tokio::test]
async fn onboarding_completion_is_persisted() {
let database = database().await;
assert_eq!(
get(&database).await.unwrap(),
AppSettings {
onboarding_done: false,
}
);
set_onboarding_done(&database, true).await.unwrap();
assert_eq!(
get(&database).await.unwrap(),
AppSettings {
onboarding_done: true,
}
);
}
}
+1
View File
@@ -3,6 +3,7 @@ use tauri::AppHandle;
pub mod control_panel;
#[cfg(debug_assertions)]
mod debug;
pub mod onboarding;
pub mod scene;
mod tray;
+50
View File
@@ -0,0 +1,50 @@
use tauri::{AppHandle, Manager, WebviewUrl};
const WINDOW_LABEL: &str = "onboarding";
fn show(handle: &AppHandle, accessibility_only: bool) -> Result<(), String> {
if let Some(window) = handle.get_webview_window(WINDOW_LABEL) {
if accessibility_only {
window
.eval("window.location.href = '/onboarding?step=accessibility'")
.map_err(|error| error.to_string())?;
}
window.unminimize().map_err(|error| error.to_string())?;
window.show().map_err(|error| error.to_string())?;
return window.set_focus().map_err(|error| error.to_string());
}
let page = if accessibility_only {
"/onboarding?step=accessibility"
} else {
"/onboarding"
};
let window =
tauri::WebviewWindowBuilder::new(handle, WINDOW_LABEL, WebviewUrl::App(page.into()))
.title("Friendolls Setup")
.inner_size(680.0, 520.0)
.min_inner_size(680.0, 520.0)
.resizable(false)
.maximizable(false)
.center()
.build()
.map_err(|error| error.to_string())?;
let handle = handle.clone();
window.on_window_event(move |event| {
if matches!(event, tauri::WindowEvent::CloseRequested { .. })
&& !crate::application::is_started(&handle)
{
handle.exit(0);
}
});
Ok(())
}
pub fn show_initial(handle: &AppHandle) -> Result<(), String> {
show(handle, false)
}
pub fn show_accessibility_page(handle: &AppHandle) -> Result<(), String> {
show(handle, true)
}