setup wizard & macOS accessibility thing
This commit is contained in:
@@ -58,6 +58,7 @@ windows = { version = "0.58", features = [
|
||||
] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
macos-accessibility-client = "0.0.1"
|
||||
objc2 = "0.6.3"
|
||||
objc2-app-kit = "0.3.2"
|
||||
objc2-foundation = "0.3.2"
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for Friendolls UI windows",
|
||||
"windows": ["control-panel", "control-panel-action-*", "debug", "scene"],
|
||||
"windows": [
|
||||
"control-panel",
|
||||
"control-panel-action-*",
|
||||
"debug",
|
||||
"onboarding",
|
||||
"scene"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE app_settings (
|
||||
id INTEGER PRIMARY KEY NOT NULL CHECK (id = 1),
|
||||
onboarding_done INTEGER NOT NULL DEFAULT 0 CHECK (onboarding_done IN (0, 1))
|
||||
);
|
||||
|
||||
INSERT INTO app_settings (id) VALUES (1);
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use tauri::AppHandle;
|
||||
pub mod control_panel;
|
||||
#[cfg(debug_assertions)]
|
||||
mod debug;
|
||||
pub mod onboarding;
|
||||
pub mod scene;
|
||||
mod tray;
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user