teardown scene window when unused

This commit is contained in:
2026-09-07 01:18:50 +08:00
parent ce0fb10073
commit ad71b05dc8
7 changed files with 251 additions and 70 deletions
+5 -1
View File
@@ -94,6 +94,10 @@ impl Network {
self.keypair.public_key()
}
pub(crate) fn online_friend_ids(&self) -> Result<Vec<String>, 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<Vec<String>, String> {
network.friend_presence.snapshot()
network.online_friend_ids()
}
#[tauri::command]
+53 -16
View File
@@ -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::<CursorState>().snapshot() {
// UI state is registered only after the splash/onboarding boundary.
if !crate::ui::scene::is_initialized(&handle) {
continue;
}
let online = match handle
.state::<crate::network::Network>()
.online_friend_ids()
{
Ok(online) => online.into_iter().collect::<HashSet<_>>(),
Err(error) => {
eprintln!("failed to read scene friend presence: {error}");
continue;
}
};
let configuration = match handle.state::<SceneConfigurationState>().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::<crate::keypair::AppKeypair>()
.public_key()
.to_owned();
let mut cursor_positions = match handle.state::<CursorState>().snapshot() {
Ok(positions) => positions,
Err(error) => {
eprintln!("failed to read cursor positions for puppet motion: {error}");
continue;
}
};
let movement_mode = match handle
.state::<SceneConfigurationState>()
.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::<PuppetStateStore>().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}");
}
}
});
+110
View File
@@ -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);
}
}
+2 -2
View File
@@ -41,10 +41,10 @@ impl SceneConfigurationState {
Ok(())
}
pub(crate) fn puppet_movement_mode(&self) -> Result<PuppetMovementMode, String> {
pub(crate) fn snapshot(&self) -> Result<SceneConfiguration, String> {
self.0
.read()
.map(|configuration| configuration.puppet_movement_mode)
.map(|configuration| configuration.clone())
.map_err(|error| error.to_string())
}
}
+72 -16
View File
@@ -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<tauri::WebviewWindow, String> {
fn open_window(app_handle: &AppHandle) -> Result<tauri::WebviewWindow, String> {
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<tauri::WebviewWindow, Strin
.skip_taskbar(true)
.shadow(false)
.accept_first_mouse(true)
.visible(true)
.visible(false)
.focused(false);
let window = builder.build().map_err(|e: tauri::Error| e.to_string())?;
#[cfg(debug_assertions)]
window.open_devtools();
let configure = || -> 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::<SceneWindow>().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::<SceneWindow>()
.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::<SceneHitboxes>()
.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::<SceneHitboxes>()
.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)]
+5 -1
View File
@@ -5,12 +5,16 @@ export const puppetStates = writable<PuppetState[]>([]);
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));
}
+4 -34
View File
@@ -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<string | null>(null);
let lockedPopoverUserId = $state<string | null>(null);
let viewedImage = $state<{ source: string; senderName: string } | null>(null);
let puppetBounds = $state<PuppetScreenBounds[]>([]);
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 @@
<div class="relative size-full">
<Renderer
puppets={visiblePuppets}
puppets={$puppetStates}
selectedPuppetId={selectedUserId}
onBoundsChange={(bounds) => (puppetBounds = bounds)}
{skinHashes}
/>
<div role="banner" class="pointer-events-none fixed inset-0 z-10">
{#each visiblePuppets as puppet, index (puppet.id)}
{#each $puppetStates as puppet, index (puppet.id)}
{@const userId = puppet.id}
{@const bounds = puppetBoundsById.get(userId)}
{@const foregroundApp = $liveMetadata.foregroundApps.get(userId)}