presence: "who's here" — online devices in the room, with names
Presence chirps now carry the friendly device name, and the engine tracks the present set as Peers (id/name/source/room). New Engine.present() -> [Peer] exposes who's online in the room across any network. Both apps poll it and show a green-dot row of online devices above the feed. The present set already powered prefer-direct's coverage check; this enriches it with names and surfaces it. Verified live on macOS: an agent in the room shows as "● Patrick's NAS". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -92,10 +92,9 @@ type Receipts = Arc<Mutex<Vec<(Receipt, Instant)>>>;
|
|||||||
/// by `from`. Written by the RTC/LAN transports, read by `send` to decide whether
|
/// by `from`. Written by the RTC/LAN transports, read by `send` to decide whether
|
||||||
/// the relay is still needed.
|
/// the relay is still needed.
|
||||||
type DirectSet = Arc<Mutex<HashSet<String>>>;
|
type DirectSet = Arc<Mutex<HashSet<String>>>;
|
||||||
/// Peers currently in the room, by `from`, last-seen via presence chirps. Lets
|
/// Peers currently in the room, by `from`, with their Peer info and last-seen
|
||||||
/// `send` know the full audience so it only suppresses the relay when *every*
|
/// (via presence chirps). Drives both `send`'s coverage check and `present()`.
|
||||||
/// present peer is directly reachable.
|
type PresentSet = Arc<Mutex<HashMap<String, (Peer, Instant)>>>;
|
||||||
type PresentSet = Arc<Mutex<HashMap<String, Instant>>>;
|
|
||||||
|
|
||||||
/// Short content fingerprint — a receipt carries this, never the clipboard text,
|
/// Short content fingerprint — a receipt carries this, never the clipboard text,
|
||||||
/// so acks can't leak content (sender matches by computing the same fingerprint).
|
/// so acks can't leak content (sender matches by computing the same fingerprint).
|
||||||
@@ -112,7 +111,7 @@ fn directly_covered(direct: &DirectSet, present: &PresentSet) -> bool {
|
|||||||
let d = direct.lock().unwrap();
|
let d = direct.lock().unwrap();
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let mut p = present.lock().unwrap();
|
let mut p = present.lock().unwrap();
|
||||||
p.retain(|_, t| now.duration_since(*t) < Duration::from_secs(20));
|
p.retain(|_, (_, t)| now.duration_since(*t) < Duration::from_secs(20));
|
||||||
!p.is_empty() && p.keys().all(|x| d.contains(x))
|
!p.is_empty() && p.keys().all(|x| d.contains(x))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +233,15 @@ impl Engine {
|
|||||||
map.values().map(|(p, _)| p.clone()).collect()
|
map.values().map(|(p, _)| p.clone()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Devices currently in your room (online via presence chirps), across any
|
||||||
|
/// network — for a "who's here" indicator. Pruned after 20s unseen.
|
||||||
|
pub fn present(&self) -> Vec<Peer> {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut p = self.present.lock().unwrap();
|
||||||
|
p.retain(|_, (_, t)| now.duration_since(*t) < Duration::from_secs(20));
|
||||||
|
p.values().map(|(peer, _)| peer.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Begin streaming on every transport. Idempotent while already running.
|
/// Begin streaming on every transport. Idempotent while already running.
|
||||||
pub fn start(&self, handler: Box<dyn MessageHandler>) {
|
pub fn start(&self, handler: Box<dyn MessageHandler>) {
|
||||||
if self.running.swap(true, Ordering::SeqCst) {
|
if self.running.swap(true, Ordering::SeqCst) {
|
||||||
|
|||||||
@@ -138,7 +138,9 @@ impl RtcTransport {
|
|||||||
async fn post_presence(&self) {
|
async fn post_presence(&self) {
|
||||||
self.post(json!({
|
self.post(json!({
|
||||||
"type": "presence", "role": self.cfg.source,
|
"type": "presence", "role": self.cfg.source,
|
||||||
"from": self.cfg.from, "room": self.cfg.room,
|
"from": self.cfg.from, "source": self.cfg.source,
|
||||||
|
"text": self.cfg.name, // friendly name for the "who's here" list
|
||||||
|
"room": self.cfg.room,
|
||||||
}))
|
}))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -415,11 +417,24 @@ impl RtcTransport {
|
|||||||
}
|
}
|
||||||
match v["type"].as_str().unwrap_or("") {
|
match v["type"].as_str().unwrap_or("") {
|
||||||
"presence" => {
|
"presence" => {
|
||||||
// Track who's in the room so send() knows the full audience.
|
// Track who's in the room (with their name) for send()'s coverage
|
||||||
self.present
|
// check and the engine's present() "who's here" list.
|
||||||
.lock()
|
let name = v["text"].as_str().filter(|s| !s.is_empty()).unwrap_or(from);
|
||||||
.unwrap()
|
let source = v["source"].as_str().unwrap_or("").to_string();
|
||||||
.insert(from.to_string(), Instant::now());
|
let room = v["room"].as_str().unwrap_or("").to_string();
|
||||||
|
self.present.lock().unwrap().insert(
|
||||||
|
from.to_string(),
|
||||||
|
(
|
||||||
|
crate::Peer {
|
||||||
|
id: from.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
source,
|
||||||
|
room,
|
||||||
|
account: String::new(),
|
||||||
|
},
|
||||||
|
Instant::now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
// Deterministic initiator: the smaller id offers.
|
// Deterministic initiator: the smaller id offers.
|
||||||
if self.cfg.from.as_str() < from {
|
if self.cfg.from.as_str() < from {
|
||||||
self.initiate_offer(from).await;
|
self.initiate_offer(from).await;
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ struct ContentView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
toolbar
|
toolbar
|
||||||
Divider()
|
Divider()
|
||||||
|
if store.connected && !store.present.isEmpty {
|
||||||
|
presenceRow
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
if store.connected && !store.nearby.isEmpty {
|
if store.connected && !store.nearby.isEmpty {
|
||||||
nearbyStrip
|
nearbyStrip
|
||||||
Divider()
|
Divider()
|
||||||
@@ -38,6 +42,22 @@ struct ContentView: View {
|
|||||||
.padding(.horizontal, 14).padding(.vertical, 5)
|
.padding(.horizontal, 14).padding(.vertical, 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Presence ("who's here")
|
||||||
|
|
||||||
|
private var presenceRow: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(store.present, id: \.id) { peer in
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Circle().fill(.green).frame(width: 7, height: 7)
|
||||||
|
Image(systemName: sourceIcon(peer.source)).font(.caption2)
|
||||||
|
Text(peer.name).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 5)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Nearby devices (AirDrop-style picker)
|
// MARK: - Nearby devices (AirDrop-style picker)
|
||||||
|
|
||||||
private var nearbyStrip: some View {
|
private var nearbyStrip: some View {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ final class MacTetherStore {
|
|||||||
}()
|
}()
|
||||||
var feed: [FeedItem] = []
|
var feed: [FeedItem] = []
|
||||||
var nearby: [Peer] = [] // tether devices on the LAN, for the picker
|
var nearby: [Peer] = [] // tether devices on the LAN, for the picker
|
||||||
|
var present: [Peer] = [] // devices online in the room ("who's here")
|
||||||
var receipts: [Receipt] = [] // delivery acks — "seen by <device>"
|
var receipts: [Receipt] = [] // delivery acks — "seen by <device>"
|
||||||
var connected = false
|
var connected = false
|
||||||
var lastError: String?
|
var lastError: String?
|
||||||
@@ -69,6 +70,7 @@ final class MacTetherStore {
|
|||||||
try? await Task.sleep(for: .seconds(2))
|
try? await Task.sleep(for: .seconds(2))
|
||||||
guard let self, let engine = self.engine else { continue }
|
guard let self, let engine = self.engine else { continue }
|
||||||
self.nearby = engine.nearby().sorted { $0.name < $1.name }
|
self.nearby = engine.nearby().sorted { $0.name < $1.name }
|
||||||
|
self.present = engine.present().sorted { $0.name < $1.name }
|
||||||
self.receipts = engine.receipts()
|
self.receipts = engine.receipts()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ struct RoomView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
connectionBar
|
connectionBar
|
||||||
Divider()
|
Divider()
|
||||||
|
if store.connected && !store.present.isEmpty {
|
||||||
|
presenceRow
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
if store.connected && !store.nearby.isEmpty {
|
if store.connected && !store.nearby.isEmpty {
|
||||||
nearbyStrip
|
nearbyStrip
|
||||||
Divider()
|
Divider()
|
||||||
@@ -58,6 +62,23 @@ struct RoomView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Presence ("who's here")
|
||||||
|
|
||||||
|
private var presenceRow: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(store.present, id: \.id) { peer in
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Circle().fill(.green).frame(width: 7, height: 7)
|
||||||
|
Image(systemName: sourceIcon(peer.source)).font(.caption2)
|
||||||
|
Text(peer.name).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Nearby devices (AirDrop-style picker)
|
// MARK: - Nearby devices (AirDrop-style picker)
|
||||||
|
|
||||||
private var nearbyStrip: some View {
|
private var nearbyStrip: some View {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ final class TetherStore {
|
|||||||
}()
|
}()
|
||||||
var feed: [FeedItem] = []
|
var feed: [FeedItem] = []
|
||||||
var nearby: [Peer] = [] // tether devices on the LAN (empty on iOS w/o multicast entitlement)
|
var nearby: [Peer] = [] // tether devices on the LAN (empty on iOS w/o multicast entitlement)
|
||||||
|
var present: [Peer] = [] // devices online in the room ("who's here")
|
||||||
var receipts: [Receipt] = [] // delivery acks — "seen by <device>"
|
var receipts: [Receipt] = [] // delivery acks — "seen by <device>"
|
||||||
var connected = false
|
var connected = false
|
||||||
var lastError: String?
|
var lastError: String?
|
||||||
@@ -68,6 +69,7 @@ final class TetherStore {
|
|||||||
try? await Task.sleep(for: .seconds(2))
|
try? await Task.sleep(for: .seconds(2))
|
||||||
guard let self, let engine = self.engine else { continue }
|
guard let self, let engine = self.engine else { continue }
|
||||||
self.nearby = engine.nearby().sorted { $0.name < $1.name }
|
self.nearby = engine.nearby().sorted { $0.name < $1.name }
|
||||||
|
self.present = engine.present().sorted { $0.name < $1.name }
|
||||||
self.receipts = engine.receipts()
|
self.receipts = engine.receipts()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user