Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions linux/packaging/install-deps.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
43 changes: 41 additions & 2 deletions linux/ui-tauri/src-tauri/src/mirror.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<MirrorHandle>> = 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<Option<tokio::task::JoinHandle<()>>> =
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.
Expand Down Expand Up @@ -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::<Vec<u8>>(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);
Expand Down Expand Up @@ -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();
Expand Down