From 049456521f75df431037ecb2a191496ee6a8a25f Mon Sep 17 00:00:00 2001 From: Patrick Ecord Date: Sun, 21 Jun 2026 01:26:37 -0500 Subject: [PATCH] macos: Nearby-devices picker (AirDrop-style) over the LAN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a horizontal strip of discovered tether devices to the window: the store polls engine.nearby() every 2s while connected and renders each as a chip with its friendly name + platform icon. Tapping a device calls pair() — sets room to that device's room and reconnects, so you join it without typing a room id. Devices already in your room show "in your room" (disabled). Verified live on macOS: two named LAN devices appear as "tap to join" chips once the app has Local Network permission. Co-Authored-By: Claude Opus 4.8 --- ios/MacTetherApp/Sources/ContentView.swift | 38 +++++++++++++++++++ ios/MacTetherApp/Sources/MacTetherStore.swift | 20 ++++++++++ 2 files changed, 58 insertions(+) diff --git a/ios/MacTetherApp/Sources/ContentView.swift b/ios/MacTetherApp/Sources/ContentView.swift index c491054..ba3e9b2 100644 --- a/ios/MacTetherApp/Sources/ContentView.swift +++ b/ios/MacTetherApp/Sources/ContentView.swift @@ -8,6 +8,10 @@ struct ContentView: View { VStack(spacing: 0) { toolbar Divider() + if store.connected && !store.nearby.isEmpty { + nearbyStrip + Divider() + } feedArea Divider() composer @@ -16,6 +20,40 @@ struct ContentView: View { .task { store.connectIfSaved() } } + // 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) + .padding(.trailing, 4) + 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) + .help(here ? "Already in this room" : "Join \(peer.name)'s room") + } + } + .padding(.horizontal, 12).padding(.vertical, 6) + } + } + // MARK: - Toolbar private var toolbar: some View { diff --git a/ios/MacTetherApp/Sources/MacTetherStore.swift b/ios/MacTetherApp/Sources/MacTetherStore.swift index 6131a2a..1c6d3c1 100644 --- a/ios/MacTetherApp/Sources/MacTetherStore.swift +++ b/ios/MacTetherApp/Sources/MacTetherStore.swift @@ -19,6 +19,7 @@ final class MacTetherStore { var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "https://tether.pecord.io" var room: String = RoomIdentity.derive() var feed: [FeedItem] = [] + var nearby: [Peer] = [] // tether devices on the LAN, for the picker var connected = false var lastError: String? var hasAttemptedConnect = false @@ -27,6 +28,7 @@ final class MacTetherStore { private var engine: Engine? private var bridge: EngineBridge? private var pollTask: Task? + private var nearbyTask: Task? private var lastChangeCount = NSPasteboard.general.changeCount func connectIfSaved() { @@ -53,6 +55,15 @@ final class MacTetherStore { connected = true 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 } + } + } + // Send: poll NSPasteboard for local changes → push to room. lastChangeCount = NSPasteboard.general.changeCount pollTask = Task { [weak self] in @@ -75,9 +86,18 @@ final class MacTetherStore { bridge = nil pollTask?.cancel() pollTask = 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() + } + func send(_ text: String) { engine?.send(text: text) }