diff --git a/README.md b/README.md index d7e8a7a..e3de9e4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ A **mobile companion** for [KDE Connect](https://kdeconnect.kde.org) / [Valent](https://valent.andyholmes.ca/) peers. +

+ Global on Waydroid — paired with desktop, live channel, Ping / Unpair +

+ +

Waydroid (x86_64) — egui + vidya shell, on-device KDE Connect peer

+ | Layer | Role | |--------|------| | **Gleam** (`global_app`, `dist_driver`) | Domain model + controller over Erlang distribution | diff --git a/dist-ui/src/egui_ui.rs b/dist-ui/src/egui_ui.rs index 71ef277..c9c2322 100644 --- a/dist-ui/src/egui_ui.rs +++ b/dist-ui/src/egui_ui.rs @@ -94,17 +94,17 @@ impl App for DistApp { } } - egui::TopBottomPanel::top("header") - .frame(th.header_frame()) - .show(ctx, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing = Vec2::new(th.spacing.md, th.spacing.sm); - vidya::title(ui, th, &self.title); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - vidya::dim_label(ui, th, &self.status); - }); + // Vidya owns system status/nav safe areas on Android (edge-to-edge). + // Prefer top_header so title/status cannot sit under the clock widgets. + vidya::top_header(ctx, th, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = Vec2::new(th.spacing.md, th.spacing.sm); + vidya::title(ui, th, &self.title); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + vidya::dim_label(ui, th, &self.status); }); }); + }); egui::CentralPanel::default() .frame(th.page_frame()) @@ -170,6 +170,13 @@ fn show_engine_panel( if !snap.activity.is_empty() { ui.add_space(th.spacing.xs); vidya::dim_label(ui, th, &format!("Activity: {}", snap.activity)); + // Keep status toast in sync with async ping results. + if snap.activity.starts_with("Ping ") + || snap.activity.starts_with("Sending ping") + || snap.activity.contains("channel") + { + *kc_msg = snap.activity.clone(); + } } ui.add_space(th.spacing.sm); @@ -200,16 +207,53 @@ fn show_engine_panel( } } - if !snap.pending_pairs.is_empty() { + // Incoming pair requests (and a linked-but-untrusted peer for one-tap Accept). + let mut accept_targets: Vec<(String, String)> = snap.pending_pairs.clone(); + if let (Some(id), Some(name)) = (&snap.linked_peer_id, &snap.linked_peer_name) { + let trusted = snap.trusted.iter().any(|(tid, _)| tid == id); + if !trusted && !accept_targets.iter().any(|(tid, _)| tid == id) { + accept_targets.push((id.clone(), name.clone())); + } + } + // Live unpaired peers not already listed. + for id in &snap.live_peers { + let trusted = snap.trusted.iter().any(|(tid, _)| tid == id); + if trusted || accept_targets.iter().any(|(tid, _)| tid == id) { + continue; + } + let name = snap + .nearby + .iter() + .find(|(nid, _, _)| nid == id) + .map(|(_, n, _)| n.clone()) + .or_else(|| snap.linked_peer_name.clone()) + .unwrap_or_else(|| id[..8.min(id.len())].to_string()); + accept_targets.push((id.clone(), name)); + } + + if !accept_targets.is_empty() { vidya::title(ui, th, "Pair requests"); - for (id, name) in &snap.pending_pairs { + vidya::dim_label( + ui, + th, + "Valent sent a pair request — Accept to finish pairing.", + ); + for (id, name) in &accept_targets { ui.horizontal(|ui| { vidya::body(ui, th, &format!("From {name}")); - if ui.button("Accept").clicked() { + if vidya::primary_button(ui, th, "Accept pair").clicked() { if let Some(eng) = engine::global_engine() { *kc_msg = match eng.accept_pair(id) { Ok(s) => s, - Err(e) => format!("Failed: {e}"), + Err(e) => format!("Accept failed: {e}"), + }; + } + } + if ui.button("Reject").clicked() { + if let Some(eng) = engine::global_engine() { + *kc_msg = match eng.unpair(id) { + Ok(s) => s, + Err(e) => format!("Reject failed: {e}"), }; } } @@ -220,17 +264,22 @@ fn show_engine_panel( vidya::title(ui, th, "Trusted peers"); if snap.trusted.is_empty() { - vidya::dim_label(ui, th, "None yet — use Request pairing in Valent."); + vidya::dim_label( + ui, + th, + "None yet — pair from Valent, then Accept pair above if needed.", + ); } else { for (id, name) in &snap.trusted { let live = snap.live_peers.iter().any(|p| p == id); ui.horizontal(|ui| { - let mark = if live { "●" } else { "○" }; + // Drawn circle — Unicode ●/○ becomes a box (missing glyph) on Android. + vidya::status_dot(ui, th, live); vidya::body( ui, th, &format!( - "{mark} {name} ({}){}", + "{name} ({}){}", &id[..8.min(id.len())], if live { " · live" } else { " · offline" } ), @@ -240,7 +289,9 @@ fn show_engine_panel( ui.add_enabled_ui(live, |ui| { if ui.button("Ping").clicked() { if let Some(eng) = engine::global_engine() { - *kc_msg = eng.ping_peer(id); + // Never block the UI/render thread on TLS (Android ANR). + *kc_msg = "Sending ping…".into(); + eng.ping_peer_async(id.clone()); } } }); diff --git a/dist-ui/src/kdeconnect/channel.rs b/dist-ui/src/kdeconnect/channel.rs index 54e189c..a030494 100644 --- a/dist-ui/src/kdeconnect/channel.rs +++ b/dist-ui/src/kdeconnect/channel.rs @@ -114,11 +114,12 @@ fn configure_stream(stream: &TcpStream) -> Result<(), String> { stream .set_nonblocking(false) .map_err(|e| e.to_string())?; - // No short read timeout: a 10s timeout made idle paired channels look - // "Disconnected" in Valent (EAGAIN/TimedOut treated as hangup). + // Session loop sets a short read timeout while polling. Keep default None so + // idle channels are not closed as TimedOut (Valent "Disconnected"). stream.set_read_timeout(None).map_err(|e| e.to_string())?; + // Short write timeout: UI must never block for seconds (Android ANR). stream - .set_write_timeout(Some(Duration::from_secs(30))) + .set_write_timeout(Some(Duration::from_secs(2))) .map_err(|e| e.to_string())?; let _ = stream.set_nodelay(true); Ok(()) @@ -362,13 +363,26 @@ pub fn write_packet(stream: &mut S, packet: &KdePacket) -> Result<(), stream.flush().map_err(|e| e.to_string()) } +/// Read one newline-terminated packet. +/// +/// On idle sockets (read timeout / would-block with **no** bytes yet), returns +/// `Err("TimedOut")` immediately so the caller can drain outbound work and retry. +/// Never sleeps while holding the caller's stream lock. pub fn read_packet_line(stream: &mut S) -> Result { let mut buf = Vec::new(); let mut byte = [0u8; 1]; + // Cap how long we sit mid-line waiting for more data (ms of consecutive timeouts). + let mut mid_line_timeouts: u32 = 0; loop { match stream.read(&mut byte) { - Ok(0) => return Err("eof".into()), + Ok(0) => { + if buf.is_empty() { + return Err("eof".into()); + } + return Err("eof mid-packet".into()); + } Ok(_) => { + mid_line_timeouts = 0; if byte[0] == b'\n' { break; } @@ -382,8 +396,17 @@ pub fn read_packet_line(stream: &mut S) -> Result { || e.kind() == std::io::ErrorKind::TimedOut || e.kind() == std::io::ErrorKind::Interrupted => { - // Transient — keep the channel; caller may also retry. - std::thread::sleep(Duration::from_millis(20)); + if buf.is_empty() { + // Idle: let the session loop flush outbound pings. + return Err("TimedOut".into()); + } + // Mid-line: allow a few short timeouts, then surface so outbound can drain. + mid_line_timeouts += 1; + if mid_line_timeouts > 40 { + // ~2s at 50ms socket timeout + return Err("TimedOut".into()); + } + // Do not sleep here — socket timeout already waited. continue; } Err(e) => return Err(e.to_string()), diff --git a/dist-ui/src/kdeconnect/engine.rs b/dist-ui/src/kdeconnect/engine.rs index 9adc007..6622f1a 100644 --- a/dist-ui/src/kdeconnect/engine.rs +++ b/dist-ui/src/kdeconnect/engine.rs @@ -17,13 +17,19 @@ use crate::kdeconnect::packet::KdePacket; use crate::kdeconnect::pair::{PairEffect, PairEvent, PairState, PairStateMachine}; use crate::kdeconnect::trust::TrustStore; -/// One live TLS session. Shared so UI can **write synchronously** while the -/// session thread reads. Valent opens several connections; we keep all and -/// fan-out sends. +/// Work item for the session thread (never written from the UI thread). +enum OutboundMsg { + Packet(KdePacket), + /// Packet + oneshot so the caller can wait for the TLS write result. + PacketAck(KdePacket, std::sync::mpsc::Sender>), +} + +/// One live TLS session. The session thread owns reads/writes; UI only enqueues. #[derive(Clone)] struct SessionEntry { gen: u64, stream: Arc>, + outbound: std::sync::mpsc::Sender, } #[derive(Debug, Clone)] @@ -41,6 +47,8 @@ pub struct EngineSnapshot { /// Valent-style verification key (8 hex chars) while a channel is live. pub verification_key: Option, pub linked_peer_name: Option, + /// Device id of the currently linked peer (if known), for Accept UI. + pub linked_peer_id: Option, pub status: String, /// Last activity line for the UI (ping received, etc.). pub activity: String, @@ -60,6 +68,7 @@ pub struct Engine { activity: Arc>, verification_key: Arc>>, linked_peer_name: Arc>>, + linked_peer_id: Arc>>, stop: Arc, packet_ids: Arc, live_sessions: Arc, @@ -115,6 +124,7 @@ impl Engine { let live_sessions = Arc::new(AtomicU64::new(0)); let verification_key = Arc::new(Mutex::new(None)); let linked_peer_name = Arc::new(Mutex::new(None)); + let linked_peer_id = Arc::new(Mutex::new(None)); let sessions: Arc>>> = Arc::new(Mutex::new(HashMap::new())); let session_gen = Arc::new(AtomicU64::new(1)); @@ -130,6 +140,7 @@ impl Engine { let live_a = live_sessions.clone(); let vkey_a = verification_key.clone(); let lname_a = linked_peer_name.clone(); + let lid_a = linked_peer_id.clone(); let sessions_a = sessions.clone(); let session_gen_a = session_gen.clone(); let activity_a = activity.clone(); @@ -145,6 +156,7 @@ impl Engine { live_a, vkey_a, lname_a, + lid_a, sessions_a, session_gen_a, activity_a, @@ -164,6 +176,7 @@ impl Engine { activity, verification_key, linked_peer_name, + linked_peer_id, stop, packet_ids, live_sessions, @@ -220,6 +233,11 @@ impl Engine { .lock() .ok() .and_then(|g| g.clone()); + let linked_peer_id = self + .linked_peer_id + .lock() + .ok() + .and_then(|g| g.clone()); let live_peers = self .sessions .lock() @@ -241,6 +259,7 @@ impl Engine { live_peers, verification_key, linked_peer_name, + linked_peer_id, status, activity, } @@ -259,66 +278,92 @@ impl Engine { .channel_addr() } - /// Send a packet on **every** open primary channel for this peer (sync write). - pub fn send_to_peer(&self, peer_id: &str, packet: KdePacket) -> Result { - let streams: Vec>> = self - .sessions - .lock() - .map_err(|e| e.to_string())? - .get(peer_id) - .map(|v| v.iter().map(|e| e.stream.clone()).collect()) - .unwrap_or_default(); - if streams.is_empty() { - return Err(format!( - "no live channel to {peer_id} — wait until Valent is connected" - )); - } - let mut ok = 0usize; - let mut last_err = String::new(); - for stream in streams { - match stream.lock() { - Ok(mut g) => match write_packet(&mut *g, &packet) { - Ok(()) => { - ok += 1; - if let Ok(raw) = packet.encode_line() { - eprintln!( - "[kdeconnect] → {} (sync): {}", - packet.packet_type, - String::from_utf8_lossy(&raw).trim() - ); - } + /// Enqueue a packet on the **newest** live channel for this peer. + /// + /// The session thread drains `outbound` and performs the TLS write. + /// If `wait` is true, block (with timeout) until the write completes. + pub fn send_to_peer( + &self, + peer_id: &str, + packet: KdePacket, + wait: bool, + ) -> Result { + let tx = { + let map = self.sessions.lock().map_err(|e| e.to_string())?; + let list = map.get(peer_id).ok_or_else(|| { + format!("no live channel to {peer_id} — wait until Valent is connected") + })?; + // Prefer newest generation (Valent thrash leaves dead older sockets). + list.iter() + .max_by_key(|e| e.gen) + .map(|e| e.outbound.clone()) + .ok_or_else(|| format!("no live channel to {peer_id}"))? + }; + + if wait { + let (ack_tx, ack_rx) = std::sync::mpsc::channel(); + tx.send(OutboundMsg::PacketAck(packet.clone(), ack_tx)) + .map_err(|e| format!("session gone: {e}"))?; + match ack_rx.recv_timeout(Duration::from_secs(3)) { + Ok(Ok(())) => { + if let Ok(raw) = packet.encode_line() { + eprintln!( + "[kdeconnect] → {} (on wire): {}", + packet.packet_type, + String::from_utf8_lossy(&raw).trim() + ); } - Err(e) => last_err = e, - }, - Err(e) => last_err = e.to_string(), + Ok(1) + } + Ok(Err(e)) => Err(e), + Err(_) => Err("ping timed out waiting for channel write".into()), } + } else { + tx.send(OutboundMsg::Packet(packet.clone())) + .map_err(|e| format!("session gone: {e}"))?; + if let Ok(raw) = packet.encode_line() { + eprintln!( + "[kdeconnect] → {} (queued): {}", + packet.packet_type, + String::from_utf8_lossy(&raw).trim() + ); + } + Ok(1) } - if ok == 0 { - return Err(if last_err.is_empty() { - format!("all channels to {peer_id} failed to write") - } else { - last_err - }); - } - Ok(ok) } /// Ping a trusted / live peer over the open KDE Connect channel. /// /// KDE Connect ping is **one-way**: Valent shows a **desktop notification** /// (title = device name “Global”, body = message). The Valent window itself - /// does not change. + /// does not change. Requires **paired** + live channel; unpaired peers drop + /// plugin packets. pub fn ping_peer(&self, peer_id: &str) -> String { + let trusted = self + .pair + .lock() + .map(|g| g.trust().is_trusted(peer_id)) + .unwrap_or(false); + if !trusted { + let msg = format!( + "Ping skipped: not paired with {peer_id} yet — Accept pair first (Valent rejects unpaired plugins)" + ); + if let Ok(mut a) = self.activity.lock() { + *a = msg.clone(); + } + return msg; + } // Use millisecond ids like official clients. let pid = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as i64) .unwrap_or_else(|_| self.next_packet_id()); let pkt = KdePacket::ping_with_message(pid, "Hello from Global"); - match self.send_to_peer(peer_id, pkt) { - Ok(n) => { - let msg = format!( - "Ping written on {n} channel(s) — desktop notification “Global” / “Hello from Global”" + // Wait for the session thread to actually write (honest status). + match self.send_to_peer(peer_id, pkt, true) { + Ok(_) => { + let msg = String::from( + "Ping on wire — check desktop notification tray for “Global”", ); if let Ok(mut a) = self.activity.lock() { *a = msg.clone(); @@ -338,6 +383,18 @@ impl Engine { } } + /// Non-blocking ping for the UI thread (avoids Android ANR on TLS I/O). + pub fn ping_peer_async(self: &Arc, peer_id: String) { + let eng = Arc::clone(self); + if let Ok(mut a) = eng.activity.lock() { + *a = format!("Sending ping to {peer_id}…"); + } + thread::spawn(move || { + let msg = eng.ping_peer(&peer_id); + eprintln!("[kdeconnect] async ping result: {msg}"); + }); + } + /// Apply pair effects including **SendPair over TCP+TLS**. fn apply_effects(&self, effects: Vec) -> String { let mut msgs = Vec::new(); @@ -348,7 +405,7 @@ impl Engine { let pkt = KdePacket::pair_request(self.next_packet_id(), pair); // Prefer live session; fall back to opening a new connection. let r = self - .send_to_peer(&peer_id, pkt) + .send_to_peer(&peer_id, pkt, false) .map(|_| ()) .or_else(|_| self.send_pair_wire(&peer_id, pair)); match r { @@ -493,6 +550,7 @@ fn accept_loop( live_sessions: Arc, verification_key: Arc>>, linked_peer_name: Arc>>, + linked_peer_id: Arc>>, sessions: Arc>>>, session_gen: Arc, activity: Arc>, @@ -509,6 +567,7 @@ fn accept_loop( let live_sessions = live_sessions.clone(); let verification_key = verification_key.clone(); let linked_peer_name = linked_peer_name.clone(); + let linked_peer_id = linked_peer_id.clone(); let sessions = sessions.clone(); let session_gen = session_gen.clone(); let activity = activity.clone(); @@ -526,6 +585,7 @@ fn accept_loop( live_sessions, verification_key, linked_peer_name, + linked_peer_id, sessions, session_gen, activity, @@ -563,6 +623,7 @@ fn handle_primary_session( live_sessions: Arc, verification_key: Arc>>, linked_peer_name: Arc>>, + linked_peer_id: Arc>>, sessions: Arc>>>, session_gen: Arc, activity: Arc>, @@ -571,8 +632,9 @@ fn handle_primary_session( set_ads_paused(&discovery, true); eprintln!("[kdeconnect] primary channel up with {addr} (live={n})"); - // Shared stream: session thread reads; UI writes via send_to_peer. + // Shared stream: session thread reads + drains outbound; UI only enqueues. let stream = Arc::new(Mutex::new(tls)); + let (outbound_tx, outbound_rx) = std::sync::mpsc::channel::(); // Verification key from peer cert. if let Ok(g) = stream.lock() { @@ -613,22 +675,26 @@ fn handle_primary_session( if let Ok(mut g) = linked_peer_name.lock() { *g = Some(body.device_name.clone()); } + if let Ok(mut g) = linked_peer_id.lock() { + *g = Some(body.device_id.clone()); + } let gen = session_gen.fetch_add(1, Ordering::SeqCst); let is_trusted = pair .lock() .map(|g| g.trust().is_trusted(&body.device_id)) .unwrap_or(false); if let Ok(mut map) = sessions.lock() { - map.entry(body.device_id.clone()) - .or_default() - .push(SessionEntry { - gen, - stream: stream.clone(), - }); + // Valent opens many short-lived sockets; keep only this newest session + // so Ping/plugin traffic goes to the channel it is still reading. + let entry = SessionEntry { + gen, + stream: stream.clone(), + outbound: outbound_tx.clone(), + }; + map.insert(body.device_id.clone(), vec![entry]); registered = Some((body.device_id.clone(), gen)); - let n_sess = map.get(&body.device_id).map(|v| v.len()).unwrap_or(0); eprintln!( - "[kdeconnect] registered session gen={gen} for {} (now {n_sess} live)", + "[kdeconnect] registered sole session gen={gen} for {}", body.device_id ); } @@ -637,7 +703,7 @@ fn handle_primary_session( format!("connected to “{}” (paired) — use Ping below", body.device_name) } else { format!( - "linked with “{}” — match verification key, then Request pairing in Valent", + "linked with “{}” — match verification key, then Accept pair request (or request from Valent)", body.device_name ) }; @@ -646,6 +712,10 @@ fn handle_primary_session( } loop { + // Drain UI-enqueued packets before (and after) each poll so Ping never + // waits on the stream mutex. + flush_outbound(&outbound_rx, &stream); + let read_result = { let mut g = match stream.lock() { Ok(g) => g, @@ -654,7 +724,8 @@ fn handle_primary_session( break; } }; - let _ = set_session_read_timeout(&mut g, Some(Duration::from_millis(200))); + // Short poll so outbound pings flush promptly (≤50ms). + let _ = set_session_read_timeout(&mut g, Some(Duration::from_millis(50))); read_packet_line(&mut *g) }; @@ -664,6 +735,7 @@ fn handle_primary_session( "[kdeconnect] ← {} from {addr} type={}", session_peer_name, pkt.packet_type ); + flush_outbound(&outbound_rx, &stream); // Once Valent is actively using this channel, push battery + ping. // Battery updates Valent’s device page (visible). Ping is notify-only. @@ -765,37 +837,58 @@ fn handle_primary_session( "[kdeconnect] pair packet pair={} from {peer_name} ({peer_id})", pb.pair ); + if let Ok(mut g) = linked_peer_id.lock() { + *g = Some(peer_id.clone()); + } + if let Ok(mut g) = linked_peer_name.lock() { + *g = Some(peer_name.clone()); + } + // Incoming pair:true from Valent (not a confirm of our own request): + // auto-accept on the live stream so pairing completes without a + // second TCP hop. Also leave a clear status for the UI. if pb.pair { let st = pair .lock() .map(|g| g.state_of(&peer_id)) .unwrap_or(PairState::Idle); - if st != PairState::OutgoingRequest && st != PairState::Paired { + if st != PairState::OutgoingRequest { if let Ok(mut g) = pair.lock() { - let _ = g.handle(PairEvent::ReceivedPair { - peer_id: peer_id.clone(), - peer_name: peer_name.clone(), - peer_type: peer_type.clone(), - body: pb.clone(), - }); + if st != PairState::Paired { + let _ = g.handle(PairEvent::ReceivedPair { + peer_id: peer_id.clone(), + peer_name: peer_name.clone(), + peer_type: peer_type.clone(), + body: pb.clone(), + }); + } let (effects, changed) = g.handle(PairEvent::AcceptIncoming { peer_id: peer_id.clone(), }); if changed { let _ = g.trust().save(&trust_path); } + let mut wrote_ok = false; for e in effects { match e { PairEffect::SendPair { pair: p, .. } => { - let reply = KdePacket::pair_request(2, p); + let reply = KdePacket::pair_request( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(2), + p, + ); if let Ok(mut sg) = stream.lock() { match write_packet(&mut *sg, &reply) { - Ok(()) => eprintln!( - "[kdeconnect] → pair={p} reply to {peer_name}" - ), + Ok(()) => { + wrote_ok = true; + eprintln!( + "[kdeconnect] → pair={p} accept to {peer_name}" + ); + } Err(err) => eprintln!( - "[kdeconnect] write pair reply: {err}" + "[kdeconnect] write pair accept: {err}" ), } } @@ -808,11 +901,27 @@ fn handle_primary_session( } } } + let msg = if wrote_ok { + format!( + "Paired with “{peer_name}” — accept sent on channel" + ) + } else { + format!( + "Pair request from “{peer_name}” — tap Accept if still unpaired" + ) + }; + if let Ok(mut st) = status.lock() { + *st = msg.clone(); + } + if let Ok(mut a) = activity.lock() { + *a = msg; + } } continue; } } + // Unpair or confirmation of our OutgoingRequest. if let Ok(mut g) = pair.lock() { let (effects, changed) = g.handle(PairEvent::ReceivedPair { peer_id: peer_id.clone(), @@ -859,6 +968,7 @@ fn handle_primary_session( } } + // Unregister first so new pings fail fast; then fail any queued PacketAcks. if let Some((id, gen)) = registered { if let Ok(mut map) = sessions.lock() { if let Some(list) = map.get_mut(&id) { @@ -874,6 +984,12 @@ fn handle_primary_session( } } } + drop(outbound_tx); + while let Ok(msg) = outbound_rx.try_recv() { + if let OutboundMsg::PacketAck(_, ack) = msg { + let _ = ack.send(Err("session closed before write".into())); + } + } let left = live_sessions .fetch_sub(1, Ordering::SeqCst) .saturating_sub(1); @@ -893,6 +1009,9 @@ fn handle_primary_session( if let Ok(mut g) = linked_peer_name.lock() { *g = None; } + if let Ok(mut g) = linked_peer_id.lock() { + *g = None; + } } } @@ -908,6 +1027,47 @@ fn set_session_read_timeout( sock.set_read_timeout(timeout).map_err(|e| e.to_string()) } +/// Write all queued packets while holding the stream briefly. +fn flush_outbound( + rx: &std::sync::mpsc::Receiver, + stream: &Arc>, +) { + loop { + match rx.try_recv() { + Ok(msg) => { + let (pkt, ack) = match msg { + OutboundMsg::Packet(p) => (p, None), + OutboundMsg::PacketAck(p, a) => (p, Some(a)), + }; + let result = match stream.lock() { + Ok(mut g) => write_packet(&mut *g, &pkt).map_err(|e| e), + Err(e) => Err(e.to_string()), + }; + match &result { + Ok(()) => { + if let Ok(raw) = pkt.encode_line() { + eprintln!( + "[kdeconnect] → {} (flushed): {}", + pkt.packet_type, + String::from_utf8_lossy(&raw).trim() + ); + } + } + Err(e) => eprintln!( + "[kdeconnect] outbound write {} failed: {e}", + pkt.packet_type + ), + } + if let Some(ack) = ack { + let _ = ack.send(result); + } + } + Err(std::sync::mpsc::TryRecvError::Empty) => break, + Err(std::sync::mpsc::TryRecvError::Disconnected) => break, + } + } +} + /// Process-wide engine for the device UI process. static ENGINE: Mutex>> = Mutex::new(None); @@ -928,6 +1088,7 @@ pub fn ensure_global_engine() -> Result, String> { #[cfg(test)] mod tests { use super::*; + use crate::kdeconnect::channel::{connect_primary, read_packet_line, TlsStream}; use std::time::{SystemTime, UNIX_EPOCH}; fn tmp_dir(name: &str) -> PathBuf { @@ -1014,4 +1175,99 @@ mod tests { "trusted ids must not be IP-derived: {trusted_ids:?}" ); } + + #[test] + fn unpaired_ping_skips_without_write() { + let dir = tmp_dir("unpaired-ping"); + let eng = Engine::start(dir.join("i.json"), dir.join("t.json"), 0).unwrap(); + let msg = eng.ping_peer("never-seen-peer-id"); + assert!( + msg.contains("not paired") || msg.contains("skipped") || msg.contains("Ping skipped"), + "expected skip text, got: {msg}" + ); + assert!(!msg.contains("on wire"), "{msg}"); + } + + /// Real path: local inverted-TLS peer + wait-mode ping → peer reads `kdeconnect.ping`. + #[test] + fn wait_mode_ping_delivers_kdeconnect_ping_with_message() { + rustls::crypto::ring::default_provider() + .install_default() + .ok(); + let dir = tmp_dir("ping-wire"); + let eng = Engine::start(dir.join("i.json"), dir.join("t.json"), 0).unwrap(); + let port = eng.snapshot().tcp_port; + let desk = + DeviceIdentity::generate("ValentDesk", crate::kdeconnect::identity::DeviceType::Desktop) + .unwrap(); + let desk_id = desk.device_id.clone(); + + // Mark trusted so ping_peer does not skip (Valent would drop unpaired plugins). + eng.pair + .lock() + .unwrap() + .trust_mut() + .trust(crate::kdeconnect::trust::TrustedPeer { + device_id: desk_id.clone(), + device_name: "ValentDesk".into(), + device_type: "desktop".into(), + cert_pem: None, + }); + + let (got_tx, got_rx) = std::sync::mpsc::channel::(); + let desk2 = desk.clone(); + let peer = thread::spawn(move || { + let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); + // Desktop opens TCP client → TLS server; sends cleartext identity. + let mut tls = connect_primary(addr, &desk2, None, 7).unwrap(); + // Short poll so we can exit if no ping arrives. + let sock = match &tls { + TlsStream::AsClient(s) => &s.sock, + TlsStream::AsServer(s) => &s.sock, + }; + let _ = sock.set_read_timeout(Some(Duration::from_millis(100))); + // Stay connected and wait for plugin traffic from the phone engine. + for _ in 0..80 { + match read_packet_line(&mut tls) { + Ok(pkt) if pkt.packet_type == "kdeconnect.ping" => { + let _ = got_tx.send(pkt); + return; + } + Ok(_) => continue, + Err(e) if e.contains("TimedOut") || e.contains("timed out") => continue, + Err(_) => return, + } + } + }); + + // Allow accept + session registration. + std::thread::sleep(Duration::from_millis(400)); + assert!( + eng.snapshot().live_peers.contains(&desk_id), + "peer must be live before wait-mode ping: {:?}", + eng.snapshot().live_peers + ); + + let msg = eng.ping_peer(&desk_id); + assert!( + msg.contains("on wire") || msg.contains("Ping on wire"), + "expected write success, got: {msg}" + ); + + let pkt = got_rx + .recv_timeout(Duration::from_secs(3)) + .expect("peer must receive kdeconnect.ping line"); + assert_eq!(pkt.packet_type, "kdeconnect.ping"); + match &pkt.body { + crate::kdeconnect::packet::PacketBody::Other(v) => { + let message = v + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or(""); + assert!(!message.is_empty(), "body.message must be non-empty: {v}"); + } + other => panic!("expected Other body with message, got {other:?}"), + } + let _ = peer.join(); + } } diff --git a/dist-ui/src/kdeconnect/pair.rs b/dist-ui/src/kdeconnect/pair.rs index 09b6b4f..294dadc 100644 --- a/dist-ui/src/kdeconnect/pair.rs +++ b/dist-ui/src/kdeconnect/pair.rs @@ -202,30 +202,61 @@ impl PairStateMachine { } } PairEvent::AcceptIncoming { peer_id } => { - if self.state_of(&peer_id) != PairState::IncomingRequest { - effects.push(PairEffect::Status( - "no incoming pair request to accept".into(), - )); - } else { - let (name, ty) = self - .pending - .remove(&peer_id) - .unwrap_or_else(|| (peer_id.clone(), "desktop".into())); - self.trust.trust(TrustedPeer { - device_id: peer_id.clone(), - device_name: name, - device_type: ty, - cert_pem: None, - }); - self.states.insert(peer_id.clone(), PairState::Paired); - trust_changed = true; - effects.push(PairEffect::SendPair { - peer_id: peer_id.clone(), - pair: true, - }); - effects.push(PairEffect::Status(format!( - "accepted pair with {peer_id} (protocol confirmed)" - ))); + match self.state_of(&peer_id) { + PairState::IncomingRequest => { + let (name, ty) = self + .pending + .remove(&peer_id) + .unwrap_or_else(|| (peer_id.clone(), "desktop".into())); + self.trust.trust(TrustedPeer { + device_id: peer_id.clone(), + device_name: name.clone(), + device_type: ty, + cert_pem: None, + }); + self.states.insert(peer_id.clone(), PairState::Paired); + trust_changed = true; + effects.push(PairEffect::SendPair { + peer_id: peer_id.clone(), + pair: true, + }); + effects.push(PairEffect::Status(format!( + "accepted pair with {name} (protocol confirmed)" + ))); + } + PairState::Paired => { + // Re-ACK so Valent sees our pair:true even if an earlier write dropped. + effects.push(PairEffect::SendPair { + peer_id: peer_id.clone(), + pair: true, + }); + effects.push(PairEffect::Status(format!( + "re-sent pair confirm to {peer_id}" + ))); + } + PairState::Idle | PairState::OutgoingRequest => { + // User tapped Accept while Valent is connected but we never stored + // IncomingRequest (missed packet / race). Still complete the handshake. + let (name, ty) = self + .pending + .remove(&peer_id) + .unwrap_or_else(|| (peer_id.clone(), "desktop".into())); + self.trust.trust(TrustedPeer { + device_id: peer_id.clone(), + device_name: name.clone(), + device_type: ty, + cert_pem: None, + }); + self.states.insert(peer_id.clone(), PairState::Paired); + trust_changed = true; + effects.push(PairEffect::SendPair { + peer_id: peer_id.clone(), + pair: true, + }); + effects.push(PairEffect::Status(format!( + "accepted pair with {name} (forced confirm)" + ))); + } } } PairEvent::Unpair { peer_id } => { @@ -343,6 +374,22 @@ mod tests { assert!(fx.iter().any(|e| matches!(e, PairEffect::SendPair { pair: true, .. }))); } + #[test] + fn accept_from_idle_still_pairs() { + // UI Accept after a missed IncomingRequest (live Valent session). + let mut m = PairStateMachine::new(TrustStore::new()); + m.handle(PairEvent::ObservedIdentity(identity("desk", "Desk"))); + let (fx, changed) = m.handle(PairEvent::AcceptIncoming { + peer_id: "desk".into(), + }); + assert!(changed); + assert!(m.trust().is_trusted("desk")); + assert_eq!(m.state_of("desk"), PairState::Paired); + assert!(fx + .iter() + .any(|e| matches!(e, PairEffect::SendPair { pair: true, .. }))); + } + #[test] fn unpair_clears_trust() { let mut trust = TrustStore::new(); diff --git a/docs/screenshots/waydroid-global.png b/docs/screenshots/waydroid-global.png new file mode 100644 index 0000000000000000000000000000000000000000..b321ec199e8f95b69f95f0e3e95d6583d36b8acb GIT binary patch literal 53770 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 z zZ7PFOQ=iKA1qTO9mqhN|X(>DHur*(^X?R#zsHWPn?`$)#ToExfHQm~_;QGzY>8#Bx zooCa6*Uz)9on^3jlhM@+7ao{StB?Hk?QFpOd$n6~qZg*niQQfH`at9PrnPg9zq?yq z@^Y#2*3XyyMWv-z-`)MaWXIeXy^~p6+4i2}WOyiWZ`nmN_Iu9le6Kg3=UeGN-)@q( zRmqQzS+i!<-f&v14pYx3QGCKW2%VR(+afclxhq(y*&@Qg-hPNJ{#wEOPPUMJfBdHSho2^0Ci}*aoWgtUlaKzqS9r zL;LBB?Aza*jvjHzK9xGT^ifLZlD_T1&dssT?Qxejt$0@T^N{$liaHxc2aA^PJq8C1 z3lAUjm9nW=u;kh1BVVP>GOG@n@XF6qUVT+-_T6>YwA+_OifHO9v}0aWtQ9J7Hj3i*B%@g7#Q{Q%Vtowe#=YySw>%tdUus9xxZmsx_|KU zm}PhF$lT01d$vvbSC_tqDA-5GfoBYe(U3qp^{+EvO7Kte_deiOhZjGL- z{`B?Ht@_?s_uAg(F1M}zmI+#xJ!QW7>Z_~7<096A3i`kNX1}M1GMsBoeQ{yoLb=n2 zw2X@z-jr2;F5R!N@9*0DjHj1ZPnk7KY4_cAlXcUDa=*U38~u^eM^UcsOu|4;krp}tRsP6Bt zRZEumJbn6 zWUx6@cRj;G)}p|nxA!0a^v91T&6=i_?b2eFb7M*E?{DShg$xIdv(~zCFgS!SmR4hE zxX9r|A#v!M{KBI`irp&Hul!o>TC`Ke`*eIwTWrSTingD17w%bzfB97se77}fKZtSJ zV&S{;xBbNr*PM4X=KES5_j>!^m(G&QOC|NfCm&MGah!3nIABK_D}zHjXzBOqnnPkO z>i)9Nu7{QPIRA~?vOmrK`{RnM+`6na-*+d?DOlKE#n5nZMEY z8;lkOqb1>JNjNe~!YwCST3X)eF*7jSZ8N5{ZG)|Gq_lfCLq>c~&Kl6>62s(UC(<^* z>@~l4p-lP2lu$sRiRwCLrh)B52)K4&6*-~U?}5)pBN|L*6W z>=y9}H*Q4eMr?4{ey7NLH~X0xGbDD#%sCOSla!QXCEAoHDJdDXA>m-8d_>{Dxv#es z{l9SZ=TXUrtV#jK8hUl_Hn*H>TM+H$)~2`rPtn@g-P1O=aL4SZo3pF_#iqUI<(oFl z-g=#jtw3EZJx#jn{xOXCKyVx8n=DoeUd;9Kg28It#0`t=Af8W0UxAUUE&%alH{HXGUGG5W!dwzddcab|{gFajQl>^bo9>5o5(AMF0$SSK$2PKuQZYZ~AFXOCCheEY=n SidqH+1_n=8KbLh*2~7aiUrJ;E literal 0 HcmV?d00001 diff --git a/flake.lock b/flake.lock index f8e00a2..75e03c2 100644 --- a/flake.lock +++ b/flake.lock @@ -29,11 +29,11 @@ ] }, "locked": { - "lastModified": 1785287339, - "narHash": "sha256-0AM+YSRsigVZ7rDff+L5bkQn8HrZxQMh9zAmBA+GKxQ=", + "lastModified": 1785314778, + "narHash": "sha256-Rf/3zpYMrCRih10cbYIvJdw3U1Znua2OwJAsnR0/E0s=", "ref": "refs/heads/main", - "rev": "f4a205fa5eeeb428ba8945a6e7ee34c0a9d9b387", - "revCount": 2, + "rev": "7bdedaf47fc86dd490804c7d3506c8bb28fcd2e8", + "revCount": 6, "type": "git", "url": "https://knot1.tangled.sh/nandi.uk/vidya" }, diff --git a/flake.nix b/flake.nix index 95a1586..b5873c4 100644 --- a/flake.nix +++ b/flake.nix @@ -111,24 +111,35 @@ pkgs.cacert pkgs.android-tools pkgs.just - pkgs.cargo - pkgs.rustc + # Do NOT add pkgs.cargo / pkgs.rustc here: nixpkgs rustc has no + # x86_64-linux-android std, and shadowing rustup breaks `just waydroid`. + # Use host rustup (rustc + `rustup target add x86_64-linux-android`). ]; shellHook = '' export SSL_CERT_FILE="${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" export NIX_SSL_CERT_FILE="$SSL_CERT_FILE" export GLOBAL_VIDYA_SRC="${vidya}" - # Stage path dep for ad-hoc cargo builds in this shell + # Stage path dep for ad-hoc cargo builds, preferring a local checkout. if [ -d dist-ui ]; then mkdir -p .flake - rm -rf .flake/vidya - cp -aL "$GLOBAL_VIDYA_SRC" .flake/vidya - chmod -R u+w .flake/vidya + if [ -d "$HOME/code/vidya" ] && [ -f "$HOME/code/vidya/Cargo.toml" ]; then + rm -rf .flake/vidya + ln -sfn "$HOME/code/vidya" .flake/vidya + elif [ ! -e .flake/vidya ]; then + cp -aL "$GLOBAL_VIDYA_SRC" .flake/vidya + chmod -R u+w .flake/vidya + fi fi - echo "global — device companion (vidya flake input staged at .flake/vidya)" + echo "global — device companion (vidya at .flake/vidya)" + echo " just waydroid # APK → Waydroid (needs rustup + android target)" echo " nix run .#global # APK → install → Gleam" echo " gleam test" - echo " override: --override-input vidya path:\$HOME/code/vidya" + if ! command -v rustc >/dev/null 2>&1; then + echo " warn: no rustc on PATH — install rustup and: rustup target add x86_64-linux-android" + elif [ ! -d "$(rustc --print sysroot)/lib/rustlib/x86_64-linux-android/lib" ]; then + echo " warn: active rustc lacks x86_64-linux-android — run: rustup target add x86_64-linux-android" + echo " (and ensure which rustc points at ~/.rustup, not nixpkgs)" + fi ''; }; } diff --git a/justfile b/justfile index 779e06c..b8b524e 100644 --- a/justfile +++ b/justfile @@ -19,6 +19,26 @@ run: run-skip-apk: GLOBAL_APK_SKIP_BUILD=1 nix run .#global {{nix_flags}} +# Waydroid: x86_64 APK → adb install → launch (default: install) +# just waydroid # build + install + launch +# just waydroid launch # start activity only +# just waydroid drive # host Gleam → ui@ +# just waydroid all # install + drive +waydroid *args: + #!/usr/bin/env bash + set -euo pipefail + cd "{{justfile_directory()}}" + # rustup shims before anything nix develop may have put on PATH + export PATH="${HOME}/.cargo/bin:${HOME}/.local/share/android-sdk/platform-tools:${PATH}" + export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$HOME/.local/share/android-ndk-r29}" + export ANDROID_HOME="${ANDROID_HOME:-$HOME/.local/share/android-sdk}" + unset ANDROID_SDK_ROOT 2>/dev/null || true + args=({{args}}) + if [[ ${#args[@]} -eq 0 ]]; then + args=(install) + fi + exec bash ./scripts/waydroid-dist-ui.sh "${args[@]}" + # Gleam domain tests only test-driver: #!/usr/bin/env bash diff --git a/scripts/global-run.sh b/scripts/global-run.sh index 652f9b7..a1544a2 100755 --- a/scripts/global-run.sh +++ b/scripts/global-run.sh @@ -150,24 +150,41 @@ setup_android_ndk() { exit 1 fi # Prefer rustup toolchain with android std over nixpkgs rustc (no android target). - local rust_tc="" - for rust_tc in \ - "${HOME}/.rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/bin" \ - "${HOME}/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin" \ - "${HOME}/.rustup/toolchains/1.96.0-x86_64-unknown-linux-gnu/bin"; do - if [[ -x "${rust_tc}/cargo" ]]; then - break + if command -v rustup >/dev/null 2>&1; then + rustup target add x86_64-linux-android >/dev/null 2>&1 || true + fi + local rust_bin="" tc + if command -v rustup >/dev/null 2>&1; then + local c + c="$(rustup which cargo 2>/dev/null || true)" + if [[ -n "$c" && -x "$c" ]]; then + local sr + sr="$("$(dirname "$c")/rustc" --print sysroot 2>/dev/null || true)" + if [[ -d "${sr}/lib/rustlib/x86_64-linux-android/lib" ]]; then + rust_bin="$(dirname "$c")" + fi fi - rust_tc="" - done - export PATH="${rust_tc:+${rust_tc}:}${HOME}/.cargo/bin:${prebuilt}/bin:${ANDROID_HOME}/platform-tools:${PATH}" + fi + if [[ -z "$rust_bin" && -d "${HOME}/.rustup/toolchains" ]]; then + for tc in "${HOME}/.rustup/toolchains"/*; do + [[ -x "${tc}/bin/cargo" ]] || continue + if [[ -d "${tc}/lib/rustlib/x86_64-linux-android/lib" ]]; then + rust_bin="${tc}/bin" + break + fi + done + fi + if [[ -z "$rust_bin" ]]; then + echo "[global] error: no rustc with x86_64-linux-android std (nixpkgs rustc is not enough)" >&2 + echo " rustup target add x86_64-linux-android" >&2 + exit 1 + fi + export PATH="${rust_bin}:${HOME}/.cargo/bin:${prebuilt}/bin:${ANDROID_HOME}/platform-tools:${PATH}" export CC_x86_64_linux_android="${CC_x86_64_linux_android:-x86_64-linux-android28-clang}" export CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER="$CC_x86_64_linux_android" export AR_x86_64_linux_android="${AR_x86_64_linux_android:-llvm-ar}" - if command -v rustup >/dev/null; then - rustup target add x86_64-linux-android >/dev/null 2>&1 || true - fi echo "[global] cargo=$(command -v cargo) $(cargo --version 2>/dev/null || true)" >&2 + echo "[global] rustc=$(command -v rustc) sysroot=$(rustc --print sysroot)" >&2 } build_apk() { @@ -188,17 +205,20 @@ build_apk() { # --lib: only the cdylib (avoids cargo-apk "Bin is not compatible with Cdylib"). # All cargo/apk chatter → stderr; stdout is only the APK path. cargo apk build --lib --target x86_64-linux-android --no-default-features >&2 - ) + ) || { + echo "[global] cargo apk build failed — not installing a stale APK" >&2 + exit 1 + } [[ -f "$apk" ]] || { echo "[global] APK missing after build: $apk" >&2 exit 1 } after=$(stat -c %Y "$apk" 2>/dev/null || echo 0) if [[ "$after" -le "$before" ]]; then - echo "[global] warning: APK mtime unchanged — build may have failed; reinstalling existing APK" >&2 - else - echo "[global] APK updated: $apk" >&2 + echo "[global] error: APK mtime unchanged after build — refusing stale install" >&2 + exit 1 fi + echo "[global] APK updated: $apk" >&2 printf '%s\n' "$apk" } diff --git a/scripts/waydroid-dist-ui.sh b/scripts/waydroid-dist-ui.sh index 1c638b1..97317af 100755 --- a/scripts/waydroid-dist-ui.sh +++ b/scripts/waydroid-dist-ui.sh @@ -21,7 +21,6 @@ unset ANDROID_SDK_ROOT 2>/dev/null || true need() { command -v "$1" >/dev/null || { echo "missing: $1" >&2; exit 1; }; } need waydroid -need cargo # adb if ! command -v adb >/dev/null; then @@ -30,11 +29,56 @@ if ! command -v adb >/dev/null; then fi need adb -export PATH="${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin:${HOME}/.cargo/bin:${ANDROID_HOME}/platform-tools:${PATH}" +# Prefer a rustup toolchain that actually has x86_64-linux-android std. +# nix develop / nixpkgs cargo+rustc ship host std only — rustup target add is a no-op for them. +prefer_rustup_android() { + local tc bin sysroot + if command -v rustup >/dev/null 2>&1; then + rustup target add x86_64-linux-android >&2 || true + fi + # Prefer active rustup toolchain, then any installed one with android std. + if command -v rustup >/dev/null 2>&1; then + bin="$(rustup which cargo 2>/dev/null || true)" + if [[ -n "$bin" && -x "$bin" ]]; then + sysroot="$("$(dirname "$bin")/rustc" --print sysroot 2>/dev/null || true)" + if [[ -d "${sysroot}/lib/rustlib/x86_64-linux-android/lib" ]]; then + export PATH="$(dirname "$bin"):${HOME}/.cargo/bin:${PATH}" + return 0 + fi + fi + fi + if [[ -d "${HOME}/.rustup/toolchains" ]]; then + for tc in "${HOME}/.rustup/toolchains"/*; do + [[ -x "${tc}/bin/cargo" ]] || continue + if [[ -d "${tc}/lib/rustlib/x86_64-linux-android/lib" ]]; then + export PATH="${tc}/bin:${HOME}/.cargo/bin:${PATH}" + return 0 + fi + done + fi + echo "error: no rustc with x86_64-linux-android std found." >&2 + echo " nixpkgs rustc (from nix develop) does not include Android targets." >&2 + echo " Fix: leave the pure-nix cargo/rustc aside and use rustup, e.g.:" >&2 + echo " rustup toolchain install 1.95.0" >&2 + echo " rustup target add x86_64-linux-android" >&2 + echo " hash -r; which rustc; rustc --print sysroot" >&2 + exit 1 +} + +prefer_rustup_android +# NDK clang after rustup bins so `cargo`/`rustc` stay rustup-managed. +export PATH="${PATH}:${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin:${ANDROID_HOME}/platform-tools" + export CC_x86_64_linux_android="${CC_x86_64_linux_android:-x86_64-linux-android28-clang}" export CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER="$CC_x86_64_linux_android" export AR_x86_64_linux_android="${AR_x86_64_linux_android:-llvm-ar}" +need cargo +need rustc +echo "[waydroid] cargo=$(command -v cargo) rustc=$(command -v rustc)" >&2 +echo "[waydroid] sysroot=$(rustc --print sysroot)" >&2 +echo "[waydroid] $(cargo --version) / $(rustc --version)" >&2 + waydroid_ip() { waydroid status 2>/dev/null | awk -F': *' '/IP address/{gsub(/[[:space:]]/,"",$2); print $2; exit}' } @@ -65,17 +109,55 @@ ensure_adb() { } build_apk() { - need cargo-apk || cargo install cargo-apk + need cargo + if ! cargo apk --help >/dev/null 2>&1; then + cargo install cargo-apk --locked >&2 + fi [[ -d "$ANDROID_NDK_HOME" ]] || { echo "Set ANDROID_NDK_HOME (expected $ANDROID_NDK_HOME)" >&2 exit 1 } - cd "$DIST_UI" - # --lib: only the cdylib (avoids cargo-apk "Bin is not compatible with Cdylib"). - cargo apk build --lib --target x86_64-linux-android --no-default-features - APK="$DIST_UI/target/debug/apk/global-dist-ui.apk" - [[ -f "$APK" ]] || { echo "APK missing at $APK" >&2; exit 1; } - echo "$APK" + # Refuse nixpkgs rustc: it has no Android std even after `rustup target add`. + local sysroot + sysroot="$(rustc --print sysroot)" + if [[ ! -d "${sysroot}/lib/rustlib/x86_64-linux-android/lib" ]]; then + echo "error: active rustc has no x86_64-linux-android std:" >&2 + echo " rustc=$(command -v rustc)" >&2 + echo " sysroot=${sysroot}" >&2 + echo " (nix develop installs pkgs.rustc without Android targets — use rustup.)" >&2 + prefer_rustup_android + sysroot="$(rustc --print sysroot)" + [[ -d "${sysroot}/lib/rustlib/x86_64-linux-android/lib" ]] || exit 1 + fi + # Stage vidya for dist-ui path dep if missing. + if [[ ! -e "$ROOT/.flake/vidya" ]]; then + mkdir -p "$ROOT/.flake" + if [[ -d "$HOME/code/vidya" && -f "$HOME/code/vidya/Cargo.toml" ]]; then + ln -sfn "$HOME/code/vidya" "$ROOT/.flake/vidya" + elif [[ -d "$ROOT/vidya" ]]; then + ln -sfn "$ROOT/vidya" "$ROOT/.flake/vidya" + fi + fi + local apk before after + apk="$DIST_UI/target/debug/apk/global-dist-ui.apk" + before=0 + [[ -f "$apk" ]] && before=$(stat -c %Y "$apk" 2>/dev/null || echo 0) + ( + cd "$DIST_UI" + # --lib: only the cdylib (avoids cargo-apk "Bin is not compatible with Cdylib"). + # All cargo/apk chatter → stderr; stdout is only the APK path (for $(build_apk)). + cargo apk build --lib --target x86_64-linux-android --no-default-features >&2 + ) || { + echo "error: cargo apk build failed — not installing a stale APK" >&2 + exit 1 + } + [[ -f "$apk" ]] || { echo "APK missing at $apk" >&2; exit 1; } + after=$(stat -c %Y "$apk" 2>/dev/null || echo 0) + if [[ "$after" -le "$before" ]]; then + echo "error: APK mtime unchanged after build ($apk) — refusing stale install" >&2 + exit 1 + fi + printf '%s\n' "$apk" } cmd_install() { diff --git a/vidya/src/chrome.rs b/vidya/src/chrome.rs new file mode 100644 index 0000000..010f980 --- /dev/null +++ b/vidya/src/chrome.rs @@ -0,0 +1,108 @@ +//! System chrome (status bar / nav bar) safe areas. +//! +//! Android NativeActivity draws **edge-to-edge**: the window fills under the +//! system status bar (clock, battery, …) and the gesture/nav bar. Without an +//! explicit reserve, app chrome and labels sit under those system widgets. +//! +//! Call [`reserve_system_chrome`] once at the start of every frame (before any +//! other `TopBottomPanel` / `SidePanel` / `CentralPanel`), or use +//! [`top_header`] which does that for you. + +use egui::{Context, Frame, Margin, Sense, Ui}; + +use crate::Theme; + +/// Insets for system status / navigation chrome on edge-to-edge surfaces. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SystemChrome { + /// Space under the status bar (clock, indicators). + pub top: f32, + /// Space above the system gesture / 3-button nav bar. + pub bottom: f32, +} + +impl SystemChrome { + pub const ZERO: Self = Self { + top: 0.0, + bottom: 0.0, + }; + + pub fn is_zero(self) -> bool { + self.top <= 0.0 && self.bottom <= 0.0 + } +} + +/// Platform defaults for edge-to-edge drawing. +/// +/// Values match common gesture-nav phones (≈24–36 dp status, ≈48 dp nav). +/// Override later if/when real `WindowInsets` are plumbed through egui. +pub fn system_chrome(_ctx: &Context) -> SystemChrome { + #[cfg(target_os = "android")] + { + SystemChrome { + top: 36.0, + bottom: 48.0, + } + } + #[cfg(not(target_os = "android"))] + { + SystemChrome::ZERO + } +} + +/// Reserve top/bottom strips so **no** subsequent panel or central content can +/// paint under the system status or navigation bars. +/// +/// Call this **once per frame**, before other panels. Safe to call when insets +/// are zero (no-op on desktop). +/// +/// The reserved bands use the theme headerbar fill so they blend with a top +/// header sitting just below the status bar. +pub fn reserve_system_chrome(ctx: &Context, theme: &Theme) { + let chrome = system_chrome(ctx); + if chrome.is_zero() { + return; + } + + let fill = theme.palette.headerbar_bg; + let band = Frame::new().fill(fill).inner_margin(Margin::ZERO); + + if chrome.top > 0.0 { + egui::TopBottomPanel::top("vidya_system_chrome_top") + .exact_height(chrome.top) + .frame(band) + .show_separator_line(false) + .show(ctx, |_ui| {}); + } + + if chrome.bottom > 0.0 { + egui::TopBottomPanel::bottom("vidya_system_chrome_bottom") + .exact_height(chrome.bottom) + .frame(band) + .show_separator_line(false) + .show(ctx, |ui| { + // Absorb input so gestures aren't stolen by widgets behind the band. + ui.allocate_exact_size(ui.available_size(), Sense::hover()); + }); + } +} + +/// Top app header with system status bar already reserved. +/// +/// Preferred entry for shell chrome: apps cannot place title/status text under +/// the clock / indicators. Also reserves the bottom system nav band. +/// +/// ```ignore +/// vidya::top_header(ctx, &theme, |ui| { +/// ui.horizontal(|ui| { +/// vidya::title(ui, &theme, "My App"); +/// }); +/// }); +/// ``` +pub fn top_header(ctx: &Context, theme: &Theme, add_contents: impl FnOnce(&mut Ui)) { + reserve_system_chrome(ctx, theme); + egui::TopBottomPanel::top("vidya_app_header") + .frame(theme.header_frame()) + .show_separator_line(false) + .show(ctx, add_contents); +} diff --git a/vidya/src/lib.rs b/vidya/src/lib.rs index 52d5787..1f48c97 100644 --- a/vidya/src/lib.rs +++ b/vidya/src/lib.rs @@ -3,6 +3,10 @@ //! Palette and spacing track GNOME HIG / libadwaita *feel* without linking GTK //! or using that name in the public API. Dark is the default. //! +//! On Android (edge-to-edge), call [`reserve_system_chrome`] once per frame +//! **before** other panels, or use [`top_header`] so content cannot sit under +//! the system status / navigation bars. +//! //! ```ignore //! use vidya::{apply_dark, Theme}; //! @@ -10,8 +14,10 @@ //! if vidya::primary_button(ui, &Theme::dark(), "Open").clicked() { /* … */ } //! ``` +mod chrome; mod theme; +pub use chrome::{reserve_system_chrome, system_chrome, top_header, SystemChrome}; pub use theme::{ body, button, destructive_button, dim_label, primary_button, title, title_2, Mode, Palette, Spacing, Theme, TypeScale,