diff --git a/.gitignore b/.gitignore index 78144578..72f7f7ea 100644 --- a/.gitignore +++ b/.gitignore @@ -74,4 +74,4 @@ AGENTS.md .rambles .tend-stack docs/plans/ -build.log +build.log \ No newline at end of file diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 2fbb5b75..7da962e2 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -137,6 +137,7 @@ class AppState: ObservableObject { } @Published var minAndroidVersion = Bundle.main.infoDictionary?["AndroidVersion"] as? String ?? "2.0.0" + @Published var isManuallyDisconnected: Bool = false @Published var device: Device? = nil { didSet { @@ -738,6 +739,8 @@ class AppState: ObservableObject { func disconnectDevice() { DispatchQueue.main.async { + self.isManuallyDisconnected = true + // Send request to remote device to disconnect WebSocketServer.shared.sendDisconnectRequest() @@ -765,6 +768,32 @@ class AppState: ObservableObject { } } + func handleAutomaticDisconnect() { + DispatchQueue.main.async { + self.device = nil + self.activeMacIp = nil + self.notifications.removeAll() + self.status = nil + self.currentDeviceWallpaperBase64 = nil + + if QuickShareManager.shared.transferState != .idle { + QuickShareManager.shared.transferState = .idle + } + + if self.adbConnected { + ADBConnector.disconnectADB() + self.adbConnected = false + } + + self.showFileBrowser = false + self.browseItems.removeAll() + + if BLECentralManager.shared.isAuthenticated { + self.updateVirtualDeviceForBLE() + } + } + } + // MARK: - Remote File Browser func openFileBrowser() { diff --git a/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift b/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift index a2add757..daa69c39 100644 --- a/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift +++ b/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift @@ -94,22 +94,43 @@ class UDPDiscoveryManager: ObservableObject { guard Date().timeIntervalSince(self.lastBroadcastTime) >= 2.0 else { return } print("[Discovery] Network change detected – broadcasting presence") self.broadcastBurst() + // Also send peer exchange for no-WiFi scenarios + self.sendPeerExchange() } self.networkChangePendingWork = work queue.asyncAfter(deadline: .now() + 2.0, execute: work) } networkMonitor?.start(queue: queue) + + // 3. Start periodic peer exchange for no-WiFi discovery + startPeerExchangeTimer() + } + + private var peerExchangeTimer: Timer? + + private func startPeerExchangeTimer() { + peerExchangeTimer?.invalidate() + peerExchangeTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in + self?.sendPeerExchange() + } } private func stopMonitoring() { NSWorkspace.shared.notificationCenter.removeObserver(self) networkMonitor?.cancel() networkMonitor = nil + peerExchangeTimer?.invalidate() + peerExchangeTimer = nil } @objc private func handleSystemWake() { print("[Discovery] System wake detected") broadcastBurst() + sendPeerExchange() + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.tryAutoConnectToKnownDevice() + } } // MARK: - Broadcasting @@ -306,6 +327,12 @@ class UDPDiscoveryManager: ObservableObject { return } + // Handle peerExchange messages for no-WiFi discovery + if type == "peerExchange" { + handlePeerExchange(json, sourceIp: json["sourceIP"] as? String) + return + } + guard type == "presence" else { return } let name = json["name"] as? String ?? "Unknown Android" @@ -344,7 +371,97 @@ class UDPDiscoveryManager: ObservableObject { self.discoveredDevices.append(device) } } + if !AppState.shared.isManuallyDisconnected { + self.tryAutoConnectToKnownDevice() + } + } + } + + private func handlePeerExchange(_ json: [String: Any], sourceIp: String?) { + let id = json["id"] as? String ?? UUID().uuidString + let name = json["name"] as? String ?? "Unknown Android" + let port = json["port"] as? Int ?? 0 + + // Get IPs + var incomingIps: Set = [] + if let ipsArray = json["ips"] as? [String] { + incomingIps = Set(ipsArray) + } else if let singleIp = json["ip"] as? String { + incomingIps = [singleIp] + } + + // Get known peers from the exchange + if let knownPeers = json["knownPeers"] as? [String: [String]] { + // Learn about peer's known peers for recursive discovery + for (peerName, peerIps) in knownPeers { + print("[Discovery] Learned peer \(peerName) with IPs \(peerIps) from peer exchange") + } + } + + let validIps = incomingIps.filter { isValidCandidateIP($0) } + guard !validIps.isEmpty else { return } + + DispatchQueue.main.async { + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + if let index = self.discoveredDevices.firstIndex(where: { $0.deviceId == id }) { + var device = self.discoveredDevices[index] + device.ips.formUnion(validIps) + device.lastSeen = Date() + self.discoveredDevices[index] = device + } else { + let device = DiscoveredDevice( + deviceId: id, + name: name, + ips: validIps, + port: port, + type: "android", + lastSeen: Date() + ) + self.discoveredDevices.append(device) + } + } + } + } + + func sendPeerExchange() { + let adapters = WebSocketServer.shared.getAvailableNetworkAdapters() + let allIPs = adapters.map { $0.address } + + guard !allIPs.isEmpty else { return } + + let info = AppState.shared.myDevice + let port = info?.port ?? Int(Defaults.serverPort) + let name = info?.name ?? Host.current().localizedName ?? "Mac" + let uuid = getStableUUID() + + // Build peer exchange message with known peers + var knownPeers: [String: [String]] = [:] + for (_, device) in QuickConnectManager.shared.lastConnectedDevices { + knownPeers[device.name] = [device.ipAddress] + } + + let payload: [String: Any] = [ + "type": "peerExchange", + "deviceType": "mac", + "id": uuid, + "name": name, + "ips": allIPs, + "port": port, + "knownPeers": knownPeers + ] + + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let jsonString = String(data: data, encoding: .utf8) else { return } + + // Send to all known peers (Tailscale IPs) + for (_, device) in QuickConnectManager.shared.lastConnectedDevices { + if device.ipAddress.hasPrefix("100.") { + sendUnicast(message: jsonString, targetIP: device.ipAddress) + } } + + // Also broadcast normally + broadcastPresence() } /// IP validation @@ -407,6 +524,26 @@ class UDPDiscoveryManager: ObservableObject { self.objectWillChange.send() } } + + self.tryAutoConnectToKnownDevice() + } + } + + private func tryAutoConnectToKnownDevice() { + guard WebSocketServer.shared.connectedDevice == nil, + !AppState.shared.isManuallyDisconnected else { return } + + let lastDevice = QuickConnectManager.shared.getLastConnectedDevice() + ?? QuickConnectManager.shared.lastConnectedDevices.values.first + + guard let validLastDevice = lastDevice else { return } + + if let match = discoveredDevices.first(where: { $0.name == validLastDevice.name }) { + let bestIP = QuickConnectManager.shared.getBestTargetIP(from: match.ips) + if !bestIP.isEmpty { + print("[Discovery] Auto-connecting to known Android: \(validLastDevice.name) at \(bestIP):\(match.port)") + QuickConnectManager.shared.connect(to: match) + } } } diff --git a/airsync-mac/Core/QuickConnect/QuickConnectManager.swift b/airsync-mac/Core/QuickConnect/QuickConnectManager.swift index c5ff217c..049ba83f 100644 --- a/airsync-mac/Core/QuickConnect/QuickConnectManager.swift +++ b/airsync-mac/Core/QuickConnect/QuickConnectManager.swift @@ -64,8 +64,8 @@ class QuickConnectManager: ObservableObject { /// Attempts to wake up and reconnect to a specific discovered device func connect(to discoveredDevice: DiscoveredDevice) { - // Pick best IP: prefer local (non-100.x) over VPN - let bestIP = discoveredDevice.ips.first(where: { !$0.hasPrefix("100.") }) ?? discoveredDevice.ips.first ?? "" + // Pick best IP using subnet matching + let bestIP = getBestTargetIP(from: discoveredDevice.ips) // Convert DiscoveredDevice to Device model let device = Device( @@ -83,6 +83,7 @@ class QuickConnectManager: ObservableObject { // Show progress in UI DispatchQueue.main.async { self.connectingDeviceID = discoveredDevice.id + AppState.shared.isManuallyDisconnected = false } Task { @@ -184,6 +185,9 @@ class QuickConnectManager: ObservableObject { """ // Try to send HTTP POST request to the Android device + Task { + await sendUDPWakeUpRequest(to: device, message: wakeUpMessage) + } await sendHTTPWakeUpRequest(to: device, message: wakeUpMessage) // Clear progress after short delay @@ -192,6 +196,53 @@ class QuickConnectManager: ObservableObject { } } + /// Selects the best target IP from a set of discovered IPs by matching subnets with the Mac's adapters + func getBestTargetIP(from targetIPs: Set) -> String { + let adapters = WebSocketServer.shared.getAvailableNetworkAdapters() + let allMacIPs = adapters.map { $0.address } + + // 1. Try to find a target IP that shares the first 3 octets (same subnet) with one of our Mac IPs + for macIP in allMacIPs { + let macParts = macIP.split(separator: ".") + if macParts.count >= 3 { + let macSubnet = macParts[0...2].joined(separator: ".") + "." + if let match = targetIPs.first(where: { $0.hasPrefix(macSubnet) }) { + return match + } + } + } + + // 2. Try to find a target IP that shares the first 2 octets with one of our Mac IPs + for macIP in allMacIPs { + let macParts = macIP.split(separator: ".") + if macParts.count >= 2 { + let macSubnet = macParts[0...1].joined(separator: ".") + "." + if let match = targetIPs.first(where: { $0.hasPrefix(macSubnet) }) { + return match + } + } + } + + // 3. Try to find a target IP that shares the first octet with one of our Mac IPs + for macIP in allMacIPs { + let macParts = macIP.split(separator: ".") + if let firstOctet = macParts.first { + let macSubnet = "\(firstOctet)." + if let match = targetIPs.first(where: { $0.hasPrefix(macSubnet) }) { + return match + } + } + } + + // 4. Fallback: Prefer non-Tailscale local IPs + if let localIP = targetIPs.first(where: { !$0.hasPrefix("100.") }) { + return localIP + } + + // 5. Ultimate fallback + return targetIPs.first ?? "" + } + /// Selects the best local IP to present to the target device /// Prioritizes IPs that match the target's subnet/prefix (e.g. Tailscale 100.x) private func getBestLocalIP(for targetIP: String) -> String? { @@ -243,38 +294,19 @@ class QuickConnectManager: ObservableObject { request.httpBody = message.data(using: .utf8) request.timeoutInterval = 5.0 - var success = false - do { let (_, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse { if httpResponse.statusCode == 200 { print("[quick-connect] Wake-up request successful - device should reconnect soon") - success = true - } else if httpResponse.statusCode == 502 { - print("[quick-connect] Wake-up request failed with 502 (Bad Gateway). Retrying once...") - - // Small delay before retry - try? await Task.sleep(nanoseconds: 500_000_000) - - if let (_, secondResponse) = try? await URLSession.shared.data(for: request), - let secondHttpResponse = secondResponse as? HTTPURLResponse, - secondHttpResponse.statusCode == 200 { - print("[quick-connect] Wake-up retry successful") - success = true - } else { - print("[quick-connect] Wake-up retry failed") - } } else { print("[quick-connect] Wake-up request failed with status: \(httpResponse.statusCode)") } } } catch { print("[quick-connect] Failed to send wake-up request: \(error)") - } - - if !success { + // Fallback: Try UDP broadcast await sendUDPWakeUpRequest(to: device, message: message) } @@ -284,28 +316,48 @@ class QuickConnectManager: ObservableObject { print("[quick-connect] Trying UDP wake-up to \(device.ipAddress):\(Self.ANDROID_UDP_WAKEUP_PORT) as fallback") // Simple UDP wake-up attempt (fire and forget) - let udpMessage = "AIRSYNC_WAKEUP:\(message)" + let udpMessage = message DispatchQueue.global(qos: .background).async { // Create UDP socket and send wake-up message - let socket = socket(AF_INET, SOCK_DGRAM, 0) - defer { close(socket) } + let socketFd = socket(AF_INET, SOCK_DGRAM, 0) + defer { close(socketFd) } - guard socket >= 0 else { + guard socketFd >= 0 else { print("[quick-connect] Failed to create UDP socket") return } - var addr = sockaddr_in() - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = in_port_t(UInt16(Self.ANDROID_UDP_WAKEUP_PORT).bigEndian) - inet_aton(device.ipAddress, &addr.sin_addr) + // Enable broadcast + var broadcastEnable: Int32 = 1 + setsockopt(socketFd, SOL_SOCKET, SO_BROADCAST, &broadcastEnable, socklen_t(MemoryLayout.size)) let messageData = udpMessage.data(using: .utf8) ?? Data() + + // 1. Send Unicast + var unicastAddr = sockaddr_in() + unicastAddr.sin_family = sa_family_t(AF_INET) + unicastAddr.sin_port = in_port_t(UInt16(Self.ANDROID_UDP_WAKEUP_PORT).bigEndian) + inet_aton(device.ipAddress, &unicastAddr.sin_addr) + + _ = messageData.withUnsafeBytes { bytes in + withUnsafePointer(to: unicastAddr) { addrPtr in + addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + sendto(socketFd, bytes.bindMemory(to: Int8.self).baseAddress, messageData.count, 0, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + } + + // 2. Send Broadcast + var broadcastAddr = sockaddr_in() + broadcastAddr.sin_family = sa_family_t(AF_INET) + broadcastAddr.sin_port = in_port_t(UInt16(Self.ANDROID_UDP_WAKEUP_PORT).bigEndian) + broadcastAddr.sin_addr.s_addr = inet_addr("255.255.255.255") + _ = messageData.withUnsafeBytes { bytes in - withUnsafePointer(to: addr) { addrPtr in + withUnsafePointer(to: broadcastAddr) { addrPtr in addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - return sendto(socket, bytes.bindMemory(to: Int8.self).baseAddress, messageData.count, 0, sockaddrPtr, socklen_t(MemoryLayout.size)) + sendto(socketFd, bytes.bindMemory(to: Int8.self).baseAddress, messageData.count, 0, sockaddrPtr, socklen_t(MemoryLayout.size)) } } } diff --git a/airsync-mac/Core/SentryInitializer.swift b/airsync-mac/Core/SentryInitializer.swift index 99667283..3a7650d9 100644 --- a/airsync-mac/Core/SentryInitializer.swift +++ b/airsync-mac/Core/SentryInitializer.swift @@ -25,12 +25,6 @@ struct SentryInitializer { options.sendDefaultPii = true options.beforeSend = { event in - // Ignore transient wake-up failures (often 502/timeout while device is waking up) - if let request = event.request, let url = request.url, url.contains("/wakeup") { - print("[SentryInitializer] Filtering out transient wake-up error for: \(url)") - return nil - } - if let exceptions = event.exceptions, let firstException = exceptions.first, firstException.type == "App Hanging" { diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index 5d15ad66..2353ec4f 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -22,6 +22,8 @@ extension WebSocketServer { } switch message.type { + case .pong: + break case .device: handleDeviceHandshake(message, session: session) case .notification: diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Networking.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Networking.swift index a3281768..c1285a48 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Networking.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Networking.swift @@ -153,17 +153,25 @@ extension WebSocketServer { } if let lastIP = lastIP, lastIP != chosenIP { - print("[websocket] (network) IP changed from \(lastIP) to \(chosenIP ?? "N/A"), restarting WebSocket in 5 seconds") + print("[websocket] (network) IP changed from \(lastIP) to \(chosenIP ?? "N/A"), stopping server immediately and restarting in 5 seconds") DispatchQueue.main.async { self.lock.lock() self.lastKnownIP = chosenIP self.lock.unlock() AppState.shared.shouldRefreshQR = true + + self.reconnectGraceTimer?.invalidate() + self.reconnectGraceTimer = nil + + AppState.shared.handleAutomaticDisconnect() } + // Stop the server immediately to close existing connections + self.stop() + + // Delay the startup of the new server to let network interfaces stabilize DispatchQueue.main.asyncAfter(deadline: .now() + 5) { - self.stop() self.start(port: Defaults.serverPort) } } else if lastIP == nil { diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index eff27e75..56faad1f 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -21,6 +21,7 @@ class WebSocketServer: ObservableObject { internal let pingInterval: TimeInterval = 12.5 internal var lastActivity: [ObjectIdentifier: Date] = [:] internal let activityTimeout: TimeInterval = 45.0 + internal var reconnectGraceTimer: Timer? @Published var symmetricKey: SymmetricKey? @Published var localPort: UInt16? @@ -81,8 +82,8 @@ class WebSocketServer: ObservableObject { return } - self.lock.lock() self.stopAllServers() + self.lock.lock() if let specificAdapter = adapterName { self.isListeningOnAll = false @@ -138,15 +139,19 @@ class WebSocketServer: ObservableObject { } internal func stopAllServers() { - for (_, server) in servers { + self.lock.lock() + let serversToStop = Array(servers.values) + servers.removeAll() + self.lock.unlock() + + for server in serversToStop { server.stop() } - servers.removeAll() } func stop() { - lock.lock() stopAllServers() + lock.lock() activeSessions.removeAll() primarySessionID = nil stopPing() @@ -207,6 +212,14 @@ class WebSocketServer: ObservableObject { self.lock.unlock() print("[websocket] Session \(sessionId) connected.") + DispatchQueue.main.async { + if self.reconnectGraceTimer != nil { + print("[websocket] Client reconnected within grace period. Cancelling disconnect timer.") + self.reconnectGraceTimer?.invalidate() + self.reconnectGraceTimer = nil + } + } + if self.primarySessionID == nil { self.primarySessionID = sessionId } @@ -232,11 +245,26 @@ class WebSocketServer: ObservableObject { if wasPrimary { DispatchQueue.main.async { - AppState.shared.disconnectDevice() - ADBConnector.disconnectADB() - AppState.shared.adbConnected = false - // Guard against cascading restarts from multiple disconnected callbacks - self.restartServer() + self.reconnectGraceTimer?.invalidate() + if !AppState.shared.isManuallyDisconnected { + self.reconnectGraceTimer = Timer.scheduledTimer(withTimeInterval: 6.0, repeats: false) { [weak self] _ in + guard let self = self else { return } + self.lock.lock() + let activeCount = self.activeSessions.count + self.lock.unlock() + + if activeCount == 0 { + print("[websocket] Grace period expired without reconnection. Disconnecting device.") + AppState.shared.handleAutomaticDisconnect() + self.restartServer() + } else { + print("[websocket] Grace period expired, but an active session is running. Skipping disconnect.") + } + } + } else { + print("[websocket] Manual disconnect detected. Restarting server immediately.") + self.restartServer() + } } } } @@ -298,13 +326,16 @@ class WebSocketServer: ObservableObject { self.lock.unlock() print("[websocket] Scheduling server restart in 1.5 s…") - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + // Use a background queue so stop()/start() don't block the main thread / UI + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 1.5) { self.stop() self.start(port: port) // Re-announce presence immediately after restart so Android can find us - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - UDPDiscoveryManager.shared.broadcastBurst() + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 1.0) { + DispatchQueue.main.async { + UDPDiscoveryManager.shared.broadcastBurst() + } self.lock.lock() self.isRestarting = false self.lock.unlock() diff --git a/airsync-mac/Model/Message.swift b/airsync-mac/Model/Message.swift index 4d0c4619..5cd467b5 100644 --- a/airsync-mac/Model/Message.swift +++ b/airsync-mac/Model/Message.swift @@ -7,6 +7,7 @@ import Foundation enum MessageType: String, Codable { + case pong case device case macInfo case notification