diff --git a/ios/TetherApp/Sources/RoomView.swift b/ios/TetherApp/Sources/RoomView.swift index a007aec..946e20b 100644 --- a/ios/TetherApp/Sources/RoomView.swift +++ b/ios/TetherApp/Sources/RoomView.swift @@ -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) { diff --git a/ios/TetherApp/Sources/TetherStore.swift b/ios/TetherApp/Sources/TetherStore.swift index e4be746..b18d562 100644 --- a/ios/TetherApp/Sources/TetherStore.swift +++ b/ios/TetherApp/Sources/TetherStore.swift @@ -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? 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() {