diff --git a/Cargo.lock b/Cargo.lock index d53c180..909049c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1365,6 +1365,7 @@ dependencies = [ "getrandom 0.4.3", "image", "lazy_static", + "macos-accessibility-client", "objc2", "objc2-app-kit", "objc2-foundation", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6b0f861..83b3158 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 3699235..2884eca 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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", diff --git a/src-tauri/migrations/20260901000000_create_app_settings.sql b/src-tauri/migrations/20260901000000_create_app_settings.sql new file mode 100644 index 0000000..1842f12 --- /dev/null +++ b/src-tauri/migrations/20260901000000_create_app_settings.sql @@ -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); diff --git a/src-tauri/src/application.rs b/src-tauri/src/application.rs new file mode 100644 index 0000000..f29e541 --- /dev/null +++ b/src-tauri/src/application.rs @@ -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::(); + 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::() + .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}"); + } +} diff --git a/src-tauri/src/cursor/mod.rs b/src-tauri/src/cursor/mod.rs index 86e07d3..980cc2f 100644 --- a/src-tauri/src/cursor/mod.rs +++ b/src-tauri/src/cursor/mod.rs @@ -77,7 +77,10 @@ fn current_position(handle: &AppHandle) -> Result { .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::(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, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 01dc4d5..87bcd13 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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> 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 { 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 { interactions::FriendInteractionReceived, puppet::PuppetStatesChanged, scene_configuration::SceneConfigurationChanged, + onboarding::OnboardingStatus, ]) } diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs new file mode 100644 index 0000000..aaefd99 --- /dev/null +++ b/src-tauri/src/macos.rs @@ -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::().granted() +} + +async fn apply_permission_state(handle: &AppHandle, granted: bool) -> Result { + if handle.state::().granted() == granted { + crate::application::reconcile_cursor(handle); + return Ok(false); + } + + if !handle + .state::() + .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 { + 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 { + 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) +} diff --git a/src-tauri/src/onboarding.rs b/src-tauri/src/onboarding.rs new file mode 100644 index 0000000..c6cd40c --- /dev/null +++ b/src-tauri/src/onboarding.rs @@ -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 { + 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 { + 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 { + 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, + } + ); + } +} diff --git a/src-tauri/src/ui/mod.rs b/src-tauri/src/ui/mod.rs index 8ca25ad..259e091 100644 --- a/src-tauri/src/ui/mod.rs +++ b/src-tauri/src/ui/mod.rs @@ -3,6 +3,7 @@ use tauri::AppHandle; pub mod control_panel; #[cfg(debug_assertions)] mod debug; +pub mod onboarding; pub mod scene; mod tray; diff --git a/src-tauri/src/ui/onboarding.rs b/src-tauri/src/ui/onboarding.rs new file mode 100644 index 0000000..73a4764 --- /dev/null +++ b/src-tauri/src/ui/onboarding.rs @@ -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) +} diff --git a/src/app.css b/src/app.css index 53de12d..4eb6a79 100644 --- a/src/app.css +++ b/src/app.css @@ -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; +} diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index be316ed..6ebb6ae 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -80,6 +80,15 @@ async getSceneConfiguration() : Promise { async updateSceneConfiguration(configuration: SceneConfiguration) : Promise { return await TAURI_INVOKE("update_scene_configuration", { configuration }); }, +async getOnboardingStatus() : Promise { + return await TAURI_INVOKE("get_onboarding_status"); +}, +async completeOnboarding() : Promise { + return await TAURI_INVOKE("complete_onboarding"); +}, +async requestAccessibilityPermission() : Promise { + return await TAURI_INVOKE("request_accessibility_permission"); +}, async openActionWindow(name: string, title: string, pageUrl: string) : Promise { 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 } diff --git a/src/lib/components/control-panel/panel-message.svelte b/src/lib/components/control-panel/panel-message.svelte index 34fcf5e..2e94efd 100644 --- a/src/lib/components/control-panel/panel-message.svelte +++ b/src/lib/components/control-panel/panel-message.svelte @@ -3,13 +3,14 @@ kind = "info", children, }: { - kind?: "info" | "error" | "warning"; + kind?: "info" | "success" | "error" | "warning"; children: import("svelte").Snippet; } = $props();
- - diff --git a/src/routes/onboarding/+page.svelte b/src/routes/onboarding/+page.svelte new file mode 100644 index 0000000..a768811 --- /dev/null +++ b/src/routes/onboarding/+page.svelte @@ -0,0 +1,405 @@ + + +Friendolls Setup + +
+
+ + +
+ {#if step} +
+

{step.title}

+ +
+ +
+ {#if error} +
+ {error} +
+ {/if} + + {#if !status || !profile} +
+
+
+
+ {:else if step.id === "welcome"} + + {:else if step.id === "identity"} + + {:else if step.id === "skin"} + + {:else if step.id === "movement"} + + {:else if step.id === "server"} + + {:else if step.id === "friend"} + + {:else if step.id === "accessibility"} + + {:else if step.id === "complete"} + + {/if} +
+ +
+ + {remediation + ? "Permission repair" + : `Step ${stepIndex + 1} of ${steps.length}`} + +
+ {#if !remediation} + + {/if} + +
+
+ {/if} +
+
+
diff --git a/src/routes/onboarding/components/accessibility-step.svelte b/src/routes/onboarding/components/accessibility-step.svelte new file mode 100644 index 0000000..215f135 --- /dev/null +++ b/src/routes/onboarding/components/accessibility-step.svelte @@ -0,0 +1,39 @@ + + +
+ + {permissionGranted + ? "Accessibility access is granted. Puppets can now follow your cursor." + : "Accessibility access is still waiting for your approval."} + +
+ Why Friendolls needs this +

+ 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. +

+
    +
  1. Click Open Accessibility Settings.
  2. +
  3. Turn on Friendolls in Privacy & Security → Accessibility.
  4. +
  5. Return here; this wizard confirms the change automatically.
  6. +
+ +
+
diff --git a/src/routes/onboarding/components/complete-step.svelte b/src/routes/onboarding/components/complete-step.svelte new file mode 100644 index 0000000..9961118 --- /dev/null +++ b/src/routes/onboarding/components/complete-step.svelte @@ -0,0 +1,7 @@ +
+
✓
+

+ Click Finish to save setup and launch Friendolls. The + control panel remains available from the menu bar or system tray. +

+
diff --git a/src/routes/onboarding/components/friend-step.svelte b/src/routes/onboarding/components/friend-step.svelte new file mode 100644 index 0000000..f98f8bc --- /dev/null +++ b/src/routes/onboarding/components/friend-step.svelte @@ -0,0 +1,22 @@ + + +If no one has shared a key with you yet, leave this empty and continue. You + can add friends anytime afterwards. +
+ Friend identity + + +

Verify this key with your friend before adding it.

+
diff --git a/src/routes/onboarding/components/identity-step.svelte b/src/routes/onboarding/components/identity-step.svelte new file mode 100644 index 0000000..cb170a0 --- /dev/null +++ b/src/routes/onboarding/components/identity-step.svelte @@ -0,0 +1,17 @@ + + +
+ Identity + + +

Up to 64 characters. You can change it later.

+
diff --git a/src/routes/onboarding/components/movement-step.svelte b/src/routes/onboarding/components/movement-step.svelte new file mode 100644 index 0000000..9f1e4bf --- /dev/null +++ b/src/routes/onboarding/components/movement-step.svelte @@ -0,0 +1,37 @@ + + +
+ Puppets reposition +
+ {#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)} + + {/each} +
+
diff --git a/src/routes/onboarding/components/server-step.svelte b/src/routes/onboarding/components/server-step.svelte new file mode 100644 index 0000000..23d7a7d --- /dev/null +++ b/src/routes/onboarding/components/server-step.svelte @@ -0,0 +1,55 @@ + + +If you do not have a server yet, leave this empty and continue. You can add + one later. +
+ Server details + + + + + + +
diff --git a/src/routes/onboarding/components/skin-step.svelte b/src/routes/onboarding/components/skin-step.svelte new file mode 100644 index 0000000..3ebe32b --- /dev/null +++ b/src/routes/onboarding/components/skin-step.svelte @@ -0,0 +1,82 @@ + + +
+
+
+ +
+
+
+ Puppet skin + {#if profile.skinHash} + + {/if} + + + onselect(event.currentTarget.files?.[0] ?? null)} + /> +

Skins must be exactly 64×64 pixels.

+
+
diff --git a/src/routes/onboarding/components/welcome-step.svelte b/src/routes/onboarding/components/welcome-step.svelte new file mode 100644 index 0000000..78337f3 --- /dev/null +++ b/src/routes/onboarding/components/welcome-step.svelte @@ -0,0 +1,16 @@ + + +
+

+ Welcome to the Friendolls setup wizard, which will guide you through the + process of setting up Friendolls. +

+

+ Friendolls turns your friends into desktop puppets that follow their cursors + and appear when they are online. +

+