migrate from ts-rs to tauri-specta

This commit is contained in:
2026-03-07 18:36:51 +08:00
parent f65d837841
commit 4d7e97771a
86 changed files with 766 additions and 609 deletions

View File

@@ -1,105 +1,84 @@
use serde::Serialize;
#[allow(unused_imports)]
use std::{fs, path::Path};
use strum::{AsRefStr, EnumIter};
use ts_rs::TS;
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri_specta::Event;
#[derive(Serialize, TS, EnumIter, AsRefStr)]
#[serde(rename_all = "kebab-case")]
#[ts(export)]
pub enum AppEvents {
CursorPosition,
SceneInteractive,
AppDataRefreshed,
SetInteractionOverlay,
EditDoll,
CreateDoll,
UserStatusChanged,
FriendCursorPosition,
FriendDisconnected,
FriendActiveDollChanged,
FriendUserStatus,
InteractionReceived,
InteractionDeliveryFailed,
FriendRequestReceived,
FriendRequestAccepted,
FriendRequestDenied,
Unfriended,
}
use crate::{
models::{
app_data::UserData,
event_payloads::{
FriendActiveDollChangedPayload, FriendDisconnectedPayload,
FriendRequestAcceptedPayload, FriendRequestDeniedPayload, FriendRequestReceivedPayload,
FriendUserStatusPayload, UnfriendedPayload, UserStatusPayload,
},
interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto},
},
services::{cursor::CursorPositions, ws::OutgoingFriendCursorPayload},
};
impl AppEvents {
pub fn as_str(&self) -> &'static str {
match self {
AppEvents::CursorPosition => "cursor-position",
AppEvents::SceneInteractive => "scene-interactive",
AppEvents::AppDataRefreshed => "app-data-refreshed",
AppEvents::SetInteractionOverlay => "set-interaction-overlay",
AppEvents::EditDoll => "edit-doll",
AppEvents::CreateDoll => "create-doll",
AppEvents::UserStatusChanged => "user-status-changed",
AppEvents::FriendCursorPosition => "friend-cursor-position",
AppEvents::FriendDisconnected => "friend-disconnected",
AppEvents::FriendActiveDollChanged => "friend-active-doll-changed",
AppEvents::FriendUserStatus => "friend-user-status",
AppEvents::InteractionReceived => "interaction-received",
AppEvents::InteractionDeliveryFailed => "interaction-delivery-failed",
AppEvents::FriendRequestReceived => "friend-request-received",
AppEvents::FriendRequestAccepted => "friend-request-accepted",
AppEvents::FriendRequestDenied => "friend-request-denied",
AppEvents::Unfriended => "unfriended",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "cursor-position")]
pub struct CursorMoved(pub CursorPositions);
#[test]
fn export_bindings_appeventsconsts() {
use strum::IntoEnumIterator;
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "scene-interactive")]
pub struct SceneInteractiveChanged(pub bool);
let some_export_dir = std::env::var("TS_RS_EXPORT_DIR")
.ok()
.map(|s| Path::new(&s).to_owned());
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "app-data-refreshed")]
pub struct AppDataRefreshed(pub UserData);
let Some(export_dir) = some_export_dir else {
eprintln!("TS_RS_EXPORT_DIR not set, skipping constants export");
return;
};
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "set-interaction-overlay")]
pub struct SetInteractionOverlay(pub bool);
let to_kebab_case = |s: &str| -> String {
let mut result = String::new();
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() {
if i > 0 {
result.push('-');
}
result.push(c.to_lowercase().next().unwrap());
} else {
result.push(c);
}
}
result
};
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "edit-doll")]
pub struct EditDoll(pub String);
let mut lines = vec![
r#"// Auto-generated constants - DO NOT EDIT"#.to_string(),
r#"// Generated from Rust AppEvents enum"#.to_string(),
"".to_string(),
"export const AppEvents = {".to_string(),
];
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "create-doll")]
pub struct CreateDoll;
for variant in AppEvents::iter() {
let name = variant.as_ref();
let kebab = to_kebab_case(name);
lines.push(format!(" {}: \"{}\",", name, kebab));
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "user-status-changed")]
pub struct UserStatusChanged(pub UserStatusPayload);
lines.push("} as const;".to_string());
lines.push("".to_string());
lines.push("export type AppEvents = typeof AppEvents[keyof typeof AppEvents];".to_string());
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-cursor-position")]
pub struct FriendCursorPositionUpdated(pub OutgoingFriendCursorPayload);
let constants_content = lines.join("\n");
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-disconnected")]
pub struct FriendDisconnected(pub FriendDisconnectedPayload);
let constants_path = export_dir.join("AppEventsConstants.ts");
if let Err(e) = fs::write(&constants_path, constants_content) {
eprintln!("Failed to write {}: {}", constants_path.display(), e);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-active-doll-changed")]
pub struct FriendActiveDollChanged(pub FriendActiveDollChangedPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-user-status")]
pub struct FriendUserStatusChanged(pub FriendUserStatusPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "interaction-received")]
pub struct InteractionReceived(pub InteractionPayloadDto);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "interaction-delivery-failed")]
pub struct InteractionDeliveryFailed(pub InteractionDeliveryFailedDto);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-request-received")]
pub struct FriendRequestReceived(pub FriendRequestReceivedPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-request-accepted")]
pub struct FriendRequestAccepted(pub FriendRequestAcceptedPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-request-denied")]
pub struct FriendRequestDenied(pub FriendRequestDeniedPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "unfriended")]
pub struct Unfriended(pub UnfriendedPayload);

View File

@@ -1,6 +1,7 @@
use std::{fs, path::PathBuf};
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::Manager;
use thiserror::Error;
use tracing::{error, warn};
@@ -8,7 +9,7 @@ use url::Url;
use crate::get_app_handle;
#[derive(Default, Serialize, Deserialize, Clone, Debug)]
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type)]
pub struct AppConfig {
pub api_base_url: Option<String>,
}

