fix(shell): coordinate startup cover and tab stills

This commit is contained in:
CRTkafa 2026-09-30 12:25:46 +07:00
parent 01f2ec0417
commit 5a2e959763
13 changed files with 442 additions and 52 deletions

View file

@ -461,6 +461,12 @@ mod linux {
/// The event carrying a tab's last frame to the shell ([`capture_still`]).
pub const EVENT_TAB_STILL: &str = "tab-still";
#[cfg(windows)]
const EVENT_TAB_STILL_START: &str = "tab-still-start";
#[cfg(windows)]
const EVENT_TAB_STILL_CANCEL: &str = "tab-still-cancel";
#[cfg(windows)]
const EVENT_TAB_STILL_RESET: &str = "tab-still-reset";
#[cfg(windows)]
#[derive(Clone, Serialize)]
@ -468,18 +474,114 @@ struct TabStill {
id: String,
/// `data:image/jpeg;base64,…`
image: String,
token: u64,
}
#[cfg(windows)]
#[derive(Clone, Serialize)]
struct TabStillToken { id: String, token: u64 }
#[cfg(windows)]
#[derive(Clone, Copy, PartialEq, Eq)]
enum StillPhase { Pending, Hidden }
#[cfg(windows)]
#[derive(Clone, Copy)]
struct StillEntry { token: u64, phase: StillPhase }
#[cfg(windows)]
impl StillEntry {
fn accepts(self, token: u64) -> bool { self.token == token }
fn hide(&mut self, token: u64) -> bool {
if !self.accepts(token) || self.phase != StillPhase::Pending { return false; }
self.phase = StillPhase::Hidden;
true
}
}
#[cfg(all(test, windows))]
mod still_tests {
use super::{cancel_pending_still, pending_stills, StillEntry, StillPhase};
#[test]
fn timeout_keeps_late_frame_valid_but_stale_tokens_cannot_hide() {
let mut entry = StillEntry { token: 7, phase: StillPhase::Pending };
assert!(!entry.hide(6));
assert!(entry.hide(7));
assert!(entry.accepts(7)); // CapturePreview may finish after the bounded hide.
assert!(!entry.hide(7)); // A late acknowledgement must not hide a shown tab again.
entry = StillEntry { token: 8, phase: StillPhase::Pending };
assert!(!entry.accepts(7));
assert!(!entry.hide(7));
}
#[test]
fn teardown_invalidates_old_capture_before_same_label_is_reused() {
let label = "tab-still-test-recreated";
pending_stills().lock().unwrap().insert(label.into(), StillEntry { token: 31, phase: StillPhase::Hidden });
assert_eq!(cancel_pending_still(label), Some(31));
pending_stills().lock().unwrap().insert(label.into(), StillEntry { token: 32, phase: StillPhase::Pending });
let current = pending_stills().lock().unwrap().get(label).copied().unwrap();
assert!(!current.accepts(31));
assert!(current.accepts(32));
cancel_pending_still(label);
}
}
#[cfg(windows)]
fn pending_stills() -> &'static std::sync::Mutex<std::collections::HashMap<String, StillEntry>> {
use std::sync::{Mutex, OnceLock};
static PENDING: OnceLock<Mutex<std::collections::HashMap<String, StillEntry>>> = OnceLock::new();
PENDING.get_or_init(Default::default)
}
#[cfg(windows)]
fn cancel_pending_still(label: &str) -> Option<u64> {
pending_stills().lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(label).map(|entry| entry.token)
}
#[cfg(windows)]
fn hide_after_still<R: Runtime>(app: &AppHandle<R>, label: &str, token: u64) {
let app = app.clone();
let label = label.to_owned();
let dispatcher = app.clone();
// Webview::show/hide both execute on Tauri's main thread. Check the token there, directly
// before hiding, so a queued hide cannot run after a newer show canceled this capture.
let _ = dispatcher.run_on_main_thread(move || {
let current = {
let mut pending = pending_stills().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
pending.get_mut(&label).is_some_and(|entry| entry.hide(token))
};
if current {
if let Some(tab) = app.get_webview(&label) { let _ = tab.hide(); }
}
});
}
/// The shell has painted a captured frame; complete the bounded overlay handoff.
#[cfg(windows)]
pub fn acknowledge_still<R: Runtime>(app: &AppHandle<R>, id: &str, token: u64) {
if policy::valid_tab_id(id) {
hide_after_still(app, &policy::tab_label(id), token);
}
}
#[cfg(not(windows))]
pub fn acknowledge_still<R: Runtime>(_app: &AppHandle<R>, _id: &str, _token: u64) {}
/// Windows: a tab webview about to be hidden hands the shell its last frame (WebView2
/// `CapturePreview`, JPEG) as [`EVENT_TAB_STILL`], which its pane shows while no webview is on
/// screen there. Only a webview that is on screen now; requested before the hide, on the same
/// thread, so the frame is still there. Kept in the shell's memory only. No-op elsewhere.
fn capture_still<R: Runtime>(webview: &Webview<R>) {
fn capture_still<R: Runtime>(webview: &Webview<R>, token: u64) {
#[cfg(windows)]
{
let Some(id) = policy::tab_id(webview.label()).map(str::to_owned) else { return };
let label = webview.label().to_owned();
let app = webview.app_handle().clone();
let _ = webview.with_webview(move |platform| {
let fallback = app.clone();
let fallback_label = label.clone();
let result = webview.with_webview(move |platform| {
use base64::Engine;
use webview2_com::CapturePreviewCompletedHandler;
use webview2_com::Microsoft::Web::WebView2::Win32::COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_JPEG;
@ -491,41 +593,82 @@ fn capture_still<R: Runtime>(webview: &Webview<R>) {
let controller = platform.controller();
let mut shown = windows::core::BOOL::default();
if controller.IsVisible(&mut shown).is_err() || !shown.as_bool() {
hide_after_still(&app, &label, token);
return;
}
let (Ok(core), Some(stream)) = (controller.CoreWebView2(), SHCreateMemStream(None)) else { return };
let (Ok(core), Some(stream)) = (controller.CoreWebView2(), SHCreateMemStream(None)) else {
hide_after_still(&app, &label, token);
return;
};
let target = stream.clone();
let callback_app = app.clone();
let callback_label = label.clone();
let handler = CapturePreviewCompletedHandler::create(Box::new(move |result| {
result?;
let mut size = 0u64;
target.Seek(0, STREAM_SEEK_END, Some(&mut size))?;
if size == 0 || size > 16 << 20 {
return Ok(());
let image = (|| {
result.ok()?;
let mut size = 0u64;
target.Seek(0, STREAM_SEEK_END, Some(&mut size)).ok()?;
if size == 0 || size > 16 << 20 { return None; }
target.Seek(0, STREAM_SEEK_SET, None).ok()?;
let mut bytes = vec![0u8; size as usize];
let mut read = 0u32;
target.Read(bytes.as_mut_ptr().cast(), size as u32, Some(&mut read)).ok().ok()?;
bytes.truncate(read as usize);
Some(format!("data:image/jpeg;base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes)))
})();
if let Some(image) = image {
let current = pending_stills().lock().unwrap_or_else(|poisoned| poisoned.into_inner())
.get(&callback_label).is_some_and(|entry| entry.accepts(token));
if current {
emit_shell(&callback_app, EVENT_TAB_STILL, TabStill { id, image, token });
}
} else {
hide_after_still(&callback_app, &callback_label, token);
}
target.Seek(0, STREAM_SEEK_SET, None)?;
let mut bytes = vec![0u8; size as usize];
let mut read = 0u32;
target.Read(bytes.as_mut_ptr().cast(), size as u32, Some(&mut read)).ok()?;
bytes.truncate(read as usize);
let image = format!("data:image/jpeg;base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes));
emit_shell(&app, EVENT_TAB_STILL, TabStill { id, image });
Ok(())
}));
let _ = core.CapturePreview(COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_JPEG, &stream, &handler);
if core.CapturePreview(COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_JPEG, &stream, &handler).is_err() {
hide_after_still(&app, &label, token);
}
}
});
if result.is_err() {
hide_after_still(&fallback, &fallback_label, token);
}
}
#[cfg(not(windows))]
let _ = webview;
let _ = (webview, token);
}
/// Shows or hides a tab webview and sets its WebView2 memory target to match
/// ([`tabs::memory_target`]): hidden tabs run at `COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_LOW`
/// (ICoreWebView2_19, WebView2 Runtime 114+; older runtimes just skip it). Other platforms only
/// show/hide.
pub fn set_tab_visible<R: Runtime>(webview: &Webview<R>, visible: bool, parked: bool) {
if !visible {
capture_still(webview);
pub fn set_tab_visible<R: Runtime>(webview: &Webview<R>, visible: bool, parked: bool, was_visible: bool) {
#[cfg(windows)]
{
let app = webview.app_handle();
let id = policy::tab_id(webview.label()).unwrap_or_default().to_owned();
if let Some(token) = cancel_pending_still(webview.label()) {
emit_shell(app, EVENT_TAB_STILL_CANCEL, TabStillToken { id: id.clone(), token });
}
if !visible && !parked && was_visible && webview.window().is_visible().unwrap_or(false) {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(1);
let token = NEXT.fetch_add(1, Ordering::Relaxed);
let label = webview.label().to_owned();
pending_stills().lock().unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(label.clone(), StillEntry { token, phase: StillPhase::Pending });
emit_shell(app, EVENT_TAB_STILL_START, TabStillToken { id, token });
let app = app.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(160)).await;
hide_after_still(&app, &label, token);
});
capture_still(webview, token);
set_memory_target(webview, visible, parked);
return;
}
}
let _ = if visible { webview.show() } else { webview.hide() };
set_memory_target(webview, visible, parked);
@ -577,20 +720,33 @@ fn placements() -> std::sync::MutexGuard<'static, crate::layout::Placements> {
/// native calls for whatever did not change since its last placement: a sync that moves one pane
/// touches one webview, and one that changes nothing costs nothing.
pub fn place_tab<R: Runtime>(webview: &Webview<R>, rect: crate::layout::Rect, visible: bool, parked: bool) {
let changed = placements().place(webview.label(), rect, visible);
let (changed, was_visible) = {
let mut placed = placements();
let was_visible = placed.was_visible(webview.label());
(placed.place(webview.label(), rect, visible), was_visible)
};
if changed.bounds {
let (x, y, width, height) = rect;
place_child(webview, x, y, width, height);
}
if changed.visible {
set_tab_visible(webview, visible, parked);
set_tab_visible(webview, visible, parked, was_visible);
} else {
set_memory_target(webview, visible, parked);
}
}
/// A tab webview was closed: a new one with the same label is placed from scratch.
pub fn forget_tab(label: &str) {
pub fn forget_tab<R: Runtime>(app: &AppHandle<R>, label: &str) {
#[cfg(windows)]
{
cancel_pending_still(label);
if let Some(id) = policy::tab_id(label) {
emit_shell(app, EVENT_TAB_STILL_RESET, id.to_owned());
}
}
#[cfg(not(windows))]
let _ = app;
placements().forget(label);
}