macos: Nearby-devices picker (AirDrop-style) over the LAN
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 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,10 @@ struct ContentView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
toolbar
|
toolbar
|
||||||
Divider()
|
Divider()
|
||||||
|
if store.connected && !store.nearby.isEmpty {
|
||||||
|
nearbyStrip
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
feedArea
|
feedArea
|
||||||
Divider()
|
Divider()
|
||||||
composer
|
composer
|
||||||
@@ -16,6 +20,40 @@ struct ContentView: View {
|
|||||||
.task { store.connectIfSaved() }
|
.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
|
// MARK: - Toolbar
|
||||||
|
|
||||||
private var toolbar: some View {
|
private var toolbar: some View {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ final class MacTetherStore {
|
|||||||
var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "https://tether.pecord.io"
|
var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "https://tether.pecord.io"
|
||||||
var room: String = RoomIdentity.derive()
|
var room: String = RoomIdentity.derive()
|
||||||
var feed: [FeedItem] = []
|
var feed: [FeedItem] = []
|
||||||
|
var nearby: [Peer] = [] // tether devices on the LAN, for the picker
|
||||||
var connected = false
|
var connected = false
|
||||||
var lastError: String?
|
var lastError: String?
|
||||||
var hasAttemptedConnect = false
|
var hasAttemptedConnect = false
|
||||||
@@ -27,6 +28,7 @@ final class MacTetherStore {
|
|||||||
private var engine: Engine?
|
private var engine: Engine?
|
||||||
private var bridge: EngineBridge?
|
private var bridge: EngineBridge?
|
||||||
private var pollTask: Task<Void, Never>?
|
private var pollTask: Task<Void, Never>?
|
||||||
|
private var nearbyTask: Task<Void, Never>?
|
||||||
private var lastChangeCount = NSPasteboard.general.changeCount
|
private var lastChangeCount = NSPasteboard.general.changeCount
|
||||||
|
|
||||||
func connectIfSaved() {
|
func connectIfSaved() {
|
||||||
@@ -53,6 +55,15 @@ final class MacTetherStore {
|
|||||||
connected = true
|
connected = true
|
||||||
lastError = nil
|
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.
|
// Send: poll NSPasteboard for local changes → push to room.
|
||||||
lastChangeCount = NSPasteboard.general.changeCount
|
lastChangeCount = NSPasteboard.general.changeCount
|
||||||
pollTask = Task { [weak self] in
|
pollTask = Task { [weak self] in
|
||||||
@@ -75,9 +86,18 @@ final class MacTetherStore {
|
|||||||
bridge = nil
|
bridge = nil
|
||||||
pollTask?.cancel()
|
pollTask?.cancel()
|
||||||
pollTask = nil
|
pollTask = nil
|
||||||
|
nearbyTask?.cancel()
|
||||||
|
nearbyTask = nil
|
||||||
|
nearby = []
|
||||||
connected = false
|
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) {
|
func send(_ text: String) {
|
||||||
engine?.send(text: text)
|
engine?.send(text: text)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user