View File

@@ -1,26 +1,29 @@
use device_query::{DeviceEvents, DeviceEventsHandler};
use once_cell::sync::OnceCell;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tauri::Emitter;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use ts_rs::TS;
use crate::{get_app_handle, lock_r, services::app_events::AppEvents, state::FDOLL};
use crate::{
get_app_handle,
lock_r,
services::app_events::CursorMoved,
state::FDOLL,
};
use tauri_specta::Event as _;
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct CursorPosition {
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone, Serialize, TS)]
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct CursorPositions {
pub raw: CursorPosition,
pub mapped: CursorPosition,
@@ -103,7 +106,7 @@ async fn init_cursor_tracking_i() -> Result<(), String> {
crate::services::ws::report_cursor_data(mapped_for_ws).await;
// 2. Broadcast to local windows
if let Err(e) = app_handle.emit(AppEvents::CursorPosition.as_str(), &positions) {
if let Err(e) = CursorMoved(positions).emit(app_handle) {
error!("Failed to emit cursor position event: {:?}", e);
}
}

View File

@@ -1,9 +1,13 @@
#[cfg(target_os = "windows")]
use tauri::WebviewWindow;
use tauri::{Emitter, Listener, Manager};
use tauri::{Listener, Manager};
use tauri_specta::Event as _;
use tracing::{error, info};
use crate::{get_app_handle, services::app_events::AppEvents};
use crate::{
get_app_handle,
services::app_events::{CreateDoll, EditDoll, SetInteractionOverlay},
};
static APP_MENU_WINDOW_LABEL: &str = "app_menu";
@@ -51,6 +55,7 @@ fn set_window_interaction(window: &WebviewWindow, enable: bool) {
}
#[tauri::command]
#[specta::specta]
pub async fn open_doll_editor_window(doll_id: Option<String>) {
let app_handle = get_app_handle().clone();
@@ -76,17 +81,17 @@ pub async fn open_doll_editor_window(doll_id: Option<String>) {
// Ensure overlay is active on parent (redundancy for safety)
#[cfg(target_os = "macos")]
if let Some(parent) = app_handle.get_webview_window(APP_MENU_WINDOW_LABEL) {
if let Err(e) = parent.emit(AppEvents::SetInteractionOverlay.as_str(), true) {
if let Err(e) = SetInteractionOverlay(true).emit(&parent) {
error!("Failed to ensure interaction overlay on parent: {}", e);
}
}
// Emit event to update context
if let Some(id) = doll_id {
if let Err(e) = window.emit(AppEvents::EditDoll.as_str(), id) {
if let Err(e) = EditDoll(id).emit(&window) {
error!("Failed to emit edit-doll event: {}", e);
}
} else if let Err(e) = window.emit(AppEvents::CreateDoll.as_str(), ()) {
} else if let Err(e) = CreateDoll.emit(&window) {
error!("Failed to emit create-doll event: {}", e);
}
@@ -136,7 +141,7 @@ pub async fn open_doll_editor_window(doll_id: Option<String>) {
let app_handle_clone = get_app_handle().clone();
// Emit event to show overlay
if let Err(e) = parent.emit(AppEvents::SetInteractionOverlay.as_str(), true) {
if let Err(e) = SetInteractionOverlay(true).emit(&parent) {
error!("Failed to emit set-interaction-overlay event: {}", e);
}
@@ -169,7 +174,7 @@ pub async fn open_doll_editor_window(doll_id: Option<String>) {
parent.unlisten(id);
}
// Remove overlay if we failed
let _ = parent.emit(AppEvents::SetInteractionOverlay.as_str(), false);
let _ = SetInteractionOverlay(false).emit(&parent);
}
return;
}
@@ -204,9 +209,7 @@ pub async fn open_doll_editor_window(doll_id: Option<String>) {
parent.unlisten(id);
}
// Remove overlay
if let Err(e) = parent
.emit(AppEvents::SetInteractionOverlay.as_str(), false)
{
if let Err(e) = SetInteractionOverlay(false).emit(&parent) {
error!("Failed to remove interaction overlay: {}", e);
}
}
@@ -232,7 +235,7 @@ pub async fn open_doll_editor_window(doll_id: Option<String>) {
if let Some(id) = parent_focus_listener_id {
parent.unlisten(id);
}
let _ = parent.emit(AppEvents::SetInteractionOverlay.as_str(), false);
let _ = SetInteractionOverlay(false).emit(&parent);
}
}
}

View File

@@ -1,18 +1,16 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct PresenceStatus {
pub title: Option<String>,
pub subtitle: Option<String>,
pub graphics_b64: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
#[derive(Serialize, Deserialize, Debug, Clone, Type)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct ModuleMetadata {
pub id: String,
pub name: String,

View File

@@ -4,11 +4,12 @@ use std::thread;
use device_query::{DeviceQuery, DeviceState, Keycode};
use once_cell::sync::OnceCell;
use tauri::{Emitter, Manager};
use tauri::Manager;
use tauri_plugin_positioner::WindowExt;
use tauri_specta::Event as _;
use tracing::{error, info, warn};
use crate::{get_app_handle, services::app_events::AppEvents};
use crate::{get_app_handle, services::app_events::SceneInteractiveChanged};
pub static SCENE_WINDOW_LABEL: &str = "scene";
pub static SPLASH_WINDOW_LABEL: &str = "splash";
@@ -69,7 +70,7 @@ pub fn update_scene_interactive(interactive: bool, should_click: bool) {
}
}
if let Err(e) = window.emit(AppEvents::SceneInteractive.as_str(), &interactive) {
if let Err(e) = SceneInteractiveChanged(interactive).emit(&window) {
error!("Failed to emit scene interactive event: {}", e);
}
} else {
@@ -78,16 +79,19 @@ pub fn update_scene_interactive(interactive: bool, should_click: bool) {
}
#[tauri::command]
#[specta::specta]
pub fn set_scene_interactive(interactive: bool, should_click: bool) {
update_scene_interactive(interactive, should_click);
}
#[tauri::command]
#[specta::specta]
pub fn get_scene_interactive() -> Result<bool, String> {
Ok(scene_interactive_state().load(Ordering::SeqCst))
}
#[tauri::command]
#[specta::specta]
pub fn set_pet_menu_state(id: String, open: bool) {
let menus_arc = get_open_pet_menus();
let should_update = {

View File

@@ -1,6 +1,7 @@
use rust_socketio::Payload;
use serde::Serialize;
use tauri::{async_runtime, Emitter};
use tauri::async_runtime;
use tauri_specta::Event;
use tracing::{error, warn};
use crate::{
@@ -110,11 +111,8 @@ pub async fn ws_emit_soft<T: Serialize + Send + 'static>(
}
}
/// Emit event to frontend (Tauri window)
///
/// Handles error logging consistently.
pub fn emit_to_frontend<T: Serialize + Clone>(event: &str, payload: T) {
if let Err(e) = get_app_handle().emit(event, payload) {
error!("Failed to emit {} event to frontend: {:?}", event, e);
pub fn emit_to_frontend_typed<E: Event + Serialize + Clone>(event: &E) {
if let Err(e) = event.emit(get_app_handle()) {
error!("Failed to emit {} event to frontend: {:?}", E::NAME, e);
}
}

View File

@@ -6,7 +6,11 @@ use crate::models::event_payloads::{
FriendRequestDeniedPayload, FriendRequestReceivedPayload, FriendUserStatusPayload,
UnfriendedPayload,
};
use crate::services::app_events::AppEvents;
use crate::services::app_events::{
FriendActiveDollChanged, FriendCursorPositionUpdated, FriendDisconnected,
FriendRequestAccepted, FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged,
Unfriended,
};
use crate::services::cursor::{normalized_to_absolute, CursorPositions};
use crate::state::AppDataRefreshScope;
@@ -21,7 +25,7 @@ pub fn on_friend_request_received(payload: Payload, _socket: RawClient) {
if let Ok(data) =
utils::extract_and_parse::<FriendRequestReceivedPayload>(payload, "friend-request-received")
{
emitter::emit_to_frontend(AppEvents::FriendRequestReceived.as_str(), data);
emitter::emit_to_frontend_typed(&FriendRequestReceived(data));
}
}
@@ -30,7 +34,7 @@ pub fn on_friend_request_accepted(payload: Payload, _socket: RawClient) {
if let Ok(data) =
utils::extract_and_parse::<FriendRequestAcceptedPayload>(payload, "friend-request-accepted")
{
emitter::emit_to_frontend(AppEvents::FriendRequestAccepted.as_str(), data);
emitter::emit_to_frontend_typed(&FriendRequestAccepted(data));
refresh::refresh_app_data(AppDataRefreshScope::Friends);
}
}
@@ -40,14 +44,14 @@ pub fn on_friend_request_denied(payload: Payload, _socket: RawClient) {
if let Ok(data) =
utils::extract_and_parse::<FriendRequestDeniedPayload>(payload, "friend-request-denied")
{
emitter::emit_to_frontend(AppEvents::FriendRequestDenied.as_str(), data);
emitter::emit_to_frontend_typed(&FriendRequestDenied(data));
}
}
/// Handler for unfriended event
pub fn on_unfriended(payload: Payload, _socket: RawClient) {
if let Ok(data) = utils::extract_and_parse::<UnfriendedPayload>(payload, "unfriended") {
emitter::emit_to_frontend(AppEvents::Unfriended.as_str(), data);
emitter::emit_to_frontend_typed(&Unfriended(data));
refresh::refresh_app_data(AppDataRefreshScope::Friends);
}
}
@@ -68,7 +72,7 @@ pub fn on_friend_cursor_position(payload: Payload, _socket: RawClient) {
},
};
emitter::emit_to_frontend(AppEvents::FriendCursorPosition.as_str(), outgoing_payload);
emitter::emit_to_frontend_typed(&FriendCursorPositionUpdated(outgoing_payload));
}
}
@@ -77,7 +81,7 @@ pub fn on_friend_disconnected(payload: Payload, _socket: RawClient) {
if let Ok(data) =
utils::extract_and_parse::<FriendDisconnectedPayload>(payload, "friend-disconnected")
{
emitter::emit_to_frontend(AppEvents::FriendDisconnected.as_str(), data);
emitter::emit_to_frontend_typed(&FriendDisconnected(data));
}
}
@@ -110,7 +114,7 @@ pub fn on_friend_active_doll_changed(payload: Payload, _socket: RawClient) {
payload,
"friend-active-doll-changed",
) {
emitter::emit_to_frontend(AppEvents::FriendActiveDollChanged.as_str(), data);
emitter::emit_to_frontend_typed(&FriendActiveDollChanged(data));
refresh::refresh_app_data(AppDataRefreshScope::Friends);
}
}
@@ -120,6 +124,6 @@ pub fn on_friend_user_status(payload: Payload, _socket: RawClient) {
if let Ok(data) =
utils::extract_and_parse::<FriendUserStatusPayload>(payload, "friend-user-status")
{
emitter::emit_to_frontend(AppEvents::FriendUserStatus.as_str(), data);
emitter::emit_to_frontend_typed(&FriendUserStatusChanged(data));
}
}

View File

@@ -1,7 +1,7 @@
use rust_socketio::{Payload, RawClient};
use crate::models::interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto};
use crate::services::app_events::AppEvents;
use crate::services::app_events::{InteractionDeliveryFailed, InteractionReceived};
use super::{emitter, utils};
@@ -10,7 +10,7 @@ pub fn on_interaction_received(payload: Payload, _socket: RawClient) {
if let Ok(data) =
utils::extract_and_parse::<InteractionPayloadDto>(payload, "interaction-received")
{
emitter::emit_to_frontend(AppEvents::InteractionReceived.as_str(), data);
emitter::emit_to_frontend_typed(&InteractionReceived(data));
}
}
@@ -20,6 +20,6 @@ pub fn on_interaction_delivery_failed(payload: Payload, _socket: RawClient) {
payload,
"interaction-delivery-failed",
) {
emitter::emit_to_frontend(AppEvents::InteractionDeliveryFailed.as_str(), data);
emitter::emit_to_frontend_typed(&InteractionDeliveryFailed(data));
}
}

View File

@@ -29,4 +29,5 @@ pub mod client;
// Re-export public API
pub use cursor::report_cursor_data;
pub use emitter::ws_emit_soft;
pub use types::OutgoingFriendCursorPayload;
pub use types::WS_EVENT;

View File

@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use specta::Type;
/// WebSocket event constants
#[allow(non_camel_case_types)]
@@ -37,7 +38,7 @@ pub struct IncomingFriendCursorPayload {
}
/// Outgoing friend cursor position to frontend
#[derive(Clone, Serialize)]
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct OutgoingFriendCursorPayload {
pub user_id: String,

View File

@@ -1,4 +1,5 @@
use once_cell::sync::Lazy;
use tauri_specta::Event as _;
use tauri::async_runtime::{self, JoinHandle};
use tokio::sync::Mutex;
use tokio::time::Duration;
@@ -6,7 +7,7 @@ use tracing::warn;
use crate::models::event_payloads::UserStatusPayload;
use crate::services::app_events::AppEvents;
use crate::services::app_events::UserStatusChanged;
use super::{emitter, types::WS_EVENT};
@@ -23,7 +24,9 @@ pub async fn report_user_status(status: UserStatusPayload) {
handle.abort();
}
emitter::emit_to_frontend(AppEvents::UserStatusChanged.as_str(), &status);
if let Err(e) = UserStatusChanged(status.clone()).emit(crate::get_app_handle()) {
warn!("Failed to emit user-status-changed event: {e}");
}
// Schedule new report after 500ms
let handle = async_runtime::spawn(async move {