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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand Down
24 changes: 24 additions & 0 deletions android/app/src/main/java/com/vortex/a3/service/VortexStack.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions linux/daemon/src/core/appstate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<LaptopCast>,
/// 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<String>,
/// 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`].
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 10 additions & 3 deletions linux/daemon/src/core/mirror_tcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions linux/ui-tauri/src-tauri/src/ble.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions linux/ui-tauri/src-tauri/src/lan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading