diff --git a/src-tauri/src/network/mod.rs b/src-tauri/src/network/mod.rs index 91bccf8..f8844a3 100644 --- a/src-tauri/src/network/mod.rs +++ b/src-tauri/src/network/mod.rs @@ -94,6 +94,10 @@ impl Network { self.keypair.public_key() } + pub(crate) fn online_friend_ids(&self) -> Result, String> { + self.friend_presence.snapshot() + } + pub fn send_live_data(&self, data: LiveData) { let active_remotes = match self.friend_presence.active_remotes() { Ok(remotes) if remotes.is_empty() => return, @@ -502,7 +506,7 @@ pub fn list_statuses( #[tauri::command] #[specta::specta] pub fn list_friend_statuses(network: State<'_, Network>) -> Result, String> { - network.friend_presence.snapshot() + network.online_friend_ids() } #[tauri::command] diff --git a/src-tauri/src/puppet/mod.rs b/src-tauri/src/puppet/mod.rs index d554f5a..fe0022a 100644 --- a/src-tauri/src/puppet/mod.rs +++ b/src-tauri/src/puppet/mod.rs @@ -1,5 +1,7 @@ +mod visibility; + use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, sync::RwLock, time::{Duration, Instant}, }; @@ -91,6 +93,7 @@ pub fn init(handle: &AppHandle) -> Result<(), String> { let mut ticker = tokio::time::interval(TICK_INTERVAL); ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); let mut previous_tick = Instant::now(); + let mut visibility = visibility::Visibility::default(); loop { ticker.tick().await; @@ -98,40 +101,74 @@ pub fn init(handle: &AppHandle) -> Result<(), String> { let elapsed = now.duration_since(previous_tick); previous_tick = now; - let cursor_positions = match handle.state::().snapshot() { + // UI state is registered only after the splash/onboarding boundary. + if !crate::ui::scene::is_initialized(&handle) { + continue; + } + let online = match handle + .state::() + .online_friend_ids() + { + Ok(online) => online.into_iter().collect::>(), + Err(error) => { + eprintln!("failed to read scene friend presence: {error}"); + continue; + } + }; + let configuration = match handle.state::().snapshot() { + Ok(configuration) => configuration, + Err(error) => { + eprintln!("failed to read scene configuration: {error}"); + continue; + } + }; + let has_online_friends = !online.is_empty(); + visibility.update( + configuration.hide_local_puppet_when_alone, + has_online_friends, + now, + ); + let local_id = handle + .state::() + .public_key() + .to_owned(); + + let mut cursor_positions = match handle.state::().snapshot() { Ok(positions) => positions, Err(error) => { eprintln!("failed to read cursor positions for puppet motion: {error}"); continue; } }; - let movement_mode = match handle - .state::() - .puppet_movement_mode() - { - Ok(mode) => mode, - Err(error) => { - eprintln!("failed to read Puppet movement mode: {error}"); - continue; - } - }; + cursor_positions + .retain(|id, _| online.contains(id) || (id == &local_id && visibility.show_local)); let puppets = match handle.state::().update( &cursor_positions, viewport, elapsed, - movement_mode, + configuration.puppet_movement_mode, ) { - Ok(Some(puppets)) => puppets, - Ok(None) => continue, + Ok(puppets) => puppets, Err(error) => { eprintln!("failed to update puppet motion: {error}"); continue; } }; - if let Err(error) = emit_changed(&handle, puppets) { + if let Some(puppets) = puppets + && let Err(error) = emit_changed(&handle, puppets) + { eprintln!("failed to emit puppet states: {error}"); } + // Reconcile even when motion is unchanged (including the idle deadline). + if let Err(error) = crate::ui::scene::reconcile_window( + &handle, + visibility.window_open(has_online_friends), + ) + .await + { + eprintln!("failed to reconcile scene window: {error}"); + } } }); diff --git a/src-tauri/src/puppet/visibility.rs b/src-tauri/src/puppet/visibility.rs new file mode 100644 index 0000000..b68adbd --- /dev/null +++ b/src-tauri/src/puppet/visibility.rs @@ -0,0 +1,110 @@ +use std::time::{Duration, Instant}; + +const HIDE_DELAY: Duration = Duration::from_secs(5); +const SHOW_DELAY: Duration = Duration::from_secs(3); + +/// Backend-owned visibility survives destruction of the scene webview. +pub(super) struct Visibility { + pub show_local: bool, + pending: Option<(bool, Instant)>, +} + +impl Default for Visibility { + fn default() -> Self { + Self { + show_local: true, + pending: None, + } + } +} + +impl Visibility { + pub fn window_open(&self, has_friends: bool) -> bool { + self.show_local || has_friends + } + + pub fn update(&mut self, hide_when_alone: bool, has_friends: bool, now: Instant) { + if !hide_when_alone { + self.show_local = true; + self.pending = None; + return; + } + if self.show_local == has_friends { + self.pending = None; + return; + } + let (target, deadline) = *self.pending.get_or_insert_with(|| { + ( + has_friends, + now + if has_friends { SHOW_DELAY } else { HIDE_DELAY }, + ) + }); + if now >= deadline { + self.show_local = target; + self.pending = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_puppet_hides_after_five_seconds_and_return_delays_only_local_puppet() { + let now = Instant::now(); + let mut visibility = Visibility::default(); + visibility.update(true, false, now); + visibility.update(true, false, now + HIDE_DELAY - Duration::from_millis(1)); + assert!(visibility.show_local); + visibility.update(true, false, now + HIDE_DELAY); + assert!(!visibility.show_local); + assert!(!visibility.window_open(false)); + let returned = now + HIDE_DELAY; + visibility.update(true, true, returned); + assert!(!visibility.show_local); + assert!(visibility.window_open(true)); + visibility.update(true, true, returned + SHOW_DELAY); + assert!(visibility.show_local); + } + + #[test] + fn reconnect_cancels_hide_and_disconnect_cancels_show() { + let now = Instant::now(); + let mut visibility = Visibility::default(); + visibility.update(true, false, now); + visibility.update(true, true, now + Duration::from_secs(4)); + visibility.update(true, false, now + Duration::from_secs(5)); + assert!(visibility.show_local); + visibility.update(true, false, now + Duration::from_secs(10)); + assert!(!visibility.show_local); + visibility.update(true, true, now + Duration::from_secs(11)); + visibility.update(true, false, now + Duration::from_secs(12)); + visibility.update(true, true, now + Duration::from_secs(14)); + assert!(!visibility.show_local); + visibility.update(true, true, now + Duration::from_secs(17)); + assert!(visibility.show_local); + } + + #[test] + fn disabling_setting_restores_local_and_cancels_pending_timer() { + let now = Instant::now(); + let mut visibility = Visibility::default(); + visibility.update(true, false, now); + visibility.update(true, false, now + HIDE_DELAY); + visibility.update(false, false, now + HIDE_DELAY); + assert!(visibility.show_local); + visibility.update(true, false, now + HIDE_DELAY); + visibility.update(false, false, now + HIDE_DELAY + Duration::from_secs(1)); + let restarted = now + HIDE_DELAY + Duration::from_secs(2); + visibility.update(true, false, restarted); + visibility.update( + true, + false, + restarted + HIDE_DELAY - Duration::from_millis(1), + ); + assert!(visibility.show_local); + visibility.update(true, false, restarted + HIDE_DELAY); + assert!(!visibility.show_local); + } +} diff --git a/src-tauri/src/scene_configuration/mod.rs b/src-tauri/src/scene_configuration/mod.rs index 4d0b824..f20ed2d 100644 --- a/src-tauri/src/scene_configuration/mod.rs +++ b/src-tauri/src/scene_configuration/mod.rs @@ -41,10 +41,10 @@ impl SceneConfigurationState { Ok(()) } - pub(crate) fn puppet_movement_mode(&self) -> Result { + pub(crate) fn snapshot(&self) -> Result { self.0 .read() - .map(|configuration| configuration.puppet_movement_mode) + .map(|configuration| configuration.clone()) .map_err(|error| error.to_string()) } } diff --git a/src-tauri/src/ui/scene/mod.rs b/src-tauri/src/ui/scene/mod.rs index 1d0700f..b8fdb97 100644 --- a/src-tauri/src/ui/scene/mod.rs +++ b/src-tauri/src/ui/scene/mod.rs @@ -80,7 +80,8 @@ pub fn apply_macos_decoration_window_policy(app_handle: &AppHandle, window_label pub const WINDOW_LABEL: &str = "scene"; fn track_scene_hitboxes(app_handle: AppHandle, window: tauri::WebviewWindow) { - tauri::async_runtime::spawn(async move { + let tracked_window = window.clone(); + let task = tauri::async_runtime::spawn(async move { let mut ignores_cursor = true; let mut interval = tokio::time::interval(crate::cursor::SYSTEM_CURSOR_POLL_INTERVAL); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -101,14 +102,14 @@ fn track_scene_hitboxes(app_handle: AppHandle, window: tauri::WebviewWindow) { continue; } }; - let window_position = match window.outer_position() { + let window_position = match tracked_window.outer_position() { Ok(position) => position, Err(error) => { eprintln!("Failed to read scene position for hit-testing: {error}"); continue; } }; - let scale_factor = match window.scale_factor() { + let scale_factor = match tracked_window.scale_factor() { Ok(scale_factor) => scale_factor, Err(error) => { eprintln!("Failed to read scene scale factor for hit-testing: {error}"); @@ -126,7 +127,7 @@ fn track_scene_hitboxes(app_handle: AppHandle, window: tauri::WebviewWindow) { }; if should_ignore != ignores_cursor { - match window.set_ignore_cursor_events(should_ignore) { + match tracked_window.set_ignore_cursor_events(should_ignore) { Ok(()) => ignores_cursor = should_ignore, Err(error) => { eprintln!("Failed to update scene cursor event policy: {error}") @@ -135,6 +136,11 @@ fn track_scene_hitboxes(app_handle: AppHandle, window: tauri::WebviewWindow) { } } }); + window.on_window_event(move |event| { + if matches!(event, tauri::WindowEvent::Destroyed) { + task.abort(); + } + }); } pub fn overlay_fullscreen( @@ -156,9 +162,8 @@ pub fn overlay_fullscreen( Ok(()) } -pub fn open_window(app_handle: &AppHandle) -> Result { +fn open_window(app_handle: &AppHandle) -> Result { if let Some(window) = app_handle.get_webview_window(WINDOW_LABEL) { - window.show().map_err(|e| e.to_string())?; return Ok(window); }; @@ -181,26 +186,77 @@ pub fn open_window(app_handle: &AppHandle) -> Result Result<(), tauri::Error> { + overlay_fullscreen(app_handle, &window)?; + window.set_ignore_cursor_events(true)?; + window.show()?; + Ok(()) + }; + if let Err(error) = configure() { + let _ = window.destroy(); + return Err(error.to_string()); + } + apply_macos_decoration_window_policy(app_handle, WINDOW_LABEL.to_string()); + track_scene_hitboxes(app_handle.clone(), window.clone()); Ok(window) } +/// Serializes native create/destroy requests, including destruction acknowledgement. +#[derive(Default)] +struct SceneWindow(tokio::sync::Mutex<()>); + +pub(crate) fn is_initialized(handle: &AppHandle) -> bool { + handle.try_state::().is_some() +} + +/// Creates a fully configured scene, or destroys its webview and stops hit-testing. +pub(crate) async fn reconcile_window(handle: &AppHandle, open: bool) -> Result<(), String> { + let state = handle + .try_state::() + .ok_or("scene UI has not started")?; + let _guard = state.0.lock().await; + if open { + if handle.get_webview_window(WINDOW_LABEL).is_none() { + handle + .state::() + .0 + .write() + .map_err(|error| error.to_string())? + .clear(); + open_window(&handle)?; + } + } else if let Some(window) = handle.get_webview_window(WINDOW_LABEL) { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let sender = std::sync::Mutex::new(Some(sender)); + window.on_window_event(move |event| { + if matches!(event, tauri::WindowEvent::Destroyed) + && let Some(sender) = sender.lock().unwrap().take() + { + let _ = sender.send(()); + } + }); + window.destroy().map_err(|error| error.to_string())?; + receiver.await.map_err(|error| error.to_string())?; + handle + .state::() + .0 + .write() + .map_err(|error| error.to_string())? + .clear(); + } + Ok(()) +} + pub fn init(app_handle: &AppHandle) { app_handle.manage(SceneHitboxes::default()); - let window = open_window(app_handle).expect("Scene window should be opened successfully"); - overlay_fullscreen(app_handle, &window).expect("Scene window should be fullscreened"); - apply_macos_decoration_window_policy(app_handle, WINDOW_LABEL.to_string()); - window - .set_ignore_cursor_events(true) - .expect("Scene window needs to ignore cursor events"); - track_scene_hitboxes(app_handle.clone(), window); + // The existing puppet tick opens the scene only after publishing its snapshot. + app_handle.manage(SceneWindow::default()); } #[cfg(test)] diff --git a/src/lib/listeners/puppets.ts b/src/lib/listeners/puppets.ts index e4a2fbd..feca83b 100644 --- a/src/lib/listeners/puppets.ts +++ b/src/lib/listeners/puppets.ts @@ -5,12 +5,16 @@ export const puppetStates = writable([]); export const puppetStatesListenerError = writable(""); export async function initPuppetStatesListener() { + let receivedEvent = false; const unlisten = await events.puppetStatesChanged.listen((event) => { + receivedEvent = true; puppetStates.set(event.payload.puppets); }); try { - puppetStates.set(await commands.listPuppetStates()); + const snapshot = await commands.listPuppetStates(); + // A newly created scene must not overwrite a newer event with its startup snapshot. + if (!receivedEvent) puppetStates.set(snapshot); } catch (error) { puppetStatesListenerError.set(String(error)); } diff --git a/src/routes/scene/+page.svelte b/src/routes/scene/+page.svelte index 1429024..a75cdd0 100644 --- a/src/routes/scene/+page.svelte +++ b/src/routes/scene/+page.svelte @@ -3,7 +3,6 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import Popover from "$lib/components/popover.svelte"; import { friendName, friends } from "$lib/listeners/friends"; - import { onlineFriendIds } from "$lib/listeners/friend-statuses"; import { incomingInteraction } from "$lib/listeners/interactions"; import { liveMetadata } from "$lib/listeners/live-metadata"; import { profile } from "$lib/listeners/profile"; @@ -17,28 +16,16 @@ import SceneUserPopoverContent from "./popovers/user-interaction.svelte"; import { messages } from "$lib/i18n"; - const LOCAL_PUPPET_HIDE_DELAY_MS = 5_000; - const LOCAL_PUPPET_SHOW_DELAY_MS = 3_000; - let selectedUserId = $state(null); let lockedPopoverUserId = $state(null); let viewedImage = $state<{ source: string; senderName: string } | null>(null); let puppetBounds = $state([]); - let showLocalPuppet = $state(true); let puppetBoundsById = $derived( new Map(puppetBounds.map((bounds) => [bounds.id, bounds])), ); - let visiblePuppets = $derived( - $puppetStates.filter( - ({ id }) => - $onlineFriendIds.has(id) || - (id === $liveMetadata.localId && - (!$sceneConfiguration.hideLocalPuppetWhenAlone || showLocalPuppet)), - ), - ); let skinHashes = $derived( new Map( - visiblePuppets.map(({ id }) => [ + $puppetStates.map(({ id }) => [ id, id === $profile?.id ? $profile.skinHash @@ -51,23 +38,6 @@ void getCurrentWindow().setTitle($messages.scene_window_title()); }); - $effect(() => { - if (!$sceneConfiguration.hideLocalPuppetWhenAlone) { - showLocalPuppet = true; - return; - } - - const nextShowLocalPuppet = $onlineFriendIds.size > 0; - if (showLocalPuppet === nextShowLocalPuppet) return; - const timer = window.setTimeout( - () => (showLocalPuppet = nextShowLocalPuppet), - nextShowLocalPuppet - ? LOCAL_PUPPET_SHOW_DELAY_MS - : LOCAL_PUPPET_HIDE_DELAY_MS, - ); - return () => window.clearTimeout(timer); - }); - onMount(startHitboxSync); $effect(() => { @@ -84,7 +54,7 @@ $effect(() => { if ( selectedUserId && - !visiblePuppets.some((puppet) => puppet.id === selectedUserId) + !$puppetStates.some((puppet) => puppet.id === selectedUserId) ) { dismissPopover(); } @@ -133,14 +103,14 @@
(puppetBounds = bounds)} {skinHashes} />