diff --git a/mac/Sources/DroidBridge/DeviceLink.swift b/mac/Sources/DroidBridge/DeviceLink.swift index 2971fd6..64f912a 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.2.0" + static let serverVersion = "0.2.1" private static let remoteJar = "/data/local/tmp/droidbridge-server.jar" private let log = Logger(subsystem: "dev.droidbridge", category: "link") @@ -38,7 +38,12 @@ final class DeviceLink { private var hardwareSerials: [String: String] = [:] private(set) var state: State = .waiting { - didSet { if state != oldValue { let s = state; DispatchQueue.main.async { self.onState?(s) } } } + didSet { + guard state != oldValue else { return } + let s = state + log.notice("state: \(String(describing: s), privacy: .public)") + DispatchQueue.main.async { self.onState?(s) } + } } private let queue = DispatchQueue(label: "dev.droidbridge.link") @@ -106,7 +111,7 @@ final class DeviceLink { try connect(adb: adb, serial: serial) readUntilClosed() } catch { - log.error("connect failed: \(error.localizedDescription, privacy: .public)") + log.error("connect to \(serial, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") state = .failed(error.localizedDescription) } disconnect() @@ -196,14 +201,34 @@ final class DeviceLink { socket = try Self.connectLocal(port: p) send(Wire.hello()) + log.notice("connected to \(serial, privacy: .public)") } + /// Seconds without anything from the device before the link counts as dead. + static let silenceLimit: TimeInterval = 8 + private func readUntilClosed() { var reader = FrameReader() var buf = [UInt8](repeating: 0, count: 64 * 1024) + var lastHeard = Date() + var lastPing = Date() while !stopped { + // The socket times out every second (SO_RCVTIMEO), so a device that stops answering + // without closing the connection (asleep, unplugged mid-write) is noticed. + if Date().timeIntervalSince(lastPing) >= 2 { + send(Wire.ping()) + lastPing = Date() + } let n = recv(socket, &buf, buf.count, 0) + if n < 0, errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR { + if Date().timeIntervalSince(lastHeard) > Self.silenceLimit { + log.notice("device silent for \(Int(Self.silenceLimit)) s, reconnecting") + break + } + continue + } if n <= 0 { break } + lastHeard = Date() do { for message in try reader.feed(Array(buf[0..= 0 { shutdown(socket, SHUT_RDWR) } writeQueue.sync { if socket >= 0 { close(socket); socket = -1 } } @@ -281,6 +308,10 @@ final class DeviceLink { var one: Int32 = 1 setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, socklen_t(MemoryLayout.size)) setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) + var second = timeval(tv_sec: 1, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &second, socklen_t(MemoryLayout.size)) + var sendLimit = timeval(tv_sec: 3, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &sendLimit, socklen_t(MemoryLayout.size)) var addr = sockaddr_in() addr.sin_family = sa_family_t(AF_INET) addr.sin_port = in_port_t(UInt16(port).bigEndian) diff --git a/mac/Sources/DroidBridge/InputCapture.swift b/mac/Sources/DroidBridge/InputCapture.swift index 836b7b4..099603e 100644 --- a/mac/Sources/DroidBridge/InputCapture.swift +++ b/mac/Sources/DroidBridge/InputCapture.swift @@ -29,6 +29,9 @@ final class InputCapture { private var tap: CFMachPort? private var source: CFRunLoopSource? private var crossed: Passage? + /// Where the cursor stays while the pointer is on the device. + private var frozenAt = CGPoint.zero + private var refreezes = 0 private var activePassages: [Passage] = [] /// After coming back, the cursor must move away from the edge before it can cross again. private var armed = true @@ -62,7 +65,9 @@ final class InputCapture { source = CFMachPortCreateRunLoopSource(nil, tap, 0) CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) CGEvent.tapEnable(tap: tap, enable: true) - log.info("event tap started") + log.notice("event tap started") + // Warping the cursor would otherwise mute the mouse for 0.25 s. + CGEventSource(stateID: .combinedSessionState)?.localEventsSuppressionInterval = 0 return true } @@ -130,17 +135,20 @@ final class InputCapture { isRemote = true crossed = passage activePassages = all + frozenAt = CGEvent(source: nil)?.location ?? frozenAt + refreezes = 0 CGAssociateMouseAndMouseCursorPosition(0) cursor.hide() let returns = all.map { (side: $0.edge.androidSide, start: $0.deviceStart, end: $0.deviceEnd) } send?(Wire.enter(side: passage.edge.androidSide, ratio: ratio, returns: returns)) - log.info("to Android, ratio \(ratio)") + log.notice("to Android, ratio \(ratio)") onRemoteChanged?(true) } private func forward(_ type: CGEventType, _ event: CGEvent) { switch type { case .mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + keepFrozen(event.location) let dx = Int((event.getDoubleValueField(.mouseEventDeltaX) * options.speed).rounded()) let dy = Int((event.getDoubleValueField(.mouseEventDeltaY) * options.speed).rounded()) if dx != 0 || dy != 0 { send?(Wire.mouse(buttons: buttons, dx: dx, dy: dy)) } @@ -163,6 +171,19 @@ final class InputCapture { } } + /// The cursor must stay put while the pointer is on the device. Detaching it from the mouse + /// (CGAssociateMouseAndMouseCursorPosition) does not always hold, e.g. right after the app starts, + /// so a cursor found away from its spot goes back and is detached again. + private func keepFrozen(_ location: CGPoint) { + guard abs(location.x - frozenAt.x) > 1 || abs(location.y - frozenAt.y) > 1 else { return } + CGWarpMouseCursorPosition(frozenAt) + CGAssociateMouseAndMouseCursorPosition(0) + refreezes += 1 + if refreezes == 1 || refreezes % 100 == 0 { + log.notice("cursor was moving while on the device; frozen again (\(self.refreezes)x)") + } + } + private func scroll(_ event: CGEvent) { let sign: Double = options.invertScroll ? -1 : 1 var wheel = 0 diff --git a/mac/scripts/build-app.sh b/mac/scripts/build-app.sh index df1a49f..f443086 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.2.0} +VERSION=${DROIDBRIDGE_VERSION:-0.2.1} IDENTITY=${SIGN_IDENTITY:--} APP=dist/DroidBridge.app diff --git a/server/build.sh b/server/build.sh index b5cec6d..e1a138a 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.2.0} +VERSION=${DROIDBRIDGE_VERSION:-0.2.1} PLATFORM=${ANDROID_PLATFORM:-36} BUILD_TOOLS=${ANDROID_BUILD_TOOLS:-36.0.0} : "${ANDROID_HOME:?set ANDROID_HOME}" diff --git a/server/src/dev/droidbridge/KeyboardLayouts.java b/server/src/dev/droidbridge/KeyboardLayouts.java index 4e108e9..7a8771f 100644 --- a/server/src/dev/droidbridge/KeyboardLayouts.java +++ b/server/src/dev/droidbridge/KeyboardLayouts.java @@ -23,11 +23,17 @@ static boolean apply(String deviceName, String layout) { return false; } try { + // The Mac sends its layout right after connecting; the keyboard may not be registered yet. InputDevice device = null; - for (int id : InputDevice.getDeviceIds()) { - InputDevice d = InputDevice.getDevice(id); - if (d != null && deviceName.equals(d.getName())) { - device = d; + for (int attempt = 0; attempt < 30 && device == null; attempt++) { + for (int id : InputDevice.getDeviceIds()) { + InputDevice d = InputDevice.getDevice(id); + if (d != null && deviceName.equals(d.getName())) { + device = d; + } + } + if (device == null) { + Thread.sleep(100); } } if (device == null) { @@ -42,6 +48,9 @@ static boolean apply(String deviceName, String layout) { set.invoke(im, identifier, PREFIX + layout); Log.i("keyboard layout " + layout); return true; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; } catch (ReflectiveOperationException | RuntimeException e) { Log.e("cannot set keyboard layout " + layout, e); return false; diff --git a/tools/devtest.py b/tools/devtest.py index bdba854..72ad063 100644 --- a/tools/devtest.py +++ b/tools/devtest.py @@ -5,7 +5,7 @@ ADB = "adb" REMOTE = "/data/local/tmp/droidbridge-server.jar" -VERSION = "0.2.0" +VERSION = "0.2.1" LEFT, RIGHT, TOP, BOTTOM = 0, 1, 2, 3 def adb(*a):