refactored svelte tauri events

This commit is contained in:
2026-03-07 03:11:39 +08:00
parent 2bf8581095
commit f372e86457
12 changed files with 257 additions and 232 deletions

View File

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

View File

@@ -82,6 +82,11 @@ pub fn set_scene_interactive(interactive: bool, should_click: bool) {
update_scene_interactive(interactive, should_click); 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] #[tauri::command]
pub fn set_pet_menu_state(id: String, open: bool) { pub fn set_pet_menu_state(id: String, open: bool) {
let menus_arc = get_open_pet_menus(); let menus_arc = get_open_pet_menus();

View File

@@ -9,14 +9,17 @@ export const appData = writable<UserData | null>(null);
const subscription = createListenerSubscription(); 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 { try {
if (subscription.isListening()) return; if (subscription.isListening()) return;
appData.set(await invoke("get_app_data")); appData.set(await invoke("get_app_data"));
const unlisten = await listen<UserData>( const unlisten = await listen<UserData>(
AppEvents.AppDataRefreshed, AppEvents.AppDataRefreshed,
(event) => { (event) => {
console.log("app-data-refreshed", event.payload);
appData.set(event.payload); appData.set(event.payload);
}, },
); );
@@ -28,8 +31,8 @@ export async function initAppDataListener() {
} }
} }
export function stopAppDataListener() { export function stopAppData() {
subscription.stop(); subscription.stop();
} }
setupHmrCleanup(stopAppDataListener); setupHmrCleanup(stopAppData);

View File

@@ -2,155 +2,31 @@ import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import type { CursorPositions } from "../types/bindings/CursorPositions"; 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 { AppEvents } from "../types/bindings/AppEventsConstants";
import { import { createListenerSubscription, setupHmrCleanup } from "./listener-utils";
createMultiListenerSubscription,
parseEventPayload,
removeFromStore,
setupHmrCleanup,
} from "./listener-utils";
export const cursorPositionOnScreen = writable<CursorPositions>({ export const cursorPositionOnScreen = writable<CursorPositions>({
raw: { x: 0, y: 0 }, raw: { x: 0, y: 0 },
mapped: { x: 0, y: 0 }, mapped: { x: 0, y: 0 },
}); });
export type FriendCursorPosition = { const subscription = createListenerSubscription();
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> = {};
/** /**
* Initialize cursor tracking for this window. * Starts tracking the local cursor position.
* Can be called from multiple windows - only the first call starts tracking on the Rust side, * Initializes cursor position from the backend and listens for updates.
* but all windows can independently listen to the broadcast events.
*/ */
export async function initCursorTracking() { export async function startCursorTracking() {
if (subscription.isListening()) return; if (subscription.isListening()) return;
try { try {
// Listen to cursor position events (each window subscribes independently) const unlisten = await listen<CursorPositions>(
const unlistenCursor = await listen<CursorPositions>(
AppEvents.CursorPosition, AppEvents.CursorPosition,
(event) => { (event) => {
cursorPositionOnScreen.set(event.payload); cursorPositionOnScreen.set(event.payload);
}, },
); );
subscription.addUnlisten(unlistenCursor); subscription.setUnlisten(unlisten);
// 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.setListening(true); subscription.setListening(true);
} catch (err) { } catch (err) {
console.error("Failed to initialize cursor tracking:", err); console.error("Failed to initialize cursor tracking:", err);
@@ -158,10 +34,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() { export function stopCursorTracking() {
subscription.stop(); 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 { 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 { InteractionPayloadDto } from "../types/bindings/InteractionPayloadDto";
import type { InteractionDeliveryFailedDto } from "../types/bindings/InteractionDeliveryFailedDto"; import type { InteractionDeliveryFailedDto } from "../types/bindings/InteractionDeliveryFailedDto";
import { AppEvents } from "../types/bindings/AppEventsConstants"; import { AppEvents } from "../types/bindings/AppEventsConstants";
@@ -8,11 +8,35 @@ import {
setupHmrCleanup, setupHmrCleanup,
} from "./listener-utils"; } 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(); const subscription = createMultiListenerSubscription();
export async function initInteractionListeners() { /**
* Starts listening for interaction events (received and delivery failed).
*/
export async function startInteraction() {
if (subscription.isListening()) return; if (subscription.isListening()) return;
try {
const unlistenReceived = await listen<InteractionPayloadDto>( const unlistenReceived = await listen<InteractionPayloadDto>(
AppEvents.InteractionReceived, AppEvents.InteractionReceived,
(event) => { (event) => {
@@ -25,7 +49,6 @@ export async function initInteractionListeners() {
AppEvents.InteractionDeliveryFailed, AppEvents.InteractionDeliveryFailed,
(event) => { (event) => {
console.error("Interaction delivery failed:", event.payload); console.error("Interaction delivery failed:", event.payload);
// You might want to show a toast or alert here
alert( alert(
`Failed to send message to user ${event.payload.recipientUserId}: ${event.payload.reason}`, `Failed to send message to user ${event.payload.recipientUserId}: ${event.payload.reason}`,
); );
@@ -33,10 +56,14 @@ export async function initInteractionListeners() {
); );
subscription.addUnlisten(unlistenFailed); subscription.addUnlisten(unlistenFailed);
subscription.setListening(true); subscription.setListening(true);
} catch (err) {
console.error("Failed to initialize interaction listeners:", err);
throw err;
}
} }
export function stopInteractionListeners() { export function stopInteraction() {
subscription.stop(); subscription.stop();
} }
setupHmrCleanup(stopInteractionListeners); setupHmrCleanup(stopInteraction);

View File

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

View File

@@ -9,17 +9,20 @@ import {
setupHmrCleanup, setupHmrCleanup,
} from "./listener-utils"; } from "./listener-utils";
export type UserStatus = { export type PresenceState = {
presenceStatus: PresenceStatus; presenceStatus: PresenceStatus;
state: "idle" | "resting"; state: "idle" | "resting";
}; };
export const friendsUserStatuses = writable<Record<string, UserStatus>>({}); export const friendsPresenceStates = writable<Record<string, PresenceState>>({});
export const currentUserStatus = writable<UserStatus | null>(null); export const currentPresenceState = writable<PresenceState | null>(null);
const subscription = createMultiListenerSubscription(); 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; if (subscription.isListening()) return;
try { try {
@@ -28,7 +31,7 @@ export async function initUserStatusListeners() {
(event) => { (event) => {
const payload = parseEventPayload<{ const payload = parseEventPayload<{
userId?: string; userId?: string;
status?: UserStatus; status?: PresenceState;
}>(event.payload, AppEvents.FriendUserStatus); }>(event.payload, AppEvents.FriendUserStatus);
if (!payload) return; if (!payload) return;
@@ -48,7 +51,7 @@ export async function initUserStatusListeners() {
if (status.state !== "idle" && status.state !== "resting") return; if (status.state !== "idle" && status.state !== "resting") return;
friendsUserStatuses.update((current) => ({ friendsPresenceStates.update((current) => ({
...current, ...current,
[userId]: { [userId]: {
presenceStatus: status.presenceStatus, presenceStatus: status.presenceStatus,
@@ -59,10 +62,10 @@ export async function initUserStatusListeners() {
); );
subscription.addUnlisten(unlistenStatus); subscription.addUnlisten(unlistenStatus);
const unlistenUserStatusChanged = await listen<UserStatus>( const unlistenUserStatusChanged = await listen<PresenceState>(
AppEvents.UserStatusChanged, AppEvents.UserStatusChanged,
(event) => { (event) => {
currentUserStatus.set(event.payload); currentPresenceState.set(event.payload);
}, },
); );
subscription.addUnlisten(unlistenUserStatusChanged); subscription.addUnlisten(unlistenUserStatusChanged);
@@ -79,7 +82,7 @@ export async function initUserStatusListeners() {
const userId = data?.userId as string | undefined; const userId = data?.userId as string | undefined;
if (!userId) return; if (!userId) return;
friendsUserStatuses.update((current) => removeFromStore(current, userId)); friendsPresenceStates.update((current) => removeFromStore(current, userId));
}); });
subscription.addUnlisten(unlistenFriendDisconnected); subscription.addUnlisten(unlistenFriendDisconnected);
@@ -90,8 +93,8 @@ export async function initUserStatusListeners() {
} }
} }
export function stopUserStatusListeners() { export function stopUserStatus() {
subscription.stop(); 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> <script>
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { initCursorTracking, stopCursorTracking } from "../events/cursor"; import { startCursorTracking, stopCursorTracking } from "../events/cursor";
import { initAppDataListener } from "../events/app-data";
import { initInteractionListeners, stopInteractionListeners } from "../events/interaction";
import { import {
initSceneInteractiveListener, startFriendCursorTracking,
stopSceneInteractiveListener, stopFriendCursorTracking,
} from "../events/friend-cursor";
import { startAppData } from "../events/app-data";
import { startInteraction, stopInteraction } from "../events/interaction";
import {
startSceneInteractive,
stopSceneInteractive,
} from "../events/scene-interactive"; } from "../events/scene-interactive";
import { initUserStatusListeners, stopUserStatusListeners } from "../events/user-status"; import { startUserStatus, stopUserStatus } from "../events/user-status";
let { children } = $props(); let { children } = $props();
if (browser) { if (browser) {
onMount(async () => { onMount(async () => {
try { try {
await initCursorTracking(); await startAppData();
await initAppDataListener(); await startCursorTracking();
await initSceneInteractiveListener(); await startFriendCursorTracking();
await initInteractionListeners(); await startSceneInteractive();
await initUserStatusListeners(); await startInteraction();
await startUserStatus();
} catch (err) { } catch (err) {
console.error("Failed to initialize event listeners:", err); console.error("Failed to initialize event listeners:", err);
} }
@@ -26,9 +31,10 @@
onDestroy(() => { onDestroy(() => {
stopCursorTracking(); stopCursorTracking();
stopSceneInteractiveListener(); stopFriendCursorTracking();
stopInteractionListeners(); stopSceneInteractive();
stopUserStatusListeners(); stopInteraction();
stopUserStatus();
}); });
} }
</script> </script>

View File

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

View File

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