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/AppDelegate.swift b/airsync-mac/Core/AppDelegate.swift index c173a379..47d4df52 100644 --- a/airsync-mac/Core/AppDelegate.swift +++ b/airsync-mac/Core/AppDelegate.swift @@ -42,12 +42,86 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Register Services Provider NSApp.servicesProvider = self NSUpdateDynamicServices() + + // Register for System Sleep/Wake Notifications + registerForSleepWakeNotifications() } private func setupSentry() { SentryInitializer.start() } + private func registerForSleepWakeNotifications() { + let nc = NSWorkspace.shared.notificationCenter + nc.addObserver( + self, + selector: #selector(handleSystemSleep), + name: NSWorkspace.willSleepNotification, + object: nil + ) + nc.addObserver( + self, + selector: #selector(handleSystemWake), + name: NSWorkspace.didWakeNotification, + object: nil + ) + } + + @objc private func handleSystemSleep() { + print("[AppDelegate] System going to sleep. Cleaning up connections and background tasks.") + + // 1. Mark system as sleeping to block automatic background connections/scans + AppState.shared.isSystemSleeping = true + + // 2. Disconnect active device connection cleanly + AppState.shared.disconnectDevice(isManual: false) + + // 3. Stop WebSocket server + WebSocketServer.shared.stop() + + // 4. Stop BLE scanning explicitly + BLECentralManager.shared.stopScanning() + + // 5. Stop UDP Discovery + UDPDiscoveryManager.shared.stop() + } + + @objc private func handleSystemWake() { + print("[AppDelegate] System waking up. Resuming services.") + + // 1. Mark system as awake + AppState.shared.isSystemSleeping = false + + // 2. Restart WebSocket server + startWebSocketServer() + + // 3. Restart UDP Discovery + UDPDiscoveryManager.shared.start() + + // 4. If BLE Auto Connect is enabled, start BLE scanning + if AppState.shared.isBLEAutoConnectEnabled { + BLECentralManager.shared.isManuallyDisconnected = false + BLECentralManager.shared.startScanning() + } + + // 5. Auto connect to known/last connected device via Quick Connect + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + QuickConnectManager.shared.wakeUpLastConnectedDevice() + } + } + + private func startWebSocketServer() { + let rawPortInt = AppState.shared.myDevice?.port ?? Int(Defaults.serverPort) + let chosenPort: UInt16 + if rawPortInt <= 0 || rawPortInt > 65_535 { + print("[AppDelegate] Invalid configured port \(rawPortInt). Falling back to 8080.") + chosenPort = UInt16(8080) + } else { + chosenPort = UInt16(rawPortInt) + } + WebSocketServer.shared.start(port: chosenPort) + } + func application(_ application: NSApplication, open urls: [URL]) { if !urls.isEmpty { QuickShareManager.shared.transferURLs = urls diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 03c65d68..55ef1683 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -33,7 +33,7 @@ class AppState: ObservableObject { self.isPlus = isPlusLoaded let adbPortValue = UserDefaults.standard.integer(forKey: "adbPort") - self.adbPort = adbPortValue == 0 ? 5555 : UInt16(adbPortValue) + self.adbPort = (adbPortValue > 0 && adbPortValue <= 65535) ? UInt16(adbPortValue) : 5555 self.adbConnectedIP = UserDefaults.standard.string(forKey: "adbConnectedIP") ?? "" self.mirroringPlus = UserDefaults.standard.bool(forKey: "mirroringPlus") self.adbEnabled = UserDefaults.standard.bool(forKey: "adbEnabled") @@ -126,7 +126,9 @@ class AppState: ObservableObject { self.isBLEAutoConnectEnabled = UserDefaults.standard.object(forKey: "isBLEAutoConnectEnabled") == nil ? true : UserDefaults.standard.bool(forKey: "isBLEAutoConnectEnabled") if isBLEEnabled { - BLECentralManager.shared.startScanning() + DispatchQueue.main.async { + BLECentralManager.shared.startScanning() + } } BLECentralManager.shared.$connectionStatus @@ -174,6 +176,8 @@ class AppState: ObservableObject { } @Published var minAndroidVersion = Bundle.main.infoDictionary?["AndroidVersion"] as? String ?? "2.0.0" + @Published var isManuallyDisconnected: Bool = false + @Published var isSystemSleeping: Bool = false @Published var device: Device? = nil { didSet { @@ -205,10 +209,16 @@ class AppState: ObservableObject { let wasRegularConnection = oldValue?.ipAddress != nil && oldValue?.ipAddress != "BLE" if isRegularConnection && !wasRegularConnection { - // Regular connection established — stop BLE scanning to save power/bandwidth - if isBLEEnabled && BLECentralManager.shared.connectionStatus == .scanning { - print("[state] Regular connection active — pausing BLE scan") - BLECentralManager.shared.stopScanning() + // Regular connection established — stop BLE scanning to save power/bandwidth and disconnect active BLE + if isBLEEnabled { + if BLECentralManager.shared.connectionStatus == .scanning { + print("[state] Regular connection active — pausing BLE scan") + BLECentralManager.shared.stopScanning() + } + if BLECentralManager.shared.isAuthenticated { + print("[state] Regular connection established over Wi-Fi — disconnecting BLE active connection to shift to local network") + BLECentralManager.shared.disconnect(isManual: false) + } } } else if !isRegularConnection && wasRegularConnection { // Regular connection lost — resume BLE scanning if BLE is enabled and not already BLE-connected @@ -218,6 +228,19 @@ class AppState: ObservableObject { BLECentralManager.shared.startScanning() } } + + // UDP scan management: stop when regular connection is active, start when connection is lost or only BLE is active + let isRegularConnectionActive = device != nil && device?.ipAddress != "BLE" + if isSystemSleeping { + print("[state] System is sleeping — keeping UDP discovery stopped") + UDPDiscoveryManager.shared.stop() + } else if isRegularConnectionActive { + print("[state] Regular connection established — stopping UDP discovery") + UDPDiscoveryManager.shared.stop() + } else { + print("[state] No regular connection active — starting/keeping UDP discovery active") + UDPDiscoveryManager.shared.start() + } } } @Published var notifications: [Notification] = [] @@ -504,7 +527,7 @@ class AppState: ObservableObject { BLECentralManager.shared.startScanning() } else { BLECentralManager.shared.stopScanning() - BLECentralManager.shared.disconnect() + BLECentralManager.shared.disconnect(isManual: true) } } } @@ -957,10 +980,14 @@ class AppState: ObservableObject { } } - func disconnectDevice() { + func disconnectDevice(isManual: Bool = true) { DispatchQueue.main.async { + self.isManuallyDisconnected = isManual + // Send request to remote device to disconnect - WebSocketServer.shared.sendDisconnectRequest() + if isManual { + WebSocketServer.shared.sendDisconnectRequest() + } // Then locally reset state self.device = nil @@ -970,7 +997,7 @@ class AppState: ObservableObject { self.currentDeviceWallpaperBase64 = nil // Disconnect BLE - BLECentralManager.shared.disconnect() + BLECentralManager.shared.disconnect(isManual: isManual) // Clean up Quick Share state if QuickShareManager.shared.transferState != .idle { @@ -989,6 +1016,33 @@ class AppState: ObservableObject { } } + func handleAutomaticDisconnect() { + DispatchQueue.main.async { + self.isManuallyDisconnected = false + 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() { @@ -1505,15 +1559,6 @@ class AppState: ObservableObject { self.status = nil self.notifications = [] } - // Resume scanning after BLE disconnect (unless a regular connection is already active) - let hasRegularConnection = self.device?.ipAddress != nil && self.device?.ipAddress != "BLE" - if isBLEEnabled && !hasRegularConnection && !BLECentralManager.shared.isManuallyDisconnected { - DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { - if self.isBLEEnabled && self.device?.ipAddress != "BLE" && !BLECentralManager.shared.isAuthenticated { - BLECentralManager.shared.startScanning() - } - } - } } } diff --git a/airsync-mac/Core/BLE/BLECentralManager.swift b/airsync-mac/Core/BLE/BLECentralManager.swift index e7068910..a931ae26 100644 --- a/airsync-mac/Core/BLE/BLECentralManager.swift +++ b/airsync-mac/Core/BLE/BLECentralManager.swift @@ -10,6 +10,9 @@ class BLECentralManager: NSObject, ObservableObject { private var characteristics: [CBUUID: CBCharacteristic] = [:] private var chunkBuffers: [CBUUID: [Int: Data]] = [:] + private var serviceCharacteristicsDiscoveryStarted = Set() + private var discoveredServiceUUIDs = Set() + private var isAuthTokenWritten = false private var discoveredServiceCount = 0 private let expectedServiceCount = 4 @@ -17,6 +20,7 @@ class BLECentralManager: NSObject, ObservableObject { @Published var connectedDeviceName: String? = nil struct BLEDiscoveryRecord { let peripheral: CBPeripheral + var name: String var lastSeen: Date } @@ -50,7 +54,17 @@ class BLECentralManager: NSObject, ObservableObject { private var watchdogTimer: Timer? func startScanning() { + guard !AppState.shared.isSystemSleeping else { + print("[BLE] System is sleeping, refusing to start BLE scanning.") + return + } guard centralManager.state == .poweredOn else { return } + let isRegularConnectionActive = AppState.shared.device != nil && AppState.shared.device?.ipAddress != "BLE" + guard !isRegularConnectionActive else { + print("[BLE] Skipping scan: active regular connection exists") + return + } + guard connectionStatus == .disconnected || connectionStatus == .scanning else { return } print("[BLE] Starting scan...") connectionStatus = .scanning @@ -64,6 +78,16 @@ class BLECentralManager: NSObject, ObservableObject { scanTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { [weak self] _ in guard let self = self else { return } + let regularActive = AppState.shared.device != nil && AppState.shared.device?.ipAddress != "BLE" + if regularActive { + print("[BLE] Stopping scan: regular connection active") + self.stopScanning() + return + } + + guard self.connectionStatus == .scanning || self.connectionStatus == .disconnected else { return } +// print("[BLE] Restarting scan...") + // Prune stale devices older than 25 seconds let now = Date() let staleUUIDs = self.discoveredPeripherals.filter { now.timeIntervalSince($1.lastSeen) > 15.0 }.map { $0.key } @@ -85,14 +109,28 @@ class BLECentralManager: NSObject, ObservableObject { } } - func disconnect() { + private func prepareForConnection() { + characteristics.removeAll() + chunkBuffers.removeAll() + serviceCharacteristicsDiscoveryStarted.removeAll() + discoveredServiceUUIDs.removeAll() + isAuthTokenWritten = false + discoveredServiceCount = 0 + connectionTimer?.invalidate() + connectionTimer = nil + } + + func disconnect(isManual: Bool = false) { watchdogTimer?.invalidate() watchdogTimer = nil - isManuallyDisconnected = true + if isManual { + isManuallyDisconnected = true + } if let peripheral = discoveredPeripheral { centralManager.cancelPeripheralConnection(peripheral) } + discoveredPeripheral = nil connectionStatus = .disconnected connectingDeviceUUID = nil discoveredPeripherals.removeAll() @@ -117,20 +155,43 @@ class BLECentralManager: NSObject, ObservableObject { } } + private func getStoredDeviceName() -> String? { + if let name = UserDefaults.standard.string(forKey: "bleDeviceName"), !name.isEmpty { + return name + } + if let device = QuickConnectManager.shared.lastConnectedDevices.values.first { + return device.name + } + return nil + } + var discoveredBLEDevices: [DiscoveredDevice] { let token = UserDefaults.standard.string(forKey: "bleAuthToken") ?? "" if token.isEmpty { return [] } + let fallbackName = getStoredDeviceName() ?? "Android Device" + return discoveredPeripherals.values.map { record in - DiscoveredDevice( + var displayName = record.name + if displayName == "Unknown" || displayName == "Android Device" { + displayName = record.peripheral.name ?? fallbackName + } + if displayName.hasPrefix("AirSync-") { + displayName = String(displayName.dropFirst(8)) + } + + let udpDevice = UDPDiscoveryManager.shared.discoveredDevices.first(where: { $0.name == displayName }) + return DiscoveredDevice( deviceId: record.peripheral.identifier.uuidString, - name: record.peripheral.name ?? "Android Device", + name: displayName, ips: ["Bluetooth LE"], port: 0, type: "ble", - lastSeen: record.lastSeen + lastSeen: record.lastSeen, + autoConnect: udpDevice?.autoConnect ?? true, + bleAutoConnect: udpDevice?.bleAutoConnect ?? true ) } } @@ -154,22 +215,17 @@ class BLECentralManager: NSObject, ObservableObject { connectingDeviceUUID = uuidStr connectionStatus = .scanning + prepareForConnection() centralManager.connect(peripheral, options: [ CBConnectPeripheralOptionNotifyOnDisconnectionKey: true ]) - connectionTimer?.invalidate() connectionTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: false) { [weak self] _ in guard let self = self else { return } print("[BLE] Manual connection timed out, cancelling...") if let p = self.discoveredPeripheral { self.centralManager.cancelPeripheralConnection(p) } - self.discoveredPeripheral = nil - self.connectingDeviceUUID = nil - self.connectionStatus = .disconnected - self.characteristics.removeAll() - self.discoveredServiceCount = 0 } } @@ -198,7 +254,20 @@ extension BLECentralManager: CBCentralManagerDelegate { } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { - let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "Unknown" + // Try to get name from advertisementData, peripheral, or stored name + var name = "Unknown" + if let advName = advertisementData[CBAdvertisementDataLocalNameKey] as? String, !advName.isEmpty { + name = advName + } else if let pName = peripheral.name, !pName.isEmpty { + name = pName + } else if let storedName = getStoredDeviceName(), !storedName.isEmpty { + name = storedName + } + + if name.hasPrefix("AirSync-") { + name = String(name.dropFirst(8)) + } + let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? [] let uuidStr = peripheral.identifier.uuidString let isNewDevice = discoveredPeripherals[uuidStr] == nil @@ -207,44 +276,83 @@ extension BLECentralManager: CBCentralManagerDelegate { } DispatchQueue.main.async { - self.discoveredPeripherals[uuidStr] = BLEDiscoveryRecord(peripheral: peripheral, lastSeen: Date()) + self.discoveredPeripherals[uuidStr] = BLEDiscoveryRecord( + peripheral: peripheral, + name: name, + lastSeen: Date() + ) } - // Auto connect if enabled and not manually disconnected - if AppState.shared.isBLEAutoConnectEnabled && !isManuallyDisconnected { + // Auto connect if enabled, not manually disconnected, and no active regular connection exists + let isRegularConnectionActive = AppState.shared.device != nil && AppState.shared.device?.ipAddress != "BLE" + if AppState.shared.isBLEAutoConnectEnabled && !isManuallyDisconnected && !isRegularConnectionActive { + // Guard: check if we have a UDP discovered device record and if it disables BLE auto-connect + if let match = UDPDiscoveryManager.shared.discoveredDevices.first(where: { $0.name == name }) { + guard match.bleAutoConnect else { + print("[BLE] Skipping BLE auto-connect because bleAutoConnect is disabled for \(name)") + return + } + } + + // Guard: must not be already connecting or connected + guard connectionTimer == nil && connectionStatus != .connected && connectionStatus != .authenticated else { return } + let token = UserDefaults.standard.string(forKey: "bleAuthToken") ?? "" if token.isEmpty { return } - discoveredPeripheral = peripheral - centralManager.stopScan() - scanTimer?.invalidate() - scanTimer = nil - - print("[BLE] Attempting auto-connect to \(name)...") - centralManager.connect(peripheral, options: [ - CBConnectPeripheralOptionNotifyOnDisconnectionKey: true - ]) + // Check if already discovered via UDP + let isDiscoveredViaUDP = UDPDiscoveryManager.shared.discoveredDevices.contains { $0.name == name } + guard !isDiscoveredViaUDP else { + print("[BLE] Prioritizing Wi-Fi/Hotspot: \(name) is discovered via UDP, skipping BLE auto-connect") + return + } - // CoreBluetooth connect() has no timeout — it can hang forever with stale pairing data. - connectionTimer?.invalidate() - connectionTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: false) { [weak self] _ in - guard let self = self else { return } - print("[BLE] Connection timed out, cancelling and retrying...") - if let p = self.discoveredPeripheral { - self.centralManager.cancelPeripheralConnection(p) - } - self.discoveredPeripheral = nil - self.connectionStatus = .disconnected - self.characteristics.removeAll() - self.discoveredServiceCount = 0 + // If local network is active, delay BLE auto-connect by 3 seconds to give Wi-Fi/Hotspot priority + let adapters = WebSocketServer.shared.getAvailableNetworkAdapters() + if !adapters.isEmpty { + print("[BLE] Local network active. Delaying BLE auto-connect by 3.0s to prioritize Wi-Fi/Hotspot...") - if AppState.shared.isBLEAutoConnectEnabled && !self.isManuallyDisconnected { - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self.startScanning() + connectionTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in + guard let self = self else { return } + + // Re-verify all guards after 3 seconds + let isRegActive = AppState.shared.device != nil && AppState.shared.device?.ipAddress != "BLE" + let regDiscovered = UDPDiscoveryManager.shared.discoveredDevices.contains { $0.name == name } + guard !isRegActive && !regDiscovered && !self.isManuallyDisconnected else { + print("[BLE] Wi-Fi/Hotspot connection active or discovered, cancelling delayed BLE auto-connect") + self.connectionTimer = nil + return } + + self.performBLEAutoConnect(peripheral: peripheral, name: name) } + } else { + // No local network active — immediately connect via BLE as last resort! + print("[BLE] No local network active. Connecting via BLE immediately as last resort...") + performBLEAutoConnect(peripheral: peripheral, name: name) + } + } + } + + private func performBLEAutoConnect(peripheral: CBPeripheral, name: String) { + discoveredPeripheral = peripheral + centralManager.stopScan() + scanTimer?.invalidate() + scanTimer = nil + + print("[BLE] Attempting auto-connect to \(name)...") + prepareForConnection() + centralManager.connect(peripheral, options: [ + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true + ]) + + connectionTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: false) { [weak self] _ in + guard let self = self else { return } + print("[BLE] Connection timed out, cancelling...") + if let p = self.discoveredPeripheral { + self.centralManager.cancelPeripheralConnection(p) } } } @@ -253,6 +361,7 @@ extension BLECentralManager: CBCentralManagerDelegate { connectionTimer?.invalidate() connectionTimer = nil connectingDeviceUUID = nil + discoveredServiceCount = 0 let name = peripheral.name ?? "Unknown Device" let maxWrite = peripheral.maximumWriteValueLength(for: .withoutResponse) print("[BLE] Connected to \(name), Max Write Length: \(maxWrite)") @@ -271,6 +380,10 @@ extension BLECentralManager: CBCentralManagerDelegate { connectionStatus = .disconnected discoveredPeripheral = nil characteristics.removeAll() + chunkBuffers.removeAll() + serviceCharacteristicsDiscoveryStarted.removeAll() + discoveredServiceUUIDs.removeAll() + isAuthTokenWritten = false discoveredServiceCount = 0 // Retry scanning after a delay @@ -294,6 +407,9 @@ extension BLECentralManager: CBCentralManagerDelegate { connectedDeviceName = nil characteristics.removeAll() chunkBuffers.removeAll() + serviceCharacteristicsDiscoveryStarted.removeAll() + discoveredServiceUUIDs.removeAll() + isAuthTokenWritten = false discoveredServiceCount = 0 if AppState.shared.isBLEAutoConnectEnabled && !isManuallyDisconnected { @@ -306,9 +422,28 @@ extension BLECentralManager: CBCentralManagerDelegate { extension BLECentralManager: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - guard let services = peripheral.services else { return } + guard let services = peripheral.services else { + print("[BLE] Service discovery completed with empty services or error: \(error?.localizedDescription ?? "nil")") + return + } + let targetServices = [ + BLEConstants.serviceSystem, + BLEConstants.serviceNotifications, + BLEConstants.serviceMedia, + BLEConstants.serviceClipboard + ] for service in services { - peripheral.discoverCharacteristics(nil, for: service) + if targetServices.contains(service.uuid) { + if !serviceCharacteristicsDiscoveryStarted.contains(service.uuid) { + serviceCharacteristicsDiscoveryStarted.insert(service.uuid) + print("[BLE] Discovering characteristics for target service: \(service.uuid)") + peripheral.discoverCharacteristics(nil, for: service) + } else { + print("[BLE] Skipping characteristics discovery for already-started service: \(service.uuid)") + } + } else { + print("[BLE] Skipping discovery for non-target service: \(service.uuid)") + } } } @@ -319,18 +454,32 @@ extension BLECentralManager: CBPeripheralDelegate { characteristics[char.uuid] = char if char.properties.contains(.notify) { - print("[BLE] Subscribing to \(char.uuid)") - peripheral.setNotifyValue(true, for: char) + if !char.isNotifying { + print("[BLE] Subscribing to \(char.uuid)") + peripheral.setNotifyValue(true, for: char) + } else { + print("[BLE] Already subscribed to notify for \(char.uuid)") + } } } - discoveredServiceCount += 1 - print("[BLE] Services discovered: \(discoveredServiceCount)/\(expectedServiceCount)") + if !discoveredServiceUUIDs.contains(service.uuid) { + discoveredServiceUUIDs.insert(service.uuid) + discoveredServiceCount = discoveredServiceUUIDs.count + print("[BLE] Service characteristics successfully discovered: \(service.uuid) (\(discoveredServiceCount)/\(expectedServiceCount))") + } - // Only attempt auth after ALL services are discovered - if discoveredServiceCount >= expectedServiceCount { + // Only attempt auth after ALL target services are discovered + let targetServices = [ + BLEConstants.serviceSystem, + BLEConstants.serviceNotifications, + BLEConstants.serviceMedia, + BLEConstants.serviceClipboard + ] + let allDiscovered = targetServices.allSatisfy { discoveredServiceUUIDs.contains($0) } + if allDiscovered { if characteristics[BLEConstants.charAuthToken] != nil { - print("[BLE] All services discovered, attempting authentication...") + print("[BLE] All target services discovered, attempting authentication...") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.attemptAuthentication() } @@ -339,17 +488,46 @@ extension BLECentralManager: CBPeripheralDelegate { } private func attemptAuthentication() { - guard connectionStatus == .connected else { return } + guard connectionStatus == .connected else { + print("[BLE] Skipping authentication attempt: connectionStatus is \(connectionStatus)") + return + } + guard !isAuthTokenWritten else { + print("[BLE] Auth token already written, skipping duplicate write.") + return + } let token = UserDefaults.standard.string(forKey: "bleAuthToken") ?? "" + print("[BLE] Found stored auth token for authentication: length=\(token.count), tokenIsEmpty=\(token.isEmpty)") if !token.isEmpty, let data = token.data(using: .utf8) { - print("[BLE] Attempting authentication...") - write(characteristicUUID: BLEConstants.charAuthToken, data: data) + if let peripheral = discoveredPeripheral, let char = characteristics[BLEConstants.charAuthToken] { + print("[BLE] Writing auth token data (\(data.count) bytes) to characteristic \(char.uuid) withResponse") + isAuthTokenWritten = true + peripheral.writeValue(data, for: char, type: .withResponse) + } else { + print("[BLE] Failed to write auth token: peripheral is \(discoveredPeripheral == nil ? "nil" : "non-nil"), char is \(characteristics[BLEConstants.charAuthToken] == nil ? "nil" : "non-nil")") + } } else { print("[BLE] Auth token is empty, skipping auth and disconnecting because they have never paired") disconnect() } } + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + print("[BLE] Error writing value for \(characteristic.uuid.uuidString): \(error.localizedDescription) (Raw error: \(error))") + } else { + print("[BLE] Successfully wrote value for characteristic: \(characteristic.uuid.uuidString)") + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + print("[BLE] Error updating notification state for \(characteristic.uuid.uuidString): \(error.localizedDescription) (Raw error: \(error))") + } else { + print("[BLE] Notification state updated for \(characteristic.uuid.uuidString): isNotifying=\(characteristic.isNotifying)") + } + } + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { resetWatchdog() guard let data = characteristic.value else { return } diff --git a/airsync-mac/Core/BLE/BLEChunkUtil.swift b/airsync-mac/Core/BLE/BLEChunkUtil.swift index a8688877..baa700b6 100644 --- a/airsync-mac/Core/BLE/BLEChunkUtil.swift +++ b/airsync-mac/Core/BLE/BLEChunkUtil.swift @@ -8,7 +8,8 @@ struct BLEChunkUtil { guard maxPayloadSize > 0 else { return [] } - let totalChunks = Int(ceil(Double(data.count) / Double(maxPayloadSize))) + let totalChunksVal = Int(ceil(Double(data.count) / Double(maxPayloadSize))) + let totalChunks = min(totalChunksVal, Int(UInt16.max)) var chunks: [Data] = [] for i in 0..= 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() { + guard !AppState.shared.isSystemSleeping else { return } print("[Discovery] System wake detected") broadcastBurst() + sendPeerExchange() + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + guard !AppState.shared.isSystemSleeping else { return } + self?.tryAutoConnectToKnownDevice() + } } // MARK: - Broadcasting @@ -128,6 +157,7 @@ class UDPDiscoveryManager: ObservableObject { } func broadcastPresence() { + guard AppState.shared.device == nil || AppState.shared.device?.ipAddress == "BLE" else { return } let adapters = WebSocketServer.shared.getAvailableNetworkAdapters() guard !adapters.isEmpty else { return } @@ -306,10 +336,18 @@ 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" let port = json["port"] as? Int ?? 0 + let autoConnect = json["autoConnect"] as? Bool ?? true + let bleAutoConnect = json["bleAutoConnect"] as? Bool ?? true // Support both "ips" (array) and legacy "ip" (string) var incomingIps: Set = [] @@ -330,6 +368,8 @@ class UDPDiscoveryManager: ObservableObject { var device = self.discoveredDevices[index] device.ips.formUnion(validIps) device.lastSeen = Date() + device.autoConnect = autoConnect + device.bleAutoConnect = bleAutoConnect self.discoveredDevices[index] = device } else { // New device @@ -339,7 +379,64 @@ class UDPDiscoveryManager: ObservableObject { ips: validIps, port: port, type: deviceType, - lastSeen: Date() + lastSeen: Date(), + autoConnect: autoConnect, + bleAutoConnect: bleAutoConnect + ) + 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 + let autoConnect = json["autoConnect"] as? Bool ?? true + let bleAutoConnect = json["bleAutoConnect"] as? Bool ?? true + + // 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() + device.autoConnect = autoConnect + device.bleAutoConnect = bleAutoConnect + self.discoveredDevices[index] = device + } else { + let device = DiscoveredDevice( + deviceId: id, + name: name, + ips: validIps, + port: port, + type: "android", + lastSeen: Date(), + autoConnect: autoConnect, + bleAutoConnect: bleAutoConnect ) self.discoveredDevices.append(device) } @@ -347,6 +444,48 @@ class UDPDiscoveryManager: ObservableObject { } } + func sendPeerExchange() { + guard AppState.shared.device == nil || AppState.shared.device?.ipAddress == "BLE" else { return } + 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 private func isValidCandidateIP(_ ip: String) -> Bool { // 1. Allow Tailscale (100.x.x.x) @@ -396,7 +535,7 @@ class UDPDiscoveryManager: ObservableObject { let newCount = self.discoveredDevices.count if newCount < initialCount { - // print("[Discovery] Pruned \(initialCount - newCount) devices. Remaining: \(newCount)") + // print("[Discovery] Pruned \(initialCount - newCount) devices. Remaining: \(newCount)") } if self.discoveredDevices.contains(where: { device in @@ -407,6 +546,33 @@ class UDPDiscoveryManager: ObservableObject { self.objectWillChange.send() } } + + self.tryAutoConnectToKnownDevice() + } + } + + private func tryAutoConnectToKnownDevice() { + guard AppState.shared.isBLEAutoConnectEnabled else { return } + let isRegularConnectionActive = AppState.shared.device != nil && AppState.shared.device?.ipAddress != "BLE" + guard !isRegularConnectionActive, + QuickConnectManager.shared.connectingDeviceID == 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 }) { + guard match.autoConnect else { + print("[Discovery] Skipping auto-connect to \(validLastDevice.name) because auto-connect is disabled on the device") + return + } + 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..e4bf7990 100644 --- a/airsync-mac/Core/QuickConnect/QuickConnectManager.swift +++ b/airsync-mac/Core/QuickConnect/QuickConnectManager.swift @@ -31,10 +31,35 @@ class QuickConnectManager: ObservableObject { // MARK: - Public Interface - /// Gets the last connected device for the current network + /// Gets the last connected device for the current network, with a gateway fallback for hotspots func getLastConnectedDevice() -> Device? { guard let currentIP = getCurrentMacIP() else { return nil } - return lastConnectedDevices[currentIP] + + // 1. Exact match for this Mac IP (if we connected on this network/hotspot before) + if let exactMatch = lastConnectedDevices[currentIP] { + return exactMatch + } + + // 2. Hotspot Fallback: if no exact match, find the most recently connected device from ANY network + // and target the default gateway (.1) of the current network. + let allDevices = lastConnectedDevices.values + guard let fallbackDevice = allDevices.first else { return nil } + + // Construct gateway IP by replacing the last octet with .1 + let parts = currentIP.split(separator: ".") + if parts.count == 4 { + let gatewayIP = parts[0...2].joined(separator: ".") + ".1" + print("[quick-connect] No exact device history for IP \(currentIP). Hotspot fallback: targeting gateway \(gatewayIP) for \(fallbackDevice.name)") + return Device( + name: fallbackDevice.name, + ipAddress: gatewayIP, + port: fallbackDevice.port, + version: fallbackDevice.version, + adbPorts: fallbackDevice.adbPorts + ) + } + + return nil } /// Saves a device as the last connected for the current network @@ -64,8 +89,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( @@ -81,27 +106,48 @@ class QuickConnectManager: ObservableObject { print("[quick-connect] Initiating connection to discovered device: \(device.name) at \(device.ipAddress)") // Show progress in UI - DispatchQueue.main.async { + if Thread.isMainThread { self.connectingDeviceID = discoveredDevice.id + AppState.shared.isManuallyDisconnected = false + } else { + DispatchQueue.main.async { + self.connectingDeviceID = discoveredDevice.id + AppState.shared.isManuallyDisconnected = false + } } Task { - await sendWakeUpRequest(to: device) + await sendWakeUpRequest(to: device, isManual: true) } } /// Attempts to wake up and reconnect to the last connected device func wakeUpLastConnectedDevice() { + guard AppState.shared.isBLEAutoConnectEnabled else { + print("[quick-connect] Skipping wake-up request because isBLEAutoConnectEnabled is false") + return + } + guard !AppState.shared.isManuallyDisconnected else { + print("[quick-connect] Skipping wake-up request because isManuallyDisconnected is true") + return + } guard let lastDevice = getLastConnectedDevice() else { print("[quick-connect] No last connected device to wake up") return } + if let match = UDPDiscoveryManager.shared.discoveredDevices.first(where: { $0.name == lastDevice.name }) { + guard match.autoConnect else { + print("[quick-connect] Skipping wake-up request because autoConnect is disabled on \(lastDevice.name)") + return + } + } + print("[quick-connect] Attempting to wake up device: \(lastDevice.name) at \(lastDevice.ipAddress)") print("[quick-connect] Will try HTTP port \(Self.ANDROID_HTTP_WAKEUP_PORT), then UDP port \(Self.ANDROID_UDP_WAKEUP_PORT) if needed") Task { - await sendWakeUpRequest(to: lastDevice) + await sendWakeUpRequest(to: lastDevice, isManual: false) } } @@ -141,7 +187,7 @@ class QuickConnectManager: ObservableObject { // MARK: - Wake-up Implementation - private func sendWakeUpRequest(to device: Device) async { + private func sendWakeUpRequest(to device: Device, isManual: Bool) async { // Get current connection info to send in wake-up request var currentIP = getBestLocalIP(for: device.ipAddress) var currentPort = getCurrentMacPort() @@ -178,12 +224,16 @@ class QuickConnectManager: ObservableObject { "macIP": "\(finalIP)", "macPort": \(finalPort), "macName": "\(macName)", - "isPlus": \(AppState.shared.isPlus) + "isPlus": \(AppState.shared.isPlus), + "isManual": \(isManual) } } """ // 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 +242,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 +340,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 +362,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/Util/MacInfo/MacInfoSyncManager.swift b/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift index 6b27120a..ea551cf7 100644 --- a/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift +++ b/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift @@ -235,8 +235,14 @@ class MacInfoSyncManager: ObservableObject { isMuted: MacRemoteManager.shared.lastVolumeLevel == 0, albumArt: currentHash ?? "", // Use hash for snapshot comparison likeStatus: "none", // must match payload default - elapsedTime: Int(info.elapsedTime ?? 0), - duration: Int(info.duration ?? 0) + elapsedTime: { + let v = info.elapsedTime ?? 0 + return v.isFinite ? Int(max(0, min(Double(Int.max), v))) : 0 + }(), + duration: { + let v = info.duration ?? 0 + return v.isFinite ? Int(max(0, min(Double(Int.max), v))) : 0 + }() ) }() diff --git a/airsync-mac/Core/Util/Remote/MacRemoteManager.swift b/airsync-mac/Core/Util/Remote/MacRemoteManager.swift index 4cbc1f44..fbf9009f 100644 --- a/airsync-mac/Core/Util/Remote/MacRemoteManager.swift +++ b/airsync-mac/Core/Util/Remote/MacRemoteManager.swift @@ -120,17 +120,26 @@ class MacRemoteManager: ObservableObject { } func simulateMouseScroll(dx: CGFloat, dy: Double) { + // Guard against non-finite values before casting to Int32 + guard dy.isFinite, dx.isFinite else { return } + let clampedDy = Int32(max(Double(Int32.min), min(Double(Int32.max), dy))) + let clampedDx = Int32(max(Double(Int32.min), min(Double(Int32.max), Double(dx)))) // wheel1 is vertical, wheel2 is horizontal - let event = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2, wheel1: Int32(dy), wheel2: Int32(dx), wheel3: 0) + let event = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2, wheel1: clampedDy, wheel2: clampedDx, wheel3: 0) event?.post(tap: .cghidEventTap) } func simulateKeyCode(_ code: Int, modifiers: [String] = []) { + guard code >= 0 && code <= UInt16.max else { + print("[MacRemoteManager] Error: Keycode \(code) is out of bounds for CGKeyCode") + return + } + let keyCode = CGKeyCode(code) let flags = parseModifiers(modifiers) let src: CGEventSource? = nil // Better compatibility for system shortcuts - let keyDown = CGEvent(keyboardEventSource: src, virtualKey: CGKeyCode(code), keyDown: true) - let keyUp = CGEvent(keyboardEventSource: src, virtualKey: CGKeyCode(code), keyDown: false) + let keyDown = CGEvent(keyboardEventSource: src, virtualKey: keyCode, keyDown: true) + let keyUp = CGEvent(keyboardEventSource: src, virtualKey: keyCode, keyDown: false) keyDown?.flags = flags keyUp?.flags = flags @@ -219,7 +228,16 @@ class MacRemoteManager: ObservableObject { func getVolume() -> Int { let vol = getSystemVolume() - return Int(vol * 100) + guard vol.isFinite, !vol.isNaN else { + print("[MacRemoteManager] Warning: System volume returned non-finite value: \(vol)") + return 0 + } + let scaledVol = vol * 100 + guard scaledVol >= Float(Int.min) && scaledVol <= Float(Int.max) else { + print("[MacRemoteManager] Warning: Scaled volume \(scaledVol) is out of bounds for Int") + return 0 + } + return Int(max(0.0, min(100.0, scaledVol))) } func increaseVolume() { diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index 87dbb334..4ed95e09 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: @@ -101,24 +103,76 @@ extension WebSocketServer { let ip = dict["ipAddress"] as? String, let port = dict["port"] as? Int { - if let targetIp = dict["targetIpAddress"] as? String { - AppState.shared.activeMacIp = targetIp.trimmingCharacters(in: .whitespaces) - } - + let targetIpVal = (dict["targetIpAddress"] as? String)?.trimmingCharacters(in: .whitespaces) let version = dict["version"] as? String ?? "2.0.0" let adbPorts = dict["adbPorts"] as? [String] ?? [] - - AppState.shared.device = Device( + let deviceVal = Device( name: name, ipAddress: ip, port: port, version: version, adbPorts: adbPorts ) + let wallpaperBase64 = dict["wallpaper"] as? String + + DispatchQueue.main.async { + if let targetIp = targetIpVal { + AppState.shared.activeMacIp = targetIp + } + + AppState.shared.isManuallyDisconnected = false + AppState.shared.device = deviceVal + + if let base64 = wallpaperBase64 { + AppState.shared.currentDeviceWallpaperBase64 = base64 + } + + let state = AppState.shared + if (!state.adbConnected && (state.adbEnabled || state.manualAdbConnectionPending || state.wiredAdbEnabled) && state.isPlus) { + if state.wiredAdbEnabled { + ADBConnector.getWiredDeviceSerial(completion: { serial in + if let serial = serial { + DispatchQueue.main.async { + state.adbConnected = true + state.adbConnectionMode = .wired + state.adbConnectionResult = "Connected via Wired ADB (Serial: \(serial))" + state.manualAdbConnectionPending = false + } + } else if state.adbEnabled || state.manualAdbConnectionPending { + // Try wireless connection if wired failed or no device found + ADBConnector.connectToADB(ip: ip) + DispatchQueue.main.async { + state.manualAdbConnectionPending = false + } + } + }) + } else if state.adbEnabled || state.manualAdbConnectionPending { + // Try wireless connection directly + ADBConnector.connectToADB(ip: ip) + state.manualAdbConnectionPending = false + } + } + + if let bleToken = dict["bleAuthToken"] as? String { + let oldToken = UserDefaults.standard.string(forKey: "bleAuthToken") + UserDefaults.standard.set(bleToken, forKey: "bleAuthToken") + UserDefaults.standard.set(name, forKey: "bleDeviceName") + print("[websocket] Received BLE auth token and device name: \(name)") + + // If token changed or was new, and BLE is enabled, we might want to reconnect + if oldToken != bleToken && state.isBLEEnabled { + BLECentralManager.shared.startScanning() + } + } - if let base64 = dict["wallpaper"] as? String { - AppState.shared.currentDeviceWallpaperBase64 = base64 + if UserDefaults.standard.hasPairedDeviceOnce == false { + UserDefaults.standard.hasPairedDeviceOnce = true + } + self.sendMacInfoResponse() + } + + if let base64 = wallpaperBase64 { // Save wallpaper to disk for DeviceCard if let id = dict["id"] as? String, let data = Data(base64Encoded: base64, options: .ignoreUnknownCharacters) { @@ -140,48 +194,6 @@ extension WebSocketServer { } } } - - if (!AppState.shared.adbConnected && (AppState.shared.adbEnabled || AppState.shared.manualAdbConnectionPending || AppState.shared.wiredAdbEnabled) && AppState.shared.isPlus) { - if AppState.shared.wiredAdbEnabled { - ADBConnector.getWiredDeviceSerial(completion: { serial in - if let serial = serial { - DispatchQueue.main.async { - AppState.shared.adbConnected = true - AppState.shared.adbConnectionMode = .wired - AppState.shared.adbConnectionResult = "Connected via Wired ADB (Serial: \(serial))" - AppState.shared.manualAdbConnectionPending = false - } - } else if AppState.shared.adbEnabled || AppState.shared.manualAdbConnectionPending { - // Try wireless connection if wired failed or no device found - ADBConnector.connectToADB(ip: ip) - DispatchQueue.main.async { - AppState.shared.manualAdbConnectionPending = false - } - } - }) - } else if AppState.shared.adbEnabled || AppState.shared.manualAdbConnectionPending { - // Try wireless connection directly - ADBConnector.connectToADB(ip: ip) - AppState.shared.manualAdbConnectionPending = false - } - } - - if let bleToken = dict["bleAuthToken"] as? String { - let oldToken = UserDefaults.standard.string(forKey: "bleAuthToken") - UserDefaults.standard.set(bleToken, forKey: "bleAuthToken") - print("[websocket] Received BLE auth token") - - // If token changed or was new, and BLE is enabled, we might want to reconnect - if oldToken != bleToken && AppState.shared.isBLEEnabled { - BLECentralManager.shared.startScanning() - } - } - - if UserDefaults.standard.hasPairedDeviceOnce == false { - UserDefaults.standard.hasPairedDeviceOnce = true - } - - sendMacInfoResponse() } } @@ -292,9 +304,7 @@ extension WebSocketServer { positionSec = durationSec } - let oldTitle = AppState.shared.status?.music?.title - - AppState.shared.status = DeviceStatus( + let newStatus = DeviceStatus( battery: .init(level: level, isCharging: isCharging), isPaired: paired, music: .init( @@ -312,6 +322,9 @@ extension WebSocketServer { ) DispatchQueue.main.async { + let oldTitle = AppState.shared.status?.music?.title + AppState.shared.status = newStatus + if oldTitle != title { AppState.shared.handleTrackChange() } else { @@ -349,70 +362,74 @@ extension WebSocketServer { private func handleAppIcons(_ message: Message) { if let dict = message.data.value as? [String: [String: Any]] { - DispatchQueue.global(qos: .background).async { - let incomingPackages = Set(dict.keys) + DispatchQueue.main.async { let existingPackages = Set(AppState.shared.androidApps.keys) - for (package, details) in dict { - guard let name = details["name"] as? String, - let iconBase64 = details["icon"] as? String, - let systemApp = details["systemApp"] as? Bool, - let listening = details["listening"] as? Bool else { continue } + DispatchQueue.global(qos: .background).async { + let incomingPackages = Set(dict.keys) + var iconPaths: [String: String] = [:] - var cleaned = iconBase64 - if let range = cleaned.range(of: "base64,") { cleaned = String(cleaned[range.upperBound...]) } + for (package, details) in dict { + guard let iconBase64 = details["icon"] as? String else { continue } - var iconPath: String? = nil - if let data = Data(base64Encoded: cleaned), !cleaned.isEmpty { - let fileURL = appIconsDirectory().appendingPathComponent("\(package).png") - do { - try data.write(to: fileURL, options: .atomic) - iconPath = fileURL.path - } catch { - print("[websocket] Failed to write icon for \(package): \(error)") - } - } + var cleaned = iconBase64 + if let range = cleaned.range(of: "base64,") { cleaned = String(cleaned[range.upperBound...]) } - DispatchQueue.main.async { - if var existingApp = AppState.shared.androidApps[package] { - existingApp.listening = listening - if let newIconPath = iconPath { - existingApp.iconUrl = newIconPath + if let data = Data(base64Encoded: cleaned), !cleaned.isEmpty { + let fileURL = appIconsDirectory().appendingPathComponent("\(package).png") + do { + try data.write(to: fileURL, options: .atomic) + iconPaths[package] = fileURL.path + } catch { + print("[websocket] Failed to write icon for \(package): \(error)") } - AppState.shared.androidApps[package] = existingApp - } else { - let app = AndroidApp( - packageName: package, - name: name, - iconUrl: iconPath, - listening: listening, - systemApp: systemApp - ) - AppState.shared.androidApps[package] = app } } - } - let toRemove = existingPackages.subtracting(incomingPackages) - if !toRemove.isEmpty { DispatchQueue.main.async { - var pathsToRemove: [String] = [] - for pkg in toRemove { - if let iconPath = AppState.shared.androidApps[pkg]?.iconUrl { - pathsToRemove.append(iconPath) + for (package, details) in dict { + guard let name = details["name"] as? String, + let systemApp = details["systemApp"] as? Bool, + let listening = details["listening"] as? Bool else { continue } + + let iconPath = iconPaths[package] + + if var existingApp = AppState.shared.androidApps[package] { + existingApp.listening = listening + if let newIconPath = iconPath { + existingApp.iconUrl = newIconPath + } + AppState.shared.androidApps[package] = existingApp + } else { + let app = AndroidApp( + packageName: package, + name: name, + iconUrl: iconPath, + listening: listening, + systemApp: systemApp + ) + AppState.shared.androidApps[package] = app } - AppState.shared.androidApps.removeValue(forKey: pkg) } - DispatchQueue.global(qos: .background).async { - for path in pathsToRemove { - try? FileManager.default.removeItem(atPath: path) + + let toRemove = existingPackages.subtracting(incomingPackages) + if !toRemove.isEmpty { + var pathsToRemove: [String] = [] + for pkg in toRemove { + if let iconPath = AppState.shared.androidApps[pkg]?.iconUrl { + pathsToRemove.append(iconPath) + } + AppState.shared.androidApps.removeValue(forKey: pkg) + } + DispatchQueue.global(qos: .background).async { + for path in pathsToRemove { + try? FileManager.default.removeItem(atPath: path) + } } } - } - } - DispatchQueue.main.async { - AppState.shared.saveAppsToDisk() + AppState.shared.saveAppsToDisk() + } } } } @@ -662,6 +679,11 @@ extension WebSocketServer { MacRemoteManager.shared.increaseBrightness() case "brightness_down": MacRemoteManager.shared.decreaseBrightness() + case "manual_disconnect": + print("[WebSocketServer] Received manual disconnect from Android client! Instantly setting manually disconnected...") + DispatchQueue.main.async { + AppState.shared.disconnectDevice() + } default: break } } @@ -800,45 +822,49 @@ extension WebSocketServer { } private func sendMacInfoResponse() { - let macName = AppState.shared.myDevice?.name ?? (Host.current().localizedName ?? "My Mac") - let categoryTypeRaw = DeviceTypeUtil.deviceTypeDescription() - let exactDeviceNameRaw = DeviceTypeUtil.deviceFullDescription() - let categoryType = categoryTypeRaw.isEmpty ? "Mac" : categoryTypeRaw - let exactDeviceName = exactDeviceNameRaw.isEmpty ? categoryType : exactDeviceNameRaw - let isPlusSubscription = AppState.shared.isPlus - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.0.0" - let savedAppPackages = Array(AppState.shared.androidApps.keys) - - let modelId = DeviceTypeUtil.modelIdentifier() - let macInfo = MacInfo( - name: macName, - modelIdentifier: modelId, - categoryType: categoryType, - exactDeviceName: exactDeviceName, - isPlusSubscription: isPlusSubscription, - version: appVersion, - savedAppPackages: savedAppPackages - ) - - do { - let jsonData = try JSONEncoder().encode(macInfo) - if var jsonDict = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] { - jsonDict["model"] = modelId - jsonDict["type"] = categoryType - jsonDict["isPlus"] = isPlusSubscription - - let messageDict: [String: Any] = [ - "type": "macInfo", - "data": jsonDict - ] - - let messageJsonData = try JSONSerialization.data(withJSONObject: messageDict, options: []) - if let messageJsonString = String(data: messageJsonData, encoding: .utf8) { - sendToFirstAvailable(message: messageJsonString) + DispatchQueue.main.async { + let macName = AppState.shared.myDevice?.name ?? (Host.current().localizedName ?? "My Mac") + let categoryTypeRaw = DeviceTypeUtil.deviceTypeDescription() + let exactDeviceNameRaw = DeviceTypeUtil.deviceFullDescription() + let categoryType = categoryTypeRaw.isEmpty ? "Mac" : categoryTypeRaw + let exactDeviceName = exactDeviceNameRaw.isEmpty ? categoryType : exactDeviceNameRaw + let isPlusSubscription = AppState.shared.isPlus + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.0.0" + let savedAppPackages = Array(AppState.shared.androidApps.keys) + + DispatchQueue.global(qos: .background).async { + let modelId = DeviceTypeUtil.modelIdentifier() + let macInfo = MacInfo( + name: macName, + modelIdentifier: modelId, + categoryType: categoryType, + exactDeviceName: exactDeviceName, + isPlusSubscription: isPlusSubscription, + version: appVersion, + savedAppPackages: savedAppPackages + ) + + do { + let jsonData = try JSONEncoder().encode(macInfo) + if var jsonDict = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] { + jsonDict["model"] = modelId + jsonDict["type"] = categoryType + jsonDict["isPlus"] = isPlusSubscription + + let messageDict: [String: Any] = [ + "type": "macInfo", + "data": jsonDict + ] + + let messageJsonData = try JSONSerialization.data(withJSONObject: messageDict, options: []) + if let messageJsonString = String(data: messageJsonData, encoding: .utf8) { + self.sendToFirstAvailable(message: messageJsonString) + } + } + } catch { + print("[websocket] Error creating mac info response: \(error)") } } - } catch { - print("[websocket] Error creating mac info response: \(error)") } } } diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Networking.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Networking.swift index a3281768..9148338f 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Networking.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Networking.swift @@ -5,6 +5,7 @@ import Foundation import UniformTypeIdentifiers +import Network #if canImport(MobileCoreServices) import MobileCoreServices #endif @@ -128,6 +129,53 @@ extension WebSocketServer { } } + func startFastNetworkMonitoring() { + self.lock.lock() + networkPathMonitor?.cancel() + + let monitor = NWPathMonitor() + networkPathMonitor = monitor + self.lock.unlock() + + monitor.pathUpdateHandler = { [weak self] path in + guard let self = self else { return } + if path.status == .unsatisfied { + self.lock.lock() + self.networkLossGraceTimer?.cancel() + + let work = DispatchWorkItem { [weak self] in + guard let self = self else { return } + DispatchQueue.main.async { + guard let dev = AppState.shared.device, dev.ipAddress != "BLE", !AppState.shared.isManuallyDisconnected else { return } + print("[websocket] Network path lost — triggering fast disconnect") + AppState.shared.handleAutomaticDisconnect() + self.restartServer() + } + } + self.networkLossGraceTimer = work + self.lock.unlock() + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: work) + } else { + self.lock.lock() + self.networkLossGraceTimer?.cancel() + self.networkLossGraceTimer = nil + self.lock.unlock() + } + } + + monitor.start(queue: DispatchQueue(label: "com.airsync.netpath.fast")) + } + + func stopFastNetworkMonitoring() { + self.lock.lock() + networkLossGraceTimer?.cancel() + networkLossGraceTimer = nil + networkPathMonitor?.cancel() + networkPathMonitor = nil + self.lock.unlock() + } + /// Monitors network adapter state changes. /// Triggers a WebSocket server restart if the active IP address changes to maintain connectivity. func checkNetworkChange() { @@ -138,6 +186,7 @@ extension WebSocketServer { let lastAddresses = lastKnownAdapters.map { $0.address } let currentAddresses = adapters.map { $0.address } let lastIP = lastKnownIP + let hasActiveServers = !servers.isEmpty self.lock.unlock() if currentAddresses != lastAddresses { @@ -153,18 +202,47 @@ 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 + + if let dev = AppState.shared.device, dev.ipAddress != "BLE" { + 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) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + print("[websocket] Network restarted, triggering auto wake-up of last connected device...") + QuickConnectManager.shared.wakeUpLastConnectedDevice() + } + } + } else if chosenIP != nil && (lastIP == nil || !hasActiveServers) { + print("[websocket] (network) Network restored or active IP became available (\(chosenIP!)), starting server immediately") + + DispatchQueue.main.async { + self.lock.lock() + self.lastKnownIP = chosenIP + self.lock.unlock() + AppState.shared.shouldRefreshQR = true + } + + self.start(port: Defaults.serverPort) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + print("[websocket] Network active, triggering auto wake-up of last connected device...") + QuickConnectManager.shared.wakeUpLastConnectedDevice() } } else if lastIP == nil { DispatchQueue.main.async { @@ -197,4 +275,4 @@ extension WebSocketServer { } return nil } -} +} \ No newline at end of file diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift index d516efd7..dad510af 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift @@ -200,7 +200,8 @@ extension WebSocketServer { /// Seek Android playback to a specific position (in seconds). func seekTo(positionSeconds: Double) { - let positionMs = Int(positionSeconds * 1000) + guard positionSeconds.isFinite, positionSeconds >= 0 else { return } + let positionMs = Int(min(positionSeconds * 1000, Double(Int.max))) sendMessage(type: "mediaControl", data: ["action": "seekTo", "positionMs": positionMs]) } diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift index 7011528d..59653492 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift @@ -63,7 +63,7 @@ extension WebSocketServer { // Primary session has gone silent — full reconnect cycle print("[websocket] Primary session \(sessionId) is stale (>\(Int(timeout))s). Restarting server.") DispatchQueue.main.async { - AppState.shared.disconnectDevice() + AppState.shared.disconnectDevice(isManual: false) ADBConnector.disconnectADB() AppState.shared.adbConnected = false self.restartServer() diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index eff27e75..745f0762 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -10,6 +10,7 @@ import Swifter import CryptoKit import UserNotifications import Combine +import Network class WebSocketServer: ObservableObject { static let shared = WebSocketServer() @@ -18,9 +19,12 @@ class WebSocketServer: ObservableObject { internal var activeSessions: [WebSocketSession] = [] internal var primarySessionID: ObjectIdentifier? internal var pingTimer: Timer? - internal let pingInterval: TimeInterval = 12.5 + internal let pingInterval: TimeInterval = 5.0 internal var lastActivity: [ObjectIdentifier: Date] = [:] - internal let activityTimeout: TimeInterval = 45.0 + internal let activityTimeout: TimeInterval = 25.0 + internal var reconnectGraceTimer: Timer? + internal var networkPathMonitor: NWPathMonitor? + internal var networkLossGraceTimer: DispatchWorkItem? @Published var symmetricKey: SymmetricKey? @Published var localPort: UInt16? @@ -81,8 +85,8 @@ class WebSocketServer: ObservableObject { return } - self.lock.lock() self.stopAllServers() + self.lock.lock() if let specificAdapter = adapterName { self.isListeningOnAll = false @@ -125,11 +129,18 @@ class WebSocketServer: ObservableObject { self.lastKnownIP = ipList } print("[websocket] WebSocket server started on all available adapters at port \(port)") + } else { + DispatchQueue.main.async { + AppState.shared.webSocketStatus = .failed(error: "No active network adapters") + self.lastKnownIP = nil + } + print("[websocket] Failed to start WebSocket server: no active adapters") } } self.lock.unlock() self.startNetworkMonitoring() + self.startFastNetworkMonitoring() } catch { self.lock.unlock() DispatchQueue.main.async { AppState.shared.webSocketStatus = .failed(error: "\(error)") } @@ -138,21 +149,26 @@ 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() lock.unlock() DispatchQueue.main.async { AppState.shared.webSocketStatus = .stopped } stopNetworkMonitoring() + stopFastNetworkMonitoring() } /// Configures WebSocket routes and event callbacks. @@ -207,6 +223,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 +256,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 +337,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() @@ -312,4 +354,4 @@ class WebSocketServer: ObservableObject { } } } -} +} \ No newline at end of file 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 diff --git a/airsync-mac/Screens/HomeScreen/AppContentView.swift b/airsync-mac/Screens/HomeScreen/AppContentView.swift index ea73420b..bab47933 100644 --- a/airsync-mac/Screens/HomeScreen/AppContentView.swift +++ b/airsync-mac/Screens/HomeScreen/AppContentView.swift @@ -122,10 +122,12 @@ struct AppContentView: View { .frame(minWidth: 550, minHeight: 510) .onAppear { // Ensure the correct tab is selected when the view appears - if appState.device == nil { - AppState.shared.selectedTab = .qr - } else { - AppState.shared.selectedTab = .notifications + DispatchQueue.main.async { + if appState.device == nil { + AppState.shared.selectedTab = .qr + } else { + AppState.shared.selectedTab = .notifications + } } } .sheet(isPresented: $showAboutSheet) { diff --git a/airsync-mac/Screens/HomeScreen/HomeView.swift b/airsync-mac/Screens/HomeScreen/HomeView.swift index b550cf5f..11bf2c85 100644 --- a/airsync-mac/Screens/HomeScreen/HomeView.swift +++ b/airsync-mac/Screens/HomeScreen/HomeView.swift @@ -50,7 +50,9 @@ struct HomeView: View { .onAppear { if needsOnboarding { showOnboarding = true - appState.isOnboardingActive = true + DispatchQueue.main.async { + appState.isOnboardingActive = true + } } updateSidebarVisibility() } @@ -63,7 +65,9 @@ struct HomeView: View { } .onChange(of: showOnboarding) { oldValue, newValue in if !newValue { - appState.isOnboardingActive = false + DispatchQueue.main.async { + appState.isOnboardingActive = false + } } } .onChange(of: appState.isOnboardingActive) { oldValue, newValue in diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift index 56048a0e..bfbb732b 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift @@ -148,8 +148,10 @@ struct DeviceStatusView: View { // If no valid media info and card is currently expanded, collapse it if !hasValidMedia && !appState.isMusicCardHidden { - withAnimation(.easeInOut(duration: 0.28)) { - appState.isMusicCardHidden = true + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.28)) { + appState.isMusicCardHidden = true + } } } } diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/MediaPlayerView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/MediaPlayerView.swift index e45e2d94..358bdd98 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/MediaPlayerView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/MediaPlayerView.swift @@ -42,7 +42,7 @@ private struct MediaSeekbarView: View { } private func formatTime(_ seconds: Double) -> String { - guard seconds >= 0 else { return "--:--" } + guard seconds.isFinite, seconds >= 0 else { return "--:--" } let s = Int(seconds) let m = s / 60 let h = m / 60 diff --git a/airsync-mac/Screens/MenubarView/MenubarDeviceDiscoveryView.swift b/airsync-mac/Screens/MenubarView/MenubarDeviceDiscoveryView.swift index 8552e5ca..2c97e151 100644 --- a/airsync-mac/Screens/MenubarView/MenubarDeviceDiscoveryView.swift +++ b/airsync-mac/Screens/MenubarView/MenubarDeviceDiscoveryView.swift @@ -37,7 +37,9 @@ struct MenubarDeviceDiscoveryView: View { ips: bleDevice.ips, port: bleDevice.port, type: bleDevice.type, - lastSeen: bleDevice.lastSeen + lastSeen: bleDevice.lastSeen, + autoConnect: bleDevice.autoConnect, + bleAutoConnect: bleDevice.bleAutoConnect ) mergedDevices.append(cleanedBLEDevice) } diff --git a/airsync-mac/Screens/OnboardingView/OnboardingView.swift b/airsync-mac/Screens/OnboardingView/OnboardingView.swift index 69c9e170..c5367a77 100644 --- a/airsync-mac/Screens/OnboardingView/OnboardingView.swift +++ b/airsync-mac/Screens/OnboardingView/OnboardingView.swift @@ -71,10 +71,12 @@ struct OnboardingView: View { case .done: Color.clear.onAppear { - hasPairedDeviceOnce = true - UserDefaults.standard.markOnboardingCompleted() - AppState.shared.isOnboardingActive = false - dismiss() + DispatchQueue.main.async { + hasPairedDeviceOnce = true + UserDefaults.standard.markOnboardingCompleted() + AppState.shared.isOnboardingActive = false + dismiss() + } } } } diff --git a/airsync-mac/Screens/ScannerView/QRScannerSidebarView.swift b/airsync-mac/Screens/ScannerView/QRScannerSidebarView.swift index 569c5da4..f94c3775 100644 --- a/airsync-mac/Screens/ScannerView/QRScannerSidebarView.swift +++ b/airsync-mac/Screens/ScannerView/QRScannerSidebarView.swift @@ -22,6 +22,9 @@ struct QRScannerSidebarView: View { case .started: return ("Ready", "checkmark.circle", .green) case .failed(let error): + if error == "No active network adapters" { + return ("Offline", "wifi.slash", .gray) + } return ("Failed: \(error)", "exclamationmark.triangle", .red) } } @@ -168,5 +171,11 @@ struct QRScannerSidebarView: View { .onDisappear { qrManager.cleanUpTimer() } + .onChange(of: appState.shouldRefreshQR) { _, shouldRefresh in + if shouldRefresh { + qrManager.generateQRAsync() + appState.shouldRefreshQR = false + } + } } -} +} \ No newline at end of file diff --git a/airsync-mac/Screens/ScannerView/ScannerView.swift b/airsync-mac/Screens/ScannerView/ScannerView.swift index 79d0eacb..e81663c3 100644 --- a/airsync-mac/Screens/ScannerView/ScannerView.swift +++ b/airsync-mac/Screens/ScannerView/ScannerView.swift @@ -54,7 +54,9 @@ struct ScannerView: View { ips: bleDevice.ips, port: bleDevice.port, type: bleDevice.type, - lastSeen: bleDevice.lastSeen + lastSeen: bleDevice.lastSeen, + autoConnect: bleDevice.autoConnect, + bleAutoConnect: bleDevice.bleAutoConnect ) mergedDevices.append(cleanedBLEDevice) } diff --git a/airsync-mac/airsync_macApp.swift b/airsync-mac/airsync_macApp.swift index 000adef6..1c2c7871 100644 --- a/airsync-mac/airsync_macApp.swift +++ b/airsync-mac/airsync_macApp.swift @@ -32,7 +32,6 @@ struct airsync_macApp: App { let center = UNUserNotificationCenter.current() center.delegate = notificationDelegate updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) - updaterController.updater.checkForUpdatesInBackground() // Register base default category with generic View action; dynamic per-notification categories added later let viewAction = UNNotificationAction(identifier: "VIEW_ACTION", title: "View", options: [])