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