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/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 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(); diff --git a/linux/ui-tauri/src-tauri/src/laptop_cast.rs b/linux/ui-tauri/src-tauri/src/laptop_cast.rs index 49d6799..4311be7 100644 --- a/linux/ui-tauri/src-tauri/src/laptop_cast.rs +++ b/linux/ui-tauri/src-tauri/src/laptop_cast.rs @@ -43,6 +43,59 @@ 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; + } +} + +/// 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. @@ -120,12 +173,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 +193,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 +217,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 +268,7 @@ pub async fn start( .select_sources( &session, CursorMode::Embedded, - SourceType::Monitor.into(), + source.into(), false, None, PersistMode::DoNot, @@ -251,7 +343,7 @@ pub async fn start( ); // 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) @@ -267,9 +359,15 @@ pub async fn start( } 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 @@ -315,7 +413,14 @@ pub async fn start( 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(()) @@ -432,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}"))?;