setup wizard & macOS accessibility thing
This commit is contained in:
Generated
+1
@@ -1365,6 +1365,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"image",
|
"image",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
|
"macos-accessibility-client",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ windows = { version = "0.58", features = [
|
|||||||
] }
|
] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
macos-accessibility-client = "0.0.1"
|
||||||
objc2 = "0.6.3"
|
objc2 = "0.6.3"
|
||||||
objc2-app-kit = "0.3.2"
|
objc2-app-kit = "0.3.2"
|
||||||
objc2-foundation = "0.3.2"
|
objc2-foundation = "0.3.2"
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for Friendolls UI windows",
|
"description": "Capability for Friendolls UI windows",
|
||||||
"windows": ["control-panel", "control-panel-action-*", "debug", "scene"],
|
"windows": [
|
||||||
|
"control-panel",
|
||||||
|
"control-panel-action-*",
|
||||||
|
"debug",
|
||||||
|
"onboarding",
|
||||||
|
"scene"
|
||||||
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-close",
|
"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()
|
.primary_monitor()
|
||||||
.map_err(|error| error.to_string())?
|
.map_err(|error| error.to_string())?
|
||||||
.ok_or("Primary monitor is unavailable")?;
|
.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")]
|
#[cfg(target_os = "windows")]
|
||||||
let raw = CursorPosition {
|
let raw = CursorPosition {
|
||||||
@@ -111,20 +114,23 @@ lazy_static! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize cursor tracking.
|
/// Initialize cursor tracking.
|
||||||
pub fn init(app: &AppHandle) {
|
pub fn init(app: &AppHandle) -> Result<(), String> {
|
||||||
println!("init_cursor_tracking called");
|
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() {
|
let mut tracker = match CURSOR_TASK.lock() {
|
||||||
Ok(tracker) => tracker,
|
Ok(tracker) => tracker,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
println!("Failed to lock cursor tracker state: {}", e);
|
return Err(format!("Failed to lock cursor tracker state: {e}"));
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if tracker.is_some() {
|
if tracker.is_some() {
|
||||||
println!("Cursor tracking already initialized");
|
println!("Cursor tracking already initialized");
|
||||||
return;
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
match current_position(app) {
|
match current_position(app) {
|
||||||
@@ -138,8 +144,8 @@ pub fn init(app: &AppHandle) {
|
|||||||
|
|
||||||
let primary_monitor = app
|
let primary_monitor = app
|
||||||
.primary_monitor()
|
.primary_monitor()
|
||||||
.expect("Failed to resolve primary monitor")
|
.map_err(|error| error.to_string())?
|
||||||
.expect("Failed to resolve primary monitor");
|
.ok_or_else(|| "Failed to resolve primary monitor".to_owned())?;
|
||||||
|
|
||||||
let handle = app.clone();
|
let handle = app.clone();
|
||||||
let task = tauri::async_runtime::spawn(async move {
|
let task = tauri::async_runtime::spawn(async move {
|
||||||
@@ -150,6 +156,7 @@ pub fn init(app: &AppHandle) {
|
|||||||
|
|
||||||
*tracker = Some(CursorTask { stop_tx, task });
|
*tracker = Some(CursorTask { stop_tx, task });
|
||||||
println!("EVENT: Cursor Tracker Enabled");
|
println!("EVENT: Cursor Tracker Enabled");
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
@@ -320,6 +327,7 @@ async fn init_cursor_tracking_i(
|
|||||||
// Create a channel to decouple event generation (producer) from processing (consumer).
|
// Create a channel to decouple event generation (producer) from processing (consumer).
|
||||||
// Capacity 100 is plenty for 500ms polling (2Hz).
|
// Capacity 100 is plenty for 500ms polling (2Hz).
|
||||||
let (tx, mut rx) = mpsc::channel::<CursorPositions>(100);
|
let (tx, mut rx) = mpsc::channel::<CursorPositions>(100);
|
||||||
|
let permission_handle = handle.clone();
|
||||||
|
|
||||||
// Spawn the consumer task
|
// Spawn the consumer task
|
||||||
// This task handles WebSocket reporting and local position projection updates.
|
// 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.
|
// The producer closure moves `tx` into it.
|
||||||
// device_query runs this closure on its own thread.
|
// device_query runs this closure on its own thread.
|
||||||
let _guard = device_state.on_mouse_move(move |position: &(i32, i32)| {
|
let _guard = device_state.on_mouse_move(move |position: &(i32, i32)| {
|
||||||
|
if !crate::macos::accessibility_permission_granted(&permission_handle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let raw = CursorPosition {
|
let raw = CursorPosition {
|
||||||
x: position.0 as f64,
|
x: position.0 as f64,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod application;
|
||||||
mod cursor;
|
mod cursor;
|
||||||
mod db;
|
mod db;
|
||||||
mod friends;
|
mod friends;
|
||||||
@@ -5,7 +6,9 @@ mod images;
|
|||||||
mod interactions;
|
mod interactions;
|
||||||
mod keypair;
|
mod keypair;
|
||||||
mod live_data;
|
mod live_data;
|
||||||
|
mod macos;
|
||||||
mod network;
|
mod network;
|
||||||
|
mod onboarding;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod puppet;
|
mod puppet;
|
||||||
mod remotes;
|
mod remotes;
|
||||||
@@ -30,9 +33,8 @@ async fn launch_app(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>>
|
|||||||
app.manage(puppet::PuppetStateStore::default());
|
app.manage(puppet::PuppetStateStore::default());
|
||||||
network::init(handle).await?;
|
network::init(handle).await?;
|
||||||
ufa::init(handle);
|
ufa::init(handle);
|
||||||
cursor::init(handle);
|
|
||||||
puppet::init(handle)?;
|
puppet::init(handle)?;
|
||||||
ui::init(handle);
|
application::init(handle).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +94,9 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
|||||||
puppet::list_puppet_states,
|
puppet::list_puppet_states,
|
||||||
scene_configuration::get_scene_configuration,
|
scene_configuration::get_scene_configuration,
|
||||||
scene_configuration::update_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::control_panel::open_action_window,
|
||||||
ui::scene::update_scene_hitboxes,
|
ui::scene::update_scene_hitboxes,
|
||||||
])
|
])
|
||||||
@@ -107,6 +112,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
|||||||
interactions::FriendInteractionReceived,
|
interactions::FriendInteractionReceived,
|
||||||
puppet::PuppetStatesChanged,
|
puppet::PuppetStatesChanged,
|
||||||
scene_configuration::SceneConfigurationChanged,
|
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;
|
pub mod control_panel;
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
mod debug;
|
mod debug;
|
||||||
|
pub mod onboarding;
|
||||||
pub mod scene;
|
pub mod scene;
|
||||||
mod tray;
|
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)
|
||||||
|
}
|
||||||
+29
@@ -107,3 +107,32 @@ input {
|
|||||||
|
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-gridded {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent 24%,
|
||||||
|
var(--color-primary) 25%,
|
||||||
|
var(--color-primary) 26%,
|
||||||
|
transparent 27%,
|
||||||
|
transparent 74%,
|
||||||
|
var(--color-primary) 75%,
|
||||||
|
var(--color-primary) 76%,
|
||||||
|
transparent 77%,
|
||||||
|
transparent
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 24%,
|
||||||
|
var(--color-primary) 25%,
|
||||||
|
var(--color-primary) 26%,
|
||||||
|
transparent 27%,
|
||||||
|
transparent 74%,
|
||||||
|
var(--color-primary) 75%,
|
||||||
|
var(--color-primary) 76%,
|
||||||
|
transparent 77%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
background-size: 32px 32px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,6 +80,15 @@ async getSceneConfiguration() : Promise<SceneConfiguration> {
|
|||||||
async updateSceneConfiguration(configuration: SceneConfiguration) : Promise<SceneConfiguration> {
|
async updateSceneConfiguration(configuration: SceneConfiguration) : Promise<SceneConfiguration> {
|
||||||
return await TAURI_INVOKE("update_scene_configuration", { configuration });
|
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 openActionWindow(name: string, title: string, pageUrl: string) : Promise<null> {
|
async openActionWindow(name: string, title: string, pageUrl: string) : Promise<null> {
|
||||||
return await TAURI_INVOKE("open_action_window", { name, title, pageUrl });
|
return await TAURI_INVOKE("open_action_window", { name, title, pageUrl });
|
||||||
},
|
},
|
||||||
@@ -99,6 +108,7 @@ friendInteractionReceived: FriendInteractionReceived,
|
|||||||
friendStatusesChanged: FriendStatusesChanged,
|
friendStatusesChanged: FriendStatusesChanged,
|
||||||
friendsChanged: FriendsChanged,
|
friendsChanged: FriendsChanged,
|
||||||
networkStatusChanged: NetworkStatusChanged,
|
networkStatusChanged: NetworkStatusChanged,
|
||||||
|
onboardingStatus: OnboardingStatus,
|
||||||
profileChanged: ProfileChanged,
|
profileChanged: ProfileChanged,
|
||||||
puppetStatesChanged: PuppetStatesChanged,
|
puppetStatesChanged: PuppetStatesChanged,
|
||||||
remotesChanged: RemotesChanged,
|
remotesChanged: RemotesChanged,
|
||||||
@@ -111,6 +121,7 @@ friendInteractionReceived: "friend-interaction-received",
|
|||||||
friendStatusesChanged: "friend-statuses-changed",
|
friendStatusesChanged: "friend-statuses-changed",
|
||||||
friendsChanged: "friends-changed",
|
friendsChanged: "friends-changed",
|
||||||
networkStatusChanged: "network-status-changed",
|
networkStatusChanged: "network-status-changed",
|
||||||
|
onboardingStatus: "onboarding-status",
|
||||||
profileChanged: "profile-changed",
|
profileChanged: "profile-changed",
|
||||||
puppetStatesChanged: "puppet-states-changed",
|
puppetStatesChanged: "puppet-states-changed",
|
||||||
remotesChanged: "remotes-changed",
|
remotesChanged: "remotes-changed",
|
||||||
@@ -153,6 +164,7 @@ export type FriendsChanged = { friends: Friend[] }
|
|||||||
export type InteractionContent = { type: "text"; text: string } | { type: "wave" } | { type: "image"; mediaType: string; data: string }
|
export type InteractionContent = { type: "text"; text: string } | { type: "wave" } | { type: "image"; mediaType: string; data: string }
|
||||||
export type LiveDataSnapshot = { cursorPositions: Partial<{ [key in string]: CursorPositions }>; foregroundApps: Partial<{ [key in string]: AppMeta }> }
|
export type LiveDataSnapshot = { cursorPositions: Partial<{ [key in string]: CursorPositions }>; foregroundApps: Partial<{ [key in string]: AppMeta }> }
|
||||||
export type NetworkStatusChanged = { statuses: ConnectionStatus[] }
|
export type NetworkStatusChanged = { statuses: ConnectionStatus[] }
|
||||||
|
export type OnboardingStatus = { onboardingDone: boolean; macosAccessibilityPermissionGranted: boolean; requiresAccessibilityPermission: boolean }
|
||||||
export type ProfileChanged = { profile: User }
|
export type ProfileChanged = { profile: User }
|
||||||
export type PuppetMovementMode = "free" | "bottom"
|
export type PuppetMovementMode = "free" | "bottom"
|
||||||
export type PuppetState = { id: string; position: CursorPosition; isMoving: boolean }
|
export type PuppetState = { id: string; position: CursorPosition; isMoving: boolean }
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
kind = "info",
|
kind = "info",
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
kind?: "info" | "error" | "warning";
|
kind?: "info" | "success" | "error" | "warning";
|
||||||
children: import("svelte").Snippet;
|
children: import("svelte").Snippet;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class:alert-info={kind === "info"}
|
class:alert-info={kind === "info"}
|
||||||
|
class:alert-success={kind === "success"}
|
||||||
class:alert-error={kind === "error"}
|
class:alert-error={kind === "error"}
|
||||||
class:alert-warning={kind === "warning"}
|
class:alert-warning={kind === "warning"}
|
||||||
class="alert alert-soft min-h-0 px-2 py-1.5 text-xs"
|
class="alert alert-soft min-h-0 px-2 py-1.5 text-xs"
|
||||||
|
|||||||
@@ -301,34 +301,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
|
||||||
.bg-gridded {
|
|
||||||
background-image:
|
|
||||||
linear-gradient(
|
|
||||||
0deg,
|
|
||||||
transparent 24%,
|
|
||||||
var(--color-primary) 25%,
|
|
||||||
var(--color-primary) 26%,
|
|
||||||
transparent 27%,
|
|
||||||
transparent 74%,
|
|
||||||
var(--color-primary) 75%,
|
|
||||||
var(--color-primary) 76%,
|
|
||||||
transparent 77%,
|
|
||||||
transparent
|
|
||||||
),
|
|
||||||
linear-gradient(
|
|
||||||
90deg,
|
|
||||||
transparent 24%,
|
|
||||||
var(--color-primary) 25%,
|
|
||||||
var(--color-primary) 26%,
|
|
||||||
transparent 27%,
|
|
||||||
transparent 74%,
|
|
||||||
var(--color-primary) 75%,
|
|
||||||
var(--color-primary) 76%,
|
|
||||||
transparent 77%,
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
background-size: 32px 32px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
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";
|
||||||
|
import MovementStep from "./components/movement-step.svelte";
|
||||||
|
import ServerStep from "./components/server-step.svelte";
|
||||||
|
import SkinStep from "./components/skin-step.svelte";
|
||||||
|
import WelcomeStep from "./components/welcome-step.svelte";
|
||||||
|
|
||||||
|
type StepId =
|
||||||
|
| "welcome"
|
||||||
|
| "identity"
|
||||||
|
| "skin"
|
||||||
|
| "movement"
|
||||||
|
| "server"
|
||||||
|
| "friend"
|
||||||
|
| "accessibility"
|
||||||
|
| "complete";
|
||||||
|
|
||||||
|
const allSteps: Array<{
|
||||||
|
id: StepId;
|
||||||
|
title: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: "welcome",
|
||||||
|
title: "Welcome to Friendolls!",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "identity",
|
||||||
|
title: "Choose your Display Name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "skin",
|
||||||
|
title: "Choose your Puppet's Skin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "movement",
|
||||||
|
title: "Choose your Movement Mode",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "server",
|
||||||
|
title: "Add a server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "friend",
|
||||||
|
title: "Add a friend",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "accessibility",
|
||||||
|
title: "Allow cursor access",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "complete",
|
||||||
|
title: "Setup is complete",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let status = $state<OnboardingStatus | null>(null);
|
||||||
|
let profile = $state<User | null>(null);
|
||||||
|
let configuration = $state<SceneConfiguration>({
|
||||||
|
puppetScale: 1,
|
||||||
|
puppetOpacity: 1,
|
||||||
|
puppetMovementMode: "free",
|
||||||
|
});
|
||||||
|
let remediation = $state(false);
|
||||||
|
let stepIndex = $state(0);
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state("");
|
||||||
|
|
||||||
|
let displayName = $state("");
|
||||||
|
let skinMode = $state<"current" | "default" | "custom">("default");
|
||||||
|
let skinFile = $state<File | null>(null);
|
||||||
|
let skinPreviewUrl = $state<string | null>(null);
|
||||||
|
let movementMode = $state<PuppetMovementMode>("free");
|
||||||
|
let serverName = $state("");
|
||||||
|
let serverAddress = $state("");
|
||||||
|
let serverPort = $state("");
|
||||||
|
let savedServerId = $state<string | null>(null);
|
||||||
|
let friendId = $state("");
|
||||||
|
let savedFriendId = $state<string | null>(null);
|
||||||
|
|
||||||
|
let steps = $derived(
|
||||||
|
remediation
|
||||||
|
? allSteps.filter((candidate) => candidate.id === "accessibility")
|
||||||
|
: allSteps.filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.id !== "accessibility" ||
|
||||||
|
status?.requiresAccessibilityPermission,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let step = $derived(steps[stepIndex] ?? steps[0]);
|
||||||
|
let permissionGranted = $derived(
|
||||||
|
status?.macosAccessibilityPermissionGranted ?? false,
|
||||||
|
);
|
||||||
|
let isLastStep = $derived(stepIndex === steps.length - 1);
|
||||||
|
|
||||||
|
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(),
|
||||||
|
commands.getProfile(),
|
||||||
|
commands.getSceneConfiguration(),
|
||||||
|
]);
|
||||||
|
if (disposed) return;
|
||||||
|
|
||||||
|
status = nextStatus;
|
||||||
|
profile = nextProfile;
|
||||||
|
configuration = nextConfiguration;
|
||||||
|
displayName =
|
||||||
|
nextProfile.displayName === "Anonymous"
|
||||||
|
? ""
|
||||||
|
: nextProfile.displayName;
|
||||||
|
skinMode = nextProfile.skinHash ? "current" : "default";
|
||||||
|
movementMode = nextConfiguration.puppetMovementMode;
|
||||||
|
|
||||||
|
const requestedStep = new URLSearchParams(window.location.search).get(
|
||||||
|
"step",
|
||||||
|
);
|
||||||
|
remediation =
|
||||||
|
requestedStep === "accessibility" && nextStatus.onboardingDone;
|
||||||
|
if (!remediation && requestedStep) {
|
||||||
|
const requestedIndex = steps.findIndex(
|
||||||
|
(candidate) => candidate.id === requestedStep,
|
||||||
|
);
|
||||||
|
if (requestedIndex >= 0) stepIndex = requestedIndex;
|
||||||
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
if (!disposed) error = String(cause);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unlisten?.();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (skinPreviewUrl) URL.revokeObjectURL(skinPreviewUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectSkin(file: File | null) {
|
||||||
|
if (skinPreviewUrl) URL.revokeObjectURL(skinPreviewUrl);
|
||||||
|
skinFile = file;
|
||||||
|
skinPreviewUrl = file ? URL.createObjectURL(file) : null;
|
||||||
|
if (file) skinMode = "custom";
|
||||||
|
error = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function remoteInput(): RemoteInput | null {
|
||||||
|
if (!serverAddress.trim()) return null;
|
||||||
|
const port = serverPort ? Number(serverPort) : null;
|
||||||
|
if (
|
||||||
|
port !== null &&
|
||||||
|
(!Number.isInteger(port) || port < 1 || port > 65535)
|
||||||
|
) {
|
||||||
|
throw new Error("Port must be a whole number from 1 to 65535.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
address: serverAddress.trim(),
|
||||||
|
name: serverName.trim() || null,
|
||||||
|
port,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCurrentStep() {
|
||||||
|
if (step.id === "identity") {
|
||||||
|
const nextName = displayName.trim();
|
||||||
|
if (!nextName) throw new Error("Display name cannot be empty.");
|
||||||
|
profile = await commands.updateProfile(nextName, null);
|
||||||
|
displayName = profile.displayName;
|
||||||
|
} else if (step.id === "skin") {
|
||||||
|
if (!profile) throw new Error("Your profile is still loading.");
|
||||||
|
if (skinMode === "default" && profile.skinHash) {
|
||||||
|
profile = await commands.resetProfileSkin();
|
||||||
|
} else if (skinMode === "custom" && skinFile) {
|
||||||
|
const skinData = Array.from(
|
||||||
|
new Uint8Array(await skinFile.arrayBuffer()),
|
||||||
|
);
|
||||||
|
profile = await commands.updateProfile(profile.displayName, skinData);
|
||||||
|
selectSkin(null);
|
||||||
|
skinMode = "current";
|
||||||
|
} else if (skinMode === "custom") {
|
||||||
|
throw new Error("Choose a 64×64 PNG skin before continuing.");
|
||||||
|
}
|
||||||
|
} else if (step.id === "movement") {
|
||||||
|
configuration = await commands.updateSceneConfiguration({
|
||||||
|
...configuration,
|
||||||
|
puppetMovementMode: movementMode,
|
||||||
|
});
|
||||||
|
} else if (step.id === "server") {
|
||||||
|
const remote = remoteInput();
|
||||||
|
if (remote) {
|
||||||
|
if (savedServerId) {
|
||||||
|
await commands.updateRemote(savedServerId, remote);
|
||||||
|
} else {
|
||||||
|
savedServerId = (await commands.createRemote(remote)).id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (step.id === "friend") {
|
||||||
|
const nextFriendId = friendId.trim();
|
||||||
|
if (nextFriendId && nextFriendId !== savedFriendId) {
|
||||||
|
const previousFriendId = savedFriendId;
|
||||||
|
savedFriendId = (await commands.createFriend(nextFriendId)).id;
|
||||||
|
if (previousFriendId) await commands.deleteFriend(previousFriendId);
|
||||||
|
}
|
||||||
|
} else if (step.id === "accessibility" && !permissionGranted) {
|
||||||
|
throw new Error(
|
||||||
|
"Turn on Friendolls in macOS Accessibility settings before continuing.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function next() {
|
||||||
|
if (!step) return;
|
||||||
|
busy = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
await saveCurrentStep();
|
||||||
|
if (remediation) {
|
||||||
|
await getCurrentWindow().close();
|
||||||
|
} else if (isLastStep) {
|
||||||
|
await commands.completeOnboarding();
|
||||||
|
await getCurrentWindow().close();
|
||||||
|
} else {
|
||||||
|
stepIndex += 1;
|
||||||
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
error = String(cause);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function back() {
|
||||||
|
if (stepIndex > 0 && !busy) {
|
||||||
|
stepIndex -= 1;
|
||||||
|
error = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestAccessibilityPermission() {
|
||||||
|
busy = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const granted = await commands.requestAccessibilityPermission();
|
||||||
|
if (!granted) {
|
||||||
|
error =
|
||||||
|
"macOS opened Accessibility settings. Enable Friendolls there; this page will confirm automatically.";
|
||||||
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
error = String(cause);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Friendolls Setup</title></svelte:head>
|
||||||
|
|
||||||
|
<main class="flex h-full min-h-0 flex-col bg-base-200 text-base-content">
|
||||||
|
<div class="grid min-h-0 flex-1 grid-cols-[11rem_minmax(0,1fr)]">
|
||||||
|
<aside
|
||||||
|
class="flex flex-col bg-linear-to-b from-primary to-primary/50 p-5 text-primary-content"
|
||||||
|
>
|
||||||
|
<div class="text-lg font-bold leading-tight">Friendolls<br />Setup</div>
|
||||||
|
<div class="mt-6 flex-1 space-y-2 text-[11px]">
|
||||||
|
{#each steps as candidate, index (candidate.id)}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 opacity-60"
|
||||||
|
class:font-bold={index === stepIndex}
|
||||||
|
class:opacity-100={index <= stepIndex}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="grid size-4 place-content-center border border-primary-content text-[9px]"
|
||||||
|
class:bg-primary-content={index < stepIndex}
|
||||||
|
class:text-primary={index < stepIndex}
|
||||||
|
>
|
||||||
|
{index < stepIndex ? "✓" : index + 1}
|
||||||
|
</span>
|
||||||
|
<span>{candidate.title.replace("Choose your ", "")}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] opacity-70">Friendolls 0.1</p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="flex min-h-0 flex-col bg-base-100">
|
||||||
|
{#if step}
|
||||||
|
<header class="bg-base-100 px-6 pt-4">
|
||||||
|
<p class="font-bold text-2xl">{step.title}</p>
|
||||||
|
<!-- <p class="text-xs text-base-content/65">{step.description}</p> -->
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !status || !profile}
|
||||||
|
<div class="space-y-3" aria-label="Loading setup">
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
{:else if step.id === "identity"}
|
||||||
|
<IdentityStep bind:displayName {busy} />
|
||||||
|
{:else if step.id === "skin"}
|
||||||
|
<SkinStep
|
||||||
|
{profile}
|
||||||
|
{configuration}
|
||||||
|
bind:skinMode
|
||||||
|
{skinPreviewUrl}
|
||||||
|
{busy}
|
||||||
|
onselect={selectSkin}
|
||||||
|
/>
|
||||||
|
{:else if step.id === "movement"}
|
||||||
|
<MovementStep bind:movementMode {busy} />
|
||||||
|
{:else if step.id === "server"}
|
||||||
|
<ServerStep
|
||||||
|
bind:serverName
|
||||||
|
bind:serverAddress
|
||||||
|
bind:serverPort
|
||||||
|
{busy}
|
||||||
|
/>
|
||||||
|
{: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}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer
|
||||||
|
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
|
||||||
|
? "Permission repair"
|
||||||
|
: `Step ${stepIndex + 1} of ${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}>< Back</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="btn min-w-20"
|
||||||
|
type="button"
|
||||||
|
disabled={busy ||
|
||||||
|
(step.id === "accessibility" && !permissionGranted)}
|
||||||
|
onclick={next}
|
||||||
|
>
|
||||||
|
{busy
|
||||||
|
? "Working…"
|
||||||
|
: remediation
|
||||||
|
? "Done"
|
||||||
|
: isLastStep
|
||||||
|
? "Finish"
|
||||||
|
: "Next >"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
permissionGranted,
|
||||||
|
busy,
|
||||||
|
onrequest,
|
||||||
|
}: { permissionGranted: boolean; busy: boolean; onrequest: () => void } =
|
||||||
|
$props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<PanelMessage kind={permissionGranted ? "success" : "warning"}>
|
||||||
|
{permissionGranted
|
||||||
|
? "Accessibility access is granted. Puppets can now follow your cursor."
|
||||||
|
: "Accessibility access is still waiting for your approval."}
|
||||||
|
</PanelMessage>
|
||||||
|
<fieldset class="fieldset border border-base-300 bg-base-100 p-4">
|
||||||
|
<legend class="fieldset-legend px-1">Why Friendolls needs this</legend>
|
||||||
|
<p class="text-xs leading-relaxed">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<ol class="mt-3 list-decimal space-y-1 pl-5 text-xs">
|
||||||
|
<li>Click <strong>Open Accessibility Settings</strong>.</li>
|
||||||
|
<li>Turn on Friendolls in Privacy & Security → Accessibility.</li>
|
||||||
|
<li>Return here; this wizard confirms the change automatically.</li>
|
||||||
|
</ol>
|
||||||
|
<button
|
||||||
|
class="btn mt-4 w-fit"
|
||||||
|
type="button"
|
||||||
|
disabled={busy || permissionGranted}
|
||||||
|
onclick={onrequest}
|
||||||
|
>
|
||||||
|
{permissionGranted ? "Access Granted" : "Open Accessibility Settings…"}
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<div class="space-y-4 text-sm">
|
||||||
|
<div class="grid size-14 place-content-center border-2 border-success bg-success/10 text-3xl text-success">✓</div>
|
||||||
|
<p>
|
||||||
|
Click <strong>Finish</strong> to save setup and launch Friendolls. The
|
||||||
|
control panel remains available from the menu bar or system tray.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||||
|
|
||||||
|
let { friendId = $bindable(), busy }: { friendId: string; busy: boolean } =
|
||||||
|
$props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PanelMessage
|
||||||
|
>If no one has shared a key with you yet, leave this empty and continue. You
|
||||||
|
can add friends anytime afterwards.</PanelMessage
|
||||||
|
>
|
||||||
|
<fieldset class="fieldset mt-3 border border-base-300 bg-base-100 p-4">
|
||||||
|
<legend class="fieldset-legend px-1">Friend identity</legend>
|
||||||
|
<label class="label" for="onboarding-friend-key">Identification Key</label>
|
||||||
|
<textarea
|
||||||
|
id="onboarding-friend-key"
|
||||||
|
class="textarea h-24 w-full resize-none font-mono text-[10px]"
|
||||||
|
bind:value={friendId}
|
||||||
|
autocomplete="off"
|
||||||
|
disabled={busy}></textarea>
|
||||||
|
<p class="label">Verify this key with your friend before adding it.</p>
|
||||||
|
</fieldset>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { displayName = $bindable(), busy }: { displayName: string; busy: boolean } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<fieldset class="fieldset border border-base-300 bg-base-100 p-4">
|
||||||
|
<legend class="fieldset-legend px-1">Identity</legend>
|
||||||
|
<label class="label" for="onboarding-display-name">Display name</label>
|
||||||
|
<input
|
||||||
|
id="onboarding-display-name"
|
||||||
|
class="input w-full"
|
||||||
|
bind:value={displayName}
|
||||||
|
maxlength="64"
|
||||||
|
autocomplete="off"
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<p class="label">Up to 64 characters. You can change it later.</p>
|
||||||
|
</fieldset>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PuppetMovementMode } from "$lib/bindings";
|
||||||
|
|
||||||
|
let {
|
||||||
|
movementMode = $bindable(),
|
||||||
|
busy,
|
||||||
|
}: { movementMode: PuppetMovementMode; busy: boolean } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<fieldset class="fieldset border border-base-300 bg-base-100 p-4">
|
||||||
|
<legend class="fieldset-legend px-1">Puppets reposition</legend>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
{#each [{ id: "free" as const, image: "/puppet-movement-free.svg", alt: "Puppets moving freely across the full viewport", label: "Freeroam around" }, { id: "bottom" as const, image: "/puppet-movement-bottom.svg", alt: "Puppets moving along the bottom of the viewport", label: "Stay on the ground" }] as option (option.id)}
|
||||||
|
<label
|
||||||
|
class="card cursor-pointer overflow-hidden border border-base-300 bg-base-200"
|
||||||
|
class:border-primary={movementMode === option.id}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={option.image}
|
||||||
|
alt={option.alt}
|
||||||
|
class="w-full bg-linear-to-b from-base-100 to-base-300 object-cover"
|
||||||
|
/>
|
||||||
|
<span class="card-body flex-row items-center gap-2 p-3">
|
||||||
|
<input
|
||||||
|
class="radio scale-50 radio-primary"
|
||||||
|
type="radio"
|
||||||
|
name="movement-mode"
|
||||||
|
value={option.id}
|
||||||
|
bind:group={movementMode}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<span class="text-xs font-bold">{option.label}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
serverName = $bindable(),
|
||||||
|
serverAddress = $bindable(),
|
||||||
|
serverPort = $bindable(),
|
||||||
|
busy,
|
||||||
|
}: {
|
||||||
|
serverName: string;
|
||||||
|
serverAddress: string;
|
||||||
|
serverPort: string;
|
||||||
|
busy: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PanelMessage
|
||||||
|
>If you do not have a server yet, leave this empty and continue. You can add
|
||||||
|
one later.</PanelMessage
|
||||||
|
>
|
||||||
|
<fieldset class="fieldset mt-3 border border-base-300 bg-base-100 p-4">
|
||||||
|
<legend class="fieldset-legend px-1">Server details</legend>
|
||||||
|
<label class="label" for="onboarding-server-name"
|
||||||
|
>Friendly name (optional)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="onboarding-server-name"
|
||||||
|
class="input w-full"
|
||||||
|
bind:value={serverName}
|
||||||
|
maxlength="64"
|
||||||
|
autocomplete="off"
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<label class="label mt-1" for="onboarding-server-address">Address</label>
|
||||||
|
<input
|
||||||
|
id="onboarding-server-address"
|
||||||
|
class="input w-full"
|
||||||
|
bind:value={serverAddress}
|
||||||
|
placeholder="example.net"
|
||||||
|
autocomplete="off"
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<label class="label mt-1" for="onboarding-server-port">Port (optional)</label>
|
||||||
|
<input
|
||||||
|
id="onboarding-server-port"
|
||||||
|
class="input w-28"
|
||||||
|
bind:value={serverPort}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="Default"
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { SceneConfiguration, User } from "$lib/bindings";
|
||||||
|
import PuppetPreview from "../../scene/components/renderer/puppet/preview.svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
profile,
|
||||||
|
configuration,
|
||||||
|
skinMode = $bindable(),
|
||||||
|
skinPreviewUrl,
|
||||||
|
busy,
|
||||||
|
onselect,
|
||||||
|
}: {
|
||||||
|
profile: User;
|
||||||
|
configuration: SceneConfiguration;
|
||||||
|
skinMode: "current" | "default" | "custom";
|
||||||
|
skinPreviewUrl: string | null;
|
||||||
|
busy: boolean;
|
||||||
|
onselect: (file: File | null) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-[9rem_minmax(0,1fr)] gap-4 items-center">
|
||||||
|
<div class="bg-gridded aspect-square">
|
||||||
|
<div
|
||||||
|
class="aspect-square border border-primary bg-primary/5 shadow-[inset_0_0_10px] shadow-primary"
|
||||||
|
>
|
||||||
|
<PuppetPreview
|
||||||
|
userId={profile.id}
|
||||||
|
skinHash={skinMode === "default" ? null : profile.skinHash}
|
||||||
|
skinSource={skinMode === "custom" ? skinPreviewUrl : null}
|
||||||
|
scale={configuration.puppetScale}
|
||||||
|
opacity={configuration.puppetOpacity}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<fieldset class="fieldset border border-base-300 bg-base-100 p-4">
|
||||||
|
<legend class="fieldset-legend px-1">Puppet skin</legend>
|
||||||
|
{#if profile.skinHash}
|
||||||
|
<label class="label cursor-pointer justify-start gap-2">
|
||||||
|
<input
|
||||||
|
class="radio radio-sm radio-primary"
|
||||||
|
type="radio"
|
||||||
|
name="skin-mode"
|
||||||
|
value="current"
|
||||||
|
bind:group={skinMode}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
Keep current skin
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
<label class="label cursor-pointer justify-start gap-2">
|
||||||
|
<input
|
||||||
|
class="radio radio-sm radio-primary"
|
||||||
|
type="radio"
|
||||||
|
name="skin-mode"
|
||||||
|
value="default"
|
||||||
|
bind:group={skinMode}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
Use default skin
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer justify-start gap-2">
|
||||||
|
<input
|
||||||
|
class="radio radio-sm radio-primary"
|
||||||
|
type="radio"
|
||||||
|
name="skin-mode"
|
||||||
|
value="custom"
|
||||||
|
bind:group={skinMode}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
Choose a PNG file
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
class="file-input file-input-sm mt-2 w-full"
|
||||||
|
type="file"
|
||||||
|
accept="image/png"
|
||||||
|
disabled={busy}
|
||||||
|
onchange={(event) => onselect(event.currentTarget.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<p class="label">Skins must be exactly 64×64 pixels.</p>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
requiresAccessibilityPermission,
|
||||||
|
}: { requiresAccessibilityPermission: boolean } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4 text-sm">
|
||||||
|
<p>
|
||||||
|
Welcome to the Friendolls setup wizard, which will guide you through the
|
||||||
|
process of setting up Friendolls.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Friendolls turns your friends into desktop puppets that follow their cursors
|
||||||
|
and appear when they are online.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user