setup wizard & macOS accessibility thing

This commit is contained in:
2026-09-01 21:30:59 +08:00
parent 4198f8b4d5
commit 9041283b65
24 changed files with 1109 additions and 42 deletions
Generated
+1
View File
@@ -1365,6 +1365,7 @@ dependencies = [
"getrandom 0.4.3",
"image",
"lazy_static",
"macos-accessibility-client",
"objc2",
"objc2-app-kit",
"objc2-foundation",
+1
View File
@@ -58,6 +58,7 @@ windows = { version = "0.58", features = [
] }
[target.'cfg(target_os = "macos")'.dependencies]
macos-accessibility-client = "0.0.1"
objc2 = "0.6.3"
objc2-app-kit = "0.3.2"
objc2-foundation = "0.3.2"
+7 -1
View File
@@ -2,7 +2,13 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for Friendolls UI windows",
"windows": ["control-panel", "control-panel-action-*", "debug", "scene"],
"windows": [
"control-panel",
"control-panel-action-*",
"debug",
"onboarding",
"scene"
],
"permissions": [
"core:default",
"core:window:allow-close",
@@ -0,0 +1,6 @@
CREATE TABLE app_settings (
id INTEGER PRIMARY KEY NOT NULL CHECK (id = 1),
onboarding_done INTEGER NOT NULL DEFAULT 0 CHECK (onboarding_done IN (0, 1))
);
INSERT INTO app_settings (id) VALUES (1);
+57
View File
@@ -0,0 +1,57 @@
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::{AppHandle, Manager};
#[derive(Default)]
struct ApplicationState {
started: AtomicBool,
}
pub async fn init(handle: &AppHandle) -> Result<(), String> {
handle.manage(ApplicationState::default());
let accessibility_permission_granted = crate::macos::init(handle).await?;
let settings = crate::onboarding::get(&handle.state())
.await
.map_err(crate::db::command_error)?;
if settings.onboarding_done {
start(handle);
if cfg!(target_os = "macos") && !accessibility_permission_granted {
crate::ui::onboarding::show_accessibility_page(handle)?;
}
} else {
crate::ui::onboarding::show_initial(handle)?;
}
Ok(())
}
pub fn start(handle: &AppHandle) {
let state = handle.state::<ApplicationState>();
if state
.started
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return;
}
crate::ui::init(handle);
reconcile_cursor(handle);
}
pub fn is_started(handle: &AppHandle) -> bool {
handle
.state::<ApplicationState>()
.started
.load(Ordering::Acquire)
}
pub fn reconcile_cursor(handle: &AppHandle) {
if !is_started(handle) || !crate::macos::accessibility_permission_granted(handle) {
return;
}
if let Err(error) = crate::cursor::init(handle) {
eprintln!("failed to initialize cursor tracking; will retry: {error}");
}
}
+19 -7
View File
@@ -77,7 +77,10 @@ fn current_position(handle: &AppHandle) -> Result<CursorPositions, String> {
.primary_monitor()
.map_err(|error| error.to_string())?
.ok_or("Primary monitor is unavailable")?;
let position = DeviceState::new().get_mouse().coords;
let position = DeviceState::checked_new()
.ok_or_else(|| "System cursor access is unavailable".to_owned())?
.get_mouse()
.coords;
#[cfg(target_os = "windows")]
let raw = CursorPosition {
@@ -111,20 +114,23 @@ lazy_static! {
}
/// Initialize cursor tracking.
pub fn init(app: &AppHandle) {
pub fn init(app: &AppHandle) -> Result<(), String> {
println!("init_cursor_tracking called");
if !crate::macos::accessibility_permission_granted(app) {
return Err("macOS Accessibility permission has not been granted".to_owned());
}
let mut tracker = match CURSOR_TASK.lock() {
Ok(tracker) => tracker,
Err(e) => {
println!("Failed to lock cursor tracker state: {}", e);
return;
return Err(format!("Failed to lock cursor tracker state: {e}"));
}
};
if tracker.is_some() {
println!("Cursor tracking already initialized");
return;
return Ok(());
}
match current_position(app) {
@@ -138,8 +144,8 @@ pub fn init(app: &AppHandle) {
let primary_monitor = app
.primary_monitor()
.expect("Failed to resolve primary monitor")
.expect("Failed to resolve primary monitor");
.map_err(|error| error.to_string())?
.ok_or_else(|| "Failed to resolve primary monitor".to_owned())?;
let handle = app.clone();
let task = tauri::async_runtime::spawn(async move {
@@ -150,6 +156,7 @@ pub fn init(app: &AppHandle) {
*tracker = Some(CursorTask { stop_tx, task });
println!("EVENT: Cursor Tracker Enabled");
Ok(())
}
#[inline]
@@ -320,6 +327,7 @@ async fn init_cursor_tracking_i(
// Create a channel to decouple event generation (producer) from processing (consumer).
// Capacity 100 is plenty for 500ms polling (2Hz).
let (tx, mut rx) = mpsc::channel::<CursorPositions>(100);
let permission_handle = handle.clone();
// Spawn the consumer task
// This task handles WebSocket reporting and local position projection updates.
@@ -345,6 +353,10 @@ async fn init_cursor_tracking_i(
// The producer closure moves `tx` into it.
// device_query runs this closure on its own thread.
let _guard = device_state.on_mouse_move(move |position: &(i32, i32)| {
if !crate::macos::accessibility_permission_granted(&permission_handle) {
return;
}
#[cfg(target_os = "windows")]
let raw = CursorPosition {
x: position.0 as f64,
+8 -2
View File
@@ -1,3 +1,4 @@
mod application;
mod cursor;
mod db;
mod friends;
@@ -5,7 +6,9 @@ mod images;
mod interactions;
mod keypair;
mod live_data;
mod macos;
mod network;
mod onboarding;
mod profile;
mod puppet;
mod remotes;
@@ -30,9 +33,8 @@ async fn launch_app(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>>
app.manage(puppet::PuppetStateStore::default());
network::init(handle).await?;
ufa::init(handle);
cursor::init(handle);
puppet::init(handle)?;
ui::init(handle);
application::init(handle).await?;
Ok(())
}
@@ -92,6 +94,9 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
puppet::list_puppet_states,
scene_configuration::get_scene_configuration,
scene_configuration::update_scene_configuration,
onboarding::get_onboarding_status,
onboarding::complete_onboarding,
macos::request_accessibility_permission,
ui::control_panel::open_action_window,
ui::scene::update_scene_hitboxes,
])
@@ -107,6 +112,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
interactions::FriendInteractionReceived,
puppet::PuppetStatesChanged,
scene_configuration::SceneConfigurationChanged,
onboarding::OnboardingStatus,
])
}
+108
View File
@@ -0,0 +1,108 @@
use std::{
sync::atomic::{AtomicBool, Ordering},
time::Duration,
};
use tauri::{AppHandle, Manager};
pub struct AccessibilityPermissionState(AtomicBool);
impl AccessibilityPermissionState {
fn new(granted: bool) -> Self {
Self(AtomicBool::new(granted))
}
fn replace(&self, granted: bool) -> bool {
self.0.swap(granted, Ordering::AcqRel) != granted
}
fn granted(&self) -> bool {
self.0.load(Ordering::Acquire)
}
}
#[cfg(target_os = "macos")]
fn system_accessibility_permission_granted() -> bool {
macos_accessibility_client::accessibility::application_is_trusted()
}
#[cfg(not(target_os = "macos"))]
fn system_accessibility_permission_granted() -> bool {
true
}
#[cfg(target_os = "macos")]
fn request_system_accessibility_permission() -> bool {
macos_accessibility_client::accessibility::application_is_trusted_with_prompt()
}
#[cfg(not(target_os = "macos"))]
fn request_system_accessibility_permission() -> bool {
true
}
pub fn accessibility_permission_granted(handle: &AppHandle) -> bool {
if !cfg!(target_os = "macos") {
return true;
}
handle.state::<AccessibilityPermissionState>().granted()
}
async fn apply_permission_state(handle: &AppHandle, granted: bool) -> Result<bool, String> {
if handle.state::<AccessibilityPermissionState>().granted() == granted {
crate::application::reconcile_cursor(handle);
return Ok(false);
}
if !handle
.state::<AccessibilityPermissionState>()
.replace(granted)
{
return Ok(false);
}
let database = handle.state();
let status = crate::onboarding::emit_status(handle, &database).await?;
if status.onboarding_done {
if granted {
crate::application::reconcile_cursor(handle);
} else {
crate::ui::onboarding::show_accessibility_page(handle)?;
}
}
Ok(true)
}
pub async fn init(handle: &AppHandle) -> Result<bool, String> {
let granted = system_accessibility_permission_granted();
handle.manage(AccessibilityPermissionState::new(granted));
#[cfg(target_os = "macos")]
{
let handle = handle.clone();
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(1));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
interval.tick().await;
loop {
interval.tick().await;
let granted = system_accessibility_permission_granted();
if let Err(error) = apply_permission_state(&handle, granted).await {
eprintln!("failed to update macOS Accessibility permission: {error}");
}
}
});
}
Ok(granted)
}
#[tauri::command]
#[specta::specta]
pub async fn request_accessibility_permission(handle: AppHandle) -> Result<bool, String> {
let granted = tauri::async_runtime::spawn_blocking(request_system_accessibility_permission)
.await
.map_err(|error| error.to_string())?;
apply_permission_state(&handle, granted).await?;
Ok(granted)
}
+128
View File
@@ -0,0 +1,128 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::{AppHandle, State};
use tauri_specta::Event;
use crate::db::{self, AppDatabase};
const SETTINGS_ID: i64 = 1;
#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
pub struct AppSettings {
pub onboarding_done: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type, Event)]
#[serde(rename_all = "camelCase")]
pub struct OnboardingStatus {
pub onboarding_done: bool,
pub macos_accessibility_permission_granted: bool,
pub requires_accessibility_permission: bool,
}
impl OnboardingStatus {
fn new(handle: &AppHandle, settings: AppSettings) -> Self {
Self {
onboarding_done: settings.onboarding_done,
macos_accessibility_permission_granted: crate::macos::accessibility_permission_granted(
handle,
),
requires_accessibility_permission: cfg!(target_os = "macos"),
}
}
}
pub async fn get(database: &AppDatabase) -> Result<AppSettings, sqlx::Error> {
sqlx::query_as::<_, AppSettings>("SELECT onboarding_done FROM app_settings WHERE id = ?1")
.bind(SETTINGS_ID)
.fetch_one(database.pool())
.await
}
async fn set_onboarding_done(
database: &AppDatabase,
onboarding_done: bool,
) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE app_settings SET onboarding_done = ?1 WHERE id = ?2")
.bind(onboarding_done)
.bind(SETTINGS_ID)
.execute(database.pool())
.await?;
Ok(())
}
pub(crate) async fn emit_status(
handle: &AppHandle,
database: &AppDatabase,
) -> Result<OnboardingStatus, String> {
let status = OnboardingStatus::new(handle, get(database).await.map_err(db::command_error)?);
status.clone().emit(handle).map_err(db::command_error)?;
Ok(status)
}
#[tauri::command]
#[specta::specta]
pub async fn get_onboarding_status(
handle: AppHandle,
database: State<'_, AppDatabase>,
) -> Result<OnboardingStatus, String> {
let settings = get(&database).await.map_err(db::command_error)?;
Ok(OnboardingStatus::new(&handle, settings))
}
#[tauri::command]
#[specta::specta]
pub async fn complete_onboarding(
handle: AppHandle,
database: State<'_, AppDatabase>,
) -> Result<(), String> {
if cfg!(target_os = "macos") && !crate::macos::accessibility_permission_granted(&handle) {
return Err("Accessibility access must be granted before setup can finish.".to_owned());
}
set_onboarding_done(&database, true)
.await
.map_err(db::command_error)?;
emit_status(&handle, &database).await?;
crate::application::start(&handle);
Ok(())
}
#[cfg(test)]
mod tests {
use sqlx::sqlite::SqlitePoolOptions;
use super::*;
async fn database() -> AppDatabase {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("connect to in-memory SQLite");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("run database migrations");
AppDatabase::new(pool)
}
#[tokio::test]
async fn onboarding_completion_is_persisted() {
let database = database().await;
assert_eq!(
get(&database).await.unwrap(),
AppSettings {
onboarding_done: false,
}
);
set_onboarding_done(&database, true).await.unwrap();
assert_eq!(
get(&database).await.unwrap(),
AppSettings {
onboarding_done: true,
}
);
}
}
+1
View File
@@ -3,6 +3,7 @@ use tauri::AppHandle;
pub mod control_panel;
#[cfg(debug_assertions)]
mod debug;
pub mod onboarding;
pub mod scene;
mod tray;
+50
View File
@@ -0,0 +1,50 @@
use tauri::{AppHandle, Manager, WebviewUrl};
const WINDOW_LABEL: &str = "onboarding";
fn show(handle: &AppHandle, accessibility_only: bool) -> Result<(), String> {
if let Some(window) = handle.get_webview_window(WINDOW_LABEL) {
if accessibility_only {
window
.eval("window.location.href = '/onboarding?step=accessibility'")
.map_err(|error| error.to_string())?;
}
window.unminimize().map_err(|error| error.to_string())?;
window.show().map_err(|error| error.to_string())?;
return window.set_focus().map_err(|error| error.to_string());
}
let page = if accessibility_only {
"/onboarding?step=accessibility"
} else {
"/onboarding"
};
let window =
tauri::WebviewWindowBuilder::new(handle, WINDOW_LABEL, WebviewUrl::App(page.into()))
.title("Friendolls Setup")
.inner_size(680.0, 520.0)
.min_inner_size(680.0, 520.0)
.resizable(false)
.maximizable(false)
.center()
.build()
.map_err(|error| error.to_string())?;
let handle = handle.clone();
window.on_window_event(move |event| {
if matches!(event, tauri::WindowEvent::CloseRequested { .. })
&& !crate::application::is_started(&handle)
{
handle.exit(0);
}
});
Ok(())
}
pub fn show_initial(handle: &AppHandle) -> Result<(), String> {
show(handle, false)
}
pub fn show_accessibility_page(handle: &AppHandle) -> Result<(), String> {
show(handle, true)
}
+29
View File
@@ -107,3 +107,32 @@ input {
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;
}
+12
View File
@@ -80,6 +80,15 @@ 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 openActionWindow(name: string, title: string, pageUrl: string) : Promise<null> {
return await TAURI_INVOKE("open_action_window", { name, title, pageUrl });
},
@@ -99,6 +108,7 @@ friendInteractionReceived: FriendInteractionReceived,
friendStatusesChanged: FriendStatusesChanged,
friendsChanged: FriendsChanged,
networkStatusChanged: NetworkStatusChanged,
onboardingStatus: OnboardingStatus,
profileChanged: ProfileChanged,
puppetStatesChanged: PuppetStatesChanged,
remotesChanged: RemotesChanged,
@@ -111,6 +121,7 @@ friendInteractionReceived: "friend-interaction-received",
friendStatusesChanged: "friend-statuses-changed",
friendsChanged: "friends-changed",
networkStatusChanged: "network-status-changed",
onboardingStatus: "onboarding-status",
profileChanged: "profile-changed",
puppetStatesChanged: "puppet-states-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 LiveDataSnapshot = { cursorPositions: Partial<{ [key in string]: CursorPositions }>; foregroundApps: Partial<{ [key in string]: AppMeta }> }
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 }
@@ -3,13 +3,14 @@
kind = "info",
children,
}: {
kind?: "info" | "error" | "warning";
kind?: "info" | "success" | "error" | "warning";
children: import("svelte").Snippet;
} = $props();
</script>
<div
class:alert-info={kind === "info"}
class:alert-success={kind === "success"}
class:alert-error={kind === "error"}
class:alert-warning={kind === "warning"}
class="alert alert-soft min-h-0 px-2 py-1.5 text-xs"
@@ -301,34 +301,3 @@
</div>
</fieldset>
</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>
+405
View File
@@ -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}>&lt; 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 &amp; 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>