Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ xcuserdata/
# SwiftPM
.swiftpm/
.build/

# Claude Code — personal, machine-local project memory (never commit)
CLAUDE.local.md
122 changes: 122 additions & 0 deletions Sources/Core/Devices/AdbPairingService.swift
Original file line number Diff line number Diff line change
@@ -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 <addr> [guid=<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..<close.lowerBound])
return backend.isEmpty || backend.lowercased().contains("unknown")
? .disabled(text)
: .ok(backend: backend)
}
return text.isEmpty ? .unknown("no output") : .unknown(text)
}
}

/// Thin async wrapper around the `adb` CLI for wireless pairing + reconnection.
/// Mirrors `AndroidDeviceProvider`'s use of `CommandRunner`; holds no state beyond
/// the resolved adb path, so it's cheap to construct per call.
struct AdbPairingService: Sendable {
let adbURL: URL

/// `adb mdns services` → discovered pairing/connect/legacy services.
func discoverServices() async -> [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 <host:port> <code>`. 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 <host:port>` — 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"])
}
}
95 changes: 95 additions & 0 deletions Sources/Core/Devices/MdnsService.swift
Original file line number Diff line number Diff line change
@@ -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<String>()

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..<colon])
let portStr = String(raw[raw.index(after: colon)...])
guard !ip.isEmpty, let port = Int(portStr), port > 0 else { return nil }
return (ip, port)
}
}
141 changes: 141 additions & 0 deletions Sources/Core/Devices/PairedDeviceStore.swift
Original file line number Diff line number Diff line change
@@ -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
}()
}
Loading