frontend events system refactor

This commit is contained in:
2026-03-07 01:14:41 +08:00
parent 59253d286c
commit 93e33e8d64
14 changed files with 322 additions and 224 deletions

View File

@@ -1,24 +1,27 @@
import { writable } from "svelte/store";
import { type UserData } from "../types/bindings/UserData";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { AppEvents } from "../types/bindings/AppEventsConstants";
import { createListenerSubscription, setupHmrCleanup } from "./listener-utils";
export let appData = writable<UserData | null>(null);
export const appData = writable<UserData | null>(null);
let unlisten: UnlistenFn | null = null;
let isListening = false;
const subscription = createListenerSubscription();
export async function initAppDataListener() {
try {
if (isListening) return;
if (subscription.isListening()) return;
appData.set(await invoke("get_app_data"));
unlisten = await listen<UserData>(AppEvents.AppDataRefreshed, (event) => {
console.log("app-data-refreshed", event.payload);
appData.set(event.payload);
});
isListening = true;
const unlisten = await listen<UserData>(
AppEvents.AppDataRefreshed,
(event) => {
console.log("app-data-refreshed", event.payload);
appData.set(event.payload);
},
);
subscription.setUnlisten(unlisten);
subscription.setListening(true);
} catch (error) {
console.error(error);
throw error;
@@ -26,16 +29,7 @@ export async function initAppDataListener() {
}
export function stopAppDataListener() {
if (unlisten) {
unlisten();
unlisten = null;
isListening = false;
}
subscription.stop();
}
// Handle HMR (Hot Module Replacement) cleanup
if (import.meta.hot) {
import.meta.hot.dispose(() => {
stopAppDataListener();
});
}
setupHmrCleanup(stopAppDataListener);

View File

@@ -1,12 +1,18 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
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";
export let cursorPositionOnScreen = writable<CursorPositions>({
export const cursorPositionOnScreen = writable<CursorPositions>({
raw: { x: 0, y: 0 },
mapped: { x: 0, y: 0 },
});
@@ -26,16 +32,12 @@ type FriendCursorData = {
// 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 let friendsCursorPositions = writable<Record<string, CursorPositions>>(
export const friendsCursorPositions = writable<Record<string, CursorPositions>>(
{},
);
export let friendsActiveDolls = writable<Record<string, DollDto | null>>({});
export const friendsActiveDolls = writable<Record<string, DollDto | null>>({});
let unlistenCursor: UnlistenFn | null = null;
let unlistenFriendCursor: UnlistenFn | null = null;
let unlistenFriendDisconnected: UnlistenFn | null = null;
let unlistenFriendActiveDollChanged: UnlistenFn | null = null;
let isListening = false;
const subscription = createMultiListenerSubscription();
// Internal state to track timestamps
let friendCursorState: Record<string, FriendCursorData> = {};
@@ -46,22 +48,21 @@ let friendCursorState: Record<string, FriendCursorData> = {};
* but all windows can independently listen to the broadcast events.
*/
export async function initCursorTracking() {
if (isListening) {
return;
}
if (subscription.isListening()) return;
try {
// Listen to cursor position events (each window subscribes independently)
unlistenCursor = await listen<CursorPositions>(
const unlistenCursor = await listen<CursorPositions>(
AppEvents.CursorPosition,
(event) => {
cursorPositionOnScreen.set(event.payload);
},
);
subscription.addUnlisten(unlistenCursor);
// Listen to friend cursor position events
unlistenFriendCursor = await listen<FriendCursorPosition>(
"friend-cursor-position",
const unlistenFriendCursor = await listen<FriendCursorPosition>(
AppEvents.FriendCursorPosition,
(event) => {
// We now receive a clean object from Rust
const data = event.payload;
@@ -80,58 +81,44 @@ export async function initCursorTracking() {
});
},
);
subscription.addUnlisten(unlistenFriendCursor);
// Listen to friend disconnected events
unlistenFriendDisconnected = await listen<[{ userId: string }]>(
"friend-disconnected",
(event) => {
let payload = event.payload;
if (typeof payload === "string") {
try {
payload = JSON.parse(payload);
} catch (e) {
console.error("Failed to parse friend disconnected payload:", e);
return;
}
}
const unlistenFriendDisconnected = await listen<
[{ userId: string }] | { userId: string } | string
>(AppEvents.FriendDisconnected, (event) => {
const payload = parseEventPayload<
[{ userId: string }] | { userId: string }
>(event.payload, "friend-disconnected");
if (!payload) return;
const data = Array.isArray(payload) ? payload[0] : payload;
const data = Array.isArray(payload) ? payload[0] : payload;
// Remove from internal state
if (friendCursorState[data.userId]) {
delete friendCursorState[data.userId];
}
// Remove from internal state
if (friendCursorState[data.userId]) {
delete friendCursorState[data.userId];
}
// Update svelte store
friendsCursorPositions.update((current) => {
const next = { ...current };
delete next[data.userId];
return next;
});
},
);
// Update svelte store
friendsCursorPositions.update((current) =>
removeFromStore(current, data.userId),
);
});
subscription.addUnlisten(unlistenFriendDisconnected);
// Listen to friend active doll changed events
unlistenFriendActiveDollChanged = await listen<
const unlistenFriendActiveDollChanged = await listen<
| string
| {
friendId: string;
doll: DollDto | null;
}
>("friend-active-doll-changed", (event) => {
let data = event.payload;
if (typeof data === "string") {
try {
data = JSON.parse(data);
} catch (e) {
console.error(
"Failed to parse friend-active-doll-changed payload:",
e,
);
return;
}
}
>(AppEvents.FriendActiveDollChanged, (event) => {
const data = parseEventPayload<{
friendId: string;
doll: DollDto | null;
}>(event.payload, "friend-active-doll-changed");
if (!data) return;
// Cast to expected type after parsing
const payload = data as { friendId: string; doll: DollDto | null };
@@ -149,11 +136,9 @@ export async function initCursorTracking() {
});
// Also remove from cursor positions so the sprite disappears
friendsCursorPositions.update((current) => {
const next = { ...current };
delete next[payload.friendId];
return next;
});
friendsCursorPositions.update((current) =>
removeFromStore(current, payload.friendId),
);
} else {
// Update or add the new doll configuration
friendsActiveDolls.update((current) => {
@@ -164,8 +149,9 @@ export async function initCursorTracking() {
});
}
});
subscription.addUnlisten(unlistenFriendActiveDollChanged);
isListening = true;
subscription.setListening(true);
} catch (err) {
console.error("Failed to initialize cursor tracking:", err);
throw err;
@@ -177,28 +163,7 @@ export async function initCursorTracking() {
* Note: This doesn't stop the Rust-side tracking, just stops this window from receiving events.
*/
export function stopCursorTracking() {
if (unlistenCursor) {
unlistenCursor();
unlistenCursor = null;
}
if (unlistenFriendCursor) {
unlistenFriendCursor();
unlistenFriendCursor = null;
}
if (unlistenFriendDisconnected) {
unlistenFriendDisconnected();
unlistenFriendDisconnected = null;
}
if (unlistenFriendActiveDollChanged) {
unlistenFriendActiveDollChanged();
unlistenFriendActiveDollChanged = null;
}
isListening = false;
subscription.stop();
}
// Handle HMR (Hot Module Replacement) cleanup
if (import.meta.hot) {
import.meta.hot.dispose(() => {
stopCursorTracking();
});
}
setupHmrCleanup(stopCursorTracking);

View File

@@ -2,20 +2,27 @@ import { listen } from "@tauri-apps/api/event";
import { addInteraction } from "$lib/stores/interaction-store";
import type { InteractionPayloadDto } from "../types/bindings/InteractionPayloadDto";
import type { InteractionDeliveryFailedDto } from "../types/bindings/InteractionDeliveryFailedDto";
import { AppEvents } from "../types/bindings/AppEventsConstants";
import {
createMultiListenerSubscription,
setupHmrCleanup,
} from "./listener-utils";
let unlistenReceived: (() => void) | undefined;
let unlistenFailed: (() => void) | undefined;
const subscription = createMultiListenerSubscription();
export async function initInteractionListeners() {
unlistenReceived = await listen<InteractionPayloadDto>(
"interaction-received",
if (subscription.isListening()) return;
const unlistenReceived = await listen<InteractionPayloadDto>(
AppEvents.InteractionReceived,
(event) => {
addInteraction(event.payload);
},
);
subscription.addUnlisten(unlistenReceived);
unlistenFailed = await listen<InteractionDeliveryFailedDto>(
"interaction-delivery-failed",
const unlistenFailed = await listen<InteractionDeliveryFailedDto>(
AppEvents.InteractionDeliveryFailed,
(event) => {
console.error("Interaction delivery failed:", event.payload);
// You might want to show a toast or alert here
@@ -24,9 +31,12 @@ export async function initInteractionListeners() {
);
},
);
subscription.addUnlisten(unlistenFailed);
subscription.setListening(true);
}
export function stopInteractionListeners() {
if (unlistenReceived) unlistenReceived();
if (unlistenFailed) unlistenFailed();
subscription.stop();
}
setupHmrCleanup(stopInteractionListeners);

View File

@@ -0,0 +1,101 @@
import type { UnlistenFn } from "@tauri-apps/api/event";
export type ListenerSubscription = {
stop: () => void;
isListening: () => boolean;
setListening: (value: boolean) => void;
setUnlisten: (unlisten: UnlistenFn | null) => void;
};
export type MultiListenerSubscription = {
stop: () => void;
isListening: () => boolean;
setListening: (value: boolean) => void;
addUnlisten: (unlisten: UnlistenFn | null) => void;
};
export function createListenerSubscription(
stopFn: () => void = () => {},
): ListenerSubscription {
let unlisten: UnlistenFn | null = null;
let listening = false;
return {
stop: () => {
if (unlisten) {
unlisten();
unlisten = null;
}
listening = false;
stopFn();
},
isListening: () => listening,
setListening: (value) => {
listening = value;
},
setUnlisten: (next) => {
unlisten = next;
},
};
}
export function createMultiListenerSubscription(
stopFn: () => void = () => {},
): MultiListenerSubscription {
let unlistens: UnlistenFn[] = [];
let listening = false;
return {
stop: () => {
for (const unlisten of unlistens) {
unlisten();
}
unlistens = [];
listening = false;
stopFn();
},
isListening: () => listening,
setListening: (value) => {
listening = value;
},
addUnlisten: (unlisten) => {
if (unlisten) {
unlistens.push(unlisten);
}
},
};
}
export function setupHmrCleanup(cleanup: () => void) {
if (import.meta.hot) {
import.meta.hot.dispose(() => {
cleanup();
});
}
}
export function parseEventPayload<T>(
payload: unknown,
errorLabel: string,
): T | null {
if (typeof payload === "string") {
try {
return JSON.parse(payload) as T;
} catch (error) {
console.error(`Failed to parse ${errorLabel} payload`, error);
return null;
}
}
return payload as T;
}
export function removeFromStore<T>(
current: Record<string, T>,
key: string,
): Record<string, T> {
if (!(key in current)) return current;
const next = { ...current };
delete next[key];
return next;
}

View File

@@ -1,22 +1,26 @@
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { listen } from "@tauri-apps/api/event";
import { writable } from "svelte/store";
import { AppEvents } from "../types/bindings/AppEventsConstants";
import { createListenerSubscription, setupHmrCleanup } from "./listener-utils";
export const sceneInteractive = writable<boolean>(false);
let unlisten: UnlistenFn | null = null;
let isListening = false;
const subscription = createListenerSubscription();
export async function initSceneInteractiveListener() {
if (isListening) return;
if (subscription.isListening()) return;
try {
// ensure initial default matches backend default
sceneInteractive.set(false);
unlisten = await listen<boolean>(AppEvents.SceneInteractive, (event) => {
sceneInteractive.set(Boolean(event.payload));
});
isListening = true;
const unlisten = await listen<boolean>(
AppEvents.SceneInteractive,
(event) => {
sceneInteractive.set(Boolean(event.payload));
},
);
subscription.setUnlisten(unlisten);
subscription.setListening(true);
} catch (error) {
console.error("Failed to initialize scene interactive listener:", error);
throw error;
@@ -24,15 +28,7 @@ export async function initSceneInteractiveListener() {
}
export function stopSceneInteractiveListener() {
if (unlisten) {
unlisten();
unlisten = null;
isListening = false;
}
subscription.stop();
}
if (import.meta.hot) {
import.meta.hot.dispose(() => {
stopSceneInteractiveListener();
});
}
setupHmrCleanup(stopSceneInteractiveListener);

View File

@@ -1,6 +1,13 @@
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { listen } from "@tauri-apps/api/event";
import { writable } from "svelte/store";
import type { PresenceStatus } from "../types/bindings/PresenceStatus";
import { AppEvents } from "../types/bindings/AppEventsConstants";
import {
createMultiListenerSubscription,
parseEventPayload,
removeFromStore,
setupHmrCleanup,
} from "./listener-utils";
export type UserStatus = {
presenceStatus: PresenceStatus;
@@ -9,75 +16,65 @@ export type UserStatus = {
export const friendsUserStatuses = writable<Record<string, UserStatus>>({});
let unlistenStatus: UnlistenFn | null = null;
let unlistenFriendDisconnected: UnlistenFn | null = null;
let isListening = false;
const subscription = createMultiListenerSubscription();
export async function initUserStatusListeners() {
if (isListening) return;
if (subscription.isListening()) return;
try {
unlistenStatus = await listen<unknown>("friend-user-status", (event) => {
let payload = event.payload as any;
if (typeof payload === "string") {
try {
payload = JSON.parse(payload);
} catch (error) {
console.error("Failed to parse friend-user-status payload", error);
return;
}
}
const unlistenStatus = await listen<unknown>(
AppEvents.FriendUserStatus,
(event) => {
const payload = parseEventPayload<{
userId?: string;
status?: UserStatus;
}>(event.payload, "friend-user-status");
if (!payload) return;
const userId = payload?.userId as string | undefined;
const status = payload?.status as UserStatus | undefined;
const userId = payload.userId;
const status = payload.status;
if (!userId || !status) return;
if (!status.presenceStatus) return;
if (!userId || !status) return;
if (!status.presenceStatus) return;
// Validate that appMetadata has at least one valid name
const hasValidName =
(typeof status.presenceStatus.title === "string" &&
status.presenceStatus.title.trim() !== "") ||
(typeof status.presenceStatus.subtitle === "string" &&
status.presenceStatus.subtitle.trim() !== "");
if (!hasValidName) return;
// Validate that appMetadata has at least one valid name
const hasValidName =
(typeof status.presenceStatus.title === "string" &&
status.presenceStatus.title.trim() !== "") ||
(typeof status.presenceStatus.subtitle === "string" &&
status.presenceStatus.subtitle.trim() !== "");
if (!hasValidName) return;
if (status.state !== "idle" && status.state !== "resting") return;
if (status.state !== "idle" && status.state !== "resting") return;
friendsUserStatuses.update((current) => ({
...current,
[userId]: {
presenceStatus: status.presenceStatus,
state: status.state,
},
}));
});
friendsUserStatuses.update((current) => ({
...current,
[userId]: {
presenceStatus: status.presenceStatus,
state: status.state,
},
}));
},
);
subscription.addUnlisten(unlistenStatus);
unlistenFriendDisconnected = await listen<
const unlistenFriendDisconnected = await listen<
[{ userId: string }] | { userId: string } | string
>("friend-disconnected", (event) => {
let payload = event.payload as any;
if (typeof payload === "string") {
try {
payload = JSON.parse(payload);
} catch (error) {
console.error("Failed to parse friend-disconnected payload", error);
return;
}
}
>(AppEvents.FriendDisconnected, (event) => {
const payload = parseEventPayload<
[{ userId: string }] | { userId: string }
>(event.payload, "friend-disconnected");
if (!payload) return;
const data = Array.isArray(payload) ? payload[0] : payload;
const userId = data?.userId as string | undefined;
if (!userId) return;
friendsUserStatuses.update((current) => {
const next = { ...current };
delete next[userId];
return next;
});
friendsUserStatuses.update((current) => removeFromStore(current, userId));
});
subscription.addUnlisten(unlistenFriendDisconnected);
isListening = true;
subscription.setListening(true);
} catch (error) {
console.error("Failed to initialize user status listeners", error);
throw error;
@@ -85,19 +82,7 @@ export async function initUserStatusListeners() {
}
export function stopUserStatusListeners() {
if (unlistenStatus) {
unlistenStatus();
unlistenStatus = null;
}
if (unlistenFriendDisconnected) {
unlistenFriendDisconnected();
unlistenFriendDisconnected = null;
}
isListening = false;
subscription.stop();
}
if (import.meta.hot) {
import.meta.hot.dispose(() => {
stopUserStatusListeners();
});
}
setupHmrCleanup(stopUserStatusListeners);