From 5ad5cc6b4ebd27d43a42f2953c240735d82bce52 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Tue, 11 Aug 2026 12:34:28 +0200 Subject: [PATCH 1/4] docs(mirror): LAPTOP_VIDEO_PORT is served by the PHONE, not the laptop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed "the LAPTOP serves its own screen on [this port]; the phone connects out to it". It is the other way round: `laptop_cast` dials the phone on 51823, exactly as the phone→laptop path dials 51822. A live socket says so plainly — ESTAB 192.168.0.119:43574 → 192.168.0.79:51823 (vortex-ui-tauri) (and no listener on 51823 anywhere on the laptop) This cost real time: the wording led to "the phone must connect inbound, so open 51823 in the firewall", and a `ufw allow` rule was added chasing a laptop→phone mirror failure that had nothing to do with the firewall. The actual property is the opposite and worth stating outright: every video path is dialled OUTWARD from the laptop, so Vortex needs no inbound rule for any of them — which the module header already says for VIDEO_PORT. --- linux/daemon/src/core/mirror_tcp.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/linux/daemon/src/core/mirror_tcp.rs b/linux/daemon/src/core/mirror_tcp.rs index a55f692..9ce22d2 100644 --- a/linux/daemon/src/core/mirror_tcp.rs +++ b/linux/daemon/src/core/mirror_tcp.rs @@ -32,9 +32,16 @@ use tokio::sync::mpsc; /// MUST match the Android `ScreenMirrorService` video server port. pub const VIDEO_PORT: u16 = 51822; -/// Fixed TCP port the LAPTOP serves its own screen on (laptop→phone mirror, the -/// mirror image of [`VIDEO_PORT`]); the phone connects out to it. MUST match the -/// Android `LaptopMirrorClient` port. +/// Fixed TCP port the PHONE serves its laptop-screen viewer on (laptop→phone +/// mirror); the laptop connects out to it, exactly as for [`VIDEO_PORT`]. MUST +/// match the Android `LaptopMirrorClient` port. +/// +/// The name is historical and reads backwards: it is the port used *for* the +/// laptop's screen, not a port the laptop listens on. Every video path here is +/// dialled outward from the laptop — see the module note above — so Vortex needs +/// no inbound firewall rule for any of them. (Said plainly because the previous +/// wording claimed the laptop served this port, which sent one debugging session +/// off after a firewall that was never involved.) pub const LAPTOP_VIDEO_PORT: u16 = 51823; /// Fixed TCP port the phone serves its CAMERA on (phone-as-webcam); the laptop From 2c1243de09d7cc3b220cec63d34e206a0ff51e44 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Tue, 11 Aug 2026 13:49:47 +0200 Subject: [PATCH 2/4] feat(cast): extended display works off GNOME via the portal's Virtual source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Extended display" called `org.gnome.Mutter.ScreenCast` directly, so on any other compositor it died instantly with CreateSession: org.freedesktop.DBus.Error.ServiceUnknown: The name is not activatable and — because the phone re-asserts `laptop_mirror_req` on every heartbeat — retried every 4 minutes indefinitely with nothing on either screen to say why. On a KDE session the feature was simply unreachable. The ScreenCast portal's `SourceType::Virtual` is the cross-desktop equivalent of Mutter's `CreateVirtualMonitor`, and KWin implements it (its portal reports MONITOR|WINDOW|VIRTUAL in AvailableSourceTypes). So try Mutter first — it stays the tuned path on GNOME, and it carries its own cursor overlay because Mutter will not composite a pointer into a virtual monitor — then fall back to the portal. Verified on Plasma 6.7.4 / KWin 6.7.4: the portal creates a real output, Plasma prompts for its placement, and it lands in the display layout as a draggable target — Output: 1 Virtual-virtual-xdp-kde-… Geometry: 2058,0 1920x1080 Output: 2 eDP-1 Geometry: 0,0 2058x1286 laptop-cast: portal stream ready node_id=144 size=Some((1920, 1080)) laptop-cast: connected to phone viewer — streaming The portal body is split into `start_portal(.., source)` so mirror (`Monitor`) and extend (`Virtual`) share it; everything downstream — encode, seal, transport, the phone's viewer — was already identical. Caveat found while testing, not caused by this change: xdg-desktop-portal-kde 6.7.4 can ABORT on a Virtual request ("Object destroyed while one of its QML signal handlers is in progress", SIGABRT with a coredump), which kills the session and leaves the request hanging. Restarting the portal service clears it. Worth reporting upstream. --- linux/ui-tauri/src-tauri/src/laptop_cast.rs | 70 +++++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/linux/ui-tauri/src-tauri/src/laptop_cast.rs b/linux/ui-tauri/src-tauri/src/laptop_cast.rs index 49d6799..289cb34 100644 --- a/linux/ui-tauri/src-tauri/src/laptop_cast.rs +++ b/linux/ui-tauri/src-tauri/src/laptop_cast.rs @@ -43,6 +43,27 @@ static CAST: Mutex> = Mutex::new(None); /// AppState, so the phone knows where to dial. `None` when not casting. static CAST_OFFER: Mutex> = Mutex::new(None); +/// Why the last cast attempt failed, for the phone to show and act on. +/// +/// Without this the phone cannot tell "starting…" from "never going to work": +/// it re-asserts `laptop_mirror_req` on every heartbeat, we fail again, and the +/// only trace is a WARN in a log the user is not reading. Its request stays +/// latched, `requestView` early-returns on `requestActive`, and further taps do +/// nothing — the UI wedges until the app is force-stopped. The laptop already +/// knows the exact reason and has a sealed channel to say it on, so it does. +static CAST_ERROR: Mutex> = Mutex::new(None); + +/// The failure reason to ship in the next AppState push, if any. +pub(crate) fn current_error() -> Option { + CAST_ERROR.lock().ok().and_then(|g| g.clone()) +} + +fn set_error(msg: Option) { + if let Ok(mut g) = CAST_ERROR.lock() { + *g = msg; + } +} + /// Edge-tracker for the phone's `laptop_mirror_req` level: we act only on the /// false→true (start) and true→false (stop) transitions, ignoring the repeats /// that arrive on every heartbeat. @@ -120,12 +141,16 @@ pub fn dispatch_request(req: bool, extend: Option) { key: hex::encode(key), // key material — logged nowhere }); } + // A fresh attempt is not the previous attempt's failure: clear the + // reason so the phone isn't shown a stale one while this one runs. + set_error(None); tokio::spawn(async move { if let Err(e) = start(phone_ip, key, extend).await { tracing::warn!("laptop-cast: start failed: {e}"); if let Ok(mut g) = CAST_OFFER.lock() { *g = None; } + set_error(Some(e)); REQ_WANTED.store(false, Ordering::SeqCst); } }); @@ -136,6 +161,10 @@ pub fn dispatch_request(req: bool, extend: Option) { if let Ok(mut g) = CAST_OFFER.lock() { *g = None; } + // The phone has stopped asking, so it has either seen the reason or no + // longer cares. Keeping it would re-report an old failure against the + // next request the moment it is made. + set_error(None); } } @@ -156,14 +185,45 @@ pub async fn start( stop(); // Extend mode swaps the SOURCE, nothing else: instead of a view of a screen - // that already exists, we ask Mutter for a brand-new monitor and capture - // that. Everything downstream — encode, seal, transport, the phone's viewer - // — is identical, which is the whole reason this fits here rather than in a + // that already exists we ask for a brand-new monitor and capture that. + // Everything downstream — encode, seal, transport, the phone's viewer — is + // identical, which is the whole reason this fits here rather than in a // module of its own. if extend.unwrap_or_else(extend_enabled) { - return start_extend(phone_ip, key).await; + // Mutter first: it is the tuned path (and it rides its own cursor + // overlay, because Mutter will not composite a pointer into a virtual + // monitor). But `org.gnome.Mutter.ScreenCast` is GNOME's private API, so + // on any other compositor it fails instantly with ServiceUnknown — and + // the ScreenCast portal's `Virtual` source is the cross-desktop + // equivalent, which KWin implements (its portal advertises it in + // AvailableSourceTypes). Falling back keeps "second screen" working off + // GNOME instead of failing with nothing on screen to say why. + match start_extend(phone_ip, key).await { + Ok(()) => return Ok(()), + Err(e) => { + tracing::warn!( + "laptop-cast: Mutter virtual monitor unavailable ({e}); \ + trying the ScreenCast portal's Virtual source instead" + ); + return start_portal(phone_ip, key, SourceType::Virtual).await; + } + } } + start_portal(phone_ip, key, SourceType::Monitor).await +} +/// Capture `source` through the ScreenCast portal and serve it sealed on +/// [`mirror_tcp::LAPTOP_VIDEO_PORT`]. +/// +/// `SourceType::Monitor` is a view of a screen that already exists (mirror); +/// `SourceType::Virtual` asks the compositor to materialise a NEW one (extend). +/// Everything after the source selection is identical, which is why both kinds +/// share this body. +async fn start_portal( + phone_ip: std::net::IpAddr, + key: [u8; 32], + source: SourceType, +) -> Result<(), String> { // ---- Portal: open a ScreenCast session and get the PipeWire node + fd. ---- let proxy = Screencast::new() .await @@ -176,7 +236,7 @@ pub async fn start( .select_sources( &session, CursorMode::Embedded, - SourceType::Monitor.into(), + source.into(), false, None, PersistMode::DoNot, From 360547b301775e003541cb8e76f4851a776b2ef2 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Tue, 11 Aug 2026 13:53:17 +0200 Subject: [PATCH 3/4] fix(cast): tell the phone when the laptop can't cast, and time out silent requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cast the laptop could not start was invisible and unrecoverable. The phone re-asserted `laptop_mirror_req` on every heartbeat, the laptop failed again and logged a WARN nobody reads, and nothing ever told the phone. Its UI then wedged, because: fun requestView(extend: Boolean) { if (requestActive) return // every further tap is a no-op } fun onLaptopCastEnded() { if (!viewerOpen) return // give-up needs a viewer to exist if (++castMisses < MISS_LIMIT) return } `onLaptopCastEnded` only ever protected an already-open viewer, so a request that never produced an offer had NO timeout at all: `castMisses` was not even incremented and `requestActive` stayed true forever. Observed in practice as half an hour of 4-minute retries against "Extended display" on a KDE session, with the phone showing nothing and taps doing nothing — recoverable only by force-stopping the app. Laptop: `laptop_cast_error` rides AppState next to `laptop_cast`, set when a start fails, cleared when a fresh attempt begins and on the falling edge so a stale reason is never reported against a new request. Optional + skipped when absent, so peers on either side that predate it are unaffected. Phone: an explicit reason clears the request, invokes `onCastFailed` (toast) and closes any viewer; and `onLaptopCastSilent` gives up after SILENT_LIMIT=10 heartbeats when there is neither an offer nor a reason — covering a laptop that never answers at all, or one too old to send one. SILENT_LIMIT is deliberately above MISS_LIMIT: consent dialog, portal session and encoder startup are legitimately slow, and giving up while the user is still reading the consent prompt would be worse than waiting. The toast is English only. The app localizes via `ui/Strings.kt`, whose `str()` is @Composable and so unusable from a service, and a service has no locale to pick with — noted in a comment rather than papered over. --- .../com/vortex/a3/core/appstate/AppState.kt | 8 ++++ .../com/vortex/a3/core/mirror/LaptopMirror.kt | 48 +++++++++++++++++++ .../java/com/vortex/a3/service/VortexStack.kt | 24 ++++++++++ .../vortex/a3/service/VortexStackAppState.kt | 9 ++++ linux/daemon/src/core/appstate.rs | 11 +++++ linux/ui-tauri/src-tauri/src/ble.rs | 1 + linux/ui-tauri/src-tauri/src/lan.rs | 1 + 7 files changed, 102 insertions(+) diff --git a/android/app/src/main/java/com/vortex/a3/core/appstate/AppState.kt b/android/app/src/main/java/com/vortex/a3/core/appstate/AppState.kt index baaa682..790e6b5 100644 --- a/android/app/src/main/java/com/vortex/a3/core/appstate/AppState.kt +++ b/android/app/src/main/java/com/vortex/a3/core/appstate/AppState.kt @@ -157,6 +157,12 @@ data class AppState( * viewer dials. null = not casting. Carried under the Noise-sealed * transport; the key is never logged. */ val laptopCast: LaptopCast? = null, + /** Laptop→phone: why the laptop's last cast attempt failed; null when it + * didn't. Lets us stop asking and tell the user, instead of re-sending a + * request the laptop cannot satisfy on every heartbeat forever — which + * wedged the UI, since [LaptopMirror.requestView] ignores taps while a + * request is already active. Absent from older laptops, hence nullable. */ + val laptopCastError: String? = null, /** Laptop→phone: true while the laptop wants the phone's camera as a webcam * (Continuity Camera). The phone starts its camera on the false→true edge. */ val cameraReq: Boolean = false, @@ -338,6 +344,8 @@ data class AppState( lockCommandSeq = obj.optLong("lock_command_seq", 0L), laptopMirrorReq = obj.optBoolean("laptop_mirror_req", false), laptopMirrorExtend = obj.optBoolean("laptop_mirror_extend", false), + laptopCastError = obj.optString("laptop_cast_error", "") + .takeIf { it.isNotBlank() }, laptopCast = obj.optJSONObject("laptop_cast")?.let { c -> // ip is unused (the laptop dials us — we're the server); only // port + key matter. diff --git a/android/app/src/main/java/com/vortex/a3/core/mirror/LaptopMirror.kt b/android/app/src/main/java/com/vortex/a3/core/mirror/LaptopMirror.kt index d284aaf..46d1d0a 100644 --- a/android/app/src/main/java/com/vortex/a3/core/mirror/LaptopMirror.kt +++ b/android/app/src/main/java/com/vortex/a3/core/mirror/LaptopMirror.kt @@ -30,6 +30,13 @@ object LaptopMirror { * confirmed "not casting" — well past any start-up race). */ private const val MISS_LIMIT = 5 + /** Heartbeats to wait for an offer before giving up on a request that has + * produced neither a cast nor an error. Higher than [MISS_LIMIT]: the + * laptop legitimately takes a moment here (screen-share consent, portal + * session, encoder start), and giving up while the user is still reading + * the consent dialog would be worse than waiting. */ + private const val SILENT_LIMIT = 10 + /** True while the user wants to see the laptop screen. Read by the AppState * builder → `laptopMirrorReq`; the laptop casts only while it's set. */ @Volatile @@ -110,6 +117,47 @@ object LaptopMirror { onRequestChanged?.invoke() } + /** The laptop reported that it CANNOT cast (AppState `laptop_cast_error`). + * + * Clears the request, so we stop re-asserting something the laptop will + * keep failing, and so [requestView] stops early-returning — its + * `requestActive` guard is what made every further tap a no-op. Also hands + * the reason to the UI: previously the user tapped, nothing happened, and + * the explanation existed only in the laptop's log. + * + * Idempotent: the laptop re-sends the same reason on every heartbeat until + * it sees us stop asking, so only the first one does anything. */ + fun onLaptopCastFailed(reason: String) { + if (!requestActive) return + requestActive = false + castMisses = 0 + Log.w(TAG, "laptop cannot cast: $reason → request cleared") + onCastFailed?.invoke(reason) + viewerCloser?.invoke() // no-op when no viewer is up + onRequestChanged?.invoke() // tell the laptop at once, don't wait a heartbeat + } + + /** Set by the UI to surface a cast failure (toast/dialog). */ + @Volatile + var onCastFailed: ((String) -> Unit)? = null + + /** A request that produced NO offer and NO error — the laptop never answered + * at all (out of range, killed mid-request, an older build with no + * `laptop_cast_error`). Give up after several heartbeats. + * + * [onLaptopCastEnded] cannot cover this: it returns early unless a viewer is + * already open, so a request that never got as far as a viewer had no + * timeout whatsoever and stayed latched indefinitely. */ + fun onLaptopCastSilent() { + if (!requestActive || viewerOpen) return + if (++castMisses < SILENT_LIMIT) return + castMisses = 0 + requestActive = false + Log.w(TAG, "no cast offer after $SILENT_LIMIT heartbeats → request cleared") + onCastFailed?.invoke("The laptop did not respond") + onRequestChanged?.invoke() + } + /** The LAPTOP stopped casting (its AppState `laptop_cast` cleared — user hit * "Stop sharing" on the laptop, or the capture errored). Tear the viewer * down on this side too so the screens stay in sync. No-op if no viewer. */ diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt index 6225c88..87353e3 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt @@ -198,6 +198,30 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { lanServer?.nudge() pushStateViaBle() } + // The laptop could not cast: say so. Without this the user taps, nothing + // appears, and the reason sits in a log on the other machine — which is + // exactly how "Extended display" failing on a non-GNOME desktop looked + // like the app doing nothing at all. + com.vortex.a3.core.mirror.LaptopMirror.onCastFailed = { reason -> + android.os.Handler(android.os.Looper.getMainLooper()).post { + try { + // English only: the app localizes through `ui/Strings.kt`, + // whose `str()` is @Composable and so unavailable here, and a + // service has no locale context to pick with. Worth moving + // into the UI layer if this message becomes prominent. + android.widget.Toast.makeText( + ctx, + "Can't show the laptop screen: $reason", + android.widget.Toast.LENGTH_LONG, + ).show() + } catch (t: Throwable) { + // A toast is best-effort (blocked in the background on some + // ROMs); the request is cleared either way, which is the part + // that matters — the UI is usable again. + Log.w(TAG, "cast-failure toast suppressed: ${t.message}") + } + } + } startLanServer(identity) // mDNS + TCP IK + AppState sync return true diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStackAppState.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackAppState.kt index 455a50c..1be56ae 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStackAppState.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStackAppState.kt @@ -167,13 +167,22 @@ internal fun VortexStack.handlePeerAppState(peerPub: ByteArray, state: com.vorte // offer DROPS (laptop hit "Stop sharing" / capture errored) close the viewer // so both screens stay in sync. val cast = state.laptopCast + val castError = state.laptopCastError if (cast != null) { val key = hexToBytes(cast.key) if (key != null && key.size == 32) { com.vortex.a3.core.mirror.LaptopMirror.onLaptopOffer(ctx, cast.port, key) } + } else if (castError != null) { + // The laptop tried and cannot: stop asking and say why. Checked BEFORE + // the silent path — an explicit reason beats waiting out a timeout. + com.vortex.a3.core.mirror.LaptopMirror.onLaptopCastFailed(castError) } else { + // No offer and no reason: either a viewer that just ended, or a request + // the laptop never answered. Both are handled, and each ignores the case + // that belongs to the other. com.vortex.a3.core.mirror.LaptopMirror.onLaptopCastEnded() + com.vortex.a3.core.mirror.LaptopMirror.onLaptopCastSilent() } // Continuity Camera: the laptop wants this phone's camera as a webcam. handleCameraRequest(state.cameraReq, state.cameraFacing) diff --git a/linux/daemon/src/core/appstate.rs b/linux/daemon/src/core/appstate.rs index 6b83700..c4bc99d 100644 --- a/linux/daemon/src/core/appstate.rs +++ b/linux/daemon/src/core/appstate.rs @@ -285,6 +285,15 @@ pub struct AppState { /// media key rides here under the Noise-sealed transport; never log it. #[serde(default, skip_serializing_if = "Option::is_none")] pub laptop_cast: Option, + /// Laptop→phone: why the last cast attempt failed, `None` when it didn't. + /// + /// Lets the phone stop asking and say something. Without it a request the + /// laptop cannot satisfy is re-asserted on every heartbeat forever, with the + /// reason only in the laptop's log — and since the phone's `requestView` + /// ignores a tap while a request is already active, its UI wedges. Optional + /// and skipped when absent, so older peers on both sides are unaffected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub laptop_cast_error: Option, /// Laptop→phone: `true` while the user wants to use the phone's camera as a /// laptop webcam (phone-as-webcam). A level — the phone starts its camera /// on the false→true edge and stops on true→false. See [`camera_offer`]. @@ -419,6 +428,7 @@ impl AppState { laptop_mirror_req: false, // laptop is the caster, never the requester laptop_mirror_extend: None, // ditto — the phone picks the kind laptop_cast: None, // filled while actively casting + laptop_cast_error: None, // set only when an attempt fails camera_req: false, // filled by the UI when webcam is wanted camera_facing: String::new(), // filled by the UI front/back toggle camera_offer: None, // laptop never offers a camera @@ -591,6 +601,7 @@ mod tests { laptop_mirror_req: false, laptop_mirror_extend: None, laptop_cast: None, + laptop_cast_error: None, camera_req: false, camera_facing: String::new(), camera_offer: None, diff --git a/linux/ui-tauri/src-tauri/src/ble.rs b/linux/ui-tauri/src-tauri/src/ble.rs index 28cbd42..6172199 100644 --- a/linux/ui-tauri/src-tauri/src/ble.rs +++ b/linux/ui-tauri/src-tauri/src/ble.rs @@ -953,6 +953,7 @@ pub(crate) async fn run_ble_persistent_loop( // Laptop→phone screen-cast offer (where to dial + key) while // we're casting; None otherwise. state.laptop_cast = crate::laptop_cast::current_offer(); + state.laptop_cast_error = crate::laptop_cast::current_error(); // Continuity Camera: ask the phone for its camera as a webcam. state.camera_req = crate::camera::camera_wanted(); state.camera_facing = crate::camera::camera_facing(); diff --git a/linux/ui-tauri/src-tauri/src/lan.rs b/linux/ui-tauri/src-tauri/src/lan.rs index f24cbbf..3043e4e 100644 --- a/linux/ui-tauri/src-tauri/src/lan.rs +++ b/linux/ui-tauri/src-tauri/src/lan.rs @@ -385,6 +385,7 @@ pub(crate) async fn try_lan_reconnect( local_state.locked = vortex_l3_daemon::core::session_lock::locked_hint().await; // Laptop→phone screen-cast offer (where to dial + the key) while casting. local_state.laptop_cast = crate::laptop_cast::current_offer(); + local_state.laptop_cast_error = crate::laptop_cast::current_error(); // Continuity Camera: request the phone's camera as a laptop webcam. local_state.camera_req = crate::camera::camera_wanted(); local_state.camera_facing = crate::camera::camera_facing(); From a3050b9d48308b7758474358bc0ebf5bb2387160 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Tue, 11 Aug 2026 14:34:17 +0200 Subject: [PATCH 4/4] fix(cast): stop the cast when the viewer dies, and CLOSE the portal session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cast whose viewer went away kept running, and for an extend cast that left a phantom 1920x1080 output in the desktop layout — a screen windows can be dragged into and lost. Reproduced by swiping the phone's viewer away (which kills it without its normal close path, so the phone never tells us to stop) and watching `kscreen-doctor` keep listing `Virtual-virtual-xdp-kde-…` at 2058,0 for as long as the app lived. Two independent causes, which is why it looked so stubborn: 1. Nothing acted on the transport giving up. `run_tcp_video_client` already bounds its reconnects (~60 s) and returns, but the pipeline and portal session live in a DIFFERENT task, so its give-up meant nothing: the capture ran on with no viewer. Both spawn sites now go through `spawn_video_sender`, which stops the cast and records the reason — the phone then learns why instead of sitting on a black screen. The `CAST.is_some()` guard keeps it off the normal stop path, where `stop()` has already taken the handle. 2. The session was dropped, never closed. The old comment claimed "dropping `fd`/`session` closes the PipeWire stream and the portal session" — but ashpd 0.9's `Session` has `close()` and NO `Drop` impl that calls it, so the session stayed open until our whole D-Bus connection went away. That is why killing the app cleared the output and nothing short of it did. Pipeline-to-Null stops the CAPTURE; the SOURCE stays allocated, and for `SourceType::Virtual` the source is an output. Teardown now awaits `session.close()` and warns if it fails. Verified on Plasma 6.7.4: viewer swiped away → output cleared in ~39 s, laptop-cast: phone viewer unreachable — stopping the cast laptop-cast: stopped (capture + portal session closed) and `kscreen-doctor` back to one output with no manual intervention. The ~39 s is the existing reconnect budget, deliberately left alone: a viewer legitimately drops and re-accepts across a network blip or an activity recreation, and killing the cast on the first broken pipe would trade a phantom monitor for a mirror that cannot survive a hiccup. --- linux/ui-tauri/src-tauri/src/laptop_cast.rs | 57 ++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/linux/ui-tauri/src-tauri/src/laptop_cast.rs b/linux/ui-tauri/src-tauri/src/laptop_cast.rs index 289cb34..4311be7 100644 --- a/linux/ui-tauri/src-tauri/src/laptop_cast.rs +++ b/linux/ui-tauri/src-tauri/src/laptop_cast.rs @@ -64,6 +64,38 @@ fn set_error(msg: Option) { } } +/// Push the sealed stream to the phone, and tear the cast down if that gives up. +/// +/// `run_tcp_video_client` returns either because `au_rx` closed (the normal stop +/// path) or because it exhausted its ~60 s of connect retries — the phone's +/// viewer never came up, or went away for good. The pipeline and the portal +/// session live in a DIFFERENT task, so nothing used to act on that: the capture +/// kept running with nobody watching, and for an extend cast KWin kept the +/// virtual output in the desktop layout — a phantom 1920x1080 screen with no +/// viewer behind it, which windows can be dragged into and lost. Observed +/// exactly that after a viewer was closed: `kscreen-doctor` still listed +/// `Virtual-virtual-xdp-kde-…` at 2058,0 until the whole app was killed. +/// +/// Give-up on the transport therefore has to mean give-up on the cast — which +/// also releases the compositor's output and, via `CAST_ERROR`, tells the phone +/// why instead of leaving it on a black screen. +fn spawn_video_sender( + phone_ip: std::net::IpAddr, + key: [u8; 32], + au_rx: mpsc::Receiver>, +) { + tokio::spawn(async move { + mirror_tcp::run_tcp_video_client(phone_ip, key, au_rx).await; + // On the normal stop path `stop()` has already taken CAST, so this is + // only reached with a live handle when the transport gave up by itself. + if CAST.lock().map(|g| g.is_some()).unwrap_or(false) { + tracing::warn!("laptop-cast: phone viewer unreachable — stopping the cast"); + set_error(Some("the phone's viewer stopped responding".to_string())); + stop(); + } + }); +} + /// Edge-tracker for the phone's `laptop_mirror_req` level: we act only on the /// false→true (start) and true→false (stop) transitions, ignoring the repeats /// that arrive on every heartbeat. @@ -311,7 +343,7 @@ async fn start_portal( ); // Push the sealed stream to the phone (we dial it — the phone is the server). - tokio::spawn(mirror_tcp::run_tcp_video_client(phone_ip, key, au_rx)); + spawn_video_sender(phone_ip, key, au_rx); pipeline .set_state(gst::State::Playing) @@ -327,9 +359,15 @@ async fn start_portal( } let bus = pipeline.bus(); tokio::spawn(async move { - // Keep these alive until teardown: dropping `fd`/`session` closes the - // PipeWire stream and the portal session; `proxy` backs the session. - let _keep = (proxy, session, fd); + // Keep these alive until teardown. `fd` closing does end the PipeWire + // stream, but the SESSION must be closed explicitly — ashpd 0.9's + // `Session` has `close()` and no `Drop` that calls it, so merely dropping + // it leaves the portal session open until our whole D-Bus connection goes + // away. For an extend cast that means the compositor keeps the virtual + // output: a phantom 1920x1080 screen stayed in the KDE display layout + // after the cast stopped, and only disappeared when the app was killed. + // See the explicit `close()` at the end of this task. + let _keep = (proxy, fd); let mut stop_rx = stop_rx; loop { // Drain any pending bus messages WITHOUT blocking the async runtime @@ -375,7 +413,14 @@ async fn start_portal( if let Ok(mut g) = CAST_OFFER.lock() { *g = None; } - tracing::info!("laptop-cast: stopped (capture + portal released)"); + // Hand the session back to the compositor. Pipeline-to-Null stops the + // capture but leaves the SOURCE allocated — for `SourceType::Virtual` + // that is a whole output still sitting in the user's display layout, + // which windows can be dragged into and lost. + if let Err(e) = session.close().await { + tracing::warn!("laptop-cast: portal session close failed: {e}"); + } + tracing::info!("laptop-cast: stopped (capture + portal session closed)"); }); Ok(()) @@ -492,7 +537,7 @@ async fn start_extend(phone_ip: std::net::IpAddr, key: [u8; 32]) -> Result<(), S .build(), ); - tokio::spawn(mirror_tcp::run_tcp_video_client(phone_ip, key, au_rx)); + spawn_video_sender(phone_ip, key, au_rx); pipeline .set_state(gst::State::Playing) .map_err(|e| format!("pipeline play: {e}"))?;