replace device_query with tauri API so no need for accessibility perm
on darwin
This commit is contained in:
Generated
-47
@@ -7,14 +7,12 @@ name = "Friendolls"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"device_query",
|
||||
"ed25519-dalek",
|
||||
"friendolls-common",
|
||||
"futures-util",
|
||||
"getrandom 0.4.3",
|
||||
"image",
|
||||
"lazy_static",
|
||||
"macos-accessibility-client",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
@@ -967,20 +965,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "device_query"
|
||||
version = "4.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7331225604b9b097b41872d550134933d4be83465d70a2c4a132b929f99aaca"
|
||||
dependencies = [
|
||||
"macos-accessibility-client",
|
||||
"pkg-config",
|
||||
"readkey",
|
||||
"readmouse",
|
||||
"windows 0.48.0",
|
||||
"x11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -2560,16 +2544,6 @@ version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
|
||||
[[package]]
|
||||
name = "macos-accessibility-client"
|
||||
version = "0.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edf7710fbff50c24124331760978fb9086d6de6288dcdb38b25a97f8b1bdebbb"
|
||||
dependencies = [
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.38.0"
|
||||
@@ -3498,18 +3472,6 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "readkey"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a36870cefdfcff57edbc0fa62165f42dfd4e5a0d8965117c1ea84c5700e4450"
|
||||
|
||||
[[package]]
|
||||
name = "readmouse"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be105c72a1e6a5a1198acee3d5b506a15676b74a02ecd78060042a447f408d94"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -6098,15 +6060,6 @@ dependencies = [
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"error_friend_missing": "The selected friend no longer exists.",
|
||||
"error_profile_loading": "Your profile is still loading.",
|
||||
"error_skin_required": "Choose a 64×64 PNG skin before continuing.",
|
||||
"error_accessibility_required": "Turn on Friendolls in macOS Accessibility settings before continuing.",
|
||||
"error_key_invalid_base64": "Identification Key is not valid base64url.",
|
||||
"error_key_invalid_length": "Identification Key must encode a 32-byte public key.",
|
||||
"error_key_invalid_ed25519": "Identification Key is not a valid Ed25519 public key.",
|
||||
@@ -78,8 +77,6 @@
|
||||
"onboarding_server_nav": "Add a server",
|
||||
"onboarding_friend_title": "Add a friend",
|
||||
"onboarding_friend_nav": "Add a friend",
|
||||
"onboarding_accessibility_title": "Allow cursor access",
|
||||
"onboarding_accessibility_nav": "Cursor access",
|
||||
"onboarding_complete_title": "Setup is complete",
|
||||
"onboarding_complete_nav": "Complete",
|
||||
"onboarding_welcome_intro": "Welcome to the Friendolls setup wizard, which will guide you through setting up Friendolls.",
|
||||
@@ -99,19 +96,8 @@
|
||||
"onboarding_server_skip": "If you do not have a server yet, leave this empty and continue. You can add one later.",
|
||||
"onboarding_friend_skip": "If no one has shared a key with you yet, leave this empty and continue. You can add friends anytime afterwards.",
|
||||
"friend_key_verify": "Verify this key with your friend before adding it.",
|
||||
"accessibility_granted": "Accessibility access is granted. Puppets can now follow your cursor.",
|
||||
"accessibility_waiting": "Waiting for Accessibility permission.",
|
||||
"accessibility_why": "Why Friendolls needs this",
|
||||
"accessibility_explanation": "Friendolls reads the system cursor position so your puppet can follow you and your friends can see that movement. It does not use Accessibility access to read typed text, click buttons, or control other apps.",
|
||||
"accessibility_step_open": "Click Open Accessibility Settings.",
|
||||
"accessibility_step_enable": "Turn on Friendolls in Privacy & Security → Accessibility.",
|
||||
"accessibility_step_return": "Return here; this wizard confirms the change automatically.",
|
||||
"accessibility_access_granted": "Access granted",
|
||||
"accessibility_open_settings": "Open Accessibility Settings…",
|
||||
"accessibility_settings_opened": "macOS opened Accessibility settings. Enable Friendolls there; this page will confirm automatically.",
|
||||
"onboarding_complete_body": "Click Finish to save setup and launch Friendolls. The control panel remains available from the menu bar or system tray.",
|
||||
"onboarding_loading": "Loading setup",
|
||||
"onboarding_permission_repair": "Permission repair",
|
||||
"onboarding_step_count": "Step {current} of {total}",
|
||||
"onboarding_back": "< Back",
|
||||
"onboarding_working": "Working…",
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"error_friend_missing": "所选好友已不存在。",
|
||||
"error_profile_loading": "个人资料仍在加载中。",
|
||||
"error_skin_required": "请选择一张 64×64 的 PNG 皮肤图片后再继续。",
|
||||
"error_accessibility_required": "请先在 macOS 的“辅助功能”设置中启用 Friendolls。",
|
||||
"error_key_invalid_base64": "身份密钥不是有效的 base64url 内容。",
|
||||
"error_key_invalid_length": "身份密钥必须编码一个 32 字节的公钥。",
|
||||
"error_key_invalid_ed25519": "身份密钥不是有效的 Ed25519 公钥。",
|
||||
@@ -78,8 +77,6 @@
|
||||
"onboarding_server_nav": "添加服务器",
|
||||
"onboarding_friend_title": "添加好友",
|
||||
"onboarding_friend_nav": "添加好友",
|
||||
"onboarding_accessibility_title": "允许访问光标位置",
|
||||
"onboarding_accessibility_nav": "光标权限",
|
||||
"onboarding_complete_title": "设置已完成",
|
||||
"onboarding_complete_nav": "完成",
|
||||
"onboarding_welcome_intro": "欢迎使用 Friendolls 安装向导。本向导将帮助你完成 Friendolls 的初始设置。",
|
||||
@@ -99,19 +96,8 @@
|
||||
"onboarding_server_skip": "如果目前没有服务器,可以留空并继续,之后随时可以添加。",
|
||||
"onboarding_friend_skip": "如果还没有好友向你分享密钥,可以留空并继续,之后随时可以添加好友。",
|
||||
"friend_key_verify": "添加前,请与好友核对该密钥。",
|
||||
"accessibility_granted": "已获得辅助功能权限,玩偶现在可以跟随你的光标。",
|
||||
"accessibility_waiting": "正在等待辅助功能权限。",
|
||||
"accessibility_why": "Friendolls 为什么需要此权限",
|
||||
"accessibility_explanation": "Friendolls 会读取系统光标位置,让你的玩偶跟随光标移动,并让好友看到这一动作。Friendolls 不会利用辅助功能权限读取你输入的文字、点击按钮或控制其他应用。",
|
||||
"accessibility_step_open": "点击“打开辅助功能设置”。",
|
||||
"accessibility_step_enable": "在“隐私与安全性”→“辅助功能”中启用 Friendolls。",
|
||||
"accessibility_step_return": "返回此处;本向导会自动确认权限状态。",
|
||||
"accessibility_access_granted": "已授予权限",
|
||||
"accessibility_open_settings": "打开辅助功能设置…",
|
||||
"accessibility_settings_opened": "macOS 已打开“辅助功能”设置。请在其中启用 Friendolls,返回后此页面会自动确认权限状态。",
|
||||
"onboarding_complete_body": "点击“完成”保存设置并启动 Friendolls。之后仍可从菜单栏或系统托盘打开控制面板。",
|
||||
"onboarding_loading": "正在加载设置向导",
|
||||
"onboarding_permission_repair": "修复权限",
|
||||
"onboarding_step_count": "第 {current} 步,共 {total} 步",
|
||||
"onboarding_back": "< 上一步",
|
||||
"onboarding_working": "正在处理…",
|
||||
|
||||
@@ -29,7 +29,6 @@ image = { version = "0.25", default-features = false, features = ["gif", "jpeg",
|
||||
lazy_static = "1.5.0"
|
||||
ed25519-dalek = "3.0.0"
|
||||
getrandom = "0.4.3"
|
||||
device_query = "4.0.1"
|
||||
tokio = { version = "1.53.1", features = ["macros", "sync", "time"] }
|
||||
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-native-roots"] }
|
||||
futures-util = "0.3"
|
||||
@@ -60,7 +59,6 @@ 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"
|
||||
|
||||
@@ -9,7 +9,6 @@ struct ApplicationState {
|
||||
|
||||
pub async fn init(handle: &AppHandle) -> Result<(), String> {
|
||||
handle.manage(ApplicationState::default());
|
||||
let accessibility_permission_granted = crate::macos::init(handle).await?;
|
||||
let settings = crate::settings::get(&handle.state())
|
||||
.await
|
||||
.map_err(crate::db::command_error)?;
|
||||
@@ -17,17 +16,14 @@ pub async fn init(handle: &AppHandle) -> Result<(), String> {
|
||||
crate::ui::splashscreen::close(handle).await?;
|
||||
|
||||
if settings.onboarding_done {
|
||||
start(handle).await;
|
||||
if cfg!(target_os = "macos") && !accessibility_permission_granted {
|
||||
crate::ui::onboarding::show_accessibility_page(handle).await?;
|
||||
}
|
||||
start(handle);
|
||||
} else {
|
||||
crate::ui::onboarding::show_initial(handle).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn start(handle: &AppHandle) {
|
||||
pub fn start(handle: &AppHandle) {
|
||||
let state = handle.state::<ApplicationState>();
|
||||
if state
|
||||
.started
|
||||
@@ -37,8 +33,10 @@ pub async fn start(handle: &AppHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(error) = crate::cursor::start_tracking(handle) {
|
||||
eprintln!("failed to initialize cursor tracking: {error}");
|
||||
}
|
||||
crate::ui::init(handle);
|
||||
reconcile_cursor(handle).await;
|
||||
}
|
||||
|
||||
pub fn is_started(handle: &AppHandle) -> bool {
|
||||
@@ -47,13 +45,3 @@ pub fn is_started(handle: &AppHandle) -> bool {
|
||||
.started
|
||||
.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub async fn reconcile_cursor(handle: &AppHandle) {
|
||||
if !is_started(handle) || !crate::macos::accessibility_permission_granted(handle) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(error) = crate::cursor::start_tracking(handle).await {
|
||||
eprintln!("failed to initialize cursor tracking: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
+151
-202
@@ -1,5 +1,3 @@
|
||||
use device_query::{DeviceEvents, DeviceEventsHandler, DeviceQuery, DeviceState};
|
||||
use lazy_static::lazy_static;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
@@ -8,7 +6,10 @@ use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri_specta::Event;
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use tokio::sync::watch;
|
||||
|
||||
pub(crate) const SYSTEM_CURSOR_POLL_INTERVAL: Duration = Duration::from_millis(250);
|
||||
const CURSOR_BROADCAST_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -71,121 +72,87 @@ impl CursorState {
|
||||
}
|
||||
}
|
||||
|
||||
fn current_position(handle: &AppHandle) -> Result<CursorPositions, String> {
|
||||
let monitor = handle
|
||||
.primary_monitor()
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or("Primary monitor is unavailable")?;
|
||||
let position = DeviceState::checked_new()
|
||||
.ok_or_else(|| "System cursor access is unavailable".to_owned())?
|
||||
.get_mouse()
|
||||
.coords;
|
||||
fn read_system_cursor_position(handle: &AppHandle) -> Result<CursorPosition, String> {
|
||||
handle
|
||||
.cursor_position()
|
||||
.map(|position| CursorPosition {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let raw = CursorPosition {
|
||||
x: position.0 as f64,
|
||||
y: position.1 as f64,
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let raw = CursorPosition {
|
||||
x: position.0 as f64 * monitor.scale_factor(),
|
||||
y: position.1 as f64 * monitor.scale_factor(),
|
||||
};
|
||||
|
||||
Ok(CursorPositions {
|
||||
mapped: transform_cursor_pos(&raw, true, &monitor),
|
||||
fn positions_from_raw(raw: CursorPosition, monitor: &tauri::Monitor) -> CursorPositions {
|
||||
CursorPositions {
|
||||
mapped: transform_cursor_pos(&raw, true, monitor),
|
||||
raw,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Was private, but for some reason LSP
|
||||
// complains even when there's no external references.
|
||||
// Possibly because of `lazy_static!`.
|
||||
// Just leave it public I guess.
|
||||
struct CursorTask {
|
||||
stop_tx: watch::Sender<bool>,
|
||||
task: tauri::async_runtime::JoinHandle<()>,
|
||||
}
|
||||
|
||||
enum CursorTracker {
|
||||
Starting,
|
||||
Running(CursorTask),
|
||||
#[derive(Default)]
|
||||
pub(crate) struct CursorPositionProvider {
|
||||
latest: RwLock<Option<CursorPosition>>,
|
||||
tracker: Mutex<Option<CursorTask>>,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref CURSOR_TRACKER: Mutex<Option<CursorTracker>> = Mutex::new(None);
|
||||
impl CursorPositionProvider {
|
||||
pub(crate) fn latest(&self) -> Result<Option<CursorPosition>, String> {
|
||||
self.latest
|
||||
.read()
|
||||
.map(|position| position.clone())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn update(&self, position: CursorPosition) -> Result<(), String> {
|
||||
let mut latest = self.latest.write().map_err(|error| error.to_string())?;
|
||||
if latest.as_ref() != Some(&position) {
|
||||
*latest = Some(position);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(app: &AppHandle) {
|
||||
app.manage(CursorState::default());
|
||||
app.manage(CursorPositionProvider::default());
|
||||
}
|
||||
|
||||
/// Starts cursor tracking after Accessibility permission is available.
|
||||
pub async fn start_tracking(app: &AppHandle) -> Result<(), String> {
|
||||
if !crate::macos::accessibility_permission_granted(app) {
|
||||
return Err("macOS Accessibility permission has not been granted".to_owned());
|
||||
}
|
||||
|
||||
/// Starts the shared cursor provider and change-based cursor broadcasting.
|
||||
pub fn start_tracking(app: &AppHandle) -> Result<(), String> {
|
||||
let primary_monitor = app
|
||||
.primary_monitor()
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or_else(|| "Failed to resolve primary monitor".to_owned())?;
|
||||
|
||||
{
|
||||
let mut tracker = CURSOR_TRACKER
|
||||
.lock()
|
||||
.map_err(|error| format!("Failed to lock cursor tracker state: {error}"))?;
|
||||
if tracker.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
*tracker = Some(CursorTracker::Starting);
|
||||
let provider = app.state::<CursorPositionProvider>();
|
||||
let mut tracker = provider
|
||||
.tracker
|
||||
.lock()
|
||||
.map_err(|error| format!("Failed to lock cursor tracker state: {error}"))?;
|
||||
if tracker.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Initializing cursor tracking...");
|
||||
match current_position(app) {
|
||||
Ok(positions) => update_cursor_position(app, positions),
|
||||
Err(error) => eprintln!("Failed to resolve current cursor position: {error}"),
|
||||
}
|
||||
let initial_position = read_system_cursor_position(app)
|
||||
.map_err(|error| format!("Failed to resolve current cursor position: {error}"))?;
|
||||
provider.update(initial_position.clone())?;
|
||||
update_cursor_position(app, positions_from_raw(initial_position, &primary_monitor));
|
||||
|
||||
let (stop_tx, stop_rx) = watch::channel(false);
|
||||
let (ready_tx, ready_rx) = oneshot::channel();
|
||||
|
||||
let handle = app.clone();
|
||||
let task = tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = init_cursor_tracking_i(stop_rx, primary_monitor, handle, ready_tx).await {
|
||||
println!("Failed to initialize cursor tracking: {}", e);
|
||||
if let Err(error) = track_cursor(stop_rx, primary_monitor, handle).await {
|
||||
eprintln!("Cursor tracking stopped with an error: {error}");
|
||||
}
|
||||
});
|
||||
|
||||
let startup = ready_rx
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("Cursor tracking task stopped during initialization".to_owned()));
|
||||
if let Err(error) = startup {
|
||||
if let Ok(mut tracker) = CURSOR_TRACKER.lock()
|
||||
&& matches!(*tracker, Some(CursorTracker::Starting))
|
||||
{
|
||||
*tracker = None;
|
||||
}
|
||||
let _ = task.await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
let installed = {
|
||||
let mut tracker = CURSOR_TRACKER
|
||||
.lock()
|
||||
.map_err(|error| format!("Failed to lock cursor tracker state: {error}"))?;
|
||||
if matches!(*tracker, Some(CursorTracker::Starting)) {
|
||||
*tracker = Some(CursorTracker::Running(CursorTask { stop_tx, task }));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !installed {
|
||||
return Err("Cursor tracking startup was cancelled".to_owned());
|
||||
}
|
||||
*tracker = Some(CursorTask { stop_tx, task });
|
||||
|
||||
println!("EVENT: Cursor Tracker Enabled");
|
||||
Ok(())
|
||||
@@ -265,6 +232,86 @@ fn transform_coords(pos: &CursorPosition, to_normalized: bool, w: f64, h: f64) -
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop cursor tracking and unregister all listeners.
|
||||
#[allow(dead_code)] // TODO: get rid of this when app teardown sequence is introduced.
|
||||
pub async fn stop_cursor_tracking(app: &AppHandle) {
|
||||
println!("stop_cursor_tracking called");
|
||||
|
||||
let tracker = match app.state::<CursorPositionProvider>().tracker.lock() {
|
||||
Ok(mut tracker) => tracker.take(),
|
||||
Err(e) => {
|
||||
println!("Failed to lock cursor tracker state: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let tracker = match tracker {
|
||||
Some(tracker) => tracker,
|
||||
None => {
|
||||
println!("Cursor tracking is not running");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = tracker.stop_tx.send(true) {
|
||||
println!("Failed to signal cursor tracking stop: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = tracker.task.await {
|
||||
println!("Cursor tracking task join failed: {}", e);
|
||||
}
|
||||
|
||||
println!("EVENT: Cursor Tracker Disabled");
|
||||
}
|
||||
|
||||
async fn track_cursor(
|
||||
mut stop_rx: watch::Receiver<bool>,
|
||||
monitor: tauri::Monitor,
|
||||
handle: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let provider = handle.state::<CursorPositionProvider>();
|
||||
let mut last_broadcast_position = provider.latest()?;
|
||||
let mut poll_interval = tokio::time::interval(SYSTEM_CURSOR_POLL_INTERVAL);
|
||||
let mut broadcast_interval = tokio::time::interval(CURSOR_BROADCAST_INTERVAL);
|
||||
poll_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
broadcast_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
// The initial position was already sampled and broadcast by start_tracking.
|
||||
poll_interval.tick().await;
|
||||
broadcast_interval.tick().await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = poll_interval.tick() => {
|
||||
match read_system_cursor_position(&handle) {
|
||||
Ok(position) => provider.update(position)?,
|
||||
Err(error) => eprintln!("Failed to read system cursor position: {error}"),
|
||||
}
|
||||
}
|
||||
_ = broadcast_interval.tick() => {
|
||||
let latest = provider.latest()?;
|
||||
if latest != last_broadcast_position {
|
||||
if let Some(position) = latest.as_ref() {
|
||||
update_cursor_position(
|
||||
&handle,
|
||||
positions_from_raw(position.clone(), &monitor),
|
||||
);
|
||||
}
|
||||
last_broadcast_position = latest;
|
||||
}
|
||||
}
|
||||
changed = stop_rx.changed() => {
|
||||
if changed.is_err() || *stop_rx.borrow() {
|
||||
println!("Stopping cursor tracking");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -318,124 +365,26 @@ mod tests {
|
||||
assert_eq!(two_x.x, one_x.x);
|
||||
assert_eq!(two_x.y, one_x.y);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop cursor tracking and unregister all listeners.
|
||||
#[allow(dead_code)] // TODO: get rid of this when app teardown sequence is introduced.
|
||||
pub async fn stop_cursor_tracking() {
|
||||
println!("stop_cursor_tracking called");
|
||||
#[test]
|
||||
fn cursor_provider_keeps_the_latest_system_position() {
|
||||
let provider = CursorPositionProvider::default();
|
||||
assert_eq!(provider.latest().unwrap(), None);
|
||||
|
||||
let tracker = match CURSOR_TRACKER.lock() {
|
||||
Ok(mut tracker) => tracker.take(),
|
||||
Err(e) => {
|
||||
println!("Failed to lock cursor tracker state: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
provider
|
||||
.update(CursorPosition { x: 120.0, y: 80.0 })
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
provider.latest().unwrap(),
|
||||
Some(CursorPosition { x: 120.0, y: 80.0 })
|
||||
);
|
||||
|
||||
let tracker = match tracker {
|
||||
Some(CursorTracker::Running(tracker)) => tracker,
|
||||
Some(CursorTracker::Starting) => {
|
||||
println!("Cursor tracking startup cancelled");
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
println!("Cursor tracking is not running");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = tracker.stop_tx.send(true) {
|
||||
println!("Failed to signal cursor tracking stop: {}", e);
|
||||
provider
|
||||
.update(CursorPosition { x: 240.0, y: 160.0 })
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
provider.latest().unwrap(),
|
||||
Some(CursorPosition { x: 240.0, y: 160.0 })
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(e) = tracker.task.await {
|
||||
println!("Cursor tracking task join failed: {}", e);
|
||||
}
|
||||
|
||||
println!("EVENT: Cursor Tracker Disabled");
|
||||
}
|
||||
|
||||
async fn init_cursor_tracking_i(
|
||||
mut stop_rx: watch::Receiver<bool>,
|
||||
monitor: tauri::Monitor,
|
||||
handle: AppHandle,
|
||||
ready_tx: oneshot::Sender<Result<(), String>>,
|
||||
) -> Result<(), String> {
|
||||
// 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.
|
||||
// It runs independently of the device event loop.
|
||||
tauri::async_runtime::spawn(async move {
|
||||
println!("Cursor event consumer started");
|
||||
|
||||
while let Some(positions) = rx.recv().await {
|
||||
update_cursor_position(&handle, positions);
|
||||
}
|
||||
println!("Cursor event consumer stopped (channel closed)");
|
||||
});
|
||||
|
||||
let device_state = match DeviceEventsHandler::new(Duration::from_millis(500)) {
|
||||
Some(device_state) => device_state,
|
||||
None => {
|
||||
let error = "Failed to create device event handler (already running?)".to_owned();
|
||||
let _ = ready_tx.send(Err(error.clone()));
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
println!("Device event handler created successfully");
|
||||
println!("Setting up mouse move handler for event broadcasting...");
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let scale_factor = monitor.scale_factor();
|
||||
|
||||
// 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,
|
||||
y: position.1 as f64,
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let raw = CursorPosition {
|
||||
x: position.0 as f64 * scale_factor,
|
||||
y: position.1 as f64 * scale_factor,
|
||||
};
|
||||
|
||||
let mapped = transform_cursor_pos(&raw, true, &monitor);
|
||||
|
||||
let positions = CursorPositions { raw, mapped };
|
||||
|
||||
// Send to consumer channel (non-blocking)
|
||||
if let Err(e) = tx.try_send(positions) {
|
||||
println!("Failed to send cursor position to channel: {:?}", e);
|
||||
}
|
||||
});
|
||||
|
||||
ready_tx
|
||||
.send(Ok(()))
|
||||
.map_err(|_| "Cursor tracking startup was cancelled".to_owned())?;
|
||||
println!("Mouse move handler registered - now broadcasting cursor events to all windows");
|
||||
|
||||
// Keep the handler alive while tracking is enabled.
|
||||
// This loop is necessary to keep `_guard` and `device_state` in scope.
|
||||
while stop_rx.changed().await.is_ok() {
|
||||
if *stop_rx.borrow() {
|
||||
println!("Stopping cursor tracking event handler");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ mod images;
|
||||
mod interactions;
|
||||
mod keypair;
|
||||
mod live_data;
|
||||
mod macos;
|
||||
mod network;
|
||||
mod onboarding;
|
||||
mod profile;
|
||||
@@ -114,9 +113,7 @@ 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,
|
||||
settings::autostart::get_autostart_enabled,
|
||||
settings::autostart::set_autostart_enabled,
|
||||
settings::get_locale_settings,
|
||||
@@ -136,7 +133,6 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
interactions::FriendInteractionReceived,
|
||||
puppet::PuppetStatesChanged,
|
||||
scene_configuration::SceneConfigurationChanged,
|
||||
onboarding::OnboardingStatus,
|
||||
settings::LocaleChanged,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
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>()
|
||||
.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).await;
|
||||
} else {
|
||||
crate::ui::onboarding::show_accessibility_page(handle).await?;
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn permission_state_reports_only_actual_transitions() {
|
||||
let state = AccessibilityPermissionState::new(false);
|
||||
|
||||
assert!(!state.replace(false));
|
||||
assert!(state.replace(true));
|
||||
assert!(!state.replace(true));
|
||||
assert!(state.replace(false));
|
||||
}
|
||||
}
|
||||
@@ -1,56 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_specta::Event;
|
||||
|
||||
use crate::db::{self, AppDatabase};
|
||||
use crate::settings::AppSettings;
|
||||
|
||||
#[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(crate) async fn emit_status(
|
||||
handle: &AppHandle,
|
||||
database: &AppDatabase,
|
||||
) -> Result<OnboardingStatus, String> {
|
||||
let status = OnboardingStatus::new(
|
||||
handle,
|
||||
crate::settings::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 = crate::settings::get(&database)
|
||||
.await
|
||||
.map_err(db::command_error)?;
|
||||
Ok(OnboardingStatus::new(&handle, settings))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -58,15 +8,10 @@ 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());
|
||||
}
|
||||
|
||||
crate::settings::set_onboarding_done(&database, true)
|
||||
.await
|
||||
.map_err(db::command_error)?;
|
||||
emit_status(&handle, &database).await?;
|
||||
crate::application::start(&handle).await;
|
||||
crate::application::start(&handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -75,6 +20,7 @@ mod tests {
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
use super::*;
|
||||
use crate::settings::AppSettings;
|
||||
|
||||
async fn database() -> AppDatabase {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
|
||||
@@ -2,37 +2,30 @@ use tauri::{AppHandle, Manager, WebviewUrl};
|
||||
|
||||
const WINDOW_LABEL: &str = "onboarding";
|
||||
|
||||
async fn show(handle: &AppHandle, accessibility_only: bool) -> Result<(), String> {
|
||||
pub async fn show_initial(handle: &AppHandle) -> 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(crate::settings::text(
|
||||
handle,
|
||||
crate::settings::NativeText::OnboardingTitle,
|
||||
))
|
||||
.inner_size(680.0, 520.0)
|
||||
.min_inner_size(680.0, 520.0)
|
||||
.transparent(true)
|
||||
.resizable(false)
|
||||
.maximizable(false)
|
||||
.center()
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let window = tauri::WebviewWindowBuilder::new(
|
||||
handle,
|
||||
WINDOW_LABEL,
|
||||
WebviewUrl::App("/onboarding".into()),
|
||||
)
|
||||
.title(crate::settings::text(
|
||||
handle,
|
||||
crate::settings::NativeText::OnboardingTitle,
|
||||
))
|
||||
.inner_size(680.0, 520.0)
|
||||
.min_inner_size(680.0, 520.0)
|
||||
.transparent(true)
|
||||
.resizable(false)
|
||||
.maximizable(false)
|
||||
.center()
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let handle = handle.clone();
|
||||
window.on_window_event(move |event| {
|
||||
@@ -44,11 +37,3 @@ async fn show(handle: &AppHandle, accessibility_only: bool) -> Result<(), String
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn show_initial(handle: &AppHandle) -> Result<(), String> {
|
||||
show(handle, false).await
|
||||
}
|
||||
|
||||
pub async fn show_accessibility_page(handle: &AppHandle) -> Result<(), String> {
|
||||
show(handle, true).await
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{sync::RwLock, time::Duration};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use serde::Deserialize;
|
||||
use specta::Type;
|
||||
@@ -82,15 +82,22 @@ pub const WINDOW_LABEL: &str = "scene";
|
||||
fn track_scene_hitboxes(app_handle: AppHandle, window: tauri::WebviewWindow) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut ignores_cursor = true;
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(16));
|
||||
let mut interval = tokio::time::interval(crate::cursor::SYSTEM_CURSOR_POLL_INTERVAL);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
let cursor = match app_handle.cursor_position() {
|
||||
Ok(cursor) => cursor,
|
||||
let cursor = match app_handle
|
||||
.state::<crate::cursor::CursorPositionProvider>()
|
||||
.latest()
|
||||
{
|
||||
Ok(Some(cursor)) => cursor,
|
||||
Ok(None) => continue,
|
||||
Err(error) => {
|
||||
eprintln!("Failed to read cursor position for scene hit-testing: {error}");
|
||||
eprintln!(
|
||||
"Failed to read shared cursor position for scene hit-testing: {error}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,15 +80,9 @@ async getSceneConfiguration() : Promise<SceneConfiguration> {
|
||||
async updateSceneConfiguration(configuration: SceneConfiguration) : Promise<SceneConfiguration> {
|
||||
return await TAURI_INVOKE("update_scene_configuration", { configuration });
|
||||
},
|
||||
async getOnboardingStatus() : Promise<OnboardingStatus> {
|
||||
return await TAURI_INVOKE("get_onboarding_status");
|
||||
},
|
||||
async completeOnboarding() : Promise<null> {
|
||||
return await TAURI_INVOKE("complete_onboarding");
|
||||
},
|
||||
async requestAccessibilityPermission() : Promise<boolean> {
|
||||
return await TAURI_INVOKE("request_accessibility_permission");
|
||||
},
|
||||
async getAutostartEnabled() : Promise<boolean> {
|
||||
return await TAURI_INVOKE("get_autostart_enabled");
|
||||
},
|
||||
@@ -121,7 +115,6 @@ friendStatusesChanged: FriendStatusesChanged,
|
||||
friendsChanged: FriendsChanged,
|
||||
localeChanged: LocaleChanged,
|
||||
networkStatusChanged: NetworkStatusChanged,
|
||||
onboardingStatus: OnboardingStatus,
|
||||
profileChanged: ProfileChanged,
|
||||
puppetStatesChanged: PuppetStatesChanged,
|
||||
remotesChanged: RemotesChanged,
|
||||
@@ -135,7 +128,6 @@ friendStatusesChanged: "friend-statuses-changed",
|
||||
friendsChanged: "friends-changed",
|
||||
localeChanged: "locale-changed",
|
||||
networkStatusChanged: "network-status-changed",
|
||||
onboardingStatus: "onboarding-status",
|
||||
profileChanged: "profile-changed",
|
||||
puppetStatesChanged: "puppet-states-changed",
|
||||
remotesChanged: "remotes-changed",
|
||||
@@ -179,7 +171,6 @@ export type InteractionContent = { type: "text"; text: string } | { type: "wave"
|
||||
export type LiveDataSnapshot = { cursorPositions: Partial<{ [key in string]: CursorPositions }>; foregroundApps: Partial<{ [key in string]: AppMeta }> }
|
||||
export type LocaleChanged = { preference: string; locale: string }
|
||||
export type NetworkStatusChanged = { statuses: ConnectionStatus[] }
|
||||
export type OnboardingStatus = { onboardingDone: boolean; macosAccessibilityPermissionGranted: boolean; requiresAccessibilityPermission: boolean }
|
||||
export type ProfileChanged = { profile: User }
|
||||
export type PuppetMovementMode = "free" | "bottom"
|
||||
export type PuppetState = { id: string; position: CursorPosition; isMoving: boolean }
|
||||
|
||||
@@ -83,8 +83,6 @@ export function errorMessage(cause: unknown): string {
|
||||
return compiledMessages.error_profile_loading();
|
||||
case "Choose a 64×64 PNG skin before continuing.":
|
||||
return compiledMessages.error_skin_required();
|
||||
case "Turn on Friendolls in macOS Accessibility settings before continuing.":
|
||||
return compiledMessages.error_accessibility_required();
|
||||
case "Identification key is not valid base64url.":
|
||||
return compiledMessages.error_key_invalid_base64();
|
||||
case "Identification key must encode a 32-byte public key.":
|
||||
|
||||
@@ -3,15 +3,12 @@
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import {
|
||||
commands,
|
||||
events,
|
||||
type OnboardingStatus,
|
||||
type PuppetMovementMode,
|
||||
type RemoteInput,
|
||||
type SceneConfiguration,
|
||||
type User,
|
||||
} from "$lib/bindings";
|
||||
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||
import AccessibilityStep from "./components/accessibility-step.svelte";
|
||||
import CompleteStep from "./components/complete-step.svelte";
|
||||
import FriendStep from "./components/friend-step.svelte";
|
||||
import IdentityStep from "./components/identity-step.svelte";
|
||||
@@ -34,7 +31,6 @@
|
||||
| "movement"
|
||||
| "server"
|
||||
| "friend"
|
||||
| "accessibility"
|
||||
| "complete";
|
||||
|
||||
const allSteps: Array<{
|
||||
@@ -72,11 +68,6 @@
|
||||
title: (catalog) => catalog.onboarding_friend_title(),
|
||||
navigationLabel: (catalog) => catalog.onboarding_friend_nav(),
|
||||
},
|
||||
{
|
||||
id: "accessibility",
|
||||
title: (catalog) => catalog.onboarding_accessibility_title(),
|
||||
navigationLabel: (catalog) => catalog.onboarding_accessibility_nav(),
|
||||
},
|
||||
{
|
||||
id: "complete",
|
||||
title: (catalog) => catalog.onboarding_complete_title(),
|
||||
@@ -84,7 +75,6 @@
|
||||
},
|
||||
];
|
||||
|
||||
let status = $state<OnboardingStatus | null>(null);
|
||||
let profile = $state<User | null>(null);
|
||||
let configuration = $state<SceneConfiguration>({
|
||||
puppetScale: 1,
|
||||
@@ -92,7 +82,6 @@
|
||||
puppetMovementMode: "free",
|
||||
hideLocalPuppetWhenAlone: false,
|
||||
});
|
||||
let remediation = $state(false);
|
||||
let stepIndex = $state(0);
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
@@ -118,19 +107,8 @@
|
||||
});
|
||||
});
|
||||
|
||||
let steps = $derived(
|
||||
remediation
|
||||
? allSteps.filter((candidate) => candidate.id === "accessibility")
|
||||
: allSteps.filter(
|
||||
(candidate) =>
|
||||
candidate.id !== "accessibility" ||
|
||||
status?.requiresAccessibilityPermission,
|
||||
),
|
||||
);
|
||||
let steps = $derived(allSteps);
|
||||
let step = $derived(steps[stepIndex] ?? steps[0]);
|
||||
let permissionGranted = $derived(
|
||||
status?.macosAccessibilityPermissionGranted ?? false,
|
||||
);
|
||||
let isLastStep = $derived(stepIndex === steps.length - 1);
|
||||
|
||||
$effect(() => {
|
||||
@@ -138,22 +116,16 @@
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
unlisten = await events.onboardingStatus.listen((event) => {
|
||||
status = event.payload;
|
||||
});
|
||||
const [nextStatus, nextProfile, nextConfiguration] = await Promise.all([
|
||||
commands.getOnboardingStatus(),
|
||||
const [nextProfile, nextConfiguration] = await Promise.all([
|
||||
commands.getProfile(),
|
||||
commands.getSceneConfiguration(),
|
||||
]);
|
||||
if (disposed) return;
|
||||
|
||||
status = nextStatus;
|
||||
profile = nextProfile;
|
||||
configuration = nextConfiguration;
|
||||
displayName = nextProfile.displayNameConfigured
|
||||
@@ -165,9 +137,7 @@
|
||||
const requestedStep = new URLSearchParams(window.location.search).get(
|
||||
"step",
|
||||
);
|
||||
remediation =
|
||||
requestedStep === "accessibility" && nextStatus.onboardingDone;
|
||||
if (!remediation && requestedStep) {
|
||||
if (requestedStep) {
|
||||
const requestedIndex = steps.findIndex(
|
||||
(candidate) => candidate.id === requestedStep,
|
||||
);
|
||||
@@ -180,7 +150,6 @@
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
});
|
||||
|
||||
@@ -256,8 +225,6 @@
|
||||
savedFriendId = (await commands.createFriend(nextFriendId)).id;
|
||||
if (previousFriendId) await commands.deleteFriend(previousFriendId);
|
||||
}
|
||||
} else if (step.id === "accessibility" && !permissionGranted) {
|
||||
throw new Error($messages.error_accessibility_required());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,9 +234,7 @@
|
||||
error = "";
|
||||
try {
|
||||
await saveCurrentStep();
|
||||
if (remediation) {
|
||||
await getCurrentWindow().close();
|
||||
} else if (isLastStep) {
|
||||
if (isLastStep) {
|
||||
await commands.completeOnboarding();
|
||||
await getCurrentWindow().close();
|
||||
} else {
|
||||
@@ -288,21 +253,6 @@
|
||||
error = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function requestAccessibilityPermission() {
|
||||
busy = true;
|
||||
error = "";
|
||||
try {
|
||||
const granted = await commands.requestAccessibilityPermission();
|
||||
if (!granted) {
|
||||
error = $messages.accessibility_settings_opened();
|
||||
}
|
||||
} catch (cause) {
|
||||
error = errorMessage(cause);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{$messages.onboarding_window_title()}</title></svelte:head>
|
||||
@@ -345,23 +295,17 @@
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
{#if error}
|
||||
<div class="mb-3">
|
||||
<PanelMessage
|
||||
kind={step.id === "accessibility" && !permissionGranted
|
||||
? "warning"
|
||||
: "error"}>{error}</PanelMessage
|
||||
>
|
||||
<PanelMessage kind="error">{error}</PanelMessage>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !status || !profile}
|
||||
{#if !profile}
|
||||
<div class="space-y-3" aria-label={$messages.onboarding_loading()}>
|
||||
<div class="skeleton h-5 w-2/3"></div>
|
||||
<div class="skeleton h-24 w-full"></div>
|
||||
</div>
|
||||
{:else if step.id === "welcome"}
|
||||
<WelcomeStep
|
||||
requiresAccessibilityPermission={status.requiresAccessibilityPermission}
|
||||
/>
|
||||
<WelcomeStep />
|
||||
{:else if step.id === "identity"}
|
||||
<IdentityStep bind:displayName {busy} />
|
||||
{:else if step.id === "skin"}
|
||||
@@ -385,12 +329,6 @@
|
||||
/>
|
||||
{:else if step.id === "friend"}
|
||||
<FriendStep bind:friendId {busy} />
|
||||
{:else if step.id === "accessibility"}
|
||||
<AccessibilityStep
|
||||
{permissionGranted}
|
||||
{busy}
|
||||
onrequest={requestAccessibilityPermission}
|
||||
/>
|
||||
{:else if step.id === "complete"}
|
||||
<CompleteStep />
|
||||
{/if}
|
||||
@@ -400,36 +338,29 @@
|
||||
class="flex shrink-0 items-center justify-between border-t border-base-300 bg-base-200 px-4 py-3"
|
||||
>
|
||||
<span class="text-[10px] text-base-content/60">
|
||||
{remediation
|
||||
? $messages.onboarding_permission_repair()
|
||||
: $messages.onboarding_step_count({
|
||||
current: stepIndex + 1,
|
||||
total: steps.length,
|
||||
})}
|
||||
{$messages.onboarding_step_count({
|
||||
current: stepIndex + 1,
|
||||
total: steps.length,
|
||||
})}
|
||||
</span>
|
||||
<div class="flex justify-end gap-2">
|
||||
{#if !remediation}
|
||||
<button
|
||||
class="btn min-w-20"
|
||||
type="button"
|
||||
disabled={busy || stepIndex === 0}
|
||||
onclick={back}>{$messages.onboarding_back()}</button
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
class="btn min-w-20"
|
||||
type="button"
|
||||
disabled={busy ||
|
||||
(step.id === "accessibility" && !permissionGranted)}
|
||||
disabled={busy || stepIndex === 0}
|
||||
onclick={back}>{$messages.onboarding_back()}</button
|
||||
>
|
||||
<button
|
||||
class="btn min-w-20"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onclick={next}
|
||||
>
|
||||
{busy
|
||||
? $messages.onboarding_working()
|
||||
: remediation
|
||||
? $messages.onboarding_done()
|
||||
: isLastStep
|
||||
? $messages.onboarding_finish()
|
||||
: $messages.onboarding_next()}
|
||||
: isLastStep
|
||||
? $messages.onboarding_finish()
|
||||
: $messages.onboarding_next()}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<script lang="ts">
|
||||
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||
import { messages } from "$lib/i18n";
|
||||
|
||||
let {
|
||||
permissionGranted,
|
||||
busy,
|
||||
onrequest,
|
||||
}: { permissionGranted: boolean; busy: boolean; onrequest: () => void } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<PanelMessage kind={permissionGranted ? "success" : "warning"}>
|
||||
{permissionGranted
|
||||
? $messages.accessibility_granted()
|
||||
: $messages.accessibility_waiting()}
|
||||
</PanelMessage>
|
||||
<fieldset class="fieldset border border-base-300 bg-base-100 p-4">
|
||||
<legend class="fieldset-legend px-1">{$messages.accessibility_why()}</legend>
|
||||
<p class="text-xs leading-relaxed">{$messages.accessibility_explanation()}</p>
|
||||
<ol class="mt-3 list-decimal space-y-1 pl-5 text-xs">
|
||||
<li>{$messages.accessibility_step_open()}</li>
|
||||
<li>{$messages.accessibility_step_enable()}</li>
|
||||
<li>{$messages.accessibility_step_return()}</li>
|
||||
</ol>
|
||||
<button
|
||||
class="btn mt-4 w-fit"
|
||||
type="button"
|
||||
disabled={busy || permissionGranted}
|
||||
onclick={onrequest}
|
||||
>
|
||||
{permissionGranted
|
||||
? $messages.accessibility_access_granted()
|
||||
: $messages.accessibility_open_settings()}
|
||||
</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
@@ -8,10 +8,6 @@
|
||||
} from "$lib/i18n";
|
||||
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||
|
||||
let {
|
||||
requiresAccessibilityPermission,
|
||||
}: { requiresAccessibilityPermission: boolean } = $props();
|
||||
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user