From 55963504a4743b1e92d84bbdc91d47291e227aa1 Mon Sep 17 00:00:00 2001 From: beyondlevi Date: Wed, 7 Oct 2026 13:36:22 -0300 Subject: [PATCH 1/8] mac: choose the device (ignore other adb devices), stay connected over Wi-Fi when unplugged, pair over Wi-Fi Co-Authored-By: Volund OS <287815646+volundos[bot]@users.noreply.github.com> --- mac/Resources/en.lproj/Localizable.strings | 19 +- mac/Resources/pt-BR.lproj/Localizable.strings | 19 +- mac/Sources/DroidBridge/AppDelegate.swift | 74 +++++++- mac/Sources/DroidBridge/DeviceLink.swift | 164 ++++++++++++++++-- mac/Sources/DroidBridge/Settings.swift | 18 ++ mac/Sources/DroidBridgeCore/AdbParsing.swift | 43 +++++ .../DroidBridgeCoreTests/CoreTests.swift | 33 ++++ 7 files changed, 350 insertions(+), 20 deletions(-) create mode 100644 mac/Sources/DroidBridgeCore/AdbParsing.swift diff --git a/mac/Resources/en.lproj/Localizable.strings b/mac/Resources/en.lproj/Localizable.strings index 72a3bcf..11a8c6e 100644 --- a/mac/Resources/en.lproj/Localizable.strings +++ b/mac/Resources/en.lproj/Localizable.strings @@ -1,8 +1,8 @@ "status.noAdb" = "adb not found: install Android platform-tools"; "status.waiting" = "Waiting for an Android device (USB debugging)"; "status.connecting" = "Connecting…"; -"status.connected" = "Connected: %@"; -"status.onDevice" = "Controlling %@"; +"status.connected" = "Connected over %2$@: %1$@"; +"status.onDevice" = "Controlling %1$@ (%2$@)"; "status.failed" = "Could not connect: %@"; "menu.grantAccessibility" = "Allow Accessibility access…"; "menu.share" = "Share mouse and keyboard"; @@ -31,3 +31,18 @@ "passage.drop" = "Drop to attach to %1$@: %2$@ edge, from %3$d%% to %4$d%% of its %5$@."; "passage.enters" = "The pointer enters the Android device through the side that touches the display, at the matching position."; "passage.dropNone" = "Not touching any display: dropping it here puts it back where it was."; +"status.chooseDevice" = "Several Android devices: choose one in Device"; +"transport.usb" = "USB"; +"transport.wifi" = "Wi-Fi"; +"menu.device" = "Device"; +"menu.wifiFallback" = "Stay connected over Wi-Fi without the cable"; +"menu.pairWifi" = "Pair over Wi-Fi…"; +"pair.title" = "Pair over Wi-Fi"; +"pair.info" = "On the Android device: Developer options > Wireless debugging > Pair device with pairing code. Type the address and code shown there, and the IP address & Port from the Wireless debugging screen."; +"pair.address" = "Pairing address (e.g. 192.168.0.12:37000)"; +"pair.code" = "Pairing code (6 digits)"; +"pair.connectAddress" = "IP address & Port (e.g. 192.168.0.12:41000)"; +"pair.button" = "Pair"; +"pair.cancel" = "Cancel"; +"pair.ok" = "Paired. DroidBridge will connect over Wi-Fi."; +"pair.failed" = "Could not pair"; diff --git a/mac/Resources/pt-BR.lproj/Localizable.strings b/mac/Resources/pt-BR.lproj/Localizable.strings index f2cf71c..fa96f2a 100644 --- a/mac/Resources/pt-BR.lproj/Localizable.strings +++ b/mac/Resources/pt-BR.lproj/Localizable.strings @@ -1,8 +1,8 @@ "status.noAdb" = "adb não encontrado: instale o Android platform-tools"; "status.waiting" = "Aguardando um Android (depuração USB)"; "status.connecting" = "Conectando…"; -"status.connected" = "Conectado: %@"; -"status.onDevice" = "Controlando %@"; +"status.connected" = "Conectado por %2$@: %1$@"; +"status.onDevice" = "Controlando %1$@ (%2$@)"; "status.failed" = "Não foi possível conectar: %@"; "menu.grantAccessibility" = "Permitir acesso de Acessibilidade…"; "menu.share" = "Compartilhar mouse e teclado"; @@ -31,3 +31,18 @@ "passage.drop" = "Solte para encostar em %1$@: borda %2$@, de %3$d%% a %4$d%% da %5$@."; "passage.enters" = "O ponteiro entra no Android pelo lado que encosta no monitor, na posição correspondente."; "passage.dropNone" = "Longe dos monitores: soltando aqui, ele volta para onde estava."; +"status.chooseDevice" = "Vários Androids conectados: escolha um em Aparelho"; +"transport.usb" = "USB"; +"transport.wifi" = "Wi-Fi"; +"menu.device" = "Aparelho"; +"menu.wifiFallback" = "Continuar conectado pelo Wi-Fi sem o cabo"; +"menu.pairWifi" = "Parear pelo Wi-Fi…"; +"pair.title" = "Parear pelo Wi-Fi"; +"pair.info" = "No Android: Opções do desenvolvedor > Depuração sem fio > Parear dispositivo com código de pareamento. Digite o endereço e o código mostrados lá, e o Endereço IP e porta da tela de Depuração sem fio."; +"pair.address" = "Endereço de pareamento (ex.: 192.168.0.12:37000)"; +"pair.code" = "Código de pareamento (6 dígitos)"; +"pair.connectAddress" = "Endereço IP e porta (ex.: 192.168.0.12:41000)"; +"pair.button" = "Parear"; +"pair.cancel" = "Cancelar"; +"pair.ok" = "Pareado. O DroidBridge vai se conectar pelo Wi-Fi."; +"pair.failed" = "Não foi possível parear"; diff --git a/mac/Sources/DroidBridge/AppDelegate.swift b/mac/Sources/DroidBridge/AppDelegate.swift index 8cc5e4a..5cff7a3 100644 --- a/mac/Sources/DroidBridge/AppDelegate.swift +++ b/mac/Sources/DroidBridge/AppDelegate.swift @@ -145,6 +145,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { share.state = Settings.enabled ? .on : .off menu.addItem(share) + if link.devices.count > 1 || Settings.deviceSerial != nil { + let devices = NSMenuItem(title: L("menu.device"), action: nil, keyEquivalent: "") + let deviceMenu = NSMenu() + for d in link.devices { + let via = [d.usbSerial != nil ? L("transport.usb") : nil, d.wirelessSerial != nil ? L("transport.wifi") : nil] + .compactMap { $0 }.joined(separator: " + ") + let i = item("\(d.model) (\(via))", #selector(chooseDevice(_:))) + i.representedObject = d.id + i.state = (Settings.deviceSerial ?? (link.devices.count == 1 ? d.id : nil)) == d.id ? .on : .off + deviceMenu.addItem(i) + } + devices.submenu = deviceMenu + menu.addItem(devices) + } menu.addItem(item(L("menu.arrange"), #selector(openArrangement))) let speed = NSMenuItem(title: L("menu.speed"), action: nil, keyEquivalent: "") @@ -158,6 +172,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { speed.submenu = speedMenu menu.addItem(speed) + let wifi = item(L("menu.wifiFallback"), #selector(toggleWifi)) + wifi.state = Settings.wifiFallback ? .on : .off + menu.addItem(wifi) + menu.addItem(item(L("menu.pairWifi"), #selector(pairWifi))) + let cmd = item(L("menu.commandAsControl"), #selector(toggleCommand)) cmd.state = Settings.commandAsControl ? .on : .off menu.addItem(cmd) @@ -177,7 +196,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { case .waiting: return L("status.waiting") case .connecting: return L("status.connecting") case let .connected(model, _, _): - return String(format: L(capture.isRemote ? "status.onDevice" : "status.connected"), model) + let key = capture.isRemote ? "status.onDevice" : "status.connected" + return String(format: L(key), model, L(link.transport == .usb ? "transport.usb" : "transport.wifi")) + case .chooseDevice: return L("status.chooseDevice") case let .failed(reason): return String(format: L("status.failed"), reason) } } @@ -199,6 +220,57 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { if !Settings.enabled { capture.returnToMac() } } + @objc private func chooseDevice(_ sender: NSMenuItem) { + guard let id = sender.representedObject as? String else { return } + capture.returnToMac() + link.select(id) + } + + @objc private func toggleWifi() { + Settings.wifiFallback.toggle() + } + + /// Pairs a device over Wi-Fi without a cable: the fields come from the device's + /// "Pair device with pairing code" screen and the "IP address & Port" line above it. + @objc private func pairWifi() { + let alert = NSAlert() + alert.messageText = L("pair.title") + alert.informativeText = L("pair.info") + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 6 + func field(_ placeholder: String) -> NSTextField { + let f = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24)) + f.placeholderString = placeholder + f.widthAnchor.constraint(equalToConstant: 300).isActive = true + stack.addArrangedSubview(f) + return f + } + let pairAddress = field(L("pair.address")) + let code = field(L("pair.code")) + let connectAddress = field(L("pair.connectAddress")) + stack.frame = NSRect(x: 0, y: 0, width: 300, height: 84) + alert.accessoryView = stack + alert.addButton(withTitle: L("pair.button")) + alert.addButton(withTitle: L("pair.cancel")) + NSApp.activate(ignoringOtherApps: true) + guard alert.runModal() == .alertFirstButtonReturn else { return } + link.pair(address: pairAddress.stringValue.trimmingCharacters(in: .whitespaces), + code: code.stringValue.trimmingCharacters(in: .whitespaces), + connectAddress: connectAddress.stringValue.trimmingCharacters(in: .whitespaces)) { result in + let done = NSAlert() + switch result { + case .success: + done.messageText = L("pair.ok") + case let .failure(error): + done.messageText = L("pair.failed") + done.informativeText = error.localizedDescription + } + done.runModal() + } + } + @objc private func openArrangement() { arrangementWindow.show() } diff --git a/mac/Sources/DroidBridge/DeviceLink.swift b/mac/Sources/DroidBridge/DeviceLink.swift index ac33b05..8bead0d 100644 --- a/mac/Sources/DroidBridge/DeviceLink.swift +++ b/mac/Sources/DroidBridge/DeviceLink.swift @@ -10,15 +10,32 @@ final class DeviceLink { case waiting case connecting(String) case connected(model: String, width: Int, height: Int) + case chooseDevice case failed(String) } + /// An Android device, possibly reachable both by USB and over Wi-Fi. + struct Device: Equatable { + /// The hardware serial (ro.serialno), the same over USB and Wi-Fi. + let id: String + let model: String + var usbSerial: String? + var wirelessSerial: String? + } + + enum Transport { case usb, wifi } + static let serverVersion = "0.1.0" private static let remoteJar = "/data/local/tmp/droidbridge-server.jar" private let log = Logger(subsystem: "dev.droidbridge", category: "link") var onState: ((State) -> Void)? var onMessage: ((Wire.Message) -> Void)? + /// The devices adb sees now (main queue). + private(set) var devices: [Device] = [] + /// How the current connection reaches the device. + private(set) var transport: Transport = .usb + private var hardwareSerials: [String: String] = [:] private(set) var state: State = .waiting { didSet { if state != oldValue { let s = state; DispatchQueue.main.async { self.onState?(s) } } } @@ -66,21 +83,134 @@ final class DeviceLink { Thread.sleep(forTimeInterval: 3) continue } - guard let device = firstDevice(adb) else { - state = .waiting + let found = listDevices(adb) + DispatchQueue.main.async { self.devices = found } + let chosen = Settings.deviceSerial + let target: Device? + if let chosen { + target = found.first { $0.id == chosen } + } else { + target = found.count == 1 ? found.first : nil + } + guard let device = target, let serial = device.usbSerial ?? device.wirelessSerial else { + if found.count > 1, chosen == nil || !found.contains(where: { $0.id == chosen }) { + state = chosen == nil ? .chooseDevice : .waiting + } else { + state = .waiting + } + reconnectWireless(adb, id: chosen ?? Settings.wirelessAddresses.keys.first) Thread.sleep(forTimeInterval: 2) continue } + transport = device.usbSerial != nil ? .usb : .wifi + if transport == .usb, Settings.wifiFallback { + let id = device.id + DispatchQueue.global().async { self.prepareWireless(adb, id: id, usbSerial: serial) } + } do { - try connect(adb: adb, serial: device) + try connect(adb: adb, serial: serial) readUntilClosed() } catch { log.error("connect failed: \(error.localizedDescription, privacy: .public)") state = .failed(error.localizedDescription) } disconnect() + Thread.sleep(forTimeInterval: 1) + } + } + + /// Picks a device from the menu; reconnects to it. + func select(_ id: String) { + Settings.deviceSerial = id + queue.async { [weak self] in self?.dropConnection() } + } + + /// Ends the current connection; the loop picks the device again. + private func dropConnection() { + writeQueue.sync { + if socket >= 0 { shutdown(socket, SHUT_RDWR) } + } + } + + // MARK: - Devices and Wi-Fi + + private func listDevices(_ adb: String) -> [Device] { + guard let out = try? run(adb, ["devices", "-l"], timeout: 5) else { return [] } + var byID: [String: Device] = [:] + var order: [String] = [] + for e in AdbParsing.devices(out) where e.state == "device" { + let id: String + if e.wireless { + if let cached = hardwareSerials[e.serial] { + id = cached + } else if let s = try? run(adb, ["-s", e.serial, "shell", "getprop", "ro.serialno"], timeout: 5) + .trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty { + hardwareSerials[e.serial] = s + id = s + } else { + id = e.serial + } + } else { + id = e.serial + } + var d = byID[id] ?? Device(id: id, model: e.model ?? id, usbSerial: nil, wirelessSerial: nil) + if e.wireless { d.wirelessSerial = e.serial } else { d.usbSerial = e.serial } + if byID[id] == nil { order.append(id) } + byID[id] = d + } + return order.compactMap { byID[$0] } + } + + /// Over USB, turns on wireless debugging (Android 11+) and connects to it too, so the link survives + /// unplugging the cable. adb accepts the computer's key, already allowed over USB. + private func prepareWireless(_ adb: String, id: String, usbSerial: String) { + func shell(_ args: String...) -> String { (try? run(adb, ["-s", usbSerial, "shell"] + args, timeout: 5)) ?? "" } + guard let sdk = Int(shell("getprop", "ro.build.version.sdk").trimmingCharacters(in: .whitespacesAndNewlines)), sdk >= 30 else { return } + if shell("settings", "get", "global", "adb_wifi_enabled").trimmingCharacters(in: .whitespacesAndNewlines) != "1" { + _ = shell("settings", "put", "global", "adb_wifi_enabled", "1") Thread.sleep(forTimeInterval: 2) } + guard let port = AdbParsing.tlsPort(dumpsysAdb: shell("dumpsys", "adb")), + let ip = AdbParsing.ipv4(shell("ip", "-f", "inet", "addr", "show", "wlan0")) else { + log.info("wireless debugging not available (not on Wi-Fi, or the network is not allowed)") + return + } + let address = "\(ip):\(port)" + var saved = Settings.wirelessAddresses + saved[id] = address + Settings.wirelessAddresses = saved + let out = (try? run(adb, ["connect", address], timeout: 8)) ?? "timeout" + log.info("wireless \(address, privacy: .public): \(out.trimmingCharacters(in: .whitespacesAndNewlines), privacy: .public)") + } + + private var lastWirelessAttempt = Date.distantPast + + /// Without the device, tries its last Wi-Fi address now and then. + private func reconnectWireless(_ adb: String, id: String?) { + guard Settings.wifiFallback, let id, let address = Settings.wirelessAddresses[id], + Date().timeIntervalSince(lastWirelessAttempt) > 6 else { return } + lastWirelessAttempt = Date() + _ = try? run(adb, ["connect", address], timeout: 5) + } + + /// Pairs with a device over Wi-Fi (Settings > Developer options > Wireless debugging > Pair device + /// with pairing code), then connects to it. + func pair(address: String, code: String, connectAddress: String, completion: @escaping (Result) -> Void) { + queue.async { [weak self] in + guard let self else { return } + do { + guard let adb = self.adb ?? Self.findAdb() else { throw LinkError("adb not found") } + let paired = try self.run(adb, ["pair", address, code], timeout: 20) + guard paired.contains("Successfully paired") else { throw LinkError(paired.trimmingCharacters(in: .whitespacesAndNewlines)) } + var out = paired + if !connectAddress.isEmpty { + out += try self.run(adb, ["connect", connectAddress], timeout: 10) + } + DispatchQueue.main.async { completion(.success(out)) } + } catch { + DispatchQueue.main.async { completion(.failure(error)) } + } + } } private func connect(adb: String, serial: String) throws { @@ -161,26 +291,30 @@ final class DeviceLink { // MARK: - Helpers - private func firstDevice(_ adb: String) -> String? { - guard let out = try? run(adb, ["devices"]) else { return nil } - for line in out.split(separator: "\n").dropFirst() { - let parts = line.split(whereSeparator: { $0 == "\t" || $0 == " " }) - if parts.count >= 2, parts[1] == "device" { return String(parts[0]) } - } - return nil - } - @discardableResult - private func run(_ tool: String, _ args: [String]) throws -> String { + private func run(_ tool: String, _ args: [String], timeout: TimeInterval = 60) throws -> String { let p = Process() p.executableURL = URL(fileURLWithPath: tool) p.arguments = args let out = Pipe() p.standardOutput = out p.standardError = out + let done = DispatchSemaphore(value: 0) + p.terminationHandler = { _ in done.signal() } try p.run() - let data = out.fileHandleForReading.readDataToEndOfFile() - p.waitUntilExit() + var data = Data() + let reader = DispatchQueue.global() + let readDone = DispatchSemaphore(value: 0) + reader.async { + data = out.fileHandleForReading.readDataToEndOfFile() + readDone.signal() + } + if done.wait(timeout: .now() + timeout) == .timedOut { + p.terminate() + _ = done.wait(timeout: .now() + 1) + throw LinkError("adb \(args.joined(separator: " ")): timed out") + } + _ = readDone.wait(timeout: .now() + 2) let text = String(decoding: data, as: UTF8.self) guard p.terminationStatus == 0 else { throw LinkError("adb \(args.joined(separator: " ")): \(text)") } return text diff --git a/mac/Sources/DroidBridge/Settings.swift b/mac/Sources/DroidBridge/Settings.swift index 978e377..605bb8c 100644 --- a/mac/Sources/DroidBridge/Settings.swift +++ b/mac/Sources/DroidBridge/Settings.swift @@ -22,6 +22,24 @@ struct Settings { } } + /// The device to use (hardware serial); nil = the only one connected. + static var deviceSerial: String? { + get { d.string(forKey: "deviceSerial") } + set { d.set(newValue, forKey: "deviceSerial") } + } + + /// Keep the link over Wi-Fi when the cable is unplugged (Android 11+). + static var wifiFallback: Bool { + get { d.object(forKey: "wifiFallback") as? Bool ?? true } + set { d.set(newValue, forKey: "wifiFallback") } + } + + /// Last Wi-Fi address (ip:port) of each device, by hardware serial. + static var wirelessAddresses: [String: String] { + get { d.dictionary(forKey: "wirelessAddresses") as? [String: String] ?? [:] } + set { d.set(newValue, forKey: "wirelessAddresses") } + } + static var speed: Double { get { d.object(forKey: "speed") as? Double ?? 3.0 } set { d.set(newValue, forKey: "speed") } diff --git a/mac/Sources/DroidBridgeCore/AdbParsing.swift b/mac/Sources/DroidBridgeCore/AdbParsing.swift new file mode 100644 index 0000000..a528d3d --- /dev/null +++ b/mac/Sources/DroidBridgeCore/AdbParsing.swift @@ -0,0 +1,43 @@ +/// Parsers for adb output. +public enum AdbParsing { + public struct Entry: Equatable { + public let serial: String + public let state: String + public let model: String? + public let usb: Bool + + /// Reached over the network: "ip:port" or an mDNS name. + public var wireless: Bool { serial.contains(":") || serial.contains("._adb-tls-connect.") } + } + + /// `adb devices -l` + public static func devices(_ output: String) -> [Entry] { + output.split(separator: "\n").dropFirst().compactMap { line in + let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) + guard parts.count >= 2 else { return nil } + let model = parts.first { $0.hasPrefix("model:") }.map { String($0.dropFirst(6)).replacingOccurrences(of: "_", with: " ") } + return Entry(serial: parts[0], state: parts[1], model: model, usb: parts.contains { $0.hasPrefix("usb:") }) + } + } + + /// The wireless debugging port from `dumpsys adb` (Android 11+), when it is on. + public static func tlsPort(dumpsysAdb output: String) -> Int? { + guard let block = output.range(of: "adb_wifi={") else { return nil } + let rest = output[block.upperBound...] + guard rest.range(of: "enabled=true") != nil, let p = rest.range(of: "tls_port=") else { return nil } + let digits = rest[p.upperBound...].prefix { $0.isNumber } + guard let port = Int(digits), port > 0 else { return nil } + return port + } + + /// The IPv4 address from `ip -f inet addr show wlan0`. + public static func ipv4(_ output: String) -> String? { + for line in output.split(separator: "\n") { + let parts = line.split(separator: " ") + if let i = parts.firstIndex(of: "inet"), i + 1 < parts.count { + return String(parts[i + 1].split(separator: "/")[0]) + } + } + return nil + } +} diff --git a/mac/Tests/DroidBridgeCoreTests/CoreTests.swift b/mac/Tests/DroidBridgeCoreTests/CoreTests.swift index f3ed5fb..a369b9f 100644 --- a/mac/Tests/DroidBridgeCoreTests/CoreTests.swift +++ b/mac/Tests/DroidBridgeCoreTests/CoreTests.swift @@ -242,3 +242,36 @@ final class KeyboardTests: XCTestCase { XCTAssertEqual(k.pressed.count, 6) } } + +final class AdbParsingTests: XCTestCase { + func testDevices() { + let out = """ + List of devices attached + 1901092548006978 device usb:1179648X product:glasses model:RG_glasses device:glasses transport_id:115 + RQGL8028M4Z device usb:17825792X product:h8qxxx model:SM_F971B device:h8q transport_id:114 + 10.50.2.9:46557 device product:h8qxxx model:SM_F971B device:h8q transport_id:116 + ZY22 unauthorized usb:1-2 transport_id:3 + + """ + let d = AdbParsing.devices(out) + XCTAssertEqual(d.count, 4) + XCTAssertEqual(d[1].model, "SM F971B") + XCTAssertTrue(d[1].usb) + XCTAssertFalse(d[1].wireless) + XCTAssertTrue(d[2].wireless) + XCTAssertFalse(d[2].usb) + XCTAssertEqual(d[3].state, "unauthorized") + } + + func testTlsPort() { + let on = "debugging_manager={\n adb_wifi={\n enabled=true\n network_ssid=\"x\"\n tls_port=46557\n }\n}" + XCTAssertEqual(AdbParsing.tlsPort(dumpsysAdb: on), 46557) + XCTAssertNil(AdbParsing.tlsPort(dumpsysAdb: "adb_wifi={\n enabled=false\n tls_port=0\n }")) + XCTAssertNil(AdbParsing.tlsPort(dumpsysAdb: "nothing")) + } + + func testIPv4() { + XCTAssertEqual(AdbParsing.ipv4("27: wlan0: \n inet 10.50.2.9/24 brd 10.50.2.255 scope global wlan0\n"), "10.50.2.9") + XCTAssertNil(AdbParsing.ipv4("")) + } +} From 2f7e9258e71fbe4dad30c310a047915bad259f76 Mon Sep 17 00:00:00 2001 From: beyondlevi Date: Wed, 7 Oct 2026 15:00:24 -0300 Subject: [PATCH 2/8] Rotation: the server reports the new screen size, the Mac turns the device rectangle keeping its touching sides Co-Authored-By: Volund OS <287815646+volundos[bot]@users.noreply.github.com> --- .../DroidBridge/ArrangementWindow.swift | 9 ++++- .../DroidBridgeCore/EdgeGeometry.swift | 23 +++++++++++ .../DroidBridgeCoreTests/CoreTests.swift | 16 ++++++++ protocol/README.md | 2 +- server/src/dev/droidbridge/Session.java | 38 +++++++++++++++++++ 5 files changed, 86 insertions(+), 2 deletions(-) diff --git a/mac/Sources/DroidBridge/ArrangementWindow.swift b/mac/Sources/DroidBridge/ArrangementWindow.swift index 166a7e3..5ec6925 100644 --- a/mac/Sources/DroidBridge/ArrangementWindow.swift +++ b/mac/Sources/DroidBridge/ArrangementWindow.swift @@ -9,8 +9,15 @@ final class ArrangementModel: ObservableObject { @Published var displays: [DisplayInfo] = [] @Published var device: CGRect? @Published var deviceName: String? + /// The device screen as it is held now; when it turns, its rectangle turns too. @Published var deviceSize: CGSize? { - didSet { if oldValue != deviceSize { reload() } } + didSet { + guard oldValue != deviceSize else { return } + reload() + if let r = device, let turned = EdgeGeometry.reoriented(device: r, aspect: aspect, displays: bounds) { + commit(turned) + } + } } /// Drag offset of the device, in global points. @Published var drag: CGSize? diff --git a/mac/Sources/DroidBridgeCore/EdgeGeometry.swift b/mac/Sources/DroidBridgeCore/EdgeGeometry.swift index 339b92a..55950b4 100644 --- a/mac/Sources/DroidBridgeCore/EdgeGeometry.swift +++ b/mac/Sources/DroidBridgeCore/EdgeGeometry.swift @@ -254,6 +254,29 @@ public enum EdgeGeometry { } } + /// When the device turns (its aspect flips between portrait and landscape), the same rectangle + /// turned a quarter: the sides touching displays stay where they are. Nil when nothing changes or + /// the turned rectangle would overlap a display or touch none. + public static func reoriented(device r: CGRect, aspect: CGFloat, displays: [CGRect]) -> CGRect? { + guard aspect > 0, (r.width >= r.height) != (aspect >= 1) else { return nil } + let w = r.height + let h = r.width + var x = r.midX - w / 2 + var y = r.midY - h / 2 + for p in passages(device: r, displays: displays) { + switch p.edge { + case .right: x = r.minX + case .left: x = r.maxX - w + case .top: y = r.maxY - h + case .bottom: y = r.minY + } + } + let next = CGRect(x: x, y: y, width: w, height: h) + let overlaps = displays.contains { $0.intersection(next).width > 0.5 && $0.intersection(next).height > 0.5 } + guard !overlaps, !passages(device: next, displays: displays).isEmpty else { return nil } + return next + } + /// The default device rectangle: against the outermost display's free right edge, centered, /// `size` times that display's height. public static func defaultDevice(displays: [CGRect], aspect: CGFloat, size: CGFloat = 0.6) -> CGRect? { diff --git a/mac/Tests/DroidBridgeCoreTests/CoreTests.swift b/mac/Tests/DroidBridgeCoreTests/CoreTests.swift index a369b9f..1bde669 100644 --- a/mac/Tests/DroidBridgeCoreTests/CoreTests.swift +++ b/mac/Tests/DroidBridgeCoreTests/CoreTests.swift @@ -161,6 +161,22 @@ final class DeviceRectTests: XCTestCase { XCTAssertNil(EdgeGeometry.snap(device: CGRect(x: 5000, y: 100, width: 300, height: 300), displays: displays, reach: 24)) } + func testReorientedKeepsTheTouchingSide() { + // Portrait, right of the MacBook: turning it keeps its left side on the MacBook's right edge. + let r = CGRect(x: 3840, y: 800, width: 300, height: 600) + let t = EdgeGeometry.reoriented(device: r, aspect: 2, displays: displays) + XCTAssertEqual(t, CGRect(x: 3840, y: 950, width: 600, height: 300)) + XCTAssertNil(EdgeGeometry.reoriented(device: r, aspect: 0.5, displays: displays)) + } + + func testReorientedInTheCorner() { + // Right of the external display and above the MacBook: both sides stay. + let r = CGRect(x: 1920, y: 94, width: 530, height: 400) + let t = EdgeGeometry.reoriented(device: r, aspect: 0.75, displays: displays) + XCTAssertEqual(t, CGRect(x: 1920, y: -36, width: 400, height: 530)) + XCTAssertEqual(EdgeGeometry.passages(device: t!, displays: displays).count, 2) + } + func testDefaultDevice() { let r = EdgeGeometry.defaultDevice(displays: displays, aspect: 0.5) XCTAssertEqual(r?.minX, 3840) diff --git a/protocol/README.md b/protocol/README.md index ce079fb..5902662 100644 --- a/protocol/README.md +++ b/protocol/README.md @@ -31,7 +31,7 @@ All integers are big-endian. `side` is a side of the Android screen: | Type | Name | Payload | |---|---|---| -| 0x81 | DEVICE | u16 protocol version, u16 width, u16 height, UTF-8 model; sent first | +| 0x81 | DEVICE | u16 protocol version, u16 width, u16 height, UTF-8 model; sent first, and again when the screen turns | | 0x82 | EDGE | u8 side, u16 ratio: the pointer was pushed out through a return stretch | | 0x83 | CLIPBOARD | UTF-8 text copied on the device | | 0x84 | PONG | empty | diff --git a/server/src/dev/droidbridge/Session.java b/server/src/dev/droidbridge/Session.java index 3b63021..8fc4593 100644 --- a/server/src/dev/droidbridge/Session.java +++ b/server/src/dev/droidbridge/Session.java @@ -70,6 +70,7 @@ final class Session { void run() throws IOException { int[] size = displaySize(); sendDevice(size[0], size[1]); + watchRotation(size); if (clipboard != null) { // Clipboard callbacks need a looper; the main thread runs one. new Handler(Looper.getMainLooper()).post(() -> clipboard.addPrimaryClipChangedListener(this::onDeviceClipboard)); @@ -363,6 +364,43 @@ private synchronized void send(int type, byte[] payload) throws IOException { out.flush(); } + /** Tells the Mac when the screen turns (its size swaps), so its arrangement can turn too. */ + private void watchRotation(int[] initial) { + final int[] last = initial.clone(); + Handler main = new Handler(Looper.getMainLooper()); + main.post(() -> { + android.hardware.display.DisplayManager dm = (android.hardware.display.DisplayManager) com.genymobile.scrcpy.FakeContext.get() + .getSystemService(android.content.Context.DISPLAY_SERVICE); + dm.registerDisplayListener(new android.hardware.display.DisplayManager.DisplayListener() { + @Override + public void onDisplayAdded(int displayId) { + } + + @Override + public void onDisplayRemoved(int displayId) { + } + + @Override + public void onDisplayChanged(int displayId) { + if (displayId != 0) { + return; + } + int[] size = displaySize(); + if (size[0] > 0 && (size[0] != last[0] || size[1] != last[1])) { + last[0] = size[0]; + last[1] = size[1]; + Log.i("display is now " + size[0] + "x" + size[1]); + try { + sendDevice(size[0], size[1]); + } catch (IOException e) { + Log.w("rotation: " + e.getMessage()); + } + } + } + }, main); + }); + } + private int[] displaySize() { android.util.DisplayMetrics m = new android.util.DisplayMetrics(); try { From 1ca3a6059b60007846bc547e497a18d11848fd52 Mon Sep 17 00:00:00 2001 From: beyondlevi Date: Wed, 7 Oct 2026 15:05:41 -0300 Subject: [PATCH 3/8] server: pointer positions are physical; turn them into rotated (logical) ones Co-Authored-By: Volund OS <287815646+volundos[bot]@users.noreply.github.com> --- server/src/dev/droidbridge/PointerProbe.java | 26 +++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/server/src/dev/droidbridge/PointerProbe.java b/server/src/dev/droidbridge/PointerProbe.java index 39d9f66..317044d 100644 --- a/server/src/dev/droidbridge/PointerProbe.java +++ b/server/src/dev/droidbridge/PointerProbe.java @@ -41,7 +41,7 @@ public String toString() { } } - private static final Pattern FRAME = Pattern.compile("logicalFrame=\\[0, 0, (\\d+), (\\d+)\\]"); + private static final Pattern FRAME = Pattern.compile("orientation=(\\d).*?logicalFrame=\\[0, 0, (\\d+), (\\d+)\\]"); private final IBinder input; private int deviceId = -1; @@ -82,13 +82,31 @@ Sample sample() throws IOException { } int width = 0; int height = 0; + int rotation = 0; int cursor = dump.indexOf("MouseCursorController"); Matcher frame = FRAME.matcher(dump); if (cursor >= 0 && frame.find(cursor) || frame.find(0)) { - width = Integer.parseInt(frame.group(1)); - height = Integer.parseInt(frame.group(2)); + rotation = Integer.parseInt(frame.group(1)); + width = Integer.parseInt(frame.group(2)); + height = Integer.parseInt(frame.group(3)); + } + return toLogical(Float.parseFloat(pointer.group(1)), Float.parseFloat(pointer.group(2)), rotation, width, height); + } + + /** + * The dispatcher reports pointers in the display's unrotated (physical) coordinates; the frame is + * the rotated (logical) one. Turns a physical position into a logical one. + */ + static Sample toLogical(float px, float py, int rotation, int width, int height) { + // Physical size: the logical one, swapped when turned a quarter. + int pw = rotation % 2 == 0 ? width : height; + int ph = rotation % 2 == 0 ? height : width; + switch (rotation) { + case 1: return new Sample(py, pw - 1 - px, width, height); + case 2: return new Sample(pw - 1 - px, ph - 1 - py, width, height); + case 3: return new Sample(ph - 1 - py, px, width, height); + default: return new Sample(px, py, width, height); } - return new Sample(Float.parseFloat(pointer.group(1)), Float.parseFloat(pointer.group(2)), width, height); } private String dump() throws IOException { From 224c9e12ea77fd25d4cc2ea0e5088296d56fa9e4 Mon Sep 17 00:00:00 2001 From: beyondlevi Date: Wed, 7 Oct 2026 15:09:01 -0300 Subject: [PATCH 4/8] tools: devtest enters through any side Co-Authored-By: Volund OS <287815646+volundos[bot]@users.noreply.github.com> --- tools/devtest.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tools/devtest.py b/tools/devtest.py index afa91a1..3edf55e 100644 --- a/tools/devtest.py +++ b/tools/devtest.py @@ -56,12 +56,13 @@ def main(): jar, test = sys.argv[1], sys.argv[2] S = Server(jar) try: - if test in ("enter", "edge"): - for ratio in (0.25, 0.75): - t0 = time.time(); S.enter(LEFT, ratio); S.send(0x07) - t, _ = S.recv(5); print(f"enter left {ratio}: {time.time()-t0:.2f}s (pong={t==0x84})") - time.sleep(0.5) - if test == "edge": + if test.split(":")[0] in ("enter", "edge"): + side = {"left": LEFT, "right": RIGHT, "top": TOP, "bottom": BOTTOM}[test.split(":")[1] if ":" in test else "left"] + for ratio in (0.1, 0.5, 0.9, 0.25, 0.75): + t0 = time.time(); S.enter(side, ratio); S.send(0x07) + t, _ = S.recv(5); print(f"enter side {side} {ratio}: {time.time()-t0:.2f}s (pong={t==0x84})") + time.sleep(0.3) + if test.startswith("edge"): for _ in range(40): S.mouse(8, 0); time.sleep(0.008) # into the screen t, p = S.recv(0.3); print("after moving in, unexpected:", t) t0 = time.time(); got = None From 4331b1cb99e3188e57cbd09ff809a26c83c1e8b7 Mon Sep 17 00:00:00 2001 From: beyondlevi Date: Wed, 7 Oct 2026 15:09:36 -0300 Subject: [PATCH 5/8] server: faster entry placement (bigger steps, gain learned across entries) Co-Authored-By: Volund OS <287815646+volundos[bot]@users.noreply.github.com> --- server/src/dev/droidbridge/Session.java | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/server/src/dev/droidbridge/Session.java b/server/src/dev/droidbridge/Session.java index 8fc4593..51d892b 100644 --- a/server/src/dev/droidbridge/Session.java +++ b/server/src/dev/droidbridge/Session.java @@ -50,6 +50,8 @@ final class Session { private int pushed; private boolean edgeSent; private int buttons; + private float gainX; + private float gainY; private final Object clipLock = new Object(); private String lastClipFromMac; @@ -192,14 +194,15 @@ private void enter(int side, float ratio, float[][] spans) throws IOException { if (s != null && s.width > 0) { float length = horizontalEdge ? s.width - 1 : s.height - 1; float target = ratio * length; - float gain = 0; + // Pixels per count along this axis, learned on earlier entries. + float gain = horizontalEdge ? gainX : gainY; for (int attempt = 0; attempt < 4; attempt++) { float now = horizontalEdge ? s.x : s.y; float error = target - now; if (Math.abs(error) < 12) { break; } - int counts = gain > 0 ? Math.round(error / gain) : (int) Math.signum(error) * 60; + int counts = gain > 0 ? Math.round(error / gain) : (int) Math.signum(error) * 200; walk(horizontalEdge, counts); sleep(15); PointerProbe.Sample next = probe.sample(); @@ -207,8 +210,13 @@ private void enter(int side, float ratio, float[][] spans) throws IOException { break; } float moved = (horizontalEdge ? next.x : next.y) - now; - if (counts != 0 && moved * counts > 0) { + if (counts != 0 && moved * counts > 0 && Math.abs(counts) >= 20) { gain = moved / counts; + if (horizontalEdge) { + gainX = gain; + } else { + gainY = gain; + } } s = next; } @@ -221,14 +229,14 @@ private void enter(int side, float ratio, float[][] spans) throws IOException { returns = spans; } - /** Moves along one axis in small steps, so acceleration stays predictable. */ + /** Moves along one axis in steps of the same size and pace, so the acceleration stays the same. */ private void walk(boolean horizontal, int counts) throws IOException { - int step = counts > 0 ? 10 : -10; + int step = counts > 0 ? 50 : -50; while (counts != 0) { int s = Math.abs(counts) < Math.abs(step) ? counts : step; mouse.input(new byte[] {0, (byte) (horizontal ? s : 0), (byte) (horizontal ? 0 : s), 0, 0}); counts -= s; - sleep(2); + sleep(1); } } From beb23181f55835e95b3be6d50cc028ceb4218ede Mon Sep 17 00:00:00 2001 From: beyondlevi Date: Wed, 7 Oct 2026 15:18:44 -0300 Subject: [PATCH 6/8] mac: the device rectangle follows any change of screen shape (foldable cover screen) Co-Authored-By: Volund OS <287815646+volundos[bot]@users.noreply.github.com> --- mac/Sources/DroidBridge/ArrangementWindow.swift | 4 ++-- mac/Sources/DroidBridgeCore/EdgeGeometry.swift | 15 ++++++++------- mac/Tests/DroidBridgeCoreTests/CoreTests.swift | 17 +++++++++++++---- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/mac/Sources/DroidBridge/ArrangementWindow.swift b/mac/Sources/DroidBridge/ArrangementWindow.swift index 5ec6925..ca7ae2d 100644 --- a/mac/Sources/DroidBridge/ArrangementWindow.swift +++ b/mac/Sources/DroidBridge/ArrangementWindow.swift @@ -9,12 +9,12 @@ final class ArrangementModel: ObservableObject { @Published var displays: [DisplayInfo] = [] @Published var device: CGRect? @Published var deviceName: String? - /// The device screen as it is held now; when it turns, its rectangle turns too. + /// The device screen as it is now; when it turns or a foldable changes screens, its rectangle follows. @Published var deviceSize: CGSize? { didSet { guard oldValue != deviceSize else { return } reload() - if let r = device, let turned = EdgeGeometry.reoriented(device: r, aspect: aspect, displays: bounds) { + if let r = device, let turned = EdgeGeometry.reshaped(device: r, aspect: aspect, displays: bounds) { commit(turned) } } diff --git a/mac/Sources/DroidBridgeCore/EdgeGeometry.swift b/mac/Sources/DroidBridgeCore/EdgeGeometry.swift index 55950b4..2dc766c 100644 --- a/mac/Sources/DroidBridgeCore/EdgeGeometry.swift +++ b/mac/Sources/DroidBridgeCore/EdgeGeometry.swift @@ -254,13 +254,14 @@ public enum EdgeGeometry { } } - /// When the device turns (its aspect flips between portrait and landscape), the same rectangle - /// turned a quarter: the sides touching displays stay where they are. Nil when nothing changes or - /// the turned rectangle would overlap a display or touch none. - public static func reoriented(device r: CGRect, aspect: CGFloat, displays: [CGRect]) -> CGRect? { - guard aspect > 0, (r.width >= r.height) != (aspect >= 1) else { return nil } - let w = r.height - let h = r.width + /// When the device screen changes shape (it turns, or a foldable switches between its screens), + /// the rectangle takes the new aspect, keeping its longer side and the sides that touch displays. + /// Nil when nothing changes or the new rectangle would overlap a display or touch none. + public static func reshaped(device r: CGRect, aspect: CGFloat, displays: [CGRect]) -> CGRect? { + guard aspect > 0, r.height > 0, abs(r.width / r.height - aspect) / aspect > 0.02 else { return nil } + let long = max(r.width, r.height) + let w = aspect >= 1 ? long : long * aspect + let h = aspect >= 1 ? long / aspect : long var x = r.midX - w / 2 var y = r.midY - h / 2 for p in passages(device: r, displays: displays) { diff --git a/mac/Tests/DroidBridgeCoreTests/CoreTests.swift b/mac/Tests/DroidBridgeCoreTests/CoreTests.swift index 1bde669..5872560 100644 --- a/mac/Tests/DroidBridgeCoreTests/CoreTests.swift +++ b/mac/Tests/DroidBridgeCoreTests/CoreTests.swift @@ -164,19 +164,28 @@ final class DeviceRectTests: XCTestCase { func testReorientedKeepsTheTouchingSide() { // Portrait, right of the MacBook: turning it keeps its left side on the MacBook's right edge. let r = CGRect(x: 3840, y: 800, width: 300, height: 600) - let t = EdgeGeometry.reoriented(device: r, aspect: 2, displays: displays) + let t = EdgeGeometry.reshaped(device: r, aspect: 2, displays: displays) XCTAssertEqual(t, CGRect(x: 3840, y: 950, width: 600, height: 300)) - XCTAssertNil(EdgeGeometry.reoriented(device: r, aspect: 0.5, displays: displays)) + XCTAssertNil(EdgeGeometry.reshaped(device: r, aspect: 0.5, displays: displays)) } func testReorientedInTheCorner() { // Right of the external display and above the MacBook: both sides stay. let r = CGRect(x: 1920, y: 94, width: 530, height: 400) - let t = EdgeGeometry.reoriented(device: r, aspect: 0.75, displays: displays) - XCTAssertEqual(t, CGRect(x: 1920, y: -36, width: 400, height: 530)) + let t = EdgeGeometry.reshaped(device: r, aspect: 0.75, displays: displays) + XCTAssertEqual(t, CGRect(x: 1920, y: -36, width: 397.5, height: 530)) XCTAssertEqual(EdgeGeometry.passages(device: t!, displays: displays).count, 2) } + func testReshapedForANarrowerScreen() { + // A foldable switching from its inner screen (2448x1848) to the cover one (1248x1972). + let r = CGRect(x: 3840, y: 800, width: 400, height: 600) + let t = EdgeGeometry.reshaped(device: r, aspect: 1248.0 / 1972.0, displays: displays) + XCTAssertEqual(t?.minX, 3840) + XCTAssertEqual(t?.height ?? 0, 600, accuracy: 0.01) + XCTAssertEqual(t?.width ?? 0, 600 * 1248.0 / 1972.0, accuracy: 0.01) + } + func testDefaultDevice() { let r = EdgeGeometry.defaultDevice(displays: displays, aspect: 0.5) XCTAssertEqual(r?.minX, 3840) From b25aec2e2b331edf994f600dfa3d1d069e390ec1 Mon Sep 17 00:00:00 2001 From: beyondlevi Date: Wed, 7 Oct 2026 15:22:53 -0300 Subject: [PATCH 7/8] v0.2.0: keep device choice, rotation and foldable screens; leave Wi-Fi out for now (it stays on feature/wifi) Co-Authored-By: Volund OS <287815646+volundos[bot]@users.noreply.github.com> --- README.md | 8 +- docs/architecture.md | 8 +- mac/Resources/en.lproj/Localizable.strings | 11 -- mac/Resources/pt-BR.lproj/Localizable.strings | 11 -- mac/Sources/DroidBridge/AppDelegate.swift | 50 ------- mac/Sources/DroidBridge/DeviceLink.swift | 135 +----------------- mac/Sources/DroidBridge/Settings.swift | 12 -- mac/Sources/DroidBridgeCore/AdbParsing.swift | 21 --- .../DroidBridgeCoreTests/CoreTests.swift | 12 -- mac/scripts/build-app.sh | 2 +- server/build.sh | 2 +- tools/devtest.py | 2 +- 12 files changed, 16 insertions(+), 258 deletions(-) diff --git a/README.md b/README.md index d6f82f8..9516ecc 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,14 @@ The text clipboard is shared both ways. An open-source take on DeskDock, for mac - Real Android pointer and keyboard: the device sees a USB mouse and keyboard (UHID). - Nothing to install on the device: the Mac starts a small server over adb. - Text clipboard sync in both directions. -- Works with several displays: only edges that don't lead to another display count. +- Works with several displays: arrange the device against one or two of them, as in System + Settings > Displays; only the stretches it touches lead to it. +- Follows the device when it turns, and foldables when they switch screens (e.g. a Galaxy Z Fold + closed, on its cover screen). +- Several Android devices on adb: pick the one to use in the menu. - English and Brazilian Portuguese. -Status: early (0.1). USB only for now; Wi-Fi and file drag-and-drop are planned. +Status: early (0.2). USB only for now; Wi-Fi and file drag-and-drop are planned. ## Requirements diff --git a/docs/architecture.md b/docs/architecture.md index 7309a82..b8861b3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,7 +18,9 @@ The mouse is relative and Android accelerates it, so the Mac can't compute where is. The shell user can't monitor input events, and the system's cursor-position API is internal to `system_server`. What the shell user can do is ask the input service for a dump: it lists each device's hovering pointer in display coordinates, e.g. -`DeviceId(15):[... hoveringPointers=[Pointer(id=0, MOUSE) at (0, 1835.57)]]`. A dump takes +`DeviceId(15):[... hoveringPointers=[Pointer(id=0, MOUSE) at (0, 1835.57)]]`. Those +coordinates are the display's unrotated (physical) ones, while the frame next to them is the rotated +one, so the server turns them using the display orientation from the same dump. A dump takes about 20 ms on a recent phone, so the server only takes one (at most every 30 ms) while the pointer moves toward the edge that leads back to the Mac. Once the pointer touches that edge and keeps being pushed against it, the server tells the Mac, with the position along the edge. @@ -36,7 +38,9 @@ When the Mac cursor is pushed out of a free screen edge, the Mac freezes its cur (`CGAssociateMouseAndMouseCursorPosition(false)`), hides it and sends `ENTER` with the position along the edge. The server pushes the pointer into the corner of the entry edge, then walks it along the edge, measuring with the dump, until it is within about 12 px of the matching position -(0.3-0.5 s). From then on the Mac forwards raw mouse deltas (times the speed setting), buttons, +(0.1-0.2 s; the pixels-per-count gain of each axis is learned on earlier entries). When the +screen turns or a foldable switches screens, the server sends the new size again and the Mac +reshapes the device in its arrangement, keeping the sides that touch displays. From then on the Mac forwards raw mouse deltas (times the speed setting), buttons, scrolling and keys, and swallows them locally through a `CGEventTap`. ## The Mac side diff --git a/mac/Resources/en.lproj/Localizable.strings b/mac/Resources/en.lproj/Localizable.strings index 11a8c6e..348bcde 100644 --- a/mac/Resources/en.lproj/Localizable.strings +++ b/mac/Resources/en.lproj/Localizable.strings @@ -35,14 +35,3 @@ "transport.usb" = "USB"; "transport.wifi" = "Wi-Fi"; "menu.device" = "Device"; -"menu.wifiFallback" = "Stay connected over Wi-Fi without the cable"; -"menu.pairWifi" = "Pair over Wi-Fi…"; -"pair.title" = "Pair over Wi-Fi"; -"pair.info" = "On the Android device: Developer options > Wireless debugging > Pair device with pairing code. Type the address and code shown there, and the IP address & Port from the Wireless debugging screen."; -"pair.address" = "Pairing address (e.g. 192.168.0.12:37000)"; -"pair.code" = "Pairing code (6 digits)"; -"pair.connectAddress" = "IP address & Port (e.g. 192.168.0.12:41000)"; -"pair.button" = "Pair"; -"pair.cancel" = "Cancel"; -"pair.ok" = "Paired. DroidBridge will connect over Wi-Fi."; -"pair.failed" = "Could not pair"; diff --git a/mac/Resources/pt-BR.lproj/Localizable.strings b/mac/Resources/pt-BR.lproj/Localizable.strings index fa96f2a..461f755 100644 --- a/mac/Resources/pt-BR.lproj/Localizable.strings +++ b/mac/Resources/pt-BR.lproj/Localizable.strings @@ -35,14 +35,3 @@ "transport.usb" = "USB"; "transport.wifi" = "Wi-Fi"; "menu.device" = "Aparelho"; -"menu.wifiFallback" = "Continuar conectado pelo Wi-Fi sem o cabo"; -"menu.pairWifi" = "Parear pelo Wi-Fi…"; -"pair.title" = "Parear pelo Wi-Fi"; -"pair.info" = "No Android: Opções do desenvolvedor > Depuração sem fio > Parear dispositivo com código de pareamento. Digite o endereço e o código mostrados lá, e o Endereço IP e porta da tela de Depuração sem fio."; -"pair.address" = "Endereço de pareamento (ex.: 192.168.0.12:37000)"; -"pair.code" = "Código de pareamento (6 dígitos)"; -"pair.connectAddress" = "Endereço IP e porta (ex.: 192.168.0.12:41000)"; -"pair.button" = "Parear"; -"pair.cancel" = "Cancelar"; -"pair.ok" = "Pareado. O DroidBridge vai se conectar pelo Wi-Fi."; -"pair.failed" = "Não foi possível parear"; diff --git a/mac/Sources/DroidBridge/AppDelegate.swift b/mac/Sources/DroidBridge/AppDelegate.swift index 5cff7a3..540e2ac 100644 --- a/mac/Sources/DroidBridge/AppDelegate.swift +++ b/mac/Sources/DroidBridge/AppDelegate.swift @@ -172,11 +172,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { speed.submenu = speedMenu menu.addItem(speed) - let wifi = item(L("menu.wifiFallback"), #selector(toggleWifi)) - wifi.state = Settings.wifiFallback ? .on : .off - menu.addItem(wifi) - menu.addItem(item(L("menu.pairWifi"), #selector(pairWifi))) - let cmd = item(L("menu.commandAsControl"), #selector(toggleCommand)) cmd.state = Settings.commandAsControl ? .on : .off menu.addItem(cmd) @@ -226,51 +221,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { link.select(id) } - @objc private func toggleWifi() { - Settings.wifiFallback.toggle() - } - - /// Pairs a device over Wi-Fi without a cable: the fields come from the device's - /// "Pair device with pairing code" screen and the "IP address & Port" line above it. - @objc private func pairWifi() { - let alert = NSAlert() - alert.messageText = L("pair.title") - alert.informativeText = L("pair.info") - let stack = NSStackView() - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = 6 - func field(_ placeholder: String) -> NSTextField { - let f = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24)) - f.placeholderString = placeholder - f.widthAnchor.constraint(equalToConstant: 300).isActive = true - stack.addArrangedSubview(f) - return f - } - let pairAddress = field(L("pair.address")) - let code = field(L("pair.code")) - let connectAddress = field(L("pair.connectAddress")) - stack.frame = NSRect(x: 0, y: 0, width: 300, height: 84) - alert.accessoryView = stack - alert.addButton(withTitle: L("pair.button")) - alert.addButton(withTitle: L("pair.cancel")) - NSApp.activate(ignoringOtherApps: true) - guard alert.runModal() == .alertFirstButtonReturn else { return } - link.pair(address: pairAddress.stringValue.trimmingCharacters(in: .whitespaces), - code: code.stringValue.trimmingCharacters(in: .whitespaces), - connectAddress: connectAddress.stringValue.trimmingCharacters(in: .whitespaces)) { result in - let done = NSAlert() - switch result { - case .success: - done.messageText = L("pair.ok") - case let .failure(error): - done.messageText = L("pair.failed") - done.informativeText = error.localizedDescription - } - done.runModal() - } - } - @objc private func openArrangement() { arrangementWindow.show() } diff --git a/mac/Sources/DroidBridge/DeviceLink.swift b/mac/Sources/DroidBridge/DeviceLink.swift index 8bead0d..67cc258 100644 --- a/mac/Sources/DroidBridge/DeviceLink.swift +++ b/mac/Sources/DroidBridge/DeviceLink.swift @@ -25,7 +25,7 @@ final class DeviceLink { enum Transport { case usb, wifi } - static let serverVersion = "0.1.0" + static let serverVersion = "0.2.0" private static let remoteJar = "/data/local/tmp/droidbridge-server.jar" private let log = Logger(subsystem: "dev.droidbridge", category: "link") @@ -98,15 +98,10 @@ final class DeviceLink { } else { state = .waiting } - reconnectWireless(adb, id: chosen ?? Settings.wirelessAddresses.keys.first) Thread.sleep(forTimeInterval: 2) continue } transport = device.usbSerial != nil ? .usb : .wifi - if transport == .usb, Settings.wifiFallback { - let id = device.id - DispatchQueue.global().async { self.prepareWireless(adb, id: id, usbSerial: serial) } - } do { try connect(adb: adb, serial: serial) readUntilClosed() @@ -161,134 +156,6 @@ final class DeviceLink { return order.compactMap { byID[$0] } } - /// Over USB, turns on wireless debugging (Android 11+) and connects to it too, so the link survives - /// unplugging the cable. adb accepts the computer's key, already allowed over USB. - private func prepareWireless(_ adb: String, id: String, usbSerial: String) { - func shell(_ args: String...) -> String { (try? run(adb, ["-s", usbSerial, "shell"] + args, timeout: 5)) ?? "" } - guard let sdk = Int(shell("getprop", "ro.build.version.sdk").trimmingCharacters(in: .whitespacesAndNewlines)), sdk >= 30 else { return } - if shell("settings", "get", "global", "adb_wifi_enabled").trimmingCharacters(in: .whitespacesAndNewlines) != "1" { - _ = shell("settings", "put", "global", "adb_wifi_enabled", "1") - Thread.sleep(forTimeInterval: 2) - } - guard let port = AdbParsing.tlsPort(dumpsysAdb: shell("dumpsys", "adb")), - let ip = AdbParsing.ipv4(shell("ip", "-f", "inet", "addr", "show", "wlan0")) else { - log.info("wireless debugging not available (not on Wi-Fi, or the network is not allowed)") - return - } - let address = "\(ip):\(port)" - var saved = Settings.wirelessAddresses - saved[id] = address - Settings.wirelessAddresses = saved - let out = (try? run(adb, ["connect", address], timeout: 8)) ?? "timeout" - log.info("wireless \(address, privacy: .public): \(out.trimmingCharacters(in: .whitespacesAndNewlines), privacy: .public)") - } - - private var lastWirelessAttempt = Date.distantPast - - /// Without the device, tries its last Wi-Fi address now and then. - private func reconnectWireless(_ adb: String, id: String?) { - guard Settings.wifiFallback, let id, let address = Settings.wirelessAddresses[id], - Date().timeIntervalSince(lastWirelessAttempt) > 6 else { return } - lastWirelessAttempt = Date() - _ = try? run(adb, ["connect", address], timeout: 5) - } - - /// Pairs with a device over Wi-Fi (Settings > Developer options > Wireless debugging > Pair device - /// with pairing code), then connects to it. - func pair(address: String, code: String, connectAddress: String, completion: @escaping (Result) -> Void) { - queue.async { [weak self] in - guard let self else { return } - do { - guard let adb = self.adb ?? Self.findAdb() else { throw LinkError("adb not found") } - let paired = try self.run(adb, ["pair", address, code], timeout: 20) - guard paired.contains("Successfully paired") else { throw LinkError(paired.trimmingCharacters(in: .whitespacesAndNewlines)) } - var out = paired - if !connectAddress.isEmpty { - out += try self.run(adb, ["connect", connectAddress], timeout: 10) - } - DispatchQueue.main.async { completion(.success(out)) } - } catch { - DispatchQueue.main.async { completion(.failure(error)) } - } - } - } - - private func connect(adb: String, serial: String) throws { - self.serial = serial - state = .connecting(serial) - guard let jar = Bundle.main.url(forResource: "droidbridge-server", withExtension: "jar") else { - throw LinkError("droidbridge-server.jar is missing from the app") - } - try run(adb, ["-s", serial, "push", jar.path, Self.remoteJar]) - let port = try run(adb, ["-s", serial, "forward", "tcp:0", "localabstract:droidbridge"]) - .trimmingCharacters(in: .whitespacesAndNewlines) - guard let p = Int(port) else { throw LinkError("adb forward returned \(port)") } - forwardPort = p - - let process = Process() - process.executableURL = URL(fileURLWithPath: adb) - process.arguments = ["-s", serial, "shell", "CLASSPATH=\(Self.remoteJar)", "app_process", "/", - "dev.droidbridge.Main", Self.serverVersion] - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - let ready = DispatchSemaphore(value: 0) - var buffer = "" - pipe.fileHandleForReading.readabilityHandler = { [weak self] h in - let chunk = String(decoding: h.availableData, as: UTF8.self) - if chunk.isEmpty { h.readabilityHandler = nil; ready.signal(); return } - buffer += chunk - while let nl = buffer.firstIndex(of: "\n") { - let line = String(buffer[..= 0 { close(socket); socket = -1 } - } - if let server, server.isRunning { server.terminate() } - server = nil - if let adb, let serial, let forwardPort { - _ = try? run(adb, ["-s", serial, "forward", "--remove", "tcp:\(forwardPort)"]) - } - forwardPort = nil - if !stopped, state != .noAdb { state = .waiting } - } - // MARK: - Helpers @discardableResult diff --git a/mac/Sources/DroidBridge/Settings.swift b/mac/Sources/DroidBridge/Settings.swift index 605bb8c..2f3601d 100644 --- a/mac/Sources/DroidBridge/Settings.swift +++ b/mac/Sources/DroidBridge/Settings.swift @@ -28,18 +28,6 @@ struct Settings { set { d.set(newValue, forKey: "deviceSerial") } } - /// Keep the link over Wi-Fi when the cable is unplugged (Android 11+). - static var wifiFallback: Bool { - get { d.object(forKey: "wifiFallback") as? Bool ?? true } - set { d.set(newValue, forKey: "wifiFallback") } - } - - /// Last Wi-Fi address (ip:port) of each device, by hardware serial. - static var wirelessAddresses: [String: String] { - get { d.dictionary(forKey: "wirelessAddresses") as? [String: String] ?? [:] } - set { d.set(newValue, forKey: "wirelessAddresses") } - } - static var speed: Double { get { d.object(forKey: "speed") as? Double ?? 3.0 } set { d.set(newValue, forKey: "speed") } diff --git a/mac/Sources/DroidBridgeCore/AdbParsing.swift b/mac/Sources/DroidBridgeCore/AdbParsing.swift index a528d3d..e2fa345 100644 --- a/mac/Sources/DroidBridgeCore/AdbParsing.swift +++ b/mac/Sources/DroidBridgeCore/AdbParsing.swift @@ -19,25 +19,4 @@ public enum AdbParsing { return Entry(serial: parts[0], state: parts[1], model: model, usb: parts.contains { $0.hasPrefix("usb:") }) } } - - /// The wireless debugging port from `dumpsys adb` (Android 11+), when it is on. - public static func tlsPort(dumpsysAdb output: String) -> Int? { - guard let block = output.range(of: "adb_wifi={") else { return nil } - let rest = output[block.upperBound...] - guard rest.range(of: "enabled=true") != nil, let p = rest.range(of: "tls_port=") else { return nil } - let digits = rest[p.upperBound...].prefix { $0.isNumber } - guard let port = Int(digits), port > 0 else { return nil } - return port - } - - /// The IPv4 address from `ip -f inet addr show wlan0`. - public static func ipv4(_ output: String) -> String? { - for line in output.split(separator: "\n") { - let parts = line.split(separator: " ") - if let i = parts.firstIndex(of: "inet"), i + 1 < parts.count { - return String(parts[i + 1].split(separator: "/")[0]) - } - } - return nil - } } diff --git a/mac/Tests/DroidBridgeCoreTests/CoreTests.swift b/mac/Tests/DroidBridgeCoreTests/CoreTests.swift index 5872560..92564f5 100644 --- a/mac/Tests/DroidBridgeCoreTests/CoreTests.swift +++ b/mac/Tests/DroidBridgeCoreTests/CoreTests.swift @@ -287,16 +287,4 @@ final class AdbParsingTests: XCTestCase { XCTAssertFalse(d[2].usb) XCTAssertEqual(d[3].state, "unauthorized") } - - func testTlsPort() { - let on = "debugging_manager={\n adb_wifi={\n enabled=true\n network_ssid=\"x\"\n tls_port=46557\n }\n}" - XCTAssertEqual(AdbParsing.tlsPort(dumpsysAdb: on), 46557) - XCTAssertNil(AdbParsing.tlsPort(dumpsysAdb: "adb_wifi={\n enabled=false\n tls_port=0\n }")) - XCTAssertNil(AdbParsing.tlsPort(dumpsysAdb: "nothing")) - } - - func testIPv4() { - XCTAssertEqual(AdbParsing.ipv4("27: wlan0: \n inet 10.50.2.9/24 brd 10.50.2.255 scope global wlan0\n"), "10.50.2.9") - XCTAssertNil(AdbParsing.ipv4("")) - } } diff --git a/mac/scripts/build-app.sh b/mac/scripts/build-app.sh index af76e0e..df1a49f 100755 --- a/mac/scripts/build-app.sh +++ b/mac/scripts/build-app.sh @@ -3,7 +3,7 @@ # Env: DROIDBRIDGE_VERSION (default 0.1.0), SIGN_IDENTITY (default "-", ad-hoc), SERVER_JAR (prebuilt jar). set -euo pipefail cd "$(dirname "$0")/.." -VERSION=${DROIDBRIDGE_VERSION:-0.1.0} +VERSION=${DROIDBRIDGE_VERSION:-0.2.0} IDENTITY=${SIGN_IDENTITY:--} APP=dist/DroidBridge.app diff --git a/server/build.sh b/server/build.sh index b20c88b..b5cec6d 100755 --- a/server/build.sh +++ b/server/build.sh @@ -3,7 +3,7 @@ # build_without_gradle.sh. Needs ANDROID_HOME with platforms/android-36 and build-tools/36.0.0. set -euo pipefail -VERSION=${DROIDBRIDGE_VERSION:-0.1.0} +VERSION=${DROIDBRIDGE_VERSION:-0.2.0} PLATFORM=${ANDROID_PLATFORM:-36} BUILD_TOOLS=${ANDROID_BUILD_TOOLS:-36.0.0} : "${ANDROID_HOME:?set ANDROID_HOME}" diff --git a/tools/devtest.py b/tools/devtest.py index 3edf55e..bdba854 100644 --- a/tools/devtest.py +++ b/tools/devtest.py @@ -5,7 +5,7 @@ ADB = "adb" REMOTE = "/data/local/tmp/droidbridge-server.jar" -VERSION = "0.1.0" +VERSION = "0.2.0" LEFT, RIGHT, TOP, BOTTOM = 0, 1, 2, 3 def adb(*a): From 4cdbf7cef2437d55573bb0daec4a31d3c3d2d88c Mon Sep 17 00:00:00 2001 From: beyondlevi Date: Wed, 7 Oct 2026 15:23:16 -0300 Subject: [PATCH 8/8] v0.2.0: restore the connection code removed by mistake Co-Authored-By: Volund OS <287815646+volundos[bot]@users.noreply.github.com> --- mac/Sources/DroidBridge/DeviceLink.swift | 78 +++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/mac/Sources/DroidBridge/DeviceLink.swift b/mac/Sources/DroidBridge/DeviceLink.swift index 67cc258..2971fd6 100644 --- a/mac/Sources/DroidBridge/DeviceLink.swift +++ b/mac/Sources/DroidBridge/DeviceLink.swift @@ -127,7 +127,7 @@ final class DeviceLink { } } - // MARK: - Devices and Wi-Fi + // MARK: - Devices private func listDevices(_ adb: String) -> [Device] { guard let out = try? run(adb, ["devices", "-l"], timeout: 5) else { return [] } @@ -156,6 +156,82 @@ final class DeviceLink { return order.compactMap { byID[$0] } } + private func connect(adb: String, serial: String) throws { + self.serial = serial + state = .connecting(serial) + guard let jar = Bundle.main.url(forResource: "droidbridge-server", withExtension: "jar") else { + throw LinkError("droidbridge-server.jar is missing from the app") + } + try run(adb, ["-s", serial, "push", jar.path, Self.remoteJar]) + let port = try run(adb, ["-s", serial, "forward", "tcp:0", "localabstract:droidbridge"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let p = Int(port) else { throw LinkError("adb forward returned \(port)") } + forwardPort = p + + let process = Process() + process.executableURL = URL(fileURLWithPath: adb) + process.arguments = ["-s", serial, "shell", "CLASSPATH=\(Self.remoteJar)", "app_process", "/", + "dev.droidbridge.Main", Self.serverVersion] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + let ready = DispatchSemaphore(value: 0) + var buffer = "" + pipe.fileHandleForReading.readabilityHandler = { [weak self] h in + let chunk = String(decoding: h.availableData, as: UTF8.self) + if chunk.isEmpty { h.readabilityHandler = nil; ready.signal(); return } + buffer += chunk + while let nl = buffer.firstIndex(of: "\n") { + let line = String(buffer[..= 0 { close(socket); socket = -1 } + } + if let server, server.isRunning { server.terminate() } + server = nil + if let adb, let serial, let forwardPort { + _ = try? run(adb, ["-s", serial, "forward", "--remove", "tcp:\(forwardPort)"]) + } + forwardPort = nil + if !stopped, state != .noAdb { state = .waiting } + } + // MARK: - Helpers @discardableResult