ios: Nearby-devices picker (AirDrop-style), matching the Mac

Port the nearby picker to iOS: the store polls engine.nearby() while connected
and RoomView shows discovered devices as tap-to-join chips with platform icons,
same as MacTetherApp. pair() joins the device's room.

Dormant on iOS until the multicast entitlement lands (Rust mdns-sd uses a raw
multicast socket iOS blocks without it) — the UI is ready; it'll populate once
the entitlement is granted. No effect when the list is empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 02:39:27 -05:00
parent 290284f4e1
commit cb0df2af81
2 changed files with 64 additions and 0 deletions

View File

@@ -9,6 +9,10 @@ struct RoomView: View {
VStack(spacing: 0) {
connectionBar
Divider()
if store.connected && !store.nearby.isEmpty {
nearbyStrip
Divider()
}
feedList
Divider()
composer
@@ -50,6 +54,46 @@ struct RoomView: View {
.padding()
}
// MARK: - Nearby devices (AirDrop-style picker)
private var nearbyStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
Label("Nearby", systemImage: "dot.radiowaves.left.and.right")
.font(.caption).foregroundStyle(.secondary)
ForEach(store.nearby, id: \.id) { peer in
let here = peer.room == store.room
Button {
store.pair(with: peer)
} label: {
HStack(spacing: 6) {
Image(systemName: sourceIcon(peer.source))
VStack(alignment: .leading, spacing: 1) {
Text(peer.name).font(.caption).lineLimit(1)
Text(here ? "in your room" : "tap to join")
.font(.caption2)
.foregroundStyle(here ? .green : .secondary)
}
}
.padding(.horizontal, 10).padding(.vertical, 6)
.background(Color.primary.opacity(0.06), in: Capsule())
}
.buttonStyle(.plain)
.disabled(here)
}
}
.padding(.horizontal, 12).padding(.vertical, 6)
}
}
private func sourceIcon(_ source: String) -> String {
if source.contains("iphone") || source.contains("ios") { return "iphone" }
if source.contains("ipad") { return "ipad" }
if source.contains("mac") { return "laptopcomputer" }
if source.contains("windows") { return "pc" }
return "desktopcomputer"
}
private var feedList: some View {
List(store.feed) { msg in
VStack(alignment: .leading, spacing: 4) {

View File

@@ -30,12 +30,14 @@ final class TetherStore {
return a.isEmpty ? RoomIdentity.derive() : roomForAccount(account: a)
}()
var feed: [FeedItem] = []
var nearby: [Peer] = [] // tether devices on the LAN (empty on iOS w/o multicast entitlement)
var connected = false
var lastError: String?
private let deviceID = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
private var engine: Engine?
private var bridge: EngineBridge?
private var nearbyTask: Task<Void, Never>?
func connectIfSaved() {
guard UserDefaults.standard.string(forKey: "serverURL") != nil else { return }
@@ -58,15 +60,33 @@ final class TetherStore {
engine = e
bridge = b
lastError = nil
// Poll the engine's mDNS-discovered device list for the picker.
nearbyTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2))
guard let self, let engine = self.engine else { continue }
self.nearby = engine.nearby().sorted { $0.name < $1.name }
}
}
}
func disconnect() {
engine?.stop()
engine = nil
bridge = nil
nearbyTask?.cancel()
nearbyTask = nil
nearby = []
connected = false
}
/// Join a discovered device's room the "pair" action from the picker.
func pair(with peer: Peer) {
room = peer.room
connect()
}
/// Read the system pasteboard (foreground only iOS bans background reads)
/// and push it to the room.
func sendClipboard() {