From 1c1879a446e664f8049e94cfeeb4f41e367b7c8b Mon Sep 17 00:00:00 2001 From: Gabriel Souza Date: Wed, 24 Jun 2026 15:47:14 -0300 Subject: [PATCH] feat: Android Wi-Fi device pairing (QR + pairing code) with remember/auto-reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pair physical Android devices over Wi-Fi from a sheet in the Devices area, driving the `adb` CLI — both QR-code and pairing-code modes. - QR mode: generate service name + password, render the QR (CoreImage), and auto-pair when the phone advertises `_adb-tls-pairing._tcp` under our name. - Pairing-code mode: live mDNS discovery list + a manual `ip:port` fallback (the phone shows it) for networks that block multicast. - Surfaces adb's failure message instead of failing silently, detects a disabled mDNS daemon via `adb mdns check`, and offers a one-click `adb kill-server` recovery. - Remembers paired devices (name, last IP, method, last-seen) in Application Support and reattaches them in the background when their `_adb-tls-connect` service reappears — the auto-recover adb skips. New: Core/Devices/{MdnsService,AdbPairingService,QrPairing,PairedDeviceStore}, Model/PairingModel, Features/Pairing/PairDeviceSheet. Wired into AppModel (factory + reconnect loop) and DeviceSidebarView (button + sheet). Tests: Tests/PairingTests.swift covers the pure logic — mDNS parser, PairResult, MdnsHealth, QR payload/credentials, and the paired-device store (17 cases). Not yet exercised end-to-end against a physical device. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + Sources/Core/Devices/AdbPairingService.swift | 122 ++++++++ Sources/Core/Devices/MdnsService.swift | 95 ++++++ Sources/Core/Devices/PairedDeviceStore.swift | 141 +++++++++ Sources/Core/Devices/QrPairing.swift | 36 +++ .../Features/Pairing/PairDeviceSheet.swift | 292 ++++++++++++++++++ .../Features/Sidebar/DeviceSidebarView.swift | 18 ++ Sources/Model/AppModel.swift | 33 ++ Sources/Model/PairingModel.swift | 210 +++++++++++++ Tests/PairingTests.swift | 158 ++++++++++ 10 files changed, 1108 insertions(+) create mode 100644 Sources/Core/Devices/AdbPairingService.swift create mode 100644 Sources/Core/Devices/MdnsService.swift create mode 100644 Sources/Core/Devices/PairedDeviceStore.swift create mode 100644 Sources/Core/Devices/QrPairing.swift create mode 100644 Sources/Features/Pairing/PairDeviceSheet.swift create mode 100644 Sources/Model/PairingModel.swift create mode 100644 Tests/PairingTests.swift diff --git a/.gitignore b/.gitignore index eba3404..1897f22 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ xcuserdata/ # SwiftPM .swiftpm/ .build/ + +# Claude Code — personal, machine-local project memory (never commit) +CLAUDE.local.md diff --git a/Sources/Core/Devices/AdbPairingService.swift b/Sources/Core/Devices/AdbPairingService.swift new file mode 100644 index 0000000..b9b0df1 --- /dev/null +++ b/Sources/Core/Devices/AdbPairingService.swift @@ -0,0 +1,122 @@ +import Foundation + +/// Outcome of `adb pair`. adb prints, on success: +/// `Successfully paired to 192.168.1.174:40711 [guid=adb-43081FDAS000ST-GIVKML]` +/// and on failure a `Failed: …` line (wrong code, dropped connection, no client). +/// We keep the raw text for diagnostics — surfacing *why* it failed is the whole +/// point of doing better than Studio's silent failures. +struct PairResult: Sendable, Equatable { + let success: Bool + let guid: String? + let address: String? + let message: String + + /// Pure parser over adb's combined output, so it can be unit-tested without adb. + static func parse(stdout: String, stderr: String, exitCode: Int32) -> PairResult { + let text = (stdout + "\n" + stderr).trimmingCharacters(in: .whitespacesAndNewlines) + if let match = successRegex.firstMatch( + in: text, range: NSRange(text.startIndex..., in: text) + ), let addrRange = Range(match.range(at: 1), in: text), + let guidRange = Range(match.range(at: 2), in: text) { + return PairResult( + success: true, + guid: String(text[guidRange]), + address: String(text[addrRange]), + message: text + ) + } + // Some adb builds print "Successfully paired" without a guid; treat as success. + if exitCode == 0, text.localizedCaseInsensitiveContains("successfully paired") { + return PairResult(success: true, guid: nil, address: nil, message: text) + } + let message = text.isEmpty ? "Pairing failed (no output from adb)." : text + return PairResult(success: false, guid: nil, address: nil, message: message) + } + + // "Successfully paired to [guid=]" + private static let successRegex = try! NSRegularExpression( + pattern: #"Successfully paired to (.+?) \[guid=([^\]]*)\]"# + ) +} + +/// State of the adb server's mDNS subsystem, from `adb mdns check`. A disabled or +/// stale mDNS daemon is the #1 reason wireless devices silently never appear, so we +/// detect it and offer a one-click `adb kill-server` recovery. +enum MdnsHealth: Sendable, Equatable { + case ok(backend: String) // e.g. "Openscreen discovery 0.0.0" / "Bonjour" + case disabled(String) // adb reports mdns unavailable/disabled + case unknown(String) // couldn't classify (raw text kept) + + var isHealthy: Bool { if case .ok = self { return true }; return false } + + static func parse(stdout: String, stderr: String) -> MdnsHealth { + let text = (stdout + "\n" + stderr).trimmingCharacters(in: .whitespacesAndNewlines) + let lower = text.lowercased() + if lower.contains("error") || lower.contains("unavailable") || lower.contains("disabled") { + return .disabled(text) + } + // "mdns daemon version [Openscreen discovery 0.0.0]" + if let open = text.range(of: "["), let close = text.range(of: "]", options: .backwards), + open.upperBound <= close.lowerBound { + let backend = String(text[open.upperBound.. [MdnsService] { + guard let result = try? await CommandRunner.run(adbURL, ["mdns", "services"]) else { return [] } + return MdnsServiceParser.parse(result.stdout) + } + + /// `adb mdns check` → health of the adb server's mDNS discovery. + func mdnsCheck() async -> MdnsHealth { + guard let result = try? await CommandRunner.run(adbURL, ["mdns", "check"]) else { + return .unknown("adb did not run") + } + return MdnsHealth.parse(stdout: result.stdout, stderr: result.stderr) + } + + /// `adb pair `. The code is passed as an argument so adb never + /// prompts on stdin (which would hang us). + func pair(address: String, code: String) async -> PairResult { + guard let result = try? await CommandRunner.run(adbURL, ["pair", address, code]) else { + return PairResult(success: false, guid: nil, address: address, + message: "Could not launch adb.") + } + return PairResult.parse(stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode) + } + + /// `adb connect ` — used to (re)attach an already-paired device whose + /// `_adb-tls-connect` service we found over mDNS. Returns true on apparent success. + @discardableResult + func connect(address: String) async -> Bool { + guard let result = try? await CommandRunner.run(adbURL, ["connect", address]) else { return false } + let text = (result.stdout + result.stderr).lowercased() + // adb prints "connected to …" / "already connected …" on success, + // "failed to connect …" / "cannot connect …" on failure. + return text.contains("connected to") || text.contains("already connected") + } + + @discardableResult + func disconnect(address: String) async -> Bool { + guard let result = try? await CommandRunner.run(adbURL, ["disconnect", address]) else { return false } + return result.exitCode == 0 + } + + /// Restart the adb server to clear a wedged/disabled mDNS daemon. Best-effort. + func restartServer() async { + _ = try? await CommandRunner.run(adbURL, ["kill-server"]) + _ = try? await CommandRunner.run(adbURL, ["start-server"]) + } +} diff --git a/Sources/Core/Devices/MdnsService.swift b/Sources/Core/Devices/MdnsService.swift new file mode 100644 index 0000000..a37a72a --- /dev/null +++ b/Sources/Core/Devices/MdnsService.swift @@ -0,0 +1,95 @@ +import Foundation + +/// The three mDNS service types adb advertises/browses. We key pairing and +/// auto-reconnect off these. See `aosp-adb/adb_mdns.{h,cpp}`: +/// _adb-tls-pairing._tcp — a device sitting on its "Wireless debugging → Pair" +/// screen (advertised only while that screen is open). +/// _adb-tls-connect._tcp — an already-paired device ready to be connected to. +/// _adb._tcp — legacy plaintext (`adb tcpip`), port 5555. +enum AdbMdnsServiceType: String, Sendable, CaseIterable { + case pairing = "_adb-tls-pairing._tcp" + case connect = "_adb-tls-connect._tcp" + case legacy = "_adb._tcp" +} + +/// One line of `adb mdns services` output: an instance name, a service type, and +/// the host:port the service is reachable at. The instance name doubles as the +/// device GUID for `_adb-tls-connect` (e.g. "adb-939AX05XBZ-vWgJpq") and as our +/// chosen QR service name for `_adb-tls-pairing` after a QR scan. +struct MdnsService: Identifiable, Hashable, Sendable { + let instance: String + /// Raw service type, normalized without a trailing dot (e.g. "_adb-tls-pairing._tcp"). + let serviceType: String + let ip: String + let port: Int + + var address: String { "\(ip):\(port)" } + var kind: AdbMdnsServiceType? { AdbMdnsServiceType(rawValue: serviceType) } + /// Stable identity: same service can be heard under multiple types, so include all. + var id: String { "\(instance)\t\(serviceType)\t\(address)" } +} + +/// Parses `adb mdns services` text output into `MdnsService`s. Pure & synchronous +/// for testing, mirroring `AndroidDeviceParser`. Each useful line is tab-separated: +/// +/// List of discovered mdns services +/// adb-939AX05XBZ-vWgJpq _adb-tls-connect._tcp 192.168.1.86:39149 +/// adb-939AX05XBZ-vWgJpq _adb-tls-pairing._tcp 192.168.1.86:37313 +/// +/// Some adb builds use spaces instead of tabs, so we fall back to whitespace splitting. +/// Mirrors adblib's `MdnsServiceListParser`: drops `0.0.0.0` rows (b/390429989) and +/// deduplicates identical entries. +enum MdnsServiceParser { + static func parse(_ output: String) -> [MdnsService] { + var services: [MdnsService] = [] + var seen = Set() + + for rawLine in output.split(separator: "\n", omittingEmptySubsequences: true) { + let line = rawLine.trimmingCharacters(in: .whitespaces) + if line.isEmpty { continue } + if line.hasPrefix("List of") { continue } // header + if line.hasPrefix("*") { continue } // "* daemon started …" + if line.hasPrefix("adb:") { continue } // error lines + + // Prefer tab separation (the documented format); fall back to runs of + // whitespace for adb builds that align columns with spaces. + let fields: [String] + if line.contains("\t") { + fields = line.split(separator: "\t", omittingEmptySubsequences: true) + .map { $0.trimmingCharacters(in: .whitespaces) } + } else { + fields = line.split(whereSeparator: { $0 == " " }) + .map(String.init) + } + guard fields.count >= 3 else { continue } + + let instance = fields[0] + let serviceType = normalizeType(fields[1]) + // The address is the last field; everything between is part of the type + // only in malformed lines, so take first three meaningful columns. + guard let (ip, port) = splitAddress(fields[2]) else { continue } + if ip == "0.0.0.0" { continue } // bogus row adb sometimes emits + + let service = MdnsService(instance: instance, serviceType: serviceType, ip: ip, port: port) + if seen.insert(service.id).inserted { + services.append(service) + } + } + return services + } + + /// Strips a trailing `.` adb appends in some builds ("_adb-tls-connect._tcp."). + private static func normalizeType(_ raw: String) -> String { + raw.hasSuffix(".") ? String(raw.dropLast()) : raw + } + + /// Splits "192.168.1.86:39149" into ("192.168.1.86", 39149). Tolerates IPv6 by + /// splitting on the final colon. + private static func splitAddress(_ raw: String) -> (ip: String, port: Int)? { + guard let colon = raw.lastIndex(of: ":") else { return nil } + let ip = String(raw[raw.startIndex.. 0 else { return nil } + return (ip, port) + } +} diff --git a/Sources/Core/Devices/PairedDeviceStore.swift b/Sources/Core/Devices/PairedDeviceStore.swift new file mode 100644 index 0000000..97e0468 --- /dev/null +++ b/Sources/Core/Devices/PairedDeviceStore.swift @@ -0,0 +1,141 @@ +import Foundation + +/// How a device was paired. Surfaced in the UI and remembered so a re-pair offers +/// the method that worked last time. +enum PairingMethod: String, Codable, Sendable { + case qrCode + case pairingCode + + var label: String { + switch self { + case .qrCode: return "QR code" + case .pairingCode: return "Pairing code" + } + } +} + +/// A device Jaca has paired with before. adb's own `adb_known_hosts.pb` stores only +/// a bare GUID — no name, no last-seen, no way to say *why* a remembered device isn't +/// here right now. We store the richer record so the UI can say "Pixel 8 — paired 3 +/// days ago, not on this network" instead of nothing, and so we can proactively +/// reconnect it when its `_adb-tls-connect` service reappears. +struct PairedDevice: Codable, Identifiable, Sendable, Hashable { + /// mDNS instance name == device GUID (e.g. "adb-939AX05XBZ-vWgJpq"). Stable across + /// reconnects and IP changes; the join key against discovered connect services. + var guid: String + var name: String + /// Last `ip:port` we saw its `_adb-tls-connect` service at (for a targeted reconnect). + var lastAddress: String? + var method: PairingMethod + var firstPairedAt: Date + var lastSeenAt: Date + + var id: String { guid } +} + +/// JSON-backed persistence of paired devices under Application Support/Jaca, plus the +/// reconciliation logic that makes reconnect "just work". Actor-isolated; file IO is +/// small and serialized. +actor PairedDeviceStore { + static let shared = PairedDeviceStore() + + private let fileURL: URL + private var devices: [String: PairedDevice] = [:] + private var loaded = false + + init(fileURL: URL? = nil) { + if let fileURL { + self.fileURL = fileURL + } else { + let base = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Jaca", isDirectory: true) + try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + self.fileURL = base.appendingPathComponent("paired-devices.json") + } + } + + func all() -> [PairedDevice] { + load() + return devices.values.sorted { $0.lastSeenAt > $1.lastSeenAt } + } + + /// Upserts a freshly-paired device, stamping it as seen now. + func remember(guid: String, name: String, address: String?, method: PairingMethod, now: Date = Date()) { + load() + if var existing = devices[guid] { + existing.name = name.isEmpty ? existing.name : name + existing.lastAddress = address ?? existing.lastAddress + existing.method = method + existing.lastSeenAt = now + devices[guid] = existing + } else { + devices[guid] = PairedDevice( + guid: guid, name: name, lastAddress: address, + method: method, firstPairedAt: now, lastSeenAt: now + ) + } + save() + } + + /// Records that a known device was just observed (over mDNS or `adb devices`). + func markSeen(guid: String, address: String?, now: Date = Date()) { + load() + guard var device = devices[guid] else { return } + device.lastSeenAt = now + if let address { device.lastAddress = address } + devices[guid] = device + save() + } + + func forget(guid: String) { + load() + devices.removeValue(forKey: guid) + save() + } + + /// Given the connect services adb currently sees over mDNS, return the addresses of + /// *known* devices so the caller can `adb connect` any that aren't attached yet. + /// Updates last-seen as a side effect. This is the "auto-recover" core: adb doesn't + /// always reattach a paired device on its own, so we nudge it. + func reconnectTargets(from services: [MdnsService], now: Date = Date()) -> [(guid: String, address: String)] { + load() + var targets: [(String, String)] = [] + for service in services where service.kind == .connect { + guard var device = devices[service.instance] else { continue } + device.lastSeenAt = now + device.lastAddress = service.address + devices[service.instance] = device + targets.append((service.instance, service.address)) + } + if !targets.isEmpty { save() } + return targets + } + + // MARK: - Disk + + private func load() { + guard !loaded else { return } + loaded = true + guard let data = try? Data(contentsOf: fileURL), + let list = try? JSONDecoder.iso8601.decode([PairedDevice].self, from: data) else { return } + devices = Dictionary(uniqueKeysWithValues: list.map { ($0.guid, $0) }) + } + + private func save() { + guard let data = try? JSONEncoder.iso8601.encode(Array(devices.values)) else { return } + try? data.write(to: fileURL, options: .atomic) + } +} + +private extension JSONDecoder { + static let iso8601: JSONDecoder = { + let d = JSONDecoder(); d.dateDecodingStrategy = .iso8601; return d + }() +} + +private extension JSONEncoder { + static let iso8601: JSONEncoder = { + let e = JSONEncoder(); e.dateEncodingStrategy = .iso8601; e.outputFormatting = [.prettyPrinted, .sortedKeys]; return e + }() +} diff --git a/Sources/Core/Devices/QrPairing.swift b/Sources/Core/Devices/QrPairing.swift new file mode 100644 index 0000000..b6f4c4e --- /dev/null +++ b/Sources/Core/Devices/QrPairing.swift @@ -0,0 +1,36 @@ +import Foundation + +/// The credentials encoded into a Wi-Fi-debugging pairing QR code. When the phone +/// scans the QR (Settings → Wireless debugging → *Pair device with QR code*), it +/// starts advertising an `_adb-tls-pairing._tcp` mDNS service whose **instance name +/// equals `serviceName`**, protected by `password`. We then spot that exact instance +/// over mDNS and run `adb pair `. +/// +/// Pure & Foundation-only so it's unit-testable. QR *image* rendering lives in the +/// view layer (CoreImage), keeping this side effect-free. +struct QrPairingCredentials: Sendable, Equatable { + let serviceName: String + let password: String + + /// The exact string the QR must encode. Android parses this as a Wi-Fi-style + /// payload with type `ADB`. Format (from Android Studio): `WIFI:T:ADB;S:;P:;;` + var qrPayload: String { "WIFI:T:ADB;S:\(serviceName);P:\(password);;" } + + /// Generates fresh credentials. The phone advertises whatever name we pick, so the + /// only requirements are uniqueness (to disambiguate our QR from other devices' + /// pairing services) and that the password be hard to guess. We follow Studio's + /// "studio-" convention with a "jaca-" prefix. + static func generate() -> QrPairingCredentials { + QrPairingCredentials( + serviceName: "jaca-" + randomString(length: 10, charset: alphanumeric), + password: randomString(length: 12, charset: alphanumeric) + ) + } + + // Avoid ';', ':' and other characters that have meaning in the payload grammar. + private static let alphanumeric = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") + + private static func randomString(length: Int, charset: [Character]) -> String { + String((0.. some View { + let selected = model.selectedServiceID == service.id + return Button { + model.selectedServiceID = selected ? nil : service.id + model.manualAddress = "" + } label: { + HStack(spacing: LemonadeTheme.spaces.spacing200) { + Image(systemName: selected ? "largecircle.fill.circle" : "circle") + .foregroundStyle(selected + ? LemonadeTheme.colors.content.contentBrand + : LemonadeTheme.colors.content.contentTertiary) + VStack(alignment: .leading, spacing: 1) { + LemonadeUi.Text(service.instance, + textStyle: LemonadeTypography.shared.bodySmallSemiBold, + color: LemonadeTheme.colors.content.contentPrimary, maxLines: 1) + LemonadeUi.Text(service.address, + textStyle: LemonadeTypography.shared.bodyXSmallRegular, + color: LemonadeTheme.colors.content.contentTertiary, maxLines: 1) + } + Spacer(minLength: 0) + } + .padding(.horizontal, LemonadeTheme.spaces.spacing200) + .padding(.vertical, LemonadeTheme.spaces.spacing200) + .background(RoundedRectangle(cornerRadius: LemonadeTheme.radius.radius150) + .fill(selected ? LemonadeTheme.colors.interaction.bgSubtleInteractive : .clear)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var manualEntry: some View { + VStack(alignment: .leading, spacing: LemonadeTheme.spaces.spacing100) { + LemonadeUi.Text("OR ENTER MANUALLY", + textStyle: LemonadeTypography.shared.bodyXSmallOverline, + color: LemonadeTheme.colors.content.contentTertiary) + HStack(spacing: LemonadeTheme.spaces.spacing200) { + TextField("192.168.1.42:37313", text: $model.manualAddress) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + .onChange(of: model.manualAddress) { _, newValue in + if !newValue.isEmpty { model.selectedServiceID = nil } + } + TextField("code", text: $model.pairingCode) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + .frame(width: 90) + } + } + } + + // MARK: - Shared status + + private var isPairing: Bool { if case .pairing = model.status { return true }; return false } + + @ViewBuilder + private var statusView: some View { + switch model.status { + case .idle: + EmptyView() + case .waitingForScan: + HStack(spacing: LemonadeTheme.spaces.spacing200) { + LemonadeUi.Spinner() + LemonadeUi.Text("Waiting for your device to scan the code…", + textStyle: LemonadeTypography.shared.bodySmallRegular, + color: LemonadeTheme.colors.content.contentSecondary) + } + case .pairing: + HStack(spacing: LemonadeTheme.spaces.spacing200) { + LemonadeUi.Spinner() + LemonadeUi.Text("Pairing…", + textStyle: LemonadeTypography.shared.bodySmallRegular, + color: LemonadeTheme.colors.content.contentSecondary) + } + case .paired(let name): + VStack(alignment: .leading, spacing: LemonadeTheme.spaces.spacing200) { + LemonadeUi.Notice(content: "Paired with \(name). It should appear in your device list now.", + voice: .info, title: "Paired ✓") + LemonadeUi.Button(label: "Pair another", onClick: { model.reset() }, + variant: .neutral, type: .subtle, size: .small) + } + case .failed(let message): + VStack(alignment: .leading, spacing: LemonadeTheme.spaces.spacing200) { + LemonadeUi.Notice(content: message, voice: .critical, title: "Pairing failed") + LemonadeUi.Button(label: "Try again", onClick: { model.reset() }, + variant: .neutral, type: .subtle, size: .small) + } + } + } + + private func steps(_ items: [String]) -> some View { + VStack(alignment: .leading, spacing: LemonadeTheme.spaces.spacing200) { + ForEach(Array(items.enumerated()), id: \.offset) { index, item in + HStack(alignment: .top, spacing: LemonadeTheme.spaces.spacing200) { + LemonadeUi.Text("\(index + 1).", + textStyle: LemonadeTypography.shared.bodySmallSemiBold, + color: LemonadeTheme.colors.content.contentBrand) + LemonadeUi.Text(item, textStyle: LemonadeTypography.shared.bodySmallRegular, + color: LemonadeTheme.colors.content.contentSecondary) + } + } + } + } + + // MARK: - QR rendering + + private static let ciContext = CIContext() + + /// Renders `string` to a crisp QR `NSImage` via CoreImage (no third-party dep). + /// We rasterize through a `CIContext` to a `CGImage` rather than wrapping the + /// `CIImage` in an `NSCIImageRep` — the latter renders blank under SwiftUI. + static func qrImage(from string: String) -> NSImage? { + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(string.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10)) + guard let cgImage = ciContext.createCGImage(scaled, from: scaled.extent) else { return nil } + return NSImage(cgImage: cgImage, size: NSSize(width: scaled.extent.width, + height: scaled.extent.height)) + } +} diff --git a/Sources/Features/Sidebar/DeviceSidebarView.swift b/Sources/Features/Sidebar/DeviceSidebarView.swift index ac22b34..79a788a 100644 --- a/Sources/Features/Sidebar/DeviceSidebarView.swift +++ b/Sources/Features/Sidebar/DeviceSidebarView.swift @@ -7,6 +7,7 @@ struct DeviceSidebarView: View { @Bindable var model: AppModel @State private var showHistory = false @State private var showSettings = false + @State private var pairingModel: PairingModel? var body: some View { VStack(alignment: .leading, spacing: LemonadeTheme.spaces.spacing300) { @@ -19,6 +20,13 @@ struct DeviceSidebarView: View { : LemonadeTheme.colors.content.contentSecondary ) Spacer() + Button(action: openPairing) { + Image(systemName: "qrcode") + .foregroundStyle(LemonadeTheme.colors.content.contentSecondary) + } + .buttonStyle(.plain) + .help("Pair device over Wi-Fi") + .accessibilityIdentifier("pairButton") LemonadeUi.IconButton( icon: .clockArrowUp, contentDescription: "History", onClick: { showHistory = true }, size: .small @@ -82,6 +90,13 @@ struct DeviceSidebarView: View { .sheet(isPresented: $showSettings) { SettingsView(model: model) } + .sheet(item: $pairingModel) { pairing in + PairDeviceSheet(model: pairing) + } + } + + private func openPairing() { + pairingModel = model.makePairingModel() } private var platforms: [DevicePlatform] { @@ -115,6 +130,9 @@ struct DeviceSidebarView: View { textAlign: .center, color: LemonadeTheme.colors.content.contentTertiary ) + LemonadeUi.Button(label: "Pair over Wi-Fi", onClick: openPairing, + leadingIcon: .smartphone, variant: .neutral, type: .subtle, size: .small) + .padding(.top, LemonadeTheme.spaces.spacing200) } .frame(maxWidth: .infinity) .padding(.top, LemonadeTheme.spaces.spacing600) diff --git a/Sources/Model/AppModel.swift b/Sources/Model/AppModel.swift index 050b5e4..4a2f1bd 100644 --- a/Sources/Model/AppModel.swift +++ b/Sources/Model/AppModel.swift @@ -42,6 +42,9 @@ final class AppModel { private var providers: [DeviceProvider] = [] private var discoveryTasks: [Task] = [] + /// Background loop that reattaches previously-paired Wi-Fi devices when adb's own + /// auto-connect doesn't (the "auto-recover" gap adb leaves open). + private var reconnectTask: Task? private var devicesByPlatform: [DevicePlatform: [Device]] = [:] /// Tabs persisted from a previous launch, restored as their devices appear. @@ -89,12 +92,18 @@ final class AppModel { func reloadProviders() { discoveryTasks.forEach { $0.cancel() } discoveryTasks.removeAll() + reconnectTask?.cancel() + reconnectTask = nil devicesByPlatform.removeAll() devices = [] buildProviders() startDiscovery() } + /// A fresh `PairingModel` for the "Pair device over Wi-Fi" sheet, bound to the + /// currently-resolved adb path. + func makePairingModel() -> PairingModel { PairingModel(adbURL: adbURL) } + // MARK: - Discovery func startDiscovery() { @@ -110,6 +119,30 @@ final class AppModel { } discoveryTasks.append(task) } + startPairedReconnect() + } + + /// Periodically reattaches known paired Wi-Fi devices: when a remembered device's + /// `_adb-tls-connect` service shows up over mDNS but it isn't in `adb devices` yet, + /// `adb connect` it. adb is supposed to do this itself but frequently doesn't, which + /// is the "doesn't auto-recover" complaint. Only ever touches devices we've paired + /// before (the store filters by GUID), mirroring adb's known-host policy. + private func startPairedReconnect() { + guard reconnectTask == nil, let adbURL else { return } + let service = AdbPairingService(adbURL: adbURL) + reconnectTask = Task { [weak self] in + while !Task.isCancelled { + let services = await service.discoverServices() + let targets = await PairedDeviceStore.shared.reconnectTargets(from: services) + if let self { + let attached = Set(self.devices.map(\.id)) + for target in targets where !attached.contains(target.address) { + await service.connect(address: target.address) + } + } + try? await Task.sleep(for: .seconds(5)) + } + } } private func recomputeDevices() { diff --git a/Sources/Model/PairingModel.swift b/Sources/Model/PairingModel.swift new file mode 100644 index 0000000..d9578d9 --- /dev/null +++ b/Sources/Model/PairingModel.swift @@ -0,0 +1,210 @@ +import Foundation +import Observation + +/// Drives the "Pair device over Wi-Fi" sheet. A small state machine over the two +/// Android pairing flows plus a live mDNS discovery loop: +/// +/// • QR mode — we generate a service name + password, render a QR, then watch mDNS +/// for the phone to start advertising `_adb-tls-pairing._tcp` under *our* service +/// name (which means it scanned the QR). The moment it appears we auto-pair. +/// • Code mode — the phone shows a 6-digit code and its `ip:port`. The user either +/// picks the device from the discovered list or types the `ip:port` (the bullet- +/// proof fallback when mDNS is blocked), enters the code, and pairs. +/// +/// Throughout we surface adb's mDNS health and offer a one-click `adb kill-server` +/// recovery, because a wedged mDNS daemon is the usual reason a device "never shows up". +@MainActor +@Observable +final class PairingModel: Identifiable { + /// Stable identity so the sheet can present via `.sheet(item:)` (which guarantees + /// a non-nil model in its content — avoids the empty-dialog race that `isPresented` + /// + `if let` suffered). + let id = UUID() + + enum Mode: String, CaseIterable, Identifiable { case qr, code; var id: String { rawValue } } + + enum Status: Equatable { + case idle + case waitingForScan // QR shown; waiting for the phone to advertise + case pairing // `adb pair` in flight + case paired(name: String) // success + case failed(String) // adb failure, message kept for the user + } + + var mode: Mode = .qr { didSet { if mode != oldValue { onModeChange() } } } + private(set) var status: Status = .idle + + // QR mode + private(set) var credentials: QrPairingCredentials? + var qrPayload: String? { credentials?.qrPayload } + + // Live discovery + private(set) var pairingServices: [MdnsService] = [] // `_adb-tls-pairing._tcp` only + private(set) var mdnsHealth: MdnsHealth = .unknown("") + private(set) var recovering = false + + // Code-mode inputs + var selectedServiceID: String? + var manualAddress = "" // ip:port typed from the phone screen + var pairingCode = "" + + private let service: AdbPairingService? + private var discoveryTask: Task? + private var pollTick = 0 + + /// nil `adbURL` means the toolchain is missing — the sheet shows the adb notice. + init(adbURL: URL?) { + service = adbURL.map { AdbPairingService(adbURL: $0) } + // Generate the QR eagerly so it's on screen the instant the sheet opens, with + // no blank first frame waiting on `.task`. + credentials = .generate() + status = .waitingForScan + } + + var adbAvailable: Bool { service != nil } + + // MARK: - Lifecycle (called from the sheet's .task / onDisappear) + + func start() { + if mode == .qr, credentials == nil { regenerateQR() } + if mode == .qr { status = .waitingForScan } + startDiscovery() + } + + func stop() { + discoveryTask?.cancel() + discoveryTask = nil + } + + private func onModeChange() { + // Reset transient state when flipping tabs; keep discovery running. + switch mode { + case .qr: + if credentials == nil { regenerateQR() } + status = .waitingForScan + case .code: + status = .idle + } + } + + func regenerateQR() { + credentials = .generate() + status = .waitingForScan + } + + /// Back to a fresh state after a success/failure so the user can pair again. + func reset() { + pairingCode = "" + if mode == .qr { regenerateQR() } else { status = .idle } + } + + // MARK: - Discovery loop + + private func startDiscovery() { + guard discoveryTask == nil, let service else { return } + // The Task inherits @MainActor isolation, so property writes are safe; each + // `await` hops off the main actor to run adb and back. Capture `self` weakly so + // dismissing the sheet (which cancels the task) lets the model deallocate. + discoveryTask = Task { [weak self, service] in + while !Task.isCancelled { + let services = await service.discoverServices() + // mDNS health spawns a process; check it every 4th poll, not every second. + let needHealth = ((self?.pollTick ?? 0) % 4 == 0) + let health: MdnsHealth? = needHealth ? await service.mdnsCheck() : nil + guard let self, !Task.isCancelled else { return } + self.pairingServices = services.filter { $0.kind == .pairing } + if let health { self.mdnsHealth = health } + self.pollTick &+= 1 + self.maybeAutoPairFromQR() + try? await Task.sleep(for: .seconds(1)) + } + } + } + + /// In QR mode, the phone advertises a pairing service whose instance name equals + /// the service name we put in the QR. Spotting it means "the QR was scanned" — pair. + private func maybeAutoPairFromQR() { + guard mode == .qr, case .waitingForScan = status, + let serviceName = credentials?.serviceName, + let match = pairingServices.first(where: { $0.instance == serviceName }) else { return } + pairWithQR(address: match.address) + } + + // MARK: - Pairing + + private func pairWithQR(address: String) { + guard let service, let creds = credentials else { return } + status = .pairing + Task { [weak self] in + let result = await service.pair(address: address, code: creds.password) + self?.handle(result, method: .qrCode) + } + } + + func pairWithCode() { + guard let service else { return } + let address = resolvedCodeAddress() + let code = pairingCode.trimmingCharacters(in: .whitespaces) + guard !address.isEmpty else { + status = .failed("Select your device or type the IP:port shown on its screen.") + return + } + guard !code.isEmpty else { + status = .failed("Enter the 6-digit pairing code shown on your device.") + return + } + status = .pairing + Task { [weak self] in + let result = await service.pair(address: address, code: code) + self?.handle(result, method: .pairingCode) + } + } + + /// The address to pair against in code mode: the picked discovered service, else + /// the manually-typed `ip:port`. + private func resolvedCodeAddress() -> String { + if let id = selectedServiceID, let svc = pairingServices.first(where: { $0.id == id }) { + return svc.address + } + return manualAddress.trimmingCharacters(in: .whitespaces) + } + + private func handle(_ result: PairResult, method: PairingMethod) { + guard result.success else { + status = .failed(result.message) + return + } + let guid = result.guid ?? result.address ?? "device" + let name = Self.friendlyName(guid: guid, address: result.address) + status = .paired(name: name) + Task { + await PairedDeviceStore.shared.remember( + guid: guid, name: name, address: result.address, method: method + ) + } + } + + /// adb GUIDs look like "adb--"; show the serial as a hint until the + /// device connects and the provider supplies the real model name. + static func friendlyName(guid: String, address: String?) -> String { + if guid.hasPrefix("adb-") { + let parts = guid.dropFirst(4).split(separator: "-") + if let serial = parts.first, !serial.isEmpty { return String(serial) } + } + return address ?? guid + } + + // MARK: - Recovery + + /// Restart the adb server to clear a disabled/wedged mDNS daemon, then re-check. + func recoverMdns() { + guard let service, !recovering else { return } + recovering = true + Task { [weak self] in + await service.restartServer() + let health = await service.mdnsCheck() + self?.mdnsHealth = health + self?.recovering = false + } + } +} diff --git a/Tests/PairingTests.swift b/Tests/PairingTests.swift new file mode 100644 index 0000000..8b411aa --- /dev/null +++ b/Tests/PairingTests.swift @@ -0,0 +1,158 @@ +import XCTest +@testable import Jaca + +final class MdnsServiceParserTests: XCTestCase { + func testParsesTabSeparatedServices() { + let raw = """ + List of discovered mdns services + adb-939AX05XBZ-vWgJpq\t_adb-tls-connect._tcp\t192.168.1.86:39149 + adb-939AX05XBZ-vWgJpq\t_adb-tls-pairing._tcp\t192.168.1.86:37313 + """ + let services = MdnsServiceParser.parse(raw) + XCTAssertEqual(services.count, 2) + XCTAssertEqual(services[0].instance, "adb-939AX05XBZ-vWgJpq") + XCTAssertEqual(services[0].kind, .connect) + XCTAssertEqual(services[0].ip, "192.168.1.86") + XCTAssertEqual(services[0].port, 39149) + XCTAssertEqual(services[1].kind, .pairing) + XCTAssertEqual(services[1].port, 37313) + } + + func testStripsTrailingDotOnServiceType() { + let raw = "jaca-abc\t_adb-tls-pairing._tcp.\t10.0.0.5:5555" + let services = MdnsServiceParser.parse(raw) + XCTAssertEqual(services.first?.serviceType, "_adb-tls-pairing._tcp") + XCTAssertEqual(services.first?.kind, .pairing) + } + + func testDropsBogusZeroAddressAndDedups() { + let raw = """ + adb-x\t_adb-tls-connect._tcp\t0.0.0.0:1234 + adb-y\t_adb-tls-connect._tcp\t192.168.0.2:5555 + adb-y\t_adb-tls-connect._tcp\t192.168.0.2:5555 + """ + let services = MdnsServiceParser.parse(raw) + XCTAssertEqual(services.count, 1) + XCTAssertEqual(services.first?.instance, "adb-y") + } + + func testHandlesSpaceSeparatedFallback() { + let raw = "adb-z _adb._tcp 192.168.1.9:5555" + let services = MdnsServiceParser.parse(raw) + XCTAssertEqual(services.first?.kind, .legacy) + XCTAssertEqual(services.first?.port, 5555) + } + + func testEmptyAndHeaderOnlyYieldsNothing() { + XCTAssertTrue(MdnsServiceParser.parse("").isEmpty) + XCTAssertTrue(MdnsServiceParser.parse("List of discovered mdns services\n").isEmpty) + } +} + +final class PairResultParsingTests: XCTestCase { + func testParsesSuccessWithGuid() { + let out = "Successfully paired to 192.168.1.174:40711 [guid=adb-43081FDAS000ST-GIVKML]" + let result = PairResult.parse(stdout: out, stderr: "", exitCode: 0) + XCTAssertTrue(result.success) + XCTAssertEqual(result.address, "192.168.1.174:40711") + XCTAssertEqual(result.guid, "adb-43081FDAS000ST-GIVKML") + } + + func testParsesSuccessWithoutGuid() { + let result = PairResult.parse(stdout: "Successfully paired", stderr: "", exitCode: 0) + XCTAssertTrue(result.success) + XCTAssertNil(result.guid) + } + + func testFailureKeepsMessage() { + let err = "Failed: Wrong password or connection was dropped" + let result = PairResult.parse(stdout: "", stderr: err, exitCode: 1) + XCTAssertFalse(result.success) + XCTAssertTrue(result.message.contains("Wrong password")) + } + + func testEmptyOutputIsFriendlyFailure() { + let result = PairResult.parse(stdout: "", stderr: "", exitCode: 1) + XCTAssertFalse(result.success) + XCTAssertFalse(result.message.isEmpty) + } +} + +final class MdnsHealthParsingTests: XCTestCase { + func testHealthyBackend() { + let health = MdnsHealth.parse(stdout: "mdns daemon version [Openscreen discovery 0.0.0]", stderr: "") + XCTAssertTrue(health.isHealthy) + if case .ok(let backend) = health { XCTAssertTrue(backend.contains("Openscreen")) } else { XCTFail() } + } + + func testDisabledOnError() { + let health = MdnsHealth.parse(stdout: "", stderr: "ERROR: mdns daemon unavailable") + XCTAssertFalse(health.isHealthy) + if case .disabled = health {} else { XCTFail("expected disabled") } + } + + func testUnknownBackendBracketsTreatedAsDisabled() { + let health = MdnsHealth.parse(stdout: "mdns daemon version [Unknown]", stderr: "") + if case .disabled = health {} else { XCTFail("expected disabled for Unknown backend") } + } +} + +final class QrPairingTests: XCTestCase { + func testPayloadFormat() { + let creds = QrPairingCredentials(serviceName: "jaca-ABC123", password: "secretPW") + XCTAssertEqual(creds.qrPayload, "WIFI:T:ADB;S:jaca-ABC123;P:secretPW;;") + } + + func testGeneratedCredentialsAreWellFormed() { + let creds = QrPairingCredentials.generate() + XCTAssertTrue(creds.serviceName.hasPrefix("jaca-")) + XCTAssertFalse(creds.password.isEmpty) + // Payload grammar must not be broken by generated chars. + XCTAssertFalse(creds.serviceName.contains(";")) + XCTAssertFalse(creds.password.contains(";")) + XCTAssertFalse(creds.password.contains(":")) + XCTAssertEqual(creds.qrPayload.hasPrefix("WIFI:T:ADB;S:jaca-"), true) + } + + func testGeneratedCredentialsAreUnique() { + XCTAssertNotEqual(QrPairingCredentials.generate().serviceName, + QrPairingCredentials.generate().serviceName) + } +} + +final class PairedDeviceStoreTests: XCTestCase { + private func tempStore() -> (PairedDeviceStore, URL) { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("jaca-paired-\(UUID().uuidString).json") + return (PairedDeviceStore(fileURL: url), url) + } + + func testRememberAndReload() async { + let (store, url) = tempStore() + defer { try? FileManager.default.removeItem(at: url) } + let now = Date() + await store.remember(guid: "adb-AAA-bbb", name: "Pixel", address: "10.0.0.2:5555", + method: .pairingCode, now: now) + let reloaded = PairedDeviceStore(fileURL: url) + let all = await reloaded.all() + XCTAssertEqual(all.count, 1) + XCTAssertEqual(all.first?.name, "Pixel") + XCTAssertEqual(all.first?.method, .pairingCode) + } + + func testReconnectTargetsMatchKnownConnectServices() async { + let (store, url) = tempStore() + defer { try? FileManager.default.removeItem(at: url) } + await store.remember(guid: "adb-known", name: "K", address: nil, method: .qrCode) + let services = [ + MdnsService(instance: "adb-known", serviceType: "_adb-tls-connect._tcp", ip: "10.0.0.3", port: 5555), + MdnsService(instance: "adb-stranger", serviceType: "_adb-tls-connect._tcp", ip: "10.0.0.4", port: 5556), + MdnsService(instance: "adb-known", serviceType: "_adb-tls-pairing._tcp", ip: "10.0.0.3", port: 7777), + ] + let targets = await store.reconnectTargets(from: services) + // Only the known device's CONNECT service is a target (not the stranger, not pairing). + XCTAssertEqual(targets.count, 1) + XCTAssertEqual(targets.first?.guid, "adb-known") + XCTAssertEqual(targets.first?.address, "10.0.0.3:5555") + } +}