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