From 063e9f8837be7cc048d9d88997024d751ca12d18 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sat, 15 Nov 2025 21:02:30 +0100 Subject: [PATCH] fix: debounced payloads to prevent excessive payload freezes --- apps/oneclient/frontend/src/bindings.gen.ts | 20 +-- .../src/components/overlay/Toasts.tsx | 20 ++- .../core/src/api/proxy/impl/proxy_tauri.rs | 114 +++++++++++++++++- 3 files changed, 133 insertions(+), 21 deletions(-) diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index f5ae906a..850b2eb7 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -31,7 +31,7 @@ export type Filters = { game_versions: string[] | null; loaders: GameLoader[] | export type GameLoader = "vanilla" | "forge" | "neoforge" | "quilt" | "fabric" | "legacyfabric" -export type IOError = { type: "InvalidAbsolutePath"; data: string } | { type: "FileNotFoundInZip"; data: string } | { type: "IOErrorWrapper"; data: string } | { type: "IOError"; data: string } | { type: "DeserializeError"; data: string } | { type: "AsyncZipError"; data: string } +export type IOError = { type: "InvalidAbsolutePath"; data: string } | { type: "FileNotFoundInZip"; data: string } | { type: "IOErrorWrapper"; data: string } | { type: "IOError"; data: string } | { type: "DeserializeError"; data: string } | { type: "AsyncZipError"; data: string } | { type: "TempFileError"; data: string } export type Icon = string @@ -190,7 +190,7 @@ export type SettingProfileModel = { name: string; java_id: number | null; res: R export type Settings = { global_game_settings: SettingProfileModel; allow_parallel_running_clusters: boolean; enable_gamemode: boolean; discord_enabled: boolean; seen_onboarding: boolean; max_concurrent_requests: number; settings_version: number; native_window_frame: boolean; show_tanstack_dev_tools: boolean } -export type SettingsOsExtra = Record +export type SettingsOsExtra = { enable_gamemode: boolean | null } export type SkinVariant = "classic" | "slim" @@ -255,13 +255,8 @@ export type VersionType = */ "old_beta" -const ARGS_MAP = { 'oneclient':'{"getBundlesFor":["cluster_id"],"openDevTools":[],"getClustersGroupedByMajor":[]}', 'events':'{"ingress":["event"],"process":["event"],"message":["event"]}', 'core':'{"removeUser":["uuid"],"updateClusterProfile":["name","profile"],"readSettings":[],"getRunningProcesses":[],"isClusterRunning":["cluster_id"],"openMsaLogin":[],"writeSettings":["setting"],"removeCluster":["id"],"getPackageBody":["provider","body"],"getClusterById":["id"],"getLogs":["id"],"getLoadersForVersion":["mc_version"],"setDefaultUser":["uuid"],"fetchMinecraftProfile":["uuid"],"getScreenshots":["id"],"getGameVersions":[],"searchPackages":["provider","query"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"changeSkin":["access_token","skin_url","skin_variant"],"removeCape":["access_token"],"convertUsernameUUID":["username_uuid"],"getProfileOrDefault":["name"],"updateClusterById":["id","request"],"getWorlds":["id"],"getGlobalProfile":[],"getUsersFromAuthor":["provider","author"],"changeCape":["access_token","cape_uuid"],"createCluster":["options"],"getClusters":[],"launchCluster":["id","uuid"],"getMultiplePackages":["provider","slugs"],"getPackage":["provider","slug"],"getUsers":[],"installModpack":["modpack","cluster_id"],"createSettingsProfile":["name"],"getLogByName":["id","name"],"getUser":["uuid"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"fetchLoggedInProfile":["access_token"],"getDefaultUser":["fallback"],"open":["input"],"killProcess":["pid"],"getRunningProcessesByClusterId":["cluster_id"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}' } -export type Router = { 'events': { ingress: (event: IngressPayload) => Promise, -message: (event: MessagePayload) => Promise, -process: (event: ProcessPayload) => Promise }, -'folders': { fromCluster: (folderName: string) => Promise, -openCluster: (folderName: string) => Promise }, -'core': { getClusters: () => Promise, +const ARGS_MAP = { 'core':'{"open":["input"],"fetchLoggedInProfile":["access_token"],"convertUsernameUUID":["username_uuid"],"getGlobalProfile":[],"getRunningProcessesByClusterId":["cluster_id"],"getClusterById":["id"],"readSettings":[],"getClusters":[],"updateClusterProfile":["name","profile"],"createCluster":["options"],"getGameVersions":[],"writeSettings":["setting"],"getLoadersForVersion":["mc_version"],"getUser":["uuid"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"changeCape":["access_token","cape_uuid"],"updateClusterById":["id","request"],"fetchMinecraftProfile":["uuid"],"getPackage":["provider","slug"],"getLogByName":["id","name"],"launchCluster":["id","uuid"],"getWorlds":["id"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"removeUser":["uuid"],"getMultiplePackages":["provider","slugs"],"installModpack":["modpack","cluster_id"],"getRunningProcesses":[],"getLogs":["id"],"searchPackages":["provider","query"],"getPackageBody":["provider","body"],"changeSkin":["access_token","skin_url","skin_variant"],"removeCape":["access_token"],"removeCluster":["id"],"isClusterRunning":["cluster_id"],"killProcess":["pid"],"getUsersFromAuthor":["provider","author"],"createSettingsProfile":["name"],"getDefaultUser":["fallback"],"getUsers":[],"getProfileOrDefault":["name"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getScreenshots":["id"],"setDefaultUser":["uuid"],"openMsaLogin":[]}', 'oneclient':'{"openDevTools":[],"getClustersGroupedByMajor":[],"getBundlesFor":["cluster_id"]}', 'events':'{"ingress":["event"],"message":["event"],"process":["event"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}' } +export type Router = { 'core': { getClusters: () => Promise, getClusterById: (id: number) => Promise, removeCluster: (id: number) => Promise, createCluster: (options: CreateCluster) => Promise, @@ -307,7 +302,12 @@ convertUsernameUUID: (usernameUuid: string) => Promise, open: (input: string) => Promise }, 'oneclient': { openDevTools: () => Promise, getClustersGroupedByMajor: () => Promise>, -getBundlesFor: (clusterId: number) => Promise } }; +getBundlesFor: (clusterId: number) => Promise }, +'events': { ingress: (event: IngressPayload) => Promise, +message: (event: MessagePayload) => Promise, +process: (event: ProcessPayload) => Promise }, +'folders': { fromCluster: (folderName: string) => Promise, +openCluster: (folderName: string) => Promise } }; export type { InferCommandOutput } diff --git a/apps/oneclient/frontend/src/components/overlay/Toasts.tsx b/apps/oneclient/frontend/src/components/overlay/Toasts.tsx index b1649930..7adc0483 100644 --- a/apps/oneclient/frontend/src/components/overlay/Toasts.tsx +++ b/apps/oneclient/frontend/src/components/overlay/Toasts.tsx @@ -6,7 +6,7 @@ import { bindings } from '@/main'; import { toast, toastUpdate } from '@/utils/toast'; import { Button } from '@onelauncher/common/components'; import { AlertCircleIcon, AlertTriangleIcon, CheckCircleIcon, InfoCircleIcon, XCloseIcon } from '@untitled-theme/icons-react'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { cssTransition, ToastContainer } from 'react-toastify'; import { tv } from 'tailwind-variants'; import { Spinner } from '../Spinner'; @@ -126,14 +126,24 @@ const ingressNames: Record<(IngressType & string) | (keyof UnionToIntersection()); + const listenerRef = useRef(false); + useEffect(() => { + if (listenerRef.current) + return; + + listenerRef.current = true; + let unlisten: (() => void) | undefined; - const toastIngressMap: Map = new Map(); bindings.events.ingress.on((e) => { - const ingressType = typeof e.ingress_type === 'string' ? e.ingress_type : Object.keys(e.ingress_type).at(0) ?? 'Info'; + const toastIngressMap = toastIngressMapRef.current; + + const ingressType = typeof e.ingress_type === 'string' + ? e.ingress_type + : Object.keys(e.ingress_type)[0] ?? 'Info'; + const title = ingressNames[ingressType as keyof typeof ingressNames] ?? ingressType; const existingToastId = toastIngressMap.get(e.id); diff --git a/packages/core/src/api/proxy/impl/proxy_tauri.rs b/packages/core/src/api/proxy/impl/proxy_tauri.rs index 3e09503c..6f3c793e 100644 --- a/packages/core/src/api/proxy/impl/proxy_tauri.rs +++ b/packages/core/src/api/proxy/impl/proxy_tauri.rs @@ -1,7 +1,16 @@ +use std::collections::{HashMap, VecDeque}; +use std::fmt::Debug; +use std::sync::Arc; +use std::time::Duration; + use tauri::{AppHandle, Manager}; +use tokio::sync::{Mutex, Notify}; +use tokio::time::Instant; use tracing::{error, warn}; +use uuid::Uuid; use crate::LauncherResult; +use crate::api::ingress::IngressPayload; use crate::api::proxy::LauncherProxy; use crate::api::proxy::event::LauncherEvent; use crate::api::proxy::message::MessageLevel; @@ -9,8 +18,10 @@ use crate::api::tauri::LauncherEventEmitter; #[derive(Debug)] pub struct ProxyTauri { - emitter: LauncherEventEmitter, + emitter: Arc>, handle: AppHandle, + + ingress_queue: Arc, } impl ProxyTauri { @@ -23,9 +34,27 @@ impl ProxyTauri { ); Self { - emitter: LauncherEventEmitter::new(handle.clone()), + emitter: Arc::new(LauncherEventEmitter::new(handle.clone())), handle, + ingress_queue: IngressQueue::new(), } + .init() + } + + fn init(self) -> Self { + let ingress_queue = Arc::clone(&self.ingress_queue); + let emitter = Arc::clone(&self.emitter); + + // spawn the processor on another thread + tokio::spawn(async move { + ingress_queue + .process_queue(move |payload| { + let _ = emitter.ingress(payload); + }) + .await; + }); + + self } } @@ -41,10 +70,14 @@ impl LauncherProxy for ProxyTauri { } Ok(match event { - LauncherEvent::Ingress(ingress) => self.emitter.ingress(ingress), - LauncherEvent::Message(message) => self.emitter.message(message), - LauncherEvent::Process(process) => self.emitter.process(process), - }?) + LauncherEvent::Ingress(ingress) => { + self.ingress_queue.push(ingress).await; + + () + } + LauncherEvent::Message(message) => self.emitter.message(message)?, + LauncherEvent::Process(process) => self.emitter.process(process)?, + }) } #[tracing::instrument] @@ -67,3 +100,72 @@ impl LauncherProxy for ProxyTauri { } } } + +pub struct IngressQueue { + queue: Mutex>, + last_sent: Mutex>, + notify: Notify, + debounce: Duration, +} + +impl Debug for IngressQueue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IngressQueue") + .field("debounce", &self.debounce) + .finish() + } +} + +impl IngressQueue { + pub fn new() -> Arc { + Arc::new(Self { + queue: Mutex::new(VecDeque::new()), + last_sent: Mutex::new(HashMap::new()), + notify: Notify::new(), + debounce: Duration::from_millis(100), + }) + } + + pub async fn push(&self, payload: IngressPayload) { + let now = Instant::now(); + + // check last sent time for debouncing + let mut last_sent = self.last_sent.lock().await; + if let Some(&instant) = last_sent.get(&payload.id) { + if now.duration_since(instant) < self.debounce { + // skip update or merge with existing queued message + let mut queue = self.queue.lock().await; + if let Some(existing) = queue.iter_mut().find(|q| q.id == payload.id) { + existing.percent = payload.percent.or(existing.percent); + existing.message = payload.message; + return; + } + } + } + + last_sent.insert(payload.id, now); + + let mut queue = self.queue.lock().await; + queue.push_back(payload); + self.notify.notify_one(); // wake processor + } + + pub async fn process_queue(self: Arc, mut sender: F) + where + F: FnMut(IngressPayload) + Send + 'static, + { + loop { + let queued = { + let mut queue = self.queue.lock().await; + queue.pop_front() + }; + + if let Some(payload) = queued { + sender(payload); + } else { + // wait until next push, no CPU spin + self.notify.notified().await; + } + } + } +}