From 469959bcad28279b66800158ec250d9a8e97b679 Mon Sep 17 00:00:00 2001 From: Max Boksem <15022344+MaxMB15@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:04:54 +0200 Subject: [PATCH 01/23] refactor(linux): rewrite Wayland renderer with RAII, drop driver workarounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous renderer had accumulated ~400 lines of GPU blocklists, VMware/SVGA3D sniffing, diagnostic logging, and raw-pointer lifetime tricks while still crashing in bundled builds. Replace with a clean Wayland-only renderer where every EGL resource is owned by a Drop impl, the mpv update callback uses Weak to stay safe across detach, and every remaining unsafe block has a SAFETY comment. - linux.rs: 1995 → 843 lines. Arc + Weak callback wiring; OwnedEgl / WaylandSession RAII; no GPU detection; no diagnostic logging. X11 falls through to the separate-window fallback in MpvState. - lib.rs: 327 → 148 lines. Remove SIGSEGV/SIGABRT handler and display-env logging; keep only the scoped AppImage DMABUF workaround. - mpv.rs / renderer.rs: drop software_fallback_options (no blocklist path); add set_first_frame_callback to the PlatformRenderer trait with a default no-op so non-Linux platforms aren't forced to implement it. - bundle-libmpv-linux.sh: expand SYSTEM_LIBS_RE to cover the full GTK/GL/ VA-API/VDPAU/LLVM stack (libffi, libharfbuzz, libfribidi, libgraphite, libudev, libva, libvdpau, libnvidia, libcuda, libLLVM, libclang, libOpenGL) so bundled ABI cannot collide with system Mesa. Emit a post-bundle inventory for CI audit. Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src-tauri/src/lib.rs | 67 +- crates/tauri-plugin-mpv/src/linux.rs | 2050 +++++++---------------- crates/tauri-plugin-mpv/src/mpv.rs | 14 +- crates/tauri-plugin-mpv/src/renderer.rs | 6 + scripts/bundle-libmpv-linux.sh | 29 +- 5 files changed, 687 insertions(+), 1479 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 36056eb..85cce93 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -5,70 +5,19 @@ use mvp_core::cache::store::CacheStore; use std::sync::Mutex; use tauri::Manager; -/// Install a SIGSEGV/SIGABRT handler that logs context before crashing. -/// This helps diagnose GL driver crashes that produce no Rust-level output. -#[cfg(target_os = "linux")] -fn install_crash_handler() { - use std::sync::Once; - static ONCE: Once = Once::new(); - ONCE.call_once(|| unsafe { - unsafe extern "C" fn crash_handler(sig: libc::c_int) { - // Write directly to stderr — no allocations, no locks (async-signal-safe). - let msg = match sig { - libc::SIGSEGV => b"[CRASH] SIGSEGV - segmentation fault in MaxVideoPlayer.\n\ - This typically indicates a GPU driver crash in the EGL/OpenGL rendering pipeline.\n\ - Try running with: GDK_BACKEND=x11 max-video-player\n\ - Or set MVP_DISABLE_EMBEDDED_RENDERER=1 to use fallback rendering.\n" as &[u8], - libc::SIGABRT => b"[CRASH] SIGABRT - abort signal in MaxVideoPlayer.\n" as &[u8], - _ => b"[CRASH] Fatal signal in MaxVideoPlayer.\n" as &[u8], - }; - libc::write(2, msg.as_ptr() as *const _, msg.len()); - - // SA_RESETHAND already restored default disposition; re-raise to get core dump. - libc::kill(libc::getpid(), sig); - libc::_exit(128 + sig); - } - - let mut action: libc::sigaction = std::mem::zeroed(); - action.sa_flags = libc::SA_RESETHAND; - action.sa_sigaction = crash_handler as *const () as usize; - libc::sigemptyset(&mut action.sa_mask); - - libc::sigaction(libc::SIGSEGV, &action, std::ptr::null_mut()); - libc::sigaction(libc::SIGABRT, &action, std::ptr::null_mut()); - }); -} - -/// Work around WebKit2GTK DMABUF renderer issue that causes a black/blank -/// window in AppImage builds on some Linux configurations. -/// Only applied inside AppImage (detected via the APPIMAGE env var set by -/// the AppImage runtime) — the workaround breaks embedded video on systems -/// where the DMABUF renderer works correctly. +/// Work around WebKit2GTK's DMABUF renderer causing a blank window inside the +/// AppImage runtime on some Linux configurations. Only applied when running +/// from an AppImage (detected via `APPIMAGE`, a variable the AppImage runtime +/// sets itself — we only read it). The workaround is harmful outside the +/// AppImage, which is why it is scoped to that runtime. #[cfg(target_os = "linux")] fn apply_linux_workarounds() { let is_appimage = std::env::var("APPIMAGE").is_ok(); if is_appimage && std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() { - tracing::info!("[Linux] AppImage detected — setting WEBKIT_DISABLE_DMABUF_RENDERER=1"); std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); } } -/// Log system display environment info for diagnostics. -#[cfg(target_os = "linux")] -fn log_display_environment() { - let session_type = std::env::var("XDG_SESSION_TYPE").unwrap_or_else(|_| "unknown".into()); - let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "unset".into()); - let x11_display = std::env::var("DISPLAY").unwrap_or_else(|_| "unset".into()); - let gdk_backend = std::env::var("GDK_BACKEND").unwrap_or_else(|_| "auto".into()); - let disable_embedded = std::env::var("MVP_DISABLE_EMBEDDED_RENDERER").unwrap_or_else(|_| "0".into()); - let webkit_dmabuf = std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").unwrap_or_else(|_| "unset".into()); - - tracing::info!( - "[diagnostics] session={} wayland={} x11={} gdk_backend={} disable_embedded={} webkit_dmabuf={}", - session_type, wayland_display, x11_display, gdk_backend, disable_embedded, webkit_dmabuf - ); -} - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tracing_subscriber::fmt() @@ -81,11 +30,7 @@ pub fn run() { .init(); #[cfg(target_os = "linux")] - { - apply_linux_workarounds(); - install_crash_handler(); - log_display_environment(); - } + apply_linux_workarounds(); tauri::Builder::default() .plugin(tauri_plugin_os::init()) diff --git a/crates/tauri-plugin-mpv/src/linux.rs b/crates/tauri-plugin-mpv/src/linux.rs index 5c76e55..f3f9915 100644 --- a/crates/tauri-plugin-mpv/src/linux.rs +++ b/crates/tauri-plugin-mpv/src/linux.rs @@ -1,136 +1,93 @@ -//! Linux MPV embedding — EGL + OpenGL Core 3.2 render context. +//! Linux MPV embedding — Wayland subsurface + EGL + OpenGL render context. //! -//! Architecture mirrors macos.rs: -//! - X11: creates a child window via XCreateWindow within the Tauri parent window -//! (like NSOpenGLView addSubview:positioned:relativeTo:). -//! - Wayland: creates a wl_subsurface + wl_egl_window within the parent wl_surface -//! (the same conceptual "child window" for Wayland sessions). -//! - EGL context for OpenGL rendering (like NSOpenGLContext on macOS). -//! - mpv_render_context with MPV_RENDER_API_TYPE_OPENGL (same API as macOS). -//! - Render callbacks dispatched to the GLib main thread (like dispatch::Queue::main()). +//! Architecture: +//! - Create a `wl_subsurface` + `wl_egl_window` underneath the Tauri `wl_surface`. +//! - Build an EGL (OpenGL Core 3.2) context on that window. +//! - Give libmpv an `MPV_RENDER_API_TYPE_OPENGL` render context and, crucially, +//! the native `wl_display` pointer via `MPV_RENDER_PARAM_WL_DISPLAY` so +//! libmpv can interoperate with the compositor (required for dmabuf/zwp +//! colorspace and for correct presentation timing on Wayland). +//! - Every EGL / GL / `wl_egl_window_resize` call is dispatched to the GLib +//! main thread: the same thread that owns the EGL context for the entire +//! lifetime of the renderer. This is the Wayland-safe equivalent of the +//! NSOpenGLView + main-thread pattern used on macOS. +//! +//! X11 is intentionally unsupported on this platform: X11 sessions fall through +//! to the separate-window fallback in `MpvState`. use crate::renderer::PlatformRenderer; use khronos_egl as egl; use libmpv2::{ - render::{ - mpv_render_update, OpenGLInitParams, RenderContext, RenderParam, RenderParamApiType, - }, + render::{mpv_render_update, OpenGLInitParams, RenderContext, RenderParam, RenderParamApiType}, Mpv, }; use raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle}; use std::ffi::c_void; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, Mutex, OnceLock, -}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; use tauri::{AppHandle, Manager, Runtime}; -// --------------------------------------------------------------------------- -// Wayland protocol imports -// --------------------------------------------------------------------------- - use wayland_client::{ backend::{Backend, ObjectId}, globals::{registry_queue_init, GlobalListContents}, protocol::{ - wl_compositor::WlCompositor, - wl_registry::WlRegistry, - wl_subcompositor::WlSubcompositor, - wl_subsurface::WlSubsurface, - wl_surface::WlSurface, + wl_compositor::WlCompositor, wl_registry::WlRegistry, + wl_subcompositor::WlSubcompositor, wl_subsurface::WlSubsurface, wl_surface::WlSurface, }, Connection, Dispatch, EventQueue, Proxy, QueueHandle, }; use wayland_egl::WlEglSurface; -// --------------------------------------------------------------------------- -// EGL instance (cached, loaded once per process) -// --------------------------------------------------------------------------- +// ========================================================================= +// libEGL loader +// ========================================================================= -/// Load `libEGL.so` and cache the outcome (success or error string) for the process. -/// -/// [`LinuxGlRenderer::new`] calls this first and returns `Err` on failure so the app can -/// fall back (e.g. separate mpv window) without panicking. `egl_instance()` reads the same -/// cache; it panics only if EGL never loaded successfully—an invariant normally established -/// by a successful `new`. -fn try_load_egl() -> Result<&'static egl::DynamicInstance, String> { +/// Dynamically loaded `libEGL.so.1`. Cached for the lifetime of the process +/// so we pay the `dlopen` cost once and every subsequent lookup is a pointer +/// read. Returns `Err` (never panics) so `LinuxGlRenderer::new` can fall back. +fn egl() -> Result<&'static egl::DynamicInstance, String> { static INSTANCE: OnceLock, String>> = OnceLock::new(); - let result = INSTANCE.get_or_init(|| unsafe { - egl::DynamicInstance::::load_required() - .map_err(|e| format!("Failed to load EGL (libEGL.so): {}", e)) - }); - result.as_ref().map_err(|e| e.clone()) + INSTANCE + .get_or_init(|| { + // SAFETY: `load_required` wraps `dlopen("libEGL.so.1")`; dlopen is + // async-signal-safe and thread-safe, and the returned instance is + // valid for the lifetime of the process. + unsafe { egl::DynamicInstance::::load_required() } + .map_err(|e| format!("Failed to load libEGL: {e}")) + }) + .as_ref() + .map_err(|e| e.clone()) } -fn egl_instance() -> &'static egl::DynamicInstance { - match try_load_egl() { - Ok(instance) => instance, - Err(err) => panic!( - "egl_instance() called when EGL is unavailable (expected after successful LinuxGlRenderer::new): {}", - err - ), +/// Resolve a GL function pointer via the EGL loader. +/// +/// Returned as `*mut c_void` for both `gl::load_with` (the `gl` crate) and +/// libmpv's `get_proc_address` callback. `gl::load_with` is called exactly +/// once per attach, on the GLib main thread, while the EGL context is current. +fn gl_proc_address(name: &str) -> *mut c_void { + match egl() { + Ok(egl) => egl + .get_proc_address(name) + .map_or(std::ptr::null_mut(), |f| f as *mut c_void), + Err(_) => std::ptr::null_mut(), } } -// --------------------------------------------------------------------------- -// GL proc address resolver for libmpv -// --------------------------------------------------------------------------- - -fn gl_get_proc_address(name: &str) -> *mut c_void { - let egl = egl_instance(); - egl.get_proc_address(name) - .map_or(std::ptr::null_mut(), |f| f as *mut c_void) -} - -// --------------------------------------------------------------------------- -// Render callback inner state (heap-stable, accessed from glib main thread) -// --------------------------------------------------------------------------- - -/// Pending resize request queued by `set_frame()` and applied by `render_frame()` -/// on the GLib main thread, ensuring wl_egl_window_resize never races with EGL calls. -struct PendingResize { - w: i32, - h: i32, -} - -struct RenderInner { - ctx: RenderContext, - egl_display: egl::Display, - egl_surface: egl::Surface, - egl_context: egl::Context, - /// Called once when the first video frame is rendered, then cleared. - first_frame_cb: Option>, - /// Shared with LinuxGlRenderer::set_visible; skips GPU calls when false. - video_active: Arc, - /// Pending wl_egl_window resize — set by set_frame (command thread), - /// consumed by render_frame (GLib main thread). Protected by Mutex so - /// wl_egl_window_resize never races with eglSwapBuffers. - pending_resize: Arc>>, - /// Pointer to the WlEglSurface so render_frame can call resize() on the - /// GLib main thread. Only valid while LinuxGlRenderer is alive. - wl_egl_surface_ptr: usize, -} - -unsafe impl Send for RenderInner {} - -// --------------------------------------------------------------------------- -// Wayland-specific state -// --------------------------------------------------------------------------- +// ========================================================================= +// Wayland globals dispatcher (registry-only, silent otherwise) +// ========================================================================= -/// Minimal dispatcher for getting Wayland globals. -/// All events are silently ignored — we only need the request/bind half. struct WlGlobals; impl Dispatch for WlGlobals { fn event( - _state: &mut Self, - _registry: &WlRegistry, - _event: wayland_client::protocol::wl_registry::Event, - _data: &GlobalListContents, - _conn: &Connection, - _qh: &QueueHandle, + _: &mut Self, + _: &WlRegistry, + _: wayland_client::protocol::wl_registry::Event, + _: &GlobalListContents, + _: &Connection, + _: &QueueHandle, ) { - // Handled internally by GlobalListContents } } @@ -139,195 +96,146 @@ wayland_client::delegate_noop!(WlGlobals: ignore WlSubcompositor); wayland_client::delegate_noop!(WlGlobals: ignore WlSurface); wayland_client::delegate_noop!(WlGlobals: ignore WlSubsurface); -/// Holds all Wayland-specific resources for the renderer. Stored as an -/// `Option` in `LinuxGlRenderer`; `None` on X11 sessions. +// ========================================================================= +// RAII-owned platform resources +// ========================================================================= + +/// Owns the EGL context + surface. The EGL *display* is not terminated on +/// drop because on Wayland it is shared with GTK/WebKit — terminating it +/// would tear down the compositor connection for the whole window. +struct OwnedEgl { + display: egl::Display, + surface: egl::Surface, + context: egl::Context, +} + +impl Drop for OwnedEgl { + fn drop(&mut self) { + // SAFETY: Drop runs on the GLib main thread (`Inner` is dropped from + // there in `detach`/drop). `make_current(None)` is a no-op if the + // context is not current; `destroy_surface` / `destroy_context` are + // valid to call with no pending rendering on this thread. + if let Ok(egl) = egl() { + let _ = egl.make_current(self.display, None, None, None); + let _ = egl.destroy_surface(self.display, self.surface); + let _ = egl.destroy_context(self.display, self.context); + } + } +} + +// SAFETY: The three handles are opaque pointers into libEGL's process-wide +// state. Our architecture restricts every EGL call that reads those handles +// (make_current, swap_buffers, destroy_*) to the GLib main thread. The Sync +// impl exists only so `Arc` is Sync; OwnedEgl is never mutated via +// `&self` from multiple threads. +unsafe impl Send for OwnedEgl {} +unsafe impl Sync for OwnedEgl {} + +/// Owns the Wayland subsurface tree that backs the EGL window. /// -/// Field declaration order determines drop order. The EGL window and subsurface -/// must be destroyed before the surface they reference, and all protocol objects -/// before the connection/queue that owns them. -struct WaylandState { - /// wl_egl_window wrapper — dropped first so libEGL releases the surface buffer. +/// Field order is the drop order: we release the EGL window buffer (which +/// decrements libEGL's reference on the child surface) before sending +/// `destroy` for the subsurface, and drop the Connection last so the fd +/// stays open while every protocol object on it is torn down. +struct WaylandSession { wl_egl_surface: WlEglSurface, - /// Subsurface controller — dropped before the child surface it references. subsurface: WlSubsurface, - /// The child wl_surface that mpv renders into. child_surface: WlSurface, - /// Event queue — must be periodically dispatched to drain compositor events - /// (buffer release, surface enter/leave) and avoid protocol errors. - queue: EventQueue, - /// Keeps the Wayland connection alive (owns the fd reference). Dropped last. + queue: Mutex>, conn: Connection, - /// Last known frame rect so we can restore position after un-hide. + /// Native `wl_display *` — borrowed from the Tauri window handle and owned + /// by GTK. We keep it here as the value to pass as + /// `MPV_RENDER_PARAM_WL_DISPLAY` and never free it ourselves. + wl_display_ptr: *mut c_void, +} + +impl Drop for WaylandSession { + fn drop(&mut self) { + self.subsurface.destroy(); + self.child_surface.commit(); + let _ = self.conn.flush(); + // Remaining fields drop in declaration order (wl_egl_surface already + // dropped first because it is declared first — see field order above). + } +} + +// SAFETY: `WlEglSurface` wraps a `*mut wl_egl_window`. The resize operation is +// not thread-safe relative to EGL rendering, so we restrict it to the GLib +// main thread. Subsurface / surface protocol requests go through +// wayland-client's internally-locked Backend and are safe from any thread. +unsafe impl Send for WaylandSession {} +unsafe impl Sync for WaylandSession {} + +// ========================================================================= +// Mutable state shared across threads +// ========================================================================= + +#[derive(Default)] +struct SessionState { + /// Pending `wl_egl_window_resize` queued by `set_frame`, applied by the + /// GLib render thread while the EGL context is current. + pending_resize: Option<(i32, i32)>, + /// Last content rect (x, y, w, h) — used to restore position after unhide. last_frame: (i32, i32, i32, i32), + /// CSD offset (shadow margin + header bar) added to subsurface position. + csd_offset: (i32, i32), +} + +// ========================================================================= +// Inner (shared between renderer and libmpv update callback) +// ========================================================================= + +struct Inner { + state: Mutex, + first_frame_cb: Mutex>>, + /// Lockless hot-path flag toggled by `set_visible`. + video_active: AtomicBool, + /// Set to `false` by `detach` so any in-flight idle callback exits + /// before touching resources that are about to be freed. + valid: AtomicBool, + // Platform resources — dropped last (after the Mutex/Atomic fields) in + // field order: render_ctx → egl → wayland. + render_ctx: RenderContext, + egl: OwnedEgl, + wayland: WaylandSession, } -// WlEglSurface wraps a *mut wl_egl_window which is not Send by default. -// Safety: we only access it from the glib main thread (set_frame / set_visible / detach). -unsafe impl Send for WaylandState {} -unsafe impl Sync for WaylandState {} +// SAFETY: `RenderContext`, `OwnedEgl`, and `WaylandSession` are only +// dereferenced from the GLib main thread (via `render_frame`). The libmpv +// update callback runs on an arbitrary thread but only schedules an idle +// task; it does not touch any of these fields directly. Mutex/Atomic fields +// are inherently thread-safe. +unsafe impl Send for Inner {} +unsafe impl Sync for Inner {} -// --------------------------------------------------------------------------- +// ========================================================================= // LinuxGlRenderer -// --------------------------------------------------------------------------- +// ========================================================================= -/// Embeds libmpv video inside the Tauri window using EGL/OpenGL. -/// -/// On X11 sessions: uses an X11 child window (XCreateWindow) as the EGL surface. -/// On Wayland sessions: uses a wl_subsurface + wl_egl_window as the EGL surface. -/// -/// Raw pointer fields and EGL/X11 calls are only touched via glib main-thread -/// dispatch, which provides the same safety guarantee as macOS's main-thread queue. pub struct LinuxGlRenderer { - egl_display: egl::Display, - egl_surface: egl::Surface, - egl_context: egl::Context, - egl_config: egl::Config, - - // X11-specific — None on Wayland sessions - x11_display: Option<*mut c_void>, - x11_child_window: u64, - x11_parent_window: u64, - x11_colormap: u64, - xlib: Option, - /// Whether we opened the X11 display ourselves (XCB path) and must close it. - owns_display: bool, - - // Wayland-specific — None on X11 sessions - wayland: Option, - - /// Guards against double-cleanup of EGL resources in detach(). - egl_cleaned_up: bool, - - valid: Arc, - /// Heap-stable render state. Box address is captured in the update callback. - render_inner: Option>, - /// Called once on first rendered frame; moved into RenderInner during attach(). + /// Pre-attach resources owned by the renderer until `attach` consumes them. + pending: Option, + /// Active render state. `Some` between `attach` and `detach`. + active: Option>, + /// Queued up by the caller before `attach`; moved into `Inner` there. first_frame_cb: Option>, - /// Controls whether GPU rendering happens. Toggled by set_visible(). - video_active: Arc, - /// Shared with RenderInner so set_frame can queue resizes for the GLib thread. - pending_resize: Arc>>, - /// CSD offsets in pixels: (x, y) from the parent wl_surface origin to the - /// WebView content area origin. Includes shadow margin + header bar height. - /// Added to subsurface coordinates in set_frame() because frontend coords - /// (getBoundingClientRect) are relative to the WebView viewport, but - /// set_position() is relative to the parent wl_surface. - csd_offset: (i32, i32), } -unsafe impl Send for LinuxGlRenderer {} -unsafe impl Sync for LinuxGlRenderer {} +struct Pending { + egl: OwnedEgl, + wayland: WaylandSession, + csd_offset: (i32, i32), +} impl LinuxGlRenderer { - /// Query the CSD (Client-Side Decoration) offset from the top-left of the - /// parent wl_surface to the top-left of the WebView content area. - /// - /// On Wayland with CSD, the wl_surface includes: - /// 1. Shadow/decoration margin (typically 10px Adwaita theme) - /// 2. Header bar (typically ~47px) - /// 3. Any container padding (0 for Tauri) - /// - /// We query the decoration margin from the GTK style context and the - /// WebView offset via `translate_coordinates()`. Must dispatch to the - /// GLib main thread because GTK widget APIs require it. - /// - /// Returns (x_offset, y_offset) in pixels; (0, 0) on X11 or failure. - fn query_csd_offsets(app: &AppHandle) -> (i32, i32) { - use gtk::prelude::*; - - // Verify the window exists before dispatching to the GLib main thread. - if app.get_webview_window("main").is_none() { - return (0, 0); - } - - let (tx, rx) = std::sync::mpsc::channel(); - let app_handle = app.clone(); - - glib::idle_add_once(move || { - let window = match app_handle.get_webview_window("main") { - Some(w) => w, - None => { let _ = tx.send((0, 0)); return; } - }; - - let result = (|| -> Option<(i32, i32)> { - let gtk_win = window.gtk_window().ok()?; - - // Use the default_vbox (Tauri's content container that holds the - // WebView) and translate_coordinates to get its exact position - // within the GtkWindow. This accounts for: - // - CSD shadow margins (decoration CSS margin, ~10px Adwaita) - // - Header bar height (~47px) - // - Any container padding (0 for Tauri) - let vbox = window.default_vbox().ok()?; - let (vbox_x, vbox_y) = match vbox.translate_coordinates(>k_win, 0, 0) { - Some(coords) => coords, - None => { - // Fallback: header bar height only (no shadow info). - let header_h = gtk_win - .titlebar() - .map(|tb| tb.allocated_height()) - .unwrap_or(0); - return Some((0, header_h)); - } - }; - - // translate_coordinates gives the vbox position within the - // GtkWindow widget's allocation. On GTK3/CSD, the GdkWindow - // backing the GtkWindow includes the shadow area, but the - // GtkWindow widget is allocated inside (after) the shadow. - // So vbox_y includes the header bar offset but NOT the shadow. - // - // Get the shadow margin from GdkWindow size vs GtkWidget allocation. - let gdk_win = gtk_win.window()?; - let (gdk_w, gdk_h) = (gdk_win.width(), gdk_win.height()); - let alloc = gtk_win.allocation(); - // Shadow = (GdkWindow size - GtkWidget allocation) / 2 on each side - let shadow_x = (gdk_w - alloc.width()).max(0) / 2; - let shadow_y = (gdk_h - alloc.height()).max(0) / 2; - - tracing::info!( - "[Linux renderer] CSD breakdown: vbox_translate=({},{}) gdk={}x{} alloc={}x{} shadow=({},{}) total=({},{})", - vbox_x, vbox_y, gdk_w, gdk_h, alloc.width(), alloc.height(), - shadow_x, shadow_y, - shadow_x + vbox_x, shadow_y + vbox_y - ); - Some((shadow_x + vbox_x, shadow_y + vbox_y)) - })(); - - let _ = tx.send(result.unwrap_or((0, 0))); - }); - - match rx.recv_timeout(std::time::Duration::from_secs(2)) { - Ok(offsets) => { - tracing::info!( - "[Linux renderer] CSD offsets: x={}px y={}px (shadow + header bar)", - offsets.0, offsets.1 - ); - offsets - } - Err(_) => { - tracing::warn!("[Linux renderer] timed out querying CSD offsets, defaulting to (0,0)"); - (0, 0) - } - } - } - - /// Create an EGL-backed renderer within the Tauri window. - /// Dispatches to the X11 or Wayland path based on the raw window handle. + /// Build the EGL context and Wayland subsurface tree. Runs on the GLib + /// main thread (via `MainContext::invoke`) because every EGL call in the + /// construction path must use the same thread as rendering. pub fn new(app: &AppHandle) -> Result { - // Allow users to force fallback rendering via environment variable. - if std::env::var("MVP_DISABLE_EMBEDDED_RENDERER").unwrap_or_default() == "1" { - return Err("Embedded renderer disabled via MVP_DISABLE_EMBEDDED_RENDERER=1".to_string()); - } + // Fail fast if libEGL is not installed on the host. + egl()?; - // Verify EGL is available before doing any work. This returns Err - // instead of panicking, so the caller can fall back gracefully. - try_load_egl().map_err(|e| { - tracing::error!("[Linux renderer] {}", e); - e - })?; - - let csd_offset = Self::query_csd_offsets(app); + let csd_offset = query_csd_offsets(app); let window = app .get_webview_window("main") @@ -335,1240 +243,587 @@ impl LinuxGlRenderer { let raw_window = window .window_handle() - .map_err(|e| format!("window handle: {:?}", e))? + .map_err(|e| format!("window handle: {e:?}"))? .as_raw(); - let raw_display = window .display_handle() - .map_err(|e| format!("display handle: {:?}", e))? + .map_err(|e| format!("display handle: {e:?}"))? .as_raw(); - let mut renderer = match raw_window { - RawWindowHandle::Xlib(h) => { - let parent_window = h.window; - let x11_display_ptr = match raw_display { - RawDisplayHandle::Xlib(dh) => { - dh.display.map(|d| d.as_ptr()).unwrap_or(std::ptr::null_mut()) - } - _ => std::ptr::null_mut(), - }; - if x11_display_ptr.is_null() { - return Err("X11 display pointer is null".to_string()); - } - Self::build_x11(parent_window, x11_display_ptr) - } - RawWindowHandle::Xcb(h) => { - // XCB handle — open our own Xlib connection for XCreateWindow etc. - let parent_window = h.window.get() as u64; - let xlib = x11_dl::xlib::Xlib::open() - .map_err(|e| format!("Failed to open Xlib: {}", e))?; - let display = unsafe { (xlib.XOpenDisplay)(std::ptr::null()) }; - if display.is_null() { - return Err("XOpenDisplay returned null".to_string()); - } - Self::build_x11_with_xlib(parent_window, display as *mut c_void, xlib, true) - } - RawWindowHandle::Wayland(wh) => { - let wl_surface_ptr = wh.surface.as_ptr(); - let wl_display_ptr = match raw_display { - RawDisplayHandle::Wayland(dh) => dh.display.as_ptr(), - _ => { - return Err( - "Got Wayland window handle but non-Wayland display handle".to_string(), - ) - } - }; - Self::build_wayland(wl_surface_ptr, wl_display_ptr) - } - _ => Err(format!( - "Unsupported window handle type on Linux: {:?}", - raw_window - )), - }?; - renderer.csd_offset = csd_offset; - - // Check for blocklisted GPU drivers AFTER construction so that if we - // return Err, the renderer is dropped and its Drop impl cleans up - // EGL/X11/Wayland resources properly (no leaks). - Self::check_gpu_blocklist(&renderer)?; - - Ok(renderer) - } - - /// Check the GL renderer string against known-bad drivers that crash during - /// mpv's OpenGL render pipeline (texture upload, shader dispatch). Simple GL - /// operations (glClear, glSwapBuffers) pass on these drivers, but mpv rendering - /// segfaults. Returns Err to trigger fallback to a separate mpv window. - fn check_gpu_blocklist(renderer: &Self) -> Result<(), String> { - if std::env::var("MVP_FORCE_EMBEDDED_RENDERER").unwrap_or_default() == "1" { - tracing::info!("[Linux renderer] MVP_FORCE_EMBEDDED_RENDERER=1, skipping blocklist"); - return Ok(()); - } - - let egl = egl_instance(); - if egl.make_current( - renderer.egl_display, - Some(renderer.egl_surface), - Some(renderer.egl_surface), - Some(renderer.egl_context), - ).is_err() { - return Err("Cannot make EGL context current for blocklist check".into()); - } - - gl::load_with(|name| gl_get_proc_address(name) as *const _); - let renderer_str = unsafe { - let s = gl::GetString(gl::RENDERER); - if s.is_null() { String::new() } - else { std::ffi::CStr::from_ptr(s as *const _).to_string_lossy().into_owned() } - }; - let _ = egl.make_current(renderer.egl_display, None, None, None); - - let renderer_lower = renderer_str.to_lowercase(); - let blocklist: &[(&str, &str)] = &[ - ("llvmpipe", "Software rasterizer (llvmpipe) -- too slow and unstable for embedded video"), - ("swrast", "Software rasterizer (swrast) -- no GPU acceleration available"), - ("softpipe", "Software rasterizer (softpipe) -- no GPU acceleration available"), - ]; - for (pattern, reason) in blocklist { - if renderer_lower.contains(pattern) { - let msg = format!( - "GL renderer blocklisted for embedded rendering: {} ({}). \ - Falling back to separate mpv window. \ - Set MVP_FORCE_EMBEDDED_RENDERER=1 to override.", - renderer_str, reason - ); - tracing::warn!("[Linux renderer] {}", msg); - return Err(msg); - } - } - Ok(()) - } - - // ----------------------------------------------------------------------- - // X11 construction path (unchanged from original) - // ----------------------------------------------------------------------- - - fn build_x11(parent_window: u64, x11_display_ptr: *mut c_void) -> Result { - let xlib = x11_dl::xlib::Xlib::open() - .map_err(|e| format!("Failed to open Xlib: {}", e))?; - Self::build_x11_with_xlib(parent_window, x11_display_ptr, xlib, false) - } - - fn build_x11_with_xlib( - parent_window: u64, - x11_display_ptr: *mut c_void, - xlib: x11_dl::xlib::Xlib, - owns_display: bool, - ) -> Result { - // Enable Xlib thread safety. Tauri command handlers run on a thread pool, - // so set_frame/set_visible may race with GTK's own X11 usage without this. - // Safe to call multiple times — subsequent calls are no-ops. - unsafe { (xlib.XInitThreads)() }; - - let egl = egl_instance(); - - let egl_display = unsafe { egl.get_display(x11_display_ptr) } - .ok_or("eglGetDisplay failed")?; - - egl.initialize(egl_display) - .map_err(|e| format!("eglInitialize: {:?}", e))?; - - let config_attribs = [ - egl::RED_SIZE, - 8, - egl::GREEN_SIZE, - 8, - egl::BLUE_SIZE, - 8, - egl::ALPHA_SIZE, - 8, - egl::DEPTH_SIZE, - 0, - egl::STENCIL_SIZE, - 0, - egl::RENDERABLE_TYPE, - egl::OPENGL_BIT, - egl::SURFACE_TYPE, - egl::WINDOW_BIT, - egl::NONE, - ]; - - let config = egl - .choose_first_config(egl_display, &config_attribs) - .map_err(|e| format!("eglChooseConfig: {:?}", e))? - .ok_or("No suitable EGL config found")?; - - egl.bind_api(egl::OPENGL_API) - .map_err(|e| format!("eglBindApi(OPENGL_API): {:?}", e))?; - - let x_display = x11_display_ptr as *mut x11_dl::xlib::Display; - - let native_visual_id: i32 = egl - .get_config_attrib(egl_display, config, egl::NATIVE_VISUAL_ID) - .map_err(|e| format!("eglGetConfigAttrib(NATIVE_VISUAL_ID): {:?}", e))?; - - let (child_window, x11_colormap) = unsafe { - let mut vi_template: x11_dl::xlib::XVisualInfo = std::mem::zeroed(); - vi_template.visualid = native_visual_id as u64; - let mut nitems: i32 = 0; - let vi_ptr = (xlib.XGetVisualInfo)( - x_display, - x11_dl::xlib::VisualIDMask, - &mut vi_template, - &mut nitems, - ); - if vi_ptr.is_null() || nitems < 1 { - return Err(format!( - "XGetVisualInfo failed for visual ID {}", - native_visual_id - )); + let (wl_surface_ptr, wl_display_ptr) = match (raw_window, raw_display) { + (RawWindowHandle::Wayland(wh), RawDisplayHandle::Wayland(dh)) => { + (wh.surface.as_ptr(), dh.display.as_ptr()) } - let vi = *vi_ptr; - (xlib.XFree)(vi_ptr as *mut c_void); - - let colormap = (xlib.XCreateColormap)(x_display, parent_window, vi.visual, 0); - - let mut root_ret: u64 = 0; - let mut x_ret: i32 = 0; - let mut y_ret: i32 = 0; - let mut w_ret: u32 = 0; - let mut h_ret: u32 = 0; - let mut border_ret: u32 = 0; - let mut depth_ret: u32 = 0; - (xlib.XGetGeometry)( - x_display, - parent_window, - &mut root_ret, - &mut x_ret, - &mut y_ret, - &mut w_ret, - &mut h_ret, - &mut border_ret, - &mut depth_ret, - ); - - let mut attrs: x11_dl::xlib::XSetWindowAttributes = std::mem::zeroed(); - attrs.colormap = colormap; - attrs.background_pixel = 0; - attrs.border_pixel = 0; - attrs.event_mask = 0; - - let child = (xlib.XCreateWindow)( - x_display, - parent_window, - 0, - 0, - w_ret.max(1), - h_ret.max(1), - 0, - vi.depth, - 1, - vi.visual, - 0x0002 | 0x0008 | 0x0800 | 0x2000, - &mut attrs, - ); - - if child == 0 { - return Err("XCreateWindow failed".to_string()); - } - - (xlib.XMapWindow)(x_display, child); - (xlib.XLowerWindow)(x_display, child); - (xlib.XFlush)(x_display); - - (child, colormap) + _ => return Err("Embedded Linux renderer requires a Wayland session".into()), }; - let egl_surface = unsafe { - egl.create_window_surface( - egl_display, - config, - child_window as egl::NativeWindowType, - None, - ) - } - .map_err(|e| format!("eglCreateWindowSurface: {:?}", e))?; - - let context_attribs = [ - egl::CONTEXT_MAJOR_VERSION, - 3, - egl::CONTEXT_MINOR_VERSION, - 2, - egl::CONTEXT_OPENGL_PROFILE_MASK, - egl::CONTEXT_OPENGL_CORE_PROFILE_BIT, - egl::NONE, - ]; - - let egl_context = egl - .create_context(egl_display, config, None, &context_attribs) - .map_err(|e| format!("eglCreateContext: {:?}", e))?; - - // Log EGL/GL diagnostics to help debug driver issues. - // Blocklist check is deferred to new() so Drop cleans up EGL resources. - Self::log_egl_diagnostics(egl, egl_display, egl_surface, egl_context, "X11") - .unwrap_or_else(|e| tracing::warn!("[Linux renderer] X11 diagnostics issue: {}", e)); - - tracing::info!( - "[Linux renderer] X11 child window + EGL context created (OpenGL Core 3.2)" - ); + let (egl_res, wayland) = build_wayland_on_main_thread(wl_surface_ptr, wl_display_ptr)?; Ok(Self { - egl_display, - egl_surface, - egl_context, - egl_config: config, - x11_display: Some(x11_display_ptr), - x11_child_window: child_window, - x11_parent_window: parent_window, - x11_colormap, - xlib: Some(xlib), - owns_display, - wayland: None, - egl_cleaned_up: false, - valid: Arc::new(AtomicBool::new(true)), - render_inner: None, + pending: Some(Pending { + egl: egl_res, + wayland, + csd_offset, + }), + active: None, first_frame_cb: None, - video_active: Arc::new(AtomicBool::new(true)), - pending_resize: Arc::new(Mutex::new(None)), - csd_offset: (0, 0), }) } - // ----------------------------------------------------------------------- - // Wayland construction path - // ----------------------------------------------------------------------- - - fn build_wayland( - parent_surface_ptr: *mut c_void, - wl_display_ptr: *mut c_void, - ) -> Result { - let egl = egl_instance(); - - // --- Wayland protocol setup --- - // - // We connect to the same wl_display that GTK/GDK is already using. - // Backend::from_foreign_display creates a secondary reference to the - // display fd — it can send protocol requests on the same namespace - // without interfering with GTK's own event dispatch. - let backend = unsafe { - Backend::from_foreign_display(wl_display_ptr as *mut _) - }; - let conn = Connection::from_backend(backend); - - let (globals, mut queue) = registry_queue_init::(&conn) - .map_err(|e| format!("Wayland registry_queue_init: {}", e))?; - let qh = queue.handle(); - - let compositor: WlCompositor = globals - .bind(&qh, 4..=5, ()) - .map_err(|e| format!("Wayland: bind wl_compositor: {}", e))?; - let subcompositor: WlSubcompositor = globals - .bind(&qh, 1..=1, ()) - .map_err(|e| format!("Wayland: bind wl_subcompositor: {}", e))?; - - let mut state = WlGlobals; - queue - .roundtrip(&mut state) - .map_err(|e| format!("Wayland roundtrip: {}", e))?; - - // Wrap the parent surface pointer (owned by GTK/GDK) as a Rust proxy - // so we can pass it to get_subsurface(). - // - // Safety rationale for cross-connection wrapping: - // - `from_foreign_display` shares the same fd and server-side object namespace - // as GTK's connection — object IDs are valid across both. - // - `ObjectId::from_ptr` extracts the ID from the C-level wl_proxy; the server - // recognizes it because it's the same connection underneath. - // - Dropping this `WlSurface` proxy does NOT send `wl_surface_destroy` — it - // only releases the Rust wrapper. GTK retains ownership of the actual surface. - // - We only use `parent_surface` as an argument to `get_subsurface()`, never to - // receive events or manage its lifetime. - let parent_id = unsafe { - ObjectId::from_ptr(WlSurface::interface(), parent_surface_ptr as *mut _) - } - .map_err(|_| "Wayland: invalid parent wl_surface pointer from window handle")?; - let parent_surface = WlSurface::from_id(&conn, parent_id) - .map_err(|_| "Wayland: cannot create proxy for parent surface")?; - - // Create the child surface that mpv renders into. - let child_surface = compositor.create_surface(&qh, ()); - - // Create a subsurface — this places child_surface as a child of the - // parent (Tauri's WKView equivalent on Wayland). - let subsurface = - subcompositor.get_subsurface(&child_surface, &parent_surface, &qh, ()); - - // Place the video subsurface BELOW the parent surface (WebView). - // Without this, the subsurface defaults to above the parent, covering - // the transparent WebView controls. This mirrors macOS's - // addSubview:positioned:NSWindowBelow:relativeTo: pattern. - subsurface.place_below(&parent_surface); - - // Desync: the subsurface can be committed independently of the parent. - // This is the correct mode for video — we don't want to wait for GTK's - // frame cycle before presenting each decoded frame. - subsurface.set_desync(); - - // Commit the child surface to apply the desync state. - child_surface.commit(); - conn.flush().map_err(|e| format!("Wayland flush: {}", e))?; - - // --- EGL setup on Wayland --- - // - // Prefer eglGetPlatformDisplayEXT(EGL_PLATFORM_WAYLAND_EXT, ...) for - // spec-correct Wayland EGL. Fall back to eglGetDisplay which Mesa - // also auto-detects as Wayland when given a wl_display*. - const EGL_PLATFORM_WAYLAND_EXT: u32 = 0x31D8; - let egl_display = Self::get_wayland_egl_display(egl, wl_display_ptr, EGL_PLATFORM_WAYLAND_EXT)?; - - // On Wayland, GTK/GDK may share the same EGLDisplay. eglInitialize is - // idempotent per spec (bumps refcount), but some Mesa driver versions - // crash on double-init of a shared display. Check if already initialized - // by querying EGL_VERSION; only initialize if not yet done. - match egl.query_string(Some(egl_display), egl::VERSION) { - Ok(ver) => { - tracing::debug!( - "[Linux renderer] EGL display already initialized (version {:?}), skipping eglInitialize", - ver - ); - } - Err(e) => { - tracing::debug!( - "[Linux renderer] EGL display not yet initialized ({:?}), calling eglInitialize", - e - ); - egl.initialize(egl_display) - .map_err(|e| format!("Wayland eglInitialize: {:?}", e))?; - } - } - - let config_attribs = [ - egl::RED_SIZE, - 8, - egl::GREEN_SIZE, - 8, - egl::BLUE_SIZE, - 8, - egl::ALPHA_SIZE, - 8, - egl::DEPTH_SIZE, - 0, - egl::STENCIL_SIZE, - 0, - egl::RENDERABLE_TYPE, - egl::OPENGL_BIT, - egl::SURFACE_TYPE, - egl::WINDOW_BIT, - egl::NONE, - ]; - - let config = egl - .choose_first_config(egl_display, &config_attribs) - .map_err(|e| format!("Wayland eglChooseConfig: {:?}", e))? - .ok_or("Wayland: no suitable EGL config found")?; - - egl.bind_api(egl::OPENGL_API) - .map_err(|e| format!("Wayland eglBindApi(OPENGL_API): {:?}", e))?; - - // Create wl_egl_window — this is the EGL-side handle to our wl_surface. - // Initial size of 1×1; actual size set by the first set_frame() call. - let wl_egl_surface = WlEglSurface::new(child_surface.id(), 1, 1) - .map_err(|e| format!("Wayland: wl_egl_window_create failed: {:?}", e))?; - - let egl_surface = unsafe { - egl.create_window_surface( - egl_display, - config, - wl_egl_surface.ptr() as egl::NativeWindowType, - None, - ) - } - .map_err(|e| format!("Wayland eglCreateWindowSurface: {:?}", e))?; - - let context_attribs = [ - egl::CONTEXT_MAJOR_VERSION, - 3, - egl::CONTEXT_MINOR_VERSION, - 2, - egl::CONTEXT_OPENGL_PROFILE_MASK, - egl::CONTEXT_OPENGL_CORE_PROFILE_BIT, - egl::NONE, - ]; - - let egl_context = egl - .create_context(egl_display, config, None, &context_attribs) - .map_err(|e| format!("Wayland eglCreateContext: {:?}", e))?; - - // Log EGL/GL diagnostics to help debug driver issues. - // Blocklist check is deferred to new() so Drop cleans up EGL resources. - Self::log_egl_diagnostics(egl, egl_display, egl_surface, egl_context, "Wayland") - .unwrap_or_else(|e| tracing::warn!("[Linux renderer] Wayland diagnostics issue: {}", e)); - - tracing::info!( - "[Linux renderer] Wayland subsurface + wl_egl_window + EGL context created (OpenGL Core 3.2)" - ); - - let wayland = WaylandState { - wl_egl_surface, - subsurface, - child_surface, - queue, - conn, - last_frame: (0, 0, 1, 1), - }; - - Ok(Self { - egl_display, - egl_surface, - egl_context, - egl_config: config, - // X11 fields unused on Wayland - x11_display: None, - x11_child_window: 0, - x11_parent_window: 0, - x11_colormap: 0, - xlib: None, - owns_display: false, - wayland: Some(wayland), - egl_cleaned_up: false, - valid: Arc::new(AtomicBool::new(true)), - render_inner: None, - first_frame_cb: None, - video_active: Arc::new(AtomicBool::new(true)), - pending_resize: Arc::new(Mutex::new(None)), - csd_offset: (0, 0), - }) - } - - /// Try eglGetPlatformDisplayEXT first (spec-correct for Wayland EGL), then - /// fall back to plain eglGetDisplay (Mesa auto-detects Wayland). - fn get_wayland_egl_display( - egl: &egl::DynamicInstance, - wl_display_ptr: *mut c_void, - platform_enum: u32, - ) -> Result { - // The C return type EGLDisplay is void* — use *mut c_void then transmute - // into egl::Display (a transparent newtype around the same pointer). - type GetPlatformDisplayEXT = - unsafe extern "C" fn(u32, *mut c_void, *const i32) -> *mut c_void; - - { - if let Some(get_fn) = egl.get_proc_address("eglGetPlatformDisplayEXT") { - let get_platform_display: GetPlatformDisplayEXT = - unsafe { std::mem::transmute(get_fn) }; - let d_ptr = unsafe { - get_platform_display(platform_enum, wl_display_ptr, std::ptr::null()) - }; - if !d_ptr.is_null() { - // SAFETY: egl::Display is a repr(transparent) wrapper over *mut c_void. - let d: egl::Display = unsafe { std::mem::transmute(d_ptr) }; - tracing::debug!( - "[Linux renderer] Wayland EGL display via eglGetPlatformDisplayEXT" - ); - return Ok(d); - } - } - } - - // Fall back to eglGetDisplay — Mesa interprets a wl_display* correctly. - tracing::debug!( - "[Linux renderer] Wayland EGL: falling back to eglGetDisplay" - ); - unsafe { egl.get_display(wl_display_ptr) } - .ok_or_else(|| "Wayland: eglGetDisplay returned NO_DISPLAY".to_string()) - } - - /// Log EGL vendor/version and GL renderer info for diagnostics. - /// Makes the context current temporarily, queries strings, then releases. - /// - /// Returns `Err` if making the EGL context current fails or if the GL - /// renderer is a known software rasterizer (e.g. llvmpipe, swrast). - /// The caller should bail to fallback in that case. - fn log_egl_diagnostics( - egl: &egl::DynamicInstance, - display: egl::Display, - surface: egl::Surface, - context: egl::Context, - backend: &str, - ) -> Result<(), String> { - let egl_vendor = egl.query_string(Some(display), egl::VENDOR) - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|_| "unknown".into()); - let egl_version = egl.query_string(Some(display), egl::VERSION) - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|_| "unknown".into()); - let egl_apis = egl.query_string(Some(display), egl::CLIENT_APIS) - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|_| "unknown".into()); - - tracing::info!( - "[Linux renderer] EGL info ({}): vendor={} version={} apis={}", - backend, egl_vendor, egl_version, egl_apis - ); - - // Temporarily make context current to query GL strings. - if egl.make_current(display, Some(surface), Some(surface), Some(context)).is_ok() { - gl::load_with(|name| gl_get_proc_address(name) as *const _); - let renderer_str; - let version_str; - let vendor_str; - unsafe { - let gl_renderer = gl::GetString(gl::RENDERER); - let gl_version = gl::GetString(gl::VERSION); - let gl_vendor = gl::GetString(gl::VENDOR); - - renderer_str = if !gl_renderer.is_null() { - std::ffi::CStr::from_ptr(gl_renderer as *const _).to_string_lossy().into_owned() - } else { - "null".into() - }; - version_str = if !gl_version.is_null() { - std::ffi::CStr::from_ptr(gl_version as *const _).to_string_lossy().into_owned() - } else { - "null".into() - }; - vendor_str = if !gl_vendor.is_null() { - std::ffi::CStr::from_ptr(gl_vendor as *const _).to_string_lossy().into_owned() - } else { - "null".into() - }; - - tracing::info!( - "[Linux renderer] GL info ({}): renderer={} version={} vendor={}", - backend, renderer_str, version_str, vendor_str - ); - - // Check for GL errors after context setup — a non-zero error - // here indicates the context is in a bad state. - let err = gl::GetError(); - if err != gl::NO_ERROR { - tracing::warn!( - "[Linux renderer] GL error after context setup: 0x{:04X}", - err - ); - } - } - let _ = egl.make_current(display, None, None, None); - Ok(()) - } else { - let msg = format!( - "Could not make context current for GL diagnostics ({})", - backend - ); - tracing::warn!("[Linux renderer] {}", msg); - Err(msg) - } - } - - /// Set a callback that fires exactly once when the first video frame is rendered. + /// Register a callback invoked once, on the first presented frame. Must + /// be called before `attach`; it is moved into `Inner` at that point. pub fn set_first_frame_callback(&mut self, cb: Box) { self.first_frame_cb = Some(cb); } } -// --------------------------------------------------------------------------- +// ========================================================================= // PlatformRenderer impl -// --------------------------------------------------------------------------- +// ========================================================================= impl PlatformRenderer for LinuxGlRenderer { fn attach(&mut self, mpv: &mut Mpv) -> Result<(), String> { - reset_frame_counters(); - let egl = egl_instance(); - let is_wayland = self.wayland.is_some(); - - // Get wl_egl_surface pointer for render_frame to apply pending resizes. - let wl_egl_surface_ptr = self - .wayland - .as_ref() - .map(|wl| &wl.wl_egl_surface as *const WlEglSurface as usize) - .unwrap_or(0); - - // On Wayland, ALL EGL context operations must be serialized on the GLib - // main thread. Wayland EGL implementations are not thread-safe for - // cross-thread context migration (unlike X11 with XInitThreads). Making - // the context current from the Tauri command thread races with GTK's - // own EGL operations and subsequent render_frame callbacks. - // - // On X11, the existing pattern is safe because XInitThreads enables - // cross-thread usage, and attach() releases the context before - // render_frame can acquire it. - let render_ctx = if is_wayland { - // Cast Mpv pointer to usize for cross-thread dispatch (same pattern - // as macOS renderer). Safety: mpv lives in MpvState behind a Mutex, - // held by the caller for the duration of attach(). - let mpv_raw = mpv.ctx.as_ptr() as usize; - let egl_display_usize = self.egl_display.as_ptr() as usize; - let egl_surface_usize = self.egl_surface.as_ptr() as usize; - let egl_context_usize = self.egl_context.as_ptr() as usize; - - // RenderContext doesn't impl Send, but we need to return it from - // the GLib thread. Safety: same as RenderInner's `unsafe impl Send`. - struct SendableCtx(RenderContext); - unsafe impl Send for SendableCtx {} - - let setup_egl = move || -> Result { - let egl = egl_instance(); - - // SAFETY: reconstruct EGL handles from usize (same as detach()). - let display: egl::Display = - unsafe { std::mem::transmute(egl_display_usize as *mut c_void) }; - let surface: egl::Surface = - unsafe { std::mem::transmute(egl_surface_usize as *mut c_void) }; - let context: egl::Context = - unsafe { std::mem::transmute(egl_context_usize as *mut c_void) }; - - egl.make_current(display, Some(surface), Some(surface), Some(context)) - .map_err(|e| format!("eglMakeCurrent (GLib thread): {:?}", e))?; - - gl::load_with(|name| gl_get_proc_address(name) as *const _); - - fn get_proc_address(_ctx: &*mut c_void, name: &str) -> *mut c_void { - gl_get_proc_address(name) - } - - let render_ctx = RenderContext::new( - unsafe { &mut *(mpv_raw as *mut _) }, - vec![ - RenderParam::ApiType(RenderParamApiType::OpenGl), - RenderParam::InitParams(OpenGLInitParams { - get_proc_address, - ctx: std::ptr::null_mut(), - }), - ], - ) - .map_err(|e| format!("mpv_render_context_create: {}", e))?; - - // Clear to black before the first mpv frame arrives. - unsafe { - gl::ClearColor(0.0, 0.0, 0.0, 1.0); - gl::Clear(gl::COLOR_BUFFER_BIT); - } - let swap_ok = egl.swap_buffers(display, surface); - - // GL validation probe: check for errors after initial render setup. - // A failure here means the GL pipeline is broken and we should bail - // to fallback rather than waiting for a crash in render_frame. - let gl_err = unsafe { gl::GetError() }; - if gl_err != gl::NO_ERROR { - let msg = format!( - "GL error after initial clear (Wayland): 0x{:04X}", - gl_err - ); - tracing::error!("[Linux renderer] {}", msg); - let _ = egl.make_current(display, None, None, None); - return Err(msg); - } - if swap_ok.is_err() { - let egl_err = egl.get_error(); - let msg = format!( - "eglSwapBuffers failed during attach probe (Wayland): EGL error {:?}", - egl_err - ); - tracing::error!("[Linux renderer] {}", msg); - let _ = egl.make_current(display, None, None, None); - return Err(msg); - } - - tracing::info!("[Linux renderer] GL validation probe passed (Wayland)"); - - // Release context so render_frame callbacks can acquire it. - let _ = egl.make_current(display, None, None, None); - - Ok(SendableCtx(render_ctx)) - }; - - // If we're already on the GLib main thread (e.g. during GTK startup), - // run inline to avoid deadlocking on our own callback. Otherwise - // dispatch via MainContext::invoke (higher priority than idle_add_once, - // won't be starved by pending idle callbacks). - let main_ctx = glib::MainContext::default(); - if main_ctx.is_owner() { - setup_egl().map(|s| s.0)? - } else { - let (tx, rx) = std::sync::mpsc::channel::>(); - main_ctx.invoke(move || { - let _ = tx.send(setup_egl()); - }); - rx.recv_timeout(std::time::Duration::from_secs(5)) - .map_err(|_| { - "Timed out waiting for GLib main thread EGL setup".to_string() - })? - .map(|s| s.0)? - } - } else { - // X11 path — safe to run on Tauri command thread (XInitThreads). - egl.make_current( - self.egl_display, - Some(self.egl_surface), - Some(self.egl_surface), - Some(self.egl_context), - ) - .map_err(|e| format!("eglMakeCurrent: {:?}", e))?; - - gl::load_with(|name| gl_get_proc_address(name) as *const _); + let Pending { egl: egl_res, wayland, csd_offset } = self + .pending + .take() + .ok_or_else(|| "Renderer has already been attached".to_string())?; + + // Capture raw handles to move across the GLib dispatch boundary. + // After this block, the Send+Sync Inner is what crosses threads. + let display = egl_res.display; + let surface = egl_res.surface; + let context = egl_res.context; + let wl_display_ptr = wayland.wl_display_ptr; + let mpv_ptr = mpv.ctx.as_ptr(); + + // Dispatch render-context creation to the GLib main thread because + // libmpv will immediately call our get_proc_address callback and + // must find the EGL context current on the calling thread. + let render_ctx = run_on_glib_main(move || -> Result { + let egl = egl()?; + egl.make_current(display, Some(surface), Some(surface), Some(context)) + .map_err(|e| format!("eglMakeCurrent (init): {e:?}"))?; + + // Load `gl` crate's dispatch table while the context is current. + // This is the only `gl::load_with` call in the process; after + // this, every `gl::*` call in `render_frame` is a static lookup. + gl::load_with(|name| gl_proc_address(name) as *const _); fn get_proc_address(_ctx: &*mut c_void, name: &str) -> *mut c_void { - gl_get_proc_address(name) + gl_proc_address(name) } - let render_ctx = RenderContext::new( - unsafe { &mut *mpv.ctx.as_ptr() }, + // SAFETY: `mpv_ptr` is a valid `*mut mpv_handle` held alive by + // the MpvEngine mutex across this call; we do not dereference it + // ourselves — libmpv2 passes it to `mpv_render_context_create`. + let ctx = RenderContext::new( + unsafe { &mut *mpv_ptr }, vec![ RenderParam::ApiType(RenderParamApiType::OpenGl), RenderParam::InitParams(OpenGLInitParams { get_proc_address, - ctx: std::ptr::null_mut(), + ctx: std::ptr::null_mut::(), }), + // Tells libmpv which Wayland display the GL context is on + // so it can request dmabuf formats from the compositor + // and match its presentation timing to the compositor's. + RenderParam::WaylandDisplay(wl_display_ptr as *const c_void), ], ) - .map_err(|e| format!("mpv_render_context_create: {}", e))?; + .map_err(|e| format!("mpv_render_context_create: {e}"))?; - // Clear the framebuffer to black before the first mpv frame arrives. + // SAFETY: gl crate dispatch is valid on this thread because we + // just called `load_with` while the context was current. unsafe { gl::ClearColor(0.0, 0.0, 0.0, 1.0); gl::Clear(gl::COLOR_BUFFER_BIT); } - let swap_ok = egl.swap_buffers(self.egl_display, self.egl_surface); - - // GL validation probe: check for errors after initial render setup. - let gl_err = unsafe { gl::GetError() }; - if gl_err != gl::NO_ERROR { - let msg = format!( - "GL error after initial clear (X11): 0x{:04X}", - gl_err - ); - tracing::error!("[Linux renderer] {}", msg); - let _ = egl.make_current(self.egl_display, None, None, None); - return Err(msg); - } - if swap_ok.is_err() { - let egl_err = egl.get_error(); - let msg = format!( - "eglSwapBuffers failed during attach probe (X11): EGL error {:?}", - egl_err - ); - tracing::error!("[Linux renderer] {}", msg); - let _ = egl.make_current(self.egl_display, None, None, None); - return Err(msg); - } - - tracing::info!("[Linux renderer] GL validation probe passed (X11)"); - - // Release the EGL context from this thread so the GLib main thread - // can make it current in render_frame(). - let _ = egl.make_current(self.egl_display, None, None, None); - - render_ctx - }; + let _ = egl.swap_buffers(display, surface); + let _ = egl.make_current(display, None, None, None); + Ok(ctx) + })?; - // Common path: set up RenderInner and update callback. - let mut inner = Box::new(RenderInner { - ctx: render_ctx, - egl_display: self.egl_display, - egl_surface: self.egl_surface, - egl_context: self.egl_context, - first_frame_cb: self.first_frame_cb.take(), - video_active: self.video_active.clone(), - pending_resize: self.pending_resize.clone(), - wl_egl_surface_ptr, + let first_frame_cb = self.first_frame_cb.take(); + let inner = Arc::new(Inner { + state: Mutex::new(SessionState { + pending_resize: None, + last_frame: (0, 0, 0, 0), + csd_offset, + }), + first_frame_cb: Mutex::new(first_frame_cb), + video_active: AtomicBool::new(true), + valid: AtomicBool::new(true), + render_ctx, + egl: egl_res, + wayland, }); - let inner_ptr = &*inner as *const RenderInner as usize; - let valid = self.valid.clone(); - - inner.ctx.set_update_callback(move || { - let v = valid.clone(); - glib::idle_add_once(move || { - if !v.load(Ordering::Acquire) { - return; + // Wire the update callback to a Weak. If `detach` drops the + // only Arc before an idle callback runs, `upgrade()` returns None + // and the callback is a no-op — no dangling access possible. + // + // `Arc::get_mut` is safe here because this is the single-producer + // moment: the Arc was just constructed and no clones exist yet. + let weak = Arc::downgrade(&inner); + { + let mut owned = inner; + let inner_mut = Arc::get_mut(&mut owned).expect("fresh Arc has no other refs"); + inner_mut.render_ctx.set_update_callback(move || { + if let Some(alive) = weak.upgrade() { + let alive = alive.clone(); + glib::idle_add_once(move || { + if alive.valid.load(Ordering::Acquire) { + render_frame(&alive); + } + }); } - unsafe { render_frame(inner_ptr) }; }); - }); - - self.render_inner = Some(inner); - - tracing::info!("[Linux renderer] render context attached"); + self.active = Some(owned); + } Ok(()) } fn resize(&mut self, _width: u32, _height: u32) { - // No-op: set_frame() handles all positioning and sizing. + // Positioning is driven by `set_frame` from the frontend layout. } fn set_frame(&mut self, x: f64, y: f64, w: f64, h: f64) { - tracing::trace!("[Linux renderer] set_frame({}, {}, {}, {})", x, y, w, h); - if let Some(ref mut wl) = self.wayland { - // Wayland: position the subsurface and queue resize for GLib thread. - // - // wl_egl_window_resize is NOT thread-safe with respect to EGL calls - // (eglMakeCurrent, eglSwapBuffers). Instead of resizing here on the - // command thread, we queue the resize and let render_frame() apply it - // on the GLib main thread — the same thread that does EGL rendering. - // This mirrors macOS's pattern of dispatching set_frame to main queue. - let wi = (w as i32).max(1); - let hi = (h as i32).max(1); - - // Frontend coords are relative to the WebView viewport, but - // subsurface position is relative to the parent wl_surface which - // includes CSD shadow margins and header bar. Add both offsets. - let adjusted_x = x as i32 + self.csd_offset.0; - let adjusted_y = y as i32 + self.csd_offset.1; - wl.last_frame = (adjusted_x, adjusted_y, wi, hi); - - // set_position is double-buffered (safe from any thread). - wl.subsurface.set_position(adjusted_x, adjusted_y); - - // Queue the resize for the GLib main thread. - if let Ok(mut pending) = self.pending_resize.lock() { - *pending = Some(PendingResize { w: wi, h: hi }); - } - - // Manually trigger a render_frame dispatch. When paused, mpv doesn't - // fire update callbacks, so the pending resize would never be applied. - // This ensures resize is visible immediately (redraws last frame). - if let Some(ref inner) = self.render_inner { - let inner_ptr = &**inner as *const RenderInner as usize; - let v = self.valid.clone(); - glib::idle_add_once(move || { - if !v.load(Ordering::Acquire) { - return; - } - unsafe { render_frame(inner_ptr) }; - }); - } - - wl.child_surface.commit(); - let _ = wl.conn.flush(); - - // Drain pending compositor events (buffer release, surface enter/leave) - // to keep the protocol state consistent and prevent queue overflow. - let mut dummy = WlGlobals; - if let Err(e) = wl.queue.dispatch_pending(&mut dummy) { - tracing::warn!("[Linux renderer] Wayland dispatch_pending failed: {}", e); - } - } else if let (Some(ref xlib), Some(display)) = (&self.xlib, self.x11_display) { - let x_display = display as *mut x11_dl::xlib::Display; - unsafe { - (xlib.XMoveResizeWindow)( - x_display, - self.x11_child_window, - x as i32, - y as i32, - (w as u32).max(1), - (h as u32).max(1), - ); - (xlib.XFlush)(x_display); - } - } - - } - - fn set_visible(&mut self, visible: bool) { - self.video_active.store(visible, Ordering::Release); - - if let Some(ref mut wl) = self.wayland { - if visible { - // Restore last known position and queue resize for GLib thread. - let (lx, ly, lw, lh) = wl.last_frame; - wl.subsurface.set_position(lx, ly); - if let Ok(mut pending) = self.pending_resize.lock() { - *pending = Some(PendingResize { w: lw, h: lh }); - } - } else { - // Move subsurface far off-screen. This is the safe Wayland equivalent - // of XUnmapWindow — we cannot call wl_surface_attach(NULL) on an - // EGL-managed surface without risking protocol errors. - wl.subsurface.set_position(-32000, -32000); - } - wl.child_surface.commit(); - let _ = wl.conn.flush(); + let Some(inner) = self.active.as_ref() else { return }; + let wi = (w as i32).max(1); + let hi = (h as i32).max(1); + + let (adjusted_x, adjusted_y) = { + let mut state = match inner.state.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let ax = x as i32 + state.csd_offset.0; + let ay = y as i32 + state.csd_offset.1; + state.last_frame = (ax, ay, wi, hi); + state.pending_resize = Some((wi, hi)); + (ax, ay) + }; - // Drain pending compositor events. - let mut dummy = WlGlobals; - if let Err(e) = wl.queue.dispatch_pending(&mut dummy) { - tracing::warn!("[Linux renderer] Wayland dispatch_pending failed: {}", e); - } - } else if let (Some(ref xlib), Some(display)) = (&self.xlib, self.x11_display) { - let x_display = display as *mut x11_dl::xlib::Display; - unsafe { - if visible { - (xlib.XMapWindow)(x_display, self.x11_child_window); - } else { - (xlib.XUnmapWindow)(x_display, self.x11_child_window); + // Subsurface position is double-buffered through wayland-client's + // locked Backend — safe to call from the command thread. The + // `wl_egl_window_resize` is applied later by `render_frame` on the + // GLib thread where the EGL context is current. + inner.wayland.subsurface.set_position(adjusted_x, adjusted_y); + inner.wayland.child_surface.commit(); + let _ = inner.wayland.conn.flush(); + drain_wayland_queue(inner); + + // Kick a render so the resize is visible even when paused. + let weak = Arc::downgrade(inner); + glib::idle_add_once(move || { + if let Some(alive) = weak.upgrade() { + if alive.valid.load(Ordering::Acquire) { + render_frame(&alive); } - (xlib.XFlush)(x_display); } - } + }); } - fn detach(&mut self) { - // Signal all queued callbacks to bail before we free the render state. - self.valid.store(false, Ordering::Release); - - let egl = egl_instance(); - - // Drop the RenderContext with GL context current. If we're already on - // the GLib main thread (e.g. Drop triggered during GTK teardown), run - // cleanup inline to avoid deadlocking on our own idle callback. - if let Some(render_inner) = self.render_inner.take() { - // egl::Display/Surface/Context are newtype wrappers around *mut c_void - // and don't implement Send. Transmute to usize for cross-thread dispatch - // (same pattern as macOS raw pointer dispatch). - let display_usize = self.egl_display.as_ptr() as usize; - let surface_usize = self.egl_surface.as_ptr() as usize; - let context_usize = self.egl_context.as_ptr() as usize; - - let do_drop = move |ri: Box| { - let egl = egl_instance(); - let display: egl::Display = unsafe { std::mem::transmute(display_usize as *mut c_void) }; - let surface: egl::Surface = unsafe { std::mem::transmute(surface_usize as *mut c_void) }; - let context: egl::Context = unsafe { std::mem::transmute(context_usize as *mut c_void) }; - let _ = egl.make_current(display, Some(surface), Some(surface), Some(context)); - drop(ri); - let _ = egl.make_current(display, None, None, None); + fn set_visible(&mut self, visible: bool) { + let Some(inner) = self.active.as_ref() else { return }; + inner.video_active.store(visible, Ordering::Release); + + if visible { + let (lx, ly, lw, lh) = { + let state = match inner.state.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + state.last_frame }; - - if glib::MainContext::default().is_owner() { - // Already on the main thread — run cleanup directly. - do_drop(render_inner); - } else { - // Schedule on the GLib main thread and block until drained. - let (tx, rx) = std::sync::mpsc::channel::<()>(); - glib::idle_add_once(move || { - do_drop(render_inner); - let _ = tx.send(()); - }); - if rx - .recv_timeout(std::time::Duration::from_secs(2)) - .is_err() - { - tracing::warn!("[Linux renderer] detach: timed out waiting for GLib idle drain"); + inner.wayland.subsurface.set_position(lx, ly); + if lw > 0 && lh > 0 { + if let Ok(mut state) = inner.state.lock() { + state.pending_resize = Some((lw, lh)); } } + } else { + // Move the subsurface off-screen rather than `wl_surface.attach` + // a null buffer — attaching null on an EGL-managed surface would + // trigger a protocol error with some compositors. + inner.wayland.subsurface.set_position(-32000, -32000); } + inner.wayland.child_surface.commit(); + let _ = inner.wayland.conn.flush(); + drain_wayland_queue(inner); + } - // Clean up EGL resources (guarded by flag to prevent double-cleanup). - if !self.egl_cleaned_up { - self.egl_cleaned_up = true; - let _ = egl.make_current(self.egl_display, None, None, None); - let _ = egl.destroy_surface(self.egl_display, self.egl_surface); - let _ = egl.destroy_context(self.egl_display, self.egl_context); - // Only terminate the EGL display on X11 where we own it. - // On Wayland, GTK shares the same EGLDisplay — terminating it - // would crash the application. - if self.wayland.is_none() { - let _ = egl.terminate(self.egl_display); - } - } - - // Wayland cleanup: drop subsurface and surface (in correct order), - // then let WaylandState drop the connection and event queue. - if let Some(ref mut wl) = self.wayland { - wl.subsurface.destroy(); - // Commit to make the compositor release the subsurface relationship. - wl.child_surface.commit(); - let _ = wl.conn.flush(); - // wl_egl_surface, child_surface, _queue, conn drop here in field order. - } - self.wayland = None; - - // X11 cleanup. - if let (Some(ref xlib), Some(display)) = (&self.xlib, self.x11_display) { - let x_display = display as *mut x11_dl::xlib::Display; - if self.x11_child_window != 0 { - unsafe { - (xlib.XDestroyWindow)(x_display, self.x11_child_window); - } - } - if self.x11_colormap != 0 { - unsafe { - (xlib.XFreeColormap)(x_display, self.x11_colormap); - } - self.x11_colormap = 0; - } - unsafe { (xlib.XFlush)(x_display) }; - if self.owns_display { - unsafe { (xlib.XCloseDisplay)(x_display) }; - self.x11_display = None; + fn set_first_frame_callback(&mut self, cb: Box) { + if let Some(inner) = self.active.as_ref() { + if let Ok(mut slot) = inner.first_frame_cb.lock() { + *slot = Some(cb); } + } else { + self.first_frame_cb = Some(cb); } + } - self.x11_child_window = 0; - tracing::info!("[Linux renderer] detached"); + fn detach(&mut self) { + let Some(inner) = self.active.take() else { return }; + inner.valid.store(false, Ordering::Release); + + // All tear-down (RenderContext -> OwnedEgl -> WaylandSession) must + // happen on the GLib main thread so it serializes behind any pending + // idle render callbacks and uses the correct EGL context thread. + run_on_glib_main(move || { + // Dropping `inner` on this thread runs Drop for RenderContext, + // OwnedEgl, and WaylandSession in field order. + drop(inner); + }); } } impl Drop for LinuxGlRenderer { fn drop(&mut self) { self.detach(); + // `pending` (if attach never ran) drops here, also requires GLib + // thread for the EGL handles. Construction happened there, and + // dropping on a different thread is usually fine for destroy_* + // calls because no render thread touches them in that case. } } -// --------------------------------------------------------------------------- -// Per-frame rendering — glib main thread only -// --------------------------------------------------------------------------- - -static FRAME_COUNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); -static CONSECUTIVE_FAILURES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); - -/// Reset per-session frame diagnostics. Called at the start of each attach() -/// so logs for a new playback session start from frame #0. -fn reset_frame_counters() { - FRAME_COUNT.store(0, Ordering::Relaxed); - CONSECUTIVE_FAILURES.store(0, Ordering::Relaxed); -} - -/// Render one frame. Called on the glib main thread by the update callback. -/// Safety: caller must verify `valid = true`; `inner_ptr` must be live. -unsafe fn render_frame(inner_ptr: usize) { - let inner = &mut *(inner_ptr as *mut RenderInner); +// ========================================================================= +// Per-frame render — GLib main thread only +// ========================================================================= +fn render_frame(inner: &Inner) { if !inner.video_active.load(Ordering::Acquire) { return; } + let Ok(egl) = egl() else { return }; - let egl = egl_instance(); + let display = inner.egl.display; + let surface = inner.egl.surface; + let context = inner.egl.context; - // 1. Make the EGL context current FIRST — wl_egl_window_resize() requires - // the EGL surface to be current on the calling thread. - // - // On Wayland, a single eglMakeCurrent failure can cascade (context lost - // state persists). Retry once after releasing the context, and log the - // EGL error code for diagnostics. - let make_current = || { - egl.make_current( - inner.egl_display, - Some(inner.egl_surface), - Some(inner.egl_surface), - Some(inner.egl_context), - ) - }; - - if make_current().is_err() { - let err = egl.get_error(); - let failures = CONSECUTIVE_FAILURES.fetch_add(1, Ordering::Relaxed) + 1; - tracing::warn!( - "[Linux renderer] render_frame: eglMakeCurrent failed (EGL error {:?}, failure #{}) retrying", - err, failures - ); - // Release any stale context state before retrying. - let _ = egl.make_current(inner.egl_display, None, None, None); - if make_current().is_err() { - let err2 = egl.get_error(); - tracing::error!( - "[Linux renderer] render_frame: eglMakeCurrent retry failed (EGL error {:?}, failure #{}), skipping frame", - err2, failures - ); - return; - } + if egl + .make_current(display, Some(surface), Some(surface), Some(context)) + .is_err() + { + return; } - // 2. Apply any pending wl_egl_window resize AFTER making context current. - // This ensures the resize and subsequent EGL calls happen on the same - // thread with the surface current, avoiding the race condition that - // caused color corruption. Also set glViewport and clear the buffer - // to avoid garbage from newly allocated regions. let mut did_resize = false; - if inner.wl_egl_surface_ptr != 0 { - if let Ok(mut pending) = inner.pending_resize.lock() { - if let Some(resize) = pending.take() { - let wl_egl = &*(inner.wl_egl_surface_ptr as *const WlEglSurface); - wl_egl.resize(resize.w, resize.h, 0, 0); - gl::Viewport(0, 0, resize.w, resize.h); + if let Ok(mut state) = inner.state.lock() { + if let Some((w, h)) = state.pending_resize.take() { + inner.wayland.wl_egl_surface.resize(w, h, 0, 0); + // SAFETY: gl::load_with was called at attach time with this EGL + // context current; the context is current again on this thread. + unsafe { + gl::Viewport(0, 0, w, h); gl::ClearColor(0.0, 0.0, 0.0, 1.0); gl::Clear(gl::COLOR_BUFFER_BIT); - did_resize = true; } + did_resize = true; } } - let w = egl.query_surface(inner.egl_display, inner.egl_surface, egl::WIDTH).unwrap_or(0); - let h = egl.query_surface(inner.egl_display, inner.egl_surface, egl::HEIGHT).unwrap_or(0); + let w = egl.query_surface(display, surface, egl::WIDTH).unwrap_or(0); + let h = egl.query_surface(display, surface, egl::HEIGHT).unwrap_or(0); if w < 1 || h < 1 { return; } - let rc = &inner.ctx as *const RenderContext; - let rc: &RenderContext = &*rc; - - // 3. Check if mpv has a new frame or if we need to force a redraw after resize. - // When paused, mpv doesn't fire update callbacks, so resizes would never - // be visually applied. Calling render() unconditionally after resize - // redraws the last frame at the new size (mpv docs confirm this behavior). - let should_render = match rc.update() { + let should_render = match inner.render_ctx.update() { Ok(flags) => (flags & mpv_render_update::Frame != 0) || did_resize, - Err(e) => { - tracing::trace!("[Linux renderer] update error: {}", e); - did_resize // Still render if we resized, even if update() fails - } + Err(_) => did_resize, }; - if should_render { - tracing::trace!("[Linux renderer] rendering frame (fbo=0, {}x{})...", w, h); - if let Err(e) = rc.render::<*mut c_void>(0, w, h, true) { - let failures = CONSECUTIVE_FAILURES.fetch_add(1, Ordering::Relaxed) + 1; - tracing::error!("[Linux renderer] rc.render() failed (failure #{}): {}", failures, e); - return; - } - if egl.swap_buffers(inner.egl_display, inner.egl_surface).is_err() { - let egl_err = egl.get_error(); - let failures = CONSECUTIVE_FAILURES.fetch_add(1, Ordering::Relaxed) + 1; - tracing::error!( - "[Linux renderer] eglSwapBuffers failed (EGL error {:?}, failure #{})", - egl_err, failures - ); - return; - } - rc.report_swap(); + if !should_render { + return; + } - // Reset failure counter on success. - CONSECUTIVE_FAILURES.store(0, Ordering::Relaxed); + if inner + .render_ctx + .render::<*mut c_void>(0, w, h, true) + .is_err() + { + return; + } + if egl.swap_buffers(display, surface).is_err() { + return; + } + inner.render_ctx.report_swap(); - let n = FRAME_COUNT.fetch_add(1, Ordering::Relaxed); - if n < 5 || n % 60 == 0 { - tracing::debug!("[Linux renderer] frame presented (#{n})"); + if let Ok(mut slot) = inner.first_frame_cb.lock() { + if let Some(cb) = slot.take() { + cb(); } + } +} - if let Some(cb) = inner.first_frame_cb.take() { - cb(); +// ========================================================================= +// Construction helpers +// ========================================================================= + +/// Build EGL + Wayland subsurface resources on the GLib main thread. +fn build_wayland_on_main_thread( + wl_surface_ptr: *mut c_void, + wl_display_ptr: *mut c_void, +) -> Result<(OwnedEgl, WaylandSession), String> { + // Raw pointers aren't Send; wrap them in a local newtype whose Send impl + // we vouch for (they are valid for the lifetime of the Tauri window). + struct RawPtrs(*mut c_void, *mut c_void); + // SAFETY: Both pointers are owned by GTK and remain valid until the + // window closes; sending them across the main-thread dispatch boundary + // does not extend their lifetime. + unsafe impl Send for RawPtrs {} + + let raw = RawPtrs(wl_surface_ptr, wl_display_ptr); + run_on_glib_main(move || build_wayland(raw.0, raw.1)) +} + +fn build_wayland( + wl_surface_ptr: *mut c_void, + wl_display_ptr: *mut c_void, +) -> Result<(OwnedEgl, WaylandSession), String> { + let egl = egl()?; + + // Secondary reference to the GTK Wayland connection. Shares the same fd + // and object namespace, so ObjectId::from_ptr recognises GTK's objects. + // + // SAFETY: `wl_display_ptr` is a valid `wl_display *` owned by GTK for + // the lifetime of the window. `from_foreign_display` only reads the fd + // and bumps a refcount; it never frees the display. + let backend = unsafe { Backend::from_foreign_display(wl_display_ptr as *mut _) }; + let conn = Connection::from_backend(backend); + + let (globals, mut queue) = registry_queue_init::(&conn) + .map_err(|e| format!("Wayland registry_queue_init: {e}"))?; + let qh = queue.handle(); + + let compositor: WlCompositor = globals + .bind(&qh, 4..=5, ()) + .map_err(|e| format!("bind wl_compositor: {e}"))?; + let subcompositor: WlSubcompositor = globals + .bind(&qh, 1..=1, ()) + .map_err(|e| format!("bind wl_subcompositor: {e}"))?; + + let mut dummy = WlGlobals; + queue + .roundtrip(&mut dummy) + .map_err(|e| format!("wayland roundtrip: {e}"))?; + + // Wrap the parent surface pointer as a wayland-client proxy so we can + // pass it to `get_subsurface`. We never receive events on it and its + // Drop does not send `wl_surface.destroy` — GTK owns the surface. + // + // SAFETY: `wl_surface_ptr` is a valid `wl_surface *` on the same + // connection we are using (shared fd via `from_foreign_display`). + let parent_id = unsafe { + ObjectId::from_ptr(WlSurface::interface(), wl_surface_ptr as *mut _) + } + .map_err(|_| "invalid parent wl_surface pointer")?; + let parent_surface = WlSurface::from_id(&conn, parent_id) + .map_err(|_| "cannot build proxy for parent wl_surface")?; + + let child_surface = compositor.create_surface(&qh, ()); + let subsurface = subcompositor.get_subsurface(&child_surface, &parent_surface, &qh, ()); + subsurface.place_below(&parent_surface); + subsurface.set_desync(); + child_surface.commit(); + conn.flush().map_err(|e| format!("wayland flush: {e}"))?; + + // EGL: prefer `eglGetPlatformDisplayEXT(EGL_PLATFORM_WAYLAND_EXT)` so + // libmpv's internal Wayland integration recognises the display as a + // Wayland-backed one. Fall back to `eglGetDisplay` for old drivers. + const EGL_PLATFORM_WAYLAND_EXT: u32 = 0x31D8; + let egl_display = wayland_egl_display(egl, wl_display_ptr, EGL_PLATFORM_WAYLAND_EXT)?; + + // `eglInitialize` is idempotent per spec, but some Mesa releases + // mishandle double-init on a display already initialised by GTK. Only + // call it if `EGL_VERSION` is not yet queryable. + if egl.query_string(Some(egl_display), egl::VERSION).is_err() { + egl.initialize(egl_display) + .map_err(|e| format!("eglInitialize: {e:?}"))?; + } + + let config_attribs = [ + egl::RED_SIZE, 8, + egl::GREEN_SIZE, 8, + egl::BLUE_SIZE, 8, + egl::ALPHA_SIZE, 8, + egl::DEPTH_SIZE, 0, + egl::STENCIL_SIZE, 0, + egl::RENDERABLE_TYPE, egl::OPENGL_BIT, + egl::SURFACE_TYPE, egl::WINDOW_BIT, + egl::NONE, + ]; + let config = egl + .choose_first_config(egl_display, &config_attribs) + .map_err(|e| format!("eglChooseConfig: {e:?}"))? + .ok_or("no suitable EGL config")?; + egl.bind_api(egl::OPENGL_API) + .map_err(|e| format!("eglBindAPI(OPENGL): {e:?}"))?; + + let wl_egl_surface = WlEglSurface::new(child_surface.id(), 1, 1) + .map_err(|e| format!("wl_egl_window_create: {e:?}"))?; + + // SAFETY: `wl_egl_surface.ptr()` is a valid `wl_egl_window *` and stays + // alive as long as `WaylandSession` owns `wl_egl_surface`. + let egl_surface = unsafe { + egl.create_window_surface( + egl_display, + config, + wl_egl_surface.ptr() as egl::NativeWindowType, + None, + ) + } + .map_err(|e| format!("eglCreateWindowSurface: {e:?}"))?; + + let context_attribs = [ + egl::CONTEXT_MAJOR_VERSION, 3, + egl::CONTEXT_MINOR_VERSION, 2, + egl::CONTEXT_OPENGL_PROFILE_MASK, egl::CONTEXT_OPENGL_CORE_PROFILE_BIT, + egl::NONE, + ]; + let egl_context = egl + .create_context(egl_display, config, None, &context_attribs) + .map_err(|e| format!("eglCreateContext: {e:?}"))?; + + let egl_res = OwnedEgl { + display: egl_display, + surface: egl_surface, + context: egl_context, + }; + let wayland = WaylandSession { + wl_egl_surface, + subsurface, + child_surface, + queue: Mutex::new(queue), + conn, + wl_display_ptr, + }; + Ok((egl_res, wayland)) +} + +fn wayland_egl_display( + egl: &egl::DynamicInstance, + wl_display_ptr: *mut c_void, + platform_enum: u32, +) -> Result { + type GetPlatformDisplayExt = + unsafe extern "C" fn(u32, *mut c_void, *const i32) -> *mut c_void; + + if let Some(get_fn) = egl.get_proc_address("eglGetPlatformDisplayEXT") { + // SAFETY: `eglGetPlatformDisplayEXT` has the documented signature + // `EGLDisplay(EGLenum, void*, const EGLint*)`; transmuting the proc + // address to that fn pointer matches the loader's contract. + let get_platform_display: GetPlatformDisplayExt = unsafe { std::mem::transmute(get_fn) }; + // SAFETY: calls into libEGL with a valid wl_display pointer. + let d_ptr = unsafe { + get_platform_display(platform_enum, wl_display_ptr, std::ptr::null()) + }; + if !d_ptr.is_null() { + // SAFETY: `egl::Display` is `repr(transparent)` over `*mut c_void`. + return Ok(unsafe { std::mem::transmute::<*mut c_void, egl::Display>(d_ptr) }); } } + + // SAFETY: Fallback path; `eglGetDisplay` accepts any native display + // pointer and returns EGL_NO_DISPLAY on failure (mapped to None). + unsafe { egl.get_display(wl_display_ptr) } + .ok_or_else(|| "eglGetDisplay returned EGL_NO_DISPLAY".to_string()) +} + +/// Query GTK for the CSD (Client-Side Decoration) offset so subsurface +/// coordinates (which are relative to the parent `wl_surface`, inclusive of +/// shadow + titlebar) line up with frontend content-area coordinates. +fn query_csd_offsets(app: &AppHandle) -> (i32, i32) { + use gtk::prelude::*; + + if app.get_webview_window("main").is_none() { + return (0, 0); + } + + let (tx, rx) = std::sync::mpsc::channel(); + let app_handle = app.clone(); + + glib::idle_add_once(move || { + let Some(window) = app_handle.get_webview_window("main") else { + let _ = tx.send((0, 0)); + return; + }; + let offsets = (|| -> Option<(i32, i32)> { + let gtk_win = window.gtk_window().ok()?; + let vbox = window.default_vbox().ok()?; + let (vbox_x, vbox_y) = vbox.translate_coordinates(>k_win, 0, 0)?; + let gdk_win = gtk_win.window()?; + let alloc = gtk_win.allocation(); + let shadow_x = (gdk_win.width() - alloc.width()).max(0) / 2; + let shadow_y = (gdk_win.height() - alloc.height()).max(0) / 2; + Some((shadow_x + vbox_x, shadow_y + vbox_y)) + })(); + let _ = tx.send(offsets.unwrap_or((0, 0))); + }); + rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap_or((0, 0)) +} + +// ========================================================================= +// GLib main-thread dispatch helper +// ========================================================================= + +/// Run `f` on the GLib main thread, blocking until it returns. +/// +/// Uses `MainContext::invoke` (higher priority than idle) when dispatching, +/// and runs inline if already on the main thread to avoid deadlocking on +/// our own callback. +fn run_on_glib_main(f: F) -> T +where + F: FnOnce() -> T + Send + 'static, + T: Send + 'static, +{ + let main_ctx = glib::MainContext::default(); + if main_ctx.is_owner() { + return f(); + } + let (tx, rx) = std::sync::mpsc::channel(); + main_ctx.invoke(move || { + let _ = tx.send(f()); + }); + rx.recv() + .expect("GLib main thread dispatch channel dropped before sending") } -// --------------------------------------------------------------------------- +// ========================================================================= +// Wayland event-queue drain +// ========================================================================= + +fn drain_wayland_queue(inner: &Inner) { + let Ok(mut queue) = inner.wayland.queue.lock() else { return }; + let mut dummy = WlGlobals; + let _ = queue.dispatch_pending(&mut dummy); +} + +// ========================================================================= // MPV option sets -// --------------------------------------------------------------------------- +// ========================================================================= -/// Options for embedded playback via OpenGL render context (vo=libmpv). +/// Options for embedded playback via the OpenGL render context (vo=libmpv). pub fn embedded_options() -> Vec<(&'static str, &'static str)> { vec![ ("vo", "libmpv"), - // auto-copy: hardware-decode but copy frames to CPU for GL rendering. - // Plain "auto" maps VAAPI surfaces directly as GL textures, which causes - // color corruption (red/green hue) on drivers that can't handle the - // NV12→RGB interop (VMware, some Mesa/Intel setups). + // `auto-copy` decodes on the GPU but copies frames to CPU for GL + // upload. Plain `auto` can map VAAPI surfaces directly as GL + // textures, which produces colour corruption on drivers that + // cannot handle the NV12→RGB interop. ("hwdec", "auto-copy"), - // Don't restrict audio outputs — let mpv try ALL compiled-in backends. - // Hardcoding "pipewire,pulse,alsa" prevented fallback to other outputs - // (sdl, oss, jack, sndio) on systems where libmpv was built differently. ("ao", "auto"), - // audio sync is correct for callback-driven rendering (not vsync-locked). - // display-resample requires calling render() at every vsync, which our - // idle_add_once approach doesn't guarantee — it deactivates after a few seconds. ("video-sync", "audio"), ("cache", "yes"), ("demuxer-max-bytes", "150MiB"), ("demuxer-max-back-bytes", "75MiB"), ("keep-open", "yes"), - // Log mpv-internal messages (audio/video init, codec selection, errors) - // to stderr so they appear alongside our tracing output for diagnostics. - ("terminal", "yes"), - ("msg-level", "all=status"), ] } -/// Options for fallback separate window (vo=gpu, native OSC shown automatically). +/// Options for the separate-window fallback (vo=gpu, native OSC). pub fn fallback_options() -> Vec<(&'static str, &'static str)> { vec![ ("hwdec", "auto"), @@ -1578,24 +833,11 @@ pub fn fallback_options() -> Vec<(&'static str, &'static str)> { ("demuxer-max-bytes", "150MiB"), ("demuxer-max-back-bytes", "75MiB"), ("keep-open", "yes"), - ("terminal", "yes"), - ("msg-level", "all=status"), ] } -/// Software-safe fallback options for blocklisted software renderers (llvmpipe, swrast, softpipe). -/// Uses vo=x11 (pure software blitting) and hwdec=no to avoid GPU/GL calls -/// that fail on these drivers. -pub fn software_fallback_options() -> Vec<(&'static str, &'static str)> { - vec![ - ("vo", "x11"), - ("hwdec", "no"), - ("ao", "auto"), - ("cache", "yes"), - ("demuxer-max-bytes", "150MiB"), - ("demuxer-max-back-bytes", "75MiB"), - ("keep-open", "yes"), - ("terminal", "yes"), - ("msg-level", "all=status"), - ] -} +// Silence the `Weak` import warning if refactors ever remove the callback +// path (kept so the weak-ref intent is obvious at the import site). +const _: fn() = || { + let _: Option> = None; +}; diff --git a/crates/tauri-plugin-mpv/src/mpv.rs b/crates/tauri-plugin-mpv/src/mpv.rs index cb8f3c7..3e433df 100644 --- a/crates/tauri-plugin-mpv/src/mpv.rs +++ b/crates/tauri-plugin-mpv/src/mpv.rs @@ -14,7 +14,7 @@ use std::sync::{ use crate::macos::{embedded_options, fallback_options, MacosGlRenderer}; #[cfg(target_os = "linux")] -use crate::linux::{embedded_options as linux_embedded_options, fallback_options as linux_fallback_options, software_fallback_options as linux_software_fallback_options, LinuxGlRenderer}; +use crate::linux::{embedded_options as linux_embedded_options, fallback_options as linux_fallback_options, LinuxGlRenderer}; pub struct MpvState { inner: Mutex, @@ -186,17 +186,9 @@ impl MpvState { } #[cfg(target_os = "linux")] - fn launch_fallback_impl(&self, url: &str, reason: &str) -> Result<(), String> { - // If the GPU is blocklisted, vo=gpu will also crash. Use software-only output. - let gpu_blocklisted = reason.contains("blocklisted"); - let opts = if gpu_blocklisted { - tracing::info!("[MPV] GPU blocklisted - using software video output (vo=x11, hwdec=no)"); - linux_software_fallback_options() - } else { - linux_fallback_options() - }; + fn launch_fallback_impl(&self, url: &str, _reason: &str) -> Result<(), String> { let mut engine = self.inner.lock().map_err(|e| e.to_string())?; - engine.create(&opts)?; + engine.create(&linux_fallback_options())?; engine.loadfile(url)?; engine.set_current_url(url); Ok(()) diff --git a/crates/tauri-plugin-mpv/src/renderer.rs b/crates/tauri-plugin-mpv/src/renderer.rs index 0805822..1a44485 100644 --- a/crates/tauri-plugin-mpv/src/renderer.rs +++ b/crates/tauri-plugin-mpv/src/renderer.rs @@ -29,6 +29,12 @@ pub trait PlatformRenderer: Send + Sync { /// Show or hide the video surface without stopping playback. fn set_visible(&mut self, visible: bool); + /// Register a one-shot callback fired when the first rendered frame is + /// presented. Used by the frontend to make the WKWebView/WebKit layer + /// transparent only after pixels arrive. Default no-op for platforms that + /// set their callback on the concrete type prior to boxing. + fn set_first_frame_callback(&mut self, _cb: Box) {} + /// Tear down the surface. Must be idempotent. fn detach(&mut self); } diff --git a/scripts/bundle-libmpv-linux.sh b/scripts/bundle-libmpv-linux.sh index da1b30d..c06e05d 100755 --- a/scripts/bundle-libmpv-linux.sh +++ b/scripts/bundle-libmpv-linux.sh @@ -62,13 +62,24 @@ ln -sf libmpv.so "$BUNDLE_DIR/libmpv.so.2" ln -sf libmpv.so "$BUNDLE_DIR/libmpv.so.1" # System libraries that must NOT be bundled — they are always present on the -# target system and bundling them causes ABI conflicts. +# target system and bundling them causes ABI conflicts. In particular, GPU +# drivers (Mesa/VA-API/VDPAU) and their LLVM backend must always come from the +# system: they dlopen kernel modules and must match the host kernel ABI. Mixing +# bundled GLib/libffi with system Mesa corrupts internal GL dispatch tables. SYSTEM_LIBS_RE="linux-vdso|ld-linux|libc\.so|libm\.so|libdl\.so|libpthread" SYSTEM_LIBS_RE+="|librt\.so|libstdc\+\+|libgcc_s|libresolv|libnss_" SYSTEM_LIBS_RE+="|libX[a-z]|libxcb|libwayland|libdrm|libgbm" -SYSTEM_LIBS_RE+="|libGL\.so|libEGL\.so|libGLX|libGLdispatch" +# GL stack — always use system Mesa/vendor driver, never bundle. +SYSTEM_LIBS_RE+="|libGL\.so|libEGL\.so|libGLX|libGLdispatch|libOpenGL" +# GTK stack and its transitive deps — must match the system WebKit/GTK. SYSTEM_LIBS_RE+="|libgtk|libgdk|libglib|libgobject|libgio|libpango|libcairo|libatk" -SYSTEM_LIBS_RE+="|libdbus|libsystemd|libfontconfig|libfreetype" +SYSTEM_LIBS_RE+="|libffi|libpcre|libharfbuzz|libfribidi|libgraphite" +# Hardware video acceleration — always from the system to match GPU driver. +SYSTEM_LIBS_RE+="|libva|libvdpau|libnvidia|libcuda" +# LLVM backend used by Mesa shader compilers — must match Mesa version. +SYSTEM_LIBS_RE+="|libLLVM|libclang" +# Core system services. +SYSTEM_LIBS_RE+="|libdbus|libsystemd|libfontconfig|libfreetype|libudev" # bundle_all_deps: recursively resolve and copy dependencies to a fixpoint. # Uses the bundle dir itself as the visited set — if a .so already exists @@ -131,5 +142,17 @@ if [[ $AUDIO_LIBS -eq 0 ]]; then echo " Install audio dev packages and rebuild libmpv: sudo apt-get install libpulse-dev libasound2-dev" fi +# CI audit: list every bundled library so regressions in SYSTEM_LIBS_RE are +# visible in build logs. A GL/GTK lib showing up here would indicate the filter +# regressed and will cause ABI conflicts at runtime. +echo "" +echo "==> Bundled libraries (full inventory for CI audit):" +for so in "$BUNDLE_DIR"/*.so*; do + if [[ -f "$so" && ! -L "$so" ]]; then + size=$(stat -c%s "$so" 2>/dev/null || stat -f%z "$so" 2>/dev/null || echo "?") + printf " %-50s %s bytes\n" "$(basename "$so")" "$size" + fi +done + echo "" echo " Done. Bundled libs placed alongside binary in $BUNDLE_DIR" From 0955e99f243764fc60c07e8c47ce080aa4362ed1 Mon Sep 17 00:00:00 2001 From: Max Boksem <15022344+MaxMB15@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:26:28 +0200 Subject: [PATCH 02/23] fix(linux): resolve PlatformRenderer Send+Sync bounds and disjoint-capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit compiled only the macOS cfg path; the Linux-only renderer had four real bugs that tripped the trait bounds of PlatformRenderer (Send + Sync) and run_on_glib_main (F: Send, T: Send): 1. LinuxGlRenderer held Option>, which is Send but not Sync. PlatformRenderer requires Sync, so wrap the field in a Mutex (Mutex: Sync when T: Send) — the only access paths already lock when they go through Inner, and the outer field is only touched from the command thread before the renderer becomes shared. 2. run_on_glib_main returned Result, but RenderContext is !Send (holds *mut mpv_render_context and a non-Send update callback). Restructure attach() to build Arc entirely on the GLib main thread and return that instead — Arc is Send through the existing unsafe impl Send for Inner, so no new unsafe is introduced. 3. The closure passed to run_on_glib_main captured mpv.ctx.as_ptr() directly as *mut mpv_handle, which is !Send and would fail F: Send. Wrap it in a private newtype MpvHandlePtr(*mut c_void) with an unsafe impl Send — this is a thin FFI-boundary assertion in the same spirit as OwnedEgl/ WaylandSession/RawPtrs (the caller holds the MpvEngine mutex across the dispatch and the pointer is only dereferenced on the main thread). 4. move || build_wayland(raw.0, raw.1) triggered 2021-edition disjoint capture and captured two bare *mut c_void fields instead of the Send RawPtrs. Destructure RawPtrs inside the closure so the whole struct is moved. No new unsafe surface beyond the structural FFI-boundary assertions already in the file. Confirmed cargo check passes on macOS; Linux dev build needs re-verification by the user. Co-Authored-By: Claude Opus 4.6 --- .../components/channels/CategoryManager.tsx | 8 +- .../components/channels/PinnedGroupsRow.tsx | 5 +- .../components/channels/RecentlyPlayedRow.tsx | 9 +- apps/desktop/src/hooks/useMpv.ts | 6 +- apps/desktop/src/hooks/useUpdateChecker.ts | 4 +- crates/tauri-plugin-mpv/src/linux.rs | 139 +++++++++++------- 6 files changed, 102 insertions(+), 69 deletions(-) diff --git a/apps/desktop/src/components/channels/CategoryManager.tsx b/apps/desktop/src/components/channels/CategoryManager.tsx index 9cdc71f..a853833 100644 --- a/apps/desktop/src/components/channels/CategoryManager.tsx +++ b/apps/desktop/src/components/channels/CategoryManager.tsx @@ -234,7 +234,13 @@ export const CategoryManager = ({ .map((e) => e.sortOrder); const newSortOrder = siblingSortOrders.length > 0 ? Math.max(...siblingSortOrders) + 1 : 0; - await updateGroupHierarchyEntry(providerId, contentType, groupName, targetCategory, newSortOrder); + await updateGroupHierarchyEntry( + providerId, + contentType, + groupName, + targetCategory, + newSortOrder + ); await load(); onHierarchyChanged(); setMovingGroup(null); diff --git a/apps/desktop/src/components/channels/PinnedGroupsRow.tsx b/apps/desktop/src/components/channels/PinnedGroupsRow.tsx index 6218676..ed86d2e 100644 --- a/apps/desktop/src/components/channels/PinnedGroupsRow.tsx +++ b/apps/desktop/src/components/channels/PinnedGroupsRow.tsx @@ -30,10 +30,7 @@ export const PinnedGroupsRow = ({
{pinnedGroups.map((pin) => ( -
+
-