From 2a9a3d1eb7ec00e79e23bb74a250e3ac72bb6fc0 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Mon, 10 Aug 2026 18:28:11 +0200 Subject: [PATCH] fix(mirror): install gtksink on Arch; cancel stale video receivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent faults, both of which made screen mirroring look like it had frozen while actually reporting the reason only to the log. 1. `gtksink` was never installed on Arch. Every mirror/second-screen/ camera pipeline ends in it (see ui-tauri `mirror.rs`), but Arch splits the GTK sink OUT of gst-plugins-good into `gst-plugin-gtk`, which the dependency list never asked for. The pipeline then fails to parse at all ("gst parse: no element gtksink"), the window opens on its spinner, and no picture ever arrives — with nothing in the UI to say why. Added the package, plus a post-install check that inspects for `gtksink` and prints the install command for the detected distro when it is missing. Only the Arch name is a hard dependency; the Debian/Fedora names are printed as hints rather than installed, because an unresolvable name is all-or-nothing fatal on apt and pacman (see the NODE_PKGS note above for the same trap). 2. The TCP video receiver outlived its session. It was spawned with its `JoinHandle` dropped, so nothing could cancel it, and it retries the connect for as long as the phone's video server stays closed — which is exactly while the MediaProjection consent dialog is up. A second Start (double click, or the address-retry pass) therefore left the first receiver still looping; both then connected the instant the phone opened its server, each holding a media key derived from its OWN session's IK handshake hash. The phone encrypts for the session it honoured — it debounces the duplicate START, see Android `VortexStack.kt` — so the other receiver failed to open frame 0 ("AEAD open failed counter=0"), closed the socket, and took the whole stream down with it. The receiver is now tracked in `VIDEO_RX_TASK` and aborted from `stop_mirror` and on each new spawn. Fault 2's phone-side debounce already hinted at the duplicate sessions; this fixes the laptop side that creates them. --- linux/packaging/install-deps.sh | 29 +++++++++++++++++ linux/ui-tauri/src-tauri/src/mirror.rs | 43 ++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/linux/packaging/install-deps.sh b/linux/packaging/install-deps.sh index 4d2bdfe..fcde295 100755 --- a/linux/packaging/install-deps.sh +++ b/linux/packaging/install-deps.sh @@ -92,7 +92,12 @@ case "$PM" in BUILD=(base-devel pkgconf curl webkit2gtk-4.1 gtk3 libayatana-appindicator librsvg dbus openssl protobuf gst-plugins-base-libs) + # gst-plugin-gtk carries `gtksink`, which the screen-mirror pipeline ends in + # (see ui-tauri mirror.rs). Arch split it OUT of gst-plugins-good, so + # installing the latter is not enough — without this the mirror window opens + # and spins forever on "gst parse: no element gtksink". RUN=(gst-plugins-base gst-plugins-good gst-plugins-bad gst-libav + gst-plugin-gtk libpulse networkmanager zenity android-tools nautilus-python python-pillow) NODE_PKGS=(nodejs npm) @@ -159,6 +164,30 @@ set -e [ "$PM_RC" -eq 0 ] && ok "System packages installed." \ || warn "Package manager exited $PM_RC — some names may differ on your distro; check the log above." +# ── verify gtksink ──────────────────────────────────────────────────────────── +# The screen-mirror / second-screen / continuity-camera pipelines all end in +# `gtksink`. Which package ships it varies (Arch splits it out of +# gst-plugins-good entirely), and when it's absent the failure is invisible: +# the mirror window opens, spins on "Connecting…" forever, and the real reason +# ("gst parse: no element gtksink") only appears in the log. Check it here +# instead of shipping a guess for every distro. +if command -v gst-inspect-1.0 >/dev/null 2>&1; then + if gst-inspect-1.0 gtksink >/dev/null 2>&1; then + ok "GStreamer gtksink present (screen mirroring can render)." + else + warn "GStreamer 'gtksink' is MISSING — the screen features will open a window that never shows a picture." + case "$PM" in + pacman) warn " Install it with: sudo pacman -S --needed gst-plugin-gtk" ;; + apt-get) warn " Try: sudo apt-get install gstreamer1.0-gtk3" ;; + dnf) warn " Try: sudo dnf install gstreamer1-plugins-good-gtk" ;; + *) warn " Install your distro's GStreamer GTK sink plugin (provides libgstgtk.so)." ;; + esac + warn " Everything else (notifications, clipboard, calls, files) works without it." + fi +else + warn "gst-inspect-1.0 not found — cannot verify 'gtksink'; screen mirroring may not render." +fi + # ── Node + npm (separate & conditional) ─────────────────────────────────────── # Only touch node when it's genuinely missing — if a working node+npm already # exists (NodeSource, nvm, distro, …) we leave it alone, which is exactly what diff --git a/linux/ui-tauri/src-tauri/src/mirror.rs b/linux/ui-tauri/src-tauri/src/mirror.rs index d2b975e..cf65403 100644 --- a/linux/ui-tauri/src-tauri/src/mirror.rs +++ b/linux/ui-tauri/src-tauri/src/mirror.rs @@ -33,6 +33,40 @@ use vortex_l3_daemon::core::{mirror_tcp, mirror_udp}; /// loop pushes into `input_tx`; `stop_mirror` takes + closes it. static MIRROR_HANDLE: std::sync::Mutex> = std::sync::Mutex::new(None); +/// The live TCP video receiver task. Held so it can be ABORTED on teardown. +/// +/// Without this it outlives its session: the receiver retries the connect for +/// as long as the phone's video server is closed (it only opens after the user +/// taps "Start now" on the consent dialog), so a second Start — a double click, +/// or the address-retry pass — leaves the first receiver still looping. Both +/// then connect the instant the phone's server opens, each holding a media key +/// derived from its OWN session's IK handshake hash. The phone encrypts for the +/// session it honoured (it debounces the duplicate START, see Android +/// `VortexStack.kt`), so the other receiver fails to open frame 0 +/// ("AEAD open failed counter=0"), closes the socket, and takes the whole +/// stream down with it — leaving the window spinning on a mirror that had in +/// fact connected. +static VIDEO_RX_TASK: std::sync::Mutex>> = + std::sync::Mutex::new(None); + +/// Abort any previous video receiver and remember the new one. +fn set_video_rx_task(task: tokio::task::JoinHandle<()>) { + if let Ok(mut g) = VIDEO_RX_TASK.lock() { + if let Some(prev) = g.take() { + prev.abort(); + } + *g = Some(task); + } +} + +/// Abort the live video receiver, if any. +fn abort_video_rx_task() { + let prev = VIDEO_RX_TASK.lock().ok().and_then(|mut g| g.take()); + if let Some(t) = prev { + t.abort(); + } +} + /// The live GStreamer pipeline. Held so a UI "Stop sharing" (and `cleanup`) can /// set it to Null without relying on the bus EOS cascade. It must reach Null /// BEFORE the mirror window goes away — the sink is drawing into that window. @@ -881,12 +915,14 @@ pub async fn spawn_mirror( // needed (the laptop is the connecting side; its firewall allows that). let key = mirror_udp::derive_media_key(&handle.handshake_hash); let (au_tx, au_rx) = mpsc::channel::>(8); - tokio::spawn(mirror_tcp::run_tcp_video_receiver( + // Tracked + cancellable: a receiver from a previous session would otherwise + // still be retrying its connect with a stale media key. See VIDEO_RX_TASK. + set_video_rx_task(tokio::spawn(mirror_tcp::run_tcp_video_receiver( phone_addr.ip(), key, au_tx, Some(handle.keyframe_tx.clone()), - )); + ))); let backend = detect_decoder_backend(); start_player(app, backend, width, height, au_rx); @@ -922,6 +958,9 @@ pub async fn stop_mirror() { } POINTER_DOWN.store(false, Ordering::Relaxed); SCROLL_ACTIVE.store(false, Ordering::Relaxed); + // Kill the video receiver before the decoder: it must not outlive this + // session and reconnect later with a now-stale media key. + abort_video_rx_task(); // Decoder first, then the window it renders into. stop_pipeline(); crate::mirror_window::close();