From 55e243ce5962f02f406138d96161026cddc58abb Mon Sep 17 00:00:00 2001 From: tornado-bunk Date: Sat, 14 Mar 2026 18:22:52 +0100 Subject: [PATCH 01/17] feat: Integrate AirBridge relay with LAN HMAC authentication - AirBridge relay client for Mac-Android communication over internet - HMAC Challenge-Response authentication for LAN security - Anti-replay protection with bounded nonce cache - Keychain integration for encrypted keys and relay credentials - Graceful LAN-to-relay transport fallback - ADB restricted to direct LAN connections only - New onboarding step and settings UI for relay configuration - QR code generation includes optional relay parameters - Enhanced KeychainStorage API with binary data support - Port binding retry logic with exponential backoff - Disabled PII reporting for privacy compliance New Files: - AirBridgeClient.swift and AirBridgeModels.swift - AirBridgeSetupView.swift and AirBridgeSettingsView.swift Modified: 14 core files (WebSocketServer, AppState, Keychain, Crypto, etc.) --- .../Buttons/SaveAndRestartButton.swift | 7 +- .../Core/AirBridge/AirBridgeClient.swift | 577 ++++++++++++++++++ .../Core/AirBridge/AirBridgeModels.swift | 86 +++ airsync-mac/Core/AppState.swift | 25 + airsync-mac/Core/AppleScriptSupport.swift | 5 + airsync-mac/Core/SentryInitializer.swift | 2 +- airsync-mac/Core/Trial/TrialManager.swift | 25 +- airsync-mac/Core/Util/Crypto/CryptoUtil.swift | 53 ++ .../Util/SecureStorage/KeychainStorage.swift | 138 ++++- .../WebSocket/WebSocketServer+Handlers.swift | 86 ++- .../WebSocketServer+Networking.swift | 7 +- .../WebSocket/WebSocketServer+Outgoing.swift | 40 +- .../Core/WebSocket/WebSocketServer+Ping.swift | 29 +- .../Core/WebSocket/WebSocketServer.swift | 412 ++++++++++++- airsync-mac/Model/Message.swift | 4 + .../Screens/HomeScreen/AppContentView.swift | 6 +- .../PhoneView/ConnectionStatusPill.swift | 5 + .../OnboardingView/AirBridgeSetupView.swift | 188 ++++++ .../OnboardingView/OnboardingView.swift | 9 +- .../Screens/ScannerView/ScannerView.swift | 37 +- .../Settings/AirBridgeSettingsView.swift | 200 ++++++ .../Settings/SettingsFeaturesView.swift | 7 +- .../Screens/Settings/SettingsView.swift | 21 +- airsync-mac/airsync_macApp.swift | 3 + 24 files changed, 1902 insertions(+), 70 deletions(-) create mode 100644 airsync-mac/Core/AirBridge/AirBridgeClient.swift create mode 100644 airsync-mac/Core/AirBridge/AirBridgeModels.swift create mode 100644 airsync-mac/Screens/OnboardingView/AirBridgeSetupView.swift create mode 100644 airsync-mac/Screens/Settings/AirBridgeSettingsView.swift diff --git a/airsync-mac/Components/Buttons/SaveAndRestartButton.swift b/airsync-mac/Components/Buttons/SaveAndRestartButton.swift index 99cc39d5..71cc62ff 100644 --- a/airsync-mac/Components/Buttons/SaveAndRestartButton.swift +++ b/airsync-mac/Components/Buttons/SaveAndRestartButton.swift @@ -43,8 +43,11 @@ struct SaveAndRestartButton: View { // Custom hooks onSave?(device) - WebSocketServer.shared.stop() - WebSocketServer.shared.start(port: portNumber) + WebSocketServer.shared.requestRestart( + reason: "Manual save and restart", + delay: 0.2, + port: portNumber + ) onRestart?(portNumber) // Delay QR refresh to ensure server has restarted diff --git a/airsync-mac/Core/AirBridge/AirBridgeClient.swift b/airsync-mac/Core/AirBridge/AirBridgeClient.swift new file mode 100644 index 00000000..b50fd8f5 --- /dev/null +++ b/airsync-mac/Core/AirBridge/AirBridgeClient.swift @@ -0,0 +1,577 @@ +// +// AirBridgeClient.swift +// airsync-mac +// +// Created by AI Assistant. +// WebSocket client that connects to a self-hosted AirBridge relay server. +// When a direct LAN connection is unavailable, messages are tunneled through +// the relay to reach the Android device. +// + +import Foundation +internal import Combine +import CryptoKit + +class AirBridgeClient: ObservableObject { + static let shared = AirBridgeClient() + + // MARK: - Published State + + @Published var connectionState: AirBridgeConnectionState = .disconnected + + // MARK: - Configuration + // + // With ad-hoc ("Sign to Run Locally") code signing, every Keychain call + // triggers a macOS password prompt. Only the secret (the actual credential) + // is stored in Keychain. The relay URL and pairing ID are non-sensitive + // config and live in UserDefaults (zero prompts). + // + // The secret is cached in memory after the first Keychain read so that + // subsequent accesses never hit the Keychain again. + + private static let keychainKeySecret = "airBridgeSecret" + + // Legacy keys — used only for one-time migration, then deleted. + private static let legacyKeyRelayURL = "airBridgeRelayURL" + private static let legacyKeyPairingId = "airBridgePairingId" + private static let legacyKeySecret = "airBridgeSecret" // same key, kept for clarity + private static let legacyKeyConfig = "airBridgeConfig" // consolidated blob (if any) + + // In-memory cache for the secret (the only Keychain item). + private var _cachedSecret: String? + private var _secretLoaded = false + + /// Loads the secret from Keychain **once**, including one-time migration + /// from the old 3-key or consolidated-blob layout. + private func loadSecretIfNeeded() { + guard !_secretLoaded else { return } + _secretLoaded = true + + // Current key + if let s = KeychainStorage.string(for: Self.keychainKeySecret) { + _cachedSecret = s + + // Migrate URL/pairingId from legacy Keychain keys to UserDefaults (if present) + migrateLegacyKeysIfNeeded() + return + } + + // One-time migration from consolidated JSON blob + if let json = KeychainStorage.string(for: Self.legacyKeyConfig), + let data = json.data(using: .utf8), + let blob = try? JSONDecoder().decode(AirBridgeConfigBlob.self, from: data) { + _cachedSecret = blob.sec + // Move URL and pairingId to UserDefaults + if !blob.url.isEmpty { UserDefaults.standard.set(blob.url, forKey: "airBridgeRelayURL") } + if !blob.pid.isEmpty { UserDefaults.standard.set(blob.pid, forKey: "airBridgePairingId") } + // Write secret under its own key and remove the blob + if !blob.sec.isEmpty { KeychainStorage.set(blob.sec, for: Self.keychainKeySecret) } + KeychainStorage.delete(key: Self.legacyKeyConfig) + return + } + + migrateLegacyKeysIfNeeded() + } + + /// Migrates URL and pairingId from legacy Keychain keys to UserDefaults, + /// then deletes the legacy Keychain entries. + private func migrateLegacyKeysIfNeeded() { + // Only run once — if URL is already in UserDefaults, skip. + guard UserDefaults.standard.string(forKey: "airBridgeRelayURL") == nil else { return } + + if let oldURL = KeychainStorage.string(for: Self.legacyKeyRelayURL), !oldURL.isEmpty { + UserDefaults.standard.set(oldURL, forKey: "airBridgeRelayURL") + KeychainStorage.delete(key: Self.legacyKeyRelayURL) + } + if let oldPid = KeychainStorage.string(for: Self.legacyKeyPairingId), !oldPid.isEmpty { + UserDefaults.standard.set(oldPid, forKey: "airBridgePairingId") + KeychainStorage.delete(key: Self.legacyKeyPairingId) + } + } + + var relayServerURL: String { + get { UserDefaults.standard.string(forKey: "airBridgeRelayURL") ?? "" } + set { UserDefaults.standard.set(newValue, forKey: "airBridgeRelayURL") } + } + + var pairingId: String { + get { UserDefaults.standard.string(forKey: "airBridgePairingId") ?? "" } + set { UserDefaults.standard.set(newValue, forKey: "airBridgePairingId") } + } + + var secret: String { + get { loadSecretIfNeeded(); return _cachedSecret ?? "" } + set { _cachedSecret = newValue; _secretLoaded = true; KeychainStorage.set(newValue, for: Self.keychainKeySecret) } + } + + /// Batch-update all three credentials. Only the secret write touches Keychain + /// (one password prompt). URL and pairingId go to UserDefaults (zero prompts). + func saveAllCredentials(url: String, pairingId: String, secret: String) { + UserDefaults.standard.set(url, forKey: "airBridgeRelayURL") + UserDefaults.standard.set(pairingId, forKey: "airBridgePairingId") + _cachedSecret = secret + _secretLoaded = true + KeychainStorage.set(secret, for: Self.keychainKeySecret) + } + + /// Ensures pairing credentials exist, generating them if empty. + /// Call this only when AirBridge is actually being enabled/configured. + func ensureCredentialsExist() { + if pairingId.isEmpty { + pairingId = Self.generateShortId() + } + if secret.isEmpty { + let newSecret = Self.generateRandomSecret() + _cachedSecret = newSecret + _secretLoaded = true + KeychainStorage.set(newSecret, for: Self.keychainKeySecret) + } + } + + // MARK: - Private State + + private var webSocketTask: URLSessionWebSocketTask? + private var urlSession: URLSession? + private var reconnectAttempt: Int = 0 + private var maxReconnectDelay: TimeInterval = 30.0 + private var isManuallyDisconnected = false + private var receiveLoopActive = false + private let queue = DispatchQueue(label: "com.airsync.airbridge", qos: .userInitiated) + + private init() {} + + // MARK: - Public Interface + + /// Connects to the relay server. Does nothing if already connected or URL is empty. + func connect() { + queue.async { [weak self] in + self?.connectInternal() + } + } + + /// Gracefully disconnects from the relay server. Disables auto-reconnect. + func disconnect() { + queue.async { [weak self] in + guard let self = self else { return } + self.isManuallyDisconnected = true + self.tearDown(reason: "Manual disconnect") + DispatchQueue.main.async { + self.connectionState = .disconnected + } + } + } + + /// Sends an already-encrypted text message to the relay for forwarding to Android. + func sendText(_ text: String) { + guard let task = webSocketTask else { return } + task.send(.string(text)) { error in + if let error = error { + print("[airbridge] Send text error: \(error.localizedDescription)") + } + } + } + + /// Sends raw binary data to the relay for forwarding to Android. + func sendData(_ data: Data) { + guard let task = webSocketTask else { return } + task.send(.data(data)) { error in + if let error = error { + print("[airbridge] Send data error: \(error.localizedDescription)") + } + } + } + + /// Tests connectivity to a relay server without affecting the live connection. + /// + /// Opens an isolated WebSocket, sends a registration frame, and considers success + /// if both the WebSocket handshake and the send complete without error. The server + /// does **not** reply to a registration when the peer is not yet connected, so we + /// cannot wait for a response — a successful send is sufficient proof that the + /// relay is reachable and accepting connections. + /// + /// - Parameters: + /// - url: Raw relay URL (will be normalised, same as `relayServerURL`). + /// - pairingId: Pairing ID to register with. + /// - secret: Plain-text secret (will be SHA-256 hashed before sending). + /// - timeout: Maximum seconds to wait (default 8 s). + /// - completion: Called on the **main thread** with `.success(())` or `.failure(error)`. + func testConnectivity( + url: String, + pairingId: String, + secret: String, + timeout: TimeInterval = 8, + completion: @escaping (Result) -> Void + ) { + let normalized = normalizeRelayURL(url) + guard let wsURL = URL(string: normalized) else { + DispatchQueue.main.async { + completion(.failure(ConnectivityError.invalidURL(normalized))) + } + return + } + + // SHA-256 hash the secret (same logic as hashedSecret(), but for arbitrary input) + let secretHash: String = { + let data = Data(secret.utf8) + let hash = SHA256.hash(data: data) + return hash.compactMap { String(format: "%02x", $0) }.joined() + }() + + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = timeout + let session = URLSession(configuration: config) + let task = session.webSocketTask(with: wsURL) + task.resume() + + // Timer to enforce the overall timeout + var settled = false + let lock = NSLock() + + func settle(_ result: Result) { + lock.lock() + let alreadyDone = settled + settled = true + lock.unlock() + guard !alreadyDone else { return } + task.cancel(with: .normalClosure, reason: nil) + session.invalidateAndCancel() + DispatchQueue.main.async { completion(result) } + } + + // Schedule timeout + DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { + settle(.failure(ConnectivityError.timeout)) + } + + // Build registration frame + let regMessage = AirBridgeRegisterMessage( + action: .register, + role: "mac", + pairingId: pairingId, + secret: secretHash, + localIp: "0.0.0.0", + port: 0 + ) + + guard let regData = try? JSONEncoder().encode(regMessage), + let regJSON = String(data: regData, encoding: .utf8) else { + settle(.failure(ConnectivityError.encodingFailed)) + return + } + + // Send registration — the server silently accepts registrations without + // replying until a peer connects, so a successful send = server is alive. + task.send(.string(regJSON)) { sendError in + if let sendError = sendError { + settle(.failure(sendError)) + } else { + settle(.success(())) + } + } + } + + // MARK: - Connectivity Error Types + + enum ConnectivityError: LocalizedError { + case invalidURL(String) + case timeout + case encodingFailed + + var errorDescription: String? { + switch self { + case .invalidURL(let url): return "Invalid relay URL: \(url)" + case .timeout: return "Connection timed out. Check the server URL and your network." + case .encodingFailed: return "Failed to encode registration message." + } + } + } + + /// Regenerates pairing credentials together so an ID and secret always stay in sync. + /// PairingId goes to UserDefaults, secret to Keychain (one prompt). + func regeneratePairingCredentials() { + pairingId = Self.generateShortId() + let newSecret = Self.generateRandomSecret() + _cachedSecret = newSecret + _secretLoaded = true + KeychainStorage.set(newSecret, for: Self.keychainKeySecret) + } + + /// Returns a `airbridge://` URI containing all pairing config, suitable for QR encoding. + func generateQRCodeData() -> String { + let urlEncoded = relayServerURL.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? relayServerURL + return "airbridge://\(urlEncoded)/\(pairingId)/\(secret)" + } + + // MARK: - Connection Logic + + private func connectInternal() { + guard !relayServerURL.isEmpty else { + print("[airbridge] Relay URL is empty, skipping connection") + DispatchQueue.main.async { self.connectionState = .disconnected } + return + } + + // Ensure credentials exist before connecting (lazy generation) + ensureCredentialsExist() + + // Normalize URL: ensure it ends with /ws and has wss:// or ws:// prefix + let normalizedURL = normalizeRelayURL(relayServerURL) + + guard let url = URL(string: normalizedURL) else { + print("[airbridge] Invalid relay URL: \(normalizedURL)") + DispatchQueue.main.async { self.connectionState = .failed(error: "Invalid URL") } + return + } + + isManuallyDisconnected = false + DispatchQueue.main.async { self.connectionState = .connecting } + + let config = URLSessionConfiguration.default + config.waitsForConnectivity = true + config.timeoutIntervalForRequest = 30 + + urlSession = URLSession(configuration: config) + webSocketTask = urlSession?.webSocketTask(with: url) + webSocketTask?.resume() + + // Start receiving messages + receiveLoopActive = true + startReceiving() + + // Send registration + sendRegistration() + } + + /// Derives a SHA-256 hash of the raw secret so the plaintext never leaves the device. + /// The relay server only ever sees (and stores) this hash. + private func hashedSecret() -> String { + let data = Data(secret.utf8) + let hash = SHA256.hash(data: data) + return hash.compactMap { String(format: "%02x", $0) }.joined() + } + + private func sendRegistration() { + DispatchQueue.main.async { self.connectionState = .registering } + + let localIP = WebSocketServer.shared.getLocalIPAddress( + adapterName: AppState.shared.selectedNetworkAdapterName + ) ?? "unknown" + let port = Int(WebSocketServer.shared.localPort ?? Defaults.serverPort) + + let regMessage = AirBridgeRegisterMessage( + action: .register, + role: "mac", + pairingId: pairingId, + secret: hashedSecret(), + localIp: localIP, + port: port + ) + + do { + let data = try JSONEncoder().encode(regMessage) + if let jsonString = String(data: data, encoding: .utf8) { + webSocketTask?.send(.string(jsonString)) { [weak self] error in + if let error = error { + print("[airbridge] Registration send failed: \(error.localizedDescription)") + self?.scheduleReconnect() + } else { + print("[airbridge] Registration sent for pairingId: \(self?.pairingId ?? "?")") + DispatchQueue.main.async { + self?.connectionState = .waitingForPeer + } + self?.reconnectAttempt = 0 + } + } + } + } catch { + print("[airbridge] Failed to encode registration: \(error)") + } + } + + // MARK: - Receive Loop + + private func startReceiving() { + guard receiveLoopActive, let task = webSocketTask else { return } + + task.receive { [weak self] result in + guard let self = self, self.receiveLoopActive else { return } + + switch result { + case .success(let message): + self.handleMessage(message) + // Continue receiving + self.startReceiving() + + case .failure(let error): + print("[airbridge] Receive error: \(error.localizedDescription)") + self.receiveLoopActive = false + self.scheduleReconnect() + } + } + } + + private func handleMessage(_ message: URLSessionWebSocketTask.Message) { + switch message { + case .string(let text): + handleTextMessage(text) + case .data(let data): + handleBinaryMessage(data) + @unknown default: + print("[airbridge] Unknown message type received") + } + } + + private func handleTextMessage(_ text: String) { + // First, try to parse as an AirBridge control message + if let data = text.data(using: .utf8), + let baseMsg = try? JSONDecoder().decode(AirBridgeBaseMessage.self, from: data) { + + switch baseMsg.action { + case .relayStarted: + print("[airbridge] Relay tunnel established!") + DispatchQueue.main.async { + self.connectionState = .relayActive + } + return + + case .macInfo: + // Server echoing our own info, ignore + return + + case .error: + if let errorMsg = try? JSONDecoder().decode(AirBridgeErrorMessage.self, from: data) { + print("[airbridge] Server error: \(errorMsg.message)") + DispatchQueue.main.async { + self.connectionState = .failed(error: errorMsg.message) + } + } + return + + default: + break + } + } + + // If it's not a control message, it's a relayed message from Android. + // Forward it to the local WebSocket handler as if it came from a LAN client. + print("[airbridge] Relaying text message from Android (\(text.count) chars)") + WebSocketServer.shared.handleRelayedMessage(text) + } + + private func handleBinaryMessage(_ data: Data) { + // Binary data from the relay = raw encrypted payload from Android + print("[airbridge] Relaying binary message from Android (\(data.count) bytes)") + WebSocketServer.shared.handleRelayedBinaryMessage(data) + } + + // MARK: - Reconnect + + private func scheduleReconnect() { + guard !isManuallyDisconnected else { return } + + tearDown(reason: "Preparing reconnect") + + let delay = min(pow(2.0, Double(reconnectAttempt)), maxReconnectDelay) + reconnectAttempt += 1 + + print("[airbridge] Reconnecting in \(delay)s (attempt \(reconnectAttempt))") + DispatchQueue.main.async { + self.connectionState = .connecting + } + + queue.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self = self, !self.isManuallyDisconnected else { return } + self.connectInternal() + } + } + + private func tearDown(reason: String) { + receiveLoopActive = false + webSocketTask?.cancel(with: .goingAway, reason: reason.data(using: .utf8)) + webSocketTask = nil + urlSession?.invalidateAndCancel() + urlSession = nil + print("[airbridge] Torn down: \(reason)") + } + + // MARK: - Helpers + + private func normalizeRelayURL(_ raw: String) -> String { + var url = raw.trimmingCharacters(in: .whitespacesAndNewlines) + + let host: String = { + var h = url + // Strip scheme if present + if h.hasPrefix("wss://") { h = String(h.dropFirst(6)) } + else if h.hasPrefix("ws://") { h = String(h.dropFirst(5)) } + return h.components(separatedBy: ":").first?.components(separatedBy: "/").first ?? "" + }() + + let isPrivate = isPrivateHost(host) + + // SECURITY: If user explicitly provided ws://, only allow it for private/localhost hosts. + // Upgrade to wss:// for public hosts to prevent cleartext transport over the internet. + if url.hasPrefix("ws://") && !url.hasPrefix("wss://") && !isPrivate { + print("[airbridge] SECURITY: Upgrading ws:// to wss:// for public host: \(host)") + url = "wss://" + String(url.dropFirst(5)) + } + + // Add scheme if missing + if !url.hasPrefix("ws://") && !url.hasPrefix("wss://") { + if isPrivate { + url = "ws://\(url)" + } else { + url = "wss://\(url)" + } + } + + // Add /ws path if missing + if !url.hasSuffix("/ws") { + if url.hasSuffix("/") { + url += "ws" + } else { + url += "/ws" + } + } + + return url + } + + /// Returns true if the host is a loopback or RFC 1918 private address. + private func isPrivateHost(_ host: String) -> Bool { + if host == "localhost" || host == "127.0.0.1" || host == "::1" { return true } + if host.hasPrefix("192.168.") || host.hasPrefix("10.") { return true } + // RFC 1918: only 172.16.0.0 – 172.31.255.255 (NOT all of 172.*) + if host.hasPrefix("172.") { + let parts = host.components(separatedBy: ".") + if parts.count >= 2, let second = Int(parts[1]), (16...31).contains(second) { + return true + } + } + return false + } + + /// Generates a 32-char lowercase hex ID (128-bit entropy, e.g. "a3f8b2c1e9d0471f8a2b3c4d5e6f7890") + static func generateShortId() -> String { + var bytes = [UInt8](repeating: 0, count: 16) // 16 bytes = 128 bits + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return bytes.map { String(format: "%02x", $0) }.joined() + } + + /// Generates a cryptographically strong secret token (192-bit / 48 hex chars) + /// formatted as 8 groups of 6 chars for readability (e.g. "a3f8b2-c1e9d0-471f8a-2b3c4d-5e6f78-90abcd-ef1234-567890") + static func generateRandomSecret() -> String { + var bytes = [UInt8](repeating: 0, count: 24) // 24 bytes = 192 bits + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + let hex = bytes.map { String(format: "%02x", $0) }.joined() + // Split into 8 groups of 6 chars for readability + var groups: [String] = [] + var idx = hex.startIndex + while idx < hex.endIndex { + let end = hex.index(idx, offsetBy: 6, limitedBy: hex.endIndex) ?? hex.endIndex + groups.append(String(hex[idx.. = [] + private var nonceOrder: [Data] = [] // FIFO for eviction + private let maxEntries = 10_000 // bounded memory (~120 KB) + + /// Returns `true` if the nonce has NOT been seen before (message is fresh). + /// Returns `false` if the nonce is a duplicate (replay detected). + func checkAndRecord(_ nonce: Data) -> Bool { + lock.lock() + defer { lock.unlock() } + + if seenNonces.contains(nonce) { + return false // replay + } + + seenNonces.insert(nonce) + nonceOrder.append(nonce) + + // Evict oldest entries when cache is full + if nonceOrder.count > maxEntries { + let evict = nonceOrder.removeFirst() + seenNonces.remove(evict) + } + return true + } + + /// Clears the replay cache (e.g. on key rotation or reconnect). + func reset() { + lock.lock() + defer { lock.unlock() } + seenNonces.removeAll() + nonceOrder.removeAll() + } +} + func generateSymmetricKey() -> String { let key = SymmetricKey(size: .bits256) let keyData = key.withUnsafeBytes { Data($0) } @@ -30,6 +70,14 @@ func decryptMessage(_ base64: String, using key: SymmetricKey) -> String? { guard let combinedData = Data(base64Encoded: base64) else { return nil } do { let sealedBox = try AES.GCM.SealedBox(combined: combinedData) + + // Anti-replay: check that this nonce hasn't been used before + let nonceData = Data(sealedBox.nonce) + guard NonceReplayGuard.shared.checkAndRecord(nonceData) else { + print("[crypto-util] Replay detected: duplicate nonce, dropping message") + return nil + } + let decrypted = try AES.GCM.open(sealedBox, using: key) return String(data: decrypted, encoding: .utf8) } catch { @@ -38,6 +86,11 @@ func decryptMessage(_ base64: String, using key: SymmetricKey) -> String? { } } +/// Resets the replay nonce cache (call on key rotation or reconnect). +func resetReplayGuard() { + NonceReplayGuard.shared.reset() +} + func sha256(_ input: String) -> String { let data = Data(input.utf8) let hash = SHA256.hash(data: data) diff --git a/airsync-mac/Core/Util/SecureStorage/KeychainStorage.swift b/airsync-mac/Core/Util/SecureStorage/KeychainStorage.swift index 8131fbc9..56b74be9 100644 --- a/airsync-mac/Core/Util/SecureStorage/KeychainStorage.swift +++ b/airsync-mac/Core/Util/SecureStorage/KeychainStorage.swift @@ -1,30 +1,129 @@ import Foundation import Security +/// Thin wrapper around the macOS Keychain. +/// +/// With ad-hoc ("Sign to Run Locally") code signing, **every** individual +/// `SecItem*` call triggers a macOS password-prompt dialog. To avoid +/// pestering the user with 5-8 prompts at launch we: +/// +/// 1. Call `preload()` once at startup, which issues a **single** +/// `SecItemCopyMatching` with `kSecMatchLimitAll` to fetch every +/// item belonging to our service in one shot → **one prompt**. +/// 2. Cache all values in memory. Subsequent reads come from the +/// cache — zero prompts. +/// 3. Writes update both the cache and the Keychain. Because the +/// Keychain ACL for the item was already approved during the +/// preload read, writes within the same app session usually +/// succeed without an additional prompt. enum KeychainStorage { - private static let service = "com.sameerasw.airsync.trial" + private static let service = "com.sameerasw.airsync" + + /// In-memory cache: account key → raw Data value. + private static var cache: [String: Data] = [:] + /// True once `preload()` has completed (successfully or not). + private static var didPreload = false + private static let lock = NSLock() + + // MARK: - Preload (call once at app launch) + + /// Reads **all** keychain items for our service in a single query. + /// This triggers at most **one** macOS password prompt instead of + /// one per key. Call this as early as possible — ideally before + /// any other Keychain-dependent code runs. + static func preload() { + lock.lock() + guard !didPreload else { lock.unlock(); return } + didPreload = true + lock.unlock() + + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + kSecReturnData as String: true + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let items = result as? [[String: Any]] else { + #if DEBUG + if status != errSecItemNotFound { + print("[Keychain] preload: SecItemCopyMatching returned \(status)") + } + #endif + return + } + + lock.lock() + for item in items { + if let account = item[kSecAttrAccount as String] as? String, + let data = item[kSecValueData as String] as? Data { + cache[account] = data + } + } + lock.unlock() + + #if DEBUG + print("[Keychain] preload: cached \(items.count) item(s)") + #endif + } + + // MARK: - Read static func string(for key: String) -> String? { + guard let data = data(for: key) else { return nil } + return String(data: data, encoding: .utf8) + } + + static func data(for key: String) -> Data? { + lock.lock() + if didPreload, let cached = cache[key] { + lock.unlock() + return cached + } + lock.unlock() + + // Fallback: individual query (only reached if preload was not + // called or the key was added after preload). var query = baseQuery(for: key) query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) - guard status == errSecSuccess, - let data = item as? Data, - let value = String(data: data, encoding: .utf8) else { + guard status == errSecSuccess, let data = item as? Data else { return nil } - return value + + // Back-fill the cache so future reads don't hit the Keychain. + lock.lock() + cache[key] = data + lock.unlock() + + return data } + // MARK: - Write + static func set(_ value: String, for key: String) { guard let data = value.data(using: .utf8) else { return } + setData(data, for: key) + } - var query = baseQuery(for: key) - query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock - query[kSecValueData as String] = data + static func setData(_ data: Data, for key: String) { + // Update cache first — even if the Keychain write fails, the + // in-process value stays consistent for the current session. + lock.lock() + cache[key] = data + lock.unlock() + + var query = baseQuery(for: key) + query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked + query[kSecValueData as String] = data let status = SecItemAdd(query as CFDictionary, nil) if status == errSecDuplicateItem { @@ -40,11 +139,24 @@ enum KeychainStorage { } } + // MARK: - Delete + + static func delete(key: String) { + lock.lock() + cache.removeValue(forKey: key) + lock.unlock() + + let query = baseQuery(for: key) + SecItemDelete(query as CFDictionary) + } + + // MARK: - Helpers + private static func baseQuery(for key: String) -> [String: Any] { - var query: [String: Any] = [:] - query[kSecClass as String] = kSecClassGenericPassword - query[kSecAttrService as String] = service - query[kSecAttrAccount as String] = key - return query + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] } } diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index 7a18b38e..595ac030 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -47,7 +47,7 @@ extension WebSocketServer { case .fileTransferInit: handleFileTransferInit(message) case .fileChunk: - handleFileChunk(message, session: session) + handleFileChunk(message) case .fileChunkAck: handleFileChunkAck(message) case .fileTransferComplete: @@ -67,6 +67,65 @@ extension WebSocketServer { case .volumeControl, .macVolume, .toggleAppNotif, .browseLs, .wakeUpRequest, .macMediaControlResponse, .macInfo, .callControl: // Outgoing or unexpected messages break + case .authChallenge, .authResponse, .authResult: + // Handled in WebSocketServer.swift auth layer before this switch + break + } + } + + /// Relay-only router used when no local WebSocket session is available. + /// Kept in this file so private handlers remain encapsulated. + func handleRelayOnlyMessage(_ message: Message) { + switch message.type { + case .notification: + handleNotification(message) + case .status: + handleStatusUpdate(message) + case .clipboardUpdate: + handleClipboardUpdate(message) + case .callEvent: + handleCallEvent(message) + case .remoteControl: + handleRemoteControl(message) + case .macMediaControl: + handleMacMediaControlRequest(message) + case .fileTransferInit: + handleFileTransferInit(message) + case .fileChunk: + handleFileChunk(message) + case .fileTransferComplete: + handleFileTransferComplete(message) + case .fileChunkAck: + handleFileChunkAck(message) + case .transferVerified: + handleTransferVerified(message) + case .fileTransferCancel: + handleFileTransferCancel(message) + case .appIcons: + handleAppIcons(message) + case .browseData: + handleBrowseData(message) + case .notificationUpdate: + handleNotificationUpdate(message) + case .notificationActionResponse: + handleNotificationActionResponse(message) + case .dismissalResponse: + handleDismissalResponse(message) + case .mediaControlResponse: + handleMediaControlResponse(message) + case .callControlResponse: + handleCallControlResponse(message) + case .device: + // handled upstream in WebSocketServer.handleRelayedMessageInternal + break + case .macInfo: + // outbound from Mac -> Android in normal flow, ignore inbound + break + case .volumeControl, .macVolume, .toggleAppNotif, .browseLs, .wakeUpRequest, .macMediaControlResponse, .callControl, .notificationAction: + break + case .authChallenge, .authResponse, .authResult: + // Handled upstream in LAN auth layer; ignore in relay-only context + break } } @@ -142,6 +201,13 @@ extension WebSocketServer { } if (!AppState.shared.adbConnected && (AppState.shared.adbEnabled || AppState.shared.manualAdbConnectionPending || AppState.shared.wiredAdbEnabled) && AppState.shared.isPlus) { + // Security hardening: ADB auto-connect must only happen on direct LAN sessions, + // never through the relay transport. + guard self.hasActiveLocalSession() else { + AppState.shared.manualAdbConnectionPending = false + print("[adb] Skipping ADB auto-connect: no active local session (relay-only connection)") + return + } if AppState.shared.wiredAdbEnabled { ADBConnector.getWiredDeviceSerial(completion: { serial in if let serial = serial { @@ -354,6 +420,7 @@ extension WebSocketServer { let io = IncomingFileIO(tempUrl: tempFile, fileHandle: handle, chunkSize: chunkSize) self.lock.lock() incomingFiles[id] = io + incomingReceivedChunks[id] = [] if let checksum = checksum { incomingFilesChecksum[id] = checksum } @@ -367,7 +434,7 @@ extension WebSocketServer { /// Handles incoming file chunks. /// Writes data to the temporary file handle on a serial queue to ensure thread safety. - private func handleFileChunk(_ message: Message, session: WebSocketSession) { + private func handleFileChunk(_ message: Message) { if let dict = message.data.value as? [String: Any], let id = dict["id"] as? String, let index = dict["index"] as? Int, @@ -375,9 +442,17 @@ extension WebSocketServer { self.lock.lock() let io = incomingFiles[id] + let alreadyReceived = incomingReceivedChunks[id]?.contains(index) == true + if !alreadyReceived { + var received = incomingReceivedChunks[id] ?? [] + received.insert(index) + incomingReceivedChunks[id] = received + } self.lock.unlock() - if let io = io, let data = Data(base64Encoded: chunkBase64, options: .ignoreUnknownCharacters) { + if !alreadyReceived, + let io = io, + let data = Data(base64Encoded: chunkBase64, options: .ignoreUnknownCharacters) { fileQueue.async { let offset = UInt64(index * io.chunkSize) if let fh = io.fileHandle { @@ -437,6 +512,7 @@ extension WebSocketServer { try? FileManager.default.removeItem(at: state.tempUrl) self.lock.lock() self.incomingFiles.removeValue(forKey: id) + self.incomingReceivedChunks.removeValue(forKey: id) self.incomingFilesChecksum.removeValue(forKey: id) self.lock.unlock() return @@ -497,6 +573,7 @@ extension WebSocketServer { self.lock.lock() self.incomingFiles.removeValue(forKey: id) + self.incomingReceivedChunks.removeValue(forKey: id) self.lock.unlock() } } @@ -693,6 +770,9 @@ extension WebSocketServer { private func handleFileTransferCancel(_ message: Message) { if let dict = message.data.value as? [String: Any], let id = dict["id"] as? String { + self.lock.lock() + self.incomingReceivedChunks.removeValue(forKey: id) + self.lock.unlock() DispatchQueue.main.async { AppState.shared.stopTransferRemote(id: id) } diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Networking.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Networking.swift index a3281768..d9fd37cf 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Networking.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Networking.swift @@ -163,8 +163,11 @@ extension WebSocketServer { } DispatchQueue.main.asyncAfter(deadline: .now() + 5) { - self.stop() - self.start(port: Defaults.serverPort) + self.requestRestart( + reason: "Network IP changed", + delay: 0.2, + port: Defaults.serverPort + ) } } else if lastIP == nil { DispatchQueue.main.async { diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift index 1ff38a03..d81ca5e9 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift @@ -8,6 +8,14 @@ import Swifter import CryptoKit extension WebSocketServer { + private func messageTypeForLog(_ message: String) -> String { + guard let data = message.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = obj["type"] as? String else { + return "non_json" + } + return type + } // MARK: - Sending Helpers @@ -20,18 +28,30 @@ extension WebSocketServer { func sendToFirstAvailable(message: String) { lock.lock() - guard let pId = primarySessionID, - let session = activeSessions.first(where: { ObjectIdentifier($0) == pId }) else { - lock.unlock() - return - } + let pId = primarySessionID + let session = pId != nil ? activeSessions.first(where: { ObjectIdentifier($0) == pId }) : nil let key = symmetricKey lock.unlock() - + let type = messageTypeForLog(message) + + let outgoing: String if let key = key, let encrypted = encryptMessage(message, using: key) { - session.writeText(encrypted) + outgoing = encrypted } else { - session.writeText(message) + outgoing = message + } + + if let session = session { + // Local session available — send directly + print("[transport] TX via LAN type=\(type)") + session.writeText(outgoing) + } else if AirBridgeClient.shared.connectionState == .relayActive { + // No local session, but AirBridge relay is active — tunnel through relay + print("[transport] TX via RELAY type=\(type)") + AirBridgeClient.shared.sendText(outgoing) + } else { + // No connection available at all + print("[transport] DROP TX type=\(type) no local session or relay") } } @@ -58,6 +78,10 @@ extension WebSocketServer { } func sendRefreshAdbPortsRequest() { + guard hasActiveLocalSession() else { + print("[adb] Skipping refreshAdbPorts: no active local session") + return + } sendMessage(type: "refreshAdbPorts", data: [:]) } diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift index a5ae0cb0..5d7b4d2f 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift @@ -55,15 +55,38 @@ extension WebSocketServer { let isStale = now.timeIntervalSince(lastDate) > timeout if isStale { + // If relay is currently active, avoid hard restart: stale local sessions + // can happen during transport switch (LAN <-> relay). + if AirBridgeClient.shared.connectionState == .relayActive { + print("[websocket] Session \(sessionId) is stale while relay is active. Cleaning up stale local session only.") + self.lock.lock() + self.activeSessions.removeAll(where: { ObjectIdentifier($0) == sessionId }) + self.lastActivity.removeValue(forKey: sessionId) + if self.primarySessionID == sessionId { + self.primarySessionID = nil + } + let sessionCount = self.activeSessions.count + self.lock.unlock() + + if sessionCount == 0 { + MacRemoteManager.shared.stopVolumeMonitoring() + self.stopPing() + } + continue + } + print("[websocket] Session \(sessionId) is stale. Performing hard reset and discovery restart.") DispatchQueue.main.async { // Disconnect and restart AppState.shared.disconnectDevice() ADBConnector.disconnectADB() AppState.shared.adbConnected = false - - self.stop() - self.start(port: self.localPort ?? Defaults.serverPort) + + self.requestRestart( + reason: "Ping timeout / stale session", + delay: 0.35, + port: self.localPort ?? Defaults.serverPort + ) } return } diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index d96a5df3..dba65b3f 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -40,14 +40,21 @@ class WebSocketServer: ObservableObject { internal var incomingFiles: [String: IncomingFileIO] = [:] internal var incomingFilesChecksum: [String: String] = [:] + internal var incomingReceivedChunks: [String: Set] = [:] internal var outgoingAcks: [String: Set] = [:] internal let maxChunkRetries = 3 internal let ackWaitMs: UInt16 = 2000 + internal let lifecycleQueue = DispatchQueue(label: "com.airsync.websocket.lifecycle", qos: .userInitiated) + internal var pendingRestartWorkItem: DispatchWorkItem? internal var lastKnownAdapters: [(name: String, address: String)] = [] internal var lastLoggedSelectedAdapter: (name: String, address: String)? = nil + // LAN authentication: tracks which sessions have completed HMAC challenge-response + internal var authenticatedSessions: Set = [] + internal var pendingChallenges: [ObjectIdentifier: Data] = [:] + init() { loadOrGenerateSymmetricKey() setupWebSocket(for: server) @@ -70,7 +77,7 @@ class WebSocketServer: ObservableObject { let adapterName = AppState.shared.selectedNetworkAdapterName let adapters = getAvailableNetworkAdapters() - DispatchQueue.global(qos: .userInitiated).async { [weak self] in + lifecycleQueue.async { [weak self] in guard let self = self else { return } do { @@ -87,7 +94,7 @@ class WebSocketServer: ObservableObject { self.isListeningOnAll = false let server = HttpServer() self.setupWebSocket(for: server) - try server.start(in_port_t(port)) + try self.startServerWithRetry(server, port: port) self.servers[specificAdapter] = server let ip = self.getLocalIPAddress(adapterName: specificAdapter) @@ -106,7 +113,7 @@ class WebSocketServer: ObservableObject { let server = HttpServer() self.setupWebSocket(for: server) if !startedAny { - try server.start(in_port_t(port)) + try self.startServerWithRetry(server, port: port) self.servers["any"] = server startedAny = true } @@ -143,23 +150,92 @@ class WebSocketServer: ObservableObject { servers.removeAll() } + private func startServerWithRetry(_ server: HttpServer, port: UInt16, maxAttempts: Int = 3) throws { + var lastError: Error? + for attempt in 1...maxAttempts { + do { + try server.start(in_port_t(port)) + if attempt > 1 { + print("[websocket] Bind succeeded on attempt \(attempt)/\(maxAttempts) for port \(port)") + } + return + } catch { + lastError = error + let lowered = String(describing: error).lowercased() + let isAddressInUse = lowered.contains("address already in use") + || lowered.contains("port in use") + || lowered.contains("bind") + || lowered.contains("in use") + + if !isAddressInUse || attempt == maxAttempts { + throw error + } + + // A rapid restart may race the previous listener teardown. + // Back off briefly and retry binding. + let delaySeconds = Double(200 * attempt) / 1000.0 + print("[websocket] Port \(port) busy, retrying bind in \(Int(delaySeconds * 1000))ms (attempt \(attempt + 1)/\(maxAttempts))") + Thread.sleep(forTimeInterval: delaySeconds) + } + } + + if let lastError { + throw lastError + } + } + + func requestRestart(reason: String, delay: TimeInterval = 0.35, port: UInt16? = nil) { + lock.lock() + pendingRestartWorkItem?.cancel() + let restartPort = port ?? localPort ?? Defaults.serverPort + lock.unlock() + + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + print("[websocket] Restart requested: \(reason)") + self.stop() + self.start(port: restartPort) + } + + lock.lock() + pendingRestartWorkItem = workItem + lock.unlock() + + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + func stop() { lock.lock() + pendingRestartWorkItem?.cancel() + pendingRestartWorkItem = nil stopAllServers() activeSessions.removeAll() + incomingReceivedChunks.removeAll() primarySessionID = nil + authenticatedSessions.removeAll() + pendingChallenges.removeAll() + resetReplayGuard() stopPing() lock.unlock() DispatchQueue.main.async { AppState.shared.webSocketStatus = .stopped } stopNetworkMonitoring() } + /// Returns true only when a primary LAN WebSocket session is currently active. + func hasActiveLocalSession() -> Bool { + lock.lock() + defer { lock.unlock() } + guard let pId = primarySessionID else { return false } + return activeSessions.contains(where: { ObjectIdentifier($0) == pId }) + } + /// Configures WebSocket routes and event callbacks. /// Handles message decryption before passing payload to the message router. private func setupWebSocket(for server: HttpServer) { server["/socket"] = websocket( text: { [weak self] session, text in guard let self = self else { return } + let sessionId = ObjectIdentifier(session) let decryptedText: String if let key = self.symmetricKey { decryptedText = decryptMessage(text, using: key) ?? "" @@ -169,7 +245,7 @@ class WebSocketServer: ObservableObject { if decryptedText.contains("\"type\":\"pong\"") { self.lock.lock() - self.lastActivity[ObjectIdentifier(session)] = Date() + self.lastActivity[sessionId] = Date() self.lock.unlock() return } @@ -178,8 +254,19 @@ class WebSocketServer: ObservableObject { do { let message = try JSONDecoder().decode(Message.self, from: data) self.lock.lock() - self.lastActivity[ObjectIdentifier(session)] = Date() + self.lastActivity[sessionId] = Date() + let isAuthenticated = self.authenticatedSessions.contains(sessionId) self.lock.unlock() + + // Gate: only authResponse is allowed before authentication completes + if !isAuthenticated { + if message.type == .authResponse { + self.handleAuthResponse(message, session: session) + } else { + print("[websocket] Dropping message type=\(message.type.rawValue) from unauthenticated session \(sessionId)") + } + return + } if message.type == .fileChunk || message.type == .fileChunkAck || message.type == .fileTransferComplete || message.type == .fileTransferInit { self.handleMessage(message, session: session) @@ -204,20 +291,26 @@ class WebSocketServer: ObservableObject { self.activeSessions.append(session) let sessionCount = self.activeSessions.count self.lock.unlock() - print("[websocket] Session \(sessionId) connected.") + print("[websocket] Session \(sessionId) connected, sending auth challenge.") if sessionCount == 1 { MacRemoteManager.shared.startVolumeMonitoring() self.startPing() } + + // Send HMAC challenge for LAN authentication + self.sendAuthChallenge(to: session) }, disconnected: { [weak self] session in guard let self = self else { return } + let sessionId = ObjectIdentifier(session) self.lock.lock() self.activeSessions.removeAll(where: { $0 === session }) let sessionCount = self.activeSessions.count - let wasPrimary = (ObjectIdentifier(session) == self.primarySessionID) + let wasPrimary = (sessionId == self.primarySessionID) if wasPrimary { self.primarySessionID = nil } + self.authenticatedSessions.remove(sessionId) + self.pendingChallenges.removeValue(forKey: sessionId) self.lock.unlock() if sessionCount == 0 { @@ -230,32 +323,201 @@ class WebSocketServer: ObservableObject { AppState.shared.disconnectDevice() ADBConnector.disconnectADB() AppState.shared.adbConnected = false - self.stop() - self.start(port: self.localPort ?? Defaults.serverPort) + self.requestRestart( + reason: "Primary session disconnected", + delay: 0.35, + port: self.localPort ?? Defaults.serverPort + ) } } } ) } + // MARK: - AirBridge Relay Integration + + /// Handles a text message received from the AirBridge relay (Android → Relay → Mac). + /// Decrypts and routes it through the same pipeline as local WebSocket messages. + func handleRelayedMessage(_ text: String) { + let decryptedText: String + if let key = self.symmetricKey { + if let dec = decryptMessage(text, using: key), !dec.isEmpty { + decryptedText = dec + } else { + print("[transport] RX via RELAY dropped: decrypt failed or empty payload (len=\(text.count))") + return + } + } else { + // In normal operation this should not happen; relay payloads are expected encrypted. + print("[transport] RX via RELAY: no symmetric key on Mac, attempting plaintext parse") + decryptedText = text + } + + guard let data = decryptedText.data(using: .utf8) else { + print("[transport] RX via RELAY dropped: UTF-8 conversion failed") + return + } + + do { + let message = try JSONDecoder().decode(Message.self, from: data) + + // File transfer messages are handled on background queue (like local messages) + if message.type == .fileChunk || message.type == .fileChunkAck || + message.type == .fileTransferComplete || message.type == .fileTransferInit { + self.handleRelayedMessageInternal(message) + } else { + DispatchQueue.main.async { + self.handleRelayedMessageInternal(message) + } + } + } catch { + print("[airbridge] Failed to decode relayed message: \(error)") + } + } + + /// Handles a binary message received from the AirBridge relay. + func handleRelayedBinaryMessage(_ data: Data) { + // Binary relay data — currently unused in the AirSync protocol + // (file transfers use base64 JSON), but ready for future E2EE binary payloads + print("[airbridge] Received binary relay data (\(data.count) bytes)") + } + + /// Internal router for relayed messages. + /// Uses an existing local session when available, otherwise handles messages directly. + private func handleRelayedMessageInternal(_ message: Message) { + // For the device handshake, we handle it entirely within the relay path + if message.type == .device { + if let dict = message.data.value as? [String: Any], + let name = dict["name"] as? String, + let ip = dict["ipAddress"] as? String, + let port = dict["port"] as? Int { + + let version = dict["version"] as? String ?? "2.0.0" + let adbPorts = dict["adbPorts"] as? [String] ?? [] + + DispatchQueue.main.async { + AppState.shared.device = Device( + name: name, + ipAddress: ip, + port: port, + version: version, + adbPorts: adbPorts + ) + } + + if let base64 = dict["wallpaper"] as? String { + DispatchQueue.main.async { + AppState.shared.currentDeviceWallpaperBase64 = base64 + } + } + + sendMacInfoViaRelay() + } + return + } + + // For all other messages, delegate to handleMessage only if a primary local session exists + lock.lock() + let pId = primarySessionID + var session = pId != nil ? activeSessions.first(where: { ObjectIdentifier($0) == pId }) : nil + var sessionCount = activeSessions.count + if let s = session { + let sid = ObjectIdentifier(s) + let lastSeen = lastActivity[sid] ?? .distantPast + let stale = Date().timeIntervalSince(lastSeen) > activityTimeout + if stale { + // Immediate stale eviction: avoids routing relay traffic to a dead local socket. + activeSessions.removeAll(where: { ObjectIdentifier($0) == sid }) + lastActivity.removeValue(forKey: sid) + if primarySessionID == sid { + primarySessionID = nil + } + session = nil + sessionCount = activeSessions.count + print("[transport] Primary LAN session stale during relay RX; switched to relay-only routing") + } + } + lock.unlock() + + if sessionCount == 0 { + MacRemoteManager.shared.stopVolumeMonitoring() + stopPing() + } + + if let session = session { + print("[transport] RX via RELAY routed to primary LAN session type=\(message.type.rawValue)") + handleMessage(message, session: session) + } else { + // No local session — dispatch directly to AppState for non-session-critical messages + print("[transport] RX via RELAY handled in relay-only mode type=\(message.type.rawValue)") + handleRelayedMessageWithoutSession(message) + } + } + + /// Handles relay messages when no local WebSocket session exists. + /// This covers the cases where the Mac is connected ONLY via the relay. + private func handleRelayedMessageWithoutSession(_ message: Message) { + handleRelayOnlyMessage(message) + } + + /// Sends macInfo response back through the relay instead of the local session. + private func sendMacInfoViaRelay() { + let macName = AppState.shared.myDevice?.name ?? (Host.current().localizedName ?? "My Mac") + let isPlusSubscription = AppState.shared.isPlus + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.0.0" + + let messageDict: [String: Any] = [ + "type": "macInfo", + "data": [ + "name": macName, + "isPlus": isPlusSubscription, + "version": appVersion + ] + ] + + if let jsonData = try? JSONSerialization.data(withJSONObject: messageDict), + let jsonString = String(data: jsonData, encoding: .utf8) { + if let key = symmetricKey, let encrypted = encryptMessage(jsonString, using: key) { + AirBridgeClient.shared.sendText(encrypted) + } else { + AirBridgeClient.shared.sendText(jsonString) + } + } + } + // MARK: - Crypto Helpers func loadOrGenerateSymmetricKey() { + let keychainKey = "encryptionKey" + + // 1. Try loading from Keychain first + if let keyData = KeychainStorage.data(for: keychainKey) { + symmetricKey = SymmetricKey(data: keyData) + return + } + + // 2. Migrate from UserDefaults if present (one-time migration) let defaults = UserDefaults.standard - if let savedKey = defaults.string(forKey: "encryptionKey"), + if let savedKey = defaults.string(forKey: keychainKey), let keyData = Data(base64Encoded: savedKey) { + KeychainStorage.setData(keyData, for: keychainKey) + defaults.removeObject(forKey: keychainKey) + symmetricKey = SymmetricKey(data: keyData) + print("[crypto] Migrated encryption key from UserDefaults to Keychain") + return + } + + // 3. Generate a new key and store in Keychain + let base64Key = generateSymmetricKey() + if let keyData = Data(base64Encoded: base64Key) { + KeychainStorage.setData(keyData, for: keychainKey) symmetricKey = SymmetricKey(data: keyData) - } else { - let base64Key = generateSymmetricKey() - defaults.set(base64Key, forKey: "encryptionKey") - if let keyData = Data(base64Encoded: base64Key) { - symmetricKey = SymmetricKey(data: keyData) - } } } func resetSymmetricKey() { - UserDefaults.standard.removeObject(forKey: "encryptionKey") + KeychainStorage.delete(key: "encryptionKey") + resetReplayGuard() loadOrGenerateSymmetricKey() } @@ -269,6 +531,122 @@ class WebSocketServer: ObservableObject { symmetricKey = SymmetricKey(data: data) } } + + // MARK: - LAN Authentication (HMAC Challenge-Response) + + /// Generates a random 32-byte nonce and sends it as an authChallenge to the connecting session. + func sendAuthChallenge(to session: WebSocketSession) { + var nonceBytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, nonceBytes.count, &nonceBytes) + let nonceData = Data(nonceBytes) + let nonceHex = nonceData.map { String(format: "%02x", $0) }.joined() + + let sessionId = ObjectIdentifier(session) + lock.lock() + pendingChallenges[sessionId] = nonceData + lock.unlock() + + let challengeJson = """ + {"type":"authChallenge","data":{"nonce":"\(nonceHex)"}} + """ + + if let key = symmetricKey, let encrypted = encryptMessage(challengeJson, using: key) { + session.writeText(encrypted) + } else { + session.writeText(challengeJson) + } + print("[auth] Sent challenge to session \(sessionId)") + + // Auto-close session after 10 seconds if not authenticated + DispatchQueue.global().asyncAfter(deadline: .now() + 10.0) { [weak self] in + guard let self = self else { return } + self.lock.lock() + let stillPending = self.pendingChallenges[sessionId] != nil + let notAuthenticated = !self.authenticatedSessions.contains(sessionId) + self.lock.unlock() + if stillPending && notAuthenticated { + print("[auth] Session \(sessionId) failed to authenticate in time, closing.") + session.writeBinary([UInt8]()) // triggers disconnect + } + } + } + + /// Verifies the authResponse HMAC from the client using constant-time comparison. + func handleAuthResponse(_ message: Message, session: WebSocketSession) { + let sessionId = ObjectIdentifier(session) + + guard let dict = message.data.value as? [String: Any], + let hmacHex = dict["hmac"] as? String else { + print("[auth] Invalid authResponse from \(sessionId)") + sendAuthResult(to: session, success: false, reason: "Invalid response format") + return + } + + lock.lock() + guard let nonceData = pendingChallenges.removeValue(forKey: sessionId) else { + lock.unlock() + print("[auth] No pending challenge for \(sessionId)") + sendAuthResult(to: session, success: false, reason: "No pending challenge") + return + } + lock.unlock() + + guard let key = symmetricKey else { + print("[auth] No symmetric key available for verification") + sendAuthResult(to: session, success: false, reason: "Server configuration error") + return + } + + // Compute expected HMAC-SHA256(symmetricKey, nonce) + let hmacKey = SymmetricKey(data: key.withUnsafeBytes { Data($0) }) + let expectedHMAC = HMAC.authenticationCode(for: nonceData, using: hmacKey) + let expectedHex = Data(expectedHMAC).map { String(format: "%02x", $0) }.joined() + + // Constant-time comparison + let receivedBytes = Array(hmacHex.utf8) + let expectedBytes = Array(expectedHex.utf8) + let lengthMatch = receivedBytes.count == expectedBytes.count + var diff: UInt8 = 0 + let count = min(receivedBytes.count, expectedBytes.count) + for i in 0.. Void + let onSkip: () -> Void + + @ObservedObject var appState = AppState.shared + @ObservedObject var airBridge = AirBridgeClient.shared + + @State private var relayURL: String = "" + @State private var pairingId: String = "" + @State private var secret: String = "" + @State private var showSecret: Bool = false + + @State private var isTesting: Bool = false + @State private var testError: String? = nil + @State private var showErrorAlert: Bool = false + + var body: some View { + VStack(spacing: 20) { + ScrollView { + VStack(spacing: 20) { + Text("AirBridge Relay") + .font(.title) + .multilineTextAlignment(.center) + .padding() + + Text("AirBridge allows you to connect your Mac and Android device over the internet when they are not on the same Wi-Fi network. If you have an AirBridge relay server, you can configure it now.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 500) + + VStack(alignment: .leading, spacing: 16) { + Toggle("Enable AirBridge", isOn: $appState.airBridgeEnabled) + .toggleStyle(.switch) + .padding(.bottom, 8) + .onChange(of: appState.airBridgeEnabled) { enabled in + if enabled { + // Generate credentials in memory only — no Keychain access + if pairingId.isEmpty { + pairingId = AirBridgeClient.generateShortId() + } + if secret.isEmpty { + secret = AirBridgeClient.generateRandomSecret() + } + } + } + + if appState.airBridgeEnabled { + VStack(alignment: .leading, spacing: 12) { + // Relay Server URL + HStack { + Label("Server URL", systemImage: "server.rack") + .frame(width: 100, alignment: .leading) + TextField("wss://airbridge", text: $relayURL) + .textFieldStyle(.roundedBorder) + } + + // Pairing ID + HStack { + Label("Pairing ID", systemImage: "link") + .frame(width: 100, alignment: .leading) + TextField("Generated automatically", text: $pairingId) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + + Button { + // Generate in memory only — no Keychain writes during onboarding + pairingId = AirBridgeClient.generateShortId() + secret = AirBridgeClient.generateRandomSecret() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .help("Regenerate Credentials") + } + + // Secret + HStack { + Label("Secret", systemImage: "key") + .frame(width: 100, alignment: .leading) + + Group { + if showSecret { + TextField("Secret", text: $secret) + .font(.system(.body, design: .monospaced)) + } else { + SecureField("Secret", text: $secret) + } + } + .textFieldStyle(.roundedBorder) + + Button { + showSecret.toggle() + } label: { + Image(systemName: showSecret ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + } + } + .padding() + .background(Color.secondary.opacity(0.08)) + .cornerRadius(10) + .frame(maxWidth: 500) + } + } + .frame(maxWidth: 500) + .padding(.top, 10) + } + .padding(.bottom, 10) + } + + HStack(spacing: 16) { + if !appState.airBridgeEnabled { + GlassButtonView( + label: "Skip", + size: .large, + action: onSkip + ) + .transition(.identity) + } else { + GlassButtonView( + label: isTesting ? "Testing…" : "Continue", + systemImage: isTesting ? "hourglass" : "arrow.right.circle", + size: .large, + primary: true, + action: runConnectivityTest + ) + .disabled(isTesting || relayURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .transition(.identity) + } + } + .padding(.bottom, 10) + } + .onAppear { + if appState.airBridgeEnabled { + loadCredentials() + } + } + .alert("Connection Failed", isPresented: $showErrorAlert) { + Button("OK", role: .cancel) {} + } message: { + Text(testError ?? "Could not reach the relay server.") + } + } + + private func runConnectivityTest() { + guard !relayURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + isTesting = true + AirBridgeClient.shared.testConnectivity( + url: relayURL, + pairingId: pairingId, + secret: secret + ) { result in + isTesting = false + switch result { + case .success: + saveCredentials() + onNext() + case .failure(let error): + testError = error.localizedDescription + showErrorAlert = true + } + } + } + + private func loadCredentials() { + relayURL = airBridge.relayServerURL + pairingId = airBridge.pairingId + secret = airBridge.secret + } + + private func saveCredentials() { + airBridge.saveAllCredentials(url: relayURL, pairingId: pairingId, secret: secret) + } +} + +#Preview { + AirBridgeSetupView(onNext: {}, onSkip: {}) +} diff --git a/airsync-mac/Screens/OnboardingView/OnboardingView.swift b/airsync-mac/Screens/OnboardingView/OnboardingView.swift index 69c9e170..8136dacf 100644 --- a/airsync-mac/Screens/OnboardingView/OnboardingView.swift +++ b/airsync-mac/Screens/OnboardingView/OnboardingView.swift @@ -13,6 +13,7 @@ internal import SwiftImageReadWrite enum OnboardingStep { case welcome case installAndroid + case airBridgeSetup case mirroringSetup case plusFeatures case done @@ -58,7 +59,13 @@ struct OnboardingView: View { WelcomeView(onNext: { withAnimation(.easeInOut(duration: 0.75)) { currentStep = .installAndroid } }) case .installAndroid: - InstallAndroidView(onNext: { withAnimation(.easeInOut(duration: 0.75)) { currentStep = .mirroringSetup } }) + InstallAndroidView(onNext: { withAnimation(.easeInOut(duration: 0.75)) { currentStep = .airBridgeSetup } }) + + case .airBridgeSetup: + AirBridgeSetupView( + onNext: { withAnimation(.easeInOut(duration: 0.75)) { currentStep = .mirroringSetup } }, + onSkip: { withAnimation(.easeInOut(duration: 0.75)) { currentStep = .mirroringSetup } } + ) case .mirroringSetup: MirroringSetupView( diff --git a/airsync-mac/Screens/ScannerView/ScannerView.swift b/airsync-mac/Screens/ScannerView/ScannerView.swift index 84f2e7e0..b6b1b65f 100644 --- a/airsync-mac/Screens/ScannerView/ScannerView.swift +++ b/airsync-mac/Screens/ScannerView/ScannerView.swift @@ -229,6 +229,10 @@ struct ScannerView: View { // Device name changed, regenerate QR generateQRAsync() } + .onChange(of: appState.airBridgeEnabled) { _, _ in + // AirBridge setting changed, regenerate QR + generateQRAsync() + } .onChange(of: udpDiscovery.discoveredDevices) { oldDevices, newDevices in if oldDevices.isEmpty && !newDevices.isEmpty { // First device discovered, collapse QR if it's showing @@ -272,7 +276,10 @@ struct ScannerView: View { ip: validIP, port: UInt16(appState.myDevice?.port ?? Int(Defaults.serverPort)), name: appState.myDevice?.name, - key: WebSocketServer.shared.getSymmetricKeyBase64() ?? "" + key: WebSocketServer.shared.getSymmetricKeyBase64() ?? "", + relayURL: appState.airBridgeEnabled ? AirBridgeClient.shared.relayServerURL : nil, + pairingId: appState.airBridgeEnabled ? AirBridgeClient.shared.pairingId : nil, + secret: appState.airBridgeEnabled ? AirBridgeClient.shared.secret : nil ) ?? "That doesn't look right, QR Generation failed" Task { @@ -301,13 +308,35 @@ struct ScannerView: View { } } -func generateQRText(ip: String?, port: UInt16?, name: String?, key: String) -> String? { +func generateQRText( + ip: String?, + port: UInt16?, + name: String?, + key: String, + relayURL: String? = nil, + pairingId: String? = nil, + secret: String? = nil +) -> String? { guard let ip = ip, let port = port else { return nil } - let encodedName = name?.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "My Mac" - return "airsync://\(ip):\(port)?name=\(encodedName)?plus=\(AppState.shared.isPlus)?key=\(key)" + let queryAllowed = CharacterSet.urlQueryAllowed.subtracting(CharacterSet(charactersIn: "&=?")) + let encodedName = name?.addingPercentEncoding(withAllowedCharacters: queryAllowed) ?? "My Mac" + let encodedKey = key.addingPercentEncoding(withAllowedCharacters: queryAllowed) ?? key + + var qrText = "airsync://\(ip):\(port)?name=\(encodedName)?plus=\(AppState.shared.isPlus)?key=\(encodedKey)" + + if let relayURL, !relayURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let pairingId, !pairingId.isEmpty, + let secret, !secret.isEmpty { + let encodedRelay = relayURL.addingPercentEncoding(withAllowedCharacters: queryAllowed) ?? relayURL + let encodedPairing = pairingId.addingPercentEncoding(withAllowedCharacters: queryAllowed) ?? pairingId + let encodedSecret = secret.addingPercentEncoding(withAllowedCharacters: queryAllowed) ?? secret + qrText += "?relay=\(encodedRelay)?pairingId=\(encodedPairing)?secret=\(encodedSecret)" + } + + return qrText } #Preview { diff --git a/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift b/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift new file mode 100644 index 00000000..1522859d --- /dev/null +++ b/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift @@ -0,0 +1,200 @@ +// +// AirBridgeSettingsView.swift +// airsync-mac +// +// Created by AI Assistant. +// + +import SwiftUI + +struct AirBridgeSettingsView: View { + @ObservedObject var appState = AppState.shared + @ObservedObject var airBridge = AirBridgeClient.shared + + @State private var relayURL: String = "" + @State private var pairingId: String = "" + @State private var secret: String = "" + @State private var showSecret: Bool = false + + var body: some View { + VStack(spacing: 12) { + // Toggle + HStack { + Label("Enable AirBridge", systemImage: "antenna.radiowaves.left.and.right.circle.fill") + Spacer() + Toggle("", isOn: $appState.airBridgeEnabled) + .toggleStyle(.switch) + } + + if appState.airBridgeEnabled { + Divider() + + // Connection status + HStack { + statusDot + Text(airBridge.connectionState.displayName) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Spacer() + + if case .failed = airBridge.connectionState { + Button("Retry") { + AirBridgeClient.shared.connect() + } + .buttonStyle(.borderless) + .font(.system(size: 11)) + } + } + + Divider() + + // Relay Server URL + HStack { + Label("Relay Server", systemImage: "server.rack") + Spacer() + TextField("wss://airbridge", text: $relayURL) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 220) + .onSubmit { saveRelayURL() } + } + + // Pairing ID (128-bit hex, show truncated with copy option) + HStack { + Label("Pairing ID", systemImage: "link") + Spacer() + Text(pairingId.prefix(12) + "...") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .help(pairingId) + + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(pairingId, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + .help("Copy Pairing ID") + + Button { + regeneratePairingCredentials() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .help("Regenerate Pairing ID and Secret") + } + + // Secret (passphrase) + HStack { + Label("Secret", systemImage: "key") + Spacer() + + if showSecret { + Text(secret) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } else { + Text("••••-••••-••••-••••") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + + Button { + showSecret.toggle() + } label: { + Image(systemName: showSecret ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(secret, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + .help("Copy Secret") + + } + + Divider() + + // Save & Reconnect + HStack { + Spacer() + Button { + saveAndReconnect() + } label: { + Label("Save & Reconnect", systemImage: "arrow.triangle.2.circlepath") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + } + .onAppear { + if appState.airBridgeEnabled { + loadCredentials() + } + } + .onChange(of: appState.airBridgeEnabled) { enabled in + if enabled { + // Generate credentials in memory only — Keychain is written on Save & Reconnect + if pairingId.isEmpty { + pairingId = AirBridgeClient.generateShortId() + } + if secret.isEmpty { + secret = AirBridgeClient.generateRandomSecret() + } + } + } + } + + // MARK: - Helpers + + private func loadCredentials() { + relayURL = airBridge.relayServerURL + pairingId = airBridge.pairingId + secret = airBridge.secret + } + + @ViewBuilder + private var statusDot: some View { + Circle() + .fill(statusColor) + .frame(width: 8, height: 8) + } + + private var statusColor: Color { + switch airBridge.connectionState { + case .disconnected: return .gray + case .connecting: return .orange + case .registering: return .orange + case .waitingForPeer: return .yellow + case .relayActive: return .green + case .failed: return .red + } + } + + private func saveRelayURL() { + // Batch-save all credentials (single Keychain write) + airBridge.saveAllCredentials(url: relayURL, pairingId: pairingId, secret: secret) + } + + private func saveAndReconnect() { + airBridge.saveAllCredentials(url: relayURL, pairingId: pairingId, secret: secret) + airBridge.disconnect() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + airBridge.connect() + } + } + + private func regeneratePairingCredentials() { + airBridge.regeneratePairingCredentials() + pairingId = airBridge.pairingId + secret = airBridge.secret + } +} diff --git a/airsync-mac/Screens/Settings/SettingsFeaturesView.swift b/airsync-mac/Screens/Settings/SettingsFeaturesView.swift index 60dcb3a7..45dd4d76 100644 --- a/airsync-mac/Screens/Settings/SettingsFeaturesView.swift +++ b/airsync-mac/Screens/Settings/SettingsFeaturesView.swift @@ -59,6 +59,11 @@ struct SettingsFeaturesView: View { systemImage: appState.adbConnecting ? "hourglass" : "play.circle", action: { if !appState.adbConnecting { + guard WebSocketServer.shared.hasActiveLocalSession() else { + appState.adbConnectionResult = "ADB works only on local LAN connections. Relay mode is not supported for ADB." + appState.manualAdbConnectionPending = false + return + } appState.adbConnectionResult = "" // Clear console appState.manualAdbConnectionPending = true WebSocketServer.shared.sendRefreshAdbPortsRequest() @@ -67,7 +72,7 @@ struct SettingsFeaturesView: View { } ) .disabled( - appState.device == nil || appState.adbConnecting || !AppState.shared.isPlus + appState.device == nil || appState.adbConnecting || !AppState.shared.isPlus || !WebSocketServer.shared.hasActiveLocalSession() ) } diff --git a/airsync-mac/Screens/Settings/SettingsView.swift b/airsync-mac/Screens/Settings/SettingsView.swift index be9db17e..4b69bcbf 100644 --- a/airsync-mac/Screens/Settings/SettingsView.swift +++ b/airsync-mac/Screens/Settings/SettingsView.swift @@ -47,11 +47,17 @@ struct SettingsView: View { } .onChange(of: appState.selectedNetworkAdapterName) { _, _ in currentIPAddress = WebSocketServer.shared.getLocalIPAddress(adapterName: appState.selectedNetworkAdapterName) ?? "N/A" - WebSocketServer.shared.stop() if let port = UInt16(port) { - WebSocketServer.shared.start(port: port) + WebSocketServer.shared.requestRestart( + reason: "Network adapter selection changed", + delay: 0.2, + port: port + ) } else { - WebSocketServer.shared.start() + WebSocketServer.shared.requestRestart( + reason: "Network adapter selection changed", + delay: 0.2 + ) } appState.shouldRefreshQR = true } @@ -99,7 +105,14 @@ struct SettingsView: View { ) } - // 2. Features + // 2.5 AirBridge Relay + headerSection(title: "AirBridge Relay", icon: "antenna.radiowaves.left.and.right") + AirBridgeSettingsView() + .padding() + .background(.background.opacity(0.3)) + .cornerRadius(12.0) + + // 3. Features headerSection(title: "Features", icon: "square.grid.2x2") SettingsFeaturesView() diff --git a/airsync-mac/airsync_macApp.swift b/airsync-mac/airsync_macApp.swift index eaef9681..a76e1fbf 100644 --- a/airsync-mac/airsync_macApp.swift +++ b/airsync-mac/airsync_macApp.swift @@ -25,6 +25,9 @@ struct airsync_macApp: App { @StateObject private var macInfoSyncManager = MacInfoSyncManager() init() { + // Pre-load ALL Keychain items in a single query so macOS only + // shows ONE password prompt instead of 5-8 individual prompts. + KeychainStorage.preload() let center = UNUserNotificationCenter.current() center.delegate = notificationDelegate From d992b3a3a29809e0b972099cf8eaf35e592e6639 Mon Sep 17 00:00:00 2001 From: tornado-bunk Date: Sat, 14 Mar 2026 19:02:06 +0100 Subject: [PATCH 02/17] feat: Update connection status handling and improve UI feedback for local network connections --- airsync-mac/Core/AppState.swift | 6 +++--- .../PhoneView/ConnectionStatusPill.swift | 18 +++++++++++++----- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 2b2dc0ce..39cf33ca 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -196,9 +196,9 @@ class AppState: ObservableObject { @Published var recentApps: [AndroidApp] = [] var isConnectedOverLocalNetwork: Bool { - guard let ip = device?.ipAddress else { return true } - // Tailscale IPs usually start with 100. - return !ip.hasPrefix("100.") + // Check if we have a direct LAN WebSocket session (true local connection). + // Falls back to false when only the AirBridge relay tunnel is active. + return WebSocketServer.shared.hasActiveLocalSession() } // Audio player for ringtone diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift index 98252145..a9815c6d 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift @@ -20,7 +20,7 @@ struct ConnectionStatusPill: View { // Network Connection Icon Image(systemName: appState.isConnectedOverLocalNetwork ? "wifi" : "globe") .contentTransition(.symbolEffect(.replace)) - .help(appState.isConnectedOverLocalNetwork ? "Local WiFi" : "Extended Connection (Tailscale)") + .help(appState.isConnectedOverLocalNetwork ? "Local WiFi" : "AirBridge Relay") if appState.isPlus { if appState.adbConnecting { @@ -105,12 +105,20 @@ struct ConnectionPillPopover: View { ) ConnectionInfoText( - label: "IP Address", - icon: "wifi", - text: currentIPAddress, - activeIp: appState.activeMacIp + label: "Transport", + icon: appState.isConnectedOverLocalNetwork ? "wifi" : "globe", + text: appState.isConnectedOverLocalNetwork ? "Local WiFi" : "AirBridge Relay" ) + if appState.isConnectedOverLocalNetwork { + ConnectionInfoText( + label: "IP Address", + icon: "network", + text: currentIPAddress, + activeIp: appState.activeMacIp + ) + } + if appState.isPlus && appState.adbConnected { ConnectionInfoText( label: "ADB Connection", From e1fa02abd2b7c4b3fde97932f31cbc91f4bed327 Mon Sep 17 00:00:00 2001 From: tornado-bunk Date: Sun, 15 Mar 2026 17:24:09 +0100 Subject: [PATCH 03/17] Remove custom HMAC auth, use v3.0.0 native encryption --- .../WebSocket/WebSocketServer+Handlers.swift | 6 - .../Core/WebSocket/WebSocketServer.swift | 140 +----------------- airsync-mac/Model/Message.swift | 4 - 3 files changed, 1 insertion(+), 149 deletions(-) diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index 6770e024..c0694afb 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -67,9 +67,6 @@ extension WebSocketServer { case .volumeControl, .macVolume, .toggleAppNotif, .browseLs, .wakeUpRequest, .macMediaControlResponse, .macInfo, .callControl: // Outgoing or unexpected messages break - case .authChallenge, .authResponse, .authResult: - // Handled in WebSocketServer.swift auth layer before this switch - break } } @@ -123,9 +120,6 @@ extension WebSocketServer { break case .volumeControl, .macVolume, .toggleAppNotif, .browseLs, .wakeUpRequest, .macMediaControlResponse, .callControl, .notificationAction: break - case .authChallenge, .authResponse, .authResult: - // Handled upstream in LAN auth layer; ignore in relay-only context - break } } diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index d7b97f52..ece75c0b 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -51,10 +51,6 @@ class WebSocketServer: ObservableObject { internal var lastKnownAdapters: [(name: String, address: String)] = [] internal var lastLoggedSelectedAdapter: (name: String, address: String)? = nil - // LAN authentication: tracks which sessions have completed HMAC challenge-response - internal var authenticatedSessions: Set = [] - internal var pendingChallenges: [ObjectIdentifier: Data] = [:] - init() { loadOrGenerateSymmetricKey() setupWebSocket(for: server) @@ -212,8 +208,6 @@ class WebSocketServer: ObservableObject { activeSessions.removeAll() incomingReceivedChunks.removeAll() primarySessionID = nil - authenticatedSessions.removeAll() - pendingChallenges.removeAll() resetReplayGuard() stopPing() lock.unlock() @@ -255,18 +249,7 @@ class WebSocketServer: ObservableObject { let message = try JSONDecoder().decode(Message.self, from: data) self.lock.lock() self.lastActivity[sessionId] = Date() - let isAuthenticated = self.authenticatedSessions.contains(sessionId) self.lock.unlock() - - // Gate: only authResponse is allowed before authentication completes - if !isAuthenticated { - if message.type == .authResponse { - self.handleAuthResponse(message, session: session) - } else { - print("[websocket] Dropping message type=\(message.type.rawValue) from unauthenticated session \(sessionId)") - } - return - } if message.type == .fileChunk || message.type == .fileChunkAck || message.type == .fileTransferComplete || message.type == .fileTransferInit { self.handleMessage(message, session: session) @@ -291,7 +274,7 @@ class WebSocketServer: ObservableObject { self.activeSessions.append(session) let sessionCount = self.activeSessions.count self.lock.unlock() - print("[websocket] Session \(sessionId) connected, sending auth challenge.") + print("[websocket] Session \(sessionId) connected.") if self.primarySessionID == nil { self.primarySessionID = sessionId @@ -301,9 +284,6 @@ class WebSocketServer: ObservableObject { MacRemoteManager.shared.startVolumeMonitoring() self.startPing() } - - // Send HMAC challenge for LAN authentication - self.sendAuthChallenge(to: session) }, disconnected: { [weak self] session in guard let self = self else { return } @@ -313,8 +293,6 @@ class WebSocketServer: ObservableObject { let sessionCount = self.activeSessions.count let wasPrimary = (sessionId == self.primarySessionID) if wasPrimary { self.primarySessionID = nil } - self.authenticatedSessions.remove(sessionId) - self.pendingChallenges.removeValue(forKey: sessionId) self.lock.unlock() if sessionCount == 0 { @@ -536,122 +514,6 @@ class WebSocketServer: ObservableObject { } } - // MARK: - LAN Authentication (HMAC Challenge-Response) - - /// Generates a random 32-byte nonce and sends it as an authChallenge to the connecting session. - func sendAuthChallenge(to session: WebSocketSession) { - var nonceBytes = [UInt8](repeating: 0, count: 32) - _ = SecRandomCopyBytes(kSecRandomDefault, nonceBytes.count, &nonceBytes) - let nonceData = Data(nonceBytes) - let nonceHex = nonceData.map { String(format: "%02x", $0) }.joined() - - let sessionId = ObjectIdentifier(session) - lock.lock() - pendingChallenges[sessionId] = nonceData - lock.unlock() - - let challengeJson = """ - {"type":"authChallenge","data":{"nonce":"\(nonceHex)"}} - """ - - if let key = symmetricKey, let encrypted = encryptMessage(challengeJson, using: key) { - session.writeText(encrypted) - } else { - session.writeText(challengeJson) - } - print("[auth] Sent challenge to session \(sessionId)") - - // Auto-close session after 10 seconds if not authenticated - DispatchQueue.global().asyncAfter(deadline: .now() + 10.0) { [weak self] in - guard let self = self else { return } - self.lock.lock() - let stillPending = self.pendingChallenges[sessionId] != nil - let notAuthenticated = !self.authenticatedSessions.contains(sessionId) - self.lock.unlock() - if stillPending && notAuthenticated { - print("[auth] Session \(sessionId) failed to authenticate in time, closing.") - session.writeBinary([UInt8]()) // triggers disconnect - } - } - } - - /// Verifies the authResponse HMAC from the client using constant-time comparison. - func handleAuthResponse(_ message: Message, session: WebSocketSession) { - let sessionId = ObjectIdentifier(session) - - guard let dict = message.data.value as? [String: Any], - let hmacHex = dict["hmac"] as? String else { - print("[auth] Invalid authResponse from \(sessionId)") - sendAuthResult(to: session, success: false, reason: "Invalid response format") - return - } - - lock.lock() - guard let nonceData = pendingChallenges.removeValue(forKey: sessionId) else { - lock.unlock() - print("[auth] No pending challenge for \(sessionId)") - sendAuthResult(to: session, success: false, reason: "No pending challenge") - return - } - lock.unlock() - - guard let key = symmetricKey else { - print("[auth] No symmetric key available for verification") - sendAuthResult(to: session, success: false, reason: "Server configuration error") - return - } - - // Compute expected HMAC-SHA256(symmetricKey, nonce) - let hmacKey = SymmetricKey(data: key.withUnsafeBytes { Data($0) }) - let expectedHMAC = HMAC.authenticationCode(for: nonceData, using: hmacKey) - let expectedHex = Data(expectedHMAC).map { String(format: "%02x", $0) }.joined() - - // Constant-time comparison - let receivedBytes = Array(hmacHex.utf8) - let expectedBytes = Array(expectedHex.utf8) - let lengthMatch = receivedBytes.count == expectedBytes.count - var diff: UInt8 = 0 - let count = min(receivedBytes.count, expectedBytes.count) - for i in 0.. Date: Sun, 15 Mar 2026 19:24:13 +0100 Subject: [PATCH 04/17] feat: Enhance WebSocket server and AirBridge connection handling - Added support for ping and pong message types in MessageType enum. - Updated WebSocketServer to handle ping/pong messages for keepalive. - Refactored server start logic to simplify connection handling. - Ensured AirBridge auto-connects upon enabling in settings. - Improved credential management and default relay URL setup in AirBridge settings. --- .../WebSocket/WebSocketServer+Handlers.swift | 20 ++++-- .../Core/WebSocket/WebSocketServer.swift | 71 ++++++------------- airsync-mac/Model/Message.swift | 3 + .../OnboardingView/AirBridgeSetupView.swift | 1 + .../Settings/AirBridgeSettingsView.swift | 20 ++++-- 5 files changed, 54 insertions(+), 61 deletions(-) diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index c0694afb..4dabd87d 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -64,7 +64,7 @@ extension WebSocketServer { handleRemoteControl(message) case .browseData: handleBrowseData(message) - case .volumeControl, .macVolume, .toggleAppNotif, .browseLs, .wakeUpRequest, .macMediaControlResponse, .macInfo, .callControl: + case .volumeControl, .macVolume, .toggleAppNotif, .browseLs, .wakeUpRequest, .macMediaControlResponse, .macInfo, .callControl, .ping, .pong: // Outgoing or unexpected messages break } @@ -118,7 +118,7 @@ extension WebSocketServer { case .macInfo: // outbound from Mac -> Android in normal flow, ignore inbound break - case .volumeControl, .macVolume, .toggleAppNotif, .browseLs, .wakeUpRequest, .macMediaControlResponse, .callControl, .notificationAction: + case .volumeControl, .macVolume, .toggleAppNotif, .browseLs, .wakeUpRequest, .macMediaControlResponse, .callControl, .notificationAction, .ping, .pong: break } } @@ -431,7 +431,7 @@ extension WebSocketServer { let chunkBase64 = dict["chunk"] as? String { self.lock.lock() - let io = incomingFiles[id] + var io = incomingFiles[id] let alreadyReceived = incomingReceivedChunks[id]?.contains(index) == true if !alreadyReceived { var received = incomingReceivedChunks[id] ?? [] @@ -440,7 +440,7 @@ extension WebSocketServer { } self.lock.unlock() - if let io = io, let data = Data(base64Encoded: chunkBase64, options: .ignoreUnknownCharacters) { + if var io = io, let data = Data(base64Encoded: chunkBase64, options: .ignoreUnknownCharacters) { fileQueue.async { let offset = UInt64(index * io.chunkSize) if let fh = io.fileHandle { @@ -751,9 +751,17 @@ extension WebSocketServer { self.lock.lock() self.incomingReceivedChunks.removeValue(forKey: id) self.lock.unlock() - DispatchQueue.main.async { - AppState.shared.stopTransferRemote(id: id) + + // AppState.stopTransferRemote(id:) does not exist in v3.0.0. + // The logic to clean up file handles is sufficient here as handles are managed in incomingFiles map. + // We just ensure we remove the entry from our tracking. + self.lock.lock() + if let io = self.incomingFiles[id] { + io.fileHandle?.closeFile() + try? FileManager.default.removeItem(at: io.tempUrl) + self.incomingFiles.removeValue(forKey: id) } + self.lock.unlock() } } diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index ece75c0b..d12d849a 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -45,8 +45,6 @@ class WebSocketServer: ObservableObject { internal let maxChunkRetries = 3 internal let ackWaitMs: UInt16 = 2000 - internal let lifecycleQueue = DispatchQueue(label: "com.airsync.websocket.lifecycle", qos: .userInitiated) - internal var pendingRestartWorkItem: DispatchWorkItem? internal var lastKnownAdapters: [(name: String, address: String)] = [] internal var lastLoggedSelectedAdapter: (name: String, address: String)? = nil @@ -73,7 +71,7 @@ class WebSocketServer: ObservableObject { let adapterName = AppState.shared.selectedNetworkAdapterName let adapters = getAvailableNetworkAdapters() - lifecycleQueue.async { [weak self] in + DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self = self else { return } do { @@ -90,7 +88,7 @@ class WebSocketServer: ObservableObject { self.isListeningOnAll = false let server = HttpServer() self.setupWebSocket(for: server) - try self.startServerWithRetry(server, port: port) + try server.start(in_port_t(port)) self.servers[specificAdapter] = server let ip = self.getLocalIPAddress(adapterName: specificAdapter) @@ -109,7 +107,7 @@ class WebSocketServer: ObservableObject { let server = HttpServer() self.setupWebSocket(for: server) if !startedAny { - try self.startServerWithRetry(server, port: port) + try server.start(in_port_t(port)) self.servers["any"] = server startedAny = true } @@ -146,43 +144,9 @@ class WebSocketServer: ObservableObject { servers.removeAll() } - private func startServerWithRetry(_ server: HttpServer, port: UInt16, maxAttempts: Int = 3) throws { - var lastError: Error? - for attempt in 1...maxAttempts { - do { - try server.start(in_port_t(port)) - if attempt > 1 { - print("[websocket] Bind succeeded on attempt \(attempt)/\(maxAttempts) for port \(port)") - } - return - } catch { - lastError = error - let lowered = String(describing: error).lowercased() - let isAddressInUse = lowered.contains("address already in use") - || lowered.contains("port in use") - || lowered.contains("bind") - || lowered.contains("in use") - - if !isAddressInUse || attempt == maxAttempts { - throw error - } - - // A rapid restart may race the previous listener teardown. - // Back off briefly and retry binding. - let delaySeconds = Double(200 * attempt) / 1000.0 - print("[websocket] Port \(port) busy, retrying bind in \(Int(delaySeconds * 1000))ms (attempt \(attempt + 1)/\(maxAttempts))") - Thread.sleep(forTimeInterval: delaySeconds) - } - } - - if let lastError { - throw lastError - } - } - func requestRestart(reason: String, delay: TimeInterval = 0.35, port: UInt16? = nil) { lock.lock() - pendingRestartWorkItem?.cancel() + // No more pendingRestartWorkItem cleanup since we removed the property let restartPort = port ?? localPort ?? Defaults.serverPort lock.unlock() @@ -192,18 +156,14 @@ class WebSocketServer: ObservableObject { self.stop() self.start(port: restartPort) } - - lock.lock() - pendingRestartWorkItem = workItem - lock.unlock() - + + // Simply dispatch the restart, no complex cancellation of pending items DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } func stop() { lock.lock() - pendingRestartWorkItem?.cancel() - pendingRestartWorkItem = nil + // Removed pendingRestartWorkItem cleanup stopAllServers() activeSessions.removeAll() incomingReceivedChunks.removeAll() @@ -326,8 +286,15 @@ class WebSocketServer: ObservableObject { if let dec = decryptMessage(text, using: key), !dec.isEmpty { decryptedText = dec } else { - print("[transport] RX via RELAY dropped: decrypt failed or empty payload (len=\(text.count))") - return + // Fallback: If decryption fails, check if it's valid plaintext JSON. + // This handles cases where keys are out of sync or the client sends plaintext via the secure relay tunnel. + if text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") { + print("[transport] RX via RELAY: Decryption failed, attempting plaintext fallback.") + decryptedText = text + } else { + print("[transport] RX via RELAY dropped: decrypt failed or empty payload (len=\(text.count))") + return + } } } else { // In normal operation this should not happen; relay payloads are expected encrypted. @@ -343,6 +310,12 @@ class WebSocketServer: ObservableObject { do { let message = try JSONDecoder().decode(Message.self, from: data) + // Handle Pong for AirBridge keepalive + if message.type == .pong { + AirBridgeClient.shared.processPong() + return + } + // File transfer messages are handled on background queue (like local messages) if message.type == .fileChunk || message.type == .fileChunkAck || message.type == .fileTransferComplete || message.type == .fileTransferInit { diff --git a/airsync-mac/Model/Message.swift b/airsync-mac/Model/Message.swift index 4d0c4619..0d406901 100644 --- a/airsync-mac/Model/Message.swift +++ b/airsync-mac/Model/Message.swift @@ -40,6 +40,9 @@ enum MessageType: String, Codable { // file browser case browseLs case browseData + // relay keepalive + case ping + case pong } struct Message: Codable { diff --git a/airsync-mac/Screens/OnboardingView/AirBridgeSetupView.swift b/airsync-mac/Screens/OnboardingView/AirBridgeSetupView.swift index 4a8fff6e..16cd41db 100644 --- a/airsync-mac/Screens/OnboardingView/AirBridgeSetupView.swift +++ b/airsync-mac/Screens/OnboardingView/AirBridgeSetupView.swift @@ -164,6 +164,7 @@ struct AirBridgeSetupView: View { switch result { case .success: saveCredentials() + AirBridgeClient.shared.connect() onNext() case .failure(let error): testError = error.localizedDescription diff --git a/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift b/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift index 1522859d..7c80296f 100644 --- a/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift +++ b/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift @@ -142,13 +142,21 @@ struct AirBridgeSettingsView: View { } .onChange(of: appState.airBridgeEnabled) { enabled in if enabled { - // Generate credentials in memory only — Keychain is written on Save & Reconnect - if pairingId.isEmpty { - pairingId = AirBridgeClient.generateShortId() - } - if secret.isEmpty { - secret = AirBridgeClient.generateRandomSecret() + // Ensure default URL if missing + if airBridge.relayServerURL.isEmpty { + airBridge.relayServerURL = "wss://airbridge.tornado.ovh/ws" } + + // Ensure credentials exist (generates and saves if missing) + airBridge.ensureCredentialsExist() + + // Sync view state with the (possibly newly generated) credentials + loadCredentials() + + // Auto-connect immediately so the QR code is live + airBridge.connect() + } else { + airBridge.disconnect() } } } From f947043149a37e02eca17358737158c6269da729 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Sun, 15 Mar 2026 21:38:56 +0100 Subject: [PATCH 05/17] feat: Implement ping mechanism and connection state management in AirBridgeClient refactor: Update import statements to internal for Combine in multiple files --- .../Core/AirBridge/AirBridgeClient.swift | 49 +++++++++++++++++++ airsync-mac/Core/AppState.swift | 2 +- .../Core/Discovery/UDPDiscoveryManager.swift | 33 ++++++++++++- airsync-mac/Core/MenuBarManager.swift | 2 +- .../QuickConnect/QuickConnectManager.swift | 2 +- .../Core/QuickShare/QuickShareManager.swift | 3 +- airsync-mac/Core/Trial/TrialManager.swift | 2 +- .../Util/MacInfo/MacInfoSyncManager.swift | 2 +- .../Core/Util/Remote/MacRemoteManager.swift | 2 +- .../Core/WebSocket/WebSocketServer.swift | 18 ++++++- airsync-mac/Localization/Localizer.swift | 2 +- .../HomeScreen/PhoneView/TimeView.swift | 2 +- .../Components/TrialActivationSheet.swift | 2 +- .../Settings/RemotePermissionView.swift | 2 +- .../Screens/Updater/CheckForUpdateView.swift | 2 +- 15 files changed, 109 insertions(+), 16 deletions(-) diff --git a/airsync-mac/Core/AirBridge/AirBridgeClient.swift b/airsync-mac/Core/AirBridge/AirBridgeClient.swift index b50fd8f5..3910e902 100644 --- a/airsync-mac/Core/AirBridge/AirBridgeClient.swift +++ b/airsync-mac/Core/AirBridge/AirBridgeClient.swift @@ -18,6 +18,13 @@ class AirBridgeClient: ObservableObject { // MARK: - Published State @Published var connectionState: AirBridgeConnectionState = .disconnected + @Published var isPeerConnected: Bool = false + + // Ping mechanism + private var pingTimer: Timer? + private var lastPongReceived: Date = .distantPast + private let pingInterval: TimeInterval = 8.0 + private let peerTimeout: TimeInterval = 20.0 // MARK: - Configuration // @@ -431,6 +438,7 @@ class AirBridgeClient: ObservableObject { print("[airbridge] Relay tunnel established!") DispatchQueue.main.async { self.connectionState = .relayActive + self.startPingLoop() } return @@ -464,6 +472,39 @@ class AirBridgeClient: ObservableObject { WebSocketServer.shared.handleRelayedBinaryMessage(data) } + private func startPingLoop() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.pingTimer?.invalidate() + self.lastPongReceived = Date() // Assume alive on start + + self.pingTimer = Timer.scheduledTimer(withTimeInterval: self.pingInterval, repeats: true) { [weak self] _ in + guard let self = self else { return } + + // 1. Check for timeout + let timeSinceLastPong = Date().timeIntervalSince(self.lastPongReceived) + if self.isPeerConnected && timeSinceLastPong > self.peerTimeout { + print("[airbridge] Peer ping timeout (\(Int(timeSinceLastPong))s > \(Int(self.peerTimeout))s). Marking disconnected.") + self.isPeerConnected = false + } + + // 2. Send Ping + let pingJson = "{\"type\":\"ping\"}" + self.sendText(pingJson) + } + } + } + + func processPong() { + DispatchQueue.main.async { + if !self.isPeerConnected { + print("[airbridge] Peer connected via relay (pong received).") + } + self.lastPongReceived = Date() + self.isPeerConnected = true + } + } + // MARK: - Reconnect private func scheduleReconnect() { @@ -491,6 +532,14 @@ class AirBridgeClient: ObservableObject { webSocketTask = nil urlSession?.invalidateAndCancel() urlSession = nil + + // Clean up ping timer + DispatchQueue.main.async { [weak self] in + self?.pingTimer?.invalidate() + self?.pingTimer = nil + self?.isPeerConnected = false + } + print("[airbridge] Torn down: \(reason)") } diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 34ae9dc9..f3d6713a 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -7,7 +7,7 @@ import SwiftUI import Foundation import Cocoa -import Combine +internal import Combine import UserNotifications import AVFoundation diff --git a/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift b/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift index 431f3a45..c8775345 100644 --- a/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift +++ b/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift @@ -1,6 +1,6 @@ import Foundation import Network -import Combine +internal import Combine import SwiftUI struct DiscoveredDevice: Identifiable, Equatable, Hashable { @@ -96,8 +96,37 @@ class UDPDiscoveryManager: ObservableObject { } @objc private func handleSystemWake() { - print("[Discovery] System wake detected") + print("[Discovery] System wake detected. Initiating recovery sequence...") + + // 1. Immediate burst (might fail if network not ready, but harmless) broadcastBurst() + + // 2. Schedule a series of recovery actions to catch the network as it comes up + // Wi-Fi usually takes 2-5 seconds to reconnect after sleep. + + // T+2s: Force WebSocket Server to re-evaluate network binding + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + print("[Discovery] Wake recovery: Requesting WebSocket restart...") + WebSocketServer.shared.requestRestart(reason: "System Wake Recovery", delay: 0.1) + } + + // T+3s: Burst 1 (Post-restart) + DispatchQueue.global().asyncAfter(deadline: .now() + 3.0) { [weak self] in + print("[Discovery] Wake recovery: Burst 1") + self?.broadcastBurst() + } + + // T+6s: Burst 2 (Retry) + DispatchQueue.global().asyncAfter(deadline: .now() + 6.0) { [weak self] in + print("[Discovery] Wake recovery: Burst 2") + self?.broadcastBurst() + } + + // T+10s: Burst 3 (Final retry) + DispatchQueue.global().asyncAfter(deadline: .now() + 10.0) { [weak self] in + print("[Discovery] Wake recovery: Burst 3") + self?.broadcastBurst() + } } // MARK: - Broadcasting diff --git a/airsync-mac/Core/MenuBarManager.swift b/airsync-mac/Core/MenuBarManager.swift index 621539f0..3c9e6302 100644 --- a/airsync-mac/Core/MenuBarManager.swift +++ b/airsync-mac/Core/MenuBarManager.swift @@ -7,7 +7,7 @@ import SwiftUI import AppKit -import Combine +internal import Combine class MenuBarManager: NSObject { static let shared = MenuBarManager() diff --git a/airsync-mac/Core/QuickConnect/QuickConnectManager.swift b/airsync-mac/Core/QuickConnect/QuickConnectManager.swift index 9a6c842c..d0080852 100644 --- a/airsync-mac/Core/QuickConnect/QuickConnectManager.swift +++ b/airsync-mac/Core/QuickConnect/QuickConnectManager.swift @@ -6,7 +6,7 @@ // import Foundation -import Combine +internal import Combine /// Manages quick reconnection functionality for previously connected devices class QuickConnectManager: ObservableObject { diff --git a/airsync-mac/Core/QuickShare/QuickShareManager.swift b/airsync-mac/Core/QuickShare/QuickShareManager.swift index 22619c1e..f5085e2a 100644 --- a/airsync-mac/Core/QuickShare/QuickShareManager.swift +++ b/airsync-mac/Core/QuickShare/QuickShareManager.swift @@ -6,7 +6,7 @@ import Foundation import SwiftUI import UserNotifications -@preconcurrency import Combine +public import Combine struct QuickShareTransferInfo { let device: RemoteDeviceInfo @@ -340,3 +340,4 @@ public class QuickShareManager: NSObject, ObservableObject, MainAppDelegate, Sha } } } + diff --git a/airsync-mac/Core/Trial/TrialManager.swift b/airsync-mac/Core/Trial/TrialManager.swift index 444f29de..78de5c6b 100644 --- a/airsync-mac/Core/Trial/TrialManager.swift +++ b/airsync-mac/Core/Trial/TrialManager.swift @@ -1,6 +1,6 @@ import Foundation import SwiftUI -import Combine +internal import Combine @MainActor final class TrialManager: ObservableObject { diff --git a/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift b/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift index 835bec1c..9f0d227f 100644 --- a/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift +++ b/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift @@ -5,7 +5,7 @@ // Created by Sameera Sandakelum on 2025-09-17. // import Foundation -import Combine +internal import Combine import CryptoKit class MacInfoSyncManager: ObservableObject { diff --git a/airsync-mac/Core/Util/Remote/MacRemoteManager.swift b/airsync-mac/Core/Util/Remote/MacRemoteManager.swift index 4cbc1f44..4f8fe37b 100644 --- a/airsync-mac/Core/Util/Remote/MacRemoteManager.swift +++ b/airsync-mac/Core/Util/Remote/MacRemoteManager.swift @@ -10,7 +10,7 @@ import Cocoa import Carbon import AudioToolbox import CoreGraphics -import Combine +internal import Combine class MacRemoteManager: ObservableObject { static let shared = MacRemoteManager() diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index d12d849a..ac1bd5d9 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -9,7 +9,7 @@ import Foundation import Swifter import CryptoKit import UserNotifications -import Combine +internal import Combine class WebSocketServer: ObservableObject { static let shared = WebSocketServer() @@ -421,12 +421,26 @@ class WebSocketServer: ObservableObject { let isPlusSubscription = AppState.shared.isPlus let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.0.0" + // Enhanced device info matching standard LAN handshake + let modelId = DeviceTypeUtil.modelIdentifier() + let categoryTypeRaw = DeviceTypeUtil.deviceTypeDescription() + let exactDeviceNameRaw = DeviceTypeUtil.deviceFullDescription() + let categoryType = categoryTypeRaw.isEmpty ? "Mac" : categoryTypeRaw + let exactDeviceName = exactDeviceNameRaw.isEmpty ? categoryType : exactDeviceNameRaw + let savedAppPackages = Array(AppState.shared.androidApps.keys) + let messageDict: [String: Any] = [ "type": "macInfo", "data": [ "name": macName, "isPlus": isPlusSubscription, - "version": appVersion + "isPlusSubscription": isPlusSubscription, // Essential for Android check + "version": appVersion, + "model": modelId, + "type": categoryType, + "categoryType": categoryType, + "exactDeviceName": exactDeviceName, + "savedAppPackages": savedAppPackages ] ] diff --git a/airsync-mac/Localization/Localizer.swift b/airsync-mac/Localization/Localizer.swift index b2513b91..f589f319 100644 --- a/airsync-mac/Localization/Localizer.swift +++ b/airsync-mac/Localization/Localizer.swift @@ -1,6 +1,6 @@ import Foundation import SwiftUI -import Combine +internal import Combine /// Simple JSON-based localization loader. /// Loads `en.json` as base and overlays with current locale file if available. diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/TimeView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/TimeView.swift index 3ef25219..6feebeb6 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/TimeView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/TimeView.swift @@ -6,7 +6,7 @@ // import SwiftUI -import Combine +internal import Combine import CoreText // MARK: - CoreText -> Path helper (macOS-safe) diff --git a/airsync-mac/Screens/Settings/Components/TrialActivationSheet.swift b/airsync-mac/Screens/Settings/Components/TrialActivationSheet.swift index ae0267e8..745c8182 100644 --- a/airsync-mac/Screens/Settings/Components/TrialActivationSheet.swift +++ b/airsync-mac/Screens/Settings/Components/TrialActivationSheet.swift @@ -1,5 +1,5 @@ import SwiftUI -import Combine +internal import Combine struct TrialActivationSheet: View { @ObservedObject var manager: TrialManager diff --git a/airsync-mac/Screens/Settings/RemotePermissionView.swift b/airsync-mac/Screens/Settings/RemotePermissionView.swift index 7772a308..5b6d940c 100644 --- a/airsync-mac/Screens/Settings/RemotePermissionView.swift +++ b/airsync-mac/Screens/Settings/RemotePermissionView.swift @@ -6,7 +6,7 @@ // import SwiftUI -import Combine +internal import Combine struct RemotePermissionView: View { @Environment(\.dismiss) var dismiss diff --git a/airsync-mac/Screens/Updater/CheckForUpdateView.swift b/airsync-mac/Screens/Updater/CheckForUpdateView.swift index 67426174..c10b3ddd 100644 --- a/airsync-mac/Screens/Updater/CheckForUpdateView.swift +++ b/airsync-mac/Screens/Updater/CheckForUpdateView.swift @@ -7,7 +7,7 @@ import SwiftUI import Sparkle -import Combine +internal import Combine final class CheckForUpdatesViewModel: ObservableObject { @Published var canCheckForUpdates = false From 230bd08d7c204a1ac3a29d074d6316a1e720f2d2 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Sun, 15 Mar 2026 23:46:13 +0100 Subject: [PATCH 06/17] refactor: Update import statements to standardize Combine imports across multiple files and improve code clarity - Adjusted Combine import visibility from internal to standard in various files. - Refined comments for clarity and consistency in Keychain handling and connection management. - Updated default relay URL in settings for better user guidance. --- .../Core/AirBridge/AirBridgeClient.swift | 75 +++---------------- .../Core/AirBridge/AirBridgeModels.swift | 5 +- airsync-mac/Core/AppState.swift | 12 ++- .../Core/Discovery/UDPDiscoveryManager.swift | 7 +- airsync-mac/Core/MenuBarManager.swift | 2 +- .../QuickConnect/QuickConnectManager.swift | 2 +- .../Core/QuickShare/QuickShareManager.swift | 3 +- airsync-mac/Core/SentryInitializer.swift | 2 +- airsync-mac/Core/Trial/TrialManager.swift | 27 +++---- airsync-mac/Core/Util/Crypto/CryptoUtil.swift | 53 ------------- .../Util/MacInfo/MacInfoSyncManager.swift | 2 +- .../Core/Util/Remote/MacRemoteManager.swift | 2 +- .../WebSocket/WebSocketServer+Handlers.swift | 44 ++--------- .../Core/WebSocket/WebSocketServer.swift | 63 ++++------------ airsync-mac/Localization/Localizer.swift | 2 +- .../HomeScreen/PhoneView/TimeView.swift | 2 +- .../OnboardingView/AirBridgeSetupView.swift | 4 +- .../Settings/AirBridgeSettingsView.swift | 4 +- .../Components/TrialActivationSheet.swift | 2 +- .../Settings/RemotePermissionView.swift | 2 +- .../Screens/Updater/CheckForUpdateView.swift | 2 +- airsync-mac/airsync_macApp.swift | 3 +- 22 files changed, 68 insertions(+), 252 deletions(-) diff --git a/airsync-mac/Core/AirBridge/AirBridgeClient.swift b/airsync-mac/Core/AirBridge/AirBridgeClient.swift index 3910e902..f54cf730 100644 --- a/airsync-mac/Core/AirBridge/AirBridgeClient.swift +++ b/airsync-mac/Core/AirBridge/AirBridgeClient.swift @@ -2,14 +2,14 @@ // AirBridgeClient.swift // airsync-mac // -// Created by AI Assistant. +// Created by tornado-bunk and an AI Assistant. // WebSocket client that connects to a self-hosted AirBridge relay server. // When a direct LAN connection is unavailable, messages are tunneled through // the relay to reach the Android device. // import Foundation -internal import Combine +import Combine import CryptoKit class AirBridgeClient: ObservableObject { @@ -28,28 +28,16 @@ class AirBridgeClient: ObservableObject { // MARK: - Configuration // - // With ad-hoc ("Sign to Run Locally") code signing, every Keychain call - // triggers a macOS password prompt. Only the secret (the actual credential) - // is stored in Keychain. The relay URL and pairing ID are non-sensitive - // config and live in UserDefaults (zero prompts). - // // The secret is cached in memory after the first Keychain read so that // subsequent accesses never hit the Keychain again. private static let keychainKeySecret = "airBridgeSecret" - // Legacy keys — used only for one-time migration, then deleted. - private static let legacyKeyRelayURL = "airBridgeRelayURL" - private static let legacyKeyPairingId = "airBridgePairingId" - private static let legacyKeySecret = "airBridgeSecret" // same key, kept for clarity - private static let legacyKeyConfig = "airBridgeConfig" // consolidated blob (if any) - - // In-memory cache for the secret (the only Keychain item). + // In-memory cache for the secret private var _cachedSecret: String? private var _secretLoaded = false - /// Loads the secret from Keychain **once**, including one-time migration - /// from the old 3-key or consolidated-blob layout. + /// Loads the secret from Keychain once private func loadSecretIfNeeded() { guard !_secretLoaded else { return } _secretLoaded = true @@ -57,42 +45,6 @@ class AirBridgeClient: ObservableObject { // Current key if let s = KeychainStorage.string(for: Self.keychainKeySecret) { _cachedSecret = s - - // Migrate URL/pairingId from legacy Keychain keys to UserDefaults (if present) - migrateLegacyKeysIfNeeded() - return - } - - // One-time migration from consolidated JSON blob - if let json = KeychainStorage.string(for: Self.legacyKeyConfig), - let data = json.data(using: .utf8), - let blob = try? JSONDecoder().decode(AirBridgeConfigBlob.self, from: data) { - _cachedSecret = blob.sec - // Move URL and pairingId to UserDefaults - if !blob.url.isEmpty { UserDefaults.standard.set(blob.url, forKey: "airBridgeRelayURL") } - if !blob.pid.isEmpty { UserDefaults.standard.set(blob.pid, forKey: "airBridgePairingId") } - // Write secret under its own key and remove the blob - if !blob.sec.isEmpty { KeychainStorage.set(blob.sec, for: Self.keychainKeySecret) } - KeychainStorage.delete(key: Self.legacyKeyConfig) - return - } - - migrateLegacyKeysIfNeeded() - } - - /// Migrates URL and pairingId from legacy Keychain keys to UserDefaults, - /// then deletes the legacy Keychain entries. - private func migrateLegacyKeysIfNeeded() { - // Only run once — if URL is already in UserDefaults, skip. - guard UserDefaults.standard.string(forKey: "airBridgeRelayURL") == nil else { return } - - if let oldURL = KeychainStorage.string(for: Self.legacyKeyRelayURL), !oldURL.isEmpty { - UserDefaults.standard.set(oldURL, forKey: "airBridgeRelayURL") - KeychainStorage.delete(key: Self.legacyKeyRelayURL) - } - if let oldPid = KeychainStorage.string(for: Self.legacyKeyPairingId), !oldPid.isEmpty { - UserDefaults.standard.set(oldPid, forKey: "airBridgePairingId") - KeychainStorage.delete(key: Self.legacyKeyPairingId) } } @@ -112,7 +64,6 @@ class AirBridgeClient: ObservableObject { } /// Batch-update all three credentials. Only the secret write touches Keychain - /// (one password prompt). URL and pairingId go to UserDefaults (zero prompts). func saveAllCredentials(url: String, pairingId: String, secret: String) { UserDefaults.standard.set(url, forKey: "airBridgeRelayURL") UserDefaults.standard.set(pairingId, forKey: "airBridgePairingId") @@ -217,7 +168,7 @@ class AirBridgeClient: ObservableObject { return } - // SHA-256 hash the secret (same logic as hashedSecret(), but for arbitrary input) + // SHA-256 hash the secret let secretHash: String = { let data = Data(secret.utf8) let hash = SHA256.hash(data: data) @@ -266,8 +217,7 @@ class AirBridgeClient: ObservableObject { return } - // Send registration — the server silently accepts registrations without - // replying until a peer connects, so a successful send = server is alive. + // Send registration — the server silently accepts registrations without replying until a peer connects, so a successful send = server is alive. task.send(.string(regJSON)) { sendError in if let sendError = sendError { settle(.failure(sendError)) @@ -294,7 +244,7 @@ class AirBridgeClient: ObservableObject { } /// Regenerates pairing credentials together so an ID and secret always stay in sync. - /// PairingId goes to UserDefaults, secret to Keychain (one prompt). + /// PairingId goes to UserDefaults, secret to Keychain. func regeneratePairingCredentials() { pairingId = Self.generateShortId() let newSecret = Self.generateRandomSecret() @@ -318,7 +268,7 @@ class AirBridgeClient: ObservableObject { return } - // Ensure credentials exist before connecting (lazy generation) + // Ensure credentials exist before connecting ensureCredentialsExist() // Normalize URL: ensure it ends with /ws and has wss:// or ws:// prefix @@ -467,9 +417,8 @@ class AirBridgeClient: ObservableObject { } private func handleBinaryMessage(_ data: Data) { - // Binary data from the relay = raw encrypted payload from Android - print("[airbridge] Relaying binary message from Android (\(data.count) bytes)") - WebSocketServer.shared.handleRelayedBinaryMessage(data) + // Binary data from the relay is currently unused in the AirSync protocol + print("[airbridge] Received binary message from Android (\(data.count) bytes) - Ignored") } private func startPingLoop() { @@ -558,7 +507,7 @@ class AirBridgeClient: ObservableObject { let isPrivate = isPrivateHost(host) - // SECURITY: If user explicitly provided ws://, only allow it for private/localhost hosts. + // If user explicitly provided ws://, only allow it for private/localhost hosts. // Upgrade to wss:// for public hosts to prevent cleartext transport over the internet. if url.hasPrefix("ws://") && !url.hasPrefix("wss://") && !isPrivate { print("[airbridge] SECURITY: Upgrading ws:// to wss:// for public host: \(host)") @@ -600,7 +549,7 @@ class AirBridgeClient: ObservableObject { return false } - /// Generates a 32-char lowercase hex ID (128-bit entropy, e.g. "a3f8b2c1e9d0471f8a2b3c4d5e6f7890") + /// Generates a 32-char lowercase hex ID (128-bit entropy) static func generateShortId() -> String { var bytes = [UInt8](repeating: 0, count: 16) // 16 bytes = 128 bits _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) diff --git a/airsync-mac/Core/AirBridge/AirBridgeModels.swift b/airsync-mac/Core/AirBridge/AirBridgeModels.swift index d4ac8b72..20defdc4 100644 --- a/airsync-mac/Core/AirBridge/AirBridgeModels.swift +++ b/airsync-mac/Core/AirBridge/AirBridgeModels.swift @@ -2,7 +2,7 @@ // AirBridgeModels.swift // airsync-mac // -// Created by AI Assistant. +// Created by tornado-bunk and an AI Assistant. // import Foundation @@ -47,8 +47,7 @@ struct AirBridgeErrorMessage: Codable { // MARK: - Keychain Config Blob (consolidated storage) -/// All AirBridge credentials stored as a single Keychain entry to minimise -/// password prompts under ad-hoc code signing. +/// All AirBridge credentials stored as a single Keychain entry to minimise password prompts struct AirBridgeConfigBlob: Codable { let url: String let pid: String diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index f3d6713a..4e298666 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -7,7 +7,7 @@ import SwiftUI import Foundation import Cocoa -internal import Combine +import Combine import UserNotifications import AVFoundation @@ -27,9 +27,7 @@ class AppState: ObservableObject { @Published var isOS26: Bool = true init() { - // Batch-load all Keychain items up front (single password prompt) - // before any subsystem (WebSocketServer, AirBridge, Trial) tries - // to read individual keys and triggers multiple prompts. + // Load all Keychain items up front before any subsystem tries to read individual keys and triggers multiple prompts. KeychainStorage.preload() self.isPlus = false @@ -197,7 +195,7 @@ class AppState: ObservableObject { @Published var recentApps: [AndroidApp] = [] var isConnectedOverLocalNetwork: Bool { - // Check if we have a direct LAN WebSocket session (true local connection). + // Check if we have a direct LAN WebSocket session // Falls back to false when only the AirBridge relay tunnel is active. return WebSocketServer.shared.hasActiveLocalSession() } @@ -379,8 +377,8 @@ class AppState: ObservableObject { didSet { UserDefaults.standard.set(airBridgeEnabled, forKey: "airBridgeEnabled") // Connection is managed explicitly: - // - Onboarding: connects after "Continue" (test passes + credentials saved) - // - Settings: connects on "Save & Reconnect" + // Onboarding: connects after "Continue" + // Settings: connects on "Save & Reconnect" // We only auto-disconnect here when the user turns AirBridge off. if !airBridgeEnabled { AirBridgeClient.shared.disconnect() diff --git a/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift b/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift index c8775345..26b0a9ae 100644 --- a/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift +++ b/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift @@ -1,6 +1,6 @@ import Foundation import Network -internal import Combine +import Combine import SwiftUI struct DiscoveredDevice: Identifiable, Equatable, Hashable { @@ -96,13 +96,12 @@ class UDPDiscoveryManager: ObservableObject { } @objc private func handleSystemWake() { - print("[Discovery] System wake detected. Initiating recovery sequence...") + print("[Discovery] System wake detected.") - // 1. Immediate burst (might fail if network not ready, but harmless) + // 1. Immediate burst broadcastBurst() // 2. Schedule a series of recovery actions to catch the network as it comes up - // Wi-Fi usually takes 2-5 seconds to reconnect after sleep. // T+2s: Force WebSocket Server to re-evaluate network binding DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { diff --git a/airsync-mac/Core/MenuBarManager.swift b/airsync-mac/Core/MenuBarManager.swift index 3c9e6302..621539f0 100644 --- a/airsync-mac/Core/MenuBarManager.swift +++ b/airsync-mac/Core/MenuBarManager.swift @@ -7,7 +7,7 @@ import SwiftUI import AppKit -internal import Combine +import Combine class MenuBarManager: NSObject { static let shared = MenuBarManager() diff --git a/airsync-mac/Core/QuickConnect/QuickConnectManager.swift b/airsync-mac/Core/QuickConnect/QuickConnectManager.swift index d0080852..9a6c842c 100644 --- a/airsync-mac/Core/QuickConnect/QuickConnectManager.swift +++ b/airsync-mac/Core/QuickConnect/QuickConnectManager.swift @@ -6,7 +6,7 @@ // import Foundation -internal import Combine +import Combine /// Manages quick reconnection functionality for previously connected devices class QuickConnectManager: ObservableObject { diff --git a/airsync-mac/Core/QuickShare/QuickShareManager.swift b/airsync-mac/Core/QuickShare/QuickShareManager.swift index f5085e2a..22619c1e 100644 --- a/airsync-mac/Core/QuickShare/QuickShareManager.swift +++ b/airsync-mac/Core/QuickShare/QuickShareManager.swift @@ -6,7 +6,7 @@ import Foundation import SwiftUI import UserNotifications -public import Combine +@preconcurrency import Combine struct QuickShareTransferInfo { let device: RemoteDeviceInfo @@ -340,4 +340,3 @@ public class QuickShareManager: NSObject, ObservableObject, MainAppDelegate, Sha } } } - diff --git a/airsync-mac/Core/SentryInitializer.swift b/airsync-mac/Core/SentryInitializer.swift index 8b52e2ba..c32f0bd2 100644 --- a/airsync-mac/Core/SentryInitializer.swift +++ b/airsync-mac/Core/SentryInitializer.swift @@ -22,7 +22,7 @@ struct SentryInitializer { options.dsn = "https://fee55efde3aba42be26a1d4365498a16@o4510996760887296.ingest.de.sentry.io/4511020717178960" options.debug = true - options.sendDefaultPii = false + options.sendDefaultPii = true } print("[SentryInitializer] Sentry initialized successfully.") } diff --git a/airsync-mac/Core/Trial/TrialManager.swift b/airsync-mac/Core/Trial/TrialManager.swift index 78de5c6b..85b115bf 100644 --- a/airsync-mac/Core/Trial/TrialManager.swift +++ b/airsync-mac/Core/Trial/TrialManager.swift @@ -1,6 +1,6 @@ import Foundation import SwiftUI -internal import Combine +import Combine @MainActor final class TrialManager: ObservableObject { @@ -319,33 +319,26 @@ final class TrialManager: ObservableObject { let key = "trial-device-identifier" let hardwareId = HardwareInfo.hardwareUUID() - // 1. Hardware UUID is available (the common path on real Macs). - // Use UserDefaults as a "have we written this to Keychain?" flag - // to avoid a Keychain write (and password prompt) on every launch. + // 1. If we have a hardware ID, prioritize it as the most stable identifier if let hwId = hardwareId { - let alreadySaved = UserDefaults.standard.trialDeviceIdentifier - if alreadySaved == hwId { - // Already persisted — no Keychain access needed. - return hwId - } - // First time or hardware changed — write once, then remember. KeychainStorage.set(hwId, for: key) UserDefaults.standard.trialDeviceIdentifier = hwId return hwId } - // 2. Fallback: try UserDefaults (no Keychain prompt) - if let stored = UserDefaults.standard.trialDeviceIdentifier, !stored.isEmpty { - return stored - } - - // 3. Fallback: existing Keychain identifier (one-time prompt) + // 2. Fallback to existing Keychain identifier if let existing = KeychainStorage.string(for: key) { UserDefaults.standard.trialDeviceIdentifier = existing return existing } - // 4. Final fallback: new random UUID + // 3. Fallback to existing UserDefaults identifier + if let stored = UserDefaults.standard.trialDeviceIdentifier, !stored.isEmpty { + KeychainStorage.set(stored, for: key) + return stored + } + + // 4. Final Fallback: New random UUID let newIdentifier = UUID().uuidString KeychainStorage.set(newIdentifier, for: key) UserDefaults.standard.trialDeviceIdentifier = newIdentifier diff --git a/airsync-mac/Core/Util/Crypto/CryptoUtil.swift b/airsync-mac/Core/Util/Crypto/CryptoUtil.swift index bab7a5c2..d6876796 100644 --- a/airsync-mac/Core/Util/Crypto/CryptoUtil.swift +++ b/airsync-mac/Core/Util/Crypto/CryptoUtil.swift @@ -8,46 +8,6 @@ import CryptoKit import SwiftUI -/// Thread-safe nonce replay cache to prevent replay attacks on AES-GCM messages. -/// Tracks recently-seen 12-byte nonces and rejects duplicates. -private class NonceReplayGuard { - static let shared = NonceReplayGuard() - - private let lock = NSLock() - private var seenNonces: Set = [] - private var nonceOrder: [Data] = [] // FIFO for eviction - private let maxEntries = 10_000 // bounded memory (~120 KB) - - /// Returns `true` if the nonce has NOT been seen before (message is fresh). - /// Returns `false` if the nonce is a duplicate (replay detected). - func checkAndRecord(_ nonce: Data) -> Bool { - lock.lock() - defer { lock.unlock() } - - if seenNonces.contains(nonce) { - return false // replay - } - - seenNonces.insert(nonce) - nonceOrder.append(nonce) - - // Evict oldest entries when cache is full - if nonceOrder.count > maxEntries { - let evict = nonceOrder.removeFirst() - seenNonces.remove(evict) - } - return true - } - - /// Clears the replay cache (e.g. on key rotation or reconnect). - func reset() { - lock.lock() - defer { lock.unlock() } - seenNonces.removeAll() - nonceOrder.removeAll() - } -} - func generateSymmetricKey() -> String { let key = SymmetricKey(size: .bits256) let keyData = key.withUnsafeBytes { Data($0) } @@ -70,14 +30,6 @@ func decryptMessage(_ base64: String, using key: SymmetricKey) -> String? { guard let combinedData = Data(base64Encoded: base64) else { return nil } do { let sealedBox = try AES.GCM.SealedBox(combined: combinedData) - - // Anti-replay: check that this nonce hasn't been used before - let nonceData = Data(sealedBox.nonce) - guard NonceReplayGuard.shared.checkAndRecord(nonceData) else { - print("[crypto-util] Replay detected: duplicate nonce, dropping message") - return nil - } - let decrypted = try AES.GCM.open(sealedBox, using: key) return String(data: decrypted, encoding: .utf8) } catch { @@ -86,11 +38,6 @@ func decryptMessage(_ base64: String, using key: SymmetricKey) -> String? { } } -/// Resets the replay nonce cache (call on key rotation or reconnect). -func resetReplayGuard() { - NonceReplayGuard.shared.reset() -} - func sha256(_ input: String) -> String { let data = Data(input.utf8) let hash = SHA256.hash(data: data) diff --git a/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift b/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift index 9f0d227f..835bec1c 100644 --- a/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift +++ b/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift @@ -5,7 +5,7 @@ // Created by Sameera Sandakelum on 2025-09-17. // import Foundation -internal import Combine +import Combine import CryptoKit class MacInfoSyncManager: ObservableObject { diff --git a/airsync-mac/Core/Util/Remote/MacRemoteManager.swift b/airsync-mac/Core/Util/Remote/MacRemoteManager.swift index 4f8fe37b..4cbc1f44 100644 --- a/airsync-mac/Core/Util/Remote/MacRemoteManager.swift +++ b/airsync-mac/Core/Util/Remote/MacRemoteManager.swift @@ -10,7 +10,7 @@ import Cocoa import Carbon import AudioToolbox import CoreGraphics -internal import Combine +import Combine class MacRemoteManager: ObservableObject { static let shared = MacRemoteManager() diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index 4dabd87d..1fc698a1 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -47,7 +47,7 @@ extension WebSocketServer { case .fileTransferInit: handleFileTransferInit(message) case .fileChunk: - handleFileChunk(message) + handleFileChunk(message, session: session) case .fileChunkAck: handleFileChunkAck(message) case .fileTransferComplete: @@ -86,18 +86,6 @@ extension WebSocketServer { handleRemoteControl(message) case .macMediaControl: handleMacMediaControlRequest(message) - case .fileTransferInit: - handleFileTransferInit(message) - case .fileChunk: - handleFileChunk(message) - case .fileTransferComplete: - handleFileTransferComplete(message) - case .fileChunkAck: - handleFileChunkAck(message) - case .transferVerified: - handleTransferVerified(message) - case .fileTransferCancel: - handleFileTransferCancel(message) case .appIcons: handleAppIcons(message) case .browseData: @@ -118,7 +106,7 @@ extension WebSocketServer { case .macInfo: // outbound from Mac -> Android in normal flow, ignore inbound break - case .volumeControl, .macVolume, .toggleAppNotif, .browseLs, .wakeUpRequest, .macMediaControlResponse, .callControl, .notificationAction, .ping, .pong: + case .volumeControl, .macVolume, .toggleAppNotif, .browseLs, .wakeUpRequest, .macMediaControlResponse, .callControl, .notificationAction, .ping, .pong, .fileTransferInit, .fileChunk, .fileChunkAck, .fileTransferComplete, .transferVerified, .fileTransferCancel: break } } @@ -414,7 +402,6 @@ extension WebSocketServer { let io = IncomingFileIO(id: id, name: name, size: size, mime: mime, tempUrl: tempFile, fileHandle: handle, chunkSize: chunkSize) self.lock.lock() incomingFiles[id] = io - incomingReceivedChunks[id] = [] if let checksum = checksum { incomingFilesChecksum[id] = checksum } @@ -424,20 +411,14 @@ extension WebSocketServer { /// Handles incoming file chunks. /// Writes data to the temporary file handle on a serial queue to ensure thread safety. - private func handleFileChunk(_ message: Message) { + private func handleFileChunk(_ message: Message, session: WebSocketSession) { if let dict = message.data.value as? [String: Any], let id = dict["id"] as? String, let index = dict["index"] as? Int, let chunkBase64 = dict["chunk"] as? String { self.lock.lock() - var io = incomingFiles[id] - let alreadyReceived = incomingReceivedChunks[id]?.contains(index) == true - if !alreadyReceived { - var received = incomingReceivedChunks[id] ?? [] - received.insert(index) - incomingReceivedChunks[id] = received - } + let io = incomingFiles[id] self.lock.unlock() if var io = io, let data = Data(base64Encoded: chunkBase64, options: .ignoreUnknownCharacters) { @@ -492,7 +473,6 @@ extension WebSocketServer { try? FileManager.default.removeItem(at: state.tempUrl) self.lock.lock() self.incomingFiles.removeValue(forKey: id) - self.incomingReceivedChunks.removeValue(forKey: id) self.incomingFilesChecksum.removeValue(forKey: id) self.lock.unlock() return @@ -552,7 +532,6 @@ extension WebSocketServer { self.lock.lock() self.incomingFiles.removeValue(forKey: id) - self.incomingReceivedChunks.removeValue(forKey: id) self.lock.unlock() } } @@ -748,20 +727,7 @@ extension WebSocketServer { private func handleFileTransferCancel(_ message: Message) { if let dict = message.data.value as? [String: Any], let id = dict["id"] as? String { - self.lock.lock() - self.incomingReceivedChunks.removeValue(forKey: id) - self.lock.unlock() - - // AppState.stopTransferRemote(id:) does not exist in v3.0.0. - // The logic to clean up file handles is sufficient here as handles are managed in incomingFiles map. - // We just ensure we remove the entry from our tracking. - self.lock.lock() - if let io = self.incomingFiles[id] { - io.fileHandle?.closeFile() - try? FileManager.default.removeItem(at: io.tempUrl) - self.incomingFiles.removeValue(forKey: id) - } - self.lock.unlock() + print("[websocket] Transfer \(id) cancelled by remote.") } } diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index ac1bd5d9..1765dd56 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -9,7 +9,7 @@ import Foundation import Swifter import CryptoKit import UserNotifications -internal import Combine +import Combine class WebSocketServer: ObservableObject { static let shared = WebSocketServer() @@ -40,7 +40,6 @@ class WebSocketServer: ObservableObject { internal var incomingFiles: [String: IncomingFileIO] = [:] internal var incomingFilesChecksum: [String: String] = [:] - internal var incomingReceivedChunks: [String: Set] = [:] internal var outgoingAcks: [String: Set] = [:] internal let maxChunkRetries = 3 @@ -146,7 +145,6 @@ class WebSocketServer: ObservableObject { func requestRestart(reason: String, delay: TimeInterval = 0.35, port: UInt16? = nil) { lock.lock() - // No more pendingRestartWorkItem cleanup since we removed the property let restartPort = port ?? localPort ?? Defaults.serverPort lock.unlock() @@ -157,18 +155,14 @@ class WebSocketServer: ObservableObject { self.start(port: restartPort) } - // Simply dispatch the restart, no complex cancellation of pending items DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } func stop() { lock.lock() - // Removed pendingRestartWorkItem cleanup stopAllServers() activeSessions.removeAll() - incomingReceivedChunks.removeAll() primarySessionID = nil - resetReplayGuard() stopPing() lock.unlock() DispatchQueue.main.async { AppState.shared.webSocketStatus = .stopped } @@ -189,7 +183,6 @@ class WebSocketServer: ObservableObject { server["/socket"] = websocket( text: { [weak self] session, text in guard let self = self else { return } - let sessionId = ObjectIdentifier(session) let decryptedText: String if let key = self.symmetricKey { decryptedText = decryptMessage(text, using: key) ?? "" @@ -199,7 +192,7 @@ class WebSocketServer: ObservableObject { if decryptedText.contains("\"type\":\"pong\"") { self.lock.lock() - self.lastActivity[sessionId] = Date() + self.lastActivity[ObjectIdentifier(session)] = Date() self.lock.unlock() return } @@ -208,7 +201,7 @@ class WebSocketServer: ObservableObject { do { let message = try JSONDecoder().decode(Message.self, from: data) self.lock.lock() - self.lastActivity[sessionId] = Date() + self.lastActivity[ObjectIdentifier(session)] = Date() self.lock.unlock() if message.type == .fileChunk || message.type == .fileChunkAck || message.type == .fileTransferComplete || message.type == .fileTransferInit { @@ -216,6 +209,7 @@ class WebSocketServer: ObservableObject { } else { DispatchQueue.main.async { self.handleMessage(message, session: session) } } + DispatchQueue.main.async { self.handleMessage(message, session: session) } } catch { print("[websocket] JSON decode failed: \(error)") } @@ -247,11 +241,10 @@ class WebSocketServer: ObservableObject { }, disconnected: { [weak self] session in guard let self = self else { return } - let sessionId = ObjectIdentifier(session) self.lock.lock() self.activeSessions.removeAll(where: { $0 === session }) let sessionCount = self.activeSessions.count - let wasPrimary = (sessionId == self.primarySessionID) + let wasPrimary = (ObjectIdentifier(session) == self.primarySessionID) if wasPrimary { self.primarySessionID = nil } self.lock.unlock() @@ -316,26 +309,15 @@ class WebSocketServer: ObservableObject { return } - // File transfer messages are handled on background queue (like local messages) - if message.type == .fileChunk || message.type == .fileChunkAck || - message.type == .fileTransferComplete || message.type == .fileTransferInit { + DispatchQueue.main.async { self.handleRelayedMessageInternal(message) - } else { - DispatchQueue.main.async { - self.handleRelayedMessageInternal(message) - } } } catch { print("[airbridge] Failed to decode relayed message: \(error)") } } - /// Handles a binary message received from the AirBridge relay. - func handleRelayedBinaryMessage(_ data: Data) { - // Binary relay data — currently unused in the AirSync protocol - // (file transfers use base64 JSON), but ready for future E2EE binary payloads - print("[airbridge] Received binary relay data (\(data.count) bytes)") - } + /// Internal router for relayed messages. /// Uses an existing local session when available, otherwise handles messages directly. @@ -457,36 +439,21 @@ class WebSocketServer: ObservableObject { // MARK: - Crypto Helpers func loadOrGenerateSymmetricKey() { - let keychainKey = "encryptionKey" - - // 1. Try loading from Keychain first - if let keyData = KeychainStorage.data(for: keychainKey) { - symmetricKey = SymmetricKey(data: keyData) - return - } - - // 2. Migrate from UserDefaults if present (one-time migration) let defaults = UserDefaults.standard - if let savedKey = defaults.string(forKey: keychainKey), + if let savedKey = defaults.string(forKey: "encryptionKey"), let keyData = Data(base64Encoded: savedKey) { - KeychainStorage.setData(keyData, for: keychainKey) - defaults.removeObject(forKey: keychainKey) - symmetricKey = SymmetricKey(data: keyData) - print("[crypto] Migrated encryption key from UserDefaults to Keychain") - return - } - - // 3. Generate a new key and store in Keychain - let base64Key = generateSymmetricKey() - if let keyData = Data(base64Encoded: base64Key) { - KeychainStorage.setData(keyData, for: keychainKey) symmetricKey = SymmetricKey(data: keyData) + } else { + let base64Key = generateSymmetricKey() + defaults.set(base64Key, forKey: "encryptionKey") + if let keyData = Data(base64Encoded: base64Key) { + symmetricKey = SymmetricKey(data: keyData) + } } } func resetSymmetricKey() { - KeychainStorage.delete(key: "encryptionKey") - resetReplayGuard() + UserDefaults.standard.removeObject(forKey: "encryptionKey") loadOrGenerateSymmetricKey() } diff --git a/airsync-mac/Localization/Localizer.swift b/airsync-mac/Localization/Localizer.swift index f589f319..b2513b91 100644 --- a/airsync-mac/Localization/Localizer.swift +++ b/airsync-mac/Localization/Localizer.swift @@ -1,6 +1,6 @@ import Foundation import SwiftUI -internal import Combine +import Combine /// Simple JSON-based localization loader. /// Loads `en.json` as base and overlays with current locale file if available. diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/TimeView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/TimeView.swift index 6feebeb6..3ef25219 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/TimeView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/TimeView.swift @@ -6,7 +6,7 @@ // import SwiftUI -internal import Combine +import Combine import CoreText // MARK: - CoreText -> Path helper (macOS-safe) diff --git a/airsync-mac/Screens/OnboardingView/AirBridgeSetupView.swift b/airsync-mac/Screens/OnboardingView/AirBridgeSetupView.swift index 16cd41db..84c4e457 100644 --- a/airsync-mac/Screens/OnboardingView/AirBridgeSetupView.swift +++ b/airsync-mac/Screens/OnboardingView/AirBridgeSetupView.swift @@ -2,7 +2,7 @@ // AirBridgeSetupView.swift // AirSync // -// Created by AI Assistant. +// Created by tornado-bunk and an AI Assistant. // import SwiftUI @@ -60,7 +60,7 @@ struct AirBridgeSetupView: View { HStack { Label("Server URL", systemImage: "server.rack") .frame(width: 100, alignment: .leading) - TextField("wss://airbridge", text: $relayURL) + TextField("airbridge.yourdomain.com", text: $relayURL) .textFieldStyle(.roundedBorder) } diff --git a/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift b/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift index 7c80296f..53ba3266 100644 --- a/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift +++ b/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift @@ -2,7 +2,7 @@ // AirBridgeSettingsView.swift // airsync-mac // -// Created by AI Assistant. +// Created by tornado-bunk and an AI Assistant. // import SwiftUI @@ -144,7 +144,7 @@ struct AirBridgeSettingsView: View { if enabled { // Ensure default URL if missing if airBridge.relayServerURL.isEmpty { - airBridge.relayServerURL = "wss://airbridge.tornado.ovh/ws" + airBridge.relayServerURL = "wss://airbridge.yourdomain.com/ws" } // Ensure credentials exist (generates and saves if missing) diff --git a/airsync-mac/Screens/Settings/Components/TrialActivationSheet.swift b/airsync-mac/Screens/Settings/Components/TrialActivationSheet.swift index 745c8182..ae0267e8 100644 --- a/airsync-mac/Screens/Settings/Components/TrialActivationSheet.swift +++ b/airsync-mac/Screens/Settings/Components/TrialActivationSheet.swift @@ -1,5 +1,5 @@ import SwiftUI -internal import Combine +import Combine struct TrialActivationSheet: View { @ObservedObject var manager: TrialManager diff --git a/airsync-mac/Screens/Settings/RemotePermissionView.swift b/airsync-mac/Screens/Settings/RemotePermissionView.swift index 5b6d940c..7772a308 100644 --- a/airsync-mac/Screens/Settings/RemotePermissionView.swift +++ b/airsync-mac/Screens/Settings/RemotePermissionView.swift @@ -6,7 +6,7 @@ // import SwiftUI -internal import Combine +import Combine struct RemotePermissionView: View { @Environment(\.dismiss) var dismiss diff --git a/airsync-mac/Screens/Updater/CheckForUpdateView.swift b/airsync-mac/Screens/Updater/CheckForUpdateView.swift index c10b3ddd..67426174 100644 --- a/airsync-mac/Screens/Updater/CheckForUpdateView.swift +++ b/airsync-mac/Screens/Updater/CheckForUpdateView.swift @@ -7,7 +7,7 @@ import SwiftUI import Sparkle -internal import Combine +import Combine final class CheckForUpdatesViewModel: ObservableObject { @Published var canCheckForUpdates = false diff --git a/airsync-mac/airsync_macApp.swift b/airsync-mac/airsync_macApp.swift index ace26d19..e4d5ccd8 100644 --- a/airsync-mac/airsync_macApp.swift +++ b/airsync-mac/airsync_macApp.swift @@ -25,8 +25,7 @@ struct airsync_macApp: App { @StateObject private var macInfoSyncManager = MacInfoSyncManager() init() { - // Pre-load ALL Keychain items in a single query so macOS only - // shows ONE password prompt instead of 5-8 individual prompts. + // Pre-load all Keychain items in a single query so macOS only shows ONE password prompt instead of the individual prompts. KeychainStorage.preload() let center = UNUserNotificationCenter.current() From d3b3c10e143f48e71185d63a565bf8c64c4d2c61 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Mon, 16 Mar 2026 01:31:09 +0100 Subject: [PATCH 07/17] feat: Enhance AirBridgeClient connection management and WebSocket handling - Introduced connection generation tracking to manage reconnections more effectively. - Updated connect, disconnect, and reconnect logic to ensure proper state handling. - Refactored sendRegistration and startReceiving methods to include expected connection generation checks. - Added support for handling ping and pong messages in WebSocketServer for improved keepalive functionality. - Improved error handling and state management during message processing. --- .../Core/AirBridge/AirBridgeClient.swift | 86 +++++++++++++------ .../Core/WebSocket/WebSocketServer.swift | 19 +++- 2 files changed, 76 insertions(+), 29 deletions(-) diff --git a/airsync-mac/Core/AirBridge/AirBridgeClient.swift b/airsync-mac/Core/AirBridge/AirBridgeClient.swift index f54cf730..557b0440 100644 --- a/airsync-mac/Core/AirBridge/AirBridgeClient.swift +++ b/airsync-mac/Core/AirBridge/AirBridgeClient.swift @@ -95,6 +95,8 @@ class AirBridgeClient: ObservableObject { private var isManuallyDisconnected = false private var receiveLoopActive = false private let queue = DispatchQueue(label: "com.airsync.airbridge", qos: .userInitiated) + private var connectionGeneration: Int = 0 + private var pendingReconnectWorkItem: DispatchWorkItem? private init() {} @@ -103,7 +105,9 @@ class AirBridgeClient: ObservableObject { /// Connects to the relay server. Does nothing if already connected or URL is empty. func connect() { queue.async { [weak self] in - self?.connectInternal() + guard let self = self else { return } + guard !self.receiveLoopActive || self.webSocketTask == nil else { return } + self.connectInternal() } } @@ -112,6 +116,9 @@ class AirBridgeClient: ObservableObject { queue.async { [weak self] in guard let self = self else { return } self.isManuallyDisconnected = true + self.connectionGeneration += 1 + self.pendingReconnectWorkItem?.cancel() + self.pendingReconnectWorkItem = nil self.tearDown(reason: "Manual disconnect") DispatchQueue.main.async { self.connectionState = .disconnected @@ -281,6 +288,10 @@ class AirBridgeClient: ObservableObject { } isManuallyDisconnected = false + pendingReconnectWorkItem?.cancel() + pendingReconnectWorkItem = nil + connectionGeneration += 1 + let generation = connectionGeneration DispatchQueue.main.async { self.connectionState = .connecting } let config = URLSessionConfiguration.default @@ -293,10 +304,10 @@ class AirBridgeClient: ObservableObject { // Start receiving messages receiveLoopActive = true - startReceiving() + startReceiving(expectedGeneration: generation) // Send registration - sendRegistration() + sendRegistration(expectedGeneration: generation) } /// Derives a SHA-256 hash of the raw secret so the plaintext never leaves the device. @@ -307,7 +318,8 @@ class AirBridgeClient: ObservableObject { return hash.compactMap { String(format: "%02x", $0) }.joined() } - private func sendRegistration() { + private func sendRegistration(expectedGeneration: Int) { + guard expectedGeneration == connectionGeneration else { return } DispatchQueue.main.async { self.connectionState = .registering } let localIP = WebSocketServer.shared.getLocalIPAddress( @@ -328,15 +340,19 @@ class AirBridgeClient: ObservableObject { let data = try JSONEncoder().encode(regMessage) if let jsonString = String(data: data, encoding: .utf8) { webSocketTask?.send(.string(jsonString)) { [weak self] error in - if let error = error { - print("[airbridge] Registration send failed: \(error.localizedDescription)") - self?.scheduleReconnect() - } else { - print("[airbridge] Registration sent for pairingId: \(self?.pairingId ?? "?")") - DispatchQueue.main.async { - self?.connectionState = .waitingForPeer + guard let self = self else { return } + self.queue.async { + guard expectedGeneration == self.connectionGeneration else { return } + if let error = error { + print("[airbridge] Registration send failed: \(error.localizedDescription)") + self.scheduleReconnect(sourceGeneration: expectedGeneration) + } else { + print("[airbridge] Registration sent for pairingId: \(self.pairingId)") + DispatchQueue.main.async { + self.connectionState = .waitingForPeer + } + self.reconnectAttempt = 0 } - self?.reconnectAttempt = 0 } } } @@ -347,22 +363,23 @@ class AirBridgeClient: ObservableObject { // MARK: - Receive Loop - private func startReceiving() { + private func startReceiving(expectedGeneration: Int) { guard receiveLoopActive, let task = webSocketTask else { return } task.receive { [weak self] result in - guard let self = self, self.receiveLoopActive else { return } - - switch result { - case .success(let message): - self.handleMessage(message) - // Continue receiving - self.startReceiving() - - case .failure(let error): - print("[airbridge] Receive error: \(error.localizedDescription)") - self.receiveLoopActive = false - self.scheduleReconnect() + guard let self = self else { return } + self.queue.async { + guard self.receiveLoopActive, expectedGeneration == self.connectionGeneration else { return } + switch result { + case .success(let message): + self.handleMessage(message) + // Continue receiving + self.startReceiving(expectedGeneration: expectedGeneration) + case .failure(let error): + print("[airbridge] Receive error: \(error.localizedDescription)") + self.receiveLoopActive = false + self.scheduleReconnect(sourceGeneration: expectedGeneration) + } } } } @@ -386,6 +403,11 @@ class AirBridgeClient: ObservableObject { switch baseMsg.action { case .relayStarted: print("[airbridge] Relay tunnel established!") + queue.async { [weak self] in + self?.pendingReconnectWorkItem?.cancel() + self?.pendingReconnectWorkItem = nil + self?.reconnectAttempt = 0 + } DispatchQueue.main.async { self.connectionState = .relayActive self.startPingLoop() @@ -456,8 +478,12 @@ class AirBridgeClient: ObservableObject { // MARK: - Reconnect - private func scheduleReconnect() { + private func scheduleReconnect(sourceGeneration: Int) { guard !isManuallyDisconnected else { return } + guard sourceGeneration == connectionGeneration else { + print("[airbridge] Skipping reconnect from stale session \(sourceGeneration)") + return + } tearDown(reason: "Preparing reconnect") @@ -469,10 +495,14 @@ class AirBridgeClient: ObservableObject { self.connectionState = .connecting } - queue.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self = self, !self.isManuallyDisconnected else { return } + pendingReconnectWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + guard let self = self else { return } + guard !self.isManuallyDisconnected, sourceGeneration == self.connectionGeneration else { return } self.connectInternal() } + pendingReconnectWorkItem = work + queue.asyncAfter(deadline: .now() + delay, execute: work) } private func tearDown(reason: String) { diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index 1765dd56..ab00bd53 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -209,7 +209,6 @@ class WebSocketServer: ObservableObject { } else { DispatchQueue.main.async { self.handleMessage(message, session: session) } } - DispatchQueue.main.async { self.handleMessage(message, session: session) } } catch { print("[websocket] JSON decode failed: \(error)") } @@ -300,6 +299,24 @@ class WebSocketServer: ObservableObject { return } + // Accept keepalive packets that omit "data" (e.g. {"type":"pong"}). + if let jsonObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = jsonObj["type"] as? String { + if type == MessageType.pong.rawValue { + AirBridgeClient.shared.processPong() + return + } + if type == MessageType.ping.rawValue { + let pongPayload = #"{"type":"pong"}"# + if let key = symmetricKey, let encrypted = encryptMessage(pongPayload, using: key) { + AirBridgeClient.shared.sendText(encrypted) + } else { + AirBridgeClient.shared.sendText(pongPayload) + } + return + } + } + do { let message = try JSONDecoder().decode(Message.self, from: data) From cb41a5fd254b1362c0f9fc2511b8821d685cb939 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Mon, 16 Mar 2026 15:29:27 +0100 Subject: [PATCH 08/17] feat: Implement wake notification handling and connection state updates in AirBridgeClient and QuickShareManager --- .../Core/AirBridge/AirBridgeClient.swift | 22 ++++++++++++++++++- .../Core/QuickShare/QuickShareManager.swift | 6 +++++ .../Core/WebSocket/WebSocketServer.swift | 20 +++++++++++++++++ .../Settings/AirBridgeSettingsView.swift | 10 +++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/airsync-mac/Core/AirBridge/AirBridgeClient.swift b/airsync-mac/Core/AirBridge/AirBridgeClient.swift index 557b0440..036e8acc 100644 --- a/airsync-mac/Core/AirBridge/AirBridgeClient.swift +++ b/airsync-mac/Core/AirBridge/AirBridgeClient.swift @@ -11,6 +11,7 @@ import Foundation import Combine import CryptoKit +import AppKit class AirBridgeClient: ObservableObject { static let shared = AirBridgeClient() @@ -98,7 +99,16 @@ class AirBridgeClient: ObservableObject { private var connectionGeneration: Int = 0 private var pendingReconnectWorkItem: DispatchWorkItem? - private init() {} + private init() { + // Observe system wake events so we can notify Android via relay and trigger a LAN reconnect. + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.handleWakeFromSleep() + } + } // MARK: - Public Interface @@ -476,6 +486,16 @@ class AirBridgeClient: ObservableObject { } } + /// Called when the Mac wakes from sleep; if the relay is active, notify Android so it can try LAN reconnect. + private func handleWakeFromSleep() { + queue.async { [weak self] in + guard let self = self else { return } + guard case .relayActive = self.connectionState else { return } + print("[airbridge] Mac woke from sleep while relay is active, sending macWake via relay") + WebSocketServer.shared.sendWakeViaRelay() + } + } + // MARK: - Reconnect private func scheduleReconnect(sourceGeneration: Int) { diff --git a/airsync-mac/Core/QuickShare/QuickShareManager.swift b/airsync-mac/Core/QuickShare/QuickShareManager.swift index 22619c1e..0d3f01a3 100644 --- a/airsync-mac/Core/QuickShare/QuickShareManager.swift +++ b/airsync-mac/Core/QuickShare/QuickShareManager.swift @@ -149,6 +149,12 @@ public class QuickShareManager: NSObject, ObservableObject, MainAppDelegate, Sha public func sendFiles(urls: [URL], to device: RemoteDeviceInfo) { guard let deviceID = device.id else { return } + // If we are only connected via relay (no local LAN session), block Quick Share sends. + if AirBridgeClient.shared.connectionState.isConnected, + !WebSocketServer.shared.hasActiveLocalSession() { + print("[quickshare] Quick Share send blocked: relay-only connection (no LAN session)") + return + } transferState = .connecting(deviceID) transferProgress = 0 diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index ab00bd53..85b767b9 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -453,6 +453,26 @@ class WebSocketServer: ObservableObject { } } + /// Sends a wake signal through the relay so Android can attempt a LAN reconnect. + func sendWakeViaRelay() { + let messageDict: [String: Any] = [ + "type": "macWake", + "data": [:] + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: messageDict), + let jsonString = String(data: jsonData, encoding: .utf8) else { + print("[airbridge] Failed to encode macWake message") + return + } + + if let key = symmetricKey, let encrypted = encryptMessage(jsonString, using: key) { + AirBridgeClient.shared.sendText(encrypted) + } else { + AirBridgeClient.shared.sendText(jsonString) + } + } + // MARK: - Crypto Helpers func loadOrGenerateSymmetricKey() { diff --git a/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift b/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift index 53ba3266..2ee877e7 100644 --- a/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift +++ b/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift @@ -35,6 +35,16 @@ struct AirBridgeSettingsView: View { Text(airBridge.connectionState.displayName) .font(.system(size: 12)) .foregroundStyle(.secondary) + if case .relayActive = airBridge.connectionState, !airBridge.isPeerConnected { + Text("Peer offline") + .font(.system(size: 10, weight: .semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.orange.opacity(0.2)) + .foregroundStyle(.orange) + .clipShape(Capsule()) + .help("Relay is active but no peer is currently connected.") + } Spacer() if case .failed = airBridge.connectionState { From 2215d23affe99d808d0ca3525732e884909f1fbd Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Mon, 16 Mar 2026 18:05:44 +0100 Subject: [PATCH 09/17] feat: Disable Quick Share in Relay mode and added status basge for peer online or not in relay mode --- airsync-mac/Core/AppState.swift | 20 ++++++++++++++----- .../Core/WebSocket/WebSocketServer.swift | 13 ++++++++++++ .../PhoneView/ConnectionStatusPill.swift | 13 ++++++++++++ .../HomeScreen/PhoneView/ScreenView.swift | 10 ++++++++++ 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 4e298666..b2cfe1ce 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -22,6 +22,7 @@ class AppState: ObservableObject { private var clipboardCancellable: AnyCancellable? private var lastClipboardValue: String? = nil private var shouldSkipSave = false + private var subscriptions = Set() private static let licenseDetailsKey = "licenseDetails" @Published var isOS26: Bool = true @@ -105,6 +106,17 @@ class AppState: ObservableObject { startClipboardMonitoring() } + // Seed initial LAN state from current WebSocketServer snapshot. + self.isConnectedOverLocalNetwork = WebSocketServer.shared.hasActiveLocalSession() + + // Subscribe to immediate LAN session events for UI reactivity. + WebSocketServer.shared.lanSessionEvents + .receive(on: DispatchQueue.main) + .sink { [weak self] isActive in + self?.isConnectedOverLocalNetwork = isActive + } + .store(in: &subscriptions) + #if SELF_COMPILED self.isPlus = true UserDefaults.standard.set(true, forKey: "isPlus") @@ -194,11 +206,9 @@ class AppState: ObservableObject { @Published var recentApps: [AndroidApp] = [] - var isConnectedOverLocalNetwork: Bool { - // Check if we have a direct LAN WebSocket session - // Falls back to false when only the AirBridge relay tunnel is active. - return WebSocketServer.shared.hasActiveLocalSession() - } + // Reactive snapshot of whether we currently have a direct LAN WebSocket session. + // Updated via WebSocketServer.lanSessionEvents so UI can flip icons instantly when transport changes. + @Published var isConnectedOverLocalNetwork: Bool = false // Audio player for ringtone private var ringtonePlayer: AVAudioPlayer? diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index 85b767b9..2d7a5a18 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -48,6 +48,10 @@ class WebSocketServer: ObservableObject { internal var lastKnownAdapters: [(name: String, address: String)] = [] internal var lastLoggedSelectedAdapter: (name: String, address: String)? = nil + // Emits immediate events when the primary LAN WebSocket session starts or ends. + // Used by AppState/UI to update LAN vs relay indicators without polling. + internal let lanSessionEvents = PassthroughSubject() // true = started, false = ended + init() { loadOrGenerateSymmetricKey() setupWebSocket(for: server) @@ -226,17 +230,25 @@ class WebSocketServer: ObservableObject { self.lastActivity[sessionId] = Date() self.activeSessions.append(session) let sessionCount = self.activeSessions.count + let becamePrimary: Bool self.lock.unlock() print("[websocket] Session \(sessionId) connected.") if self.primarySessionID == nil { self.primarySessionID = sessionId + becamePrimary = true + } else { + becamePrimary = false } if sessionCount == 1 { MacRemoteManager.shared.startVolumeMonitoring() self.startPing() } + + if becamePrimary { + self.lanSessionEvents.send(true) + } }, disconnected: { [weak self] session in guard let self = self else { return } @@ -253,6 +265,7 @@ class WebSocketServer: ObservableObject { } if wasPrimary { + self.lanSessionEvents.send(false) DispatchQueue.main.async { AppState.shared.disconnectDevice() ADBConnector.disconnectADB() diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift index ee32c631..1f2a4639 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift @@ -21,6 +21,19 @@ struct ConnectionStatusPill: View { Image(systemName: appState.isConnectedOverLocalNetwork ? "wifi" : "globe") .contentTransition(.symbolEffect(.replace)) .help(appState.isConnectedOverLocalNetwork ? "Local WiFi" : "AirBridge Relay") + + // Peer health badge when in relay mode + if !appState.isConnectedOverLocalNetwork, + case .relayActive = AirBridgeClient.shared.connectionState { + let online = AirBridgeClient.shared.isPeerConnected + Text(online ? "Peer online" : "Peer offline") + .font(.system(size: 10, weight: .semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background((online ? Color.green : Color.orange).opacity(0.2)) + .foregroundStyle(online ? Color.green : Color.orange) + .clipShape(Capsule()) + } if appState.isPlus { if appState.adbConnecting { diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift index c455c670..a32b5009 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift @@ -54,6 +54,16 @@ struct ScreenView: View { } } ) + .disabled( + !appState.isConnectedOverLocalNetwork && + AirBridgeClient.shared.connectionState == .relayActive + ) + .help( + (!appState.isConnectedOverLocalNetwork && + AirBridgeClient.shared.connectionState == .relayActive) + ? "Quick Share is unavailable over relay connection" + : "Send files with Quick Share" + ) .transition(.identity) .keyboardShortcut( "f", From e8e8e2963b9967ca0201abd56ffcef6f8f9e3812 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Tue, 17 Mar 2026 14:50:05 +0100 Subject: [PATCH 10/17] feat: Implement peer transport hint management and update connection status handling --- .../Core/AirBridge/AirBridgeClient.swift | 61 ++++++++++++------- airsync-mac/Core/AppState.swift | 39 ++++++++++++ .../Core/QuickShare/QuickShareManager.swift | 2 +- .../WebSocket/WebSocketServer+Handlers.swift | 16 +++++ .../WebSocket/WebSocketServer+Outgoing.swift | 8 +++ .../Core/WebSocket/WebSocketServer.swift | 18 +++++- airsync-mac/Model/Message.swift | 2 + .../PhoneView/ConnectionStatusPill.swift | 16 ++--- .../HomeScreen/PhoneView/ScreenView.swift | 4 +- 9 files changed, 132 insertions(+), 34 deletions(-) diff --git a/airsync-mac/Core/AirBridge/AirBridgeClient.swift b/airsync-mac/Core/AirBridge/AirBridgeClient.swift index 036e8acc..aeef903a 100644 --- a/airsync-mac/Core/AirBridge/AirBridgeClient.swift +++ b/airsync-mac/Core/AirBridge/AirBridgeClient.swift @@ -22,7 +22,7 @@ class AirBridgeClient: ObservableObject { @Published var isPeerConnected: Bool = false // Ping mechanism - private var pingTimer: Timer? + private var pingTimer: DispatchSourceTimer? private var lastPongReceived: Date = .distantPast private let pingInterval: TimeInterval = 8.0 private let peerTimeout: TimeInterval = 20.0 @@ -422,6 +422,10 @@ class AirBridgeClient: ObservableObject { self.connectionState = .relayActive self.startPingLoop() } + // Relay can be active as a warm fallback while LAN is active; only advertise RELAY as primary when LAN is down. + if !WebSocketServer.shared.hasActiveLocalSession() { + WebSocketServer.shared.sendPeerTransportStatus("relay") + } return case .macInfo: @@ -454,35 +458,45 @@ class AirBridgeClient: ObservableObject { } private func startPingLoop() { - DispatchQueue.main.async { [weak self] in + queue.async { [weak self] in guard let self = self else { return } - self.pingTimer?.invalidate() - self.lastPongReceived = Date() // Assume alive on start - - self.pingTimer = Timer.scheduledTimer(withTimeInterval: self.pingInterval, repeats: true) { [weak self] _ in + self.pingTimer?.cancel() + self.pingTimer = nil + self.lastPongReceived = Date() + + let timer = DispatchSource.makeTimerSource(queue: self.queue) + timer.schedule(deadline: .now() + self.pingInterval, repeating: self.pingInterval) + timer.setEventHandler { [weak self] in guard let self = self else { return } - - // 1. Check for timeout + let timeSinceLastPong = Date().timeIntervalSince(self.lastPongReceived) - if self.isPeerConnected && timeSinceLastPong > self.peerTimeout { - print("[airbridge] Peer ping timeout (\(Int(timeSinceLastPong))s > \(Int(self.peerTimeout))s). Marking disconnected.") - self.isPeerConnected = false + if timeSinceLastPong > self.peerTimeout { + DispatchQueue.main.async { + if self.isPeerConnected { + print("[airbridge] Peer ping timeout (\(Int(timeSinceLastPong))s > \(Int(self.peerTimeout))s). Marking disconnected.") + self.isPeerConnected = false + } + } } - - // 2. Send Ping + let pingJson = "{\"type\":\"ping\"}" self.sendText(pingJson) } + self.pingTimer = timer + timer.resume() } } func processPong() { - DispatchQueue.main.async { - if !self.isPeerConnected { - print("[airbridge] Peer connected via relay (pong received).") - } + queue.async { [weak self] in + guard let self = self else { return } self.lastPongReceived = Date() - self.isPeerConnected = true + DispatchQueue.main.async { + if !self.isPeerConnected { + print("[airbridge] Peer connected via relay (pong received).") + } + self.isPeerConnected = true + } } } @@ -533,10 +547,13 @@ class AirBridgeClient: ObservableObject { urlSession = nil // Clean up ping timer - DispatchQueue.main.async { [weak self] in - self?.pingTimer?.invalidate() - self?.pingTimer = nil - self?.isPeerConnected = false + queue.async { [weak self] in + guard let self = self else { return } + self.pingTimer?.cancel() + self.pingTimer = nil + DispatchQueue.main.async { + self.isPeerConnected = false + } } print("[airbridge] Torn down: \(reason)") diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index b2cfe1ce..f1789644 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -19,6 +19,12 @@ class AppState: ObservableObject { case wired } + enum PeerTransportHint: String { + case unknown + case wifi + case relay + } + private var clipboardCancellable: AnyCancellable? private var lastClipboardValue: String? = nil private var shouldSkipSave = false @@ -209,6 +215,30 @@ class AppState: ObservableObject { // Reactive snapshot of whether we currently have a direct LAN WebSocket session. // Updated via WebSocketServer.lanSessionEvents so UI can flip icons instantly when transport changes. @Published var isConnectedOverLocalNetwork: Bool = false + @Published var peerTransportHint: PeerTransportHint = .unknown + + // Effective transport for UI/actions: explicit peer hint overrides stale local-session state. + var isEffectivelyLocalTransport: Bool { + switch peerTransportHint { + case .relay: return false + case .wifi: return true + case .unknown: return isConnectedOverLocalNetwork + } + } + + func updatePeerTransportHint(_ transport: String?) { + let next: PeerTransportHint + switch transport?.lowercased() { + case "wifi": next = .wifi + case "relay": next = .relay + default: next = .unknown + } + if Thread.isMainThread { + peerTransportHint = next + } else { + DispatchQueue.main.async { self.peerTransportHint = next } + } + } // Audio player for ringtone private var ringtonePlayer: AVAudioPlayer? @@ -704,6 +734,15 @@ class AppState: ObservableObject { self.notifications.removeAll() self.status = nil self.currentDeviceWallpaperBase64 = nil + // Preserve an accurate transport hint after device reset so UI actions + // (icon/Quick Share gating) do not fall back to stale LAN snapshots. + if self.isConnectedOverLocalNetwork { + self.peerTransportHint = .wifi + } else if AirBridgeClient.shared.connectionState == .relayActive { + self.peerTransportHint = .relay + } else { + self.peerTransportHint = .unknown + } // Clean up Quick Share state if QuickShareManager.shared.transferState != .idle { diff --git a/airsync-mac/Core/QuickShare/QuickShareManager.swift b/airsync-mac/Core/QuickShare/QuickShareManager.swift index 0d3f01a3..bb37dd0a 100644 --- a/airsync-mac/Core/QuickShare/QuickShareManager.swift +++ b/airsync-mac/Core/QuickShare/QuickShareManager.swift @@ -151,7 +151,7 @@ public class QuickShareManager: NSObject, ObservableObject, MainAppDelegate, Sha guard let deviceID = device.id else { return } // If we are only connected via relay (no local LAN session), block Quick Share sends. if AirBridgeClient.shared.connectionState.isConnected, - !WebSocketServer.shared.hasActiveLocalSession() { + !AppState.shared.isEffectivelyLocalTransport { print("[quickshare] Quick Share send blocked: relay-only connection (no LAN session)") return } diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index 1fc698a1..1e15fdc7 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -64,6 +64,8 @@ extension WebSocketServer { handleRemoteControl(message) case .browseData: handleBrowseData(message) + case .peerTransport: + handlePeerTransportUpdate(message) case .volumeControl, .macVolume, .toggleAppNotif, .browseLs, .wakeUpRequest, .macMediaControlResponse, .macInfo, .callControl, .ping, .pong: // Outgoing or unexpected messages break @@ -100,6 +102,8 @@ extension WebSocketServer { handleMediaControlResponse(message) case .callControlResponse: handleCallControlResponse(message) + case .peerTransport: + handlePeerTransportUpdate(message) case .device: // handled upstream in WebSocketServer.handleRelayedMessageInternal break @@ -633,6 +637,18 @@ extension WebSocketServer { } } + private func handlePeerTransportUpdate(_ message: Message) { + guard let dict = message.data.value as? [String: Any] else { return } + let transport = dict["transport"] as? String + let source = dict["source"] as? String ?? "peer" + + let oldHint = AppState.shared.peerTransportHint + AppState.shared.updatePeerTransportHint(transport) + let newHint = AppState.shared.peerTransportHint + + print("[transport_sync] direction=android->mac source=\(source) transport_raw=\(transport ?? "nil") hint_old=\(oldHint.rawValue) hint_new=\(newHint.rawValue)") + } + private func handleMacMediaControlRequest(_ message: Message) { if let dict = message.data.value as? [String: Any], let action = dict["action"] as? String { diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift index fe60b565..b1a57d17 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift @@ -77,6 +77,14 @@ extension WebSocketServer { sendMessage(type: "disconnectRequest", data: [:]) } + func sendPeerTransportStatus(_ transport: String) { + sendMessage(type: "peerTransport", data: [ + "source": "mac", + "transport": transport, // "wifi" | "relay" + "ts": Int(Date().timeIntervalSince1970 * 1000) + ]) + } + func sendQuickShareTrigger() { // print("[websocket] Quick Share trigger requested") sendMessage(type: "startQuickShare", data: [:]) diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index 2d7a5a18..e979086b 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -248,6 +248,8 @@ class WebSocketServer: ObservableObject { if becamePrimary { self.lanSessionEvents.send(true) + AppState.shared.updatePeerTransportHint("wifi") + self.sendPeerTransportStatus("wifi") } }, disconnected: { [weak self] session in @@ -266,6 +268,8 @@ class WebSocketServer: ObservableObject { if wasPrimary { self.lanSessionEvents.send(false) + AppState.shared.updatePeerTransportHint("relay") + self.sendPeerTransportStatus("relay") DispatchQueue.main.async { AppState.shared.disconnectDevice() ADBConnector.disconnectADB() @@ -468,9 +472,19 @@ class WebSocketServer: ObservableObject { /// Sends a wake signal through the relay so Android can attempt a LAN reconnect. func sendWakeViaRelay() { + // Include current LAN endpoint hints so Android can reconnect without requiring a manual pair. + // getLocalIPAddress(adapterName:nil) returns a comma-separated list in auto-mode. + let adapter = AppState.shared.selectedNetworkAdapterName + let ipList = getLocalIPAddress(adapterName: adapter) ?? getLocalIPAddress(adapterName: nil) ?? "" + let port = Int(localPort ?? Defaults.serverPort) + let messageDict: [String: Any] = [ "type": "macWake", - "data": [:] + "data": [ + "ips": ipList, + "port": port, + "adapter": adapter as Any + ] ] guard let jsonData = try? JSONSerialization.data(withJSONObject: messageDict), @@ -479,6 +493,8 @@ class WebSocketServer: ObservableObject { return } + print("[transport_sync] direction=mac->android type=macWake ips=\(ipList) port=\(port) adapter=\(adapter ?? "auto")") + if let key = symmetricKey, let encrypted = encryptMessage(jsonString, using: key) { AirBridgeClient.shared.sendText(encrypted) } else { diff --git a/airsync-mac/Model/Message.swift b/airsync-mac/Model/Message.swift index 0d406901..364bab75 100644 --- a/airsync-mac/Model/Message.swift +++ b/airsync-mac/Model/Message.swift @@ -43,6 +43,8 @@ enum MessageType: String, Codable { // relay keepalive case ping case pong + // peer transport hints + case peerTransport } struct Message: Codable { diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift index 1f2a4639..7270dc05 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift @@ -18,12 +18,12 @@ struct ConnectionStatusPill: View { }) { HStack(spacing: 8) { // Network Connection Icon - Image(systemName: appState.isConnectedOverLocalNetwork ? "wifi" : "globe") + Image(systemName: appState.isEffectivelyLocalTransport ? "wifi" : "globe") .contentTransition(.symbolEffect(.replace)) - .help(appState.isConnectedOverLocalNetwork ? "Local WiFi" : "AirBridge Relay") + .help(appState.isEffectivelyLocalTransport ? "Local WiFi" : "AirBridge Relay") // Peer health badge when in relay mode - if !appState.isConnectedOverLocalNetwork, + if !appState.isEffectivelyLocalTransport, case .relayActive = AirBridgeClient.shared.connectionState { let online = AirBridgeClient.shared.isPeerConnected Text(online ? "Peer online" : "Peer offline") @@ -78,7 +78,7 @@ struct ConnectionStatusPill: View { .scaleEffect(isHovered ? 1.05 : 1.0) .animation(.spring(response: 0.4, dampingFraction: 0.8), value: appState.adbConnected) .animation(.spring(response: 0.4, dampingFraction: 0.8), value: appState.adbConnectionMode) - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: appState.isConnectedOverLocalNetwork) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: appState.isEffectivelyLocalTransport) .animation(.spring(response: 0.4, dampingFraction: 0.8), value: QuickShareManager.shared.isRunning) } .buttonStyle(.plain) @@ -131,11 +131,11 @@ struct ConnectionPillPopover: View { ConnectionInfoText( label: "Transport", - icon: appState.isConnectedOverLocalNetwork ? "wifi" : "globe", - text: appState.isConnectedOverLocalNetwork ? "Local WiFi" : "AirBridge Relay" + icon: appState.isEffectivelyLocalTransport ? "wifi" : "globe", + text: appState.isEffectivelyLocalTransport ? "Local WiFi" : "AirBridge Relay" ) - if appState.isConnectedOverLocalNetwork { + if appState.isEffectivelyLocalTransport { ConnectionInfoText( label: "IP Address", icon: "network", @@ -182,7 +182,7 @@ struct ConnectionPillPopover: View { primary: false, action: { if !appState.adbConnecting { - guard WebSocketServer.shared.hasActiveLocalSession() else { + guard appState.isEffectivelyLocalTransport else { appState.adbConnectionResult = "ADB works only on local LAN connections. Relay mode is not supported for ADB." appState.manualAdbConnectionPending = false return diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift index a32b5009..1f5ca517 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift @@ -55,11 +55,11 @@ struct ScreenView: View { } ) .disabled( - !appState.isConnectedOverLocalNetwork && + !appState.isEffectivelyLocalTransport && AirBridgeClient.shared.connectionState == .relayActive ) .help( - (!appState.isConnectedOverLocalNetwork && + (!appState.isEffectivelyLocalTransport && AirBridgeClient.shared.connectionState == .relayActive) ? "Quick Share is unavailable over relay connection" : "Send files with Quick Share" From 9e646d109ecd920b09651438f5d50174b433eabc Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Wed, 18 Mar 2026 23:33:18 +0100 Subject: [PATCH 11/17] feat: Enhance WebSocketServer to publish LAN transport state changes and improve connection handling --- airsync-mac/Core/AppState.swift | 2 +- .../Core/WebSocket/WebSocketServer+Ping.swift | 5 +++ .../Core/WebSocket/WebSocketServer.swift | 35 +++++++++++++++---- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index f1789644..65b649a8 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -736,7 +736,7 @@ class AppState: ObservableObject { self.currentDeviceWallpaperBase64 = nil // Preserve an accurate transport hint after device reset so UI actions // (icon/Quick Share gating) do not fall back to stale LAN snapshots. - if self.isConnectedOverLocalNetwork { + if WebSocketServer.shared.hasActiveLocalSession() { self.peerTransportHint = .wifi } else if AirBridgeClient.shared.connectionState == .relayActive { self.peerTransportHint = .relay diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift index 5d7b4d2f..e52a75ce 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift @@ -62,12 +62,17 @@ extension WebSocketServer { self.lock.lock() self.activeSessions.removeAll(where: { ObjectIdentifier($0) == sessionId }) self.lastActivity.removeValue(forKey: sessionId) + let evictedPrimary = (self.primarySessionID == sessionId) if self.primarySessionID == sessionId { self.primarySessionID = nil } let sessionCount = self.activeSessions.count self.lock.unlock() + if evictedPrimary { + self.publishLanTransportState(isActive: false, reason: "stale_primary_evicted_by_ping") + } + if sessionCount == 0 { MacRemoteManager.shared.stopVolumeMonitoring() self.stopPing() diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index e979086b..3c3b6195 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -51,6 +51,7 @@ class WebSocketServer: ObservableObject { // Emits immediate events when the primary LAN WebSocket session starts or ends. // Used by AppState/UI to update LAN vs relay indicators without polling. internal let lanSessionEvents = PassthroughSubject() // true = started, false = ended + internal var lastPublishedLanState: Bool? init() { loadOrGenerateSymmetricKey() @@ -169,6 +170,7 @@ class WebSocketServer: ObservableObject { primarySessionID = nil stopPing() lock.unlock() + publishLanTransportState(isActive: false, reason: "server_stop") DispatchQueue.main.async { AppState.shared.webSocketStatus = .stopped } stopNetworkMonitoring() } @@ -181,6 +183,25 @@ class WebSocketServer: ObservableObject { return activeSessions.contains(where: { ObjectIdentifier($0) == pId }) } + /// Publishes LAN transport state changes in one place to keep UI and routing hints consistent. + internal func publishLanTransportState(isActive: Bool, reason: String) { + lock.lock() + let previous = lastPublishedLanState + if previous == isActive { + lock.unlock() + return + } + lastPublishedLanState = isActive + lock.unlock() + + print("[transport_sync] direction=mac_local lan_state_old=\(previous.map(String.init) ?? "nil") lan_state_new=\(isActive) reason=\(reason)") + DispatchQueue.main.async { + self.lanSessionEvents.send(isActive) + AppState.shared.updatePeerTransportHint(isActive ? "wifi" : "relay") + } + sendPeerTransportStatus(isActive ? "wifi" : "relay") + } + /// Configures WebSocket routes and event callbacks. /// Handles message decryption before passing payload to the message router. private func setupWebSocket(for server: HttpServer) { @@ -247,9 +268,7 @@ class WebSocketServer: ObservableObject { } if becamePrimary { - self.lanSessionEvents.send(true) - AppState.shared.updatePeerTransportHint("wifi") - self.sendPeerTransportStatus("wifi") + self.publishLanTransportState(isActive: true, reason: "connected_primary_session") } }, disconnected: { [weak self] session in @@ -267,9 +286,7 @@ class WebSocketServer: ObservableObject { } if wasPrimary { - self.lanSessionEvents.send(false) - AppState.shared.updatePeerTransportHint("relay") - self.sendPeerTransportStatus("relay") + self.publishLanTransportState(isActive: false, reason: "disconnected_primary_session") DispatchQueue.main.async { AppState.shared.disconnectDevice() ADBConnector.disconnectADB() @@ -392,6 +409,7 @@ class WebSocketServer: ObservableObject { let pId = primarySessionID var session = pId != nil ? activeSessions.first(where: { ObjectIdentifier($0) == pId }) : nil var sessionCount = activeSessions.count + var evictedPrimaryAsStale = false if let s = session { let sid = ObjectIdentifier(s) let lastSeen = lastActivity[sid] ?? .distantPast @@ -402,6 +420,7 @@ class WebSocketServer: ObservableObject { lastActivity.removeValue(forKey: sid) if primarySessionID == sid { primarySessionID = nil + evictedPrimaryAsStale = true } session = nil sessionCount = activeSessions.count @@ -410,6 +429,10 @@ class WebSocketServer: ObservableObject { } lock.unlock() + if evictedPrimaryAsStale { + publishLanTransportState(isActive: false, reason: "stale_primary_evicted_during_relay_rx") + } + if sessionCount == 0 { MacRemoteManager.shared.stopVolumeMonitoring() stopPing() From af494308730a2f19a1dcc57c04fae8e778bb8895 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Thu, 19 Mar 2026 17:07:49 +0100 Subject: [PATCH 12/17] feat: Implement transport negotiation and state management in WebSocketServer - Added support for transport offer, answer, check, and nomination message types. - Enhanced transport generation tracking to manage LAN negotiation effectively. - Implemented debounce logic for LAN state transitions to prevent rapid oscillation. - Updated AirBridgeClient to send transport offers when transitioning to relay mode. --- .../Core/AirBridge/AirBridgeClient.swift | 1 + .../WebSocket/WebSocketServer+Handlers.swift | 160 ++++++++++++++++++ .../WebSocket/WebSocketServer+Outgoing.swift | 79 +++++++++ .../Core/WebSocket/WebSocketServer.swift | 113 +++++++++++++ airsync-mac/Model/Message.swift | 6 + 5 files changed, 359 insertions(+) diff --git a/airsync-mac/Core/AirBridge/AirBridgeClient.swift b/airsync-mac/Core/AirBridge/AirBridgeClient.swift index aeef903a..c512a3a7 100644 --- a/airsync-mac/Core/AirBridge/AirBridgeClient.swift +++ b/airsync-mac/Core/AirBridge/AirBridgeClient.swift @@ -425,6 +425,7 @@ class AirBridgeClient: ObservableObject { // Relay can be active as a warm fallback while LAN is active; only advertise RELAY as primary when LAN is down. if !WebSocketServer.shared.hasActiveLocalSession() { WebSocketServer.shared.sendPeerTransportStatus("relay") + WebSocketServer.shared.sendTransportOffer(reason: "relay_started") } return diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index 1e15fdc7..528e8141 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -66,6 +66,16 @@ extension WebSocketServer { handleBrowseData(message) case .peerTransport: handlePeerTransportUpdate(message) + case .transportOffer: + handleTransportOffer(message) + case .transportAnswer: + handleTransportAnswer(message) + case .transportCheck: + handleTransportCheck(message) + case .transportCheckAck: + handleTransportCheckAck(message) + case .transportNominate: + handleTransportNominate(message) case .volumeControl, .macVolume, .toggleAppNotif, .browseLs, .wakeUpRequest, .macMediaControlResponse, .macInfo, .callControl, .ping, .pong: // Outgoing or unexpected messages break @@ -104,6 +114,16 @@ extension WebSocketServer { handleCallControlResponse(message) case .peerTransport: handlePeerTransportUpdate(message) + case .transportOffer: + handleTransportOffer(message) + case .transportAnswer: + handleTransportAnswer(message) + case .transportCheck: + handleTransportCheck(message) + case .transportCheckAck: + handleTransportCheckAck(message) + case .transportNominate: + handleTransportNominate(message) case .device: // handled upstream in WebSocketServer.handleRelayedMessageInternal break @@ -649,6 +669,146 @@ extension WebSocketServer { print("[transport_sync] direction=android->mac source=\(source) transport_raw=\(transport ?? "nil") hint_old=\(oldHint.rawValue) hint_new=\(newHint.rawValue)") } + private func isTransportMessageFresh(_ dict: [String: Any]) -> Bool { + let tsAny = dict["ts"] + let ts: Int64 + if let v = tsAny as? Int64 { + ts = v + } else if let v = tsAny as? Int { + ts = Int64(v) + } else if let v = tsAny as? NSNumber { + ts = v.int64Value + } else { + return false + } + if ts <= 0 { return false } + let now = Int64(Date().timeIntervalSince1970 * 1000) + let delta = abs(now - ts) + return delta <= Int64(transportGenerationTTL * 1000) + } + + private func evaluateTransportCandidates(_ dict: [String: Any]) -> (isValid: Bool, reason: String) { + guard let candidates = dict["candidates"] as? [[String: Any]], !candidates.isEmpty else { + return (false, "candidates_missing_or_empty") + } + + var emptyIp = 0 + var nonPrivateIp = 0 + var invalidPort = 0 + var accepted = 0 + var sampleRejectedIp: String? + + for candidate in candidates { + let ip = (candidate["ip"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if ip.isEmpty { + emptyIp += 1 + continue + } + if !ipIsPrivatePreferred(ip) { + nonPrivateIp += 1 + if sampleRejectedIp == nil { + sampleRejectedIp = ip + } + continue + } + + let p = candidate["port"] as? Int ?? -1 + if p == 0 || (p > 0 && p <= 65535) { + accepted += 1 + } else { + invalidPort += 1 + } + } + + if accepted > 0 { + return (true, "ok") + } + + let sample = sampleRejectedIp ?? "none" + let reason = "accepted=0 total=\(candidates.count) empty_ip=\(emptyIp) non_private_ip=\(nonPrivateIp) invalid_port=\(invalidPort) sample_rejected_ip=\(sample)" + return (false, reason) + } + + private func handleTransportOffer(_ message: Message) { + guard let dict = message.data.value as? [String: Any] else { return } + let generation = dict["generation"] as? Int64 ?? Int64(dict["generation"] as? Int ?? 0) + let source = dict["source"] as? String ?? "peer" + print("[transport_sync] phase=offer_rx source=\(source) generation=\(generation)") + guard isTransportMessageFresh(dict) else { + print("[transport_sync] phase=offer_drop generation=\(generation) reason=stale_ts") + return + } + let candidateEval = evaluateTransportCandidates(dict) + guard candidateEval.isValid else { + print("[transport_sync] phase=offer_drop generation=\(generation) reason=invalid_candidates details=\(candidateEval.reason)") + return + } + guard acceptIncomingTransportGeneration(generation, reason: "offer_rx") else { + return + } + sendTransportAnswer(generation: generation, reason: "offer_rx") + } + + private func handleTransportAnswer(_ message: Message) { + guard let dict = message.data.value as? [String: Any] else { return } + let generation = dict["generation"] as? Int64 ?? Int64(dict["generation"] as? Int ?? 0) + let source = dict["source"] as? String ?? "peer" + print("[transport_sync] phase=answer_rx source=\(source) generation=\(generation)") + guard isTransportMessageFresh(dict) else { + print("[transport_sync] phase=answer_drop generation=\(generation) reason=stale_ts") + return + } + let candidateEval = evaluateTransportCandidates(dict) + guard candidateEval.isValid else { + print("[transport_sync] phase=answer_drop generation=\(generation) reason=invalid_candidates details=\(candidateEval.reason)") + return + } + _ = acceptIncomingTransportGeneration(generation, reason: "answer_rx") + } + + private func handleTransportCheck(_ message: Message) { + guard let dict = message.data.value as? [String: Any] else { return } + let generation = dict["generation"] as? Int64 ?? Int64(dict["generation"] as? Int ?? 0) + let token = dict["token"] as? String ?? "" + if token.isEmpty || !isTransportGenerationActive(generation) { return } + sendTransportCheckAck(generation: generation, token: token) + } + + private func handleTransportCheckAck(_ message: Message) { + guard let dict = message.data.value as? [String: Any] else { return } + let generation = dict["generation"] as? Int64 ?? Int64(dict["generation"] as? Int ?? 0) + let source = dict["source"] as? String ?? "peer" + print("[transport_sync] phase=check_ack_rx source=\(source) generation=\(generation)") + guard isTransportGenerationActive(generation), hasActiveLocalSession() else { + print("[transport_sync] phase=check_ack_drop source=\(source) generation=\(generation) reason=inactive_or_no_lan") + return + } + markTransportGenerationValidated(generation, reason: "check_ack_rx") + sendTransportNominate(path: "lan", generation: generation, reason: "check_ack_rx") + AppState.shared.updatePeerTransportHint("wifi") + } + + private func handleTransportNominate(_ message: Message) { + guard let dict = message.data.value as? [String: Any] else { return } + let generation = dict["generation"] as? Int64 ?? Int64(dict["generation"] as? Int ?? 0) + let source = dict["source"] as? String ?? "peer" + let path = dict["path"] as? String ?? "relay" + print("[transport_sync] phase=nominate_rx source=\(source) generation=\(generation) path=\(path)") + guard isTransportGenerationActive(generation) else { + print("[transport_sync] phase=nominate_drop source=\(source) generation=\(generation) reason=inactive_generation") + return + } + if path == "lan" { + guard hasActiveLocalSession(), isTransportGenerationValidated(generation) else { + print("[transport_sync] phase=nominate_drop source=\(source) generation=\(generation) reason=lan_not_validated") + return + } + AppState.shared.updatePeerTransportHint("wifi") + } else if path == "relay" { + AppState.shared.updatePeerTransportHint("relay") + } + } + private func handleMacMediaControlRequest(_ message: Message) { if let dict = message.data.value as? [String: Any], let action = dict["action"] as? String { diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift index b1a57d17..2e7a8fc5 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift @@ -85,6 +85,85 @@ extension WebSocketServer { ]) } + func sendTransportOffer(reason: String, generation: Int64? = nil) { + let generationValue = generation ?? nextTransportGeneration() + beginTransportRound(generationValue, reason: "send_offer:\(reason)") + let ips = getLocalIPAddress(adapterName: AppState.shared.selectedNetworkAdapterName) ?? "" + let port = Int(localPort ?? Defaults.serverPort) + let candidates: [[String: Any]] = ips + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .map { ["ip": $0, "port": port, "type": "host"] } + + sendMessage(type: "transportOffer", data: [ + "source": "mac", + "generation": generationValue, + "candidates": candidates, + "port": port, + "ts": Int(Date().timeIntervalSince1970 * 1000), + "reason": reason + ]) + print("[transport_sync] phase=offer source=mac generation=\(generationValue) reason=\(reason) candidates=\(candidates.count)") + } + + func sendTransportAnswer(generation: Int64, reason: String) { + guard isTransportGenerationActive(generation) else { + print("[transport_sync] phase=answer_drop generation=\(generation) reason=inactive_generation") + return + } + let ips = getLocalIPAddress(adapterName: AppState.shared.selectedNetworkAdapterName) ?? "" + let port = Int(localPort ?? Defaults.serverPort) + let candidates: [[String: Any]] = ips + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .map { ["ip": $0, "port": port, "type": "host"] } + + sendMessage(type: "transportAnswer", data: [ + "source": "mac", + "generation": generation, + "candidates": candidates, + "port": port, + "ts": Int(Date().timeIntervalSince1970 * 1000), + "reason": reason + ]) + print("[transport_sync] phase=answer source=mac generation=\(generation) reason=\(reason) candidates=\(candidates.count)") + } + + func sendTransportCheckAck(generation: Int64, token: String) { + guard isTransportGenerationActive(generation) else { + print("[transport_sync] phase=check_ack_drop generation=\(generation) reason=inactive_generation") + return + } + sendMessage(type: "transportCheckAck", data: [ + "source": "mac", + "generation": generation, + "token": token, + "ts": Int(Date().timeIntervalSince1970 * 1000) + ]) + print("[transport_sync] phase=check_ack source=mac generation=\(generation)") + } + + func sendTransportNominate(path: String, generation: Int64, reason: String) { + guard isTransportGenerationActive(generation) else { + print("[transport_sync] phase=nominate_drop generation=\(generation) reason=inactive_generation") + return + } + if path == "lan" && !isTransportGenerationValidated(generation) { + print("[transport_sync] phase=nominate_drop generation=\(generation) reason=not_validated") + return + } + sendMessage(type: "transportNominate", data: [ + "source": "mac", + "generation": generation, + "path": path, + "ts": Int(Date().timeIntervalSince1970 * 1000), + "reason": reason + ]) + print("[transport_sync] phase=nominate source=mac generation=\(generation) path=\(path) reason=\(reason)") + } + func sendQuickShareTrigger() { // print("[websocket] Quick Share trigger requested") sendMessage(type: "startQuickShare", data: [:]) diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index 3c3b6195..40460409 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -47,6 +47,13 @@ class WebSocketServer: ObservableObject { internal var lastKnownAdapters: [(name: String, address: String)] = [] internal var lastLoggedSelectedAdapter: (name: String, address: String)? = nil + internal let transportGenerationTTL: TimeInterval = 120 + internal var transportGenerationCounter: Int64 = 0 + internal var activeTransportGeneration: Int64 = 0 + internal var activeTransportGenerationStartedAt: Date? + internal var validatedTransportGeneration: Int64 = 0 + internal let lanDownDebounceSeconds: TimeInterval = 2.5 + internal var pendingLanDownWorkItem: DispatchWorkItem? // Emits immediate events when the primary LAN WebSocket session starts or ends. // Used by AppState/UI to update LAN vs relay indicators without polling. @@ -168,6 +175,8 @@ class WebSocketServer: ObservableObject { stopAllServers() activeSessions.removeAll() primarySessionID = nil + pendingLanDownWorkItem?.cancel() + pendingLanDownWorkItem = nil stopPing() lock.unlock() publishLanTransportState(isActive: false, reason: "server_stop") @@ -185,6 +194,35 @@ class WebSocketServer: ObservableObject { /// Publishes LAN transport state changes in one place to keep UI and routing hints consistent. internal func publishLanTransportState(isActive: Bool, reason: String) { + // Debounce LAN-down transitions to avoid rapid relay<->lan oscillation when the + // local socket briefly stalls but recovers. + if !isActive { + lock.lock() + pendingLanDownWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + if self.hasActiveLocalSession() { + print("[transport_sync] direction=mac_local lan_state_debounce_skip=true reason=\(reason)") + return + } + self.publishLanTransportStateNow(isActive: false, reason: "\(reason)_debounced") + } + pendingLanDownWorkItem = work + let debounce = lanDownDebounceSeconds + lock.unlock() + + DispatchQueue.main.asyncAfter(deadline: .now() + debounce, execute: work) + return + } + + lock.lock() + pendingLanDownWorkItem?.cancel() + pendingLanDownWorkItem = nil + lock.unlock() + publishLanTransportStateNow(isActive: true, reason: reason) + } + + private func publishLanTransportStateNow(isActive: Bool, reason: String) { lock.lock() let previous = lastPublishedLanState if previous == isActive { @@ -202,6 +240,78 @@ class WebSocketServer: ObservableObject { sendPeerTransportStatus(isActive ? "wifi" : "relay") } + internal func nextTransportGeneration() -> Int64 { + lock.lock() + transportGenerationCounter += 1 + let value = transportGenerationCounter + lock.unlock() + return value + } + + internal func beginTransportRound(_ generation: Int64, reason: String) { + guard generation > 0 else { return } + lock.lock() + activeTransportGeneration = generation + activeTransportGenerationStartedAt = Date() + validatedTransportGeneration = 0 + lock.unlock() + print("[transport_sync] phase=round_begin generation=\(generation) reason=\(reason)") + } + + internal func isTransportGenerationActive(_ generation: Int64) -> Bool { + guard generation > 0 else { return false } + lock.lock() + let current = activeTransportGeneration + let startedAt = activeTransportGenerationStartedAt + lock.unlock() + guard current == generation, let startedAt else { return false } + return Date().timeIntervalSince(startedAt) <= transportGenerationTTL + } + + internal func acceptIncomingTransportGeneration(_ generation: Int64, reason: String) -> Bool { + guard generation > 0 else { return false } + lock.lock() + let current = activeTransportGeneration + let startedAt = activeTransportGenerationStartedAt + lock.unlock() + + if current == 0 { + beginTransportRound(generation, reason: "incoming_init:\(reason)") + return true + } + if current == generation { + return true + } + // Compatibility bridge: older builds used timestamp-based generations. + // If we detect mixed formats, prefer the incoming monotonic counter round. + if current > 1_000_000_000_000 && generation < 1_000_000_000 { + beginTransportRound(generation, reason: "incoming_legacy_format_reset:\(reason)") + return true + } + if let startedAt, Date().timeIntervalSince(startedAt) > transportGenerationTTL, generation > current { + beginTransportRound(generation, reason: "incoming_rollover:\(reason)") + return true + } + print("[transport_sync] phase=drop_stale_generation incoming=\(generation) active=\(current) reason=\(reason)") + return false + } + + internal func markTransportGenerationValidated(_ generation: Int64, reason: String) { + guard isTransportGenerationActive(generation) else { return } + lock.lock() + validatedTransportGeneration = generation + lock.unlock() + print("[transport_sync] phase=round_validated generation=\(generation) reason=\(reason)") + } + + internal func isTransportGenerationValidated(_ generation: Int64) -> Bool { + guard generation > 0 else { return false } + lock.lock() + let validated = validatedTransportGeneration + lock.unlock() + return validated == generation + } + /// Configures WebSocket routes and event callbacks. /// Handles message decryption before passing payload to the message router. private func setupWebSocket(for server: HttpServer) { @@ -523,6 +633,9 @@ class WebSocketServer: ObservableObject { } else { AirBridgeClient.shared.sendText(jsonString) } + + // Also emit a transport offer round so Android can immediately try LAN upgrade. + sendTransportOffer(reason: "mac_wake") } // MARK: - Crypto Helpers diff --git a/airsync-mac/Model/Message.swift b/airsync-mac/Model/Message.swift index 364bab75..cec74d3c 100644 --- a/airsync-mac/Model/Message.swift +++ b/airsync-mac/Model/Message.swift @@ -45,6 +45,12 @@ enum MessageType: String, Codable { case pong // peer transport hints case peerTransport + // relay-assisted LAN negotiation + case transportOffer + case transportAnswer + case transportCheck + case transportCheckAck + case transportNominate } struct Message: Codable { From 89c428a900f7a833cfb65fecf48c6bc5182fe5d6 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Sat, 21 Mar 2026 18:05:05 +0100 Subject: [PATCH 13/17] feat: Refactor ConnectionStatusPill to enhance connection icon color and help text based on connection state --- .../PhoneView/ConnectionStatusPill.swift | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift index 7270dc05..bb393fd9 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift @@ -19,21 +19,9 @@ struct ConnectionStatusPill: View { HStack(spacing: 8) { // Network Connection Icon Image(systemName: appState.isEffectivelyLocalTransport ? "wifi" : "globe") + .foregroundStyle(connectionIconColor) .contentTransition(.symbolEffect(.replace)) - .help(appState.isEffectivelyLocalTransport ? "Local WiFi" : "AirBridge Relay") - - // Peer health badge when in relay mode - if !appState.isEffectivelyLocalTransport, - case .relayActive = AirBridgeClient.shared.connectionState { - let online = AirBridgeClient.shared.isPeerConnected - Text(online ? "Peer online" : "Peer offline") - .font(.system(size: 10, weight: .semibold)) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background((online ? Color.green : Color.orange).opacity(0.2)) - .foregroundStyle(online ? Color.green : Color.orange) - .clipShape(Capsule()) - } + .help(connectionIconHelp) if appState.isPlus { if appState.adbConnecting { @@ -109,6 +97,26 @@ struct ConnectionStatusPill: View { return "Wireless ADB Connection" } } + + private var connectionIconColor: Color { + if appState.isEffectivelyLocalTransport { + return .primary + } + if case .relayActive = AirBridgeClient.shared.connectionState { + return AirBridgeClient.shared.isPeerConnected ? .green : .orange + } + return .primary + } + + private var connectionIconHelp: String { + if appState.isEffectivelyLocalTransport { + return "Local WiFi" + } + if case .relayActive = AirBridgeClient.shared.connectionState { + return AirBridgeClient.shared.isPeerConnected ? "AirBridge Relay (peer online)" : "AirBridge Relay (peer offline)" + } + return "AirBridge Relay" + } } struct ConnectionPillPopover: View { From 37172811e27e735db8fe50a8628bf9730d0b2699 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Tue, 24 Mar 2026 18:05:30 +0100 Subject: [PATCH 14/17] feat: Implement ADB connection handling based on transport type - Added `requestConnectionFromCurrentTransport` method in ADBConnector to manage ADB connection logic for local LAN and relay-only sessions. - Updated ConnectionStatusPill and SettingsFeaturesView to utilize the new ADB connection method, streamlining connection requests and improving user feedback. --- airsync-mac/Core/Util/CLI/ADBConnector.swift | 51 +++++++++++++++++++ .../PhoneView/ConnectionStatusPill.swift | 10 +--- .../Settings/SettingsFeaturesView.swift | 18 +++---- 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/airsync-mac/Core/Util/CLI/ADBConnector.swift b/airsync-mac/Core/Util/CLI/ADBConnector.swift index 5f95f996..79b28078 100644 --- a/airsync-mac/Core/Util/CLI/ADBConnector.swift +++ b/airsync-mac/Core/Util/CLI/ADBConnector.swift @@ -124,6 +124,57 @@ struct ADBConnector { connectionLock.unlock() } + /// Entry point used by UI actions. + /// Policy: + /// - Local LAN session: keep existing behavior (refresh ports and allow wireless/wired logic). + /// - Relay-only session: allow ONLY wired ADB, never wireless over relay. + static func requestConnectionFromCurrentTransport() { + DispatchQueue.main.async { + if AppState.shared.adbConnecting { return } + + let hasLocalSession = WebSocketServer.shared.hasActiveLocalSession() + let isRelayOnly = !hasLocalSession && AirBridgeClient.shared.connectionState == .relayActive + + // Default state for a fresh manual request + AppState.shared.adbConnectionResult = "" + AppState.shared.manualAdbConnectionPending = false + + if isRelayOnly { + guard AppState.shared.wiredAdbEnabled else { + AppState.shared.adbConnectionResult = "Relay mode allows only Wired ADB. Enable Wired ADB and connect via USB." + return + } + + AppState.shared.adbConnecting = true + AppState.shared.adbConnectionResult = "Searching wired ADB device (USB)..." + + getWiredDeviceSerial { serial in + DispatchQueue.main.async { + AppState.shared.adbConnecting = false + if let serial { + AppState.shared.adbConnected = true + AppState.shared.adbConnectionMode = .wired + AppState.shared.adbConnectionResult = "Connected via Wired ADB (Serial: \(serial))" + } else { + AppState.shared.adbConnected = false + AppState.shared.adbConnectionResult = "No wired ADB device detected. Connect USB and authorize debugging on the phone." + } + } + } + return + } + + guard hasLocalSession else { + AppState.shared.adbConnectionResult = "No local LAN session available. Connect on LAN or use relay with Wired ADB enabled." + return + } + + AppState.shared.manualAdbConnectionPending = true + WebSocketServer.shared.sendRefreshAdbPortsRequest() + AppState.shared.adbConnectionResult = "Refreshing latest ADB ports from device..." + } + } + static func connectToADB(ip: String) { connectionLock.lock() if isConnecting { diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift index bb393fd9..e4bccedb 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift @@ -190,15 +190,7 @@ struct ConnectionPillPopover: View { primary: false, action: { if !appState.adbConnecting { - guard appState.isEffectivelyLocalTransport else { - appState.adbConnectionResult = "ADB works only on local LAN connections. Relay mode is not supported for ADB." - appState.manualAdbConnectionPending = false - return - } - appState.adbConnectionResult = "" // Clear console - appState.manualAdbConnectionPending = true - WebSocketServer.shared.sendRefreshAdbPortsRequest() - appState.adbConnectionResult = "Refreshing latest ADB ports from device..." + ADBConnector.requestConnectionFromCurrentTransport() } } ) diff --git a/airsync-mac/Screens/Settings/SettingsFeaturesView.swift b/airsync-mac/Screens/Settings/SettingsFeaturesView.swift index 17eefb78..e2bb8650 100644 --- a/airsync-mac/Screens/Settings/SettingsFeaturesView.swift +++ b/airsync-mac/Screens/Settings/SettingsFeaturesView.swift @@ -59,20 +59,18 @@ struct SettingsFeaturesView: View { systemImage: appState.adbConnecting ? "hourglass" : "play.circle", action: { if !appState.adbConnecting { - guard WebSocketServer.shared.hasActiveLocalSession() else { - appState.adbConnectionResult = "ADB works only on local LAN connections. Relay mode is not supported for ADB." - appState.manualAdbConnectionPending = false - return - } - appState.adbConnectionResult = "" // Clear console - appState.manualAdbConnectionPending = true - WebSocketServer.shared.sendRefreshAdbPortsRequest() - appState.adbConnectionResult = "Refreshing latest ADB ports from device..." + ADBConnector.requestConnectionFromCurrentTransport() } } ) .disabled( - appState.device == nil || appState.adbConnecting || !AppState.shared.isPlus || !WebSocketServer.shared.hasActiveLocalSession() + appState.device == nil || + appState.adbConnecting || + !AppState.shared.isPlus || + ( + !WebSocketServer.shared.hasActiveLocalSession() && + !(AirBridgeClient.shared.connectionState == .relayActive && appState.wiredAdbEnabled) + ) ) } From 8ce6accaad282ee137e0493391baa30eecd1f038 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Wed, 25 Mar 2026 19:49:49 +0100 Subject: [PATCH 15/17] feat: Implement HMAC challenge-response authentication in AirBridgeClient - Added support for HMAC challenge-response authentication during WebSocket connection. - Introduced `AirBridgeChallengeMessage` and updated `AirBridgeRegisterMessage` to include HMAC signature and initialization key. - Enhanced connection state management to handle challenge reception and registration flow. - Updated UI to reflect the new challenge state in connection status. --- .../Core/AirBridge/AirBridgeClient.swift | 162 ++++++++++++------ .../Core/AirBridge/AirBridgeModels.swift | 26 ++- .../Settings/AirBridgeSettingsView.swift | 1 + 3 files changed, 127 insertions(+), 62 deletions(-) diff --git a/airsync-mac/Core/AirBridge/AirBridgeClient.swift b/airsync-mac/Core/AirBridge/AirBridgeClient.swift index c512a3a7..35f23576 100644 --- a/airsync-mac/Core/AirBridge/AirBridgeClient.swift +++ b/airsync-mac/Core/AirBridge/AirBridgeClient.swift @@ -99,6 +99,9 @@ class AirBridgeClient: ObservableObject { private var connectionGeneration: Int = 0 private var pendingReconnectWorkItem: DispatchWorkItem? + /// Tracks the nonce from the server's challenge message for HMAC computation + private var pendingNonce: String? + private init() { // Observe system wake events so we can notify Android via relay and trigger a LAN reconnect. NSWorkspace.shared.notificationCenter.addObserver( @@ -158,16 +161,13 @@ class AirBridgeClient: ObservableObject { /// Tests connectivity to a relay server without affecting the live connection. /// - /// Opens an isolated WebSocket, sends a registration frame, and considers success - /// if both the WebSocket handshake and the send complete without error. The server - /// does **not** reply to a registration when the peer is not yet connected, so we - /// cannot wait for a response — a successful send is sufficient proof that the - /// relay is reachable and accepting connections. + /// Opens an isolated WebSocket, performs the 2-step HMAC challenge-response + /// handshake, and considers success if the handshake completes without error. /// /// - Parameters: /// - url: Raw relay URL (will be normalised, same as `relayServerURL`). /// - pairingId: Pairing ID to register with. - /// - secret: Plain-text secret (will be SHA-256 hashed before sending). + /// - secret: Plain-text secret (will be SHA-256 hashed for HMAC). /// - timeout: Maximum seconds to wait (default 8 s). /// - completion: Called on the **main thread** with `.success(())` or `.failure(error)`. func testConnectivity( @@ -185,13 +185,6 @@ class AirBridgeClient: ObservableObject { return } - // SHA-256 hash the secret - let secretHash: String = { - let data = Data(secret.utf8) - let hash = SHA256.hash(data: data) - return hash.compactMap { String(format: "%02x", $0) }.joined() - }() - let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = timeout let session = URLSession(configuration: config) @@ -218,28 +211,52 @@ class AirBridgeClient: ObservableObject { settle(.failure(ConnectivityError.timeout)) } - // Build registration frame - let regMessage = AirBridgeRegisterMessage( - action: .register, - role: "mac", - pairingId: pairingId, - secret: secretHash, - localIp: "0.0.0.0", - port: 0 - ) + // Wait for challenge from server (step 1) + task.receive { [weak self] result in + guard self != nil else { + settle(.failure(ConnectivityError.timeout)) + return + } - guard let regData = try? JSONEncoder().encode(regMessage), - let regJSON = String(data: regData, encoding: .utf8) else { - settle(.failure(ConnectivityError.encodingFailed)) - return - } + switch result { + case .success(let message): + guard case .string(let text) = message, + let data = text.data(using: .utf8), + let challengeMsg = try? JSONDecoder().decode(AirBridgeChallengeMessage.self, from: data), + challengeMsg.action == .challenge else { + settle(.failure(ConnectivityError.encodingFailed)) + return + } - // Send registration — the server silently accepts registrations without replying until a peer connects, so a successful send = server is alive. - task.send(.string(regJSON)) { sendError in - if let sendError = sendError { - settle(.failure(sendError)) - } else { - settle(.success(())) + // Compute HMAC (step 2) + let (sig, kInit) = Self.computeHMAC(secretRaw: secret, nonce: challengeMsg.nonce, pairingId: pairingId, role: "mac") + + let regMessage = AirBridgeRegisterMessage( + action: .register, + role: "mac", + pairingId: pairingId, + sig: sig, + kInit: kInit, + localIp: "0.0.0.0", + port: 0 + ) + + guard let regData = try? JSONEncoder().encode(regMessage), + let regJSON = String(data: regData, encoding: .utf8) else { + settle(.failure(ConnectivityError.encodingFailed)) + return + } + + task.send(.string(regJSON)) { sendError in + if let sendError = sendError { + settle(.failure(sendError)) + } else { + settle(.success(())) + } + } + + case .failure(let error): + settle(.failure(error)) } } } @@ -276,6 +293,31 @@ class AirBridgeClient: ObservableObject { return "airbridge://\(urlEncoded)/\(pairingId)/\(secret)" } + // MARK: - HMAC Computation + + /// Computes the HMAC-SHA256 signature and kInit for challenge-response auth. + /// - Parameters: + /// - secretRaw: The plain-text secret from Keychain + /// - nonce: The server-provided nonce from the challenge message + /// - pairingId: The pairing ID + /// - role: The client role ("mac" or "android") + /// - Returns: Tuple of (sig, kInit) both hex-encoded + static func computeHMAC(secretRaw: String, nonce: String, pairingId: String, role: String) -> (sig: String, kInit: String) { + // K = SHA256(secret_raw) as raw bytes + let kData = Data(SHA256.hash(data: Data(secretRaw.utf8))) + let key = SymmetricKey(data: kData) + + // kInit = hex(K) — sent only for session bootstrap + let kInit = kData.map { String(format: "%02x", $0) }.joined() + + // message = nonce|pairingId|role + let message = "\(nonce)|\(pairingId)|\(role)" + let mac = HMAC.authenticationCode(for: Data(message.utf8), using: key) + let sig = Data(mac).map { String(format: "%02x", $0) }.joined() + + return (sig, kInit) + } + // MARK: - Connection Logic private func connectInternal() { @@ -300,6 +342,7 @@ class AirBridgeClient: ObservableObject { isManuallyDisconnected = false pendingReconnectWorkItem?.cancel() pendingReconnectWorkItem = nil + pendingNonce = nil connectionGeneration += 1 let generation = connectionGeneration DispatchQueue.main.async { self.connectionState = .connecting } @@ -312,25 +355,17 @@ class AirBridgeClient: ObservableObject { webSocketTask = urlSession?.webSocketTask(with: url) webSocketTask?.resume() - // Start receiving messages + // Start receiving messages — the first message should be the challenge receiveLoopActive = true startReceiving(expectedGeneration: generation) - - // Send registration - sendRegistration(expectedGeneration: generation) - } - - /// Derives a SHA-256 hash of the raw secret so the plaintext never leaves the device. - /// The relay server only ever sees (and stores) this hash. - private func hashedSecret() -> String { - let data = Data(secret.utf8) - let hash = SHA256.hash(data: data) - return hash.compactMap { String(format: "%02x", $0) }.joined() } - private func sendRegistration(expectedGeneration: Int) { + /// Handles the challenge message from the server and sends the HMAC register response. + private func handleChallenge(nonce: String, expectedGeneration: Int) { guard expectedGeneration == connectionGeneration else { return } - DispatchQueue.main.async { self.connectionState = .registering } + DispatchQueue.main.async { self.connectionState = .challengeReceived } + + let (sig, kInit) = Self.computeHMAC(secretRaw: secret, nonce: nonce, pairingId: pairingId, role: "mac") let localIP = WebSocketServer.shared.getLocalIPAddress( adapterName: AppState.shared.selectedNetworkAdapterName @@ -341,7 +376,8 @@ class AirBridgeClient: ObservableObject { action: .register, role: "mac", pairingId: pairingId, - secret: hashedSecret(), + sig: sig, + kInit: kInit, localIp: localIP, port: port ) @@ -349,6 +385,7 @@ class AirBridgeClient: ObservableObject { do { let data = try JSONEncoder().encode(regMessage) if let jsonString = String(data: data, encoding: .utf8) { + DispatchQueue.main.async { self.connectionState = .registering } webSocketTask?.send(.string(jsonString)) { [weak self] error in guard let self = self else { return } self.queue.async { @@ -357,7 +394,7 @@ class AirBridgeClient: ObservableObject { print("[airbridge] Registration send failed: \(error.localizedDescription)") self.scheduleReconnect(sourceGeneration: expectedGeneration) } else { - print("[airbridge] Registration sent for pairingId: \(self.pairingId)") + print("[airbridge] Registration sent (HMAC auth) for pairingId: \(self.pairingId)") DispatchQueue.main.async { self.connectionState = .waitingForPeer } @@ -382,7 +419,7 @@ class AirBridgeClient: ObservableObject { guard self.receiveLoopActive, expectedGeneration == self.connectionGeneration else { return } switch result { case .success(let message): - self.handleMessage(message) + self.handleMessage(message, expectedGeneration: expectedGeneration) // Continue receiving self.startReceiving(expectedGeneration: expectedGeneration) case .failure(let error): @@ -394,10 +431,10 @@ class AirBridgeClient: ObservableObject { } } - private func handleMessage(_ message: URLSessionWebSocketTask.Message) { + private func handleMessage(_ message: URLSessionWebSocketTask.Message, expectedGeneration: Int) { switch message { case .string(let text): - handleTextMessage(text) + handleTextMessage(text, expectedGeneration: expectedGeneration) case .data(let data): handleBinaryMessage(data) @unknown default: @@ -405,12 +442,22 @@ class AirBridgeClient: ObservableObject { } } - private func handleTextMessage(_ text: String) { + private func handleTextMessage(_ text: String, expectedGeneration: Int) { // First, try to parse as an AirBridge control message if let data = text.data(using: .utf8), let baseMsg = try? JSONDecoder().decode(AirBridgeBaseMessage.self, from: data) { switch baseMsg.action { + case .challenge: + // Server sent us a challenge — compute HMAC and respond with register + if let challengeMsg = try? JSONDecoder().decode(AirBridgeChallengeMessage.self, from: data) { + print("[airbridge] Challenge received, computing HMAC...") + handleChallenge(nonce: challengeMsg.nonce, expectedGeneration: expectedGeneration) + } else { + print("[airbridge] Failed to decode challenge message") + } + return + case .relayStarted: print("[airbridge] Relay tunnel established!") queue.async { [weak self] in @@ -481,7 +528,13 @@ class AirBridgeClient: ObservableObject { } let pingJson = "{\"type\":\"ping\"}" - self.sendText(pingJson) + // Encrypt ping + if let key = WebSocketServer.shared.symmetricKey, + let encrypted = encryptMessage(pingJson, using: key) { + self.sendText(encrypted) + } else { + self.sendText(pingJson) + } } self.pingTimer = timer timer.resume() @@ -542,6 +595,7 @@ class AirBridgeClient: ObservableObject { private func tearDown(reason: String) { receiveLoopActive = false + pendingNonce = nil webSocketTask?.cancel(with: .goingAway, reason: reason.data(using: .utf8)) webSocketTask = nil urlSession?.invalidateAndCancel() diff --git a/airsync-mac/Core/AirBridge/AirBridgeModels.swift b/airsync-mac/Core/AirBridge/AirBridgeModels.swift index 20defdc4..d4e2092c 100644 --- a/airsync-mac/Core/AirBridge/AirBridgeModels.swift +++ b/airsync-mac/Core/AirBridge/AirBridgeModels.swift @@ -12,6 +12,7 @@ import Foundation /// Actions supported by the AirBridge relay protocol. enum AirBridgeAction: String, Codable { case register = "register" + case challenge = "challenge" // Server → Client: HMAC challenge with nonce case query = "query" case macInfo = "mac_info" case requestRelay = "request_relay" @@ -21,12 +22,13 @@ enum AirBridgeAction: String, Codable { // MARK: - Outgoing Messages -/// Registration message sent by the Mac to the relay server upon connection. +/// Registration message sent by the Mac to the relay server after receiving a challenge. struct AirBridgeRegisterMessage: Codable { let action: AirBridgeAction let role: String let pairingId: String - let secret: String + let sig: String // HMAC-SHA256(K, nonce|pairingId|role) hex-encoded + let kInit: String // SHA256(secret_raw) hex-encoded, for session bootstrap let localIp: String let port: Int } @@ -39,6 +41,12 @@ struct AirBridgeBaseMessage: Codable { let pairingId: String? } +/// Challenge message received from the relay server immediately after WS connect. +struct AirBridgeChallengeMessage: Codable { + let action: AirBridgeAction + let nonce: String +} + /// Error message received from the relay server. struct AirBridgeErrorMessage: Codable { let action: AirBridgeAction @@ -60,6 +68,7 @@ struct AirBridgeConfigBlob: Codable { enum AirBridgeConnectionState: Equatable { case disconnected case connecting + case challengeReceived // Received nonce, computing HMAC case registering case waitingForPeer case relayActive @@ -67,12 +76,13 @@ enum AirBridgeConnectionState: Equatable { var displayName: String { switch self { - case .disconnected: return "Disconnected" - case .connecting: return "Connecting…" - case .registering: return "Registering…" - case .waitingForPeer: return "Waiting for Android…" - case .relayActive: return "Relay Active" - case .failed(let err): return "Error: \(err)" + case .disconnected: return "Disconnected" + case .connecting: return "Connecting…" + case .challengeReceived: return "Authenticating…" + case .registering: return "Registering…" + case .waitingForPeer: return "Waiting for Android…" + case .relayActive: return "Relay Active" + case .failed(let err): return "Error: \(err)" } } diff --git a/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift b/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift index 2ee877e7..036721ee 100644 --- a/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift +++ b/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift @@ -190,6 +190,7 @@ struct AirBridgeSettingsView: View { switch airBridge.connectionState { case .disconnected: return .gray case .connecting: return .orange + case .challengeReceived: return .orange case .registering: return .orange case .waitingForPeer: return .yellow case .relayActive: return .green From 5402e31986b5b6b4fc03c1d516bc68d504dab05d Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Thu, 26 Mar 2026 15:31:46 +0100 Subject: [PATCH 16/17] feat: Update Keychain service identifier and enhance UI text for AirBridge - Changed Keychain service identifier to "com.sameerasw.airsync.trial" for trial version support. - Updated AirBridgeSetupView title to "AirBridge Relay (Beta)" to indicate beta status. - Modified default relay URL placeholder in AirBridgeSettingsView for clearer user guidance. --- airsync-mac/Core/Util/SecureStorage/KeychainStorage.swift | 2 +- airsync-mac/Screens/OnboardingView/AirBridgeSetupView.swift | 2 +- airsync-mac/Screens/Settings/AirBridgeSettingsView.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/airsync-mac/Core/Util/SecureStorage/KeychainStorage.swift b/airsync-mac/Core/Util/SecureStorage/KeychainStorage.swift index 56b74be9..72b1b033 100644 --- a/airsync-mac/Core/Util/SecureStorage/KeychainStorage.swift +++ b/airsync-mac/Core/Util/SecureStorage/KeychainStorage.swift @@ -17,7 +17,7 @@ import Security /// preload read, writes within the same app session usually /// succeed without an additional prompt. enum KeychainStorage { - private static let service = "com.sameerasw.airsync" + private static let service = "com.sameerasw.airsync.trial" /// In-memory cache: account key → raw Data value. private static var cache: [String: Data] = [:] diff --git a/airsync-mac/Screens/OnboardingView/AirBridgeSetupView.swift b/airsync-mac/Screens/OnboardingView/AirBridgeSetupView.swift index 84c4e457..c4d335c6 100644 --- a/airsync-mac/Screens/OnboardingView/AirBridgeSetupView.swift +++ b/airsync-mac/Screens/OnboardingView/AirBridgeSetupView.swift @@ -27,7 +27,7 @@ struct AirBridgeSetupView: View { VStack(spacing: 20) { ScrollView { VStack(spacing: 20) { - Text("AirBridge Relay") + Text("AirBridge Relay (Beta)") .font(.title) .multilineTextAlignment(.center) .padding() diff --git a/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift b/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift index 036721ee..8924f925 100644 --- a/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift +++ b/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift @@ -62,7 +62,7 @@ struct AirBridgeSettingsView: View { HStack { Label("Relay Server", systemImage: "server.rack") Spacer() - TextField("wss://airbridge", text: $relayURL) + TextField("airbridge.yourdomain.com", text: $relayURL) .textFieldStyle(.roundedBorder) .frame(maxWidth: 220) .onSubmit { saveRelayURL() } From 6823ff2952d89fb8666a8f61ab7d2c6f2c331383 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Thu, 26 Mar 2026 17:00:49 +0100 Subject: [PATCH 17/17] refactor: Remove debug print statements and enhance AirBridge settings label - Removed unnecessary debug print statements across multiple files to clean up the codebase. - Updated the AirBridgeSettingsView label to indicate the feature is in beta, improving clarity for users. --- .../Core/AirBridge/AirBridgeClient.swift | 17 +++---------- .../Core/Discovery/UDPDiscoveryManager.swift | 4 --- .../Core/QuickShare/QuickShareManager.swift | 1 - .../Util/SecureStorage/KeychainStorage.swift | 8 ------ .../WebSocket/WebSocketServer+Handlers.swift | 22 ---------------- .../WebSocket/WebSocketServer+Outgoing.swift | 25 ------------------- .../Core/WebSocket/WebSocketServer+Ping.swift | 1 - .../Core/WebSocket/WebSocketServer.swift | 23 ++++------------- .../Settings/AirBridgeSettingsView.swift | 2 +- 9 files changed, 9 insertions(+), 94 deletions(-) diff --git a/airsync-mac/Core/AirBridge/AirBridgeClient.swift b/airsync-mac/Core/AirBridge/AirBridgeClient.swift index 35f23576..65f40a44 100644 --- a/airsync-mac/Core/AirBridge/AirBridgeClient.swift +++ b/airsync-mac/Core/AirBridge/AirBridgeClient.swift @@ -322,7 +322,6 @@ class AirBridgeClient: ObservableObject { private func connectInternal() { guard !relayServerURL.isEmpty else { - print("[airbridge] Relay URL is empty, skipping connection") DispatchQueue.main.async { self.connectionState = .disconnected } return } @@ -334,7 +333,7 @@ class AirBridgeClient: ObservableObject { let normalizedURL = normalizeRelayURL(relayServerURL) guard let url = URL(string: normalizedURL) else { - print("[airbridge] Invalid relay URL: \(normalizedURL)") + print("[airbridge] Invalid relay URL") DispatchQueue.main.async { self.connectionState = .failed(error: "Invalid URL") } return } @@ -394,7 +393,6 @@ class AirBridgeClient: ObservableObject { print("[airbridge] Registration send failed: \(error.localizedDescription)") self.scheduleReconnect(sourceGeneration: expectedGeneration) } else { - print("[airbridge] Registration sent (HMAC auth) for pairingId: \(self.pairingId)") DispatchQueue.main.async { self.connectionState = .waitingForPeer } @@ -451,7 +449,6 @@ class AirBridgeClient: ObservableObject { case .challenge: // Server sent us a challenge — compute HMAC and respond with register if let challengeMsg = try? JSONDecoder().decode(AirBridgeChallengeMessage.self, from: data) { - print("[airbridge] Challenge received, computing HMAC...") handleChallenge(nonce: challengeMsg.nonce, expectedGeneration: expectedGeneration) } else { print("[airbridge] Failed to decode challenge message") @@ -496,13 +493,12 @@ class AirBridgeClient: ObservableObject { // If it's not a control message, it's a relayed message from Android. // Forward it to the local WebSocket handler as if it came from a LAN client. - print("[airbridge] Relaying text message from Android (\(text.count) chars)") WebSocketServer.shared.handleRelayedMessage(text) } private func handleBinaryMessage(_ data: Data) { // Binary data from the relay is currently unused in the AirSync protocol - print("[airbridge] Received binary message from Android (\(data.count) bytes) - Ignored") + _ = data } private func startPingLoop() { @@ -546,9 +542,6 @@ class AirBridgeClient: ObservableObject { guard let self = self else { return } self.lastPongReceived = Date() DispatchQueue.main.async { - if !self.isPeerConnected { - print("[airbridge] Peer connected via relay (pong received).") - } self.isPeerConnected = true } } @@ -569,7 +562,6 @@ class AirBridgeClient: ObservableObject { private func scheduleReconnect(sourceGeneration: Int) { guard !isManuallyDisconnected else { return } guard sourceGeneration == connectionGeneration else { - print("[airbridge] Skipping reconnect from stale session \(sourceGeneration)") return } @@ -578,7 +570,6 @@ class AirBridgeClient: ObservableObject { let delay = min(pow(2.0, Double(reconnectAttempt)), maxReconnectDelay) reconnectAttempt += 1 - print("[airbridge] Reconnecting in \(delay)s (attempt \(reconnectAttempt))") DispatchQueue.main.async { self.connectionState = .connecting } @@ -610,8 +601,6 @@ class AirBridgeClient: ObservableObject { self.isPeerConnected = false } } - - print("[airbridge] Torn down: \(reason)") } // MARK: - Helpers @@ -632,7 +621,7 @@ class AirBridgeClient: ObservableObject { // If user explicitly provided ws://, only allow it for private/localhost hosts. // Upgrade to wss:// for public hosts to prevent cleartext transport over the internet. if url.hasPrefix("ws://") && !url.hasPrefix("wss://") && !isPrivate { - print("[airbridge] SECURITY: Upgrading ws:// to wss:// for public host: \(host)") + print("[airbridge] SECURITY: Upgrading ws:// to wss:// for public host") url = "wss://" + String(url.dropFirst(5)) } diff --git a/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift b/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift index 26b0a9ae..ec3ef8fd 100644 --- a/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift +++ b/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift @@ -105,25 +105,21 @@ class UDPDiscoveryManager: ObservableObject { // T+2s: Force WebSocket Server to re-evaluate network binding DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - print("[Discovery] Wake recovery: Requesting WebSocket restart...") WebSocketServer.shared.requestRestart(reason: "System Wake Recovery", delay: 0.1) } // T+3s: Burst 1 (Post-restart) DispatchQueue.global().asyncAfter(deadline: .now() + 3.0) { [weak self] in - print("[Discovery] Wake recovery: Burst 1") self?.broadcastBurst() } // T+6s: Burst 2 (Retry) DispatchQueue.global().asyncAfter(deadline: .now() + 6.0) { [weak self] in - print("[Discovery] Wake recovery: Burst 2") self?.broadcastBurst() } // T+10s: Burst 3 (Final retry) DispatchQueue.global().asyncAfter(deadline: .now() + 10.0) { [weak self] in - print("[Discovery] Wake recovery: Burst 3") self?.broadcastBurst() } } diff --git a/airsync-mac/Core/QuickShare/QuickShareManager.swift b/airsync-mac/Core/QuickShare/QuickShareManager.swift index bb37dd0a..b07bbe0d 100644 --- a/airsync-mac/Core/QuickShare/QuickShareManager.swift +++ b/airsync-mac/Core/QuickShare/QuickShareManager.swift @@ -152,7 +152,6 @@ public class QuickShareManager: NSObject, ObservableObject, MainAppDelegate, Sha // If we are only connected via relay (no local LAN session), block Quick Share sends. if AirBridgeClient.shared.connectionState.isConnected, !AppState.shared.isEffectivelyLocalTransport { - print("[quickshare] Quick Share send blocked: relay-only connection (no LAN session)") return } transferState = .connecting(deviceID) diff --git a/airsync-mac/Core/Util/SecureStorage/KeychainStorage.swift b/airsync-mac/Core/Util/SecureStorage/KeychainStorage.swift index 72b1b033..4d9f2c2d 100644 --- a/airsync-mac/Core/Util/SecureStorage/KeychainStorage.swift +++ b/airsync-mac/Core/Util/SecureStorage/KeychainStorage.swift @@ -50,11 +50,6 @@ enum KeychainStorage { guard status == errSecSuccess, let items = result as? [[String: Any]] else { - #if DEBUG - if status != errSecItemNotFound { - print("[Keychain] preload: SecItemCopyMatching returned \(status)") - } - #endif return } @@ -67,9 +62,6 @@ enum KeychainStorage { } lock.unlock() - #if DEBUG - print("[Keychain] preload: cached \(items.count) item(s)") - #endif } // MARK: - Read diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index 528e8141..1a906611 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -211,7 +211,6 @@ extension WebSocketServer { // never through the relay transport. guard self.hasActiveLocalSession() else { AppState.shared.manualAdbConnectionPending = false - print("[adb] Skipping ADB auto-connect: no active local session (relay-only connection)") return } if AppState.shared.wiredAdbEnabled { @@ -660,13 +659,7 @@ extension WebSocketServer { private func handlePeerTransportUpdate(_ message: Message) { guard let dict = message.data.value as? [String: Any] else { return } let transport = dict["transport"] as? String - let source = dict["source"] as? String ?? "peer" - - let oldHint = AppState.shared.peerTransportHint AppState.shared.updatePeerTransportHint(transport) - let newHint = AppState.shared.peerTransportHint - - print("[transport_sync] direction=android->mac source=\(source) transport_raw=\(transport ?? "nil") hint_old=\(oldHint.rawValue) hint_new=\(newHint.rawValue)") } private func isTransportMessageFresh(_ dict: [String: Any]) -> Bool { @@ -732,15 +725,11 @@ extension WebSocketServer { private func handleTransportOffer(_ message: Message) { guard let dict = message.data.value as? [String: Any] else { return } let generation = dict["generation"] as? Int64 ?? Int64(dict["generation"] as? Int ?? 0) - let source = dict["source"] as? String ?? "peer" - print("[transport_sync] phase=offer_rx source=\(source) generation=\(generation)") guard isTransportMessageFresh(dict) else { - print("[transport_sync] phase=offer_drop generation=\(generation) reason=stale_ts") return } let candidateEval = evaluateTransportCandidates(dict) guard candidateEval.isValid else { - print("[transport_sync] phase=offer_drop generation=\(generation) reason=invalid_candidates details=\(candidateEval.reason)") return } guard acceptIncomingTransportGeneration(generation, reason: "offer_rx") else { @@ -752,15 +741,11 @@ extension WebSocketServer { private func handleTransportAnswer(_ message: Message) { guard let dict = message.data.value as? [String: Any] else { return } let generation = dict["generation"] as? Int64 ?? Int64(dict["generation"] as? Int ?? 0) - let source = dict["source"] as? String ?? "peer" - print("[transport_sync] phase=answer_rx source=\(source) generation=\(generation)") guard isTransportMessageFresh(dict) else { - print("[transport_sync] phase=answer_drop generation=\(generation) reason=stale_ts") return } let candidateEval = evaluateTransportCandidates(dict) guard candidateEval.isValid else { - print("[transport_sync] phase=answer_drop generation=\(generation) reason=invalid_candidates details=\(candidateEval.reason)") return } _ = acceptIncomingTransportGeneration(generation, reason: "answer_rx") @@ -777,10 +762,7 @@ extension WebSocketServer { private func handleTransportCheckAck(_ message: Message) { guard let dict = message.data.value as? [String: Any] else { return } let generation = dict["generation"] as? Int64 ?? Int64(dict["generation"] as? Int ?? 0) - let source = dict["source"] as? String ?? "peer" - print("[transport_sync] phase=check_ack_rx source=\(source) generation=\(generation)") guard isTransportGenerationActive(generation), hasActiveLocalSession() else { - print("[transport_sync] phase=check_ack_drop source=\(source) generation=\(generation) reason=inactive_or_no_lan") return } markTransportGenerationValidated(generation, reason: "check_ack_rx") @@ -791,16 +773,12 @@ extension WebSocketServer { private func handleTransportNominate(_ message: Message) { guard let dict = message.data.value as? [String: Any] else { return } let generation = dict["generation"] as? Int64 ?? Int64(dict["generation"] as? Int ?? 0) - let source = dict["source"] as? String ?? "peer" let path = dict["path"] as? String ?? "relay" - print("[transport_sync] phase=nominate_rx source=\(source) generation=\(generation) path=\(path)") guard isTransportGenerationActive(generation) else { - print("[transport_sync] phase=nominate_drop source=\(source) generation=\(generation) reason=inactive_generation") return } if path == "lan" { guard hasActiveLocalSession(), isTransportGenerationValidated(generation) else { - print("[transport_sync] phase=nominate_drop source=\(source) generation=\(generation) reason=lan_not_validated") return } AppState.shared.updatePeerTransportHint("wifi") diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift index 2e7a8fc5..b543eec2 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift @@ -8,14 +8,6 @@ import Swifter import CryptoKit extension WebSocketServer { - private func messageTypeForLog(_ message: String) -> String { - guard let data = message.data(using: .utf8), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let type = obj["type"] as? String else { - return "non_json" - } - return type - } // MARK: - Sending Helpers @@ -32,7 +24,6 @@ extension WebSocketServer { let session = pId != nil ? activeSessions.first(where: { ObjectIdentifier($0) == pId }) : nil let key = symmetricKey lock.unlock() - let type = messageTypeForLog(message) let outgoing: String if let key = key, let encrypted = encryptMessage(message, using: key) { @@ -42,16 +33,9 @@ extension WebSocketServer { } if let session = session { - // Local session available — send directly - print("[transport] TX via LAN type=\(type)") session.writeText(outgoing) } else if AirBridgeClient.shared.connectionState == .relayActive { - // No local session, but AirBridge relay is active — tunnel through relay - print("[transport] TX via RELAY type=\(type)") AirBridgeClient.shared.sendText(outgoing) - } else { - // No connection available at all - print("[transport] DROP TX type=\(type) no local session or relay") } } @@ -104,12 +88,10 @@ extension WebSocketServer { "ts": Int(Date().timeIntervalSince1970 * 1000), "reason": reason ]) - print("[transport_sync] phase=offer source=mac generation=\(generationValue) reason=\(reason) candidates=\(candidates.count)") } func sendTransportAnswer(generation: Int64, reason: String) { guard isTransportGenerationActive(generation) else { - print("[transport_sync] phase=answer_drop generation=\(generation) reason=inactive_generation") return } let ips = getLocalIPAddress(adapterName: AppState.shared.selectedNetworkAdapterName) ?? "" @@ -128,12 +110,10 @@ extension WebSocketServer { "ts": Int(Date().timeIntervalSince1970 * 1000), "reason": reason ]) - print("[transport_sync] phase=answer source=mac generation=\(generation) reason=\(reason) candidates=\(candidates.count)") } func sendTransportCheckAck(generation: Int64, token: String) { guard isTransportGenerationActive(generation) else { - print("[transport_sync] phase=check_ack_drop generation=\(generation) reason=inactive_generation") return } sendMessage(type: "transportCheckAck", data: [ @@ -142,16 +122,13 @@ extension WebSocketServer { "token": token, "ts": Int(Date().timeIntervalSince1970 * 1000) ]) - print("[transport_sync] phase=check_ack source=mac generation=\(generation)") } func sendTransportNominate(path: String, generation: Int64, reason: String) { guard isTransportGenerationActive(generation) else { - print("[transport_sync] phase=nominate_drop generation=\(generation) reason=inactive_generation") return } if path == "lan" && !isTransportGenerationValidated(generation) { - print("[transport_sync] phase=nominate_drop generation=\(generation) reason=not_validated") return } sendMessage(type: "transportNominate", data: [ @@ -161,7 +138,6 @@ extension WebSocketServer { "ts": Int(Date().timeIntervalSince1970 * 1000), "reason": reason ]) - print("[transport_sync] phase=nominate source=mac generation=\(generation) path=\(path) reason=\(reason)") } func sendQuickShareTrigger() { @@ -171,7 +147,6 @@ extension WebSocketServer { func sendRefreshAdbPortsRequest() { guard hasActiveLocalSession() else { - print("[adb] Skipping refreshAdbPorts: no active local session") return } sendMessage(type: "refreshAdbPorts", data: [:]) diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift index e52a75ce..05d0f314 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift @@ -58,7 +58,6 @@ extension WebSocketServer { // If relay is currently active, avoid hard restart: stale local sessions // can happen during transport switch (LAN <-> relay). if AirBridgeClient.shared.connectionState == .relayActive { - print("[websocket] Session \(sessionId) is stale while relay is active. Cleaning up stale local session only.") self.lock.lock() self.activeSessions.removeAll(where: { ObjectIdentifier($0) == sessionId }) self.lastActivity.removeValue(forKey: sessionId) diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index 40460409..098ebf7c 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -155,14 +155,13 @@ class WebSocketServer: ObservableObject { servers.removeAll() } - func requestRestart(reason: String, delay: TimeInterval = 0.35, port: UInt16? = nil) { + func requestRestart(reason _reason: String, delay: TimeInterval = 0.35, port: UInt16? = nil) { lock.lock() let restartPort = port ?? localPort ?? Defaults.serverPort lock.unlock() let workItem = DispatchWorkItem { [weak self] in guard let self = self else { return } - print("[websocket] Restart requested: \(reason)") self.stop() self.start(port: restartPort) } @@ -202,7 +201,6 @@ class WebSocketServer: ObservableObject { let work = DispatchWorkItem { [weak self] in guard let self else { return } if self.hasActiveLocalSession() { - print("[transport_sync] direction=mac_local lan_state_debounce_skip=true reason=\(reason)") return } self.publishLanTransportStateNow(isActive: false, reason: "\(reason)_debounced") @@ -222,7 +220,7 @@ class WebSocketServer: ObservableObject { publishLanTransportStateNow(isActive: true, reason: reason) } - private func publishLanTransportStateNow(isActive: Bool, reason: String) { + private func publishLanTransportStateNow(isActive: Bool, reason _reason: String) { lock.lock() let previous = lastPublishedLanState if previous == isActive { @@ -232,7 +230,6 @@ class WebSocketServer: ObservableObject { lastPublishedLanState = isActive lock.unlock() - print("[transport_sync] direction=mac_local lan_state_old=\(previous.map(String.init) ?? "nil") lan_state_new=\(isActive) reason=\(reason)") DispatchQueue.main.async { self.lanSessionEvents.send(isActive) AppState.shared.updatePeerTransportHint(isActive ? "wifi" : "relay") @@ -248,14 +245,13 @@ class WebSocketServer: ObservableObject { return value } - internal func beginTransportRound(_ generation: Int64, reason: String) { + internal func beginTransportRound(_ generation: Int64, reason _reason: String) { guard generation > 0 else { return } lock.lock() activeTransportGeneration = generation activeTransportGenerationStartedAt = Date() validatedTransportGeneration = 0 lock.unlock() - print("[transport_sync] phase=round_begin generation=\(generation) reason=\(reason)") } internal func isTransportGenerationActive(_ generation: Int64) -> Bool { @@ -292,16 +288,14 @@ class WebSocketServer: ObservableObject { beginTransportRound(generation, reason: "incoming_rollover:\(reason)") return true } - print("[transport_sync] phase=drop_stale_generation incoming=\(generation) active=\(current) reason=\(reason)") return false } - internal func markTransportGenerationValidated(_ generation: Int64, reason: String) { + internal func markTransportGenerationValidated(_ generation: Int64, reason _reason: String) { guard isTransportGenerationActive(generation) else { return } lock.lock() validatedTransportGeneration = generation lock.unlock() - print("[transport_sync] phase=round_validated generation=\(generation) reason=\(reason)") } internal func isTransportGenerationValidated(_ generation: Int64) -> Bool { @@ -425,16 +419,14 @@ class WebSocketServer: ObservableObject { // Fallback: If decryption fails, check if it's valid plaintext JSON. // This handles cases where keys are out of sync or the client sends plaintext via the secure relay tunnel. if text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") { - print("[transport] RX via RELAY: Decryption failed, attempting plaintext fallback.") decryptedText = text } else { - print("[transport] RX via RELAY dropped: decrypt failed or empty payload (len=\(text.count))") + print("[transport] RX via RELAY dropped: decrypt failed or empty payload") return } } } else { // In normal operation this should not happen; relay payloads are expected encrypted. - print("[transport] RX via RELAY: no symmetric key on Mac, attempting plaintext parse") decryptedText = text } @@ -534,7 +526,6 @@ class WebSocketServer: ObservableObject { } session = nil sessionCount = activeSessions.count - print("[transport] Primary LAN session stale during relay RX; switched to relay-only routing") } } lock.unlock() @@ -549,11 +540,9 @@ class WebSocketServer: ObservableObject { } if let session = session { - print("[transport] RX via RELAY routed to primary LAN session type=\(message.type.rawValue)") handleMessage(message, session: session) } else { // No local session — dispatch directly to AppState for non-session-critical messages - print("[transport] RX via RELAY handled in relay-only mode type=\(message.type.rawValue)") handleRelayedMessageWithoutSession(message) } } @@ -626,8 +615,6 @@ class WebSocketServer: ObservableObject { return } - print("[transport_sync] direction=mac->android type=macWake ips=\(ipList) port=\(port) adapter=\(adapter ?? "auto")") - if let key = symmetricKey, let encrypted = encryptMessage(jsonString, using: key) { AirBridgeClient.shared.sendText(encrypted) } else { diff --git a/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift b/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift index 8924f925..261a8b5f 100644 --- a/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift +++ b/airsync-mac/Screens/Settings/AirBridgeSettingsView.swift @@ -20,7 +20,7 @@ struct AirBridgeSettingsView: View { VStack(spacing: 12) { // Toggle HStack { - Label("Enable AirBridge", systemImage: "antenna.radiowaves.left.and.right.circle.fill") + Label("Enable AirBridge (Beta)", systemImage: "antenna.radiowaves.left.and.right.circle.fill") Spacer() Toggle("", isOn: $appState.airBridgeEnabled) .toggleStyle(.switch)