refactored svelte tauri events

This commit is contained in:
2026-03-07 03:09:45 +08:00
parent 2bf8581095
commit c13a069fc8
12 changed files with 258 additions and 232 deletions

View File

@@ -2,7 +2,7 @@ use crate::{
commands::app_state::get_modules,
services::{
doll_editor::open_doll_editor_window,
scene::{set_pet_menu_state, set_scene_interactive},
scene::{get_scene_interactive, set_pet_menu_state, set_scene_interactive},
},
};
use commands::app::{quit_app, restart_app, retry_connection};
@@ -83,6 +83,7 @@ pub fn run() {
save_client_config,
open_client_config_manager,
open_doll_editor_window,
get_scene_interactive,
set_scene_interactive,
set_pet_menu_state,
login,

View File

@@ -82,6 +82,11 @@ pub fn set_scene_interactive(interactive: bool, should_click: bool) {
update_scene_interactive(interactive, should_click);
}
#[tauri::command]
pub fn get_scene_interactive() -> Result<bool, String> {
Ok(scene_interactive_state().load(Ordering::SeqCst))
}
#[tauri::command]
pub fn set_pet_menu_state(id: String, open: bool) {
let menus_arc = get_open_pet_menus();

View File

@@ -9,14 +9,17 @@ export const appData = writable<UserData | null>(null);
const subscription = createListenerSubscription();
export async function initAppDataListener() {
/**
* Starts listening for app data refresh events.
* Initializes app data from the backend.
*/
export async function startAppData() {
try {
if (subscription.isListening()) return;
appData.set(await invoke("get_app_data"));
const unlisten = await listen<UserData>(
AppEvents.AppDataRefreshed,
(event) => {
console.log("app-data-refreshed", event.payload);
appData.set(event.payload);
},
);
@@ -28,8 +31,8 @@ export async function initAppDataListener() {
}
}
export function stopAppDataListener() {
export function stopAppData() {
subscription.stop();
}
setupHmrCleanup(stopAppDataListener);
setupHmrCleanup(stopAppData);

View File

@@ -2,155 +2,32 @@ import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { writable } from "svelte/store";
import type { CursorPositions } from "../types/bindings/CursorPositions";
import type { CursorPosition } from "../types/bindings/CursorPosition";
import type { DollDto } from "../types/bindings/DollDto";
import { AppEvents } from "../types/bindings/AppEventsConstants";
import {
createMultiListenerSubscription,
parseEventPayload,
removeFromStore,
setupHmrCleanup,
} from "./listener-utils";
import { createListenerSubscription, setupHmrCleanup } from "./listener-utils";
export const cursorPositionOnScreen = writable<CursorPositions>({
raw: { x: 0, y: 0 },
mapped: { x: 0, y: 0 },
});
export type FriendCursorPosition = {
userId: string;
position: CursorPositions;
};
// Map of userId -> { position: CursorPositions, lastUpdated: number }
// We store the timestamp to detect stale cursors
type FriendCursorData = {
position: CursorPositions;
lastUpdated: number;
};
// The exported store will only expose the position part to consumers,
// but internally we manage the full data.
// Actually, it's easier if we just export the positions and manage state internally.
export const friendsCursorPositions = writable<Record<string, CursorPositions>>(
{},
);
export const friendsActiveDolls = writable<Record<string, DollDto | null>>({});
const subscription = createMultiListenerSubscription();
// Internal state to track timestamps
let friendCursorState: Record<string, FriendCursorData> = {};
const subscription = createListenerSubscription();
/**
* Initialize cursor tracking for this window.
* Can be called from multiple windows - only the first call starts tracking on the Rust side,
* but all windows can independently listen to the broadcast events.
* Starts tracking the local cursor position.
* Initializes cursor position from the backend and listens for updates.
*/
export async function initCursorTracking() {
export async function startCursorTracking() {
if (subscription.isListening()) return;
try {
// Listen to cursor position events (each window subscribes independently)
const unlistenCursor = await listen<CursorPositions>(
cursorPositionOnScreen.set(await invoke("get_cursor_position"));
const unlisten = await listen<CursorPositions>(
AppEvents.CursorPosition,
(event) => {
cursorPositionOnScreen.set(event.payload);
},
);
subscription.addUnlisten(unlistenCursor);
// Listen to friend cursor position events
const unlistenFriendCursor = await listen<FriendCursorPosition>(
AppEvents.FriendCursorPosition,
(event) => {
// We now receive a clean object from Rust
const data = event.payload;
// Update internal state with timestamp
friendCursorState[data.userId] = {
position: data.position,
lastUpdated: Date.now(),
};
friendsCursorPositions.update((current) => {
return {
...current,
[data.userId]: data.position,
};
});
},
);
subscription.addUnlisten(unlistenFriendCursor);
// Listen to friend disconnected events
const unlistenFriendDisconnected = await listen<
[{ userId: string }] | { userId: string } | string
>(AppEvents.FriendDisconnected, (event) => {
const payload = parseEventPayload<
[{ userId: string }] | { userId: string }
>(event.payload, AppEvents.FriendDisconnected);
if (!payload) return;
const data = Array.isArray(payload) ? payload[0] : payload;
// Remove from internal state
if (friendCursorState[data.userId]) {
delete friendCursorState[data.userId];
}
// Update svelte store
friendsCursorPositions.update((current) =>
removeFromStore(current, data.userId),
);
});
subscription.addUnlisten(unlistenFriendDisconnected);
// Listen to friend active doll changed events
const unlistenFriendActiveDollChanged = await listen<
| string
| {
friendId: string;
doll: DollDto | null;
}
>(AppEvents.FriendActiveDollChanged, (event) => {
const data = parseEventPayload<{
friendId: string;
doll: DollDto | null;
}>(event.payload, AppEvents.FriendActiveDollChanged);
if (!data) return;
// Cast to expected type after parsing
const payload = data as { friendId: string; doll: DollDto | null };
if (!payload.doll) {
// If doll is null, it means the friend deactivated their doll.
// Update the active dolls store to explicitly set this friend's doll to null
// We MUST set it to null instead of deleting it, otherwise the UI might
// fall back to the initial appData snapshot which might still have the old doll.
friendsActiveDolls.update((current) => {
const next = { ...current };
next[payload.friendId] = null;
return next;
});
// Also remove from cursor positions so the sprite disappears
friendsCursorPositions.update((current) =>
removeFromStore(current, payload.friendId),
);
} else {
// Update or add the new doll configuration
friendsActiveDolls.update((current) => {
return {
...current,
[payload.friendId]: payload.doll!,
};
});
}
});
subscription.addUnlisten(unlistenFriendActiveDollChanged);
subscription.setUnlisten(unlisten);
subscription.setListening(true);
} catch (err) {
console.error("Failed to initialize cursor tracking:", err);
@@ -158,10 +35,6 @@ export async function initCursorTracking() {
}
}
/**
* Stop listening to cursor events in this window.
* Note: This doesn't stop the Rust-side tracking, just stops this window from receiving events.
*/
export function stopCursorTracking() {
subscription.stop();
}

129
src/events/friend-cursor.ts Normal file
View File

@@ -0,0 +1,129 @@
import { listen } from "@tauri-apps/api/event";
import { writable } from "svelte/store";
import type { CursorPositions } from "../types/bindings/CursorPositions";
import type { DollDto } from "../types/bindings/DollDto";
import { AppEvents } from "../types/bindings/AppEventsConstants";
import {
createMultiListenerSubscription,
parseEventPayload,
removeFromStore,
setupHmrCleanup,
} from "./listener-utils";
export type FriendCursorPosition = {
userId: string;
position: CursorPositions;
};
type FriendCursorData = {
position: CursorPositions;
lastUpdated: number;
};
export const friendsCursorPositions = writable<Record<string, CursorPositions>>(
{},
);
export const friendsActiveDolls = writable<Record<string, DollDto | null>>({});
const subscription = createMultiListenerSubscription();
let friendCursorState: Record<string, FriendCursorData> = {};
/**
* Starts listening for friend cursor position and active doll changes.
* Also handles friend disconnection events.
*/
export async function startFriendCursorTracking() {
if (subscription.isListening()) return;
try {
// TODO: Add initial sync for existing friends' cursors and dolls if needed
const unlistenFriendCursor = await listen<FriendCursorPosition>(
AppEvents.FriendCursorPosition,
(event) => {
const data = event.payload;
friendCursorState[data.userId] = {
position: data.position,
lastUpdated: Date.now(),
};
friendsCursorPositions.update((current) => {
return {
...current,
[data.userId]: data.position,
};
});
},
);
subscription.addUnlisten(unlistenFriendCursor);
const unlistenFriendDisconnected = await listen<
[{ userId: string }] | { userId: string } | string
>(AppEvents.FriendDisconnected, (event) => {
const payload = parseEventPayload<
[{ userId: string }] | { userId: string }
>(event.payload, AppEvents.FriendDisconnected);
if (!payload) return;
const data = Array.isArray(payload) ? payload[0] : payload;
if (friendCursorState[data.userId]) {
delete friendCursorState[data.userId];
}
friendsCursorPositions.update((current) =>
removeFromStore(current, data.userId),
);
});
subscription.addUnlisten(unlistenFriendDisconnected);
const unlistenFriendActiveDollChanged = await listen<
| string
| {
friendId: string;
doll: DollDto | null;
}
>(AppEvents.FriendActiveDollChanged, (event) => {
const data = parseEventPayload<{
friendId: string;
doll: DollDto | null;
}>(event.payload, AppEvents.FriendActiveDollChanged);
if (!data) return;
const payload = data as { friendId: string; doll: DollDto | null };
if (!payload.doll) {
friendsActiveDolls.update((current) => {
const next = { ...current };
next[payload.friendId] = null;
return next;
});
friendsCursorPositions.update((current) =>
removeFromStore(current, payload.friendId),
);
} else {
friendsActiveDolls.update((current) => {
return {
...current,
[payload.friendId]: payload.doll!,
};
});
}
});
subscription.addUnlisten(unlistenFriendActiveDollChanged);
subscription.setListening(true);
} catch (err) {
console.error("Failed to initialize friend cursor tracking:", err);
throw err;
}
}
export function stopFriendCursorTracking() {
subscription.stop();
}
setupHmrCleanup(stopFriendCursorTracking);

View File

@@ -1,5 +1,5 @@
import { listen } from "@tauri-apps/api/event";
import { addInteraction } from "$lib/stores/interaction-store";
import { writable } from "svelte/store";
import type { InteractionPayloadDto } from "../types/bindings/InteractionPayloadDto";
import type { InteractionDeliveryFailedDto } from "../types/bindings/InteractionDeliveryFailedDto";
import { AppEvents } from "../types/bindings/AppEventsConstants";
@@ -8,11 +8,35 @@ import {
setupHmrCleanup,
} from "./listener-utils";
export const receivedInteractions = writable<Map<string, InteractionPayloadDto>>(
new Map(),
);
export function addInteraction(interaction: InteractionPayloadDto) {
receivedInteractions.update((map) => {
const newMap = new Map(map);
newMap.set(interaction.senderUserId, interaction);
return newMap;
});
}
export function clearInteraction(userId: string) {
receivedInteractions.update((map) => {
const newMap = new Map(map);
newMap.delete(userId);
return newMap;
});
}
const subscription = createMultiListenerSubscription();
export async function initInteractionListeners() {
/**
* Starts listening for interaction events (received and delivery failed).
*/
export async function startInteraction() {
if (subscription.isListening()) return;
try {
const unlistenReceived = await listen<InteractionPayloadDto>(
AppEvents.InteractionReceived,
(event) => {
@@ -25,7 +49,6 @@ export async function initInteractionListeners() {
AppEvents.InteractionDeliveryFailed,
(event) => {
console.error("Interaction delivery failed:", event.payload);
// You might want to show a toast or alert here
alert(
`Failed to send message to user ${event.payload.recipientUserId}: ${event.payload.reason}`,
);
@@ -33,10 +56,14 @@ export async function initInteractionListeners() {
);
subscription.addUnlisten(unlistenFailed);
subscription.setListening(true);
} catch (err) {
console.error("Failed to initialize interaction listeners:", err);
throw err;
}
}
export function stopInteractionListeners() {
export function stopInteraction() {
subscription.stop();
}
setupHmrCleanup(stopInteractionListeners);
setupHmrCleanup(stopInteraction);

View File

@@ -1,4 +1,5 @@
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { writable } from "svelte/store";
import { AppEvents } from "../types/bindings/AppEventsConstants";
import { createListenerSubscription, setupHmrCleanup } from "./listener-utils";
@@ -7,12 +8,15 @@ export const sceneInteractive = writable<boolean>(false);
const subscription = createListenerSubscription();
export async function initSceneInteractiveListener() {
/**
* Starts listening for scene interactive state changes.
* Initializes the scene interactive state from the backend.
*/
export async function startSceneInteractive() {
if (subscription.isListening()) return;
try {
// ensure initial default matches backend default
sceneInteractive.set(false);
sceneInteractive.set(await invoke("get_scene_interactive"));
const unlisten = await listen<boolean>(
AppEvents.SceneInteractive,
(event) => {
@@ -27,8 +31,8 @@ export async function initSceneInteractiveListener() {
}
}
export function stopSceneInteractiveListener() {
export function stopSceneInteractive() {
subscription.stop();
}
setupHmrCleanup(stopSceneInteractiveListener);
setupHmrCleanup(stopSceneInteractive);

View File

@@ -9,17 +9,20 @@ import {
setupHmrCleanup,
} from "./listener-utils";
export type UserStatus = {
export type PresenceState = {
presenceStatus: PresenceStatus;
state: "idle" | "resting";
};
export const friendsUserStatuses = writable<Record<string, UserStatus>>({});
export const currentUserStatus = writable<UserStatus | null>(null);
export const friendsPresenceStates = writable<Record<string, PresenceState>>({});
export const currentPresenceState = writable<PresenceState | null>(null);
const subscription = createMultiListenerSubscription();
export async function initUserStatusListeners() {
/**
* Starts listening for user status changes and friend status updates.
*/
export async function startUserStatus() {
if (subscription.isListening()) return;
try {
@@ -28,7 +31,7 @@ export async function initUserStatusListeners() {
(event) => {
const payload = parseEventPayload<{
userId?: string;
status?: UserStatus;
status?: PresenceState;
}>(event.payload, AppEvents.FriendUserStatus);
if (!payload) return;
@@ -48,7 +51,7 @@ export async function initUserStatusListeners() {
if (status.state !== "idle" && status.state !== "resting") return;
friendsUserStatuses.update((current) => ({
friendsPresenceStates.update((current) => ({
...current,
[userId]: {
presenceStatus: status.presenceStatus,
@@ -59,10 +62,10 @@ export async function initUserStatusListeners() {
);
subscription.addUnlisten(unlistenStatus);
const unlistenUserStatusChanged = await listen<UserStatus>(
const unlistenUserStatusChanged = await listen<PresenceState>(
AppEvents.UserStatusChanged,
(event) => {
currentUserStatus.set(event.payload);
currentPresenceState.set(event.payload);
},
);
subscription.addUnlisten(unlistenUserStatusChanged);
@@ -79,7 +82,7 @@ export async function initUserStatusListeners() {
const userId = data?.userId as string | undefined;
if (!userId) return;
friendsUserStatuses.update((current) => removeFromStore(current, userId));
friendsPresenceStates.update((current) => removeFromStore(current, userId));
});
subscription.addUnlisten(unlistenFriendDisconnected);
@@ -90,8 +93,8 @@ export async function initUserStatusListeners() {
}
}
export function stopUserStatusListeners() {
export function stopUserStatus() {
subscription.stop();
}
setupHmrCleanup(stopUserStatusListeners);
setupHmrCleanup(stopUserStatus);

View File

@@ -1,23 +0,0 @@
import { writable } from "svelte/store";
import type { InteractionPayloadDto } from "../../types/bindings/InteractionPayloadDto";
// Map senderUserId -> InteractionPayloadDto
export const receivedInteractions = writable<Map<string, InteractionPayloadDto>>(new Map());
export function addInteraction(interaction: InteractionPayloadDto) {
receivedInteractions.update((map) => {
// For now, we only store the latest message per user.
// In the future, we could store an array if we want a history.
const newMap = new Map(map);
newMap.set(interaction.senderUserId, interaction);
return newMap;
});
}
export function clearInteraction(userId: string) {
receivedInteractions.update((map) => {
const newMap = new Map(map);
newMap.delete(userId);
return newMap;
});
}

View File

@@ -1,24 +1,29 @@
<script>
import { browser } from "$app/environment";
import { onMount, onDestroy } from "svelte";
import { initCursorTracking, stopCursorTracking } from "../events/cursor";
import { initAppDataListener } from "../events/app-data";
import { initInteractionListeners, stopInteractionListeners } from "../events/interaction";
import { startCursorTracking, stopCursorTracking } from "../events/cursor";
import {
initSceneInteractiveListener,
stopSceneInteractiveListener,
startFriendCursorTracking,
stopFriendCursorTracking,
} from "../events/friend-cursor";
import { startAppData } from "../events/app-data";
import { startInteraction, stopInteraction } from "../events/interaction";
import {
startSceneInteractive,
stopSceneInteractive,
} from "../events/scene-interactive";
import { initUserStatusListeners, stopUserStatusListeners } from "../events/user-status";
import { startUserStatus, stopUserStatus } from "../events/user-status";
let { children } = $props();
if (browser) {
onMount(async () => {
try {
await initCursorTracking();
await initAppDataListener();
await initSceneInteractiveListener();
await initInteractionListeners();
await initUserStatusListeners();
await startAppData();
await startCursorTracking();
await startFriendCursorTracking();
await startSceneInteractive();
await startInteraction();
await startUserStatus();
} catch (err) {
console.error("Failed to initialize event listeners:", err);
}
@@ -26,9 +31,10 @@
onDestroy(() => {
stopCursorTracking();
stopSceneInteractiveListener();
stopInteractionListeners();
stopUserStatusListeners();
stopFriendCursorTracking();
stopSceneInteractive();
stopInteraction();
stopUserStatus();
});
}
</script>

View File

@@ -1,13 +1,11 @@
<script lang="ts">
import {
cursorPositionOnScreen,
friendsCursorPositions,
} from "../../events/cursor";
import { cursorPositionOnScreen } from "../../events/cursor";
import { friendsCursorPositions } from "../../events/friend-cursor";
import { appData } from "../../events/app-data";
import { sceneInteractive } from "../../events/scene-interactive";
import {
friendsUserStatuses,
currentUserStatus,
friendsPresenceStates,
currentPresenceState,
} from "../../events/user-status";
import { invoke } from "@tauri-apps/api/core";
import DebugBar from "./components/debug-bar.svelte";
@@ -28,10 +26,10 @@
<DebugBar
isInteractive={$sceneInteractive}
cursorPosition={$cursorPositionOnScreen}
presenceStatus={$currentUserStatus?.presenceStatus ?? null}
presenceStatus={$currentPresenceState?.presenceStatus ?? null}
friendsCursorPositions={$friendsCursorPositions}
friends={$appData?.friends ?? []}
friendsUserStatuses={$friendsUserStatuses}
friendsPresenceStates={$friendsPresenceStates}
/>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { PresenceStatus } from "../../../types/bindings/PresenceStatus";
import type { UserStatus } from "../../../events/user-status";
import type { PresenceState } from "../../../events/user-status";
interface Friend {
friend?: {
@@ -15,7 +15,7 @@
presenceStatus: PresenceStatus | null;
friendsCursorPositions: Record<string, { mapped: { x: number; y: number } }>;
friends: Friend[];
friendsUserStatuses: Record<string, UserStatus>;
friendsPresenceStates: Record<string, PresenceState>;
}
let {
@@ -24,7 +24,7 @@
presenceStatus,
friendsCursorPositions,
friends,
friendsUserStatuses,
friendsPresenceStates,
}: Props = $props();
function getFriendById(userId: string) {
@@ -33,7 +33,7 @@
}
function getFriendStatus(userId: string) {
return friendsUserStatuses[userId];
return friendsPresenceStates[userId];
}
</script>